@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.
@@ -1,4 +1,4 @@
1
- import { $ as plateGalleryCapability, A as TIMELAPSE_DENSE_FLOOR_SEC, B as cosineSimilarity, C as NcSnoozeSchema, Ct as string, D as OpsLogEntrySchema, E as NcTaxonomySchema, F as addonWidgetsSourceCapability, G as encodeVectorBase64, H as defineCustomActions, I as alarmPanelCapability, J as isDetectionMacroClass, K as faceGalleryCapability, L as assertTimelapseCadences, M as TimelapseRulePatchSchema, N as TimelapseRuleSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, P as TrackSourceSchema, Q as pipelineAnalyticsCapability, R as audioMetricsCapability, S as NcSnoozeInputSchema, St as record, T as NcSystemEventKindSchema, Tt as EventCategory, U as deriveRecordingMode, V as customAction, X as kebabToCamel, Y as isScheduleActive, Z as notificationRulesCapability, _ as NcRuleInputSchema, _t as discriminatedUnion, at as zoneAnalyticsCapability, b as NcRuleTargetSchema, bt as object, c as EVENT_PAD_MS, ct as DeviceType, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as isDeviceScopedCap, et as readDeviceStateFrom, f as NC_CONDITION_CATALOG, ft as nodePin, g as NcConditionDescriptorSchema, gt as boolean, h as NC_TAXONOMY, ht as array, i as DETECTION_MACRO_CLASSES, it as videoclipsCapability, j as TimelapseRuleInputSchema, k as RetrainStatusSchema, l as LabelAttributionSchema, lt as createEvent, mt as _enum, n as DEFAULT_EVENT_COLOR, nt as subKindsOf, o as DeclaredDevices, ot as errMsg, p as NC_DEFAULT_SNOOZE_MINUTES, pt as sleep, rt as vectorDimFromBase64, s as EVENT_KIND_BY_CAP, st as BaseAddon, t as BaseDevice, tt as readTimelapseGeneratedAt, u as MACRO_LABELS, ut as hydrateSchema, v as NcRulePatchSchema, vt as literal, w as NcSnoozeSuppressedSchema, wt as unknown, x as NcScheduleSchema, xt as partialRecord, y as NcRuleSchema, yt as number, z as buildEventKindDescriptor } from "../dist-_D0jdUh4.mjs";
1
+ import { $ as plateGalleryCapability, A as TIMELAPSE_DENSE_FLOOR_SEC, B as cosineSimilarity, C as NcSnoozeSchema, Ct as string, D as OpsLogEntrySchema, E as NcTaxonomySchema, F as addonWidgetsSourceCapability, G as encodeVectorBase64, H as defineCustomActions, I as alarmPanelCapability, J as isDetectionMacroClass, K as faceGalleryCapability, L as assertTimelapseCadences, M as TimelapseRulePatchSchema, N as TimelapseRuleSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, P as TrackSourceSchema, Q as pipelineAnalyticsCapability, R as audioMetricsCapability, S as NcSnoozeInputSchema, St as record, T as NcSystemEventKindSchema, Tt as EventCategory, U as deriveRecordingMode, V as customAction, X as kebabToCamel, Y as isScheduleActive, Z as notificationRulesCapability, _ as NcRuleInputSchema, _t as discriminatedUnion, at as zoneAnalyticsCapability, b as NcRuleTargetSchema, bt as object, c as EVENT_PAD_MS, ct as DeviceType, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as isDeviceScopedCap, et as readDeviceStateFrom, f as NC_CONDITION_CATALOG, ft as nodePin, g as NcConditionDescriptorSchema, gt as boolean, h as NC_TAXONOMY, ht as array, i as DETECTION_MACRO_CLASSES, it as videoclipsCapability, j as TimelapseRuleInputSchema, k as RetrainStatusSchema, l as LabelAttributionSchema, lt as createEvent, mt as _enum, n as DEFAULT_EVENT_COLOR, nt as subKindsOf, o as DeclaredDevices, ot as errMsg, p as NC_DEFAULT_SNOOZE_MINUTES, pt as sleep, rt as vectorDimFromBase64, s as EVENT_KIND_BY_CAP, st as BaseAddon, t as BaseDevice, tt as readTimelapseGeneratedAt, u as MACRO_LABELS, ut as hydrateSchema, v as NcRulePatchSchema, vt as literal, w as NcSnoozeSuppressedSchema, wt as unknown, x as NcScheduleSchema, xt as partialRecord, y as NcRuleSchema, yt as number, z as buildEventKindDescriptor } from "../dist-CvWsGsxP.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1574,6 +1574,11 @@ var NC_ALARM_DECLARATION = {
1574
1574
  };
1575
1575
  //#endregion
1576
1576
  //#region src/notification-center/artifact-index.ts
1577
+ /**
1578
+ * @durable class=ledger owner=notification-center
1579
+ * write="one row per RETAINED artefact, written once at publish; expiresAt patched never"
1580
+ * 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)"
1581
+ */
1577
1582
  var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1578
1583
  var NcArtifactKindSchema = _enum([
1579
1584
  "timelapse",
@@ -5008,13 +5013,27 @@ function attachmentKindPreference(policy, ownerKind) {
5008
5013
  * scene is the honest fallback (same picture, no annotation) — never a subject
5009
5014
  * crop, which shows something else entirely.
5010
5015
  *
5016
+ * **`boxed` used to decide nothing.** Its first rung was `fullFrameBoxed`, a
5017
+ * kind RETIRED for new tracks on 2026-07-21 (`event-media-dispatcher.ts`), and
5018
+ * the rest of the ladder was the clean `keyFrame` family — so an authorable
5019
+ * option degraded, silently and always, to exactly what `full` returns. A knob
5020
+ * that decides nothing is worse than no knob: the operator set it and believed
5021
+ * they had asked for something.
5022
+ *
5023
+ * The annotated frames were there the whole time under different names.
5024
+ * `firstFrame` / `snapshot` / `lastFrame` are written by `boxedTimelineFrame`
5025
+ * — the track's OWN box burned onto the native full frame — so `boxed` now asks
5026
+ * for them, newest-moment-first-per-family, before degrading to the clean
5027
+ * scene. No new capture path, no new kind, and the option answers its own
5028
+ * question again.
5029
+ *
5011
5030
  * The strictness is about FAMILY, not about which moment of the track the
5012
- * frame is from: every rung of the `full`/track ladder is a clean, unannotated
5013
- * whole scene from this camera. `lastFrame` is `firstFrame`'s twin — the media
5014
- * schema defines them together, "full-frame snapshots captured at a track's
5015
- * start and end" — and `snapshot` is the same picture again. A live audit found
5016
- * a track holding ONLY a `lastFrame` ship text-only: the honest answer to the
5017
- * rule's question was stored and the ladder stopped one rung short of it.
5031
+ * frame is from. On the `full`/track ladder, `keyFrame`/`keyFrameSmall` are the
5032
+ * clean whole scene; `firstFrame`/`lastFrame`/`snapshot` are the same whole
5033
+ * scene WITH the track's own box on it, which is why they sit below the clean
5034
+ * pair and above nothing. (They are on this ladder because a live audit found a
5035
+ * track holding ONLY a `lastFrame` ship text-only: an annotated whole scene is
5036
+ * a far better answer to "what is going on" than no picture at all.)
5018
5037
  */
5019
5038
  function framePreference(frame, ownerKind) {
5020
5039
  if (frame === "cropped") return ownerKind === "track" ? [
@@ -5028,6 +5047,9 @@ function framePreference(frame, ownerKind) {
5028
5047
  ];
5029
5048
  if (frame === "boxed") return [
5030
5049
  "fullFrameBoxed",
5050
+ "snapshot",
5051
+ "lastFrame",
5052
+ "firstFrame",
5031
5053
  "keyFrame",
5032
5054
  "keyFrameSmall",
5033
5055
  "fullFrame"
@@ -5123,6 +5145,26 @@ var en_default = {
5123
5145
  "other": "plates"
5124
5146
  },
5125
5147
  "class.other": "{{name}}",
5148
+ "group.others.person": {
5149
+ "one": "1 other person",
5150
+ "other": "{{count}} other people"
5151
+ },
5152
+ "group.others.vehicle": {
5153
+ "one": "1 other vehicle",
5154
+ "other": "{{count}} other vehicles"
5155
+ },
5156
+ "group.others.animal": {
5157
+ "one": "1 other animal",
5158
+ "other": "{{count}} other animals"
5159
+ },
5160
+ "group.others.package": {
5161
+ "one": "1 other package",
5162
+ "other": "{{count}} other packages"
5163
+ },
5164
+ "group.others.other": {
5165
+ "one": "1 other {{classNoun}}",
5166
+ "other": "{{count}} other {{classNoun}}"
5167
+ },
5126
5168
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
5127
5169
  "occupancy.op.occupied": "occupied",
5128
5170
  "occupancy.op.free": "free",
@@ -5146,6 +5188,13 @@ var en_default = {
5146
5188
  "system.node-offline.title": "Node offline",
5147
5189
  "system.node-offline.body.agent": "Agent {{nodeId}} is offline",
5148
5190
  "system.node-offline.body.worker": "Worker {{nodeId}} is offline",
5191
+ "system.node-inference-unavailable.title": "Node has no usable inference device",
5192
+ "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.",
5193
+ "system.detection-blind.title": "Camera detecting nothing",
5194
+ "system.detection-blind.body": {
5195
+ "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
5196
+ "other": "Camera {{deviceName}} has been analysed for {{minutes}} minutes with no detection at all"
5197
+ },
5149
5198
  "system.addon-update-available.title": "Addon update available",
5150
5199
  "system.addon-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
5151
5200
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
@@ -5290,6 +5339,26 @@ var it_default = {
5290
5339
  "other": "targhe"
5291
5340
  },
5292
5341
  "class.other": "{{name}}",
5342
+ "group.others.person": {
5343
+ "one": "1 altra persona",
5344
+ "other": "{{count}} altre persone"
5345
+ },
5346
+ "group.others.vehicle": {
5347
+ "one": "1 altro veicolo",
5348
+ "other": "{{count}} altri veicoli"
5349
+ },
5350
+ "group.others.animal": {
5351
+ "one": "1 altro animale",
5352
+ "other": "{{count}} altri animali"
5353
+ },
5354
+ "group.others.package": {
5355
+ "one": "1 altro pacco",
5356
+ "other": "{{count}} altri pacchi"
5357
+ },
5358
+ "group.others.other": {
5359
+ "one": "1 altro {{classNoun}}",
5360
+ "other": "{{count}} altri {{classNoun}}"
5361
+ },
5293
5362
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
5294
5363
  "occupancy.op.occupied": "occupata",
5295
5364
  "occupancy.op.free": "libera",
@@ -5313,6 +5382,13 @@ var it_default = {
5313
5382
  "system.node-offline.title": "Nodo offline",
5314
5383
  "system.node-offline.body.agent": "L'agent {{nodeId}} è offline",
5315
5384
  "system.node-offline.body.worker": "Il worker {{nodeId}} è offline",
5385
+ "system.node-inference-unavailable.title": "Nodo senza dispositivi di inferenza utilizzabili",
5386
+ "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.",
5387
+ "system.detection-blind.title": "Camera che non rileva nulla",
5388
+ "system.detection-blind.body": {
5389
+ "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
5390
+ "other": "La camera {{deviceName}} è analizzata da {{minutes}} minuti senza alcun rilevamento"
5391
+ },
5316
5392
  "system.addon-update-available.title": "Aggiornamento addon disponibile",
5317
5393
  "system.addon-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
5318
5394
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
@@ -5567,6 +5643,149 @@ var NcTextCatalog = class {
5567
5643
  }
5568
5644
  };
5569
5645
  //#endregion
5646
+ //#region src/shared/frame/box-drawer.ts
5647
+ var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
5648
+ var DEFAULT_QUALITY = 80;
5649
+ var STROKE_WIDTH = 3;
5650
+ function escapeXml$2(s) {
5651
+ return s.replace(/[<>&'"]/g, (ch) => {
5652
+ switch (ch) {
5653
+ case "<": return "&lt;";
5654
+ case ">": return "&gt;";
5655
+ case "&": return "&amp;";
5656
+ case "'": return "&apos;";
5657
+ default: return "&quot;";
5658
+ }
5659
+ });
5660
+ }
5661
+ /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
5662
+ function clampBox(b, frameWidth, frameHeight) {
5663
+ const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
5664
+ const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
5665
+ return {
5666
+ x,
5667
+ y,
5668
+ w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
5669
+ h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
5670
+ };
5671
+ }
5672
+ /**
5673
+ * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
5674
+ *
5675
+ * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
5676
+ * optional caption) at full resolution, then optionally downscaled — so the
5677
+ * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
5678
+ * avoid sharp `extract`/region errors on slightly-out-of-range detections.
5679
+ */
5680
+ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
5681
+ const quality = opts.quality ?? DEFAULT_QUALITY;
5682
+ let base = frameData;
5683
+ let baseIsRaw = true;
5684
+ if (boxes.length > 0) {
5685
+ const fontSize = Math.max(12, Math.round(frameHeight / 30));
5686
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
5687
+ const c = clampBox(b, frameWidth, frameHeight);
5688
+ const color = b.color ?? DEFAULT_COLOR;
5689
+ const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
5690
+ if (!b.label) return rect;
5691
+ const ty = Math.max(fontSize, c.y - 4);
5692
+ 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>`;
5693
+ }).join("")}</svg>`;
5694
+ base = await sharp(frameData, { raw: {
5695
+ width: frameWidth,
5696
+ height: frameHeight,
5697
+ channels: 3
5698
+ } }).composite([{
5699
+ input: Buffer.from(svg),
5700
+ top: 0,
5701
+ left: 0
5702
+ }]).png().toBuffer();
5703
+ baseIsRaw = false;
5704
+ }
5705
+ let pipeline = baseIsRaw ? sharp(base, { raw: {
5706
+ width: frameWidth,
5707
+ height: frameHeight,
5708
+ channels: 3
5709
+ } }) : sharp(base);
5710
+ if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
5711
+ width: opts.maxWidth,
5712
+ withoutEnlargement: true
5713
+ });
5714
+ return pipeline.jpeg({ quality }).toBuffer();
5715
+ }
5716
+ /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
5717
+ var GROUP_FRAME_QUALITY = 82;
5718
+ /**
5719
+ * Draw the members onto the frame. Returns `null` when there is nothing to
5720
+ * draw, or when anything at all goes wrong — the caller then ships the
5721
+ * unannotated frame unchanged.
5722
+ */
5723
+ async function composeGroupFrame(input) {
5724
+ const freshnessMs = input.freshnessMs ?? 3e3;
5725
+ const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
5726
+ const withheld = input.boxes.length - fresh.length;
5727
+ if (fresh.length < 2) {
5728
+ if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
5729
+ tags: { deviceId: input.deviceId },
5730
+ meta: {
5731
+ groupKey: input.groupKey,
5732
+ members: input.boxes.length,
5733
+ fresh: fresh.length,
5734
+ withheld
5735
+ }
5736
+ });
5737
+ return null;
5738
+ }
5739
+ try {
5740
+ const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
5741
+ const { width, height, channels } = decoded.info;
5742
+ if (!(width > 0 && height > 0) || channels !== 3) {
5743
+ input.logger.warn("group frame not drawn — unexpected raster shape", {
5744
+ tags: { deviceId: input.deviceId },
5745
+ meta: {
5746
+ groupKey: input.groupKey,
5747
+ width,
5748
+ height,
5749
+ channels
5750
+ }
5751
+ });
5752
+ return null;
5753
+ }
5754
+ const boxes = fresh.map((b) => ({
5755
+ x: b.bbox.x * width,
5756
+ y: b.bbox.y * height,
5757
+ w: b.bbox.w * width,
5758
+ h: b.bbox.h * height,
5759
+ ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
5760
+ }));
5761
+ const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
5762
+ input.logger.debug("group frame composed", {
5763
+ tags: { deviceId: input.deviceId },
5764
+ meta: {
5765
+ groupKey: input.groupKey,
5766
+ drawn: boxes.length,
5767
+ withheld,
5768
+ width,
5769
+ height
5770
+ }
5771
+ });
5772
+ return {
5773
+ bytes,
5774
+ drawn: boxes.length,
5775
+ withheld
5776
+ };
5777
+ } catch (err) {
5778
+ input.logger.warn("group frame compose failed — shipping the unannotated frame", {
5779
+ tags: { deviceId: input.deviceId },
5780
+ meta: {
5781
+ groupKey: input.groupKey,
5782
+ error: err instanceof Error ? err.message : String(err)
5783
+ }
5784
+ });
5785
+ return null;
5786
+ }
5787
+ }
5788
+ //#endregion
5570
5789
  //#region src/notification-center/text-compose.ts
5571
5790
  /** One normalisation, used on both sides of every class lookup — a pipeline
5572
5791
  * that emitted `Person` must find the key written `class.person`. */
@@ -5652,6 +5871,70 @@ function ncDetectionSummary(texts, counts) {
5652
5871
  return texts.join(parts);
5653
5872
  }
5654
5873
  //#endregion
5874
+ //#region src/notification-center/group/nc-group-text.ts
5875
+ var DEFAULT_MAX_NAMES = 2;
5876
+ /**
5877
+ * `N altre persone` — the remainder phrase for one class, in its own gender.
5878
+ *
5879
+ * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
5880
+ * locale names falls through to `group.others.other`, which prints the raw class
5881
+ * name rather than vanishing. A custom model's class must appear in the sentence.
5882
+ */
5883
+ function ncGroupOthers(texts, className, count) {
5884
+ const classNoun = ncClassNoun(texts, className, count);
5885
+ const key = `group.others.${className.trim().toLowerCase()}`;
5886
+ if (texts.has(key)) return texts.text({
5887
+ key,
5888
+ count,
5889
+ vars: {
5890
+ count: `${count}`,
5891
+ classNoun
5892
+ }
5893
+ });
5894
+ return texts.text({
5895
+ key: "group.others.other",
5896
+ count,
5897
+ vars: {
5898
+ count: `${count}`,
5899
+ classNoun
5900
+ }
5901
+ });
5902
+ }
5903
+ /** Counts per class, in first-seen order so the same burst always reads the same. */
5904
+ function countByClass(members) {
5905
+ const counts = /* @__PURE__ */ new Map();
5906
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
5907
+ return [...counts].map(([className, count]) => ({
5908
+ className,
5909
+ count
5910
+ }));
5911
+ }
5912
+ /**
5913
+ * The finished `{{subject}}` phrase for a group.
5914
+ *
5915
+ * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
5916
+ * other composer in `text-compose.ts`, and for the same reason.
5917
+ */
5918
+ function ncGroupSubjectText(texts, input) {
5919
+ const members = input.members;
5920
+ const only = members.length === 1 ? members[0] : void 0;
5921
+ if (only !== void 0) return ncSubjectText(texts, {
5922
+ className: only.className,
5923
+ ...only.label !== void 0 ? { label: only.label } : {},
5924
+ ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
5925
+ });
5926
+ if (members.length === 0) return "";
5927
+ const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
5928
+ const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
5929
+ const names = named.slice(0, maxNames).map((m) => m.label ?? "");
5930
+ if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
5931
+ const spoken = new Set(named.slice(0, maxNames));
5932
+ const remainder = members.filter((m) => !spoken.has(m));
5933
+ if (remainder.length === 0) return texts.join(names);
5934
+ const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
5935
+ return texts.join([...names, ...others]);
5936
+ }
5937
+ //#endregion
5655
5938
  //#region src/notification-center/dispatcher.ts
5656
5939
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
5657
5940
  /**
@@ -6138,7 +6421,7 @@ var NcDispatcher = class {
6138
6421
  title,
6139
6422
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
6140
6423
  ...params,
6141
- tag: entry.ruleId,
6424
+ tag: entry.payload.group?.key ?? entry.ruleId,
6142
6425
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
6143
6426
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
6144
6427
  ...systemEvent !== void 0 ? { metadata: {
@@ -6224,7 +6507,14 @@ var NcDispatcher = class {
6224
6507
  bytes: cropped,
6225
6508
  name: "zone.jpg"
6226
6509
  } : still);
6227
- } else out.push(still);
6510
+ } else {
6511
+ const grouped = await this.groupFrame(entry, still.bytes);
6512
+ out.push(grouped !== null ? {
6513
+ ...still,
6514
+ bytes: grouped,
6515
+ name: "group.jpg"
6516
+ } : still);
6517
+ }
6228
6518
  } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
6229
6519
  tags: { deviceId: entry.payload.subject.deviceId },
6230
6520
  meta: {
@@ -6390,6 +6680,47 @@ var NcDispatcher = class {
6390
6680
  return null;
6391
6681
  }
6392
6682
  }
6683
+ /**
6684
+ * Draw the group's members onto the still this row already resolved.
6685
+ *
6686
+ * Produced at SEND, like every other media decision here, and returned as
6687
+ * BYTES: an annotated frame is a delivery artefact and must never enter the
6688
+ * media store as a track-owned kind (`retrain-frame-selection.ts` calls a
6689
+ * boxed frame "worse than useless" as model input, and the mosaic renderer
6690
+ * refuses boxes because a burnt-in box is permanent).
6691
+ *
6692
+ * Null on anything less than two drawable members, or on any failure at all —
6693
+ * the caller then ships the frame it already had. A group notification with a
6694
+ * plain picture is a small loss; one that does not arrive is the feature.
6695
+ */
6696
+ async groupFrame(entry, jpeg) {
6697
+ const group = entry.payload.group;
6698
+ if (group === void 0 || group.members.length < 2) return null;
6699
+ const boxes = group.members.filter((m) => m.bbox !== void 0).map((m) => ({
6700
+ bbox: m.bbox ?? {
6701
+ x: 0,
6702
+ y: 0,
6703
+ w: 0,
6704
+ h: 0
6705
+ },
6706
+ label: m.label ?? ncClassNoun(this.texts, m.className, 1),
6707
+ observedAt: m.at
6708
+ }));
6709
+ if (boxes.length < 2) return null;
6710
+ const anchor = group.members.find((m) => m.trackId === group.anchorTrackId);
6711
+ const composed = await composeGroupFrame({
6712
+ jpeg: Buffer.from(jpeg),
6713
+ boxes,
6714
+ at: anchor?.at ?? Math.max(...group.members.map((m) => m.at)),
6715
+ logger: this.deps.logger,
6716
+ deviceId: entry.payload.subject.deviceId,
6717
+ groupKey: group.key
6718
+ });
6719
+ if (composed === null) return null;
6720
+ const bytes = new Uint8Array(composed.bytes.byteLength);
6721
+ bytes.set(composed.bytes);
6722
+ return bytes;
6723
+ }
6393
6724
  async resolveAttachment(entry, policyOverride) {
6394
6725
  const policy = policyOverride ?? entry.payload.media;
6395
6726
  if (policy === "none") return null;
@@ -6471,11 +6802,21 @@ function percentOf(confidence) {
6471
6802
  * params go last because they are the row's own truth about a system event and
6472
6803
  * outrank the live vocabulary for exactly those names.
6473
6804
  */
6805
+ /** Class composition of a group, busiest classes in first-seen order. */
6806
+ function classCounts(members) {
6807
+ const counts = /* @__PURE__ */ new Map();
6808
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
6809
+ return [...counts].map(([className, count]) => ({
6810
+ className,
6811
+ count
6812
+ }));
6813
+ }
6474
6814
  function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
6475
6815
  const subject = entry.payload.subject;
6476
6816
  const occupancy = subject.occupancy;
6477
6817
  const systemEvent = subject.systemEvent;
6478
6818
  const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
6819
+ const group = entry.payload.group;
6479
6820
  return {
6480
6821
  camera: deviceName,
6481
6822
  class: detection?.className ?? subject.className,
@@ -6484,18 +6825,19 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
6484
6825
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
6485
6826
  className: detection.className,
6486
6827
  ...detection.label !== void 0 ? { label: detection.label } : {}
6487
- }) : systemEvent?.subject ?? ncSubjectText(texts, {
6828
+ }) : (group !== void 0 && group.members.length > 1 ? ncGroupSubjectText(texts, { members: group.members }) : void 0) ?? systemEvent?.subject ?? ncSubjectText(texts, {
6488
6829
  className: subject.className,
6489
6830
  ...subject.label !== void 0 ? { label: subject.label } : {},
6490
6831
  ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
6491
6832
  }),
6833
+ detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
6492
6834
  inZones: ncInZonesText(texts, zoneLabels),
6493
6835
  scope: occupancy?.zone ?? deviceName,
6494
6836
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
6495
6837
  confidence: percentOf(detection?.confidence ?? subject.confidence),
6496
6838
  time: new Date(subject.timestamp).toLocaleTimeString(),
6497
6839
  rule: entry.payload.ruleName,
6498
- count: occupancy !== void 0 ? `${occupancy.count}` : "",
6840
+ count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
6499
6841
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
6500
6842
  op: occupancy !== void 0 ? ncOccupancyOp(texts, occupancy.occupied) : "",
6501
6843
  eventKind: systemEvent?.kind ?? "",
@@ -7003,6 +7345,45 @@ function incomingFromSystemEvent(event, lookup) {
7003
7345
  nodeId
7004
7346
  });
7005
7347
  }
7348
+ case "pipeline.node-inference-unavailable": {
7349
+ const nodeId = stringField(data, "nodeId");
7350
+ if (nodeId === void 0) return null;
7351
+ const keys = data["unavailableDeviceKeys"];
7352
+ const devices = Array.isArray(keys) ? keys.filter((k) => typeof k === "string") : [];
7353
+ return systemSubject({
7354
+ facts,
7355
+ kind: "node-inference-unavailable",
7356
+ subject: `node-inference:${nodeId}`,
7357
+ titleKey: "system.node-inference-unavailable.title",
7358
+ bodyKey: "system.node-inference-unavailable.body",
7359
+ textParams: {
7360
+ nodeId,
7361
+ devices: devices.join(", ")
7362
+ },
7363
+ nodeId
7364
+ });
7365
+ }
7366
+ case "pipeline.detection-blind": {
7367
+ const deviceId = numberField(data, "deviceId");
7368
+ const blindForMs = numberField(data, "blindForMs");
7369
+ if (deviceId === void 0 || blindForMs === void 0) return null;
7370
+ const identity = lookup(deviceId);
7371
+ const minutes = Math.max(1, Math.round(blindForMs / 6e4));
7372
+ return systemSubject({
7373
+ facts,
7374
+ kind: "detection-blind",
7375
+ subject: `detection-blind:${deviceId}`,
7376
+ titleKey: "system.detection-blind.title",
7377
+ bodyKey: "system.detection-blind.body",
7378
+ textParams: {
7379
+ ...deviceTextParams(deviceId, identity),
7380
+ minutes: String(minutes)
7381
+ },
7382
+ textCount: minutes,
7383
+ deviceId,
7384
+ ...identity !== void 0 ? { deviceType: identity.type } : {}
7385
+ });
7386
+ }
7006
7387
  case "update.available": {
7007
7388
  const target = stringField(data, "target");
7008
7389
  const packageName = stringField(data, "packageName");
@@ -8787,6 +9168,353 @@ function rowToEntry$1(id, data) {
8787
9168
  };
8788
9169
  }
8789
9170
  //#endregion
9171
+ //#region src/notification-center/group/nc-group-buffer.ts
9172
+ var DEFAULT_MAX_MEMBERS = 12;
9173
+ var NcGroupBuffer = class NcGroupBuffer {
9174
+ /** One OPEN group per `${ruleId}${deviceId}`. Sealed groups leave. */
9175
+ open = /* @__PURE__ */ new Map();
9176
+ maxMembers;
9177
+ constructor(options = {}) {
9178
+ this.maxMembers = options.maxMembers !== void 0 && options.maxMembers > 0 ? options.maxMembers : DEFAULT_MAX_MEMBERS;
9179
+ }
9180
+ static slot(ruleId, deviceId) {
9181
+ return `${ruleId}${deviceId}`;
9182
+ }
9183
+ /** Currently open (unsealed) groups — for the store mirror and for tests. */
9184
+ openGroups() {
9185
+ return [...this.open.values()];
9186
+ }
9187
+ /** The open group for this rule + camera, if any. */
9188
+ peek(ruleId, deviceId) {
9189
+ return this.open.get(NcGroupBuffer.slot(ruleId, deviceId));
9190
+ }
9191
+ /**
9192
+ * Offer a matching subject to its group.
9193
+ *
9194
+ * Never throws and never blocks: it is called from inside the serialized
9195
+ * evaluation chain, where a failure would cost the notification itself.
9196
+ */
9197
+ admit(input, now) {
9198
+ if (!(input.idleSec > 0)) return { outcome: "disabled" };
9199
+ const slot = NcGroupBuffer.slot(input.ruleId, input.deviceId);
9200
+ const existing = this.open.get(slot);
9201
+ const idleMs = input.idleSec * 1e3;
9202
+ if (existing !== void 0 && now - existing.lastActivityAt >= idleMs) {
9203
+ this.open.delete(slot);
9204
+ return this.openGroup(slot, input, now);
9205
+ }
9206
+ if (existing === void 0) return this.openGroup(slot, input, now);
9207
+ const at = existing.members.findIndex((m) => m.trackId === input.member.trackId);
9208
+ if (at === -1) {
9209
+ if (existing.members.length >= this.maxMembers) {
9210
+ this.open.set(slot, {
9211
+ ...existing,
9212
+ lastActivityAt: now
9213
+ });
9214
+ return {
9215
+ outcome: "refused",
9216
+ groupKey: existing.key,
9217
+ memberCount: existing.members.length
9218
+ };
9219
+ }
9220
+ const grown = {
9221
+ ...existing,
9222
+ members: [...existing.members, input.member],
9223
+ revision: existing.revision + 1,
9224
+ lastGrowthAt: now,
9225
+ lastActivityAt: now,
9226
+ anchorTrackId: input.member.trackId
9227
+ };
9228
+ this.open.set(slot, grown);
9229
+ return {
9230
+ outcome: "grown",
9231
+ group: {
9232
+ ...grown,
9233
+ reason: "member"
9234
+ }
9235
+ };
9236
+ }
9237
+ const previous = existing.members[at];
9238
+ const named = input.member.label !== void 0 && input.member.label !== previous.label;
9239
+ const refreshed = {
9240
+ ...previous,
9241
+ ...input.member.label !== void 0 ? {
9242
+ label: input.member.label,
9243
+ ...input.member.labelKind !== void 0 ? { labelKind: input.member.labelKind } : {}
9244
+ } : {},
9245
+ ...input.member.bbox !== void 0 ? { bbox: input.member.bbox } : {},
9246
+ eventId: input.member.eventId,
9247
+ at: now
9248
+ };
9249
+ const members = existing.members.map((m, i) => i === at ? refreshed : m);
9250
+ const updated = {
9251
+ ...existing,
9252
+ members,
9253
+ lastActivityAt: now,
9254
+ ...named ? {
9255
+ revision: existing.revision + 1,
9256
+ lastGrowthAt: now,
9257
+ anchorTrackId: refreshed.trackId
9258
+ } : {}
9259
+ };
9260
+ this.open.set(slot, updated);
9261
+ return named ? {
9262
+ outcome: "grown",
9263
+ group: {
9264
+ ...updated,
9265
+ reason: "name"
9266
+ }
9267
+ } : {
9268
+ outcome: "unchanged",
9269
+ group: updated
9270
+ };
9271
+ }
9272
+ /**
9273
+ * Close every group whose idle cutoff has elapsed, and report them ONCE.
9274
+ *
9275
+ * A close is a transition, not a state: re-reporting it every tick would turn
9276
+ * one arrival into an unbounded stream of "group closed" lines. `idleSec` is
9277
+ * carried on the group so a rule edited mid-burst does not change the cutoff
9278
+ * of a burst already open.
9279
+ */
9280
+ sweep(now) {
9281
+ const closed = [];
9282
+ for (const [slot, group] of this.open) {
9283
+ if (now - group.lastActivityAt < group.idleMs) continue;
9284
+ this.open.delete(slot);
9285
+ closed.push({
9286
+ ...group,
9287
+ sealed: true
9288
+ });
9289
+ }
9290
+ return closed;
9291
+ }
9292
+ openGroup(slot, input, now) {
9293
+ const group = {
9294
+ key: `g:${input.ruleId}:d:${input.deviceId}:${now}`,
9295
+ ruleId: input.ruleId,
9296
+ deviceId: input.deviceId,
9297
+ openedAt: now,
9298
+ lastGrowthAt: now,
9299
+ lastActivityAt: now,
9300
+ revision: 1,
9301
+ members: [input.member],
9302
+ anchorTrackId: input.member.trackId,
9303
+ sealed: false,
9304
+ idleMs: input.idleSec * 1e3
9305
+ };
9306
+ this.open.set(slot, group);
9307
+ return {
9308
+ outcome: "opened",
9309
+ group
9310
+ };
9311
+ }
9312
+ };
9313
+ //#endregion
9314
+ //#region src/notification-center/group/nc-group-store.ts
9315
+ /**
9316
+ * @durable class=audit owner=notification-center
9317
+ * write="one row per burst at OPEN, patched at each growth and at close, best-effort"
9318
+ * retention="ring — the newest NC_GROUP_MAX_ROWS_PER_DEVICE rows per device, pruned on write; never age-keyed"
9319
+ */
9320
+ var NC_GROUPS_COLLECTION = "notification-center:track-groups";
9321
+ var NC_GROUPS_COLUMNS = [
9322
+ {
9323
+ name: "id",
9324
+ type: "TEXT",
9325
+ primaryKey: true,
9326
+ notNull: true
9327
+ },
9328
+ {
9329
+ name: "ruleId",
9330
+ type: "TEXT",
9331
+ notNull: true
9332
+ },
9333
+ {
9334
+ name: "deviceId",
9335
+ type: "INTEGER",
9336
+ notNull: true
9337
+ },
9338
+ {
9339
+ name: "openedAt",
9340
+ type: "INTEGER",
9341
+ notNull: true
9342
+ },
9343
+ {
9344
+ name: "lastGrowthAt",
9345
+ type: "INTEGER",
9346
+ notNull: true
9347
+ },
9348
+ (
9349
+ /** JSON `string[]` — the members, in admission order. */
9350
+ {
9351
+ name: "memberTrackIds",
9352
+ type: "JSON",
9353
+ notNull: true
9354
+ }),
9355
+ {
9356
+ name: "memberCount",
9357
+ type: "INTEGER",
9358
+ notNull: true
9359
+ },
9360
+ (
9361
+ /** How many notifications this group has produced (1 at open, +1 per growth). */
9362
+ {
9363
+ name: "revision",
9364
+ type: "INTEGER",
9365
+ notNull: true
9366
+ }),
9367
+ {
9368
+ name: "sealed",
9369
+ type: "BOOLEAN",
9370
+ notNull: true
9371
+ }
9372
+ ];
9373
+ var NC_GROUPS_INDEXES = [{
9374
+ name: "idx_nc_groups_device_opened",
9375
+ columns: ["deviceId", "openedAt"]
9376
+ }, {
9377
+ name: "idx_nc_groups_rule",
9378
+ columns: ["ruleId"]
9379
+ }];
9380
+ /** Read cap, so a client cannot ask for the whole ring in one call. */
9381
+ var MAX_QUERY_LIMIT = 200;
9382
+ var NcGroupStore = class {
9383
+ store;
9384
+ logger;
9385
+ /** Writes since the last prune, per device — the ring is trimmed lazily. */
9386
+ writesSincePrune = /* @__PURE__ */ new Map();
9387
+ constructor(deps) {
9388
+ this.store = deps.store;
9389
+ this.logger = deps.logger;
9390
+ }
9391
+ static async declare(store) {
9392
+ await store.declareCollection.mutate({
9393
+ collection: NC_GROUPS_COLLECTION,
9394
+ columns: [...NC_GROUPS_COLUMNS],
9395
+ indexes: [...NC_GROUPS_INDEXES]
9396
+ });
9397
+ }
9398
+ /**
9399
+ * Record a group as it stands. Idempotent on the group key, so open, every
9400
+ * growth and the close all write the SAME row — a burst is one record, never
9401
+ * one per revision.
9402
+ *
9403
+ * Best-effort by construction: this is an audit surface, and a failed write
9404
+ * must never cost the notification it is describing. It is called from the
9405
+ * serialized evaluation chain, so it is also deliberately not awaited there.
9406
+ */
9407
+ async record(group) {
9408
+ try {
9409
+ await this.store.set.mutate({
9410
+ collection: NC_GROUPS_COLLECTION,
9411
+ key: group.key,
9412
+ value: {
9413
+ ruleId: group.ruleId,
9414
+ deviceId: group.deviceId,
9415
+ openedAt: group.openedAt,
9416
+ lastGrowthAt: group.lastGrowthAt,
9417
+ memberTrackIds: group.members.map((m) => m.trackId),
9418
+ memberCount: group.members.length,
9419
+ revision: group.revision,
9420
+ sealed: group.sealed
9421
+ }
9422
+ });
9423
+ await this.pruneIfDue(group.deviceId);
9424
+ } catch (err) {
9425
+ this.logger.warn("group record write failed", {
9426
+ tags: { deviceId: group.deviceId },
9427
+ meta: {
9428
+ groupKey: group.key,
9429
+ error: err instanceof Error ? err.message : String(err)
9430
+ }
9431
+ });
9432
+ }
9433
+ }
9434
+ /** Newest first. The timeline card's read. */
9435
+ async list(query = {}) {
9436
+ const where = {};
9437
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
9438
+ if (query.ruleId !== void 0) where["ruleId"] = query.ruleId;
9439
+ try {
9440
+ return (await this.store.query.query({
9441
+ collection: NC_GROUPS_COLLECTION,
9442
+ filter: {
9443
+ ...Object.keys(where).length > 0 ? { where } : {},
9444
+ ...query.since !== void 0 ? { whereBetween: { openedAt: [query.since, Number.MAX_SAFE_INTEGER] } } : {},
9445
+ orderBy: {
9446
+ field: "openedAt",
9447
+ direction: "desc"
9448
+ },
9449
+ limit: Math.min(query.limit ?? 50, MAX_QUERY_LIMIT)
9450
+ }
9451
+ })).map((r) => rowToRecord(r.id, r.data));
9452
+ } catch (err) {
9453
+ this.logger.warn("group record read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
9454
+ return [];
9455
+ }
9456
+ }
9457
+ /**
9458
+ * Trim the ring for one camera, every {@link PRUNE_EVERY} writes.
9459
+ *
9460
+ * Lazy rather than per-write because the delete is a query + a bulk delete and
9461
+ * a busy camera writes several times per burst; amortising it keeps the write
9462
+ * path a single `set`.
9463
+ */
9464
+ async pruneIfDue(deviceId) {
9465
+ const n = (this.writesSincePrune.get(deviceId) ?? 0) + 1;
9466
+ if (n < PRUNE_EVERY) {
9467
+ this.writesSincePrune.set(deviceId, n);
9468
+ return;
9469
+ }
9470
+ this.writesSincePrune.set(deviceId, 0);
9471
+ const keep = await this.store.query.query({
9472
+ collection: NC_GROUPS_COLLECTION,
9473
+ filter: {
9474
+ where: { deviceId },
9475
+ orderBy: {
9476
+ field: "openedAt",
9477
+ direction: "desc"
9478
+ },
9479
+ limit: 500,
9480
+ offset: 500
9481
+ }
9482
+ });
9483
+ if (keep.length === 0) return;
9484
+ const cutoff = keep[0]?.data["openedAt"];
9485
+ if (typeof cutoff !== "number") return;
9486
+ const { deleted } = await this.store.deleteWhere.mutate({
9487
+ collection: NC_GROUPS_COLLECTION,
9488
+ filter: {
9489
+ where: { deviceId },
9490
+ whereBetween: { openedAt: [0, cutoff] }
9491
+ }
9492
+ });
9493
+ if (deleted > 0) this.logger.debug("group ring trimmed", {
9494
+ tags: { deviceId },
9495
+ meta: {
9496
+ deleted,
9497
+ keptPerDevice: 500
9498
+ }
9499
+ });
9500
+ }
9501
+ };
9502
+ var PRUNE_EVERY = 50;
9503
+ function rowToRecord(id, data) {
9504
+ const members = data["memberTrackIds"];
9505
+ return {
9506
+ id,
9507
+ ruleId: typeof data["ruleId"] === "string" ? data["ruleId"] : "",
9508
+ deviceId: typeof data["deviceId"] === "number" ? data["deviceId"] : 0,
9509
+ openedAt: typeof data["openedAt"] === "number" ? data["openedAt"] : 0,
9510
+ lastGrowthAt: typeof data["lastGrowthAt"] === "number" ? data["lastGrowthAt"] : 0,
9511
+ memberTrackIds: Array.isArray(members) ? members.filter((m) => typeof m === "string") : [],
9512
+ memberCount: typeof data["memberCount"] === "number" ? data["memberCount"] : 0,
9513
+ revision: typeof data["revision"] === "number" ? data["revision"] : 1,
9514
+ sealed: data["sealed"] === true
9515
+ };
9516
+ }
9517
+ //#endregion
8790
9518
  //#region src/notification-center/rule-actions.ts
8791
9519
  var NcRuleActionRunner = class {
8792
9520
  deps;
@@ -10169,7 +10897,7 @@ var GLYPH_ADVANCE = .56;
10169
10897
  var LABEL_USABLE_FRACTION = .94;
10170
10898
  /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
10171
10899
  * turn the overlay into a document librsvg refuses. */
10172
- function escapeXml$2(s) {
10900
+ function escapeXml$1(s) {
10173
10901
  return s.replace(/[<>&'"]/g, (ch) => {
10174
10902
  switch (ch) {
10175
10903
  case "<": return "&lt;";
@@ -10257,7 +10985,7 @@ function tileLabelSvg(width, height, label) {
10257
10985
  const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
10258
10986
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
10259
10987
  <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
10260
- <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$2(fitted.text)}</text>
10988
+ <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
10261
10989
  </svg>`);
10262
10990
  }
10263
10991
  /** The mosaic's own title band. */
@@ -10266,7 +10994,7 @@ function titleSvg(width, height, caption) {
10266
10994
  const baseline = Math.round(height / 2 + fitted.fontPx * .36);
10267
10995
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
10268
10996
  <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
10269
- <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>
10997
+ <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>
10270
10998
  </svg>`);
10271
10999
  }
10272
11000
  /**
@@ -14413,6 +15141,16 @@ var NotificationCenter = class NotificationCenter {
14413
15141
  alarmPanel = null;
14414
15142
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
14415
15143
  lastFiredAt = /* @__PURE__ */ new Map();
15144
+ /**
15145
+ * The open BURSTS, one per (rule, camera). See `group/nc-group-buffer.ts`.
15146
+ *
15147
+ * It sits on the serialized evaluation chain, so it needs no locking, and it
15148
+ * is RAM-only on purpose: losing it costs the current burst its updates and
15149
+ * degrades to exactly the behaviour that shipped before grouping existed.
15150
+ */
15151
+ groups = new NcGroupBuffer();
15152
+ /** The audit record behind a future timeline card. */
15153
+ groupStore;
14416
15154
  /** The AI confirm gate, or null when this node has no vision LLM wired. */
14417
15155
  confirmGate;
14418
15156
  /**
@@ -14731,6 +15469,10 @@ var NotificationCenter = class NotificationCenter {
14731
15469
  store: deps.store,
14732
15470
  logger: this.logger.child("occupancy")
14733
15471
  });
15472
+ this.groupStore = new NcGroupStore({
15473
+ store: deps.store,
15474
+ logger: this.logger.child("groups")
15475
+ });
14734
15476
  this.liveness = new NcLivenessLedger({
14735
15477
  store: deps.store,
14736
15478
  logger: this.logger.child("liveness")
@@ -14911,6 +15653,7 @@ var NotificationCenter = class NotificationCenter {
14911
15653
  await NcSummaryStore.declare(store);
14912
15654
  await NcTextCatalogStore.declare(store);
14913
15655
  await NcArtifactIndex.declare(store);
15656
+ await NcGroupStore.declare(store);
14914
15657
  }
14915
15658
  /**
14916
15659
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -16026,8 +16769,74 @@ var NotificationCenter = class NotificationCenter {
16026
16769
  continue;
16027
16770
  }
16028
16771
  this.runRuleActions(rule, subject, kind, controls);
16772
+ const admission = this.admitToGroup(rule, subject, now);
16773
+ if (admission.outcome === "unchanged") {
16774
+ this.logger.debug("subject already in the open group — no new notification", {
16775
+ tags: { deviceId: subject.deviceId },
16776
+ meta: {
16777
+ ruleId: rule.id,
16778
+ rule: rule.name,
16779
+ groupKey: admission.group.key,
16780
+ members: admission.group.members.length,
16781
+ eventId: subject.recordId,
16782
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16783
+ }
16784
+ });
16785
+ outcomes.push({
16786
+ ruleId: rule.id,
16787
+ ruleName: rule.name,
16788
+ matched: true,
16789
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
16790
+ cooldown: "suppressed",
16791
+ enqueued: 0,
16792
+ targetIds: []
16793
+ });
16794
+ anyMatched = true;
16795
+ continue;
16796
+ }
16797
+ if (admission.outcome === "refused") {
16798
+ this.logger.info("group is full — subject not added to the notification", {
16799
+ tags: { deviceId: subject.deviceId },
16800
+ meta: {
16801
+ ruleId: rule.id,
16802
+ rule: rule.name,
16803
+ groupKey: admission.groupKey,
16804
+ members: admission.memberCount,
16805
+ eventId: subject.recordId,
16806
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16807
+ }
16808
+ });
16809
+ outcomes.push({
16810
+ ruleId: rule.id,
16811
+ ruleName: rule.name,
16812
+ matched: true,
16813
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
16814
+ cooldown: "suppressed",
16815
+ enqueued: 0,
16816
+ targetIds: []
16817
+ });
16818
+ anyMatched = true;
16819
+ continue;
16820
+ }
16821
+ const group = admission.outcome === "disabled" ? void 0 : admission.group;
16822
+ if (group !== void 0) {
16823
+ this.logger.info(admission.outcome === "opened" ? "group opened" : "group grew — updating the notification", {
16824
+ tags: { deviceId: subject.deviceId },
16825
+ meta: {
16826
+ ruleId: rule.id,
16827
+ rule: rule.name,
16828
+ groupKey: group.key,
16829
+ revision: group.revision,
16830
+ members: group.members.length,
16831
+ ...group.reason !== void 0 ? { grewBy: group.reason } : {},
16832
+ eventId: subject.recordId,
16833
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16834
+ }
16835
+ });
16836
+ this.groupStore.record(group);
16837
+ }
16029
16838
  const key = cooldownKey(rule, subject);
16030
- const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
16839
+ const cooling = group !== void 0 && admission.outcome === "grown" ? false : isCoolingDown(rule, this.lastFiredAt.get(key), now);
16031
16840
  if (cooling && controls.bypassCooldown !== true) {
16032
16841
  this.logger.debug("rule matched but is cooling down", {
16033
16842
  tags: { deviceId: subject.deviceId },
@@ -16089,7 +16898,7 @@ var NotificationCenter = class NotificationCenter {
16089
16898
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
16090
16899
  key,
16091
16900
  at: now
16092
- });
16901
+ }, void 0, group);
16093
16902
  const inserted = await this.outbox.enqueue(entries);
16094
16903
  this.noteRecognitionFire(rule, subject, kind, entries, inserted);
16095
16904
  if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
@@ -16223,6 +17032,78 @@ var NotificationCenter = class NotificationCenter {
16223
17032
  }
16224
17033
  }
16225
17034
  /**
17035
+ * Offer a matching subject to its rule's open burst.
17036
+ *
17037
+ * Every gate lives here so the evaluation loop reads as one decision:
17038
+ *
17039
+ * - **grouping is per-rule and opt-in.** No `groupIdleSec` ⇒ `disabled` ⇒
17040
+ * the caller does exactly what it did before this existed;
17041
+ * - **a subject with no track cannot be a member.** A device event, a system
17042
+ * event and an occupancy edge have no subject to draw a box for and no
17043
+ * identity to add later — grouping them would be grouping *records*, which
17044
+ * is not what the operator asked about;
17045
+ * - **an actuation is never grouped.** `runRuleActions` has already fired by
17046
+ * the time this is reached, and deliberately so: "how often may this gate
17047
+ * physically move" is the sequence runner's own `minDelaySec`, and it must
17048
+ * not become a notification question.
17049
+ */
17050
+ admitToGroup(rule, subject, now) {
17051
+ const idleSec = rule.groupIdleSec;
17052
+ if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
17053
+ const trackId = subject.trackId;
17054
+ if (trackId === void 0) return { outcome: "disabled" };
17055
+ const member = {
17056
+ trackId,
17057
+ className: subject.classNames[0] ?? "event",
17058
+ ...subject.label !== void 0 ? { label: subject.label } : {},
17059
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
17060
+ ...subject.bbox !== void 0 ? { bbox: subject.bbox } : {},
17061
+ eventId: subject.recordId,
17062
+ at: subject.timestamp
17063
+ };
17064
+ return this.groups.admit({
17065
+ ruleId: rule.id,
17066
+ deviceId: subject.deviceId,
17067
+ idleSec,
17068
+ member
17069
+ }, now);
17070
+ }
17071
+ /**
17072
+ * The grouped bursts, newest first — the timeline card's read.
17073
+ *
17074
+ * The STORE, not the live buffer: a card is about arrivals that happened, and
17075
+ * the buffer only knows the ones still open on this process. The open group's
17076
+ * row is written at open and patched at each growth, so a burst in progress
17077
+ * is in the answer too, with `sealed: false`.
17078
+ */
17079
+ async listTrackGroups(query = {}) {
17080
+ return this.groupStore.list(query);
17081
+ }
17082
+ /**
17083
+ * Close every burst whose idle cutoff has elapsed, and say so ONCE.
17084
+ *
17085
+ * Ridden on the outbox drain tick rather than a timer of its own: the drain
17086
+ * already runs every 2 s, which is an order of magnitude finer than the
17087
+ * shortest sensible cutoff, and a second interval would be a second thing to
17088
+ * stop on shutdown. The close is a LOG plus a sealed record — it sends
17089
+ * nothing. Nobody wants a notification saying a group is over.
17090
+ */
17091
+ sweepGroups(now) {
17092
+ for (const group of this.groups.sweep(now)) {
17093
+ this.logger.info("group closed", {
17094
+ tags: { deviceId: group.deviceId },
17095
+ meta: {
17096
+ ruleId: group.ruleId,
17097
+ groupKey: group.key,
17098
+ members: group.members.length,
17099
+ revisions: group.revision,
17100
+ openForMs: group.lastActivityAt - group.openedAt
17101
+ }
17102
+ });
17103
+ this.groupStore.record(group);
17104
+ }
17105
+ }
17106
+ /**
16226
17107
  * Run this rule's `onTrigger` sequences for this match.
16227
17108
  *
16228
17109
  * Called BEFORE the cooldown gate — see the call site for why. Everything
@@ -16290,7 +17171,7 @@ var NotificationCenter = class NotificationCenter {
16290
17171
  }
16291
17172
  });
16292
17173
  }
16293
- buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
17174
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
16294
17175
  const hasEventMedia = kind === "object-event" || kind === "package-event";
16295
17176
  const isTrackScoped = kind === "object-event" || kind === "track-end";
16296
17177
  const isSystemEvent = kind === "system-event";
@@ -16324,6 +17205,19 @@ var NotificationCenter = class NotificationCenter {
16324
17205
  ...!noMedia && mediaRule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: mediaRule.media.clipSpeed } : {},
16325
17206
  ...!noMedia && mediaRule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: mediaRule.media.clipPostRollSec } : {},
16326
17207
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
17208
+ ...group !== void 0 && group.members.length > 1 ? { group: {
17209
+ key: group.key,
17210
+ revision: group.revision,
17211
+ anchorTrackId: group.anchorTrackId,
17212
+ members: group.members.map((m) => ({
17213
+ trackId: m.trackId,
17214
+ className: m.className,
17215
+ ...m.label !== void 0 ? { label: m.label } : {},
17216
+ ...m.labelKind === "identity" || m.labelKind === "plate" ? { labelKind: m.labelKind } : {},
17217
+ ...m.bbox !== void 0 ? { bbox: m.bbox } : {},
17218
+ at: m.at
17219
+ }))
17220
+ } } : {},
16327
17221
  ...target.params !== void 0 ? { params: target.params } : {},
16328
17222
  subject: {
16329
17223
  deviceId: subject.deviceId,
@@ -16346,7 +17240,7 @@ var NotificationCenter = class NotificationCenter {
16346
17240
  recordKind: kind === "occupancy-event" ? "device-event" : kind,
16347
17241
  recordId: subject.recordId,
16348
17242
  ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
16349
- ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
17243
+ ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : group !== void 0 && group.revision > 1 ? { dedupRef: `${group.key}:v${group.revision}` } : {},
16350
17244
  payload
16351
17245
  };
16352
17246
  });
@@ -16720,6 +17614,7 @@ var NotificationCenter = class NotificationCenter {
16720
17614
  }
16721
17615
  async drainTick() {
16722
17616
  await this.snoozes.load();
17617
+ this.sweepGroups(this.now());
16723
17618
  await this.outbox.drainOnce();
16724
17619
  await this.deliverDueDigests();
16725
17620
  this.drainTicks += 1;
@@ -17168,6 +18063,8 @@ function systemEventSubject(kind, scope) {
17168
18063
  if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
17169
18064
  if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
17170
18065
  if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
18066
+ if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
18067
+ if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
17171
18068
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
17172
18069
  return `alarm:${scope.deviceId ?? 0}`;
17173
18070
  }
@@ -17274,7 +18171,9 @@ function planRuleTest(input) {
17274
18171
  currentVersion: "0.0.0",
17275
18172
  latestVersion: "0.0.0",
17276
18173
  profile: "low",
17277
- seconds: "30"
18174
+ seconds: "30",
18175
+ devices: "cuda:0",
18176
+ minutes: "15"
17278
18177
  },
17279
18178
  ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
17280
18179
  ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
@@ -17324,6 +18223,27 @@ var NcViewerTimelapseRuleSchema = TimelapseRuleSchema.extend({ readOnly: boolean
17324
18223
  /** Same verdict, for a multi-camera digest rule. */
17325
18224
  var NcViewerSummaryRuleSchema = NcSummaryRuleSchema.extend({ readOnly: boolean() });
17326
18225
  /**
18226
+ * One grouped burst, as the viewer reads it.
18227
+ *
18228
+ * The LIGHT half of the Phase-2 group entity on purpose: ids and timestamps, no
18229
+ * representative frame and no clustering verdict. A card needs to know which
18230
+ * tracks were one arrival and when; anything more would be a second source of
18231
+ * truth about a relation the entity has not been built for yet.
18232
+ */
18233
+ var NcTrackGroupSchema = object({
18234
+ id: string(),
18235
+ ruleId: string(),
18236
+ deviceId: number().int(),
18237
+ openedAt: number().int(),
18238
+ lastGrowthAt: number().int(),
18239
+ memberTrackIds: array(string()).readonly(),
18240
+ memberCount: number().int(),
18241
+ /** How many notifications the burst produced — 1 at open, +1 per growth. */
18242
+ revision: number().int(),
18243
+ /** False while the arrival is still happening. */
18244
+ sealed: boolean()
18245
+ });
18246
+ /**
17327
18247
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
17328
18248
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
17329
18249
  * caller identity is declared `caller: 'required'` so the dispatcher forwards
@@ -17364,6 +18284,22 @@ var ncActions = defineCustomActions({
17364
18284
  * desk. Scoped to the caller: their own windows plus the global ones that
17365
18285
  * silence them.
17366
18286
  */
18287
+ /**
18288
+ * The BURSTS this hub has grouped — the data half of the timeline's
18289
+ * expandable group card.
18290
+ *
18291
+ * Rides the generic bridge, like every other viewer read here: no cap method,
18292
+ * no codegen, ships train-free. Read-only and deliberately thin — it answers
18293
+ * "which tracks were one arrival", and the per-subject tracks stay the record
18294
+ * and stay clickable. A group card EXPANDS to its members; it never replaces
18295
+ * them.
18296
+ */
18297
+ "nc.listTrackGroups": customAction(object({
18298
+ deviceId: number().int().optional(),
18299
+ ruleId: string().optional(),
18300
+ since: number().int().optional(),
18301
+ limit: number().int().min(1).max(200).optional()
18302
+ }), object({ groups: array(NcTrackGroupSchema) }), { caller: "required" }),
17367
18303
  "nc.listSnoozes": customAction(object({}), object({ snoozes: array(NcSnoozeSchema) }), { caller: "required" }),
17368
18304
  "nc.createSnooze": customAction(object({ snooze: NcSnoozeInputSchema }), object({ snooze: NcSnoozeSchema }), {
17369
18305
  kind: "mutation",
@@ -17697,6 +18633,12 @@ function makeNcActionHandlers(deps) {
17697
18633
  readOnly: !c.isAdmin && r.ownerUserId !== c.userId
17698
18634
  })) };
17699
18635
  },
18636
+ "nc.listTrackGroups": async (input, caller) => {
18637
+ requireCaller(caller);
18638
+ const list = deps.listTrackGroups;
18639
+ if (list === void 0) return { groups: [] };
18640
+ return { groups: [...await list(input)] };
18641
+ },
17700
18642
  "nc.getConditionCatalog": async () => ({
17701
18643
  catalog: [...NC_CONDITION_CATALOG],
17702
18644
  taxonomy: NC_TAXONOMY
@@ -18313,7 +19255,7 @@ function buildProbeDurationArgs(inputPath) {
18313
19255
  }
18314
19256
  /** `<`, `>`, `&`, `'` and `"` in an operator's free-text caption would each
18315
19257
  * turn the overlay into a document librsvg refuses. */
18316
- function escapeXml$1(s) {
19258
+ function escapeXml(s) {
18317
19259
  return s.replace(/[<>&'"]/g, (ch) => {
18318
19260
  switch (ch) {
18319
19261
  case "<": return "&lt;";
@@ -18401,7 +19343,7 @@ function buildTitleSvg(input) {
18401
19343
  <rect x="0" y="0" width="${width}" height="${height}" fill="black" fill-opacity="${TITLE_SCRIM_OPACITY}"/>
18402
19344
  ${lines.map((line, i) => {
18403
19345
  const y = top + lineHeight * i + Math.round(fontPx * .8);
18404
- const esc = escapeXml$1(line);
19346
+ const esc = escapeXml(line);
18405
19347
  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");
18406
19348
  }).join("\n")}
18407
19349
  </svg>`;
@@ -21709,6 +22651,8 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
21709
22651
  *
21710
22652
  * One FrameProcessor per device — lifecycle managed by DeviceRegistry.
21711
22653
  */
22654
+ /** Shared empty — a fold is rare and every other frame would allocate. */
22655
+ var EMPTY_RIDER_FOLDS = [];
21712
22656
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
21713
22657
  * canonical TrackState enum used on tracks + events. */
21714
22658
  function mapObjectStateToTrackState(s) {
@@ -21767,8 +22711,6 @@ var FrameProcessor = class {
21767
22711
  * this frame still carries the zones it was in. See the use site.
21768
22712
  */
21769
22713
  lastZonesByTrack = /* @__PURE__ */ new Map();
21770
- /** See {@link getLastRiderPairs}. */
21771
- lastRiderPairs = [];
21772
22714
  /** See {@link getLastLabelWriteRefusals}. */
21773
22715
  lastLabelWriteRefusals = [];
21774
22716
  /**
@@ -21829,17 +22771,11 @@ var FrameProcessor = class {
21829
22771
  setZoneMembershipMinOverlap(minOverlap) {
21830
22772
  this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
21831
22773
  }
21832
- /** Rider pairings folded on the most recent frame — the PERSON half that was
21833
- * dropped so the passage counts once, as a vehicle. Reported so both
21834
- * entities stay on the record rather than one silently disappearing. */
21835
- getLastRiderPairs() {
21836
- return this.lastRiderPairs;
21837
- }
21838
22774
  /**
21839
22775
  * Label candidates the write rule refused on the most recent frame (see
21840
22776
  * `resolveTieredLabelWrite` in `label-tier.ts`).
21841
22777
  *
21842
- * Reported for the same reason as {@link getLastRiderPairs}: the branch DROPS
22778
+ * Reported for the same reason a {@link RiderFold} is: the branch DROPS
21843
22779
  * a value, and a drop nobody logs reads as "it never happened". The five live
21844
22780
  * rows that produced the macro-class rule were found by reading the rows, a
21845
22781
  * week after the fact. Deduped per (track, token) so a track that keeps
@@ -21964,16 +22900,30 @@ var FrameProcessor = class {
21964
22900
  bbox: d.bbox,
21965
22901
  score: d.score
21966
22902
  })));
22903
+ /**
22904
+ * Pending folds, keyed by the SURVIVING vehicle's bbox object.
22905
+ *
22906
+ * The bbox object reference is the file's established way to carry
22907
+ * frame-local facts across the tracker (`labelsByBbox`,
22908
+ * `firstLevelBboxById`): the tracker assigns `track.bbox = det.bbox` on
22909
+ * every match and spawn, so the emitted `TrackedDetection.bbox` IS this
22910
+ * object when the vehicle was seen this frame.
22911
+ */
22912
+ const foldByVehicleBbox = /* @__PURE__ */ new Map();
21967
22913
  if (riderPairs.length > 0) {
21968
22914
  const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
21969
- this.lastRiderPairs = riderPairs.map((p) => ({
21970
- overlap: p.overlap,
21971
- personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
21972
- vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
21973
- vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
21974
- }));
22915
+ for (const p of riderPairs) {
22916
+ const vehicle = filteredDetections[Number(p.vehicleId)];
22917
+ if (vehicle === void 0) continue;
22918
+ foldByVehicleBbox.set(vehicle.bbox, {
22919
+ overlap: p.overlap,
22920
+ personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
22921
+ vehicleScore: vehicle.score,
22922
+ vehicleClass: vehicle.originalClass ?? "two-wheeler"
22923
+ });
22924
+ }
21975
22925
  filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
21976
- } else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
22926
+ }
21977
22927
  const gate = this.stationaryGate ? this.stationaryGate.filter({
21978
22928
  detections: filteredDetections,
21979
22929
  frameWidth,
@@ -21988,6 +22938,13 @@ var FrameProcessor = class {
21988
22938
  frameWidth,
21989
22939
  frameHeight
21990
22940
  });
22941
+ const riderFolds = foldByVehicleBbox.size === 0 ? EMPTY_RIDER_FOLDS : [...foldByVehicleBbox].map(([bbox, fold]) => {
22942
+ const td = trackedDetections.find((t) => t.bbox === bbox && t.matchedThisFrame !== false);
22943
+ return td !== void 0 ? {
22944
+ ...fold,
22945
+ trackId: td.trackId
22946
+ } : fold;
22947
+ });
21991
22948
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
21992
22949
  const zonesByTrack = /* @__PURE__ */ new Map();
21993
22950
  const overlapsByTrack = /* @__PURE__ */ new Map();
@@ -22183,7 +23140,8 @@ var FrameProcessor = class {
22183
23140
  appearanceEvents,
22184
23141
  rawTrackedDetections: trackedDetections,
22185
23142
  stationaryConfirmed: gate.confirmed,
22186
- stationaryWoken: gate.wokenEntryIds
23143
+ stationaryWoken: gate.wokenEntryIds,
23144
+ riderFolds
22187
23145
  };
22188
23146
  }
22189
23147
  };
@@ -30993,77 +31951,6 @@ async function rankKeyEvents(candidates, options, peakLookup) {
30993
31951
  return scored.slice(0, options.limit);
30994
31952
  }
30995
31953
  //#endregion
30996
- //#region src/shared/frame/box-drawer.ts
30997
- var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
30998
- var DEFAULT_QUALITY = 80;
30999
- var STROKE_WIDTH = 3;
31000
- function escapeXml(s) {
31001
- return s.replace(/[<>&'"]/g, (ch) => {
31002
- switch (ch) {
31003
- case "<": return "&lt;";
31004
- case ">": return "&gt;";
31005
- case "&": return "&amp;";
31006
- case "'": return "&apos;";
31007
- default: return "&quot;";
31008
- }
31009
- });
31010
- }
31011
- /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
31012
- function clampBox(b, frameWidth, frameHeight) {
31013
- const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
31014
- const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
31015
- return {
31016
- x,
31017
- y,
31018
- w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
31019
- h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
31020
- };
31021
- }
31022
- /**
31023
- * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
31024
- *
31025
- * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
31026
- * optional caption) at full resolution, then optionally downscaled — so the
31027
- * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
31028
- * avoid sharp `extract`/region errors on slightly-out-of-range detections.
31029
- */
31030
- async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
31031
- const quality = opts.quality ?? DEFAULT_QUALITY;
31032
- let base = frameData;
31033
- let baseIsRaw = true;
31034
- if (boxes.length > 0) {
31035
- const fontSize = Math.max(12, Math.round(frameHeight / 30));
31036
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
31037
- const c = clampBox(b, frameWidth, frameHeight);
31038
- const color = b.color ?? DEFAULT_COLOR;
31039
- const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
31040
- if (!b.label) return rect;
31041
- const ty = Math.max(fontSize, c.y - 4);
31042
- 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>`;
31043
- }).join("")}</svg>`;
31044
- base = await sharp(frameData, { raw: {
31045
- width: frameWidth,
31046
- height: frameHeight,
31047
- channels: 3
31048
- } }).composite([{
31049
- input: Buffer.from(svg),
31050
- top: 0,
31051
- left: 0
31052
- }]).png().toBuffer();
31053
- baseIsRaw = false;
31054
- }
31055
- let pipeline = baseIsRaw ? sharp(base, { raw: {
31056
- width: frameWidth,
31057
- height: frameHeight,
31058
- channels: 3
31059
- } }) : sharp(base);
31060
- if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
31061
- width: opts.maxWidth,
31062
- withoutEnlargement: true
31063
- });
31064
- return pipeline.jpeg({ quality }).toBuffer();
31065
- }
31066
- //#endregion
31067
31954
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
31068
31955
  /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
31069
31956
  * Native resolution is the point: a 4K cam (3840) resolves TRUE native (no
@@ -31493,9 +32380,22 @@ function pickedUpEventId(entryId) {
31493
32380
  }
31494
32381
  var PackageDropDetector = class {
31495
32382
  deps;
32383
+ /** Per-device mirror of the last LOGGED value of the `packageDropEnabled`
32384
+ * switch, so gate 0 logs a transition instead of one line per promotion. */
32385
+ disabledLogged = /* @__PURE__ */ new Map();
31496
32386
  constructor(deps) {
31497
32387
  this.deps = deps;
31498
32388
  }
32389
+ /** Log the `packageDropEnabled` switch only when it MOVES for a device. */
32390
+ noteDisabled(deviceId, disabled) {
32391
+ if (this.disabledLogged.get(deviceId) === disabled) return;
32392
+ this.disabledLogged.set(deviceId, disabled);
32393
+ if (disabled) this.deps.logger.info("package gate 0/5 switch — packageDropEnabled is false", { tags: { deviceId } });
32394
+ }
32395
+ /** Drop a device's gate-log mirror (unbind / teardown). */
32396
+ forgetDevice(deviceId) {
32397
+ this.disabledLogged.delete(deviceId);
32398
+ }
31499
32399
  /** Single entrypoint — bridge the registry's `onChange` here. Never
31500
32400
  * throws (telemetry-lossy, D8): a failure only misses one package event. */
31501
32401
  async onStationaryChange(change) {
@@ -31516,22 +32416,104 @@ var PackageDropDetector = class {
31516
32416
  }
31517
32417
  async onAppeared(entry, timestamp) {
31518
32418
  const settings = await this.deps.resolveSettings(entry.deviceId);
31519
- if (!settings.packageDropEnabled) return;
31520
- if (!settings.packageDropClassFilter.includes(entry.className)) return;
32419
+ if (!settings.packageDropEnabled) {
32420
+ this.noteDisabled(entry.deviceId, true);
32421
+ return;
32422
+ }
32423
+ this.noteDisabled(entry.deviceId, false);
32424
+ if (!settings.packageDropClassFilter.includes(entry.className)) {
32425
+ this.deps.logger.debug("package gate 1/5 class — not a package class", {
32426
+ tags: { deviceId: entry.deviceId },
32427
+ meta: {
32428
+ entryId: entry.id,
32429
+ className: entry.className,
32430
+ classFilter: settings.packageDropClassFilter
32431
+ }
32432
+ });
32433
+ return;
32434
+ }
32435
+ const tags = { deviceId: entry.deviceId };
31521
32436
  const rules = (await this.deps.resolvePackageRules(entry.deviceId)).filter((r) => r.enabled !== false);
31522
- if (rules.length === 0) return;
32437
+ if (rules.length === 0) {
32438
+ this.deps.logger.info("package gate 2/5 zone — no enabled package-stage zone rule", {
32439
+ tags,
32440
+ meta: {
32441
+ entryId: entry.id,
32442
+ className: entry.className
32443
+ }
32444
+ });
32445
+ return;
32446
+ }
31523
32447
  const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
31524
- const hitZones = computeStationaryEntryZones(entry, await this.deps.resolveZones(entry.deviceId)).filter((z) => ruleZoneIds.has(z));
31525
- if (hitZones.length === 0) return;
31526
- if (timestamp - entry.firstSeenAt < settings.packageDropDwellSec * 1e3) return;
32448
+ const zones = await this.deps.resolveZones(entry.deviceId);
32449
+ const entryZones = computeStationaryEntryZones(entry, zones);
32450
+ const hitZones = entryZones.filter((z) => ruleZoneIds.has(z));
32451
+ if (hitZones.length === 0) {
32452
+ this.deps.logger.info("package gate 2/5 zone — centroid outside every package zone", {
32453
+ tags,
32454
+ meta: {
32455
+ entryId: entry.id,
32456
+ bbox: entry.bbox,
32457
+ entryZones,
32458
+ ruleZoneIds: [...ruleZoneIds],
32459
+ zonesOnDevice: zones.length
32460
+ }
32461
+ });
32462
+ return;
32463
+ }
32464
+ const dwellMs = timestamp - entry.firstSeenAt;
32465
+ const requiredDwellMs = settings.packageDropDwellSec * 1e3;
32466
+ if (dwellMs < requiredDwellMs) {
32467
+ this.deps.logger.info("package gate 3/5 dwell — not still long enough", {
32468
+ tags,
32469
+ meta: {
32470
+ entryId: entry.id,
32471
+ dwellMs,
32472
+ requiredDwellMs
32473
+ }
32474
+ });
32475
+ return;
32476
+ }
31527
32477
  const frameArea = entry.frameWidth * entry.frameHeight;
31528
- if (frameArea <= 0) return;
31529
- if (entry.bbox.w * entry.bbox.h / frameArea < settings.packageDropMinBboxAreaFrac) return;
32478
+ if (frameArea <= 0) {
32479
+ this.deps.logger.info("package gate 4/5 size — frame dimensions unknown", {
32480
+ tags,
32481
+ meta: {
32482
+ entryId: entry.id,
32483
+ frameWidth: entry.frameWidth,
32484
+ frameHeight: entry.frameHeight
32485
+ }
32486
+ });
32487
+ return;
32488
+ }
32489
+ const areaFrac = entry.bbox.w * entry.bbox.h / frameArea;
32490
+ if (areaFrac < settings.packageDropMinBboxAreaFrac) {
32491
+ this.deps.logger.info("package gate 4/5 size — bbox below the minimum area", {
32492
+ tags,
32493
+ meta: {
32494
+ entryId: entry.id,
32495
+ areaFrac: Number(areaFrac.toFixed(5)),
32496
+ minAreaFrac: settings.packageDropMinBboxAreaFrac,
32497
+ bbox: entry.bbox,
32498
+ frame: [entry.frameWidth, entry.frameHeight]
32499
+ }
32500
+ });
32501
+ return;
32502
+ }
31530
32503
  const eventId = deliveredEventId(entry.id);
31531
32504
  if ((await this.deps.events.queryObject({
31532
32505
  deviceId: entry.deviceId,
31533
32506
  classFilter: "package"
31534
- })).some((e) => e.id === eventId)) return;
32507
+ })).some((e) => e.id === eventId)) {
32508
+ this.deps.logger.info("package gate 5/5 idempotence — delivery already recorded", {
32509
+ tags,
32510
+ meta: {
32511
+ entryId: entry.id,
32512
+ eventId
32513
+ }
32514
+ });
32515
+ return;
32516
+ }
31535
32517
  const ev = {
31536
32518
  id: eventId,
31537
32519
  kind: "object",
@@ -31541,7 +32523,7 @@ var PackageDropDetector = class {
31541
32523
  trackId: entry.sourceTrackId ?? entry.id,
31542
32524
  className: PACKAGE_EVENT_CLASS,
31543
32525
  ...entry.label !== void 0 ? { label: entry.label } : {},
31544
- confidence: PACKAGE_IMPORTANCE,
32526
+ confidence: entry.confidence ?? PACKAGE_IMPORTANCE,
31545
32527
  bbox: {
31546
32528
  x: entry.bbox.x,
31547
32529
  y: entry.bbox.y,
@@ -31572,9 +32554,26 @@ var PackageDropDetector = class {
31572
32554
  },
31573
32555
  timestamp
31574
32556
  });
32557
+ this.deps.logger.info("package delivered — all 5 gates passed", {
32558
+ tags: { deviceId: entry.deviceId },
32559
+ meta: {
32560
+ entryId: entry.id,
32561
+ eventId,
32562
+ zoneIds: hitZones,
32563
+ dwellMs,
32564
+ areaFrac: Number(areaFrac.toFixed(5)),
32565
+ ...entry.confidence !== void 0 ? { confidence: entry.confidence } : {}
32566
+ }
32567
+ });
31575
32568
  }
31576
32569
  async onDeparted(entry, timestamp) {
31577
- if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) return;
32570
+ if (!(await this.deps.resolveSettings(entry.deviceId)).packageDropPickupEnabled) {
32571
+ this.deps.logger.debug("package pick-up skipped — packageDropPickupEnabled is false", {
32572
+ tags: { deviceId: entry.deviceId },
32573
+ meta: { entryId: entry.id }
32574
+ });
32575
+ return;
32576
+ }
31578
32577
  const deliveredId = deliveredEventId(entry.id);
31579
32578
  const pickedUpId = pickedUpEventId(entry.id);
31580
32579
  const rows = await this.deps.events.queryObject({
@@ -31582,8 +32581,26 @@ var PackageDropDetector = class {
31582
32581
  classFilter: PACKAGE_EVENT_CLASS
31583
32582
  });
31584
32583
  const delivered = rows.find((e) => e.id === deliveredId);
31585
- if (delivered === void 0) return;
31586
- if (rows.some((e) => e.id === pickedUpId)) return;
32584
+ if (delivered === void 0) {
32585
+ this.deps.logger.debug("package pick-up skipped — no delivery row for this entry", {
32586
+ tags: { deviceId: entry.deviceId },
32587
+ meta: {
32588
+ entryId: entry.id,
32589
+ className: entry.className
32590
+ }
32591
+ });
32592
+ return;
32593
+ }
32594
+ if (rows.some((e) => e.id === pickedUpId)) {
32595
+ this.deps.logger.info("package pick-up skipped — already recorded", {
32596
+ tags: { deviceId: entry.deviceId },
32597
+ meta: {
32598
+ entryId: entry.id,
32599
+ eventId: pickedUpId
32600
+ }
32601
+ });
32602
+ return;
32603
+ }
31587
32604
  const ev = {
31588
32605
  id: pickedUpId,
31589
32606
  kind: "object",
@@ -31615,6 +32632,14 @@ var PackageDropDetector = class {
31615
32632
  className: entry.className,
31616
32633
  timestamp
31617
32634
  });
32635
+ this.deps.logger.info("package picked up", {
32636
+ tags: { deviceId: entry.deviceId },
32637
+ meta: {
32638
+ entryId: entry.id,
32639
+ eventId: pickedUpId,
32640
+ deliveredEventId: deliveredId
32641
+ }
32642
+ });
31618
32643
  }
31619
32644
  };
31620
32645
  //#endregion
@@ -31800,6 +32825,150 @@ function evaluateStationaryPromotion(input) {
31800
32825
  spanFrac: metrics.pathSpanFrac
31801
32826
  };
31802
32827
  }
32828
+ var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
32829
+ minRounds: 2,
32830
+ roundGapMs: 5e3,
32831
+ ttlMs: 5 * 6e4,
32832
+ assocIou: .3
32833
+ };
32834
+ var PackageStillnessTracker = class {
32835
+ config;
32836
+ newId;
32837
+ byDevice = /* @__PURE__ */ new Map();
32838
+ constructor(deps) {
32839
+ this.config = deps.config ?? DEFAULT_PACKAGE_STILLNESS_CONFIG;
32840
+ this.newId = deps.newId;
32841
+ }
32842
+ /**
32843
+ * Fold one frame's package sightings into the per-device candidate set and
32844
+ * report the transitions. Immutable: every candidate touched is replaced,
32845
+ * never mutated. Never throws.
32846
+ */
32847
+ observe(input) {
32848
+ const next = [...this.byDevice.get(input.deviceId) ?? []];
32849
+ const consumed = /* @__PURE__ */ new Set();
32850
+ const promoted = [];
32851
+ for (const s of input.sightings) {
32852
+ const index = this.bestMatch(next, s, consumed);
32853
+ if (index < 0) {
32854
+ const fresh = this.openCandidate(input, s);
32855
+ next.push(fresh);
32856
+ consumed.add(fresh.id);
32857
+ continue;
32858
+ }
32859
+ const prev = next[index];
32860
+ if (prev === void 0) continue;
32861
+ consumed.add(prev.id);
32862
+ const updated = confirm(prev, input, s, this.config.roundGapMs);
32863
+ if (!(updated.promotedAt === null && updated.rounds >= this.config.minRounds && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
32864
+ next[index] = updated;
32865
+ continue;
32866
+ }
32867
+ const parked = {
32868
+ ...updated,
32869
+ promotedAt: input.timestamp
32870
+ };
32871
+ next[index] = parked;
32872
+ promoted.push(toEntry(parked));
32873
+ }
32874
+ const departed = [];
32875
+ const kept = [];
32876
+ for (const c of next) {
32877
+ if (input.timestamp - c.lastSeenAt > this.config.ttlMs) {
32878
+ if (c.promotedAt !== null) departed.push(toEntry(c));
32879
+ continue;
32880
+ }
32881
+ kept.push(c);
32882
+ }
32883
+ if (kept.length === 0) this.byDevice.delete(input.deviceId);
32884
+ else this.byDevice.set(input.deviceId, kept);
32885
+ return {
32886
+ promoted,
32887
+ departed
32888
+ };
32889
+ }
32890
+ /** Drop a device's candidates (unbind / step disabled). */
32891
+ forgetDevice(deviceId) {
32892
+ this.byDevice.delete(deviceId);
32893
+ }
32894
+ reset() {
32895
+ this.byDevice.clear();
32896
+ }
32897
+ /** Live candidate count for a device — diagnostics and tests only. */
32898
+ debugCandidateCount(deviceId) {
32899
+ return this.byDevice.get(deviceId)?.length ?? 0;
32900
+ }
32901
+ /** Best same-class candidate over the association floor, ignoring any already
32902
+ * consumed by an earlier sighting in THIS frame (two boxes never collapse
32903
+ * into one parcel). */
32904
+ bestMatch(candidates, s, consumed) {
32905
+ let bestIndex = -1;
32906
+ let bestIou = this.config.assocIou;
32907
+ for (let i = 0; i < candidates.length; i += 1) {
32908
+ const c = candidates[i];
32909
+ if (c === void 0 || consumed.has(c.id) || c.className !== s.className) continue;
32910
+ const overlap = iou(c.bbox, s.bbox);
32911
+ if (overlap >= bestIou) {
32912
+ bestIou = overlap;
32913
+ bestIndex = i;
32914
+ }
32915
+ }
32916
+ return bestIndex;
32917
+ }
32918
+ openCandidate(input, s) {
32919
+ return {
32920
+ id: this.newId(),
32921
+ deviceId: input.deviceId,
32922
+ className: s.className,
32923
+ bbox: { ...s.bbox },
32924
+ frameWidth: input.frameWidth,
32925
+ frameHeight: input.frameHeight,
32926
+ firstSeenAt: input.timestamp,
32927
+ lastSeenAt: input.timestamp,
32928
+ rounds: 1,
32929
+ promotedAt: null,
32930
+ ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
32931
+ ...s.label !== void 0 ? { label: s.label } : {},
32932
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
32933
+ ...s.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: s.keyFrameMediaKey } : {}
32934
+ };
32935
+ }
32936
+ };
32937
+ /** Re-confirm a candidate with this frame's box. A new ROUND is only counted
32938
+ * when the gap since the last sighting clears `roundGapMs`, so a burst of
32939
+ * frames inside one governor round counts once. */
32940
+ function confirm(prev, input, s, roundGapMs) {
32941
+ const newRound = input.timestamp - prev.lastSeenAt >= roundGapMs;
32942
+ return {
32943
+ ...prev,
32944
+ bbox: { ...s.bbox },
32945
+ frameWidth: input.frameWidth,
32946
+ frameHeight: input.frameHeight,
32947
+ lastSeenAt: input.timestamp,
32948
+ rounds: newRound ? prev.rounds + 1 : prev.rounds,
32949
+ ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
32950
+ ...s.label !== void 0 ? { label: s.label } : {},
32951
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
32952
+ ...s.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: s.keyFrameMediaKey } : {}
32953
+ };
32954
+ }
32955
+ function toEntry(c) {
32956
+ return {
32957
+ id: c.id,
32958
+ deviceId: c.deviceId,
32959
+ className: c.className,
32960
+ bbox: { ...c.bbox },
32961
+ frameWidth: c.frameWidth,
32962
+ frameHeight: c.frameHeight,
32963
+ firstSeenAt: c.firstSeenAt,
32964
+ becameStationaryAt: c.promotedAt ?? c.lastSeenAt,
32965
+ lastConfirmedAt: c.lastSeenAt,
32966
+ ...c.sourceTrackId !== void 0 ? { sourceTrackId: c.sourceTrackId } : {},
32967
+ ...c.label !== void 0 ? { label: c.label } : {},
32968
+ ...c.confidence !== void 0 ? { confidence: c.confidence } : {},
32969
+ ...c.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: c.keyFrameMediaKey } : {}
32970
+ };
32971
+ }
31803
32972
  //#endregion
31804
32973
  //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
31805
32974
  /**
@@ -33596,6 +34765,24 @@ var OBJECT_COLUMNS = [
33596
34765
  name: "source",
33597
34766
  type: "TEXT"
33598
34767
  },
34768
+ (
34769
+ /**
34770
+ * The SCENE key — every event of one processed frame shares it
34771
+ * (`frame-processor.ts`, stamped on every emitted event).
34772
+ *
34773
+ * It was documented on the cap from the day the collection existed ("lets a
34774
+ * consumer group co-occurring detections (e.g. 2 people + 1 dog) under one
34775
+ * full frame"), stamped by the producer, and read back by `slimObject` — with
34776
+ * no column to land in. The sqlite backend drops an undeclared key WITHOUT
34777
+ * ERROR (`if (decl.columns.has(k))`), so the value was written into the void
34778
+ * on every row ever persisted and the co-occurrence relation was unanswerable
34779
+ * from SQL. Additive and nullable: legacy rows keep reading `undefined`,
34780
+ * which is the truth about them.
34781
+ */
34782
+ {
34783
+ name: "frameId",
34784
+ type: "TEXT"
34785
+ }),
33599
34786
  {
33600
34787
  name: "trackId",
33601
34788
  type: "TEXT",
@@ -33705,10 +34892,17 @@ var EventStore = class {
33705
34892
  await store.declareCollection.mutate({
33706
34893
  collection: OBJECT_EVENTS_COLLECTION,
33707
34894
  columns: [...OBJECT_COLUMNS],
33708
- indexes: [...COMMON_INDEXES("object"), {
33709
- name: "idx_object_track",
33710
- columns: ["trackId"]
33711
- }]
34895
+ indexes: [
34896
+ ...COMMON_INDEXES("object"),
34897
+ {
34898
+ name: "idx_object_track",
34899
+ columns: ["trackId"]
34900
+ },
34901
+ {
34902
+ name: "idx_object_frame",
34903
+ columns: ["frameId"]
34904
+ }
34905
+ ]
33712
34906
  });
33713
34907
  await store.declareCollection.mutate({
33714
34908
  collection: AUDIO_EVENTS_COLLECTION,
@@ -36171,6 +37365,10 @@ var TRACKS_COLUMNS = [
36171
37365
  name: "hasFace",
36172
37366
  type: "BOOLEAN"
36173
37367
  },
37368
+ {
37369
+ name: "hasRider",
37370
+ type: "BOOLEAN"
37371
+ },
36174
37372
  {
36175
37373
  name: "envMinX",
36176
37374
  type: "REAL"
@@ -36252,7 +37450,8 @@ function cloneTrack(t) {
36252
37450
  markForTrain: t.retrainStatus === "staging"
36253
37451
  } : {},
36254
37452
  ...t.debug !== void 0 ? { debug: t.debug } : {},
36255
- ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
37453
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
37454
+ ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {}
36256
37455
  };
36257
37456
  }
36258
37457
  var TrackStore = class {
@@ -36658,6 +37857,41 @@ var TrackStore = class {
36658
37857
  }
36659
37858
  }
36660
37859
  /**
37860
+ * Record that a rider was folded into THIS vehicle subject (D34).
37861
+ *
37862
+ * The twin of {@link markFaceDetected}, deliberately: same set-once
37863
+ * semantics, same live-then-persisted branch, same best-effort failure. What
37864
+ * it closes is a record gap — the fold deletes the person detection before the
37865
+ * tracker, and until this method existed nothing durable said so, while both
37866
+ * the ADR and the code comment claimed otherwise.
37867
+ *
37868
+ * Set-once and never cleared: the fold is a fact about a moment of the
37869
+ * track's life, and a rider who dismounts later does not un-ride the frames
37870
+ * where they were riding. (The dismount itself needs nothing here — the fold
37871
+ * is per-FRAME, so the person simply stops overlapping and spawns its own
37872
+ * track, which is D34's whole design.)
37873
+ */
37874
+ async markRiderFolded(trackId) {
37875
+ const active = this.active.get(trackId);
37876
+ if (active) {
37877
+ if (active.hasRider === true) return;
37878
+ active.hasRider = true;
37879
+ return;
37880
+ }
37881
+ try {
37882
+ await this.store.update.mutate({
37883
+ collection: TRACKS_COLLECTION,
37884
+ id: trackId,
37885
+ data: { hasRider: true }
37886
+ });
37887
+ } catch (err) {
37888
+ this.logger.warn("markRiderFolded persist failed", { meta: {
37889
+ trackId,
37890
+ error: String(err)
37891
+ } });
37892
+ }
37893
+ }
37894
+ /**
36661
37895
  * Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
36662
37896
  * express.
36663
37897
  *
@@ -37343,6 +38577,7 @@ var TrackStore = class {
37343
38577
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
37344
38578
  ...t.debug !== void 0 ? { debug: t.debug } : {},
37345
38579
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
38580
+ ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
37346
38581
  ...envelope !== null && dims !== void 0 ? {
37347
38582
  envMinX: envelope.minX,
37348
38583
  envMinY: envelope.minY,
@@ -37378,6 +38613,8 @@ var TrackStore = class {
37378
38613
  const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
37379
38614
  const debug = data["debug"];
37380
38615
  const hasFace = data["hasFace"];
38616
+ /** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
38617
+ const hasRider = data["hasRider"];
37381
38618
  const envMinX = data["envMinX"];
37382
38619
  const envMinY = data["envMinY"];
37383
38620
  const envMaxX = data["envMaxX"];
@@ -37414,6 +38651,7 @@ var TrackStore = class {
37414
38651
  } : {},
37415
38652
  ...typeof debug === "boolean" ? { debug } : {},
37416
38653
  ...typeof hasFace === "boolean" ? { hasFace } : {},
38654
+ ...typeof hasRider === "boolean" ? { hasRider } : {},
37417
38655
  ...envelope !== null ? { envelope } : {}
37418
38656
  };
37419
38657
  }
@@ -39406,6 +40644,8 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
39406
40644
  EventCategory.AgentOffline,
39407
40645
  EventCategory.WorkerOnline,
39408
40646
  EventCategory.WorkerOffline,
40647
+ EventCategory.PipelineNodeInferenceUnavailable,
40648
+ EventCategory.PipelineDetectionBlind,
39409
40649
  EventCategory.UpdateAvailable
39410
40650
  ];
39411
40651
  /**
@@ -44343,6 +45583,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
44343
45583
  stationarySettingsCache = this.settingsCaches.registerDeviceCache("stationary", SETTINGS_CACHE_TTL_MS);
44344
45584
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
44345
45585
  packageDropDetector = null;
45586
+ /**
45587
+ * The DEDICATED package promotion path. The generic stationary registry
45588
+ * needs 4 positions inside 30s; `package-detection` is gated by the
45589
+ * `MotionSettleGovernor` at one observation per 45s, so that criterion is
45590
+ * arithmetically unreachable and `PackageDropDetector` never fired once in
45591
+ * its life. This tracker re-associates a package box across governor rounds
45592
+ * BY POSITION (the track dies between rounds) and feeds the detector the
45593
+ * same appear/depart shape the registry does. Vehicles are untouched.
45594
+ */
45595
+ packageStillness = new PackageStillnessTracker({ newId: () => randomUUID() });
44346
45596
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
44347
45597
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
44348
45598
  /** The per-track close-path orchestrator (S2, refactor spec 2026-07-22):
@@ -44728,6 +45978,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
44728
45978
  listCallerTargetIds: async (userId) => {
44729
45979
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
44730
45980
  },
45981
+ listTrackGroups: (query) => center.listTrackGroups(query),
44731
45982
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
44732
45983
  timelapse: {
44733
45984
  store: center.timelapseStore,
@@ -46082,6 +47333,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46082
47333
  this.trackStore?.clearDevice(data.deviceId);
46083
47334
  this.residents.closeDevice(data.deviceId);
46084
47335
  this.stationaryRegistry?.forgetDevice(data.deviceId);
47336
+ this.packageStillness.forgetDevice(data.deviceId);
47337
+ this.packageDropDetector?.forgetDevice(data.deviceId);
46085
47338
  this.overlayState.clearDevice(data.deviceId);
46086
47339
  this.overlaySynthesisWarnAt.delete(data.deviceId);
46087
47340
  this.forgetDeviceProcessors(data.deviceId);
@@ -46095,6 +47348,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46095
47348
  this.trackStore?.clearDevice(deviceId);
46096
47349
  this.residents.closeDevice(deviceId);
46097
47350
  this.stationaryRegistry?.forgetDevice(deviceId);
47351
+ this.packageStillness.forgetDevice(deviceId);
47352
+ this.packageDropDetector?.forgetDevice(deviceId);
46098
47353
  this.overlayState.clearDevice(deviceId);
46099
47354
  this.overlaySynthesisWarnAt.delete(deviceId);
46100
47355
  this.forgetDeviceProcessors(deviceId);
@@ -46382,7 +47637,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46382
47637
  return;
46383
47638
  }
46384
47639
  this.dropoutSkipsByKey.set(key, 0);
46385
- if (source === "pipeline" && this.stationaryRegistry) await this.resolveDeviceStationarySettings(deviceId);
47640
+ if (source === "pipeline" && this.stationaryRegistry) {
47641
+ await this.resolveDeviceStationarySettings(deviceId);
47642
+ await this.resolveDevicePackageDropSettings(deviceId);
47643
+ }
46386
47644
  const processor = await this.getOrCreateProcessor(deviceId, source);
46387
47645
  const proxy = await this.ensureProxy(deviceId);
46388
47646
  const liveZones = await this.deviceZones.resolve(deviceId);
@@ -46394,15 +47652,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46394
47652
  timestamp: frame.timestamp,
46395
47653
  frame
46396
47654
  });
46397
- for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
46398
- tags: { deviceId },
46399
- meta: {
46400
- vehicleClass: r.vehicleClass,
46401
- personScore: r.personScore,
46402
- vehicleScore: r.vehicleScore,
46403
- overlap: r.overlap
46404
- }
46405
- });
46406
47655
  for (const r of processor.getLastLabelWriteRefusals()) {
46407
47656
  const message = r.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
46408
47657
  this.ctx.logger.info(message, {
@@ -46413,6 +47662,29 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46413
47662
  }
46414
47663
  });
46415
47664
  }
47665
+ for (const fold of result.riderFolds) {
47666
+ const meta = {
47667
+ vehicleClass: fold.vehicleClass,
47668
+ overlap: fold.overlap,
47669
+ personScore: fold.personScore,
47670
+ vehicleScore: fold.vehicleScore
47671
+ };
47672
+ if (fold.trackId === void 0) {
47673
+ this.ctx.logger.info("rider folded but no surviving track emitted this frame", {
47674
+ tags: { deviceId },
47675
+ meta
47676
+ });
47677
+ continue;
47678
+ }
47679
+ this.ctx.logger.info("rider folded into its machine", {
47680
+ tags: { deviceId },
47681
+ meta: {
47682
+ ...meta,
47683
+ trackId: fold.trackId
47684
+ }
47685
+ });
47686
+ this.trackStore?.markRiderFolded(fold.trackId);
47687
+ }
46416
47688
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
46417
47689
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
46418
47690
  deviceId,
@@ -46638,6 +47910,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46638
47910
  this.lastActiveTrackIds.set(key, currentTrackIds);
46639
47911
  if (currentTrackIds.size > 0) this.lastTrackActivityMs.set(deviceId, Date.now());
46640
47912
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
47913
+ const packageSettings = this.packageDropSettingsFromCache(deviceId);
47914
+ const packageClasses = new Set(packageSettings.packageDropClassFilter);
46641
47915
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
46642
47916
  const dims = this.lastFrameDimsByDevice.get(deviceId);
46643
47917
  if (dims && dims.w > 0 && dims.h > 0) {
@@ -46647,6 +47921,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46647
47921
  windowMs: stationarySettings.promotionWindowMs
46648
47922
  };
46649
47923
  for (const t of result.tracked) {
47924
+ if (packageClasses.has(t.className)) continue;
46650
47925
  const active = this.trackStore.peekActive(t.trackId);
46651
47926
  if (!active) continue;
46652
47927
  const { promote } = evaluateStationaryPromotion({
@@ -46670,6 +47945,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
46670
47945
  }
46671
47946
  }
46672
47947
  }
47948
+ if (source === "pipeline") this.runPackageStillness({
47949
+ deviceId,
47950
+ tracked: result.tracked,
47951
+ packageClasses,
47952
+ dwellSec: packageSettings.packageDropDwellSec,
47953
+ frameWidth: result.frameWidth,
47954
+ frameHeight: result.frameHeight,
47955
+ timestamp: result.timestamp
47956
+ });
46673
47957
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
46674
47958
  const dispatcher = this.detailDispatcher;
46675
47959
  const steps = detailSteps;
@@ -47219,6 +48503,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
47219
48503
  stationarySettingsFromCache(deviceId) {
47220
48504
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
47221
48505
  }
48506
+ /** Synchronous read of the per-device package-drop settings — warmed by
48507
+ * `processFrame` before the promotion loop, exactly like the stationary one. */
48508
+ packageDropSettingsFromCache(deviceId) {
48509
+ return this.packageDropSettingsCache.peek(deviceId) ?? PACKAGE_DROP_DEFAULTS;
48510
+ }
47222
48511
  async resolveDevicePackageDropSettings(deviceId) {
47223
48512
  return this.packageDropSettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolvePackageDropSettings));
47224
48513
  }
@@ -48360,6 +49649,73 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
48360
49649
  * tracker + store forget the track so its detections stop re-spawning tracks,
48361
49650
  * and the registry suppresses them from the next frame on.
48362
49651
  */
49652
+ /**
49653
+ * Feed this frame's package-class detections to the dedicated stillness
49654
+ * tracker and bridge its transitions into `PackageDropDetector` — the SAME
49655
+ * `onStationaryChange` contract the generic registry uses, so the detector
49656
+ * has no idea there are two producers.
49657
+ *
49658
+ * Called on every pipeline frame with the (possibly empty) sighting list:
49659
+ * an empty list is what advances the observed clock, and dropping the call
49660
+ * on quiet frames would mean a parcel that was taken away is never reported
49661
+ * as picked up.
49662
+ */
49663
+ runPackageStillness(input) {
49664
+ if (this.packageDropDetector === null) return;
49665
+ const sightings = [];
49666
+ for (const t of input.tracked) {
49667
+ if (!input.packageClasses.has(t.className)) continue;
49668
+ const keyFrameMediaKey = this.residents.keyFrameKey(t.trackId);
49669
+ sightings.push({
49670
+ className: t.className,
49671
+ bbox: { ...t.bbox },
49672
+ trackId: t.trackId,
49673
+ confidence: t.confidence,
49674
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
49675
+ });
49676
+ }
49677
+ const outcome = this.packageStillness.observe({
49678
+ deviceId: input.deviceId,
49679
+ sightings,
49680
+ frameWidth: input.frameWidth,
49681
+ frameHeight: input.frameHeight,
49682
+ timestamp: input.timestamp,
49683
+ minDwellMs: input.dwellSec * 1e3
49684
+ });
49685
+ for (const entry of outcome.promoted) {
49686
+ this.ctx.logger.info("package promoted to stationary (dedicated path)", {
49687
+ tags: { deviceId: input.deviceId },
49688
+ meta: {
49689
+ entryId: entry.id,
49690
+ className: entry.className,
49691
+ bbox: entry.bbox,
49692
+ dwellMs: entry.becameStationaryAt - entry.firstSeenAt,
49693
+ ...entry.sourceTrackId !== void 0 ? { trackId: entry.sourceTrackId } : {},
49694
+ ...entry.confidence !== void 0 ? { confidence: entry.confidence } : {}
49695
+ }
49696
+ });
49697
+ this.packageDropDetector.onStationaryChange({
49698
+ phase: "appeared",
49699
+ entry,
49700
+ timestamp: entry.becameStationaryAt
49701
+ });
49702
+ }
49703
+ for (const entry of outcome.departed) {
49704
+ this.ctx.logger.info("package no longer observed (dedicated path)", {
49705
+ tags: { deviceId: input.deviceId },
49706
+ meta: {
49707
+ entryId: entry.id,
49708
+ className: entry.className,
49709
+ unseenForMs: input.timestamp - entry.lastConfirmedAt
49710
+ }
49711
+ });
49712
+ this.packageDropDetector.onStationaryChange({
49713
+ phase: "departed",
49714
+ entry,
49715
+ timestamp: input.timestamp
49716
+ });
49717
+ }
49718
+ }
48363
49719
  promoteToStationary(input) {
48364
49720
  const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
48365
49721
  const keyFrameMediaKey = this.residents.keyFrameKey(track.trackId);