@camstack/addon-post-analysis 1.2.73 → 1.2.74

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-DDv-TKUR.js");
5
+ const require_dist = require("../dist-Cy8lyywE.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");
@@ -1580,6 +1580,11 @@ var NC_ALARM_DECLARATION = {
1580
1580
  };
1581
1581
  //#endregion
1582
1582
  //#region src/notification-center/artifact-index.ts
1583
+ /**
1584
+ * @durable class=ledger owner=notification-center
1585
+ * write="one row per RETAINED artefact, written once at publish; expiresAt patched never"
1586
+ * retention="pruned by NcArtifactStore's shelf bounds (14 d / 2000 rows / 2 GiB) — the index row falls with its bytes, on the producer node only (see prune(null) gate)"
1587
+ */
1583
1588
  var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1584
1589
  var NcArtifactKindSchema = require_dist._enum([
1585
1590
  "timelapse",
@@ -3232,8 +3237,8 @@ function defaultPrompt(input) {
3232
3237
  }
3233
3238
  /** Longest-edge downscale via sharp — the production `downscale` dep. */
3234
3239
  async function downscaleJpeg(bytes, maxPx) {
3235
- const { default: sharp$12 } = await import("sharp");
3236
- const out = await sharp$12(Buffer.from(bytes)).resize({
3240
+ const { default: sharp$13 } = await import("sharp");
3241
+ const out = await sharp$13(Buffer.from(bytes)).resize({
3237
3242
  width: maxPx,
3238
3243
  height: maxPx,
3239
3244
  fit: "inside",
@@ -5014,13 +5019,27 @@ function attachmentKindPreference(policy, ownerKind) {
5014
5019
  * scene is the honest fallback (same picture, no annotation) — never a subject
5015
5020
  * crop, which shows something else entirely.
5016
5021
  *
5022
+ * **`boxed` used to decide nothing.** Its first rung was `fullFrameBoxed`, a
5023
+ * kind RETIRED for new tracks on 2026-07-21 (`event-media-dispatcher.ts`), and
5024
+ * the rest of the ladder was the clean `keyFrame` family — so an authorable
5025
+ * option degraded, silently and always, to exactly what `full` returns. A knob
5026
+ * that decides nothing is worse than no knob: the operator set it and believed
5027
+ * they had asked for something.
5028
+ *
5029
+ * The annotated frames were there the whole time under different names.
5030
+ * `firstFrame` / `snapshot` / `lastFrame` are written by `boxedTimelineFrame`
5031
+ * — the track's OWN box burned onto the native full frame — so `boxed` now asks
5032
+ * for them, newest-moment-first-per-family, before degrading to the clean
5033
+ * scene. No new capture path, no new kind, and the option answers its own
5034
+ * question again.
5035
+ *
5017
5036
  * The strictness is about FAMILY, not about which moment of the track the
5018
- * frame is from: every rung of the `full`/track ladder is a clean, unannotated
5019
- * whole scene from this camera. `lastFrame` is `firstFrame`'s twin — the media
5020
- * schema defines them together, "full-frame snapshots captured at a track's
5021
- * start and end" — and `snapshot` is the same picture again. A live audit found
5022
- * a track holding ONLY a `lastFrame` ship text-only: the honest answer to the
5023
- * rule's question was stored and the ladder stopped one rung short of it.
5037
+ * frame is from. On the `full`/track ladder, `keyFrame`/`keyFrameSmall` are the
5038
+ * clean whole scene; `firstFrame`/`lastFrame`/`snapshot` are the same whole
5039
+ * scene WITH the track's own box on it, which is why they sit below the clean
5040
+ * pair and above nothing. (They are on this ladder because a live audit found a
5041
+ * track holding ONLY a `lastFrame` ship text-only: an annotated whole scene is
5042
+ * a far better answer to "what is going on" than no picture at all.)
5024
5043
  */
5025
5044
  function framePreference(frame, ownerKind) {
5026
5045
  if (frame === "cropped") return ownerKind === "track" ? [
@@ -5034,6 +5053,9 @@ function framePreference(frame, ownerKind) {
5034
5053
  ];
5035
5054
  if (frame === "boxed") return [
5036
5055
  "fullFrameBoxed",
5056
+ "snapshot",
5057
+ "lastFrame",
5058
+ "firstFrame",
5037
5059
  "keyFrame",
5038
5060
  "keyFrameSmall",
5039
5061
  "fullFrame"
@@ -5129,6 +5151,26 @@ var en_default = {
5129
5151
  "other": "plates"
5130
5152
  },
5131
5153
  "class.other": "{{name}}",
5154
+ "group.others.person": {
5155
+ "one": "1 other person",
5156
+ "other": "{{count}} other people"
5157
+ },
5158
+ "group.others.vehicle": {
5159
+ "one": "1 other vehicle",
5160
+ "other": "{{count}} other vehicles"
5161
+ },
5162
+ "group.others.animal": {
5163
+ "one": "1 other animal",
5164
+ "other": "{{count}} other animals"
5165
+ },
5166
+ "group.others.package": {
5167
+ "one": "1 other package",
5168
+ "other": "{{count}} other packages"
5169
+ },
5170
+ "group.others.other": {
5171
+ "one": "1 other {{classNoun}}",
5172
+ "other": "{{count}} other {{classNoun}}"
5173
+ },
5132
5174
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
5133
5175
  "occupancy.op.occupied": "occupied",
5134
5176
  "occupancy.op.free": "free",
@@ -5152,6 +5194,13 @@ var en_default = {
5152
5194
  "system.node-offline.title": "Node offline",
5153
5195
  "system.node-offline.body.agent": "Agent {{nodeId}} is offline",
5154
5196
  "system.node-offline.body.worker": "Worker {{nodeId}} is offline",
5197
+ "system.node-inference-unavailable.title": "Node has no usable inference device",
5198
+ "system.node-inference-unavailable.body": "Node {{nodeId}} can no longer run inference — every enabled device is unavailable ({{devices}}). It will not receive cameras until one returns.",
5199
+ "system.detection-blind.title": "Camera detecting nothing",
5200
+ "system.detection-blind.body": {
5201
+ "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
5202
+ "other": "Camera {{deviceName}} has been analysed for {{minutes}} minutes with no detection at all"
5203
+ },
5155
5204
  "system.addon-update-available.title": "Addon update available",
5156
5205
  "system.addon-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
5157
5206
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
@@ -5296,6 +5345,26 @@ var it_default = {
5296
5345
  "other": "targhe"
5297
5346
  },
5298
5347
  "class.other": "{{name}}",
5348
+ "group.others.person": {
5349
+ "one": "1 altra persona",
5350
+ "other": "{{count}} altre persone"
5351
+ },
5352
+ "group.others.vehicle": {
5353
+ "one": "1 altro veicolo",
5354
+ "other": "{{count}} altri veicoli"
5355
+ },
5356
+ "group.others.animal": {
5357
+ "one": "1 altro animale",
5358
+ "other": "{{count}} altri animali"
5359
+ },
5360
+ "group.others.package": {
5361
+ "one": "1 altro pacco",
5362
+ "other": "{{count}} altri pacchi"
5363
+ },
5364
+ "group.others.other": {
5365
+ "one": "1 altro {{classNoun}}",
5366
+ "other": "{{count}} altri {{classNoun}}"
5367
+ },
5299
5368
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
5300
5369
  "occupancy.op.occupied": "occupata",
5301
5370
  "occupancy.op.free": "libera",
@@ -5319,6 +5388,13 @@ var it_default = {
5319
5388
  "system.node-offline.title": "Nodo offline",
5320
5389
  "system.node-offline.body.agent": "L'agent {{nodeId}} è offline",
5321
5390
  "system.node-offline.body.worker": "Il worker {{nodeId}} è offline",
5391
+ "system.node-inference-unavailable.title": "Nodo senza dispositivi di inferenza utilizzabili",
5392
+ "system.node-inference-unavailable.body": "Il nodo {{nodeId}} non può più eseguire inferenza — tutti i dispositivi abilitati non sono disponibili ({{devices}}). Non riceverà camere finché uno non torna disponibile.",
5393
+ "system.detection-blind.title": "Camera che non rileva nulla",
5394
+ "system.detection-blind.body": {
5395
+ "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
5396
+ "other": "La camera {{deviceName}} è analizzata da {{minutes}} minuti senza alcun rilevamento"
5397
+ },
5322
5398
  "system.addon-update-available.title": "Aggiornamento addon disponibile",
5323
5399
  "system.addon-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
5324
5400
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
@@ -5573,6 +5649,149 @@ var NcTextCatalog = class {
5573
5649
  }
5574
5650
  };
5575
5651
  //#endregion
5652
+ //#region src/shared/frame/box-drawer.ts
5653
+ var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
5654
+ var DEFAULT_QUALITY = 80;
5655
+ var STROKE_WIDTH = 3;
5656
+ function escapeXml$2(s) {
5657
+ return s.replace(/[<>&'"]/g, (ch) => {
5658
+ switch (ch) {
5659
+ case "<": return "&lt;";
5660
+ case ">": return "&gt;";
5661
+ case "&": return "&amp;";
5662
+ case "'": return "&apos;";
5663
+ default: return "&quot;";
5664
+ }
5665
+ });
5666
+ }
5667
+ /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
5668
+ function clampBox(b, frameWidth, frameHeight) {
5669
+ const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
5670
+ const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
5671
+ return {
5672
+ x,
5673
+ y,
5674
+ w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
5675
+ h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
5676
+ };
5677
+ }
5678
+ /**
5679
+ * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
5680
+ *
5681
+ * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
5682
+ * optional caption) at full resolution, then optionally downscaled — so the
5683
+ * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
5684
+ * avoid sharp `extract`/region errors on slightly-out-of-range detections.
5685
+ */
5686
+ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
5687
+ const quality = opts.quality ?? DEFAULT_QUALITY;
5688
+ let base = frameData;
5689
+ let baseIsRaw = true;
5690
+ if (boxes.length > 0) {
5691
+ const fontSize = Math.max(12, Math.round(frameHeight / 30));
5692
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
5693
+ const c = clampBox(b, frameWidth, frameHeight);
5694
+ const color = b.color ?? DEFAULT_COLOR;
5695
+ const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
5696
+ if (!b.label) return rect;
5697
+ const ty = Math.max(fontSize, c.y - 4);
5698
+ return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
5699
+ }).join("")}</svg>`;
5700
+ base = await (0, sharp.default)(frameData, { raw: {
5701
+ width: frameWidth,
5702
+ height: frameHeight,
5703
+ channels: 3
5704
+ } }).composite([{
5705
+ input: Buffer.from(svg),
5706
+ top: 0,
5707
+ left: 0
5708
+ }]).png().toBuffer();
5709
+ baseIsRaw = false;
5710
+ }
5711
+ let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
5712
+ width: frameWidth,
5713
+ height: frameHeight,
5714
+ channels: 3
5715
+ } }) : (0, sharp.default)(base);
5716
+ if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
5717
+ width: opts.maxWidth,
5718
+ withoutEnlargement: true
5719
+ });
5720
+ return pipeline.jpeg({ quality }).toBuffer();
5721
+ }
5722
+ /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
5723
+ var GROUP_FRAME_QUALITY = 82;
5724
+ /**
5725
+ * Draw the members onto the frame. Returns `null` when there is nothing to
5726
+ * draw, or when anything at all goes wrong — the caller then ships the
5727
+ * unannotated frame unchanged.
5728
+ */
5729
+ async function composeGroupFrame(input) {
5730
+ const freshnessMs = input.freshnessMs ?? 3e3;
5731
+ const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
5732
+ const withheld = input.boxes.length - fresh.length;
5733
+ if (fresh.length < 2) {
5734
+ if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
5735
+ tags: { deviceId: input.deviceId },
5736
+ meta: {
5737
+ groupKey: input.groupKey,
5738
+ members: input.boxes.length,
5739
+ fresh: fresh.length,
5740
+ withheld
5741
+ }
5742
+ });
5743
+ return null;
5744
+ }
5745
+ try {
5746
+ const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
5747
+ const { width, height, channels } = decoded.info;
5748
+ if (!(width > 0 && height > 0) || channels !== 3) {
5749
+ input.logger.warn("group frame not drawn — unexpected raster shape", {
5750
+ tags: { deviceId: input.deviceId },
5751
+ meta: {
5752
+ groupKey: input.groupKey,
5753
+ width,
5754
+ height,
5755
+ channels
5756
+ }
5757
+ });
5758
+ return null;
5759
+ }
5760
+ const boxes = fresh.map((b) => ({
5761
+ x: b.bbox.x * width,
5762
+ y: b.bbox.y * height,
5763
+ w: b.bbox.w * width,
5764
+ h: b.bbox.h * height,
5765
+ ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
5766
+ }));
5767
+ const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
5768
+ input.logger.debug("group frame composed", {
5769
+ tags: { deviceId: input.deviceId },
5770
+ meta: {
5771
+ groupKey: input.groupKey,
5772
+ drawn: boxes.length,
5773
+ withheld,
5774
+ width,
5775
+ height
5776
+ }
5777
+ });
5778
+ return {
5779
+ bytes,
5780
+ drawn: boxes.length,
5781
+ withheld
5782
+ };
5783
+ } catch (err) {
5784
+ input.logger.warn("group frame compose failed — shipping the unannotated frame", {
5785
+ tags: { deviceId: input.deviceId },
5786
+ meta: {
5787
+ groupKey: input.groupKey,
5788
+ error: err instanceof Error ? err.message : String(err)
5789
+ }
5790
+ });
5791
+ return null;
5792
+ }
5793
+ }
5794
+ //#endregion
5576
5795
  //#region src/notification-center/text-compose.ts
5577
5796
  /** One normalisation, used on both sides of every class lookup — a pipeline
5578
5797
  * that emitted `Person` must find the key written `class.person`. */
@@ -5658,6 +5877,70 @@ function ncDetectionSummary(texts, counts) {
5658
5877
  return texts.join(parts);
5659
5878
  }
5660
5879
  //#endregion
5880
+ //#region src/notification-center/group/nc-group-text.ts
5881
+ var DEFAULT_MAX_NAMES = 2;
5882
+ /**
5883
+ * `N altre persone` — the remainder phrase for one class, in its own gender.
5884
+ *
5885
+ * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
5886
+ * locale names falls through to `group.others.other`, which prints the raw class
5887
+ * name rather than vanishing. A custom model's class must appear in the sentence.
5888
+ */
5889
+ function ncGroupOthers(texts, className, count) {
5890
+ const classNoun = ncClassNoun(texts, className, count);
5891
+ const key = `group.others.${className.trim().toLowerCase()}`;
5892
+ if (texts.has(key)) return texts.text({
5893
+ key,
5894
+ count,
5895
+ vars: {
5896
+ count: `${count}`,
5897
+ classNoun
5898
+ }
5899
+ });
5900
+ return texts.text({
5901
+ key: "group.others.other",
5902
+ count,
5903
+ vars: {
5904
+ count: `${count}`,
5905
+ classNoun
5906
+ }
5907
+ });
5908
+ }
5909
+ /** Counts per class, in first-seen order so the same burst always reads the same. */
5910
+ function countByClass(members) {
5911
+ const counts = /* @__PURE__ */ new Map();
5912
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
5913
+ return [...counts].map(([className, count]) => ({
5914
+ className,
5915
+ count
5916
+ }));
5917
+ }
5918
+ /**
5919
+ * The finished `{{subject}}` phrase for a group.
5920
+ *
5921
+ * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
5922
+ * other composer in `text-compose.ts`, and for the same reason.
5923
+ */
5924
+ function ncGroupSubjectText(texts, input) {
5925
+ const members = input.members;
5926
+ const only = members.length === 1 ? members[0] : void 0;
5927
+ if (only !== void 0) return ncSubjectText(texts, {
5928
+ className: only.className,
5929
+ ...only.label !== void 0 ? { label: only.label } : {},
5930
+ ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
5931
+ });
5932
+ if (members.length === 0) return "";
5933
+ const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
5934
+ const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
5935
+ const names = named.slice(0, maxNames).map((m) => m.label ?? "");
5936
+ if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
5937
+ const spoken = new Set(named.slice(0, maxNames));
5938
+ const remainder = members.filter((m) => !spoken.has(m));
5939
+ if (remainder.length === 0) return texts.join(names);
5940
+ const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
5941
+ return texts.join([...names, ...others]);
5942
+ }
5943
+ //#endregion
5661
5944
  //#region src/notification-center/dispatcher.ts
5662
5945
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
5663
5946
  /**
@@ -6144,7 +6427,7 @@ var NcDispatcher = class {
6144
6427
  title,
6145
6428
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
6146
6429
  ...params,
6147
- tag: entry.ruleId,
6430
+ tag: entry.payload.group?.key ?? entry.ruleId,
6148
6431
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
6149
6432
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
6150
6433
  ...systemEvent !== void 0 ? { metadata: {
@@ -6230,7 +6513,14 @@ var NcDispatcher = class {
6230
6513
  bytes: cropped,
6231
6514
  name: "zone.jpg"
6232
6515
  } : still);
6233
- } else out.push(still);
6516
+ } else {
6517
+ const grouped = await this.groupFrame(entry, still.bytes);
6518
+ out.push(grouped !== null ? {
6519
+ ...still,
6520
+ bytes: grouped,
6521
+ name: "group.jpg"
6522
+ } : still);
6523
+ }
6234
6524
  } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
6235
6525
  tags: { deviceId: entry.payload.subject.deviceId },
6236
6526
  meta: {
@@ -6355,8 +6645,8 @@ var NcDispatcher = class {
6355
6645
  try {
6356
6646
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
6357
6647
  if (points.length === 0) return null;
6358
- const { default: sharp$11 } = await import("sharp");
6359
- const img = sharp$11(Buffer.from(jpeg));
6648
+ const { default: sharp$12 } = await import("sharp");
6649
+ const img = sharp$12(Buffer.from(jpeg));
6360
6650
  const meta = await img.metadata();
6361
6651
  const W = meta.width ?? 0;
6362
6652
  const H = meta.height ?? 0;
@@ -6396,6 +6686,47 @@ var NcDispatcher = class {
6396
6686
  return null;
6397
6687
  }
6398
6688
  }
6689
+ /**
6690
+ * Draw the group's members onto the still this row already resolved.
6691
+ *
6692
+ * Produced at SEND, like every other media decision here, and returned as
6693
+ * BYTES: an annotated frame is a delivery artefact and must never enter the
6694
+ * media store as a track-owned kind (`retrain-frame-selection.ts` calls a
6695
+ * boxed frame "worse than useless" as model input, and the mosaic renderer
6696
+ * refuses boxes because a burnt-in box is permanent).
6697
+ *
6698
+ * Null on anything less than two drawable members, or on any failure at all —
6699
+ * the caller then ships the frame it already had. A group notification with a
6700
+ * plain picture is a small loss; one that does not arrive is the feature.
6701
+ */
6702
+ async groupFrame(entry, jpeg) {
6703
+ const group = entry.payload.group;
6704
+ if (group === void 0 || group.members.length < 2) return null;
6705
+ const boxes = group.members.filter((m) => m.bbox !== void 0).map((m) => ({
6706
+ bbox: m.bbox ?? {
6707
+ x: 0,
6708
+ y: 0,
6709
+ w: 0,
6710
+ h: 0
6711
+ },
6712
+ label: m.label ?? ncClassNoun(this.texts, m.className, 1),
6713
+ observedAt: m.at
6714
+ }));
6715
+ if (boxes.length < 2) return null;
6716
+ const anchor = group.members.find((m) => m.trackId === group.anchorTrackId);
6717
+ const composed = await composeGroupFrame({
6718
+ jpeg: Buffer.from(jpeg),
6719
+ boxes,
6720
+ at: anchor?.at ?? Math.max(...group.members.map((m) => m.at)),
6721
+ logger: this.deps.logger,
6722
+ deviceId: entry.payload.subject.deviceId,
6723
+ groupKey: group.key
6724
+ });
6725
+ if (composed === null) return null;
6726
+ const bytes = new Uint8Array(composed.bytes.byteLength);
6727
+ bytes.set(composed.bytes);
6728
+ return bytes;
6729
+ }
6399
6730
  async resolveAttachment(entry, policyOverride) {
6400
6731
  const policy = policyOverride ?? entry.payload.media;
6401
6732
  if (policy === "none") return null;
@@ -6477,11 +6808,21 @@ function percentOf(confidence) {
6477
6808
  * params go last because they are the row's own truth about a system event and
6478
6809
  * outrank the live vocabulary for exactly those names.
6479
6810
  */
6811
+ /** Class composition of a group, busiest classes in first-seen order. */
6812
+ function classCounts(members) {
6813
+ const counts = /* @__PURE__ */ new Map();
6814
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
6815
+ return [...counts].map(([className, count]) => ({
6816
+ className,
6817
+ count
6818
+ }));
6819
+ }
6480
6820
  function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
6481
6821
  const subject = entry.payload.subject;
6482
6822
  const occupancy = subject.occupancy;
6483
6823
  const systemEvent = subject.systemEvent;
6484
6824
  const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
6825
+ const group = entry.payload.group;
6485
6826
  return {
6486
6827
  camera: deviceName,
6487
6828
  class: detection?.className ?? subject.className,
@@ -6490,18 +6831,19 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
6490
6831
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
6491
6832
  className: detection.className,
6492
6833
  ...detection.label !== void 0 ? { label: detection.label } : {}
6493
- }) : systemEvent?.subject ?? ncSubjectText(texts, {
6834
+ }) : (group !== void 0 && group.members.length > 1 ? ncGroupSubjectText(texts, { members: group.members }) : void 0) ?? systemEvent?.subject ?? ncSubjectText(texts, {
6494
6835
  className: subject.className,
6495
6836
  ...subject.label !== void 0 ? { label: subject.label } : {},
6496
6837
  ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
6497
6838
  }),
6839
+ detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
6498
6840
  inZones: ncInZonesText(texts, zoneLabels),
6499
6841
  scope: occupancy?.zone ?? deviceName,
6500
6842
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
6501
6843
  confidence: percentOf(detection?.confidence ?? subject.confidence),
6502
6844
  time: new Date(subject.timestamp).toLocaleTimeString(),
6503
6845
  rule: entry.payload.ruleName,
6504
- count: occupancy !== void 0 ? `${occupancy.count}` : "",
6846
+ count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
6505
6847
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
6506
6848
  op: occupancy !== void 0 ? ncOccupancyOp(texts, occupancy.occupied) : "",
6507
6849
  eventKind: systemEvent?.kind ?? "",
@@ -7009,6 +7351,45 @@ function incomingFromSystemEvent(event, lookup) {
7009
7351
  nodeId
7010
7352
  });
7011
7353
  }
7354
+ case "pipeline.node-inference-unavailable": {
7355
+ const nodeId = stringField(data, "nodeId");
7356
+ if (nodeId === void 0) return null;
7357
+ const keys = data["unavailableDeviceKeys"];
7358
+ const devices = Array.isArray(keys) ? keys.filter((k) => typeof k === "string") : [];
7359
+ return systemSubject({
7360
+ facts,
7361
+ kind: "node-inference-unavailable",
7362
+ subject: `node-inference:${nodeId}`,
7363
+ titleKey: "system.node-inference-unavailable.title",
7364
+ bodyKey: "system.node-inference-unavailable.body",
7365
+ textParams: {
7366
+ nodeId,
7367
+ devices: devices.join(", ")
7368
+ },
7369
+ nodeId
7370
+ });
7371
+ }
7372
+ case "pipeline.detection-blind": {
7373
+ const deviceId = numberField(data, "deviceId");
7374
+ const blindForMs = numberField(data, "blindForMs");
7375
+ if (deviceId === void 0 || blindForMs === void 0) return null;
7376
+ const identity = lookup(deviceId);
7377
+ const minutes = Math.max(1, Math.round(blindForMs / 6e4));
7378
+ return systemSubject({
7379
+ facts,
7380
+ kind: "detection-blind",
7381
+ subject: `detection-blind:${deviceId}`,
7382
+ titleKey: "system.detection-blind.title",
7383
+ bodyKey: "system.detection-blind.body",
7384
+ textParams: {
7385
+ ...deviceTextParams(deviceId, identity),
7386
+ minutes: String(minutes)
7387
+ },
7388
+ textCount: minutes,
7389
+ deviceId,
7390
+ ...identity !== void 0 ? { deviceType: identity.type } : {}
7391
+ });
7392
+ }
7012
7393
  case "update.available": {
7013
7394
  const target = stringField(data, "target");
7014
7395
  const packageName = stringField(data, "packageName");
@@ -8793,6 +9174,353 @@ function rowToEntry$1(id, data) {
8793
9174
  };
8794
9175
  }
8795
9176
  //#endregion
9177
+ //#region src/notification-center/group/nc-group-buffer.ts
9178
+ var DEFAULT_MAX_MEMBERS = 12;
9179
+ var NcGroupBuffer = class NcGroupBuffer {
9180
+ /** One OPEN group per `${ruleId}${deviceId}`. Sealed groups leave. */
9181
+ open = /* @__PURE__ */ new Map();
9182
+ maxMembers;
9183
+ constructor(options = {}) {
9184
+ this.maxMembers = options.maxMembers !== void 0 && options.maxMembers > 0 ? options.maxMembers : DEFAULT_MAX_MEMBERS;
9185
+ }
9186
+ static slot(ruleId, deviceId) {
9187
+ return `${ruleId}${deviceId}`;
9188
+ }
9189
+ /** Currently open (unsealed) groups — for the store mirror and for tests. */
9190
+ openGroups() {
9191
+ return [...this.open.values()];
9192
+ }
9193
+ /** The open group for this rule + camera, if any. */
9194
+ peek(ruleId, deviceId) {
9195
+ return this.open.get(NcGroupBuffer.slot(ruleId, deviceId));
9196
+ }
9197
+ /**
9198
+ * Offer a matching subject to its group.
9199
+ *
9200
+ * Never throws and never blocks: it is called from inside the serialized
9201
+ * evaluation chain, where a failure would cost the notification itself.
9202
+ */
9203
+ admit(input, now) {
9204
+ if (!(input.idleSec > 0)) return { outcome: "disabled" };
9205
+ const slot = NcGroupBuffer.slot(input.ruleId, input.deviceId);
9206
+ const existing = this.open.get(slot);
9207
+ const idleMs = input.idleSec * 1e3;
9208
+ if (existing !== void 0 && now - existing.lastActivityAt >= idleMs) {
9209
+ this.open.delete(slot);
9210
+ return this.openGroup(slot, input, now);
9211
+ }
9212
+ if (existing === void 0) return this.openGroup(slot, input, now);
9213
+ const at = existing.members.findIndex((m) => m.trackId === input.member.trackId);
9214
+ if (at === -1) {
9215
+ if (existing.members.length >= this.maxMembers) {
9216
+ this.open.set(slot, {
9217
+ ...existing,
9218
+ lastActivityAt: now
9219
+ });
9220
+ return {
9221
+ outcome: "refused",
9222
+ groupKey: existing.key,
9223
+ memberCount: existing.members.length
9224
+ };
9225
+ }
9226
+ const grown = {
9227
+ ...existing,
9228
+ members: [...existing.members, input.member],
9229
+ revision: existing.revision + 1,
9230
+ lastGrowthAt: now,
9231
+ lastActivityAt: now,
9232
+ anchorTrackId: input.member.trackId
9233
+ };
9234
+ this.open.set(slot, grown);
9235
+ return {
9236
+ outcome: "grown",
9237
+ group: {
9238
+ ...grown,
9239
+ reason: "member"
9240
+ }
9241
+ };
9242
+ }
9243
+ const previous = existing.members[at];
9244
+ const named = input.member.label !== void 0 && input.member.label !== previous.label;
9245
+ const refreshed = {
9246
+ ...previous,
9247
+ ...input.member.label !== void 0 ? {
9248
+ label: input.member.label,
9249
+ ...input.member.labelKind !== void 0 ? { labelKind: input.member.labelKind } : {}
9250
+ } : {},
9251
+ ...input.member.bbox !== void 0 ? { bbox: input.member.bbox } : {},
9252
+ eventId: input.member.eventId,
9253
+ at: now
9254
+ };
9255
+ const members = existing.members.map((m, i) => i === at ? refreshed : m);
9256
+ const updated = {
9257
+ ...existing,
9258
+ members,
9259
+ lastActivityAt: now,
9260
+ ...named ? {
9261
+ revision: existing.revision + 1,
9262
+ lastGrowthAt: now,
9263
+ anchorTrackId: refreshed.trackId
9264
+ } : {}
9265
+ };
9266
+ this.open.set(slot, updated);
9267
+ return named ? {
9268
+ outcome: "grown",
9269
+ group: {
9270
+ ...updated,
9271
+ reason: "name"
9272
+ }
9273
+ } : {
9274
+ outcome: "unchanged",
9275
+ group: updated
9276
+ };
9277
+ }
9278
+ /**
9279
+ * Close every group whose idle cutoff has elapsed, and report them ONCE.
9280
+ *
9281
+ * A close is a transition, not a state: re-reporting it every tick would turn
9282
+ * one arrival into an unbounded stream of "group closed" lines. `idleSec` is
9283
+ * carried on the group so a rule edited mid-burst does not change the cutoff
9284
+ * of a burst already open.
9285
+ */
9286
+ sweep(now) {
9287
+ const closed = [];
9288
+ for (const [slot, group] of this.open) {
9289
+ if (now - group.lastActivityAt < group.idleMs) continue;
9290
+ this.open.delete(slot);
9291
+ closed.push({
9292
+ ...group,
9293
+ sealed: true
9294
+ });
9295
+ }
9296
+ return closed;
9297
+ }
9298
+ openGroup(slot, input, now) {
9299
+ const group = {
9300
+ key: `g:${input.ruleId}:d:${input.deviceId}:${now}`,
9301
+ ruleId: input.ruleId,
9302
+ deviceId: input.deviceId,
9303
+ openedAt: now,
9304
+ lastGrowthAt: now,
9305
+ lastActivityAt: now,
9306
+ revision: 1,
9307
+ members: [input.member],
9308
+ anchorTrackId: input.member.trackId,
9309
+ sealed: false,
9310
+ idleMs: input.idleSec * 1e3
9311
+ };
9312
+ this.open.set(slot, group);
9313
+ return {
9314
+ outcome: "opened",
9315
+ group
9316
+ };
9317
+ }
9318
+ };
9319
+ //#endregion
9320
+ //#region src/notification-center/group/nc-group-store.ts
9321
+ /**
9322
+ * @durable class=audit owner=notification-center
9323
+ * write="one row per burst at OPEN, patched at each growth and at close, best-effort"
9324
+ * retention="ring — the newest NC_GROUP_MAX_ROWS_PER_DEVICE rows per device, pruned on write; never age-keyed"
9325
+ */
9326
+ var NC_GROUPS_COLLECTION = "notification-center:track-groups";
9327
+ var NC_GROUPS_COLUMNS = [
9328
+ {
9329
+ name: "id",
9330
+ type: "TEXT",
9331
+ primaryKey: true,
9332
+ notNull: true
9333
+ },
9334
+ {
9335
+ name: "ruleId",
9336
+ type: "TEXT",
9337
+ notNull: true
9338
+ },
9339
+ {
9340
+ name: "deviceId",
9341
+ type: "INTEGER",
9342
+ notNull: true
9343
+ },
9344
+ {
9345
+ name: "openedAt",
9346
+ type: "INTEGER",
9347
+ notNull: true
9348
+ },
9349
+ {
9350
+ name: "lastGrowthAt",
9351
+ type: "INTEGER",
9352
+ notNull: true
9353
+ },
9354
+ (
9355
+ /** JSON `string[]` — the members, in admission order. */
9356
+ {
9357
+ name: "memberTrackIds",
9358
+ type: "JSON",
9359
+ notNull: true
9360
+ }),
9361
+ {
9362
+ name: "memberCount",
9363
+ type: "INTEGER",
9364
+ notNull: true
9365
+ },
9366
+ (
9367
+ /** How many notifications this group has produced (1 at open, +1 per growth). */
9368
+ {
9369
+ name: "revision",
9370
+ type: "INTEGER",
9371
+ notNull: true
9372
+ }),
9373
+ {
9374
+ name: "sealed",
9375
+ type: "BOOLEAN",
9376
+ notNull: true
9377
+ }
9378
+ ];
9379
+ var NC_GROUPS_INDEXES = [{
9380
+ name: "idx_nc_groups_device_opened",
9381
+ columns: ["deviceId", "openedAt"]
9382
+ }, {
9383
+ name: "idx_nc_groups_rule",
9384
+ columns: ["ruleId"]
9385
+ }];
9386
+ /** Read cap, so a client cannot ask for the whole ring in one call. */
9387
+ var MAX_QUERY_LIMIT = 200;
9388
+ var NcGroupStore = class {
9389
+ store;
9390
+ logger;
9391
+ /** Writes since the last prune, per device — the ring is trimmed lazily. */
9392
+ writesSincePrune = /* @__PURE__ */ new Map();
9393
+ constructor(deps) {
9394
+ this.store = deps.store;
9395
+ this.logger = deps.logger;
9396
+ }
9397
+ static async declare(store) {
9398
+ await store.declareCollection.mutate({
9399
+ collection: NC_GROUPS_COLLECTION,
9400
+ columns: [...NC_GROUPS_COLUMNS],
9401
+ indexes: [...NC_GROUPS_INDEXES]
9402
+ });
9403
+ }
9404
+ /**
9405
+ * Record a group as it stands. Idempotent on the group key, so open, every
9406
+ * growth and the close all write the SAME row — a burst is one record, never
9407
+ * one per revision.
9408
+ *
9409
+ * Best-effort by construction: this is an audit surface, and a failed write
9410
+ * must never cost the notification it is describing. It is called from the
9411
+ * serialized evaluation chain, so it is also deliberately not awaited there.
9412
+ */
9413
+ async record(group) {
9414
+ try {
9415
+ await this.store.set.mutate({
9416
+ collection: NC_GROUPS_COLLECTION,
9417
+ key: group.key,
9418
+ value: {
9419
+ ruleId: group.ruleId,
9420
+ deviceId: group.deviceId,
9421
+ openedAt: group.openedAt,
9422
+ lastGrowthAt: group.lastGrowthAt,
9423
+ memberTrackIds: group.members.map((m) => m.trackId),
9424
+ memberCount: group.members.length,
9425
+ revision: group.revision,
9426
+ sealed: group.sealed
9427
+ }
9428
+ });
9429
+ await this.pruneIfDue(group.deviceId);
9430
+ } catch (err) {
9431
+ this.logger.warn("group record write failed", {
9432
+ tags: { deviceId: group.deviceId },
9433
+ meta: {
9434
+ groupKey: group.key,
9435
+ error: err instanceof Error ? err.message : String(err)
9436
+ }
9437
+ });
9438
+ }
9439
+ }
9440
+ /** Newest first. The timeline card's read. */
9441
+ async list(query = {}) {
9442
+ const where = {};
9443
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
9444
+ if (query.ruleId !== void 0) where["ruleId"] = query.ruleId;
9445
+ try {
9446
+ return (await this.store.query.query({
9447
+ collection: NC_GROUPS_COLLECTION,
9448
+ filter: {
9449
+ ...Object.keys(where).length > 0 ? { where } : {},
9450
+ ...query.since !== void 0 ? { whereBetween: { openedAt: [query.since, Number.MAX_SAFE_INTEGER] } } : {},
9451
+ orderBy: {
9452
+ field: "openedAt",
9453
+ direction: "desc"
9454
+ },
9455
+ limit: Math.min(query.limit ?? 50, MAX_QUERY_LIMIT)
9456
+ }
9457
+ })).map((r) => rowToRecord(r.id, r.data));
9458
+ } catch (err) {
9459
+ this.logger.warn("group record read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
9460
+ return [];
9461
+ }
9462
+ }
9463
+ /**
9464
+ * Trim the ring for one camera, every {@link PRUNE_EVERY} writes.
9465
+ *
9466
+ * Lazy rather than per-write because the delete is a query + a bulk delete and
9467
+ * a busy camera writes several times per burst; amortising it keeps the write
9468
+ * path a single `set`.
9469
+ */
9470
+ async pruneIfDue(deviceId) {
9471
+ const n = (this.writesSincePrune.get(deviceId) ?? 0) + 1;
9472
+ if (n < PRUNE_EVERY) {
9473
+ this.writesSincePrune.set(deviceId, n);
9474
+ return;
9475
+ }
9476
+ this.writesSincePrune.set(deviceId, 0);
9477
+ const keep = await this.store.query.query({
9478
+ collection: NC_GROUPS_COLLECTION,
9479
+ filter: {
9480
+ where: { deviceId },
9481
+ orderBy: {
9482
+ field: "openedAt",
9483
+ direction: "desc"
9484
+ },
9485
+ limit: 500,
9486
+ offset: 500
9487
+ }
9488
+ });
9489
+ if (keep.length === 0) return;
9490
+ const cutoff = keep[0]?.data["openedAt"];
9491
+ if (typeof cutoff !== "number") return;
9492
+ const { deleted } = await this.store.deleteWhere.mutate({
9493
+ collection: NC_GROUPS_COLLECTION,
9494
+ filter: {
9495
+ where: { deviceId },
9496
+ whereBetween: { openedAt: [0, cutoff] }
9497
+ }
9498
+ });
9499
+ if (deleted > 0) this.logger.debug("group ring trimmed", {
9500
+ tags: { deviceId },
9501
+ meta: {
9502
+ deleted,
9503
+ keptPerDevice: 500
9504
+ }
9505
+ });
9506
+ }
9507
+ };
9508
+ var PRUNE_EVERY = 50;
9509
+ function rowToRecord(id, data) {
9510
+ const members = data["memberTrackIds"];
9511
+ return {
9512
+ id,
9513
+ ruleId: typeof data["ruleId"] === "string" ? data["ruleId"] : "",
9514
+ deviceId: typeof data["deviceId"] === "number" ? data["deviceId"] : 0,
9515
+ openedAt: typeof data["openedAt"] === "number" ? data["openedAt"] : 0,
9516
+ lastGrowthAt: typeof data["lastGrowthAt"] === "number" ? data["lastGrowthAt"] : 0,
9517
+ memberTrackIds: Array.isArray(members) ? members.filter((m) => typeof m === "string") : [],
9518
+ memberCount: typeof data["memberCount"] === "number" ? data["memberCount"] : 0,
9519
+ revision: typeof data["revision"] === "number" ? data["revision"] : 1,
9520
+ sealed: data["sealed"] === true
9521
+ };
9522
+ }
9523
+ //#endregion
8796
9524
  //#region src/notification-center/rule-actions.ts
8797
9525
  var NcRuleActionRunner = class {
8798
9526
  deps;
@@ -10175,7 +10903,7 @@ var GLYPH_ADVANCE = .56;
10175
10903
  var LABEL_USABLE_FRACTION = .94;
10176
10904
  /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
10177
10905
  * turn the overlay into a document librsvg refuses. */
10178
- function escapeXml$2(s) {
10906
+ function escapeXml$1(s) {
10179
10907
  return s.replace(/[<>&'"]/g, (ch) => {
10180
10908
  switch (ch) {
10181
10909
  case "<": return "&lt;";
@@ -10263,7 +10991,7 @@ function tileLabelSvg(width, height, label) {
10263
10991
  const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
10264
10992
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
10265
10993
  <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
10266
- <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$2(fitted.text)}</text>
10994
+ <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
10267
10995
  </svg>`);
10268
10996
  }
10269
10997
  /** The mosaic's own title band. */
@@ -10272,7 +11000,7 @@ function titleSvg(width, height, caption) {
10272
11000
  const baseline = Math.round(height / 2 + fitted.fontPx * .36);
10273
11001
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
10274
11002
  <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
10275
- <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$2(fitted.text)}</text>
11003
+ <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
10276
11004
  </svg>`);
10277
11005
  }
10278
11006
  /**
@@ -12039,8 +12767,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
12039
12767
  ];
12040
12768
  if (bbox === void 0) return files;
12041
12769
  try {
12042
- const { default: sharp$10 } = await import("sharp");
12043
- const meta = await sharp$10(Buffer.from(jpeg)).metadata();
12770
+ const { default: sharp$11 } = await import("sharp");
12771
+ const meta = await sharp$11(Buffer.from(jpeg)).metadata();
12044
12772
  const W = meta.width ?? 0;
12045
12773
  const H = meta.height ?? 0;
12046
12774
  if (W === 0 || H === 0) return files;
@@ -12048,7 +12776,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
12048
12776
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
12049
12777
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
12050
12778
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
12051
- const cropBuf = await sharp$10(Buffer.from(jpeg)).extract({
12779
+ const cropBuf = await sharp$11(Buffer.from(jpeg)).extract({
12052
12780
  left,
12053
12781
  top,
12054
12782
  width,
@@ -12070,7 +12798,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
12070
12798
  });
12071
12799
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
12072
12800
  const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
12073
- const boxedBuf = await sharp$10(Buffer.from(jpeg)).composite([{
12801
+ const boxedBuf = await sharp$11(Buffer.from(jpeg)).composite([{
12074
12802
  input: overlay,
12075
12803
  top: 0,
12076
12804
  left: 0
@@ -14434,6 +15162,16 @@ var NotificationCenter = class NotificationCenter {
14434
15162
  alarmPanel = null;
14435
15163
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
14436
15164
  lastFiredAt = /* @__PURE__ */ new Map();
15165
+ /**
15166
+ * The open BURSTS, one per (rule, camera). See `group/nc-group-buffer.ts`.
15167
+ *
15168
+ * It sits on the serialized evaluation chain, so it needs no locking, and it
15169
+ * is RAM-only on purpose: losing it costs the current burst its updates and
15170
+ * degrades to exactly the behaviour that shipped before grouping existed.
15171
+ */
15172
+ groups = new NcGroupBuffer();
15173
+ /** The audit record behind a future timeline card. */
15174
+ groupStore;
14437
15175
  /** The AI confirm gate, or null when this node has no vision LLM wired. */
14438
15176
  confirmGate;
14439
15177
  /**
@@ -14752,6 +15490,10 @@ var NotificationCenter = class NotificationCenter {
14752
15490
  store: deps.store,
14753
15491
  logger: this.logger.child("occupancy")
14754
15492
  });
15493
+ this.groupStore = new NcGroupStore({
15494
+ store: deps.store,
15495
+ logger: this.logger.child("groups")
15496
+ });
14755
15497
  this.liveness = new NcLivenessLedger({
14756
15498
  store: deps.store,
14757
15499
  logger: this.logger.child("liveness")
@@ -14932,6 +15674,7 @@ var NotificationCenter = class NotificationCenter {
14932
15674
  await NcSummaryStore.declare(store);
14933
15675
  await NcTextCatalogStore.declare(store);
14934
15676
  await NcArtifactIndex.declare(store);
15677
+ await NcGroupStore.declare(store);
14935
15678
  }
14936
15679
  /**
14937
15680
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -16047,8 +16790,74 @@ var NotificationCenter = class NotificationCenter {
16047
16790
  continue;
16048
16791
  }
16049
16792
  this.runRuleActions(rule, subject, kind, controls);
16793
+ const admission = this.admitToGroup(rule, subject, now);
16794
+ if (admission.outcome === "unchanged") {
16795
+ this.logger.debug("subject already in the open group — no new notification", {
16796
+ tags: { deviceId: subject.deviceId },
16797
+ meta: {
16798
+ ruleId: rule.id,
16799
+ rule: rule.name,
16800
+ groupKey: admission.group.key,
16801
+ members: admission.group.members.length,
16802
+ eventId: subject.recordId,
16803
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16804
+ }
16805
+ });
16806
+ outcomes.push({
16807
+ ruleId: rule.id,
16808
+ ruleName: rule.name,
16809
+ matched: true,
16810
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
16811
+ cooldown: "suppressed",
16812
+ enqueued: 0,
16813
+ targetIds: []
16814
+ });
16815
+ anyMatched = true;
16816
+ continue;
16817
+ }
16818
+ if (admission.outcome === "refused") {
16819
+ this.logger.info("group is full — subject not added to the notification", {
16820
+ tags: { deviceId: subject.deviceId },
16821
+ meta: {
16822
+ ruleId: rule.id,
16823
+ rule: rule.name,
16824
+ groupKey: admission.groupKey,
16825
+ members: admission.memberCount,
16826
+ eventId: subject.recordId,
16827
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16828
+ }
16829
+ });
16830
+ outcomes.push({
16831
+ ruleId: rule.id,
16832
+ ruleName: rule.name,
16833
+ matched: true,
16834
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
16835
+ cooldown: "suppressed",
16836
+ enqueued: 0,
16837
+ targetIds: []
16838
+ });
16839
+ anyMatched = true;
16840
+ continue;
16841
+ }
16842
+ const group = admission.outcome === "disabled" ? void 0 : admission.group;
16843
+ if (group !== void 0) {
16844
+ this.logger.info(admission.outcome === "opened" ? "group opened" : "group grew — updating the notification", {
16845
+ tags: { deviceId: subject.deviceId },
16846
+ meta: {
16847
+ ruleId: rule.id,
16848
+ rule: rule.name,
16849
+ groupKey: group.key,
16850
+ revision: group.revision,
16851
+ members: group.members.length,
16852
+ ...group.reason !== void 0 ? { grewBy: group.reason } : {},
16853
+ eventId: subject.recordId,
16854
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16855
+ }
16856
+ });
16857
+ this.groupStore.record(group);
16858
+ }
16050
16859
  const key = cooldownKey(rule, subject);
16051
- const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
16860
+ const cooling = group !== void 0 && admission.outcome === "grown" ? false : isCoolingDown(rule, this.lastFiredAt.get(key), now);
16052
16861
  if (cooling && controls.bypassCooldown !== true) {
16053
16862
  this.logger.debug("rule matched but is cooling down", {
16054
16863
  tags: { deviceId: subject.deviceId },
@@ -16110,7 +16919,7 @@ var NotificationCenter = class NotificationCenter {
16110
16919
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
16111
16920
  key,
16112
16921
  at: now
16113
- });
16922
+ }, void 0, group);
16114
16923
  const inserted = await this.outbox.enqueue(entries);
16115
16924
  this.noteRecognitionFire(rule, subject, kind, entries, inserted);
16116
16925
  if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
@@ -16244,6 +17053,78 @@ var NotificationCenter = class NotificationCenter {
16244
17053
  }
16245
17054
  }
16246
17055
  /**
17056
+ * Offer a matching subject to its rule's open burst.
17057
+ *
17058
+ * Every gate lives here so the evaluation loop reads as one decision:
17059
+ *
17060
+ * - **grouping is per-rule and opt-in.** No `groupIdleSec` ⇒ `disabled` ⇒
17061
+ * the caller does exactly what it did before this existed;
17062
+ * - **a subject with no track cannot be a member.** A device event, a system
17063
+ * event and an occupancy edge have no subject to draw a box for and no
17064
+ * identity to add later — grouping them would be grouping *records*, which
17065
+ * is not what the operator asked about;
17066
+ * - **an actuation is never grouped.** `runRuleActions` has already fired by
17067
+ * the time this is reached, and deliberately so: "how often may this gate
17068
+ * physically move" is the sequence runner's own `minDelaySec`, and it must
17069
+ * not become a notification question.
17070
+ */
17071
+ admitToGroup(rule, subject, now) {
17072
+ const idleSec = rule.groupIdleSec;
17073
+ if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
17074
+ const trackId = subject.trackId;
17075
+ if (trackId === void 0) return { outcome: "disabled" };
17076
+ const member = {
17077
+ trackId,
17078
+ className: subject.classNames[0] ?? "event",
17079
+ ...subject.label !== void 0 ? { label: subject.label } : {},
17080
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
17081
+ ...subject.bbox !== void 0 ? { bbox: subject.bbox } : {},
17082
+ eventId: subject.recordId,
17083
+ at: subject.timestamp
17084
+ };
17085
+ return this.groups.admit({
17086
+ ruleId: rule.id,
17087
+ deviceId: subject.deviceId,
17088
+ idleSec,
17089
+ member
17090
+ }, now);
17091
+ }
17092
+ /**
17093
+ * The grouped bursts, newest first — the timeline card's read.
17094
+ *
17095
+ * The STORE, not the live buffer: a card is about arrivals that happened, and
17096
+ * the buffer only knows the ones still open on this process. The open group's
17097
+ * row is written at open and patched at each growth, so a burst in progress
17098
+ * is in the answer too, with `sealed: false`.
17099
+ */
17100
+ async listTrackGroups(query = {}) {
17101
+ return this.groupStore.list(query);
17102
+ }
17103
+ /**
17104
+ * Close every burst whose idle cutoff has elapsed, and say so ONCE.
17105
+ *
17106
+ * Ridden on the outbox drain tick rather than a timer of its own: the drain
17107
+ * already runs every 2 s, which is an order of magnitude finer than the
17108
+ * shortest sensible cutoff, and a second interval would be a second thing to
17109
+ * stop on shutdown. The close is a LOG plus a sealed record — it sends
17110
+ * nothing. Nobody wants a notification saying a group is over.
17111
+ */
17112
+ sweepGroups(now) {
17113
+ for (const group of this.groups.sweep(now)) {
17114
+ this.logger.info("group closed", {
17115
+ tags: { deviceId: group.deviceId },
17116
+ meta: {
17117
+ ruleId: group.ruleId,
17118
+ groupKey: group.key,
17119
+ members: group.members.length,
17120
+ revisions: group.revision,
17121
+ openForMs: group.lastActivityAt - group.openedAt
17122
+ }
17123
+ });
17124
+ this.groupStore.record(group);
17125
+ }
17126
+ }
17127
+ /**
16247
17128
  * Run this rule's `onTrigger` sequences for this match.
16248
17129
  *
16249
17130
  * Called BEFORE the cooldown gate — see the call site for why. Everything
@@ -16311,7 +17192,7 @@ var NotificationCenter = class NotificationCenter {
16311
17192
  }
16312
17193
  });
16313
17194
  }
16314
- buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
17195
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
16315
17196
  const hasEventMedia = kind === "object-event" || kind === "package-event";
16316
17197
  const isTrackScoped = kind === "object-event" || kind === "track-end";
16317
17198
  const isSystemEvent = kind === "system-event";
@@ -16345,6 +17226,19 @@ var NotificationCenter = class NotificationCenter {
16345
17226
  ...!noMedia && mediaRule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: mediaRule.media.clipSpeed } : {},
16346
17227
  ...!noMedia && mediaRule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: mediaRule.media.clipPostRollSec } : {},
16347
17228
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
17229
+ ...group !== void 0 && group.members.length > 1 ? { group: {
17230
+ key: group.key,
17231
+ revision: group.revision,
17232
+ anchorTrackId: group.anchorTrackId,
17233
+ members: group.members.map((m) => ({
17234
+ trackId: m.trackId,
17235
+ className: m.className,
17236
+ ...m.label !== void 0 ? { label: m.label } : {},
17237
+ ...m.labelKind === "identity" || m.labelKind === "plate" ? { labelKind: m.labelKind } : {},
17238
+ ...m.bbox !== void 0 ? { bbox: m.bbox } : {},
17239
+ at: m.at
17240
+ }))
17241
+ } } : {},
16348
17242
  ...target.params !== void 0 ? { params: target.params } : {},
16349
17243
  subject: {
16350
17244
  deviceId: subject.deviceId,
@@ -16367,7 +17261,7 @@ var NotificationCenter = class NotificationCenter {
16367
17261
  recordKind: kind === "occupancy-event" ? "device-event" : kind,
16368
17262
  recordId: subject.recordId,
16369
17263
  ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
16370
- ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
17264
+ ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : group !== void 0 && group.revision > 1 ? { dedupRef: `${group.key}:v${group.revision}` } : {},
16371
17265
  payload
16372
17266
  };
16373
17267
  });
@@ -16741,6 +17635,7 @@ var NotificationCenter = class NotificationCenter {
16741
17635
  }
16742
17636
  async drainTick() {
16743
17637
  await this.snoozes.load();
17638
+ this.sweepGroups(this.now());
16744
17639
  await this.outbox.drainOnce();
16745
17640
  await this.deliverDueDigests();
16746
17641
  this.drainTicks += 1;
@@ -17189,6 +18084,8 @@ function systemEventSubject(kind, scope) {
17189
18084
  if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
17190
18085
  if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
17191
18086
  if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
18087
+ if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
18088
+ if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
17192
18089
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
17193
18090
  return `alarm:${scope.deviceId ?? 0}`;
17194
18091
  }
@@ -17295,7 +18192,9 @@ function planRuleTest(input) {
17295
18192
  currentVersion: "0.0.0",
17296
18193
  latestVersion: "0.0.0",
17297
18194
  profile: "low",
17298
- seconds: "30"
18195
+ seconds: "30",
18196
+ devices: "cuda:0",
18197
+ minutes: "15"
17299
18198
  },
17300
18199
  ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
17301
18200
  ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
@@ -17345,6 +18244,27 @@ var NcViewerTimelapseRuleSchema = require_dist.TimelapseRuleSchema.extend({ read
17345
18244
  /** Same verdict, for a multi-camera digest rule. */
17346
18245
  var NcViewerSummaryRuleSchema = NcSummaryRuleSchema.extend({ readOnly: require_dist.boolean() });
17347
18246
  /**
18247
+ * One grouped burst, as the viewer reads it.
18248
+ *
18249
+ * The LIGHT half of the Phase-2 group entity on purpose: ids and timestamps, no
18250
+ * representative frame and no clustering verdict. A card needs to know which
18251
+ * tracks were one arrival and when; anything more would be a second source of
18252
+ * truth about a relation the entity has not been built for yet.
18253
+ */
18254
+ var NcTrackGroupSchema = require_dist.object({
18255
+ id: require_dist.string(),
18256
+ ruleId: require_dist.string(),
18257
+ deviceId: require_dist.number().int(),
18258
+ openedAt: require_dist.number().int(),
18259
+ lastGrowthAt: require_dist.number().int(),
18260
+ memberTrackIds: require_dist.array(require_dist.string()).readonly(),
18261
+ memberCount: require_dist.number().int(),
18262
+ /** How many notifications the burst produced — 1 at open, +1 per growth. */
18263
+ revision: require_dist.number().int(),
18264
+ /** False while the arrival is still happening. */
18265
+ sealed: require_dist.boolean()
18266
+ });
18267
+ /**
17348
18268
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
17349
18269
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
17350
18270
  * caller identity is declared `caller: 'required'` so the dispatcher forwards
@@ -17385,6 +18305,22 @@ var ncActions = require_dist.defineCustomActions({
17385
18305
  * desk. Scoped to the caller: their own windows plus the global ones that
17386
18306
  * silence them.
17387
18307
  */
18308
+ /**
18309
+ * The BURSTS this hub has grouped — the data half of the timeline's
18310
+ * expandable group card.
18311
+ *
18312
+ * Rides the generic bridge, like every other viewer read here: no cap method,
18313
+ * no codegen, ships train-free. Read-only and deliberately thin — it answers
18314
+ * "which tracks were one arrival", and the per-subject tracks stay the record
18315
+ * and stay clickable. A group card EXPANDS to its members; it never replaces
18316
+ * them.
18317
+ */
18318
+ "nc.listTrackGroups": require_dist.customAction(require_dist.object({
18319
+ deviceId: require_dist.number().int().optional(),
18320
+ ruleId: require_dist.string().optional(),
18321
+ since: require_dist.number().int().optional(),
18322
+ limit: require_dist.number().int().min(1).max(200).optional()
18323
+ }), require_dist.object({ groups: require_dist.array(NcTrackGroupSchema) }), { caller: "required" }),
17388
18324
  "nc.listSnoozes": require_dist.customAction(require_dist.object({}), require_dist.object({ snoozes: require_dist.array(require_dist.NcSnoozeSchema) }), { caller: "required" }),
17389
18325
  "nc.createSnooze": require_dist.customAction(require_dist.object({ snooze: require_dist.NcSnoozeInputSchema }), require_dist.object({ snooze: require_dist.NcSnoozeSchema }), {
17390
18326
  kind: "mutation",
@@ -17718,6 +18654,12 @@ function makeNcActionHandlers(deps) {
17718
18654
  readOnly: !c.isAdmin && r.ownerUserId !== c.userId
17719
18655
  })) };
17720
18656
  },
18657
+ "nc.listTrackGroups": async (input, caller) => {
18658
+ requireCaller(caller);
18659
+ const list = deps.listTrackGroups;
18660
+ if (list === void 0) return { groups: [] };
18661
+ return { groups: [...await list(input)] };
18662
+ },
17721
18663
  "nc.getConditionCatalog": async () => ({
17722
18664
  catalog: [...require_dist.NC_CONDITION_CATALOG],
17723
18665
  taxonomy: require_dist.NC_TAXONOMY
@@ -18334,7 +19276,7 @@ function buildProbeDurationArgs(inputPath) {
18334
19276
  }
18335
19277
  /** `<`, `>`, `&`, `'` and `"` in an operator's free-text caption would each
18336
19278
  * turn the overlay into a document librsvg refuses. */
18337
- function escapeXml$1(s) {
19279
+ function escapeXml(s) {
18338
19280
  return s.replace(/[<>&'"]/g, (ch) => {
18339
19281
  switch (ch) {
18340
19282
  case "<": return "&lt;";
@@ -18422,7 +19364,7 @@ function buildTitleSvg(input) {
18422
19364
  <rect x="0" y="0" width="${width}" height="${height}" fill="black" fill-opacity="${TITLE_SCRIM_OPACITY}"/>
18423
19365
  ${lines.map((line, i) => {
18424
19366
  const y = top + lineHeight * i + Math.round(fontPx * .8);
18425
- const esc = escapeXml$1(line);
19367
+ const esc = escapeXml(line);
18426
19368
  return [` <text x="${cx + shadow}" y="${y + shadow}" ${font} fill="black" fill-opacity="${TITLE_SHADOW_OPACITY}">${esc}</text>`, ` <text x="${cx}" y="${y}" ${font} fill="white">${esc}</text>`].join("\n");
18427
19369
  }).join("\n")}
18428
19370
  </svg>`;
@@ -21730,6 +22672,8 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
21730
22672
  *
21731
22673
  * One FrameProcessor per device — lifecycle managed by DeviceRegistry.
21732
22674
  */
22675
+ /** Shared empty — a fold is rare and every other frame would allocate. */
22676
+ var EMPTY_RIDER_FOLDS = [];
21733
22677
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
21734
22678
  * canonical TrackState enum used on tracks + events. */
21735
22679
  function mapObjectStateToTrackState(s) {
@@ -21788,8 +22732,6 @@ var FrameProcessor = class {
21788
22732
  * this frame still carries the zones it was in. See the use site.
21789
22733
  */
21790
22734
  lastZonesByTrack = /* @__PURE__ */ new Map();
21791
- /** See {@link getLastRiderPairs}. */
21792
- lastRiderPairs = [];
21793
22735
  /** See {@link getLastLabelWriteRefusals}. */
21794
22736
  lastLabelWriteRefusals = [];
21795
22737
  /**
@@ -21850,17 +22792,11 @@ var FrameProcessor = class {
21850
22792
  setZoneMembershipMinOverlap(minOverlap) {
21851
22793
  this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
21852
22794
  }
21853
- /** Rider pairings folded on the most recent frame — the PERSON half that was
21854
- * dropped so the passage counts once, as a vehicle. Reported so both
21855
- * entities stay on the record rather than one silently disappearing. */
21856
- getLastRiderPairs() {
21857
- return this.lastRiderPairs;
21858
- }
21859
22795
  /**
21860
22796
  * Label candidates the write rule refused on the most recent frame (see
21861
22797
  * `resolveTieredLabelWrite` in `label-tier.ts`).
21862
22798
  *
21863
- * Reported for the same reason as {@link getLastRiderPairs}: the branch DROPS
22799
+ * Reported for the same reason a {@link RiderFold} is: the branch DROPS
21864
22800
  * a value, and a drop nobody logs reads as "it never happened". The five live
21865
22801
  * rows that produced the macro-class rule were found by reading the rows, a
21866
22802
  * week after the fact. Deduped per (track, token) so a track that keeps
@@ -21985,16 +22921,30 @@ var FrameProcessor = class {
21985
22921
  bbox: d.bbox,
21986
22922
  score: d.score
21987
22923
  })));
22924
+ /**
22925
+ * Pending folds, keyed by the SURVIVING vehicle's bbox object.
22926
+ *
22927
+ * The bbox object reference is the file's established way to carry
22928
+ * frame-local facts across the tracker (`labelsByBbox`,
22929
+ * `firstLevelBboxById`): the tracker assigns `track.bbox = det.bbox` on
22930
+ * every match and spawn, so the emitted `TrackedDetection.bbox` IS this
22931
+ * object when the vehicle was seen this frame.
22932
+ */
22933
+ const foldByVehicleBbox = /* @__PURE__ */ new Map();
21988
22934
  if (riderPairs.length > 0) {
21989
22935
  const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
21990
- this.lastRiderPairs = riderPairs.map((p) => ({
21991
- overlap: p.overlap,
21992
- personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
21993
- vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
21994
- vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
21995
- }));
22936
+ for (const p of riderPairs) {
22937
+ const vehicle = filteredDetections[Number(p.vehicleId)];
22938
+ if (vehicle === void 0) continue;
22939
+ foldByVehicleBbox.set(vehicle.bbox, {
22940
+ overlap: p.overlap,
22941
+ personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
22942
+ vehicleScore: vehicle.score,
22943
+ vehicleClass: vehicle.originalClass ?? "two-wheeler"
22944
+ });
22945
+ }
21996
22946
  filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
21997
- } else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
22947
+ }
21998
22948
  const gate = this.stationaryGate ? this.stationaryGate.filter({
21999
22949
  detections: filteredDetections,
22000
22950
  frameWidth,
@@ -22009,6 +22959,13 @@ var FrameProcessor = class {
22009
22959
  frameWidth,
22010
22960
  frameHeight
22011
22961
  });
22962
+ const riderFolds = foldByVehicleBbox.size === 0 ? EMPTY_RIDER_FOLDS : [...foldByVehicleBbox].map(([bbox, fold]) => {
22963
+ const td = trackedDetections.find((t) => t.bbox === bbox && t.matchedThisFrame !== false);
22964
+ return td !== void 0 ? {
22965
+ ...fold,
22966
+ trackId: td.trackId
22967
+ } : fold;
22968
+ });
22012
22969
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
22013
22970
  const zonesByTrack = /* @__PURE__ */ new Map();
22014
22971
  const overlapsByTrack = /* @__PURE__ */ new Map();
@@ -22204,7 +23161,8 @@ var FrameProcessor = class {
22204
23161
  appearanceEvents,
22205
23162
  rawTrackedDetections: trackedDetections,
22206
23163
  stationaryConfirmed: gate.confirmed,
22207
- stationaryWoken: gate.wokenEntryIds
23164
+ stationaryWoken: gate.wokenEntryIds,
23165
+ riderFolds
22208
23166
  };
22209
23167
  }
22210
23168
  };
@@ -31014,77 +31972,6 @@ async function rankKeyEvents(candidates, options, peakLookup) {
31014
31972
  return scored.slice(0, options.limit);
31015
31973
  }
31016
31974
  //#endregion
31017
- //#region src/shared/frame/box-drawer.ts
31018
- var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
31019
- var DEFAULT_QUALITY = 80;
31020
- var STROKE_WIDTH = 3;
31021
- function escapeXml(s) {
31022
- return s.replace(/[<>&'"]/g, (ch) => {
31023
- switch (ch) {
31024
- case "<": return "&lt;";
31025
- case ">": return "&gt;";
31026
- case "&": return "&amp;";
31027
- case "'": return "&apos;";
31028
- default: return "&quot;";
31029
- }
31030
- });
31031
- }
31032
- /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
31033
- function clampBox(b, frameWidth, frameHeight) {
31034
- const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
31035
- const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
31036
- return {
31037
- x,
31038
- y,
31039
- w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
31040
- h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
31041
- };
31042
- }
31043
- /**
31044
- * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
31045
- *
31046
- * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
31047
- * optional caption) at full resolution, then optionally downscaled — so the
31048
- * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
31049
- * avoid sharp `extract`/region errors on slightly-out-of-range detections.
31050
- */
31051
- async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
31052
- const quality = opts.quality ?? DEFAULT_QUALITY;
31053
- let base = frameData;
31054
- let baseIsRaw = true;
31055
- if (boxes.length > 0) {
31056
- const fontSize = Math.max(12, Math.round(frameHeight / 30));
31057
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
31058
- const c = clampBox(b, frameWidth, frameHeight);
31059
- const color = b.color ?? DEFAULT_COLOR;
31060
- const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
31061
- if (!b.label) return rect;
31062
- const ty = Math.max(fontSize, c.y - 4);
31063
- return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml(b.label)}</text>`;
31064
- }).join("")}</svg>`;
31065
- base = await (0, sharp.default)(frameData, { raw: {
31066
- width: frameWidth,
31067
- height: frameHeight,
31068
- channels: 3
31069
- } }).composite([{
31070
- input: Buffer.from(svg),
31071
- top: 0,
31072
- left: 0
31073
- }]).png().toBuffer();
31074
- baseIsRaw = false;
31075
- }
31076
- let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
31077
- width: frameWidth,
31078
- height: frameHeight,
31079
- channels: 3
31080
- } }) : (0, sharp.default)(base);
31081
- if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
31082
- width: opts.maxWidth,
31083
- withoutEnlargement: true
31084
- });
31085
- return pipeline.jpeg({ quality }).toBuffer();
31086
- }
31087
- //#endregion
31088
31975
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
31089
31976
  /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
31090
31977
  * Native resolution is the point: a 4K cam (3840) resolves TRUE native (no
@@ -31514,9 +32401,22 @@ function pickedUpEventId(entryId) {
31514
32401
  }
31515
32402
  var PackageDropDetector = class {
31516
32403
  deps;
32404
+ /** Per-device mirror of the last LOGGED value of the `packageDropEnabled`
32405
+ * switch, so gate 0 logs a transition instead of one line per promotion. */
32406
+ disabledLogged = /* @__PURE__ */ new Map();
31517
32407
  constructor(deps) {
31518
32408
  this.deps = deps;
31519
32409
  }
32410
+ /** Log the `packageDropEnabled` switch only when it MOVES for a device. */
32411
+ noteDisabled(deviceId, disabled) {
32412
+ if (this.disabledLogged.get(deviceId) === disabled) return;
32413
+ this.disabledLogged.set(deviceId, disabled);
32414
+ if (disabled) this.deps.logger.info("package gate 0/5 switch — packageDropEnabled is false", { tags: { deviceId } });
32415
+ }
32416
+ /** Drop a device's gate-log mirror (unbind / teardown). */
32417
+ forgetDevice(deviceId) {
32418
+ this.disabledLogged.delete(deviceId);
32419
+ }
31520
32420
  /** Single entrypoint — bridge the registry's `onChange` here. Never
31521
32421
  * throws (telemetry-lossy, D8): a failure only misses one package event. */
31522
32422
  async onStationaryChange(change) {
@@ -31537,22 +32437,104 @@ var PackageDropDetector = class {
31537
32437
  }
31538
32438
  async onAppeared(entry, timestamp) {
31539
32439
  const settings = await this.deps.resolveSettings(entry.deviceId);
31540
- if (!settings.packageDropEnabled) return;
31541
- if (!settings.packageDropClassFilter.includes(entry.className)) return;
32440
+ if (!settings.packageDropEnabled) {
32441
+ this.noteDisabled(entry.deviceId, true);
32442
+ return;
32443
+ }
32444
+ this.noteDisabled(entry.deviceId, false);
32445
+ if (!settings.packageDropClassFilter.includes(entry.className)) {
32446
+ this.deps.logger.debug("package gate 1/5 class — not a package class", {
32447
+ tags: { deviceId: entry.deviceId },
32448
+ meta: {
32449
+ entryId: entry.id,
32450
+ className: entry.className,
32451
+ classFilter: settings.packageDropClassFilter
32452
+ }
32453
+ });
32454
+ return;
32455
+ }
32456
+ const tags = { deviceId: entry.deviceId };
31542
32457
  const rules = (await this.deps.resolvePackageRules(entry.deviceId)).filter((r) => r.enabled !== false);
31543
- if (rules.length === 0) return;
32458
+ if (rules.length === 0) {
32459
+ this.deps.logger.info("package gate 2/5 zone — no enabled package-stage zone rule", {
32460
+ tags,
32461
+ meta: {
32462
+ entryId: entry.id,
32463
+ className: entry.className
32464
+ }
32465
+ });
32466
+ return;
32467
+ }
31544
32468
  const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
31545
- const hitZones = computeStationaryEntryZones(entry, await this.deps.resolveZones(entry.deviceId)).filter((z) => ruleZoneIds.has(z));
31546
- if (hitZones.length === 0) return;
31547
- if (timestamp - entry.firstSeenAt < settings.packageDropDwellSec * 1e3) return;
32469
+ const zones = await this.deps.resolveZones(entry.deviceId);
32470
+ const entryZones = computeStationaryEntryZones(entry, zones);
32471
+ const hitZones = entryZones.filter((z) => ruleZoneIds.has(z));
32472
+ if (hitZones.length === 0) {
32473
+ this.deps.logger.info("package gate 2/5 zone — centroid outside every package zone", {
32474
+ tags,
32475
+ meta: {
32476
+ entryId: entry.id,
32477
+ bbox: entry.bbox,
32478
+ entryZones,
32479
+ ruleZoneIds: [...ruleZoneIds],
32480
+ zonesOnDevice: zones.length
32481
+ }
32482
+ });
32483
+ return;
32484
+ }
32485
+ const dwellMs = timestamp - entry.firstSeenAt;
32486
+ const requiredDwellMs = settings.packageDropDwellSec * 1e3;
32487
+ if (dwellMs < requiredDwellMs) {
32488
+ this.deps.logger.info("package gate 3/5 dwell — not still long enough", {
32489
+ tags,
32490
+ meta: {
32491
+ entryId: entry.id,
32492
+ dwellMs,
32493
+ requiredDwellMs
32494
+ }
32495
+ });
32496
+ return;
32497
+ }
31548
32498
  const frameArea = entry.frameWidth * entry.frameHeight;
31549
- if (frameArea <= 0) return;
31550
- if (entry.bbox.w * entry.bbox.h / frameArea < settings.packageDropMinBboxAreaFrac) return;
32499
+ if (frameArea <= 0) {
32500
+ this.deps.logger.info("package gate 4/5 size — frame dimensions unknown", {
32501
+ tags,
32502
+ meta: {
32503
+ entryId: entry.id,
32504
+ frameWidth: entry.frameWidth,
32505
+ frameHeight: entry.frameHeight
32506
+ }
32507
+ });
32508
+ return;
32509
+ }
32510
+ const areaFrac = entry.bbox.w * entry.bbox.h / frameArea;
32511
+ if (areaFrac < settings.packageDropMinBboxAreaFrac) {
32512
+ this.deps.logger.info("package gate 4/5 size — bbox below the minimum area", {
32513
+ tags,
32514
+ meta: {
32515
+ entryId: entry.id,
32516
+ areaFrac: Number(areaFrac.toFixed(5)),
32517
+ minAreaFrac: settings.packageDropMinBboxAreaFrac,
32518
+ bbox: entry.bbox,
32519
+ frame: [entry.frameWidth, entry.frameHeight]
32520
+ }
32521
+ });
32522
+ return;
32523
+ }
31551
32524
  const eventId = deliveredEventId(entry.id);
31552
32525
  if ((await this.deps.events.queryObject({
31553
32526
  deviceId: entry.deviceId,
31554
32527
  classFilter: "package"
31555
- })).some((e) => e.id === eventId)) return;
32528
+ })).some((e) => e.id === eventId)) {
32529
+ this.deps.logger.info("package gate 5/5 idempotence — delivery already recorded", {
32530
+ tags,
32531
+ meta: {
32532
+ entryId: entry.id,
32533
+ eventId
32534
+ }
32535
+ });
32536
+ return;
32537
+ }
31556
32538
  const ev = {
31557
32539
  id: eventId,
31558
32540
  kind: "object",
@@ -31562,7 +32544,7 @@ var PackageDropDetector = class {
31562
32544
  trackId: entry.sourceTrackId ?? entry.id,
31563
32545
  className: PACKAGE_EVENT_CLASS,
31564
32546
  ...entry.label !== void 0 ? { label: entry.label } : {},
31565
- confidence: PACKAGE_IMPORTANCE,
32547
+ confidence: entry.confidence ?? PACKAGE_IMPORTANCE,
31566
32548
  bbox: {
31567
32549
  x: entry.bbox.x,
31568
32550
  y: entry.bbox.y,
@@ -31593,9 +32575,26 @@ var PackageDropDetector = class {
31593
32575
  },
31594
32576
  timestamp
31595
32577
  });
32578
+ this.deps.logger.info("package delivered — all 5 gates passed", {
32579
+ tags: { deviceId: entry.deviceId },
32580
+ meta: {
32581
+ entryId: entry.id,
32582
+ eventId,
32583
+ zoneIds: hitZones,
32584
+ dwellMs,
32585
+ areaFrac: Number(areaFrac.toFixed(5)),
32586
+ ...entry.confidence !== void 0 ? { confidence: entry.confidence } : {}
32587
+ }
32588
+ });
31596
32589
  }
31597
32590
  async onDeparted(entry, timestamp) {
31598
- if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) return;
32591
+ if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) {
32592
+ this.deps.logger.debug("package pick-up skipped — packageDropPickupEnabled is false", {
32593
+ tags: { deviceId: entry.deviceId },
32594
+ meta: { entryId: entry.id }
32595
+ });
32596
+ return;
32597
+ }
31599
32598
  const deliveredId = deliveredEventId(entry.id);
31600
32599
  const pickedUpId = pickedUpEventId(entry.id);
31601
32600
  const rows = await this.deps.events.queryObject({
@@ -31603,8 +32602,26 @@ var PackageDropDetector = class {
31603
32602
  classFilter: PACKAGE_EVENT_CLASS
31604
32603
  });
31605
32604
  const delivered = rows.find((e) => e.id === deliveredId);
31606
- if (delivered === void 0) return;
31607
- if (rows.some((e) => e.id === pickedUpId)) return;
32605
+ if (delivered === void 0) {
32606
+ this.deps.logger.debug("package pick-up skipped — no delivery row for this entry", {
32607
+ tags: { deviceId: entry.deviceId },
32608
+ meta: {
32609
+ entryId: entry.id,
32610
+ className: entry.className
32611
+ }
32612
+ });
32613
+ return;
32614
+ }
32615
+ if (rows.some((e) => e.id === pickedUpId)) {
32616
+ this.deps.logger.info("package pick-up skipped — already recorded", {
32617
+ tags: { deviceId: entry.deviceId },
32618
+ meta: {
32619
+ entryId: entry.id,
32620
+ eventId: pickedUpId
32621
+ }
32622
+ });
32623
+ return;
32624
+ }
31608
32625
  const ev = {
31609
32626
  id: pickedUpId,
31610
32627
  kind: "object",
@@ -31636,6 +32653,14 @@ var PackageDropDetector = class {
31636
32653
  className: entry.className,
31637
32654
  timestamp
31638
32655
  });
32656
+ this.deps.logger.info("package picked up", {
32657
+ tags: { deviceId: entry.deviceId },
32658
+ meta: {
32659
+ entryId: entry.id,
32660
+ eventId: pickedUpId,
32661
+ deliveredEventId: deliveredId
32662
+ }
32663
+ });
31639
32664
  }
31640
32665
  };
31641
32666
  //#endregion
@@ -31821,6 +32846,150 @@ function evaluateStationaryPromotion(input) {
31821
32846
  spanFrac: metrics.pathSpanFrac
31822
32847
  };
31823
32848
  }
32849
+ var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
32850
+ minRounds: 2,
32851
+ roundGapMs: 5e3,
32852
+ ttlMs: 5 * 6e4,
32853
+ assocIou: .3
32854
+ };
32855
+ var PackageStillnessTracker = class {
32856
+ config;
32857
+ newId;
32858
+ byDevice = /* @__PURE__ */ new Map();
32859
+ constructor(deps) {
32860
+ this.config = deps.config ?? DEFAULT_PACKAGE_STILLNESS_CONFIG;
32861
+ this.newId = deps.newId;
32862
+ }
32863
+ /**
32864
+ * Fold one frame's package sightings into the per-device candidate set and
32865
+ * report the transitions. Immutable: every candidate touched is replaced,
32866
+ * never mutated. Never throws.
32867
+ */
32868
+ observe(input) {
32869
+ const next = [...this.byDevice.get(input.deviceId) ?? []];
32870
+ const consumed = /* @__PURE__ */ new Set();
32871
+ const promoted = [];
32872
+ for (const s of input.sightings) {
32873
+ const index = this.bestMatch(next, s, consumed);
32874
+ if (index < 0) {
32875
+ const fresh = this.openCandidate(input, s);
32876
+ next.push(fresh);
32877
+ consumed.add(fresh.id);
32878
+ continue;
32879
+ }
32880
+ const prev = next[index];
32881
+ if (prev === void 0) continue;
32882
+ consumed.add(prev.id);
32883
+ const updated = confirm(prev, input, s, this.config.roundGapMs);
32884
+ if (!(updated.promotedAt === null && updated.rounds >= this.config.minRounds && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
32885
+ next[index] = updated;
32886
+ continue;
32887
+ }
32888
+ const parked = {
32889
+ ...updated,
32890
+ promotedAt: input.timestamp
32891
+ };
32892
+ next[index] = parked;
32893
+ promoted.push(toEntry(parked));
32894
+ }
32895
+ const departed = [];
32896
+ const kept = [];
32897
+ for (const c of next) {
32898
+ if (input.timestamp - c.lastSeenAt > this.config.ttlMs) {
32899
+ if (c.promotedAt !== null) departed.push(toEntry(c));
32900
+ continue;
32901
+ }
32902
+ kept.push(c);
32903
+ }
32904
+ if (kept.length === 0) this.byDevice.delete(input.deviceId);
32905
+ else this.byDevice.set(input.deviceId, kept);
32906
+ return {
32907
+ promoted,
32908
+ departed
32909
+ };
32910
+ }
32911
+ /** Drop a device's candidates (unbind / step disabled). */
32912
+ forgetDevice(deviceId) {
32913
+ this.byDevice.delete(deviceId);
32914
+ }
32915
+ reset() {
32916
+ this.byDevice.clear();
32917
+ }
32918
+ /** Live candidate count for a device — diagnostics and tests only. */
32919
+ debugCandidateCount(deviceId) {
32920
+ return this.byDevice.get(deviceId)?.length ?? 0;
32921
+ }
32922
+ /** Best same-class candidate over the association floor, ignoring any already
32923
+ * consumed by an earlier sighting in THIS frame (two boxes never collapse
32924
+ * into one parcel). */
32925
+ bestMatch(candidates, s, consumed) {
32926
+ let bestIndex = -1;
32927
+ let bestIou = this.config.assocIou;
32928
+ for (let i = 0; i < candidates.length; i += 1) {
32929
+ const c = candidates[i];
32930
+ if (c === void 0 || consumed.has(c.id) || c.className !== s.className) continue;
32931
+ const overlap = iou(c.bbox, s.bbox);
32932
+ if (overlap >= bestIou) {
32933
+ bestIou = overlap;
32934
+ bestIndex = i;
32935
+ }
32936
+ }
32937
+ return bestIndex;
32938
+ }
32939
+ openCandidate(input, s) {
32940
+ return {
32941
+ id: this.newId(),
32942
+ deviceId: input.deviceId,
32943
+ className: s.className,
32944
+ bbox: { ...s.bbox },
32945
+ frameWidth: input.frameWidth,
32946
+ frameHeight: input.frameHeight,
32947
+ firstSeenAt: input.timestamp,
32948
+ lastSeenAt: input.timestamp,
32949
+ rounds: 1,
32950
+ promotedAt: null,
32951
+ ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
32952
+ ...s.label !== void 0 ? { label: s.label } : {},
32953
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
32954
+ ...s.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: s.keyFrameMediaKey } : {}
32955
+ };
32956
+ }
32957
+ };
32958
+ /** Re-confirm a candidate with this frame's box. A new ROUND is only counted
32959
+ * when the gap since the last sighting clears `roundGapMs`, so a burst of
32960
+ * frames inside one governor round counts once. */
32961
+ function confirm(prev, input, s, roundGapMs) {
32962
+ const newRound = input.timestamp - prev.lastSeenAt >= roundGapMs;
32963
+ return {
32964
+ ...prev,
32965
+ bbox: { ...s.bbox },
32966
+ frameWidth: input.frameWidth,
32967
+ frameHeight: input.frameHeight,
32968
+ lastSeenAt: input.timestamp,
32969
+ rounds: newRound ? prev.rounds + 1 : prev.rounds,
32970
+ ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
32971
+ ...s.label !== void 0 ? { label: s.label } : {},
32972
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
32973
+ ...s.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: s.keyFrameMediaKey } : {}
32974
+ };
32975
+ }
32976
+ function toEntry(c) {
32977
+ return {
32978
+ id: c.id,
32979
+ deviceId: c.deviceId,
32980
+ className: c.className,
32981
+ bbox: { ...c.bbox },
32982
+ frameWidth: c.frameWidth,
32983
+ frameHeight: c.frameHeight,
32984
+ firstSeenAt: c.firstSeenAt,
32985
+ becameStationaryAt: c.promotedAt ?? c.lastSeenAt,
32986
+ lastConfirmedAt: c.lastSeenAt,
32987
+ ...c.sourceTrackId !== void 0 ? { sourceTrackId: c.sourceTrackId } : {},
32988
+ ...c.label !== void 0 ? { label: c.label } : {},
32989
+ ...c.confidence !== void 0 ? { confidence: c.confidence } : {},
32990
+ ...c.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: c.keyFrameMediaKey } : {}
32991
+ };
32992
+ }
31824
32993
  //#endregion
31825
32994
  //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
31826
32995
  /**
@@ -33617,6 +34786,24 @@ var OBJECT_COLUMNS = [
33617
34786
  name: "source",
33618
34787
  type: "TEXT"
33619
34788
  },
34789
+ (
34790
+ /**
34791
+ * The SCENE key — every event of one processed frame shares it
34792
+ * (`frame-processor.ts`, stamped on every emitted event).
34793
+ *
34794
+ * It was documented on the cap from the day the collection existed ("lets a
34795
+ * consumer group co-occurring detections (e.g. 2 people + 1 dog) under one
34796
+ * full frame"), stamped by the producer, and read back by `slimObject` — with
34797
+ * no column to land in. The sqlite backend drops an undeclared key WITHOUT
34798
+ * ERROR (`if (decl.columns.has(k))`), so the value was written into the void
34799
+ * on every row ever persisted and the co-occurrence relation was unanswerable
34800
+ * from SQL. Additive and nullable: legacy rows keep reading `undefined`,
34801
+ * which is the truth about them.
34802
+ */
34803
+ {
34804
+ name: "frameId",
34805
+ type: "TEXT"
34806
+ }),
33620
34807
  {
33621
34808
  name: "trackId",
33622
34809
  type: "TEXT",
@@ -33726,10 +34913,17 @@ var EventStore = class {
33726
34913
  await store.declareCollection.mutate({
33727
34914
  collection: OBJECT_EVENTS_COLLECTION,
33728
34915
  columns: [...OBJECT_COLUMNS],
33729
- indexes: [...COMMON_INDEXES("object"), {
33730
- name: "idx_object_track",
33731
- columns: ["trackId"]
33732
- }]
34916
+ indexes: [
34917
+ ...COMMON_INDEXES("object"),
34918
+ {
34919
+ name: "idx_object_track",
34920
+ columns: ["trackId"]
34921
+ },
34922
+ {
34923
+ name: "idx_object_frame",
34924
+ columns: ["frameId"]
34925
+ }
34926
+ ]
33733
34927
  });
33734
34928
  await store.declareCollection.mutate({
33735
34929
  collection: AUDIO_EVENTS_COLLECTION,
@@ -36192,6 +37386,10 @@ var TRACKS_COLUMNS = [
36192
37386
  name: "hasFace",
36193
37387
  type: "BOOLEAN"
36194
37388
  },
37389
+ {
37390
+ name: "hasRider",
37391
+ type: "BOOLEAN"
37392
+ },
36195
37393
  {
36196
37394
  name: "envMinX",
36197
37395
  type: "REAL"
@@ -36273,7 +37471,8 @@ function cloneTrack(t) {
36273
37471
  markForTrain: t.retrainStatus === "staging"
36274
37472
  } : {},
36275
37473
  ...t.debug !== void 0 ? { debug: t.debug } : {},
36276
- ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
37474
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
37475
+ ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {}
36277
37476
  };
36278
37477
  }
36279
37478
  var TrackStore = class {
@@ -36679,6 +37878,41 @@ var TrackStore = class {
36679
37878
  }
36680
37879
  }
36681
37880
  /**
37881
+ * Record that a rider was folded into THIS vehicle subject (D34).
37882
+ *
37883
+ * The twin of {@link markFaceDetected}, deliberately: same set-once
37884
+ * semantics, same live-then-persisted branch, same best-effort failure. What
37885
+ * it closes is a record gap — the fold deletes the person detection before the
37886
+ * tracker, and until this method existed nothing durable said so, while both
37887
+ * the ADR and the code comment claimed otherwise.
37888
+ *
37889
+ * Set-once and never cleared: the fold is a fact about a moment of the
37890
+ * track's life, and a rider who dismounts later does not un-ride the frames
37891
+ * where they were riding. (The dismount itself needs nothing here — the fold
37892
+ * is per-FRAME, so the person simply stops overlapping and spawns its own
37893
+ * track, which is D34's whole design.)
37894
+ */
37895
+ async markRiderFolded(trackId) {
37896
+ const active = this.active.get(trackId);
37897
+ if (active) {
37898
+ if (active.hasRider === true) return;
37899
+ active.hasRider = true;
37900
+ return;
37901
+ }
37902
+ try {
37903
+ await this.store.update.mutate({
37904
+ collection: TRACKS_COLLECTION,
37905
+ id: trackId,
37906
+ data: { hasRider: true }
37907
+ });
37908
+ } catch (err) {
37909
+ this.logger.warn("markRiderFolded persist failed", { meta: {
37910
+ trackId,
37911
+ error: String(err)
37912
+ } });
37913
+ }
37914
+ }
37915
+ /**
36682
37916
  * Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
36683
37917
  * express.
36684
37918
  *
@@ -37364,6 +38598,7 @@ var TrackStore = class {
37364
38598
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
37365
38599
  ...t.debug !== void 0 ? { debug: t.debug } : {},
37366
38600
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
38601
+ ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
37367
38602
  ...envelope !== null && dims !== void 0 ? {
37368
38603
  envMinX: envelope.minX,
37369
38604
  envMinY: envelope.minY,
@@ -37399,6 +38634,8 @@ var TrackStore = class {
37399
38634
  const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
37400
38635
  const debug = data["debug"];
37401
38636
  const hasFace = data["hasFace"];
38637
+ /** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
38638
+ const hasRider = data["hasRider"];
37402
38639
  const envMinX = data["envMinX"];
37403
38640
  const envMinY = data["envMinY"];
37404
38641
  const envMaxX = data["envMaxX"];
@@ -37435,6 +38672,7 @@ var TrackStore = class {
37435
38672
  } : {},
37436
38673
  ...typeof debug === "boolean" ? { debug } : {},
37437
38674
  ...typeof hasFace === "boolean" ? { hasFace } : {},
38675
+ ...typeof hasRider === "boolean" ? { hasRider } : {},
37438
38676
  ...envelope !== null ? { envelope } : {}
37439
38677
  };
37440
38678
  }
@@ -39427,6 +40665,8 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
39427
40665
  require_dist.EventCategory.AgentOffline,
39428
40666
  require_dist.EventCategory.WorkerOnline,
39429
40667
  require_dist.EventCategory.WorkerOffline,
40668
+ require_dist.EventCategory.PipelineNodeInferenceUnavailable,
40669
+ require_dist.EventCategory.PipelineDetectionBlind,
39430
40670
  require_dist.EventCategory.UpdateAvailable
39431
40671
  ];
39432
40672
  /**
@@ -44364,6 +45604,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
44364
45604
  stationarySettingsCache = this.settingsCaches.registerDeviceCache("stationary", SETTINGS_CACHE_TTL_MS);
44365
45605
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
44366
45606
  packageDropDetector = null;
45607
+ /**
45608
+ * The DEDICATED package promotion path. The generic stationary registry
45609
+ * needs 4 positions inside 30s; `package-detection` is gated by the
45610
+ * `MotionSettleGovernor` at one observation per 45s, so that criterion is
45611
+ * arithmetically unreachable and `PackageDropDetector` never fired once in
45612
+ * its life. This tracker re-associates a package box across governor rounds
45613
+ * BY POSITION (the track dies between rounds) and feeds the detector the
45614
+ * same appear/depart shape the registry does. Vehicles are untouched.
45615
+ */
45616
+ packageStillness = new PackageStillnessTracker({ newId: () => (0, node_crypto.randomUUID)() });
44367
45617
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
44368
45618
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
44369
45619
  /** The per-track close-path orchestrator (S2, refactor spec 2026-07-22):
@@ -44749,6 +45999,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
44749
45999
  listCallerTargetIds: async (userId) => {
44750
46000
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
44751
46001
  },
46002
+ listTrackGroups: (query) => center.listTrackGroups(query),
44752
46003
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
44753
46004
  timelapse: {
44754
46005
  store: center.timelapseStore,
@@ -46103,6 +47354,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46103
47354
  this.trackStore?.clearDevice(data.deviceId);
46104
47355
  this.residents.closeDevice(data.deviceId);
46105
47356
  this.stationaryRegistry?.forgetDevice(data.deviceId);
47357
+ this.packageStillness.forgetDevice(data.deviceId);
47358
+ this.packageDropDetector?.forgetDevice(data.deviceId);
46106
47359
  this.overlayState.clearDevice(data.deviceId);
46107
47360
  this.overlaySynthesisWarnAt.delete(data.deviceId);
46108
47361
  this.forgetDeviceProcessors(data.deviceId);
@@ -46116,6 +47369,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46116
47369
  this.trackStore?.clearDevice(deviceId);
46117
47370
  this.residents.closeDevice(deviceId);
46118
47371
  this.stationaryRegistry?.forgetDevice(deviceId);
47372
+ this.packageStillness.forgetDevice(deviceId);
47373
+ this.packageDropDetector?.forgetDevice(deviceId);
46119
47374
  this.overlayState.clearDevice(deviceId);
46120
47375
  this.overlaySynthesisWarnAt.delete(deviceId);
46121
47376
  this.forgetDeviceProcessors(deviceId);
@@ -46403,7 +47658,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46403
47658
  return;
46404
47659
  }
46405
47660
  this.dropoutSkipsByKey.set(key, 0);
46406
- if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
47661
+ if (source === "pipeline" && this.stationaryRegistry) {
47662
+ await this.resolveDeviceStationarySettings(deviceId);
47663
+ await this.resolveDevicePackageDropSettings(deviceId);
47664
+ }
46407
47665
  const processor = await this.getOrCreateProcessor(deviceId, source);
46408
47666
  const proxy = await this.ensureProxy(deviceId);
46409
47667
  const liveZones = await this.deviceZones.resolve(deviceId);
@@ -46415,15 +47673,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46415
47673
  timestamp: frame.timestamp,
46416
47674
  frame
46417
47675
  });
46418
- for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
46419
- tags: { deviceId },
46420
- meta: {
46421
- vehicleClass: r.vehicleClass,
46422
- personScore: r.personScore,
46423
- vehicleScore: r.vehicleScore,
46424
- overlap: r.overlap
46425
- }
46426
- });
46427
47676
  for (const r of processor.getLastLabelWriteRefusals()) {
46428
47677
  const message = r.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
46429
47678
  this.ctx.logger.info(message, {
@@ -46434,6 +47683,29 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46434
47683
  }
46435
47684
  });
46436
47685
  }
47686
+ for (const fold of result.riderFolds) {
47687
+ const meta = {
47688
+ vehicleClass: fold.vehicleClass,
47689
+ overlap: fold.overlap,
47690
+ personScore: fold.personScore,
47691
+ vehicleScore: fold.vehicleScore
47692
+ };
47693
+ if (fold.trackId === void 0) {
47694
+ this.ctx.logger.info("rider folded but no surviving track emitted this frame", {
47695
+ tags: { deviceId },
47696
+ meta
47697
+ });
47698
+ continue;
47699
+ }
47700
+ this.ctx.logger.info("rider folded into its machine", {
47701
+ tags: { deviceId },
47702
+ meta: {
47703
+ ...meta,
47704
+ trackId: fold.trackId
47705
+ }
47706
+ });
47707
+ this.trackStore?.markRiderFolded(fold.trackId);
47708
+ }
46437
47709
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
46438
47710
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
46439
47711
  deviceId,
@@ -46659,6 +47931,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46659
47931
  this.lastActiveTrackIds.set(key, currentTrackIds);
46660
47932
  if (currentTrackIds.size > 0) this.lastTrackActivityMs.set(deviceId, Date.now());
46661
47933
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
47934
+ const packageSettings = this.packageDropSettingsFromCache(deviceId);
47935
+ const packageClasses = new Set(packageSettings.packageDropClassFilter);
46662
47936
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
46663
47937
  const dims = this.lastFrameDimsByDevice.get(deviceId);
46664
47938
  if (dims && dims.w > 0 && dims.h > 0) {
@@ -46668,6 +47942,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46668
47942
  windowMs: stationarySettings.promotionWindowMs
46669
47943
  };
46670
47944
  for (const t of result.tracked) {
47945
+ if (packageClasses.has(t.className)) continue;
46671
47946
  const active = this.trackStore.peekActive(t.trackId);
46672
47947
  if (!active) continue;
46673
47948
  const { promote } = evaluateStationaryPromotion({
@@ -46691,6 +47966,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46691
47966
  }
46692
47967
  }
46693
47968
  }
47969
+ if (source === "pipeline") this.runPackageStillness({
47970
+ deviceId,
47971
+ tracked: result.tracked,
47972
+ packageClasses,
47973
+ dwellSec: packageSettings.packageDropDwellSec,
47974
+ frameWidth: result.frameWidth,
47975
+ frameHeight: result.frameHeight,
47976
+ timestamp: result.timestamp
47977
+ });
46694
47978
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
46695
47979
  const dispatcher = this.detailDispatcher;
46696
47980
  const steps = detailSteps;
@@ -47240,6 +48524,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
47240
48524
  stationarySettingsFromCache(deviceId) {
47241
48525
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
47242
48526
  }
48527
+ /** Synchronous read of the per-device package-drop settings — warmed by
48528
+ * `processFrame` before the promotion loop, exactly like the stationary one. */
48529
+ packageDropSettingsFromCache(deviceId) {
48530
+ return this.packageDropSettingsCache.peek(deviceId) ?? PACKAGE_DROP_DEFAULTS;
48531
+ }
47243
48532
  async resolveDevicePackageDropSettings(deviceId) {
47244
48533
  return this.packageDropSettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolvePackageDropSettings));
47245
48534
  }
@@ -48381,6 +49670,73 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
48381
49670
  * tracker + store forget the track so its detections stop re-spawning tracks,
48382
49671
  * and the registry suppresses them from the next frame on.
48383
49672
  */
49673
+ /**
49674
+ * Feed this frame's package-class detections to the dedicated stillness
49675
+ * tracker and bridge its transitions into `PackageDropDetector` — the SAME
49676
+ * `onStationaryChange` contract the generic registry uses, so the detector
49677
+ * has no idea there are two producers.
49678
+ *
49679
+ * Called on every pipeline frame with the (possibly empty) sighting list:
49680
+ * an empty list is what advances the observed clock, and dropping the call
49681
+ * on quiet frames would mean a parcel that was taken away is never reported
49682
+ * as picked up.
49683
+ */
49684
+ runPackageStillness(input) {
49685
+ if (this.packageDropDetector === null) return;
49686
+ const sightings = [];
49687
+ for (const t of input.tracked) {
49688
+ if (!input.packageClasses.has(t.className)) continue;
49689
+ const keyFrameMediaKey = this.residents.keyFrameKey(t.trackId);
49690
+ sightings.push({
49691
+ className: t.className,
49692
+ bbox: { ...t.bbox },
49693
+ trackId: t.trackId,
49694
+ confidence: t.confidence,
49695
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
49696
+ });
49697
+ }
49698
+ const outcome = this.packageStillness.observe({
49699
+ deviceId: input.deviceId,
49700
+ sightings,
49701
+ frameWidth: input.frameWidth,
49702
+ frameHeight: input.frameHeight,
49703
+ timestamp: input.timestamp,
49704
+ minDwellMs: input.dwellSec * 1e3
49705
+ });
49706
+ for (const entry of outcome.promoted) {
49707
+ this.ctx.logger.info("package promoted to stationary (dedicated path)", {
49708
+ tags: { deviceId: input.deviceId },
49709
+ meta: {
49710
+ entryId: entry.id,
49711
+ className: entry.className,
49712
+ bbox: entry.bbox,
49713
+ dwellMs: entry.becameStationaryAt - entry.firstSeenAt,
49714
+ ...entry.sourceTrackId !== void 0 ? { trackId: entry.sourceTrackId } : {},
49715
+ ...entry.confidence !== void 0 ? { confidence: entry.confidence } : {}
49716
+ }
49717
+ });
49718
+ this.packageDropDetector.onStationaryChange({
49719
+ phase: "appeared",
49720
+ entry,
49721
+ timestamp: entry.becameStationaryAt
49722
+ });
49723
+ }
49724
+ for (const entry of outcome.departed) {
49725
+ this.ctx.logger.info("package no longer observed (dedicated path)", {
49726
+ tags: { deviceId: input.deviceId },
49727
+ meta: {
49728
+ entryId: entry.id,
49729
+ className: entry.className,
49730
+ unseenForMs: input.timestamp - entry.lastConfirmedAt
49731
+ }
49732
+ });
49733
+ this.packageDropDetector.onStationaryChange({
49734
+ phase: "departed",
49735
+ entry,
49736
+ timestamp: input.timestamp
49737
+ });
49738
+ }
49739
+ }
48384
49740
  promoteToStationary(input) {
48385
49741
  const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
48386
49742
  const keyFrameMediaKey = this.residents.keyFrameKey(track.trackId);