@camstack/addon-post-analysis 1.2.110 → 1.2.115
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-BtpxOtGv.mjs → dist-B13sFjR2.mjs} +22 -9
- package/dist/{dist-RzJseCJP.js → dist-C0uPGtBv.js} +22 -9
- 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-DbTCGBNZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-k_ZbrH_x.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BUnWOUjb.mjs → hostInit-DckJUjuL.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1574 -148
- package/dist/pipeline-analytics/index.mjs +1565 -140
- 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-C0uPGtBv.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");
|
|
@@ -3029,8 +3029,8 @@ async function raceTimeout(call, timeoutMs) {
|
|
|
3029
3029
|
* detail the encoder can see.
|
|
3030
3030
|
*/
|
|
3031
3031
|
async function downscaleJpeg(bytes, maxPx) {
|
|
3032
|
-
const { default: sharp$
|
|
3033
|
-
const out = await sharp$
|
|
3032
|
+
const { default: sharp$16 } = await import("sharp");
|
|
3033
|
+
const out = await sharp$16(Buffer.from(bytes)).resize({
|
|
3034
3034
|
width: maxPx,
|
|
3035
3035
|
height: maxPx,
|
|
3036
3036
|
fit: "inside",
|
|
@@ -5354,24 +5354,17 @@ function buildAlarmButtons(input) {
|
|
|
5354
5354
|
*
|
|
5355
5355
|
* ── TWO EXCLUSIVE MODES (operator decision 2026-08-14, D157) ───────────────
|
|
5356
5356
|
*
|
|
5357
|
-
* **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms
|
|
5358
|
-
*
|
|
5359
|
-
*
|
|
5360
|
-
*
|
|
5361
|
-
*
|
|
5362
|
-
*
|
|
5363
|
-
*
|
|
5364
|
-
*
|
|
5365
|
-
*
|
|
5366
|
-
*
|
|
5367
|
-
*
|
|
5368
|
-
* applies — it is the analyzer's own `classificationMinScore`, applied per
|
|
5369
|
-
* device before a label ever reaches this module.
|
|
5370
|
-
*
|
|
5371
|
-
* The rule's `throttle` cooldown is the ONLY brake in this mode. There is
|
|
5372
|
-
* deliberately no re-arm timer here: a second brake nobody can see in the
|
|
5373
|
-
* editor is how a rule ends up silently not firing, which is the failure this
|
|
5374
|
-
* change exists to remove.
|
|
5357
|
+
* **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms when
|
|
5358
|
+
* `confirmHits` labelled frames land inside `confirmWindowSec` (default 2
|
|
5359
|
+
* in 5 s). That is a COUNT of sparse frames, not a percentage of the
|
|
5360
|
+
* stream: YAMNet still labels only one to three frames per episode, so a
|
|
5361
|
+
* 60%-of-10s window can never fire, but two of those frames two seconds
|
|
5362
|
+
* apart can. A single-frame false positive (the live "Pianti" pattern)
|
|
5363
|
+
* never reaches 2. `confirmHits: 1` is the old first-frame behaviour.
|
|
5364
|
+
*
|
|
5365
|
+
* The per-label confidence floor still applies — it is the analyzer's own
|
|
5366
|
+
* `classificationMinScore`, applied per device before a label ever reaches
|
|
5367
|
+
* this module. The rule's `throttle` cooldown is the OTHER brake.
|
|
5375
5368
|
*
|
|
5376
5369
|
* **LEVEL mode** — the rule NAMES A LEVEL (`dbThreshold`). Unchanged, and the
|
|
5377
5370
|
* window is the whole point: over `samplingSeconds`, at least `hitPercent`% of
|
|
@@ -5434,7 +5427,9 @@ function audioSpecFromCondition(condition) {
|
|
|
5434
5427
|
const mode = require_dist.audioModeOf(condition);
|
|
5435
5428
|
if (mode === "label") return {
|
|
5436
5429
|
mode: "label",
|
|
5437
|
-
labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort()
|
|
5430
|
+
labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort(),
|
|
5431
|
+
confirmHits: condition.confirmHits ?? 2,
|
|
5432
|
+
confirmWindowSec: condition.confirmWindowSec ?? 5
|
|
5438
5433
|
};
|
|
5439
5434
|
const dbThreshold = condition.dbThreshold;
|
|
5440
5435
|
if (mode === "level" && dbThreshold !== void 0) return {
|
|
@@ -5447,7 +5442,7 @@ function audioSpecFromCondition(condition) {
|
|
|
5447
5442
|
}
|
|
5448
5443
|
/** Stable, device-agnostic key of a spec — rules that agree share a window. */
|
|
5449
5444
|
function audioSpecKey(spec) {
|
|
5450
|
-
return spec.mode === "label" ? `label|${spec.labels.join(",")}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
|
|
5445
|
+
return spec.mode === "label" ? `label|${spec.labels.join(",")}|c${String(spec.confirmHits)}|w${String(spec.confirmWindowSec)}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
|
|
5451
5446
|
}
|
|
5452
5447
|
/**
|
|
5453
5448
|
* The watched labels this sample carries, normalized and sorted. Empty = no
|
|
@@ -5472,6 +5467,8 @@ var AudioWatcher = class {
|
|
|
5472
5467
|
watched = /* @__PURE__ */ new Map();
|
|
5473
5468
|
/** LEVEL windows, keyed `${deviceId}|${specKey}`. */
|
|
5474
5469
|
windows = /* @__PURE__ */ new Map();
|
|
5470
|
+
/** LABEL pending hits, keyed `${deviceId}|${specKey}` — timestamps only. */
|
|
5471
|
+
labelHits = /* @__PURE__ */ new Map();
|
|
5475
5472
|
/**
|
|
5476
5473
|
* Replace the watched spec set (rule-driven — recomputed on every rule
|
|
5477
5474
|
* reload). Windows whose spec is no longer watched are DROPPED: a rule
|
|
@@ -5482,9 +5479,9 @@ var AudioWatcher = class {
|
|
|
5482
5479
|
setWatchedSpecs(specs) {
|
|
5483
5480
|
this.watched.clear();
|
|
5484
5481
|
for (const spec of specs) this.watched.set(audioSpecKey(spec), spec);
|
|
5485
|
-
for (const
|
|
5482
|
+
for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) {
|
|
5486
5483
|
const specKey = key.slice(key.indexOf("|") + 1);
|
|
5487
|
-
if (!this.watched.has(specKey))
|
|
5484
|
+
if (!this.watched.has(specKey)) store.delete(key);
|
|
5488
5485
|
}
|
|
5489
5486
|
}
|
|
5490
5487
|
/** True when at least one spec is watched — the caller's cheap no-op gate. */
|
|
@@ -5500,7 +5497,7 @@ var AudioWatcher = class {
|
|
|
5500
5497
|
if (this.watched.size === 0) return [];
|
|
5501
5498
|
const confirmed = [];
|
|
5502
5499
|
for (const [specKey, spec] of this.watched) {
|
|
5503
|
-
const hit = spec.mode === "label" ? observeLabel(deviceId, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
|
|
5500
|
+
const hit = spec.mode === "label" ? this.observeLabel(deviceId, specKey, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
|
|
5504
5501
|
if (hit !== null) confirmed.push(hit);
|
|
5505
5502
|
}
|
|
5506
5503
|
return confirmed;
|
|
@@ -5508,7 +5505,7 @@ var AudioWatcher = class {
|
|
|
5508
5505
|
/** Drop every window of a camera (unbound, removed, audio switched off). */
|
|
5509
5506
|
forgetDevice(deviceId) {
|
|
5510
5507
|
const prefix = `${deviceId}|`;
|
|
5511
|
-
for (const
|
|
5508
|
+
for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) if (key.startsWith(prefix)) store.delete(key);
|
|
5512
5509
|
}
|
|
5513
5510
|
observeLevel(deviceId, specKey, spec, sample, now) {
|
|
5514
5511
|
const key = `${deviceId}|${specKey}`;
|
|
@@ -5557,23 +5554,34 @@ var AudioWatcher = class {
|
|
|
5557
5554
|
spec
|
|
5558
5555
|
};
|
|
5559
5556
|
}
|
|
5557
|
+
/**
|
|
5558
|
+
* LABEL mode: confirm when `confirmHits` labelled frames land inside
|
|
5559
|
+
* `confirmWindowSec`. `confirmHits <= 1` is first-frame fire (stateless).
|
|
5560
|
+
* Confirming re-arms — the next episode needs a fresh count.
|
|
5561
|
+
*/
|
|
5562
|
+
observeLabel(deviceId, specKey, spec, sample, now) {
|
|
5563
|
+
const matched = matchedAudioLabels(sample, spec.labels);
|
|
5564
|
+
if (matched.length === 0) return null;
|
|
5565
|
+
const hit = {
|
|
5566
|
+
deviceId,
|
|
5567
|
+
timestamp: now,
|
|
5568
|
+
labels: matched,
|
|
5569
|
+
...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
|
|
5570
|
+
spec
|
|
5571
|
+
};
|
|
5572
|
+
if (spec.confirmHits <= 1) return hit;
|
|
5573
|
+
const key = `${deviceId}|${specKey}`;
|
|
5574
|
+
const cutoff = now - spec.confirmWindowSec * 1e3;
|
|
5575
|
+
const kept = (this.labelHits.get(key) ?? []).filter((at) => at > cutoff);
|
|
5576
|
+
kept.push(now);
|
|
5577
|
+
if (kept.length < spec.confirmHits) {
|
|
5578
|
+
this.labelHits.set(key, kept);
|
|
5579
|
+
return null;
|
|
5580
|
+
}
|
|
5581
|
+
this.labelHits.delete(key);
|
|
5582
|
+
return hit;
|
|
5583
|
+
}
|
|
5560
5584
|
};
|
|
5561
|
-
/**
|
|
5562
|
-
* LABEL mode: confirm on the first frame carrying a watched label. Stateless
|
|
5563
|
-
* by construction — nothing is remembered between frames, so there is no
|
|
5564
|
-
* window to be not-full and no percentage to fall short of.
|
|
5565
|
-
*/
|
|
5566
|
-
function observeLabel(deviceId, spec, sample, now) {
|
|
5567
|
-
const matched = matchedAudioLabels(sample, spec.labels);
|
|
5568
|
-
if (matched.length === 0) return null;
|
|
5569
|
-
return {
|
|
5570
|
-
deviceId,
|
|
5571
|
-
timestamp: now,
|
|
5572
|
-
labels: matched,
|
|
5573
|
-
...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
|
|
5574
|
-
spec
|
|
5575
|
-
};
|
|
5576
|
-
}
|
|
5577
5585
|
//#endregion
|
|
5578
5586
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5579
5587
|
/**
|
|
@@ -7920,7 +7928,10 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
7920
7928
|
if (policy === "keyFrame") return ownerKind === "track" ? [
|
|
7921
7929
|
"keyFrameSmall",
|
|
7922
7930
|
"keyFrame",
|
|
7923
|
-
"fullFrameBoxed"
|
|
7931
|
+
"fullFrameBoxed",
|
|
7932
|
+
"firstFrame",
|
|
7933
|
+
"lastFrame",
|
|
7934
|
+
"snapshot"
|
|
7924
7935
|
] : [
|
|
7925
7936
|
"fullFrameBoxed",
|
|
7926
7937
|
"fullFrame",
|
|
@@ -9455,8 +9466,8 @@ var NcDispatcher = class {
|
|
|
9455
9466
|
try {
|
|
9456
9467
|
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
9457
9468
|
if (points.length === 0) return null;
|
|
9458
|
-
const { default: sharp$
|
|
9459
|
-
const img = sharp$
|
|
9469
|
+
const { default: sharp$15 } = await import("sharp");
|
|
9470
|
+
const img = sharp$15(Buffer.from(jpeg));
|
|
9460
9471
|
const meta = await img.metadata();
|
|
9461
9472
|
const W = meta.width ?? 0;
|
|
9462
9473
|
const H = meta.height ?? 0;
|
|
@@ -15130,14 +15141,13 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15130
15141
|
/**
|
|
15131
15142
|
* The media a tile is drawn from, best first.
|
|
15132
15143
|
*
|
|
15133
|
-
* `
|
|
15134
|
-
*
|
|
15135
|
-
*
|
|
15136
|
-
*
|
|
15137
|
-
* P2) a box burnt into a pixel is text inside an image — an injection channel
|
|
15138
|
-
* and a source of hallucination. Boxes stay data.
|
|
15144
|
+
* `groupCrop` heads the ladder: it is the group's own clean wide-central-square
|
|
15145
|
+
* over the union bbox, and a drawn rectangle is a permanent lie in a digest
|
|
15146
|
+
* tile. Singletons have no groupCrop and fall through to the member ladder.
|
|
15147
|
+
* `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
|
|
15139
15148
|
*/
|
|
15140
15149
|
var SUMMARY_MEDIA_LADDER = [
|
|
15150
|
+
"groupCrop",
|
|
15141
15151
|
"keyFrameSmall",
|
|
15142
15152
|
"keyFrame",
|
|
15143
15153
|
"snapshot",
|
|
@@ -15219,7 +15229,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
|
15219
15229
|
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
15220
15230
|
return `summary-${ruleId}-${windowEndMs}`;
|
|
15221
15231
|
}
|
|
15222
|
-
var EMPTY_REPORT = {
|
|
15232
|
+
var EMPTY_REPORT$1 = {
|
|
15223
15233
|
produced: 0,
|
|
15224
15234
|
collecting: 0,
|
|
15225
15235
|
barrier: 0,
|
|
@@ -15283,9 +15293,9 @@ var NcSummaryProducer = class {
|
|
|
15283
15293
|
* must not open ten simultaneous render/publish sequences.
|
|
15284
15294
|
*/
|
|
15285
15295
|
async tick() {
|
|
15286
|
-
if (this.tickInFlight) return EMPTY_REPORT;
|
|
15296
|
+
if (this.tickInFlight) return EMPTY_REPORT$1;
|
|
15287
15297
|
this.tickInFlight = true;
|
|
15288
|
-
const report = { ...EMPTY_REPORT };
|
|
15298
|
+
const report = { ...EMPTY_REPORT$1 };
|
|
15289
15299
|
try {
|
|
15290
15300
|
const now = this.deps.now();
|
|
15291
15301
|
const rules = this.deps.store.listEnabled();
|
|
@@ -15996,7 +16006,7 @@ var NcSummaryRuleInputSchema = require_dist.object({
|
|
|
15996
16006
|
deviceIds: DeviceIdsField,
|
|
15997
16007
|
window: NcSummaryWindowSchema,
|
|
15998
16008
|
filters: NcSummaryFiltersSchema.optional(),
|
|
15999
|
-
maxTiles: MaxTilesField.default(
|
|
16009
|
+
maxTiles: MaxTilesField.default(12),
|
|
16000
16010
|
maxAiImages: MaxAiImagesField.default(6),
|
|
16001
16011
|
rank: NcSummaryRankSchema.default("importance"),
|
|
16002
16012
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
@@ -16714,8 +16724,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16714
16724
|
];
|
|
16715
16725
|
if (bbox === void 0) return files;
|
|
16716
16726
|
try {
|
|
16717
|
-
const { default: sharp$
|
|
16718
|
-
const meta = await sharp$
|
|
16727
|
+
const { default: sharp$14 } = await import("sharp");
|
|
16728
|
+
const meta = await sharp$14(Buffer.from(jpeg)).metadata();
|
|
16719
16729
|
const W = meta.width ?? 0;
|
|
16720
16730
|
const H = meta.height ?? 0;
|
|
16721
16731
|
if (W === 0 || H === 0) return files;
|
|
@@ -16723,7 +16733,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16723
16733
|
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
16724
16734
|
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
16725
16735
|
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
16726
|
-
const cropBuf = await sharp$
|
|
16736
|
+
const cropBuf = await sharp$14(Buffer.from(jpeg)).extract({
|
|
16727
16737
|
left,
|
|
16728
16738
|
top,
|
|
16729
16739
|
width,
|
|
@@ -16745,7 +16755,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16745
16755
|
});
|
|
16746
16756
|
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
16747
16757
|
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>`);
|
|
16748
|
-
const boxedBuf = await sharp$
|
|
16758
|
+
const boxedBuf = await sharp$14(Buffer.from(jpeg)).composite([{
|
|
16749
16759
|
input: overlay,
|
|
16750
16760
|
top: 0,
|
|
16751
16761
|
left: 0
|
|
@@ -18362,6 +18372,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
|
18362
18372
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
18363
18373
|
return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
18364
18374
|
}
|
|
18375
|
+
var EMPTY_REPORT = {
|
|
18376
|
+
produced: 0,
|
|
18377
|
+
waiting: 0,
|
|
18378
|
+
alreadyDone: 0,
|
|
18379
|
+
beforeFloor: 0,
|
|
18380
|
+
failed: 0,
|
|
18381
|
+
idle: 0,
|
|
18382
|
+
skippedInFlight: 0
|
|
18383
|
+
};
|
|
18365
18384
|
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
18366
18385
|
function renderFlightKey(ruleId, deviceId) {
|
|
18367
18386
|
return `${ruleId}:${String(deviceId)}`;
|
|
@@ -18369,7 +18388,8 @@ function renderFlightKey(ruleId, deviceId) {
|
|
|
18369
18388
|
var TimelapseScheduler = class {
|
|
18370
18389
|
deps;
|
|
18371
18390
|
timer = null;
|
|
18372
|
-
|
|
18391
|
+
/** True only while this tick is scanning eligibility — not while ffmpeg runs. */
|
|
18392
|
+
scanning = false;
|
|
18373
18393
|
inFlight = /* @__PURE__ */ new Map();
|
|
18374
18394
|
settleMs;
|
|
18375
18395
|
lookbackMs;
|
|
@@ -18416,25 +18436,29 @@ var TimelapseScheduler = class {
|
|
|
18416
18436
|
this.timer = null;
|
|
18417
18437
|
}
|
|
18418
18438
|
}
|
|
18419
|
-
/**
|
|
18439
|
+
/**
|
|
18440
|
+
* One pass over every enabled rule × camera.
|
|
18441
|
+
*
|
|
18442
|
+
* Eligibility is scanned under a short `scanning` lock so two ticks do not
|
|
18443
|
+
* double-launch the same pair. Renders themselves are started together —
|
|
18444
|
+
* awaiting them one-by-one made camera B wait for camera A's ffmpeg
|
|
18445
|
+
* (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
|
|
18446
|
+
* A later tick that meets a pair already in `inFlight` counts it and
|
|
18447
|
+
* moves on, so a 30-minute render does not make the producer go deaf.
|
|
18448
|
+
*/
|
|
18420
18449
|
async tick() {
|
|
18421
|
-
if (this.
|
|
18422
|
-
|
|
18423
|
-
waiting: 0,
|
|
18424
|
-
alreadyDone: 0,
|
|
18425
|
-
beforeFloor: 0,
|
|
18426
|
-
failed: 0,
|
|
18427
|
-
idle: 0
|
|
18428
|
-
};
|
|
18429
|
-
this.tickInFlight = true;
|
|
18450
|
+
if (this.scanning) return { ...EMPTY_REPORT };
|
|
18451
|
+
this.scanning = true;
|
|
18430
18452
|
const report = {
|
|
18431
18453
|
produced: 0,
|
|
18432
18454
|
waiting: 0,
|
|
18433
18455
|
alreadyDone: 0,
|
|
18434
18456
|
beforeFloor: 0,
|
|
18435
18457
|
failed: 0,
|
|
18436
|
-
idle: 0
|
|
18458
|
+
idle: 0,
|
|
18459
|
+
skippedInFlight: 0
|
|
18437
18460
|
};
|
|
18461
|
+
const jobs = [];
|
|
18438
18462
|
try {
|
|
18439
18463
|
const now = this.deps.now();
|
|
18440
18464
|
for (const rule of this.deps.store.listEnabled()) {
|
|
@@ -18469,13 +18493,20 @@ var TimelapseScheduler = class {
|
|
|
18469
18493
|
});
|
|
18470
18494
|
continue;
|
|
18471
18495
|
}
|
|
18472
|
-
if (
|
|
18473
|
-
|
|
18496
|
+
if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
|
|
18497
|
+
report.skippedInFlight += 1;
|
|
18498
|
+
continue;
|
|
18499
|
+
}
|
|
18500
|
+
jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
|
|
18501
|
+
if (ok.produced > 0) report.produced += 1;
|
|
18502
|
+
else report.failed += 1;
|
|
18503
|
+
}));
|
|
18474
18504
|
}
|
|
18475
18505
|
}
|
|
18476
18506
|
} finally {
|
|
18477
|
-
this.
|
|
18507
|
+
this.scanning = false;
|
|
18478
18508
|
}
|
|
18509
|
+
await Promise.all(jobs);
|
|
18479
18510
|
return report;
|
|
18480
18511
|
}
|
|
18481
18512
|
/**
|
|
@@ -27631,6 +27662,35 @@ function pairRiders(detections) {
|
|
|
27631
27662
|
return pairs;
|
|
27632
27663
|
}
|
|
27633
27664
|
//#endregion
|
|
27665
|
+
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
27666
|
+
/** How far the grown box's centroid may sit, as a multiple of the LARGER
|
|
27667
|
+
* diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
|
|
27668
|
+
var GROWTH_CENTROID_FACTOR = 1.35;
|
|
27669
|
+
function intersectionArea(a, b) {
|
|
27670
|
+
const x1 = Math.max(a.x, b.x);
|
|
27671
|
+
const y1 = Math.max(a.y, b.y);
|
|
27672
|
+
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
27673
|
+
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
|
27674
|
+
if (x2 <= x1 || y2 <= y1) return 0;
|
|
27675
|
+
return (x2 - x1) * (y2 - y1);
|
|
27676
|
+
}
|
|
27677
|
+
/** Same-class partial crop growing into (or collapsing from) a full-body box
|
|
27678
|
+
* with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
|
|
27679
|
+
* A SMALL box nested inside a LARGE one is occlusion, not growth — those
|
|
27680
|
+
* stay two tracks (sort-tracker occlusion suite). */
|
|
27681
|
+
function growthAdjacentMatch(track, det) {
|
|
27682
|
+
const trackArea = track.w * track.h;
|
|
27683
|
+
const detArea = det.w * det.h;
|
|
27684
|
+
if (trackArea <= 0 || detArea <= 0) return false;
|
|
27685
|
+
const larger = Math.max(trackArea, detArea);
|
|
27686
|
+
const smaller = Math.min(trackArea, detArea);
|
|
27687
|
+
if (larger / smaller < 1.5) return false;
|
|
27688
|
+
if (intersectionArea(track, det) / smaller >= .5) return false;
|
|
27689
|
+
const tc = bboxCentroid(track);
|
|
27690
|
+
const dc = bboxCentroid(det);
|
|
27691
|
+
return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.max(Math.hypot(track.w, track.h), Math.hypot(det.w, det.h));
|
|
27692
|
+
}
|
|
27693
|
+
//#endregion
|
|
27634
27694
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
27635
27695
|
var DEFAULT_TRACKER_CONFIG = {
|
|
27636
27696
|
iouThreshold: .3,
|
|
@@ -27758,7 +27818,7 @@ var SortTracker = class SortTracker {
|
|
|
27758
27818
|
* and earlier same-frame spawns).
|
|
27759
27819
|
*/
|
|
27760
27820
|
isDuplicateSpawn(det, live) {
|
|
27761
|
-
for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
|
|
27821
|
+
for (const t of live) if (this.dedupCompatible(t.class, det.class) && (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou || growthAdjacentMatch(t.bbox, det.bbox))) return true;
|
|
27762
27822
|
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
27763
27823
|
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
27764
27824
|
}
|
|
@@ -27852,7 +27912,8 @@ var SortTracker = class SortTracker {
|
|
|
27852
27912
|
const dc = bboxCentroid(det.bbox);
|
|
27853
27913
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
27854
27914
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
27855
|
-
|
|
27915
|
+
if (dist <= this.config.rescueCentroidFactor * diag) return true;
|
|
27916
|
+
return growthAdjacentMatch(track.bbox, det.bbox);
|
|
27856
27917
|
}
|
|
27857
27918
|
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
27858
27919
|
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
@@ -28177,6 +28238,7 @@ var StateAnalyzer = class {
|
|
|
28177
28238
|
enteredAt: timestamp,
|
|
28178
28239
|
stationarySince: void 0,
|
|
28179
28240
|
totalDistancePx: 0,
|
|
28241
|
+
startPosition: center,
|
|
28180
28242
|
lastPosition: center,
|
|
28181
28243
|
frameCount: 1
|
|
28182
28244
|
};
|
|
@@ -28196,8 +28258,9 @@ var StateAnalyzer = class {
|
|
|
28196
28258
|
else if (velocity <= this.config.velocityThreshold) {
|
|
28197
28259
|
if (!ts.stationarySince) ts.stationarySince = timestamp;
|
|
28198
28260
|
const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
|
|
28261
|
+
const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
|
|
28199
28262
|
if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
|
|
28200
|
-
else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28263
|
+
else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28201
28264
|
else state = "moving";
|
|
28202
28265
|
} else {
|
|
28203
28266
|
ts.stationarySince = void 0;
|
|
@@ -29030,6 +29093,22 @@ function promoteSemanticBestFrame(deps, input) {
|
|
|
29030
29093
|
};
|
|
29031
29094
|
}
|
|
29032
29095
|
//#endregion
|
|
29096
|
+
//#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
|
|
29097
|
+
/** Which box the semantic best-shot should crop: a plate/face DETAIL box
|
|
29098
|
+
* when we have one, otherwise the subject box. */
|
|
29099
|
+
function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
|
|
29100
|
+
if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
|
|
29101
|
+
return subjectBbox;
|
|
29102
|
+
}
|
|
29103
|
+
//#endregion
|
|
29104
|
+
//#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
|
|
29105
|
+
/** Crop box for a late firstFrame retry: the BIRTH geometry, not the
|
|
29106
|
+
* later frozen/coasted box (track 1b659d56). */
|
|
29107
|
+
function firstFrameRetryBbox(current, birth) {
|
|
29108
|
+
if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
|
|
29109
|
+
return { ...current };
|
|
29110
|
+
}
|
|
29111
|
+
//#endregion
|
|
29033
29112
|
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
29034
29113
|
/**
|
|
29035
29114
|
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
@@ -29244,6 +29323,7 @@ var EMPTY_CASCADE_TALLY = {
|
|
|
29244
29323
|
faces: 0,
|
|
29245
29324
|
plates: 0,
|
|
29246
29325
|
embeddings: 0,
|
|
29326
|
+
groups: 0,
|
|
29247
29327
|
bytesReclaimed: 0
|
|
29248
29328
|
};
|
|
29249
29329
|
/** Fold a leaf store's receipts into a running tally. */
|
|
@@ -29287,6 +29367,13 @@ function addLeafCounts(tally, counts) {
|
|
|
29287
29367
|
bytesReclaimed
|
|
29288
29368
|
};
|
|
29289
29369
|
break;
|
|
29370
|
+
case "groups":
|
|
29371
|
+
next = {
|
|
29372
|
+
...next,
|
|
29373
|
+
groups: next.groups + c.rows,
|
|
29374
|
+
bytesReclaimed
|
|
29375
|
+
};
|
|
29376
|
+
break;
|
|
29290
29377
|
}
|
|
29291
29378
|
}
|
|
29292
29379
|
return next;
|
|
@@ -29300,6 +29387,7 @@ function mergeCascadeTallies(a, b) {
|
|
|
29300
29387
|
faces: a.faces + b.faces,
|
|
29301
29388
|
plates: a.plates + b.plates,
|
|
29302
29389
|
embeddings: a.embeddings + b.embeddings,
|
|
29390
|
+
groups: a.groups + b.groups,
|
|
29303
29391
|
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
29304
29392
|
};
|
|
29305
29393
|
}
|
|
@@ -29348,6 +29436,7 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
29348
29436
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
29349
29437
|
if (stores.plateStore) registry.push(stores.plateStore);
|
|
29350
29438
|
if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
|
|
29439
|
+
if (stores.groupStore) registry.push(stores.groupStore);
|
|
29351
29440
|
return registry;
|
|
29352
29441
|
}
|
|
29353
29442
|
/**
|
|
@@ -31058,7 +31147,8 @@ var OWNER_KINDS = [
|
|
|
31058
31147
|
"identity",
|
|
31059
31148
|
"plate",
|
|
31060
31149
|
"vehicle",
|
|
31061
|
-
"scene"
|
|
31150
|
+
"scene",
|
|
31151
|
+
"group"
|
|
31062
31152
|
];
|
|
31063
31153
|
/**
|
|
31064
31154
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -31807,6 +31897,10 @@ var MediaStore = class {
|
|
|
31807
31897
|
async deleteForTracks(trackIds) {
|
|
31808
31898
|
return this.deleteForOwner("track", trackIds);
|
|
31809
31899
|
}
|
|
31900
|
+
/** Delete the group's OWN crop. The native frame is a pointer and is not here. */
|
|
31901
|
+
async deleteByGroups(groupIds) {
|
|
31902
|
+
return this.deleteForOwnerCounted("group", groupIds);
|
|
31903
|
+
}
|
|
31810
31904
|
/**
|
|
31811
31905
|
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
31812
31906
|
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
@@ -39898,7 +39992,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
39898
39992
|
function planPeriodicMedia(input) {
|
|
39899
39993
|
const appendSnapshot = input.dueSnapshot;
|
|
39900
39994
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
39901
|
-
const
|
|
39995
|
+
const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
39996
|
+
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
39997
|
+
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
39902
39998
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
39903
39999
|
return {
|
|
39904
40000
|
appendSnapshot,
|
|
@@ -40131,6 +40227,1171 @@ var DeferredBirthRegistry = class {
|
|
|
40131
40227
|
this.byDevice.delete(deviceKey);
|
|
40132
40228
|
}
|
|
40133
40229
|
};
|
|
40230
|
+
var AnalyticsGroupRecordSchema = require_dist.object({
|
|
40231
|
+
id: require_dist.string(),
|
|
40232
|
+
deviceId: require_dist.number().int(),
|
|
40233
|
+
openedAt: require_dist.number().int(),
|
|
40234
|
+
closedAt: require_dist.number().int(),
|
|
40235
|
+
timestamp: require_dist.number().int(),
|
|
40236
|
+
memberCount: require_dist.number().int(),
|
|
40237
|
+
memberTrackIds: require_dist.array(require_dist.string()).readonly(),
|
|
40238
|
+
className: require_dist.string(),
|
|
40239
|
+
classes: require_dist.array(require_dist.string()).readonly(),
|
|
40240
|
+
/** Relative event-media path, or null when the group has no picture yet. */
|
|
40241
|
+
mediaUrl: require_dist.string().nullable(),
|
|
40242
|
+
singleton: require_dist.boolean()
|
|
40243
|
+
});
|
|
40244
|
+
var AnalyticsGroupMemberSchema = require_dist.object({
|
|
40245
|
+
trackId: require_dist.string(),
|
|
40246
|
+
deviceId: require_dist.number().int(),
|
|
40247
|
+
className: require_dist.string(),
|
|
40248
|
+
firstSeen: require_dist.number().int(),
|
|
40249
|
+
lastSeen: require_dist.number().int(),
|
|
40250
|
+
mediaUrl: require_dist.string().nullable()
|
|
40251
|
+
});
|
|
40252
|
+
var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: require_dist.array(AnalyticsGroupMemberSchema).readonly() });
|
|
40253
|
+
var ListGroupsInputSchema = require_dist.object({
|
|
40254
|
+
deviceId: require_dist.number().int().optional(),
|
|
40255
|
+
sinceMs: require_dist.number().int().optional(),
|
|
40256
|
+
untilMs: require_dist.number().int().optional(),
|
|
40257
|
+
limit: require_dist.number().int().min(1).max(100).optional(),
|
|
40258
|
+
offset: require_dist.number().int().min(0).optional()
|
|
40259
|
+
});
|
|
40260
|
+
var groupActions = require_dist.defineCustomActions({
|
|
40261
|
+
"analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
|
|
40262
|
+
groups: require_dist.array(AnalyticsGroupRecordSchema).readonly(),
|
|
40263
|
+
hasMore: require_dist.boolean()
|
|
40264
|
+
})),
|
|
40265
|
+
"analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: AnalyticsGroupDetailSchema.nullable() }))
|
|
40266
|
+
});
|
|
40267
|
+
//#endregion
|
|
40268
|
+
//#region src/pipeline-analytics/group/group-query.ts
|
|
40269
|
+
/**
|
|
40270
|
+
* Stamp a relative event-media URL.
|
|
40271
|
+
*
|
|
40272
|
+
* Media keys contain `:` and the data-plane serves them by key. Track ids are
|
|
40273
|
+
* bare and fall through to the existing event/track thumbnail resolver. Never
|
|
40274
|
+
* percent-encode: the handler does not URL-decode the path.
|
|
40275
|
+
*/
|
|
40276
|
+
function stampEventMediaUrl(baseUrl, id) {
|
|
40277
|
+
if (baseUrl === null || baseUrl.length === 0) return null;
|
|
40278
|
+
if (id === null || id === void 0 || id.length === 0) return null;
|
|
40279
|
+
return `${baseUrl}/${id}`;
|
|
40280
|
+
}
|
|
40281
|
+
/** Union of every class on the members; `className` is the first, stable order. */
|
|
40282
|
+
function aggregateGroupClasses(tracks) {
|
|
40283
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40284
|
+
const classes = [];
|
|
40285
|
+
for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
|
|
40286
|
+
if (name.length === 0 || seen.has(name)) continue;
|
|
40287
|
+
seen.add(name);
|
|
40288
|
+
classes.push(name);
|
|
40289
|
+
}
|
|
40290
|
+
return {
|
|
40291
|
+
className: classes[0] ?? "unknown",
|
|
40292
|
+
classes
|
|
40293
|
+
};
|
|
40294
|
+
}
|
|
40295
|
+
function toGroupRecord(input) {
|
|
40296
|
+
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40297
|
+
const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40298
|
+
return {
|
|
40299
|
+
id: input.group.id,
|
|
40300
|
+
deviceId: input.group.deviceId,
|
|
40301
|
+
openedAt: input.group.openedAt,
|
|
40302
|
+
closedAt: input.group.closedAt,
|
|
40303
|
+
timestamp: input.group.timestamp,
|
|
40304
|
+
memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
|
|
40305
|
+
memberTrackIds: [...input.memberTrackIds],
|
|
40306
|
+
className,
|
|
40307
|
+
classes: [...classes],
|
|
40308
|
+
mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
|
|
40309
|
+
singleton: input.memberTrackIds.length <= 1
|
|
40310
|
+
};
|
|
40311
|
+
}
|
|
40312
|
+
function toGroupMember(member, track, eventMediaBaseUrl) {
|
|
40313
|
+
return {
|
|
40314
|
+
trackId: member.trackId,
|
|
40315
|
+
deviceId: member.deviceId,
|
|
40316
|
+
className: track?.className ?? "unknown",
|
|
40317
|
+
firstSeen: track?.firstSeen ?? member.timestamp,
|
|
40318
|
+
lastSeen: track?.lastSeen ?? member.timestamp,
|
|
40319
|
+
mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
|
|
40320
|
+
};
|
|
40321
|
+
}
|
|
40322
|
+
function trackToLite(track) {
|
|
40323
|
+
return {
|
|
40324
|
+
trackId: track.trackId,
|
|
40325
|
+
className: track.className,
|
|
40326
|
+
classes: track.classes,
|
|
40327
|
+
firstSeen: track.firstSeen,
|
|
40328
|
+
lastSeen: track.lastSeen
|
|
40329
|
+
};
|
|
40330
|
+
}
|
|
40331
|
+
async function listGroupRecords(ports, input) {
|
|
40332
|
+
const page = await ports.groupStore.listRecent(input);
|
|
40333
|
+
const groups = [];
|
|
40334
|
+
for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
|
|
40335
|
+
return {
|
|
40336
|
+
groups,
|
|
40337
|
+
hasMore: page.hasMore
|
|
40338
|
+
};
|
|
40339
|
+
}
|
|
40340
|
+
async function getGroupDetail(ports, groupId) {
|
|
40341
|
+
const group = await ports.groupStore.get(groupId);
|
|
40342
|
+
if (group === null) return null;
|
|
40343
|
+
const members = await ports.groupStore.listMembers(groupId);
|
|
40344
|
+
const lites = [];
|
|
40345
|
+
const rows = [];
|
|
40346
|
+
for (const member of members) {
|
|
40347
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40348
|
+
const lite = track === null ? null : trackToLite(track);
|
|
40349
|
+
if (lite !== null) lites.push(lite);
|
|
40350
|
+
rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
|
|
40351
|
+
}
|
|
40352
|
+
return {
|
|
40353
|
+
...toGroupRecord({
|
|
40354
|
+
group,
|
|
40355
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40356
|
+
tracks: lites,
|
|
40357
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40358
|
+
}),
|
|
40359
|
+
members: rows
|
|
40360
|
+
};
|
|
40361
|
+
}
|
|
40362
|
+
async function buildGroupRecord(ports, group) {
|
|
40363
|
+
const members = await ports.groupStore.listMembers(group.id);
|
|
40364
|
+
const lites = [];
|
|
40365
|
+
for (const member of members) {
|
|
40366
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40367
|
+
if (track !== null) lites.push(trackToLite(track));
|
|
40368
|
+
}
|
|
40369
|
+
return toGroupRecord({
|
|
40370
|
+
group,
|
|
40371
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40372
|
+
tracks: lites,
|
|
40373
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40374
|
+
});
|
|
40375
|
+
}
|
|
40376
|
+
//#endregion
|
|
40377
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40378
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40379
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40380
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40381
|
+
}
|
|
40382
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40383
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40384
|
+
W: frameWidth,
|
|
40385
|
+
H: frameHeight
|
|
40386
|
+
});
|
|
40387
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40388
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40389
|
+
}
|
|
40390
|
+
var MATCH_WINDOW_MS = 1e3;
|
|
40391
|
+
var MOVE_EPS = .35;
|
|
40392
|
+
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40393
|
+
function isEligibleMember(track) {
|
|
40394
|
+
if (track.closedReason === "stationary-promotion") return false;
|
|
40395
|
+
if (track.className === "audio") return false;
|
|
40396
|
+
return true;
|
|
40397
|
+
}
|
|
40398
|
+
function temporallyOverlap(a, b, slackMs) {
|
|
40399
|
+
return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
|
|
40400
|
+
}
|
|
40401
|
+
/** Connected components of temporal overlap (+ slack) per camera. */
|
|
40402
|
+
function sessionsOf(tracks) {
|
|
40403
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
40404
|
+
for (const t of tracks) {
|
|
40405
|
+
const list = byDevice.get(t.deviceId);
|
|
40406
|
+
if (list) list.push(t);
|
|
40407
|
+
else byDevice.set(t.deviceId, [t]);
|
|
40408
|
+
}
|
|
40409
|
+
const sessions = [];
|
|
40410
|
+
for (const deviceTracks of byDevice.values()) {
|
|
40411
|
+
const n = deviceTracks.length;
|
|
40412
|
+
const parent = deviceTracks.map((_, i) => i);
|
|
40413
|
+
const find = (i) => {
|
|
40414
|
+
let cur = i;
|
|
40415
|
+
while (parent[cur] !== cur) cur = parent[cur];
|
|
40416
|
+
let walk = i;
|
|
40417
|
+
while (parent[walk] !== cur) {
|
|
40418
|
+
const next = parent[walk];
|
|
40419
|
+
parent[walk] = cur;
|
|
40420
|
+
walk = next;
|
|
40421
|
+
}
|
|
40422
|
+
return cur;
|
|
40423
|
+
};
|
|
40424
|
+
const union = (i, j) => {
|
|
40425
|
+
const ri = find(i);
|
|
40426
|
+
const rj = find(j);
|
|
40427
|
+
if (ri !== rj) parent[ri] = rj;
|
|
40428
|
+
};
|
|
40429
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
|
|
40430
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
40431
|
+
for (let i = 0; i < n; i++) {
|
|
40432
|
+
const root = find(i);
|
|
40433
|
+
const bucket = buckets.get(root);
|
|
40434
|
+
if (bucket) bucket.push(deviceTracks[i]);
|
|
40435
|
+
else buckets.set(root, [deviceTracks[i]]);
|
|
40436
|
+
}
|
|
40437
|
+
sessions.push(...buckets.values());
|
|
40438
|
+
}
|
|
40439
|
+
return sessions;
|
|
40440
|
+
}
|
|
40441
|
+
function diag(bbox) {
|
|
40442
|
+
return Math.hypot(bbox.w, bbox.h);
|
|
40443
|
+
}
|
|
40444
|
+
function nearestPosition(positions, t) {
|
|
40445
|
+
let best;
|
|
40446
|
+
let bestDt = 1001;
|
|
40447
|
+
for (const p of positions) {
|
|
40448
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40449
|
+
if (dt <= 1e3 && dt < bestDt) {
|
|
40450
|
+
best = p;
|
|
40451
|
+
bestDt = dt;
|
|
40452
|
+
}
|
|
40453
|
+
}
|
|
40454
|
+
return best;
|
|
40455
|
+
}
|
|
40456
|
+
function meanDiag(positions) {
|
|
40457
|
+
if (positions.length === 0) return 0;
|
|
40458
|
+
let sum = 0;
|
|
40459
|
+
for (const p of positions) sum += diag(p.bbox);
|
|
40460
|
+
return sum / positions.length;
|
|
40461
|
+
}
|
|
40462
|
+
function netDisplacement(track) {
|
|
40463
|
+
const first = track.positions[0];
|
|
40464
|
+
const last = track.positions[track.positions.length - 1];
|
|
40465
|
+
if (!first || !last) return {
|
|
40466
|
+
x: 0,
|
|
40467
|
+
y: 0
|
|
40468
|
+
};
|
|
40469
|
+
const a = bboxCentroid(first.bbox);
|
|
40470
|
+
const b = bboxCentroid(last.bbox);
|
|
40471
|
+
return {
|
|
40472
|
+
x: b.x - a.x,
|
|
40473
|
+
y: b.y - a.y
|
|
40474
|
+
};
|
|
40475
|
+
}
|
|
40476
|
+
function vecLen(v) {
|
|
40477
|
+
return Math.hypot(v.x, v.y);
|
|
40478
|
+
}
|
|
40479
|
+
function cosine(a, b) {
|
|
40480
|
+
const la = vecLen(a);
|
|
40481
|
+
const lb = vecLen(b);
|
|
40482
|
+
if (la === 0 || lb === 0) return 1;
|
|
40483
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40484
|
+
}
|
|
40485
|
+
function relativeMotion(track) {
|
|
40486
|
+
const mean = meanDiag(track.positions);
|
|
40487
|
+
if (mean <= 0) return 0;
|
|
40488
|
+
return vecLen(netDisplacement(track)) / mean;
|
|
40489
|
+
}
|
|
40490
|
+
/** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
|
|
40491
|
+
function pairTogether(a, b) {
|
|
40492
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen);
|
|
40493
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
|
|
40494
|
+
if (overlapEnd < overlapStart) return false;
|
|
40495
|
+
let joint = 0;
|
|
40496
|
+
let close = 0;
|
|
40497
|
+
for (let t = overlapStart; t <= overlapEnd; t += 500) {
|
|
40498
|
+
const pa = nearestPosition(a.positions, t);
|
|
40499
|
+
const pb = nearestPosition(b.positions, t);
|
|
40500
|
+
if (!pa || !pb) continue;
|
|
40501
|
+
joint++;
|
|
40502
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40503
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40504
|
+
const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40505
|
+
const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
|
|
40506
|
+
if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
|
|
40507
|
+
}
|
|
40508
|
+
if (joint === 0) return false;
|
|
40509
|
+
if (close / joint < .5) return false;
|
|
40510
|
+
const aMoves = relativeMotion(a) >= MOVE_EPS;
|
|
40511
|
+
const bMoves = relativeMotion(b) >= MOVE_EPS;
|
|
40512
|
+
if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
|
|
40513
|
+
return true;
|
|
40514
|
+
}
|
|
40515
|
+
function clusterGroups(tracks) {
|
|
40516
|
+
const eligible = tracks.filter(isEligibleMember);
|
|
40517
|
+
const out = [];
|
|
40518
|
+
for (const session of sessionsOf(eligible)) {
|
|
40519
|
+
const pairable = [];
|
|
40520
|
+
for (const t of session) if (t.positions.length < 2) out.push({
|
|
40521
|
+
deviceId: t.deviceId,
|
|
40522
|
+
memberTrackIds: [t.trackId]
|
|
40523
|
+
});
|
|
40524
|
+
else pairable.push(t);
|
|
40525
|
+
if (pairable.length === 0) continue;
|
|
40526
|
+
const parent = pairable.map((_, i) => i);
|
|
40527
|
+
const find = (i) => {
|
|
40528
|
+
let cur = i;
|
|
40529
|
+
while (parent[cur] !== cur) cur = parent[cur];
|
|
40530
|
+
let walk = i;
|
|
40531
|
+
while (parent[walk] !== cur) {
|
|
40532
|
+
const next = parent[walk];
|
|
40533
|
+
parent[walk] = cur;
|
|
40534
|
+
walk = next;
|
|
40535
|
+
}
|
|
40536
|
+
return cur;
|
|
40537
|
+
};
|
|
40538
|
+
const union = (i, j) => {
|
|
40539
|
+
const ri = find(i);
|
|
40540
|
+
const rj = find(j);
|
|
40541
|
+
if (ri !== rj) parent[ri] = rj;
|
|
40542
|
+
};
|
|
40543
|
+
for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
|
|
40544
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
40545
|
+
for (let i = 0; i < pairable.length; i++) {
|
|
40546
|
+
const root = find(i);
|
|
40547
|
+
const bucket = buckets.get(root);
|
|
40548
|
+
const id = pairable[i].trackId;
|
|
40549
|
+
if (bucket) bucket.push(id);
|
|
40550
|
+
else buckets.set(root, [id]);
|
|
40551
|
+
}
|
|
40552
|
+
const deviceId = pairable[0].deviceId;
|
|
40553
|
+
for (const memberTrackIds of buckets.values()) out.push({
|
|
40554
|
+
deviceId,
|
|
40555
|
+
memberTrackIds
|
|
40556
|
+
});
|
|
40557
|
+
}
|
|
40558
|
+
return out;
|
|
40559
|
+
}
|
|
40560
|
+
//#endregion
|
|
40561
|
+
//#region src/pipeline-analytics/group/group-instant.ts
|
|
40562
|
+
/**
|
|
40563
|
+
* Chosen instant + union bbox for a group, at close, over stored positions[]
|
|
40564
|
+
* (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
|
|
40565
|
+
*
|
|
40566
|
+
* Tier order — the first that separates two candidates decides:
|
|
40567
|
+
* 1. completeness (members with a position within ±1 s)
|
|
40568
|
+
* 2. square containment of the union (D212)
|
|
40569
|
+
* 3. semantic (a member's confirmed recognition)
|
|
40570
|
+
* 4. edge-clear (union touches no frame border)
|
|
40571
|
+
* 5. effective score = mean(confidence) × containment × sizeFactor
|
|
40572
|
+
*
|
|
40573
|
+
* Retrospective. No new capture path.
|
|
40574
|
+
*/
|
|
40575
|
+
function nearest(positions, t, windowMs) {
|
|
40576
|
+
let best;
|
|
40577
|
+
let bestDt = windowMs + 1;
|
|
40578
|
+
for (const p of positions) {
|
|
40579
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40580
|
+
if (dt <= windowMs && dt < bestDt) {
|
|
40581
|
+
best = p;
|
|
40582
|
+
bestDt = dt;
|
|
40583
|
+
}
|
|
40584
|
+
}
|
|
40585
|
+
return best;
|
|
40586
|
+
}
|
|
40587
|
+
function unionOf(boxes) {
|
|
40588
|
+
if (boxes.length === 0) return null;
|
|
40589
|
+
let x0 = Infinity;
|
|
40590
|
+
let y0 = Infinity;
|
|
40591
|
+
let x1 = -Infinity;
|
|
40592
|
+
let y1 = -Infinity;
|
|
40593
|
+
for (const b of boxes) {
|
|
40594
|
+
x0 = Math.min(x0, b.x);
|
|
40595
|
+
y0 = Math.min(y0, b.y);
|
|
40596
|
+
x1 = Math.max(x1, b.x + b.w);
|
|
40597
|
+
y1 = Math.max(y1, b.y + b.h);
|
|
40598
|
+
}
|
|
40599
|
+
return {
|
|
40600
|
+
x: x0,
|
|
40601
|
+
y: y0,
|
|
40602
|
+
w: x1 - x0,
|
|
40603
|
+
h: y1 - y0
|
|
40604
|
+
};
|
|
40605
|
+
}
|
|
40606
|
+
function touchesBorder(box, frame, tol = 1) {
|
|
40607
|
+
return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
|
|
40608
|
+
}
|
|
40609
|
+
function scoreInstant(members, t, frame) {
|
|
40610
|
+
const boxes = [];
|
|
40611
|
+
let semantic = false;
|
|
40612
|
+
let confSum = 0;
|
|
40613
|
+
let confN = 0;
|
|
40614
|
+
for (const m of members) {
|
|
40615
|
+
const p = nearest(m.positions, t, MATCH_WINDOW_MS);
|
|
40616
|
+
if (!p) continue;
|
|
40617
|
+
boxes.push(p.bbox);
|
|
40618
|
+
if (m.semantic === true) semantic = true;
|
|
40619
|
+
if (m.confidence !== void 0) {
|
|
40620
|
+
confSum += m.confidence;
|
|
40621
|
+
confN += 1;
|
|
40622
|
+
}
|
|
40623
|
+
}
|
|
40624
|
+
const union = unionOf(boxes);
|
|
40625
|
+
if (!union) return null;
|
|
40626
|
+
const square = centralSquareContainmentFraction(union, {
|
|
40627
|
+
W: frame.width,
|
|
40628
|
+
H: frame.height
|
|
40629
|
+
});
|
|
40630
|
+
const containment = bboxContainment(union, frame.width, frame.height);
|
|
40631
|
+
const size = bboxSizeScore(union, frame.width, frame.height);
|
|
40632
|
+
const meanConf = confN > 0 ? confSum / confN : 1;
|
|
40633
|
+
return {
|
|
40634
|
+
timestamp: t,
|
|
40635
|
+
union,
|
|
40636
|
+
visibleCount: boxes.length,
|
|
40637
|
+
square,
|
|
40638
|
+
semantic,
|
|
40639
|
+
edgeClear: !touchesBorder(union, frame),
|
|
40640
|
+
effective: meanConf * containment * size
|
|
40641
|
+
};
|
|
40642
|
+
}
|
|
40643
|
+
function better(a, b) {
|
|
40644
|
+
if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
|
|
40645
|
+
if (a.square !== b.square) return a.square > b.square ? a : b;
|
|
40646
|
+
if (a.semantic !== b.semantic) return a.semantic ? a : b;
|
|
40647
|
+
if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
|
|
40648
|
+
if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
|
|
40649
|
+
return a.timestamp <= b.timestamp ? a : b;
|
|
40650
|
+
}
|
|
40651
|
+
function chooseGroupInstant(members, frame) {
|
|
40652
|
+
if (members.length === 0) return null;
|
|
40653
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
40654
|
+
for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
|
|
40655
|
+
let best = null;
|
|
40656
|
+
for (const t of candidates) {
|
|
40657
|
+
const scored = scoreInstant(members, t, frame);
|
|
40658
|
+
if (!scored) continue;
|
|
40659
|
+
best = best === null ? scored : better(scored, best);
|
|
40660
|
+
}
|
|
40661
|
+
if (!best) return null;
|
|
40662
|
+
const w = Math.max(frame.width, 1);
|
|
40663
|
+
const h = Math.max(frame.height, 1);
|
|
40664
|
+
return {
|
|
40665
|
+
timestamp: best.timestamp,
|
|
40666
|
+
union: best.union,
|
|
40667
|
+
ux: best.union.x / w,
|
|
40668
|
+
uy: best.union.y / h,
|
|
40669
|
+
uw: best.union.w / w,
|
|
40670
|
+
uh: best.union.h / h,
|
|
40671
|
+
visibleCount: best.visibleCount
|
|
40672
|
+
};
|
|
40673
|
+
}
|
|
40674
|
+
/** Longest-lived member that owns a clean full frame; ties break on trackId. */
|
|
40675
|
+
function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
40676
|
+
const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
|
|
40677
|
+
if (eligible.length === 0) return null;
|
|
40678
|
+
return eligible.toSorted((a, b) => {
|
|
40679
|
+
const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
|
|
40680
|
+
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40681
|
+
})[0].trackId;
|
|
40682
|
+
}
|
|
40683
|
+
//#endregion
|
|
40684
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40685
|
+
/**
|
|
40686
|
+
* Session close → sealed groups. A group is born here, never live.
|
|
40687
|
+
*
|
|
40688
|
+
* Session = temporal chain + 3 s slack, per camera. Close = every overlapping
|
|
40689
|
+
* track is persisted AND the newest lastSeen is older than the idle cutoff
|
|
40690
|
+
* (the Phase-1 burst's 30 s, reused — not a second clock).
|
|
40691
|
+
*
|
|
40692
|
+
* Merge and split cannot occur: connected components of a closed session is a
|
|
40693
|
+
* partition. Say it out loud so a later live-group attempt does not reach
|
|
40694
|
+
* for them here.
|
|
40695
|
+
*/
|
|
40696
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40697
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40698
|
+
function overlaps(a, b) {
|
|
40699
|
+
return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
|
|
40700
|
+
}
|
|
40701
|
+
function sessionSpan(tracks) {
|
|
40702
|
+
return {
|
|
40703
|
+
firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
|
|
40704
|
+
lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
|
|
40705
|
+
};
|
|
40706
|
+
}
|
|
40707
|
+
/**
|
|
40708
|
+
* Sessions whose members are all in `closed` and whose newest lastSeen is
|
|
40709
|
+
* older than `idleMs`. An overlapping ACTIVE track keeps the session open.
|
|
40710
|
+
*/
|
|
40711
|
+
function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40712
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
40713
|
+
for (const t of closed) {
|
|
40714
|
+
const list = byDevice.get(t.deviceId);
|
|
40715
|
+
if (list) list.push(t);
|
|
40716
|
+
else byDevice.set(t.deviceId, [t]);
|
|
40717
|
+
}
|
|
40718
|
+
const ready = [];
|
|
40719
|
+
for (const [deviceId, tracks] of byDevice) {
|
|
40720
|
+
const deviceActive = active.filter((a) => a.deviceId === deviceId);
|
|
40721
|
+
for (const session of sessionsOf(tracks)) {
|
|
40722
|
+
const span = sessionSpan(session);
|
|
40723
|
+
if (now < span.lastSeen + idleMs) continue;
|
|
40724
|
+
if (deviceActive.some((a) => overlaps(span, a))) continue;
|
|
40725
|
+
ready.push(session);
|
|
40726
|
+
}
|
|
40727
|
+
}
|
|
40728
|
+
return ready;
|
|
40729
|
+
}
|
|
40730
|
+
function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40731
|
+
return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
|
|
40732
|
+
}
|
|
40733
|
+
//#endregion
|
|
40734
|
+
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
40735
|
+
var DEFAULT_FRAME = {
|
|
40736
|
+
width: 640,
|
|
40737
|
+
height: 360
|
|
40738
|
+
};
|
|
40739
|
+
var SEAL_LOOKBACK_MS = 10 * 6e4;
|
|
40740
|
+
var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
|
|
40741
|
+
var QUERY_LIMIT = 2e3;
|
|
40742
|
+
function toInstant(t) {
|
|
40743
|
+
return {
|
|
40744
|
+
trackId: t.trackId,
|
|
40745
|
+
deviceId: t.deviceId,
|
|
40746
|
+
className: t.className,
|
|
40747
|
+
firstSeen: t.firstSeen,
|
|
40748
|
+
lastSeen: t.lastSeen,
|
|
40749
|
+
positions: t.positions,
|
|
40750
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
40751
|
+
semantic: t.hasFace === true || t.hasPlate === true,
|
|
40752
|
+
confidence: t.importance
|
|
40753
|
+
};
|
|
40754
|
+
}
|
|
40755
|
+
var GroupSealer = class {
|
|
40756
|
+
deps;
|
|
40757
|
+
constructor(deps) {
|
|
40758
|
+
this.deps = deps;
|
|
40759
|
+
}
|
|
40760
|
+
async sealRecent() {
|
|
40761
|
+
const now = this.deps.now();
|
|
40762
|
+
return this.sealSince(now - SEAL_LOOKBACK_MS, now);
|
|
40763
|
+
}
|
|
40764
|
+
async backfill() {
|
|
40765
|
+
const now = this.deps.now();
|
|
40766
|
+
return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
|
|
40767
|
+
}
|
|
40768
|
+
async sealSince(since, now) {
|
|
40769
|
+
let written = 0;
|
|
40770
|
+
let devices;
|
|
40771
|
+
try {
|
|
40772
|
+
devices = await this.deps.trackStore.listDeviceIds();
|
|
40773
|
+
} catch (err) {
|
|
40774
|
+
this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
|
|
40775
|
+
return 0;
|
|
40776
|
+
}
|
|
40777
|
+
for (const deviceId of devices) try {
|
|
40778
|
+
written += await this.sealDevice(deviceId, since, now);
|
|
40779
|
+
} catch (err) {
|
|
40780
|
+
this.deps.logger.warn("group sealer: device failed", {
|
|
40781
|
+
tags: { deviceId },
|
|
40782
|
+
meta: { error: String(err) }
|
|
40783
|
+
});
|
|
40784
|
+
}
|
|
40785
|
+
return written;
|
|
40786
|
+
}
|
|
40787
|
+
async sealDevice(deviceId, since, now) {
|
|
40788
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
40789
|
+
deviceId,
|
|
40790
|
+
since,
|
|
40791
|
+
until: now,
|
|
40792
|
+
limit: QUERY_LIMIT,
|
|
40793
|
+
projection: "full"
|
|
40794
|
+
});
|
|
40795
|
+
if (closed.length === 0) return 0;
|
|
40796
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
40797
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
40798
|
+
if (pending.length === 0) return 0;
|
|
40799
|
+
const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
|
|
40800
|
+
deviceId,
|
|
40801
|
+
firstSeen: t.firstSeen,
|
|
40802
|
+
lastSeen: t.lastSeen
|
|
40803
|
+
}));
|
|
40804
|
+
const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
|
|
40805
|
+
if (partitions.length === 0) {
|
|
40806
|
+
if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
|
|
40807
|
+
tags: { deviceId },
|
|
40808
|
+
meta: {
|
|
40809
|
+
dropped: pending.length,
|
|
40810
|
+
reason: "no-eligible-members"
|
|
40811
|
+
}
|
|
40812
|
+
});
|
|
40813
|
+
return 0;
|
|
40814
|
+
}
|
|
40815
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40816
|
+
let written = 0;
|
|
40817
|
+
for (const part of partitions) {
|
|
40818
|
+
const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
40819
|
+
if (members.length === 0) continue;
|
|
40820
|
+
if (members.every((m) => already.has(m.trackId))) continue;
|
|
40821
|
+
try {
|
|
40822
|
+
await this.persistPartition(members);
|
|
40823
|
+
written += 1;
|
|
40824
|
+
} catch (err) {
|
|
40825
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
40826
|
+
tags: { deviceId },
|
|
40827
|
+
meta: {
|
|
40828
|
+
members: members.map((m) => m.trackId),
|
|
40829
|
+
error: String(err)
|
|
40830
|
+
}
|
|
40831
|
+
});
|
|
40832
|
+
}
|
|
40833
|
+
}
|
|
40834
|
+
return written;
|
|
40835
|
+
}
|
|
40836
|
+
async persistPartition(members) {
|
|
40837
|
+
const deviceId = members[0].deviceId;
|
|
40838
|
+
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
40839
|
+
const instantMembers = members.map(toInstant);
|
|
40840
|
+
const instant = chooseGroupInstant(instantMembers, frame);
|
|
40841
|
+
const openedAt = Math.min(...members.map((m) => m.firstSeen));
|
|
40842
|
+
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
40843
|
+
const timestamp = instant?.timestamp ?? closedAt;
|
|
40844
|
+
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
40845
|
+
trackId: m.trackId,
|
|
40846
|
+
rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
|
|
40847
|
+
})));
|
|
40848
|
+
const ownsClean = (trackId) => {
|
|
40849
|
+
return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
|
|
40850
|
+
};
|
|
40851
|
+
const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
|
|
40852
|
+
let nativeKey = null;
|
|
40853
|
+
if (anchorTrackId !== null) {
|
|
40854
|
+
const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
|
|
40855
|
+
nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
|
|
40856
|
+
}
|
|
40857
|
+
const draft = await this.deps.groupStore.insert({
|
|
40858
|
+
deviceId,
|
|
40859
|
+
timestamp,
|
|
40860
|
+
openedAt,
|
|
40861
|
+
closedAt,
|
|
40862
|
+
members: members.map((m) => ({
|
|
40863
|
+
trackId: m.trackId,
|
|
40864
|
+
firstSeen: m.firstSeen
|
|
40865
|
+
})),
|
|
40866
|
+
anchorTrackId,
|
|
40867
|
+
ux: instant?.ux ?? 0,
|
|
40868
|
+
uy: instant?.uy ?? 0,
|
|
40869
|
+
uw: instant?.uw ?? 0,
|
|
40870
|
+
uh: instant?.uh ?? 0,
|
|
40871
|
+
frameWidth: frame.width,
|
|
40872
|
+
frameHeight: frame.height,
|
|
40873
|
+
mediaKey: null,
|
|
40874
|
+
nativeKey
|
|
40875
|
+
});
|
|
40876
|
+
if (members.length < 2 || instant === null) return;
|
|
40877
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp);
|
|
40878
|
+
if (source === null) return;
|
|
40879
|
+
try {
|
|
40880
|
+
const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
40881
|
+
if (raw.info.channels !== 3) return;
|
|
40882
|
+
const union = {
|
|
40883
|
+
x: instant.ux * raw.info.width,
|
|
40884
|
+
y: instant.uy * raw.info.height,
|
|
40885
|
+
w: instant.uw * raw.info.width,
|
|
40886
|
+
h: instant.uh * raw.info.height
|
|
40887
|
+
};
|
|
40888
|
+
const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
|
|
40889
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
40890
|
+
deviceId,
|
|
40891
|
+
ownerKind: "group",
|
|
40892
|
+
ownerId: draft.id,
|
|
40893
|
+
kind: "thumbnail",
|
|
40894
|
+
timestamp,
|
|
40895
|
+
data: crop
|
|
40896
|
+
});
|
|
40897
|
+
await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
|
|
40898
|
+
} catch (err) {
|
|
40899
|
+
this.deps.logger.warn("group sealer: crop failed", {
|
|
40900
|
+
tags: { deviceId },
|
|
40901
|
+
meta: {
|
|
40902
|
+
groupId: draft.id,
|
|
40903
|
+
error: String(err)
|
|
40904
|
+
}
|
|
40905
|
+
});
|
|
40906
|
+
}
|
|
40907
|
+
}
|
|
40908
|
+
async pickCleanPixels(mediaInfos, instantMs) {
|
|
40909
|
+
const candidates = [];
|
|
40910
|
+
for (const info of mediaInfos) for (const row of info.rows) {
|
|
40911
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40912
|
+
if (rank < 0) continue;
|
|
40913
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40914
|
+
if (dt > 2e3) continue;
|
|
40915
|
+
candidates.push({
|
|
40916
|
+
key: row.key,
|
|
40917
|
+
dt,
|
|
40918
|
+
rank
|
|
40919
|
+
});
|
|
40920
|
+
}
|
|
40921
|
+
const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40922
|
+
if (chosen === void 0) return null;
|
|
40923
|
+
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
40924
|
+
if (file === null) return null;
|
|
40925
|
+
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
40926
|
+
}
|
|
40927
|
+
};
|
|
40928
|
+
//#endregion
|
|
40929
|
+
//#region src/pipeline-analytics/group/group-store.ts
|
|
40930
|
+
/**
|
|
40931
|
+
* The group ENTITY store — a co-moving partition of one session, with one
|
|
40932
|
+
* picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
|
|
40933
|
+
*
|
|
40934
|
+
* Two tables, two questions:
|
|
40935
|
+
*
|
|
40936
|
+
* - `pipeline-analytics:track-groups` is the group row. It has N members and
|
|
40937
|
+
* no `trackId` column; it is owned by its members (owner-indirection).
|
|
40938
|
+
* - `pipeline-analytics:track-group-members` is one row per member. The column
|
|
40939
|
+
* is named exactly `trackId` so the classification derives `track-owned`.
|
|
40940
|
+
*
|
|
40941
|
+
* `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
|
|
40942
|
+
* they are nulled when the anchor member is deleted alone. Everything else is
|
|
40943
|
+
* written once. Re-anchoring is deliberately not done.
|
|
40944
|
+
*
|
|
40945
|
+
* No age clock. A group dies when its last member does; a group with one live
|
|
40946
|
+
* member does not, and it never keeps a dead link.
|
|
40947
|
+
*/
|
|
40948
|
+
/**
|
|
40949
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
40950
|
+
* write="one row per co-moving group at session close — sealed, never live.
|
|
40951
|
+
* Membership is a partition of one camera's eligible tracks. mediaKey is
|
|
40952
|
+
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
40953
|
+
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
40954
|
+
* nativeKey are the only fields rewritten after birth."
|
|
40955
|
+
* retention="NO age clock (D61). Rows leave with their last member through
|
|
40956
|
+
* TrackGroupStore.deleteByTracks: memberships first; zero survivors
|
|
40957
|
+
* deletes the group's media then the row; survivors plus a lost anchor
|
|
40958
|
+
* null the pointer. The orphan audit verifies the same via a second
|
|
40959
|
+
* resolver (groupId → any member still present)."
|
|
40960
|
+
*/
|
|
40961
|
+
var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
|
|
40962
|
+
/**
|
|
40963
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
40964
|
+
* write="one row per member at group birth — id is '<groupId>:<trackId>'.
|
|
40965
|
+
* timestamp is the member's firstSeen so the orphan audit has a clock
|
|
40966
|
+
* column. Never rewritten."
|
|
40967
|
+
* retention="TRACK-OWNED and clockless (D61): the column is named exactly
|
|
40968
|
+
* trackId, so classification derives ownership and the cascade deletes
|
|
40969
|
+
* the row with its track. A group with surviving members stays."
|
|
40970
|
+
*/
|
|
40971
|
+
var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
|
|
40972
|
+
var TRACK_GROUPS_COLUMNS = [
|
|
40973
|
+
{
|
|
40974
|
+
name: "id",
|
|
40975
|
+
type: "TEXT",
|
|
40976
|
+
primaryKey: true,
|
|
40977
|
+
notNull: true
|
|
40978
|
+
},
|
|
40979
|
+
{
|
|
40980
|
+
name: "deviceId",
|
|
40981
|
+
type: "INTEGER",
|
|
40982
|
+
notNull: true
|
|
40983
|
+
},
|
|
40984
|
+
{
|
|
40985
|
+
name: "timestamp",
|
|
40986
|
+
type: "INTEGER",
|
|
40987
|
+
notNull: true
|
|
40988
|
+
},
|
|
40989
|
+
{
|
|
40990
|
+
name: "openedAt",
|
|
40991
|
+
type: "INTEGER",
|
|
40992
|
+
notNull: true
|
|
40993
|
+
},
|
|
40994
|
+
{
|
|
40995
|
+
name: "closedAt",
|
|
40996
|
+
type: "INTEGER",
|
|
40997
|
+
notNull: true
|
|
40998
|
+
},
|
|
40999
|
+
{
|
|
41000
|
+
name: "memberCount",
|
|
41001
|
+
type: "INTEGER",
|
|
41002
|
+
notNull: true
|
|
41003
|
+
},
|
|
41004
|
+
{
|
|
41005
|
+
name: "anchorTrackId",
|
|
41006
|
+
type: "TEXT"
|
|
41007
|
+
},
|
|
41008
|
+
{
|
|
41009
|
+
name: "ux",
|
|
41010
|
+
type: "REAL",
|
|
41011
|
+
notNull: true
|
|
41012
|
+
},
|
|
41013
|
+
{
|
|
41014
|
+
name: "uy",
|
|
41015
|
+
type: "REAL",
|
|
41016
|
+
notNull: true
|
|
41017
|
+
},
|
|
41018
|
+
{
|
|
41019
|
+
name: "uw",
|
|
41020
|
+
type: "REAL",
|
|
41021
|
+
notNull: true
|
|
41022
|
+
},
|
|
41023
|
+
{
|
|
41024
|
+
name: "uh",
|
|
41025
|
+
type: "REAL",
|
|
41026
|
+
notNull: true
|
|
41027
|
+
},
|
|
41028
|
+
{
|
|
41029
|
+
name: "frameWidth",
|
|
41030
|
+
type: "INTEGER",
|
|
41031
|
+
notNull: true
|
|
41032
|
+
},
|
|
41033
|
+
{
|
|
41034
|
+
name: "frameHeight",
|
|
41035
|
+
type: "INTEGER",
|
|
41036
|
+
notNull: true
|
|
41037
|
+
},
|
|
41038
|
+
{
|
|
41039
|
+
name: "mediaKey",
|
|
41040
|
+
type: "TEXT"
|
|
41041
|
+
},
|
|
41042
|
+
{
|
|
41043
|
+
name: "nativeKey",
|
|
41044
|
+
type: "TEXT"
|
|
41045
|
+
},
|
|
41046
|
+
{
|
|
41047
|
+
name: "sealed",
|
|
41048
|
+
type: "BOOLEAN",
|
|
41049
|
+
notNull: true
|
|
41050
|
+
}
|
|
41051
|
+
];
|
|
41052
|
+
var TRACK_GROUP_MEMBERS_COLUMNS = [
|
|
41053
|
+
{
|
|
41054
|
+
name: "id",
|
|
41055
|
+
type: "TEXT",
|
|
41056
|
+
primaryKey: true,
|
|
41057
|
+
notNull: true
|
|
41058
|
+
},
|
|
41059
|
+
{
|
|
41060
|
+
name: "groupId",
|
|
41061
|
+
type: "TEXT",
|
|
41062
|
+
notNull: true
|
|
41063
|
+
},
|
|
41064
|
+
{
|
|
41065
|
+
name: "trackId",
|
|
41066
|
+
type: "TEXT",
|
|
41067
|
+
notNull: true
|
|
41068
|
+
},
|
|
41069
|
+
{
|
|
41070
|
+
name: "deviceId",
|
|
41071
|
+
type: "INTEGER",
|
|
41072
|
+
notNull: true
|
|
41073
|
+
},
|
|
41074
|
+
{
|
|
41075
|
+
name: "timestamp",
|
|
41076
|
+
type: "INTEGER",
|
|
41077
|
+
notNull: true
|
|
41078
|
+
}
|
|
41079
|
+
];
|
|
41080
|
+
var TRACK_GROUPS_INDEXES = [{
|
|
41081
|
+
name: "idx_track_groups_device_closed",
|
|
41082
|
+
columns: ["deviceId", "closedAt"]
|
|
41083
|
+
}, {
|
|
41084
|
+
name: "idx_track_groups_device_opened",
|
|
41085
|
+
columns: ["deviceId", "openedAt"]
|
|
41086
|
+
}];
|
|
41087
|
+
var TRACK_GROUP_MEMBERS_INDEXES = [{
|
|
41088
|
+
name: "idx_track_group_members_track",
|
|
41089
|
+
columns: ["trackId"]
|
|
41090
|
+
}, {
|
|
41091
|
+
name: "idx_track_group_members_group",
|
|
41092
|
+
columns: ["groupId"]
|
|
41093
|
+
}];
|
|
41094
|
+
function newGroupId() {
|
|
41095
|
+
return `grp-${(0, node_crypto.randomUUID)()}`;
|
|
41096
|
+
}
|
|
41097
|
+
function memberRowId(groupId, trackId) {
|
|
41098
|
+
return `${groupId}:${trackId}`;
|
|
41099
|
+
}
|
|
41100
|
+
var TrackGroupStore = class {
|
|
41101
|
+
store;
|
|
41102
|
+
logger;
|
|
41103
|
+
media;
|
|
41104
|
+
constructor(deps) {
|
|
41105
|
+
this.store = deps.store;
|
|
41106
|
+
this.logger = deps.logger;
|
|
41107
|
+
this.media = deps.media ?? null;
|
|
41108
|
+
}
|
|
41109
|
+
static async declare(store) {
|
|
41110
|
+
await Promise.all([store.declareCollection.mutate({
|
|
41111
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41112
|
+
columns: [...TRACK_GROUPS_COLUMNS],
|
|
41113
|
+
indexes: [...TRACK_GROUPS_INDEXES]
|
|
41114
|
+
}), store.declareCollection.mutate({
|
|
41115
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41116
|
+
columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
|
|
41117
|
+
indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
|
|
41118
|
+
})]);
|
|
41119
|
+
}
|
|
41120
|
+
async insert(input) {
|
|
41121
|
+
const id = newGroupId();
|
|
41122
|
+
const group = {
|
|
41123
|
+
id,
|
|
41124
|
+
deviceId: input.deviceId,
|
|
41125
|
+
timestamp: input.timestamp,
|
|
41126
|
+
openedAt: input.openedAt,
|
|
41127
|
+
closedAt: input.closedAt,
|
|
41128
|
+
memberCount: input.members.length,
|
|
41129
|
+
anchorTrackId: input.anchorTrackId,
|
|
41130
|
+
ux: input.ux,
|
|
41131
|
+
uy: input.uy,
|
|
41132
|
+
uw: input.uw,
|
|
41133
|
+
uh: input.uh,
|
|
41134
|
+
frameWidth: input.frameWidth,
|
|
41135
|
+
frameHeight: input.frameHeight,
|
|
41136
|
+
mediaKey: input.mediaKey,
|
|
41137
|
+
nativeKey: input.nativeKey,
|
|
41138
|
+
sealed: true
|
|
41139
|
+
};
|
|
41140
|
+
await this.store.set.mutate({
|
|
41141
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41142
|
+
key: id,
|
|
41143
|
+
value: this.toRow(group)
|
|
41144
|
+
});
|
|
41145
|
+
for (const member of input.members) await this.store.set.mutate({
|
|
41146
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41147
|
+
key: memberRowId(id, member.trackId),
|
|
41148
|
+
value: {
|
|
41149
|
+
groupId: id,
|
|
41150
|
+
trackId: member.trackId,
|
|
41151
|
+
deviceId: input.deviceId,
|
|
41152
|
+
timestamp: member.firstSeen
|
|
41153
|
+
}
|
|
41154
|
+
});
|
|
41155
|
+
return group;
|
|
41156
|
+
}
|
|
41157
|
+
/** The crop is written after birth; this is the only post-birth write besides the pointer null. */
|
|
41158
|
+
async setMediaKey(groupId, mediaKey) {
|
|
41159
|
+
const current = await this.get(groupId);
|
|
41160
|
+
if (current === null) return;
|
|
41161
|
+
await this.store.set.mutate({
|
|
41162
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41163
|
+
key: groupId,
|
|
41164
|
+
value: this.toRow({
|
|
41165
|
+
...current,
|
|
41166
|
+
mediaKey
|
|
41167
|
+
})
|
|
41168
|
+
});
|
|
41169
|
+
}
|
|
41170
|
+
async get(groupId) {
|
|
41171
|
+
const raw = await this.store.get.query({
|
|
41172
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41173
|
+
key: groupId
|
|
41174
|
+
});
|
|
41175
|
+
if (!raw) return null;
|
|
41176
|
+
return this.normalizeGroup({
|
|
41177
|
+
id: groupId,
|
|
41178
|
+
data: raw
|
|
41179
|
+
});
|
|
41180
|
+
}
|
|
41181
|
+
async listMembers(groupId) {
|
|
41182
|
+
return (await this.store.query.query({
|
|
41183
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41184
|
+
filter: { where: { groupId } }
|
|
41185
|
+
})).map((r) => this.normalizeMember({
|
|
41186
|
+
id: r.id,
|
|
41187
|
+
data: r.data
|
|
41188
|
+
}));
|
|
41189
|
+
}
|
|
41190
|
+
async membersOfTracks(trackIds) {
|
|
41191
|
+
const out = [];
|
|
41192
|
+
for (const trackId of trackIds) {
|
|
41193
|
+
const rows = await this.store.query.query({
|
|
41194
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41195
|
+
filter: { where: { trackId } }
|
|
41196
|
+
});
|
|
41197
|
+
for (const r of rows) out.push(this.normalizeMember({
|
|
41198
|
+
id: r.id,
|
|
41199
|
+
data: r.data
|
|
41200
|
+
}));
|
|
41201
|
+
}
|
|
41202
|
+
return out;
|
|
41203
|
+
}
|
|
41204
|
+
/**
|
|
41205
|
+
* Newest-closed page for the Library Groups tab.
|
|
41206
|
+
*
|
|
41207
|
+
* Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
|
|
41208
|
+
* same as {@link listOverlapping}. Pagination is applied AFTER the window
|
|
41209
|
+
* so a day cursor is honest rather than a raw-row offset that skips live
|
|
41210
|
+
* groups on a busy camera.
|
|
41211
|
+
*/
|
|
41212
|
+
async listRecent(input) {
|
|
41213
|
+
const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
|
|
41214
|
+
const offset = Math.max(input.offset ?? 0, 0);
|
|
41215
|
+
const slice = (await this.store.query.query({
|
|
41216
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41217
|
+
filter: {
|
|
41218
|
+
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
41219
|
+
orderBy: {
|
|
41220
|
+
field: "closedAt",
|
|
41221
|
+
direction: "desc"
|
|
41222
|
+
},
|
|
41223
|
+
limit: 2e3
|
|
41224
|
+
}
|
|
41225
|
+
})).map((r) => this.normalizeGroup({
|
|
41226
|
+
id: r.id,
|
|
41227
|
+
data: r.data
|
|
41228
|
+
})).filter((g) => {
|
|
41229
|
+
if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
|
|
41230
|
+
if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
|
|
41231
|
+
return true;
|
|
41232
|
+
}).slice(offset, offset + limit + 1);
|
|
41233
|
+
return {
|
|
41234
|
+
groups: slice.slice(0, limit),
|
|
41235
|
+
hasMore: slice.length > limit
|
|
41236
|
+
};
|
|
41237
|
+
}
|
|
41238
|
+
/** Groups whose life overlaps `[since, until]` on one camera. */
|
|
41239
|
+
async listOverlapping(input) {
|
|
41240
|
+
return (await this.store.query.query({
|
|
41241
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41242
|
+
filter: {
|
|
41243
|
+
where: { deviceId: input.deviceId },
|
|
41244
|
+
orderBy: {
|
|
41245
|
+
field: "closedAt",
|
|
41246
|
+
direction: "desc"
|
|
41247
|
+
},
|
|
41248
|
+
limit: input.limit ?? 2e3
|
|
41249
|
+
}
|
|
41250
|
+
})).map((r) => this.normalizeGroup({
|
|
41251
|
+
id: r.id,
|
|
41252
|
+
data: r.data
|
|
41253
|
+
})).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
|
|
41254
|
+
}
|
|
41255
|
+
async existingGroupIds(ids) {
|
|
41256
|
+
const live = /* @__PURE__ */ new Set();
|
|
41257
|
+
for (const id of ids) if (await this.store.get.query({
|
|
41258
|
+
collection: "pipeline-analytics:track-groups",
|
|
41259
|
+
key: id
|
|
41260
|
+
})) live.add(id);
|
|
41261
|
+
return live;
|
|
41262
|
+
}
|
|
41263
|
+
/** Group ids that still have at least one membership row. */
|
|
41264
|
+
async groupsWithMembers(ids) {
|
|
41265
|
+
const live = /* @__PURE__ */ new Set();
|
|
41266
|
+
for (const groupId of ids) if ((await this.store.query.query({
|
|
41267
|
+
collection: "pipeline-analytics:track-group-members",
|
|
41268
|
+
filter: {
|
|
41269
|
+
where: { groupId },
|
|
41270
|
+
limit: 1
|
|
41271
|
+
}
|
|
41272
|
+
})).length > 0) live.add(groupId);
|
|
41273
|
+
return live;
|
|
41274
|
+
}
|
|
41275
|
+
/**
|
|
41276
|
+
* Cascade leaf: delete memberships for the batch; a group with zero survivors
|
|
41277
|
+
* dies (media then row); a group that lost only its anchor nulls the pointer.
|
|
41278
|
+
*/
|
|
41279
|
+
async deleteByTracks(trackIds) {
|
|
41280
|
+
const batch = new Set(trackIds);
|
|
41281
|
+
let memberRows = 0;
|
|
41282
|
+
let groupRows = 0;
|
|
41283
|
+
let mediaRows = 0;
|
|
41284
|
+
let mediaBytes = 0;
|
|
41285
|
+
const affected = /* @__PURE__ */ new Set();
|
|
41286
|
+
try {
|
|
41287
|
+
const memberships = await this.membersOfTracks(trackIds);
|
|
41288
|
+
for (const member of memberships) try {
|
|
41289
|
+
await this.store.delete.mutate({
|
|
41290
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41291
|
+
key: member.id
|
|
41292
|
+
});
|
|
41293
|
+
memberRows += 1;
|
|
41294
|
+
affected.add(member.groupId);
|
|
41295
|
+
} catch (err) {
|
|
41296
|
+
this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
|
|
41297
|
+
memberId: member.id,
|
|
41298
|
+
error: String(err)
|
|
41299
|
+
} });
|
|
41300
|
+
}
|
|
41301
|
+
for (const groupId of affected) try {
|
|
41302
|
+
if ((await this.listMembers(groupId)).length === 0) {
|
|
41303
|
+
if (this.media) {
|
|
41304
|
+
const gone = await this.media.deleteByGroups([groupId]);
|
|
41305
|
+
mediaRows += gone.rows;
|
|
41306
|
+
mediaBytes += gone.bytes;
|
|
41307
|
+
}
|
|
41308
|
+
await this.store.delete.mutate({
|
|
41309
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41310
|
+
key: groupId
|
|
41311
|
+
});
|
|
41312
|
+
groupRows += 1;
|
|
41313
|
+
continue;
|
|
41314
|
+
}
|
|
41315
|
+
const group = await this.get(groupId);
|
|
41316
|
+
if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
|
|
41317
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41318
|
+
key: groupId,
|
|
41319
|
+
value: this.toRow({
|
|
41320
|
+
...group,
|
|
41321
|
+
anchorTrackId: null,
|
|
41322
|
+
nativeKey: null
|
|
41323
|
+
})
|
|
41324
|
+
});
|
|
41325
|
+
} catch (err) {
|
|
41326
|
+
this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
|
|
41327
|
+
groupId,
|
|
41328
|
+
error: String(err)
|
|
41329
|
+
} });
|
|
41330
|
+
}
|
|
41331
|
+
} catch (err) {
|
|
41332
|
+
this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
|
|
41333
|
+
}
|
|
41334
|
+
return [{
|
|
41335
|
+
family: "groups",
|
|
41336
|
+
rows: memberRows + groupRows,
|
|
41337
|
+
bytes: 0
|
|
41338
|
+
}, {
|
|
41339
|
+
family: "media",
|
|
41340
|
+
rows: mediaRows,
|
|
41341
|
+
bytes: mediaBytes
|
|
41342
|
+
}];
|
|
41343
|
+
}
|
|
41344
|
+
toRow(group) {
|
|
41345
|
+
return {
|
|
41346
|
+
deviceId: group.deviceId,
|
|
41347
|
+
timestamp: group.timestamp,
|
|
41348
|
+
openedAt: group.openedAt,
|
|
41349
|
+
closedAt: group.closedAt,
|
|
41350
|
+
memberCount: group.memberCount,
|
|
41351
|
+
anchorTrackId: group.anchorTrackId,
|
|
41352
|
+
ux: group.ux,
|
|
41353
|
+
uy: group.uy,
|
|
41354
|
+
uw: group.uw,
|
|
41355
|
+
uh: group.uh,
|
|
41356
|
+
frameWidth: group.frameWidth,
|
|
41357
|
+
frameHeight: group.frameHeight,
|
|
41358
|
+
mediaKey: group.mediaKey,
|
|
41359
|
+
nativeKey: group.nativeKey,
|
|
41360
|
+
sealed: group.sealed
|
|
41361
|
+
};
|
|
41362
|
+
}
|
|
41363
|
+
normalizeGroup(r) {
|
|
41364
|
+
const data = r.data;
|
|
41365
|
+
return {
|
|
41366
|
+
id: r.id,
|
|
41367
|
+
deviceId: Number(data["deviceId"]),
|
|
41368
|
+
timestamp: Number(data["timestamp"]),
|
|
41369
|
+
openedAt: Number(data["openedAt"]),
|
|
41370
|
+
closedAt: Number(data["closedAt"]),
|
|
41371
|
+
memberCount: Number(data["memberCount"]),
|
|
41372
|
+
anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
|
|
41373
|
+
ux: Number(data["ux"]),
|
|
41374
|
+
uy: Number(data["uy"]),
|
|
41375
|
+
uw: Number(data["uw"]),
|
|
41376
|
+
uh: Number(data["uh"]),
|
|
41377
|
+
frameWidth: Number(data["frameWidth"]),
|
|
41378
|
+
frameHeight: Number(data["frameHeight"]),
|
|
41379
|
+
mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
|
|
41380
|
+
nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
|
|
41381
|
+
sealed: Boolean(data["sealed"])
|
|
41382
|
+
};
|
|
41383
|
+
}
|
|
41384
|
+
normalizeMember(r) {
|
|
41385
|
+
const data = r.data;
|
|
41386
|
+
return {
|
|
41387
|
+
id: r.id,
|
|
41388
|
+
groupId: String(data["groupId"]),
|
|
41389
|
+
trackId: String(data["trackId"]),
|
|
41390
|
+
deviceId: Number(data["deviceId"]),
|
|
41391
|
+
timestamp: Number(data["timestamp"])
|
|
41392
|
+
};
|
|
41393
|
+
}
|
|
41394
|
+
};
|
|
40134
41395
|
//#endregion
|
|
40135
41396
|
//#region src/pipeline-analytics/pipeline/dropout-skip.ts
|
|
40136
41397
|
function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
|
|
@@ -46748,6 +48009,15 @@ function isAfterCursor(row, cursor) {
|
|
|
46748
48009
|
if (row.lastSeen < cursor.lastSeen) return true;
|
|
46749
48010
|
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
46750
48011
|
}
|
|
48012
|
+
function netDisplacementPx(positions) {
|
|
48013
|
+
const first = positions[0];
|
|
48014
|
+
const last = positions[positions.length - 1];
|
|
48015
|
+
if (!first || !last) return 0;
|
|
48016
|
+
return Math.hypot(last.x - first.x, last.y - first.y);
|
|
48017
|
+
}
|
|
48018
|
+
function isShortMotionlessTrack(lifeMs, movedPx) {
|
|
48019
|
+
return lifeMs <= 25e3 && movedPx <= 8;
|
|
48020
|
+
}
|
|
46751
48021
|
//#endregion
|
|
46752
48022
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
46753
48023
|
/**
|
|
@@ -47330,6 +48600,7 @@ var TrackStore = class {
|
|
|
47330
48600
|
for (const [trackId, t] of this.active) {
|
|
47331
48601
|
if (nowMs - t.lastSeen < this.config.ttlMs) continue;
|
|
47332
48602
|
t.active = false;
|
|
48603
|
+
if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
|
|
47333
48604
|
const record = cloneTrack(t);
|
|
47334
48605
|
try {
|
|
47335
48606
|
await this.persistCompleted(record);
|
|
@@ -48878,12 +50149,24 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48878
50149
|
collection: PLATES_COLLECTION,
|
|
48879
50150
|
classification: derived(PLATE_COLUMNS)
|
|
48880
50151
|
},
|
|
50152
|
+
{
|
|
50153
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
50154
|
+
classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
|
|
50155
|
+
},
|
|
48881
50156
|
{
|
|
48882
50157
|
collection: MEDIA_COLLECTION,
|
|
48883
50158
|
classification: {
|
|
48884
50159
|
kind: "owner-indirection",
|
|
48885
50160
|
derivation: "explicit",
|
|
48886
|
-
why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. The mapping is `resolveMediaOwner` in orphan-audit.ts."
|
|
50161
|
+
why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', a groupId for 'group', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. The mapping is `resolveMediaOwner` in orphan-audit.ts."
|
|
50162
|
+
}
|
|
50163
|
+
},
|
|
50164
|
+
{
|
|
50165
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
50166
|
+
classification: {
|
|
50167
|
+
kind: "owner-indirection",
|
|
50168
|
+
derivation: "explicit",
|
|
50169
|
+
why: "a group has N members and no trackId column; it is owned by its membership rows. The mapping is `resolveGroupOwner` in orphan-audit.ts (groupId → any member still present). Do not classify never-orphaned — that would make the last-member guarantee unverifiable."
|
|
48887
50170
|
}
|
|
48888
50171
|
},
|
|
48889
50172
|
{
|
|
@@ -48995,7 +50278,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48995
50278
|
collection: SCENE_LATCH_COLLECTION,
|
|
48996
50279
|
classification: {
|
|
48997
50280
|
kind: "never-orphaned",
|
|
48998
|
-
why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves
|
|
50281
|
+
why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves only the indirections it has a resolver for (`pipeline-analytics:media` and `pipeline-analytics:track-groups`)."
|
|
48999
50282
|
}
|
|
49000
50283
|
},
|
|
49001
50284
|
{
|
|
@@ -49083,6 +50366,10 @@ function resolveMediaOwner(row) {
|
|
|
49083
50366
|
kind: "unresolvable",
|
|
49084
50367
|
why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
|
|
49085
50368
|
};
|
|
50369
|
+
case "group": return {
|
|
50370
|
+
kind: "group",
|
|
50371
|
+
groupId: ownerId
|
|
50372
|
+
};
|
|
49086
50373
|
default: return {
|
|
49087
50374
|
kind: "unresolvable",
|
|
49088
50375
|
why: `unknown ownerKind '${kindRaw}'`
|
|
@@ -49094,6 +50381,13 @@ function resolveMediaOwner(row) {
|
|
|
49094
50381
|
* is derived the same way the classification is — an enrolled row is out of
|
|
49095
50382
|
* reach regardless of what happened to the track that produced it.
|
|
49096
50383
|
*/
|
|
50384
|
+
/** A group row lives iff any membership row still names it. */
|
|
50385
|
+
function resolveGroupOwner(row) {
|
|
50386
|
+
return {
|
|
50387
|
+
kind: "group-members",
|
|
50388
|
+
groupId: row.key
|
|
50389
|
+
};
|
|
50390
|
+
}
|
|
49097
50391
|
function resolveDeclaredTrackOwner(row, enrolmentExempt) {
|
|
49098
50392
|
if (enrolmentExempt && row.data["assigned"] === true) return {
|
|
49099
50393
|
kind: "exempt",
|
|
@@ -49113,15 +50407,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
|
|
|
49113
50407
|
const scopes = [];
|
|
49114
50408
|
for (const entry of collections) {
|
|
49115
50409
|
if (entry.classification.kind === "owner-indirection") {
|
|
49116
|
-
if (entry.collection
|
|
49117
|
-
|
|
49118
|
-
|
|
49119
|
-
|
|
49120
|
-
|
|
49121
|
-
|
|
49122
|
-
|
|
49123
|
-
|
|
49124
|
-
|
|
50410
|
+
if (entry.collection === "pipeline-analytics:media") {
|
|
50411
|
+
scopes.push({
|
|
50412
|
+
id: entry.collection,
|
|
50413
|
+
collection: entry.collection,
|
|
50414
|
+
resolve: resolveMediaOwner,
|
|
50415
|
+
timestampField: "timestamp",
|
|
50416
|
+
sizeField: "sizeBytes",
|
|
50417
|
+
deviceIdField: "deviceId"
|
|
50418
|
+
});
|
|
50419
|
+
continue;
|
|
50420
|
+
}
|
|
50421
|
+
if (entry.collection === "pipeline-analytics:track-groups") {
|
|
50422
|
+
scopes.push({
|
|
50423
|
+
id: entry.collection,
|
|
50424
|
+
collection: entry.collection,
|
|
50425
|
+
resolve: resolveGroupOwner,
|
|
50426
|
+
timestampField: "timestamp",
|
|
50427
|
+
sizeField: null,
|
|
50428
|
+
deviceIdField: "deviceId"
|
|
50429
|
+
});
|
|
50430
|
+
continue;
|
|
50431
|
+
}
|
|
49125
50432
|
continue;
|
|
49126
50433
|
}
|
|
49127
50434
|
if (entry.classification.kind !== "track-owned") continue;
|
|
@@ -49153,11 +50460,17 @@ var DEFAULT_ORPHAN_LIMITS = {
|
|
|
49153
50460
|
function questionsFor(refs) {
|
|
49154
50461
|
const trackIds = /* @__PURE__ */ new Set();
|
|
49155
50462
|
const eventIds = /* @__PURE__ */ new Set();
|
|
50463
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
50464
|
+
const groupMemberIds = /* @__PURE__ */ new Set();
|
|
49156
50465
|
for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
|
|
49157
50466
|
else if (ref.kind === "event") eventIds.add(ref.eventId);
|
|
50467
|
+
else if (ref.kind === "group") groupIds.add(ref.groupId);
|
|
50468
|
+
else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
|
|
49158
50469
|
return {
|
|
49159
50470
|
trackIds: [...trackIds],
|
|
49160
|
-
eventIds: [...eventIds]
|
|
50471
|
+
eventIds: [...eventIds],
|
|
50472
|
+
groupIds: [...groupIds],
|
|
50473
|
+
groupMemberIds: [...groupMemberIds]
|
|
49161
50474
|
};
|
|
49162
50475
|
}
|
|
49163
50476
|
/**
|
|
@@ -49189,12 +50502,16 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49189
50502
|
break;
|
|
49190
50503
|
}
|
|
49191
50504
|
const refs = rows.map((row) => scope.resolve(row));
|
|
49192
|
-
const { trackIds, eventIds } = questionsFor(refs);
|
|
50505
|
+
const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
|
|
49193
50506
|
let liveTracks;
|
|
49194
50507
|
let liveEvents;
|
|
50508
|
+
let liveGroups;
|
|
50509
|
+
let liveGroupsWithMembers;
|
|
49195
50510
|
try {
|
|
49196
50511
|
liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
|
|
49197
50512
|
liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
|
|
50513
|
+
liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
|
|
50514
|
+
liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
|
|
49198
50515
|
} catch (err) {
|
|
49199
50516
|
deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
|
|
49200
50517
|
collection: scope.collection,
|
|
@@ -49218,7 +50535,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49218
50535
|
tally.unresolvable += 1;
|
|
49219
50536
|
continue;
|
|
49220
50537
|
}
|
|
49221
|
-
if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
|
|
50538
|
+
if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.has(ref.eventId) : ref.kind === "group" ? liveGroups.has(ref.groupId) : liveGroupsWithMembers.has(ref.groupId)) continue;
|
|
49222
50539
|
const ts = num(row.data, scope.timestampField);
|
|
49223
50540
|
if (ts !== null && ts > graceFloor) {
|
|
49224
50541
|
tally.withinGrace += 1;
|
|
@@ -51310,6 +52627,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
|
51310
52627
|
};
|
|
51311
52628
|
}
|
|
51312
52629
|
//#endregion
|
|
52630
|
+
//#region src/pipeline-analytics/pipeline/usable-bbox.ts
|
|
52631
|
+
/** A box that can legally drive a crop. The leaving-event fallback
|
|
52632
|
+
* `{0,0,0,0}` must never become a face/plate JPEG. */
|
|
52633
|
+
function isUsableBbox(bbox) {
|
|
52634
|
+
return bbox.w > 0 && bbox.h > 0;
|
|
52635
|
+
}
|
|
52636
|
+
//#endregion
|
|
51313
52637
|
//#region src/pipeline-analytics/services/event-media-dispatcher.ts
|
|
51314
52638
|
/**
|
|
51315
52639
|
* Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
|
|
@@ -51888,26 +53212,29 @@ var EventMediaDispatcher = class {
|
|
|
51888
53212
|
}
|
|
51889
53213
|
}
|
|
51890
53214
|
async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
|
|
51891
|
-
if (ev.childCrops) for (const child of ev.childCrops)
|
|
51892
|
-
|
|
51893
|
-
|
|
51894
|
-
|
|
51895
|
-
|
|
51896
|
-
|
|
51897
|
-
ownerId: ev.eventId,
|
|
51898
|
-
kind: child.kind,
|
|
51899
|
-
timestamp: ev.timestamp,
|
|
51900
|
-
data: childCropData
|
|
51901
|
-
});
|
|
51902
|
-
} catch (err) {
|
|
51903
|
-
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
51904
|
-
tags: { deviceId },
|
|
51905
|
-
meta: {
|
|
53215
|
+
if (ev.childCrops) for (const child of ev.childCrops) {
|
|
53216
|
+
if (!isUsableBbox(child.bbox)) continue;
|
|
53217
|
+
try {
|
|
53218
|
+
const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
|
|
53219
|
+
if (!childCropData) continue;
|
|
53220
|
+
await this.deps.mediaStore.put({
|
|
51906
53221
|
deviceId,
|
|
51907
|
-
|
|
51908
|
-
|
|
51909
|
-
|
|
51910
|
-
|
|
53222
|
+
ownerKind: "event",
|
|
53223
|
+
ownerId: ev.eventId,
|
|
53224
|
+
kind: child.kind,
|
|
53225
|
+
timestamp: ev.timestamp,
|
|
53226
|
+
data: childCropData
|
|
53227
|
+
});
|
|
53228
|
+
} catch (err) {
|
|
53229
|
+
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
53230
|
+
tags: { deviceId },
|
|
53231
|
+
meta: {
|
|
53232
|
+
deviceId,
|
|
53233
|
+
eventId: ev.eventId,
|
|
53234
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53235
|
+
}
|
|
53236
|
+
});
|
|
53237
|
+
}
|
|
51911
53238
|
}
|
|
51912
53239
|
}
|
|
51913
53240
|
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
@@ -55168,7 +56495,8 @@ var customActions = {
|
|
|
55168
56495
|
...orphanAuditActions,
|
|
55169
56496
|
...viewerSettingsActions,
|
|
55170
56497
|
...photoEnrollActions,
|
|
55171
|
-
...faceRescoreActions
|
|
56498
|
+
...faceRescoreActions,
|
|
56499
|
+
...groupActions
|
|
55172
56500
|
};
|
|
55173
56501
|
/**
|
|
55174
56502
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -55347,11 +56675,6 @@ function decodeEmbeddingBase64(base64) {
|
|
|
55347
56675
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
55348
56676
|
return Array.from(view);
|
|
55349
56677
|
}
|
|
55350
|
-
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
55351
|
-
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
55352
|
-
var SHORT_TRACK_MAX_MS = 25e3;
|
|
55353
|
-
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
55354
|
-
var MOTIONLESS_MAX_PX = 8;
|
|
55355
56678
|
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
55356
56679
|
* to the pixel still land in one cell. */
|
|
55357
56680
|
var PHANTOM_CELL_PX = 32;
|
|
@@ -55415,6 +56738,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
55415
56738
|
faceStore = null;
|
|
55416
56739
|
faceRecognizer = null;
|
|
55417
56740
|
plateStore = null;
|
|
56741
|
+
groupStore = null;
|
|
56742
|
+
groupSealer = null;
|
|
55418
56743
|
plateRecognizer = null;
|
|
55419
56744
|
objectEmbeddingStore = null;
|
|
55420
56745
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -56232,7 +57557,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56232
57557
|
...orphanAuditActions,
|
|
56233
57558
|
...viewerSettingsActions,
|
|
56234
57559
|
...photoEnrollActions,
|
|
56235
|
-
...faceRescoreActions
|
|
57560
|
+
...faceRescoreActions,
|
|
57561
|
+
...groupActions
|
|
56236
57562
|
},
|
|
56237
57563
|
actionHandlers: {
|
|
56238
57564
|
...ncHandlers,
|
|
@@ -56240,6 +57566,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56240
57566
|
...this.buildDebugActionHandlers(),
|
|
56241
57567
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56242
57568
|
...this.buildFaceRescoreActionHandlers(),
|
|
57569
|
+
...this.buildGroupActionHandlers(),
|
|
56243
57570
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
56244
57571
|
}
|
|
56245
57572
|
} : {}
|
|
@@ -56442,6 +57769,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56442
57769
|
SceneStore.declare(api.settingsStore),
|
|
56443
57770
|
NotificationCenter.declare(api.settingsStore),
|
|
56444
57771
|
ViewerSettingsSnapshotStore.declare(api.settingsStore),
|
|
57772
|
+
TrackGroupStore.declare(api.settingsStore),
|
|
56445
57773
|
declareMigrationMarkers(api.settingsStore)
|
|
56446
57774
|
]);
|
|
56447
57775
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
@@ -56726,6 +58054,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56726
58054
|
logger: logger.child("PlateStore")
|
|
56727
58055
|
});
|
|
56728
58056
|
this.plateStore = plateStore;
|
|
58057
|
+
const vehicleStore = new VehicleStore({
|
|
58058
|
+
store: api.settingsStore,
|
|
58059
|
+
logger: logger.child("VehicleStore")
|
|
58060
|
+
});
|
|
58061
|
+
const groupStore = new TrackGroupStore({
|
|
58062
|
+
store: api.settingsStore,
|
|
58063
|
+
logger: logger.child("TrackGroupStore"),
|
|
58064
|
+
media: mediaStore
|
|
58065
|
+
});
|
|
58066
|
+
this.groupStore = groupStore;
|
|
58067
|
+
this.groupSealer = new GroupSealer({
|
|
58068
|
+
trackStore,
|
|
58069
|
+
groupStore,
|
|
58070
|
+
mediaStore,
|
|
58071
|
+
logger: logger.child("GroupSealer"),
|
|
58072
|
+
frameDims: (deviceId) => {
|
|
58073
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
58074
|
+
return dims ? {
|
|
58075
|
+
width: dims.w,
|
|
58076
|
+
height: dims.h
|
|
58077
|
+
} : void 0;
|
|
58078
|
+
},
|
|
58079
|
+
now: () => Date.now()
|
|
58080
|
+
});
|
|
58081
|
+
this.groupSealer.backfill().catch((err) => {
|
|
58082
|
+
logger.warn("group sealer backfill failed", { meta: { error: require_dist.errMsg(err) } });
|
|
58083
|
+
});
|
|
56729
58084
|
return {
|
|
56730
58085
|
trackStore,
|
|
56731
58086
|
mediaStore,
|
|
@@ -56733,10 +58088,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56733
58088
|
identityStore,
|
|
56734
58089
|
faceStore,
|
|
56735
58090
|
plateStore,
|
|
56736
|
-
vehicleStore
|
|
56737
|
-
|
|
56738
|
-
logger: logger.child("VehicleStore")
|
|
56739
|
-
})
|
|
58091
|
+
vehicleStore,
|
|
58092
|
+
groupStore
|
|
56740
58093
|
};
|
|
56741
58094
|
}
|
|
56742
58095
|
/** Face + plate recognizers, the durable keyFrame-derived plate crop and the
|
|
@@ -57170,29 +58523,65 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
57170
58523
|
buildSummaryPorts(stores) {
|
|
57171
58524
|
return {
|
|
57172
58525
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
57173
|
-
const
|
|
58526
|
+
const groups = await stores.groupStore.listOverlapping({
|
|
58527
|
+
deviceId,
|
|
58528
|
+
sinceMs,
|
|
58529
|
+
untilMs,
|
|
58530
|
+
limit
|
|
58531
|
+
});
|
|
58532
|
+
const windowTracks = await stores.trackStore.queryHistorical({
|
|
57174
58533
|
deviceId,
|
|
57175
58534
|
since: sinceMs,
|
|
57176
58535
|
until: untilMs,
|
|
57177
58536
|
projection: "slim",
|
|
57178
|
-
limit
|
|
58537
|
+
limit: Math.max(limit, 2e3)
|
|
57179
58538
|
});
|
|
58539
|
+
const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
|
|
57180
58540
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
57181
|
-
|
|
57182
|
-
|
|
57183
|
-
|
|
57184
|
-
|
|
57185
|
-
|
|
57186
|
-
|
|
57187
|
-
|
|
57188
|
-
|
|
57189
|
-
|
|
57190
|
-
|
|
57191
|
-
|
|
57192
|
-
|
|
58541
|
+
const out = [];
|
|
58542
|
+
for (const group of groups) {
|
|
58543
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58544
|
+
const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
|
|
58545
|
+
if (tracks.length === 0) continue;
|
|
58546
|
+
const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
|
|
58547
|
+
const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
|
|
58548
|
+
const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
|
|
58549
|
+
const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
|
|
58550
|
+
out.push({
|
|
58551
|
+
trackId: group.id,
|
|
58552
|
+
groupId: group.id,
|
|
58553
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
58554
|
+
deviceId,
|
|
58555
|
+
className: primary.className,
|
|
58556
|
+
firstSeen: group.openedAt,
|
|
58557
|
+
lastSeen: group.closedAt,
|
|
58558
|
+
importance,
|
|
58559
|
+
zones,
|
|
58560
|
+
classes,
|
|
58561
|
+
...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
|
|
58562
|
+
confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
|
|
58563
|
+
});
|
|
58564
|
+
}
|
|
58565
|
+
return out;
|
|
57193
58566
|
},
|
|
57194
|
-
listMediaInfo: async (
|
|
57195
|
-
|
|
58567
|
+
listMediaInfo: async (id) => {
|
|
58568
|
+
const group = await stores.groupStore.get(id);
|
|
58569
|
+
if (group?.mediaKey) return [{
|
|
58570
|
+
key: group.mediaKey,
|
|
58571
|
+
kind: "groupCrop",
|
|
58572
|
+
timestamp: group.timestamp
|
|
58573
|
+
}];
|
|
58574
|
+
if (group && group.memberCount === 1) {
|
|
58575
|
+
const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
|
|
58576
|
+
if (!memberId) return [];
|
|
58577
|
+
return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
|
|
58578
|
+
key: r.key,
|
|
58579
|
+
kind: r.kind,
|
|
58580
|
+
timestamp: r.timestamp
|
|
58581
|
+
}));
|
|
58582
|
+
}
|
|
58583
|
+
if (group) return [];
|
|
58584
|
+
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
57196
58585
|
key: r.key,
|
|
57197
58586
|
kind: r.kind,
|
|
57198
58587
|
timestamp: r.timestamp
|
|
@@ -59216,8 +60605,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59216
60605
|
const first = track.positions?.[0];
|
|
59217
60606
|
if (!first) return;
|
|
59218
60607
|
const lifeMs = track.lastSeen - track.firstSeen;
|
|
59219
|
-
const moved = track.
|
|
59220
|
-
if (!(lifeMs
|
|
60608
|
+
const moved = netDisplacementPx(track.positions ?? []);
|
|
60609
|
+
if (!isShortMotionlessTrack(lifeMs, moved)) {
|
|
59221
60610
|
this.phantomCells?.noteMovingClose({
|
|
59222
60611
|
deviceId: track.deviceId,
|
|
59223
60612
|
className: track.className,
|
|
@@ -59360,7 +60749,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59360
60749
|
deviceId,
|
|
59361
60750
|
trackId,
|
|
59362
60751
|
timestamp: frame.timestamp,
|
|
59363
|
-
bbox: { ...frame.bbox },
|
|
60752
|
+
bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
|
|
59364
60753
|
frameWidth: frame.frameWidth,
|
|
59365
60754
|
frameHeight: frame.frameHeight,
|
|
59366
60755
|
kind: d.className === "plate" ? "plate" : "classification",
|
|
@@ -59793,7 +61182,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59793
61182
|
retries.push({
|
|
59794
61183
|
trackId: t.trackId,
|
|
59795
61184
|
timestamp,
|
|
59796
|
-
bbox:
|
|
61185
|
+
bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
|
|
59797
61186
|
...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
|
|
59798
61187
|
});
|
|
59799
61188
|
}
|
|
@@ -59850,7 +61239,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59850
61239
|
lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
|
|
59851
61240
|
now: timestamp,
|
|
59852
61241
|
intervalMs: media.snapshotIntervalMs,
|
|
59853
|
-
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
|
|
61242
|
+
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
|
|
61243
|
+
firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
|
|
59854
61244
|
});
|
|
59855
61245
|
const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
|
|
59856
61246
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
@@ -60359,6 +61749,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
60359
61749
|
async sweepExpiredTracks() {
|
|
60360
61750
|
if (this.shuttingDown || !this.trackStore) return;
|
|
60361
61751
|
await this.trackCloser.sweep();
|
|
61752
|
+
this.groupSealer?.sealRecent();
|
|
60362
61753
|
}
|
|
60363
61754
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
60364
61755
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -61637,6 +63028,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61637
63028
|
},
|
|
61638
63029
|
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
61639
63030
|
liveEventIds: (ids) => eventStore.existingEventIds(ids),
|
|
63031
|
+
liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
63032
|
+
liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
61640
63033
|
reclaim: async (collection, key) => {
|
|
61641
63034
|
if (collection === "pipeline-analytics:media") {
|
|
61642
63035
|
await mediaStore.deleteByKey(key);
|
|
@@ -62360,6 +63753,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62360
63753
|
buildFaceRescoreActionHandlers() {
|
|
62361
63754
|
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62362
63755
|
}
|
|
63756
|
+
buildGroupActionHandlers() {
|
|
63757
|
+
const ports = () => {
|
|
63758
|
+
const groupStore = this.groupStore;
|
|
63759
|
+
if (groupStore === null) return null;
|
|
63760
|
+
return {
|
|
63761
|
+
groupStore,
|
|
63762
|
+
loadTrack: async (trackId) => {
|
|
63763
|
+
const tracks = this.trackStore;
|
|
63764
|
+
if (!tracks) return null;
|
|
63765
|
+
return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
|
|
63766
|
+
},
|
|
63767
|
+
eventMediaBaseUrl: () => this.eventMediaBaseUrl
|
|
63768
|
+
};
|
|
63769
|
+
};
|
|
63770
|
+
return {
|
|
63771
|
+
"analytics.listGroups": async (input) => {
|
|
63772
|
+
const ready = ports();
|
|
63773
|
+
if (ready === null) return {
|
|
63774
|
+
groups: [],
|
|
63775
|
+
hasMore: false
|
|
63776
|
+
};
|
|
63777
|
+
return listGroupRecords(ready, input);
|
|
63778
|
+
},
|
|
63779
|
+
"analytics.getGroup": async (input) => {
|
|
63780
|
+
const ready = ports();
|
|
63781
|
+
if (ready === null) return { group: null };
|
|
63782
|
+
return { group: await getGroupDetail(ready, input.groupId) };
|
|
63783
|
+
}
|
|
63784
|
+
};
|
|
63785
|
+
}
|
|
62363
63786
|
buildPhotoEnrollActionHandlers() {
|
|
62364
63787
|
return {
|
|
62365
63788
|
"photo.analyze": async (input) => {
|
|
@@ -62590,7 +64013,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62590
64013
|
mediaStore,
|
|
62591
64014
|
faceStore: this.faceStore,
|
|
62592
64015
|
plateStore: this.plateStore,
|
|
62593
|
-
objectEmbeddingStore: this.objectEmbeddingStore
|
|
64016
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
64017
|
+
groupStore: this.groupStore
|
|
62594
64018
|
}),
|
|
62595
64019
|
trackStore
|
|
62596
64020
|
};
|
|
@@ -62739,7 +64163,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62739
64163
|
media: 0,
|
|
62740
64164
|
faces: 0,
|
|
62741
64165
|
plates: 0,
|
|
62742
|
-
embeddings: 0
|
|
64166
|
+
embeddings: 0,
|
|
64167
|
+
groups: 0
|
|
62743
64168
|
};
|
|
62744
64169
|
this.lastPruneBytes = 0;
|
|
62745
64170
|
const cascade = this.buildTrackCascadeRegistry();
|
|
@@ -62799,7 +64224,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62799
64224
|
media: tally.media,
|
|
62800
64225
|
faces: tally.faces,
|
|
62801
64226
|
plates: tally.plates,
|
|
62802
|
-
embeddings: tally.embeddings
|
|
64227
|
+
embeddings: tally.embeddings,
|
|
64228
|
+
groups: tally.groups
|
|
62803
64229
|
};
|
|
62804
64230
|
}
|
|
62805
64231
|
/**
|