@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.
- package/dist/{dist-_D0jdUh4.mjs → dist-CvWsGsxP.mjs} +105 -3
- package/dist/{dist-DDv-TKUR.js → dist-Cy8lyywE.js} +105 -3
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cbess8m-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-1--pFIrH.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D4P0Tmio.mjs → hostInit-DxmzfwkG.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1501 -145
- package/dist/pipeline-analytics/index.mjs +1493 -137
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
|
5013
|
-
* whole scene
|
|
5014
|
-
*
|
|
5015
|
-
*
|
|
5016
|
-
*
|
|
5017
|
-
*
|
|
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 "<";
|
|
5654
|
+
case ">": return ">";
|
|
5655
|
+
case "&": return "&";
|
|
5656
|
+
case "'": return "'";
|
|
5657
|
+
default: return """;
|
|
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
|
|
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$
|
|
10900
|
+
function escapeXml$1(s) {
|
|
10173
10901
|
return s.replace(/[<>&'"]/g, (ch) => {
|
|
10174
10902
|
switch (ch) {
|
|
10175
10903
|
case "<": return "<";
|
|
@@ -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$
|
|
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$
|
|
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
|
|
19258
|
+
function escapeXml(s) {
|
|
18317
19259
|
return s.replace(/[<>&'"]/g, (ch) => {
|
|
18318
19260
|
switch (ch) {
|
|
18319
19261
|
case "<": return "<";
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
21970
|
-
|
|
21971
|
-
|
|
21972
|
-
|
|
21973
|
-
|
|
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
|
-
}
|
|
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 "<";
|
|
31004
|
-
case ">": return ">";
|
|
31005
|
-
case "&": return "&";
|
|
31006
|
-
case "'": return "'";
|
|
31007
|
-
default: return """;
|
|
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)
|
|
31520
|
-
|
|
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)
|
|
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
|
|
31525
|
-
|
|
31526
|
-
|
|
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)
|
|
31529
|
-
|
|
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))
|
|
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)
|
|
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)
|
|
31586
|
-
|
|
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: [
|
|
33709
|
-
|
|
33710
|
-
|
|
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)
|
|
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);
|