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