@camstack/addon-post-analysis 1.2.110 → 1.2.116
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 +1913 -162
- package/dist/pipeline-analytics/index.mjs +1904 -154
- 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;
|
|
@@ -15128,22 +15139,29 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15128
15139
|
};
|
|
15129
15140
|
}
|
|
15130
15141
|
/**
|
|
15142
|
+
* Ranked leftover after the quota pass — refill a mosaic that dropped
|
|
15143
|
+
* duplicate frames or missing crops without walking the same track twice.
|
|
15144
|
+
*/
|
|
15145
|
+
function remainingSummaryCandidates(kept, taken, rank) {
|
|
15146
|
+
const takenIds = new Set(taken.map((c) => c.trackId));
|
|
15147
|
+
return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
|
|
15148
|
+
}
|
|
15149
|
+
/**
|
|
15131
15150
|
* The media a tile is drawn from, best first.
|
|
15132
15151
|
*
|
|
15133
|
-
*
|
|
15134
|
-
*
|
|
15135
|
-
*
|
|
15136
|
-
*
|
|
15137
|
-
*
|
|
15138
|
-
*
|
|
15152
|
+
* For a group: `groupCrop` (union crop, the group's own blob) then
|
|
15153
|
+
* `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
|
|
15154
|
+
* cohort may show the native still when the crop was not written; a
|
|
15155
|
+
* member's `keyFrame` listed as itself stays off this ladder so tracker
|
|
15156
|
+
* ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
|
|
15157
|
+
* absent: a drawn rectangle is a permanent lie in a digest tile.
|
|
15139
15158
|
*/
|
|
15140
15159
|
var SUMMARY_MEDIA_LADDER = [
|
|
15141
|
-
"
|
|
15142
|
-
"
|
|
15143
|
-
"
|
|
15144
|
-
"fullFrame",
|
|
15160
|
+
"groupCrop",
|
|
15161
|
+
"nativeFrame",
|
|
15162
|
+
"thumbnail",
|
|
15145
15163
|
"thumbnailSmall",
|
|
15146
|
-
"
|
|
15164
|
+
"snapshot"
|
|
15147
15165
|
];
|
|
15148
15166
|
/**
|
|
15149
15167
|
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
@@ -15161,6 +15179,13 @@ function pickSummaryMedia(rows, aroundMs) {
|
|
|
15161
15179
|
}
|
|
15162
15180
|
return null;
|
|
15163
15181
|
}
|
|
15182
|
+
/**
|
|
15183
|
+
* Same camera, same wall-clock second → same JPEG. Tracker ID churn
|
|
15184
|
+
* otherwise fills a mosaic with eleven copies of one driveway still.
|
|
15185
|
+
*/
|
|
15186
|
+
function frameDedupeKey(deviceId, mediaTimestamp) {
|
|
15187
|
+
return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
|
|
15188
|
+
}
|
|
15164
15189
|
//#endregion
|
|
15165
15190
|
//#region src/notification-center/summary/summary-window.ts
|
|
15166
15191
|
/**
|
|
@@ -15219,7 +15244,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
|
15219
15244
|
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
15220
15245
|
return `summary-${ruleId}-${windowEndMs}`;
|
|
15221
15246
|
}
|
|
15222
|
-
var EMPTY_REPORT = {
|
|
15247
|
+
var EMPTY_REPORT$1 = {
|
|
15223
15248
|
produced: 0,
|
|
15224
15249
|
collecting: 0,
|
|
15225
15250
|
barrier: 0,
|
|
@@ -15283,9 +15308,9 @@ var NcSummaryProducer = class {
|
|
|
15283
15308
|
* must not open ten simultaneous render/publish sequences.
|
|
15284
15309
|
*/
|
|
15285
15310
|
async tick() {
|
|
15286
|
-
if (this.tickInFlight) return EMPTY_REPORT;
|
|
15311
|
+
if (this.tickInFlight) return EMPTY_REPORT$1;
|
|
15287
15312
|
this.tickInFlight = true;
|
|
15288
|
-
const report = { ...EMPTY_REPORT };
|
|
15313
|
+
const report = { ...EMPTY_REPORT$1 };
|
|
15289
15314
|
try {
|
|
15290
15315
|
const now = this.deps.now();
|
|
15291
15316
|
const rules = this.deps.store.listEnabled();
|
|
@@ -15460,7 +15485,12 @@ var NcSummaryProducer = class {
|
|
|
15460
15485
|
droppedStationary: filtered.droppedStationary
|
|
15461
15486
|
};
|
|
15462
15487
|
}
|
|
15463
|
-
const loaded = await this.loadTiles(
|
|
15488
|
+
const loaded = await this.loadTiles({
|
|
15489
|
+
rule,
|
|
15490
|
+
preferred: selection.tiles,
|
|
15491
|
+
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
15492
|
+
maxTiles: rule.maxTiles
|
|
15493
|
+
});
|
|
15464
15494
|
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
15465
15495
|
firstSeen: t.firstSeen,
|
|
15466
15496
|
lastSeen: t.lastSeen,
|
|
@@ -15518,7 +15548,7 @@ var NcSummaryProducer = class {
|
|
|
15518
15548
|
tiles: loaded.length,
|
|
15519
15549
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
15520
15550
|
camerasDropped: selection.camerasDropped,
|
|
15521
|
-
droppedNoMedia:
|
|
15551
|
+
droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
|
|
15522
15552
|
mosaicBytes: mosaic.bytes,
|
|
15523
15553
|
mosaicPublished: mosaic.ref !== null,
|
|
15524
15554
|
enqueued,
|
|
@@ -15582,10 +15612,18 @@ var NcSummaryProducer = class {
|
|
|
15582
15612
|
}
|
|
15583
15613
|
return all;
|
|
15584
15614
|
}
|
|
15585
|
-
/** One blob per tile — the cheap question (index rows) always first.
|
|
15586
|
-
|
|
15615
|
+
/** One blob per tile — the cheap question (index rows) always first.
|
|
15616
|
+
* Duplicate camera-seconds are skipped so tracker ID churn cannot paint
|
|
15617
|
+
* the same driveway eleven times; leftover ranked candidates refill. */
|
|
15618
|
+
async loadTiles(input) {
|
|
15587
15619
|
const out = [];
|
|
15588
|
-
|
|
15620
|
+
const seenFrames = /* @__PURE__ */ new Set();
|
|
15621
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
15622
|
+
const queue = [...input.preferred, ...input.refill];
|
|
15623
|
+
for (const candidate of queue) {
|
|
15624
|
+
if (out.length >= input.maxTiles) break;
|
|
15625
|
+
if (seenIds.has(candidate.trackId)) continue;
|
|
15626
|
+
seenIds.add(candidate.trackId);
|
|
15589
15627
|
const tags = { deviceId: candidate.deviceId };
|
|
15590
15628
|
try {
|
|
15591
15629
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
@@ -15594,25 +15632,38 @@ var NcSummaryProducer = class {
|
|
|
15594
15632
|
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
15595
15633
|
tags,
|
|
15596
15634
|
meta: {
|
|
15597
|
-
ruleId: rule.id,
|
|
15635
|
+
ruleId: input.rule.id,
|
|
15598
15636
|
trackId: candidate.trackId,
|
|
15599
15637
|
mediaRows: rows.length
|
|
15600
15638
|
}
|
|
15601
15639
|
});
|
|
15602
15640
|
continue;
|
|
15603
15641
|
}
|
|
15642
|
+
const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
|
|
15643
|
+
if (seenFrames.has(frameKey)) {
|
|
15644
|
+
this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
|
|
15645
|
+
tags,
|
|
15646
|
+
meta: {
|
|
15647
|
+
ruleId: input.rule.id,
|
|
15648
|
+
trackId: candidate.trackId,
|
|
15649
|
+
frameKey
|
|
15650
|
+
}
|
|
15651
|
+
});
|
|
15652
|
+
continue;
|
|
15653
|
+
}
|
|
15604
15654
|
const jpeg = await this.deps.readMedia(picked.key);
|
|
15605
15655
|
if (jpeg === null || jpeg.byteLength === 0) {
|
|
15606
15656
|
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
15607
15657
|
tags,
|
|
15608
15658
|
meta: {
|
|
15609
|
-
ruleId: rule.id,
|
|
15659
|
+
ruleId: input.rule.id,
|
|
15610
15660
|
trackId: candidate.trackId,
|
|
15611
15661
|
mediaKey: picked.key
|
|
15612
15662
|
}
|
|
15613
15663
|
});
|
|
15614
15664
|
continue;
|
|
15615
15665
|
}
|
|
15666
|
+
seenFrames.add(frameKey);
|
|
15616
15667
|
out.push({
|
|
15617
15668
|
candidate,
|
|
15618
15669
|
jpeg
|
|
@@ -15621,7 +15672,7 @@ var NcSummaryProducer = class {
|
|
|
15621
15672
|
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
15622
15673
|
tags,
|
|
15623
15674
|
meta: {
|
|
15624
|
-
ruleId: rule.id,
|
|
15675
|
+
ruleId: input.rule.id,
|
|
15625
15676
|
trackId: candidate.trackId,
|
|
15626
15677
|
error: String(err)
|
|
15627
15678
|
}
|
|
@@ -15996,7 +16047,7 @@ var NcSummaryRuleInputSchema = require_dist.object({
|
|
|
15996
16047
|
deviceIds: DeviceIdsField,
|
|
15997
16048
|
window: NcSummaryWindowSchema,
|
|
15998
16049
|
filters: NcSummaryFiltersSchema.optional(),
|
|
15999
|
-
maxTiles: MaxTilesField.default(
|
|
16050
|
+
maxTiles: MaxTilesField.default(12),
|
|
16000
16051
|
maxAiImages: MaxAiImagesField.default(6),
|
|
16001
16052
|
rank: NcSummaryRankSchema.default("importance"),
|
|
16002
16053
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
@@ -16714,8 +16765,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16714
16765
|
];
|
|
16715
16766
|
if (bbox === void 0) return files;
|
|
16716
16767
|
try {
|
|
16717
|
-
const { default: sharp$
|
|
16718
|
-
const meta = await sharp$
|
|
16768
|
+
const { default: sharp$14 } = await import("sharp");
|
|
16769
|
+
const meta = await sharp$14(Buffer.from(jpeg)).metadata();
|
|
16719
16770
|
const W = meta.width ?? 0;
|
|
16720
16771
|
const H = meta.height ?? 0;
|
|
16721
16772
|
if (W === 0 || H === 0) return files;
|
|
@@ -16723,7 +16774,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16723
16774
|
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
16724
16775
|
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
16725
16776
|
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
16726
|
-
const cropBuf = await sharp$
|
|
16777
|
+
const cropBuf = await sharp$14(Buffer.from(jpeg)).extract({
|
|
16727
16778
|
left,
|
|
16728
16779
|
top,
|
|
16729
16780
|
width,
|
|
@@ -16745,7 +16796,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
16745
16796
|
});
|
|
16746
16797
|
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
16747
16798
|
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$
|
|
16799
|
+
const boxedBuf = await sharp$14(Buffer.from(jpeg)).composite([{
|
|
16749
16800
|
input: overlay,
|
|
16750
16801
|
top: 0,
|
|
16751
16802
|
left: 0
|
|
@@ -18362,6 +18413,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
|
18362
18413
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
18363
18414
|
return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
18364
18415
|
}
|
|
18416
|
+
var EMPTY_REPORT = {
|
|
18417
|
+
produced: 0,
|
|
18418
|
+
waiting: 0,
|
|
18419
|
+
alreadyDone: 0,
|
|
18420
|
+
beforeFloor: 0,
|
|
18421
|
+
failed: 0,
|
|
18422
|
+
idle: 0,
|
|
18423
|
+
skippedInFlight: 0
|
|
18424
|
+
};
|
|
18365
18425
|
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
18366
18426
|
function renderFlightKey(ruleId, deviceId) {
|
|
18367
18427
|
return `${ruleId}:${String(deviceId)}`;
|
|
@@ -18369,7 +18429,8 @@ function renderFlightKey(ruleId, deviceId) {
|
|
|
18369
18429
|
var TimelapseScheduler = class {
|
|
18370
18430
|
deps;
|
|
18371
18431
|
timer = null;
|
|
18372
|
-
|
|
18432
|
+
/** True only while this tick is scanning eligibility — not while ffmpeg runs. */
|
|
18433
|
+
scanning = false;
|
|
18373
18434
|
inFlight = /* @__PURE__ */ new Map();
|
|
18374
18435
|
settleMs;
|
|
18375
18436
|
lookbackMs;
|
|
@@ -18416,25 +18477,29 @@ var TimelapseScheduler = class {
|
|
|
18416
18477
|
this.timer = null;
|
|
18417
18478
|
}
|
|
18418
18479
|
}
|
|
18419
|
-
/**
|
|
18480
|
+
/**
|
|
18481
|
+
* One pass over every enabled rule × camera.
|
|
18482
|
+
*
|
|
18483
|
+
* Eligibility is scanned under a short `scanning` lock so two ticks do not
|
|
18484
|
+
* double-launch the same pair. Renders themselves are started together —
|
|
18485
|
+
* awaiting them one-by-one made camera B wait for camera A's ffmpeg
|
|
18486
|
+
* (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
|
|
18487
|
+
* A later tick that meets a pair already in `inFlight` counts it and
|
|
18488
|
+
* moves on, so a 30-minute render does not make the producer go deaf.
|
|
18489
|
+
*/
|
|
18420
18490
|
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;
|
|
18491
|
+
if (this.scanning) return { ...EMPTY_REPORT };
|
|
18492
|
+
this.scanning = true;
|
|
18430
18493
|
const report = {
|
|
18431
18494
|
produced: 0,
|
|
18432
18495
|
waiting: 0,
|
|
18433
18496
|
alreadyDone: 0,
|
|
18434
18497
|
beforeFloor: 0,
|
|
18435
18498
|
failed: 0,
|
|
18436
|
-
idle: 0
|
|
18499
|
+
idle: 0,
|
|
18500
|
+
skippedInFlight: 0
|
|
18437
18501
|
};
|
|
18502
|
+
const jobs = [];
|
|
18438
18503
|
try {
|
|
18439
18504
|
const now = this.deps.now();
|
|
18440
18505
|
for (const rule of this.deps.store.listEnabled()) {
|
|
@@ -18469,13 +18534,20 @@ var TimelapseScheduler = class {
|
|
|
18469
18534
|
});
|
|
18470
18535
|
continue;
|
|
18471
18536
|
}
|
|
18472
|
-
if (
|
|
18473
|
-
|
|
18537
|
+
if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
|
|
18538
|
+
report.skippedInFlight += 1;
|
|
18539
|
+
continue;
|
|
18540
|
+
}
|
|
18541
|
+
jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
|
|
18542
|
+
if (ok.produced > 0) report.produced += 1;
|
|
18543
|
+
else report.failed += 1;
|
|
18544
|
+
}));
|
|
18474
18545
|
}
|
|
18475
18546
|
}
|
|
18476
18547
|
} finally {
|
|
18477
|
-
this.
|
|
18548
|
+
this.scanning = false;
|
|
18478
18549
|
}
|
|
18550
|
+
await Promise.all(jobs);
|
|
18479
18551
|
return report;
|
|
18480
18552
|
}
|
|
18481
18553
|
/**
|
|
@@ -27631,6 +27703,35 @@ function pairRiders(detections) {
|
|
|
27631
27703
|
return pairs;
|
|
27632
27704
|
}
|
|
27633
27705
|
//#endregion
|
|
27706
|
+
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
27707
|
+
/** How far the grown box's centroid may sit, as a multiple of the LARGER
|
|
27708
|
+
* diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
|
|
27709
|
+
var GROWTH_CENTROID_FACTOR = 1.35;
|
|
27710
|
+
function intersectionArea(a, b) {
|
|
27711
|
+
const x1 = Math.max(a.x, b.x);
|
|
27712
|
+
const y1 = Math.max(a.y, b.y);
|
|
27713
|
+
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
27714
|
+
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
|
27715
|
+
if (x2 <= x1 || y2 <= y1) return 0;
|
|
27716
|
+
return (x2 - x1) * (y2 - y1);
|
|
27717
|
+
}
|
|
27718
|
+
/** Same-class partial crop growing into (or collapsing from) a full-body box
|
|
27719
|
+
* with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
|
|
27720
|
+
* A SMALL box nested inside a LARGE one is occlusion, not growth — those
|
|
27721
|
+
* stay two tracks (sort-tracker occlusion suite). */
|
|
27722
|
+
function growthAdjacentMatch(track, det) {
|
|
27723
|
+
const trackArea = track.w * track.h;
|
|
27724
|
+
const detArea = det.w * det.h;
|
|
27725
|
+
if (trackArea <= 0 || detArea <= 0) return false;
|
|
27726
|
+
const larger = Math.max(trackArea, detArea);
|
|
27727
|
+
const smaller = Math.min(trackArea, detArea);
|
|
27728
|
+
if (larger / smaller < 1.5) return false;
|
|
27729
|
+
if (intersectionArea(track, det) / smaller >= .5) return false;
|
|
27730
|
+
const tc = bboxCentroid(track);
|
|
27731
|
+
const dc = bboxCentroid(det);
|
|
27732
|
+
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));
|
|
27733
|
+
}
|
|
27734
|
+
//#endregion
|
|
27634
27735
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
27635
27736
|
var DEFAULT_TRACKER_CONFIG = {
|
|
27636
27737
|
iouThreshold: .3,
|
|
@@ -27758,7 +27859,7 @@ var SortTracker = class SortTracker {
|
|
|
27758
27859
|
* and earlier same-frame spawns).
|
|
27759
27860
|
*/
|
|
27760
27861
|
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;
|
|
27862
|
+
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
27863
|
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
27763
27864
|
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
27764
27865
|
}
|
|
@@ -27852,7 +27953,8 @@ var SortTracker = class SortTracker {
|
|
|
27852
27953
|
const dc = bboxCentroid(det.bbox);
|
|
27853
27954
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
27854
27955
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
27855
|
-
|
|
27956
|
+
if (dist <= this.config.rescueCentroidFactor * diag) return true;
|
|
27957
|
+
return growthAdjacentMatch(track.bbox, det.bbox);
|
|
27856
27958
|
}
|
|
27857
27959
|
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
27858
27960
|
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
@@ -28177,6 +28279,7 @@ var StateAnalyzer = class {
|
|
|
28177
28279
|
enteredAt: timestamp,
|
|
28178
28280
|
stationarySince: void 0,
|
|
28179
28281
|
totalDistancePx: 0,
|
|
28282
|
+
startPosition: center,
|
|
28180
28283
|
lastPosition: center,
|
|
28181
28284
|
frameCount: 1
|
|
28182
28285
|
};
|
|
@@ -28196,8 +28299,9 @@ var StateAnalyzer = class {
|
|
|
28196
28299
|
else if (velocity <= this.config.velocityThreshold) {
|
|
28197
28300
|
if (!ts.stationarySince) ts.stationarySince = timestamp;
|
|
28198
28301
|
const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
|
|
28302
|
+
const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
|
|
28199
28303
|
if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
|
|
28200
|
-
else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28304
|
+
else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28201
28305
|
else state = "moving";
|
|
28202
28306
|
} else {
|
|
28203
28307
|
ts.stationarySince = void 0;
|
|
@@ -29030,6 +29134,22 @@ function promoteSemanticBestFrame(deps, input) {
|
|
|
29030
29134
|
};
|
|
29031
29135
|
}
|
|
29032
29136
|
//#endregion
|
|
29137
|
+
//#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
|
|
29138
|
+
/** Which box the semantic best-shot should crop: a plate/face DETAIL box
|
|
29139
|
+
* when we have one, otherwise the subject box. */
|
|
29140
|
+
function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
|
|
29141
|
+
if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
|
|
29142
|
+
return subjectBbox;
|
|
29143
|
+
}
|
|
29144
|
+
//#endregion
|
|
29145
|
+
//#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
|
|
29146
|
+
/** Crop box for a late firstFrame retry: the BIRTH geometry, not the
|
|
29147
|
+
* later frozen/coasted box (track 1b659d56). */
|
|
29148
|
+
function firstFrameRetryBbox(current, birth) {
|
|
29149
|
+
if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
|
|
29150
|
+
return { ...current };
|
|
29151
|
+
}
|
|
29152
|
+
//#endregion
|
|
29033
29153
|
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
29034
29154
|
/**
|
|
29035
29155
|
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
@@ -29244,6 +29364,7 @@ var EMPTY_CASCADE_TALLY = {
|
|
|
29244
29364
|
faces: 0,
|
|
29245
29365
|
plates: 0,
|
|
29246
29366
|
embeddings: 0,
|
|
29367
|
+
groups: 0,
|
|
29247
29368
|
bytesReclaimed: 0
|
|
29248
29369
|
};
|
|
29249
29370
|
/** Fold a leaf store's receipts into a running tally. */
|
|
@@ -29287,6 +29408,13 @@ function addLeafCounts(tally, counts) {
|
|
|
29287
29408
|
bytesReclaimed
|
|
29288
29409
|
};
|
|
29289
29410
|
break;
|
|
29411
|
+
case "groups":
|
|
29412
|
+
next = {
|
|
29413
|
+
...next,
|
|
29414
|
+
groups: next.groups + c.rows,
|
|
29415
|
+
bytesReclaimed
|
|
29416
|
+
};
|
|
29417
|
+
break;
|
|
29290
29418
|
}
|
|
29291
29419
|
}
|
|
29292
29420
|
return next;
|
|
@@ -29300,6 +29428,7 @@ function mergeCascadeTallies(a, b) {
|
|
|
29300
29428
|
faces: a.faces + b.faces,
|
|
29301
29429
|
plates: a.plates + b.plates,
|
|
29302
29430
|
embeddings: a.embeddings + b.embeddings,
|
|
29431
|
+
groups: a.groups + b.groups,
|
|
29303
29432
|
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
29304
29433
|
};
|
|
29305
29434
|
}
|
|
@@ -29348,6 +29477,7 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
29348
29477
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
29349
29478
|
if (stores.plateStore) registry.push(stores.plateStore);
|
|
29350
29479
|
if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
|
|
29480
|
+
if (stores.groupStore) registry.push(stores.groupStore);
|
|
29351
29481
|
return registry;
|
|
29352
29482
|
}
|
|
29353
29483
|
/**
|
|
@@ -31058,7 +31188,8 @@ var OWNER_KINDS = [
|
|
|
31058
31188
|
"identity",
|
|
31059
31189
|
"plate",
|
|
31060
31190
|
"vehicle",
|
|
31061
|
-
"scene"
|
|
31191
|
+
"scene",
|
|
31192
|
+
"group"
|
|
31062
31193
|
];
|
|
31063
31194
|
/**
|
|
31064
31195
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -31807,6 +31938,10 @@ var MediaStore = class {
|
|
|
31807
31938
|
async deleteForTracks(trackIds) {
|
|
31808
31939
|
return this.deleteForOwner("track", trackIds);
|
|
31809
31940
|
}
|
|
31941
|
+
/** Delete the group's OWN crop. The native frame is a pointer and is not here. */
|
|
31942
|
+
async deleteByGroups(groupIds) {
|
|
31943
|
+
return this.deleteForOwnerCounted("group", groupIds);
|
|
31944
|
+
}
|
|
31810
31945
|
/**
|
|
31811
31946
|
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
31812
31947
|
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
@@ -39898,7 +40033,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
39898
40033
|
function planPeriodicMedia(input) {
|
|
39899
40034
|
const appendSnapshot = input.dueSnapshot;
|
|
39900
40035
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
39901
|
-
const
|
|
40036
|
+
const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
40037
|
+
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
40038
|
+
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
39902
40039
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
39903
40040
|
return {
|
|
39904
40041
|
appendSnapshot,
|
|
@@ -40131,6 +40268,1405 @@ var DeferredBirthRegistry = class {
|
|
|
40131
40268
|
this.byDevice.delete(deviceKey);
|
|
40132
40269
|
}
|
|
40133
40270
|
};
|
|
40271
|
+
var AnalyticsGroupRecordSchema = require_dist.object({
|
|
40272
|
+
id: require_dist.string(),
|
|
40273
|
+
deviceId: require_dist.number().int(),
|
|
40274
|
+
openedAt: require_dist.number().int(),
|
|
40275
|
+
closedAt: require_dist.number().int(),
|
|
40276
|
+
timestamp: require_dist.number().int(),
|
|
40277
|
+
memberCount: require_dist.number().int(),
|
|
40278
|
+
memberTrackIds: require_dist.array(require_dist.string()).readonly(),
|
|
40279
|
+
className: require_dist.string(),
|
|
40280
|
+
classes: require_dist.array(require_dist.string()).readonly(),
|
|
40281
|
+
/** Relative event-media path, or null when the group has no picture yet. */
|
|
40282
|
+
mediaUrl: require_dist.string().nullable(),
|
|
40283
|
+
singleton: require_dist.boolean()
|
|
40284
|
+
});
|
|
40285
|
+
var AnalyticsGroupMemberSchema = require_dist.object({
|
|
40286
|
+
trackId: require_dist.string(),
|
|
40287
|
+
deviceId: require_dist.number().int(),
|
|
40288
|
+
className: require_dist.string(),
|
|
40289
|
+
firstSeen: require_dist.number().int(),
|
|
40290
|
+
lastSeen: require_dist.number().int(),
|
|
40291
|
+
mediaUrl: require_dist.string().nullable()
|
|
40292
|
+
});
|
|
40293
|
+
var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: require_dist.array(AnalyticsGroupMemberSchema).readonly() });
|
|
40294
|
+
var ListGroupsInputSchema = require_dist.object({
|
|
40295
|
+
deviceId: require_dist.number().int().optional(),
|
|
40296
|
+
sinceMs: require_dist.number().int().optional(),
|
|
40297
|
+
untilMs: require_dist.number().int().optional(),
|
|
40298
|
+
limit: require_dist.number().int().min(1).max(100).optional(),
|
|
40299
|
+
offset: require_dist.number().int().min(0).optional()
|
|
40300
|
+
});
|
|
40301
|
+
var groupActions = require_dist.defineCustomActions({
|
|
40302
|
+
"analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
|
|
40303
|
+
groups: require_dist.array(AnalyticsGroupRecordSchema).readonly(),
|
|
40304
|
+
hasMore: require_dist.boolean()
|
|
40305
|
+
})),
|
|
40306
|
+
"analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: AnalyticsGroupDetailSchema.nullable() }))
|
|
40307
|
+
});
|
|
40308
|
+
//#endregion
|
|
40309
|
+
//#region src/pipeline-analytics/group/group-query.ts
|
|
40310
|
+
/**
|
|
40311
|
+
* Stamp a relative event-media URL.
|
|
40312
|
+
*
|
|
40313
|
+
* Media keys contain `:` and the data-plane serves them by key. Track ids are
|
|
40314
|
+
* bare and fall through to the existing event/track thumbnail resolver. Never
|
|
40315
|
+
* percent-encode: the handler does not URL-decode the path.
|
|
40316
|
+
*/
|
|
40317
|
+
function stampEventMediaUrl(baseUrl, id) {
|
|
40318
|
+
if (baseUrl === null || baseUrl.length === 0) return null;
|
|
40319
|
+
if (id === null || id === void 0 || id.length === 0) return null;
|
|
40320
|
+
return `${baseUrl}/${id}`;
|
|
40321
|
+
}
|
|
40322
|
+
/** Union of every class on the members; `className` is the first, stable order. */
|
|
40323
|
+
function aggregateGroupClasses(tracks) {
|
|
40324
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40325
|
+
const classes = [];
|
|
40326
|
+
for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
|
|
40327
|
+
if (name.length === 0 || seen.has(name)) continue;
|
|
40328
|
+
seen.add(name);
|
|
40329
|
+
classes.push(name);
|
|
40330
|
+
}
|
|
40331
|
+
return {
|
|
40332
|
+
className: classes[0] ?? "unknown",
|
|
40333
|
+
classes
|
|
40334
|
+
};
|
|
40335
|
+
}
|
|
40336
|
+
function toGroupRecord(input) {
|
|
40337
|
+
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40338
|
+
const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40339
|
+
return {
|
|
40340
|
+
id: input.group.id,
|
|
40341
|
+
deviceId: input.group.deviceId,
|
|
40342
|
+
openedAt: input.group.openedAt,
|
|
40343
|
+
closedAt: input.group.closedAt,
|
|
40344
|
+
timestamp: input.group.timestamp,
|
|
40345
|
+
memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
|
|
40346
|
+
memberTrackIds: [...input.memberTrackIds],
|
|
40347
|
+
className,
|
|
40348
|
+
classes: [...classes],
|
|
40349
|
+
mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
|
|
40350
|
+
singleton: input.memberTrackIds.length <= 1
|
|
40351
|
+
};
|
|
40352
|
+
}
|
|
40353
|
+
function toGroupMember(member, track, eventMediaBaseUrl) {
|
|
40354
|
+
return {
|
|
40355
|
+
trackId: member.trackId,
|
|
40356
|
+
deviceId: member.deviceId,
|
|
40357
|
+
className: track?.className ?? "unknown",
|
|
40358
|
+
firstSeen: track?.firstSeen ?? member.timestamp,
|
|
40359
|
+
lastSeen: track?.lastSeen ?? member.timestamp,
|
|
40360
|
+
mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
|
|
40361
|
+
};
|
|
40362
|
+
}
|
|
40363
|
+
function trackToLite(track) {
|
|
40364
|
+
return {
|
|
40365
|
+
trackId: track.trackId,
|
|
40366
|
+
className: track.className,
|
|
40367
|
+
classes: track.classes,
|
|
40368
|
+
firstSeen: track.firstSeen,
|
|
40369
|
+
lastSeen: track.lastSeen
|
|
40370
|
+
};
|
|
40371
|
+
}
|
|
40372
|
+
async function listGroupRecords(ports, input) {
|
|
40373
|
+
const page = await ports.groupStore.listRecent(input);
|
|
40374
|
+
const groups = [];
|
|
40375
|
+
for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
|
|
40376
|
+
return {
|
|
40377
|
+
groups,
|
|
40378
|
+
hasMore: page.hasMore
|
|
40379
|
+
};
|
|
40380
|
+
}
|
|
40381
|
+
async function getGroupDetail(ports, groupId) {
|
|
40382
|
+
const group = await ports.groupStore.get(groupId);
|
|
40383
|
+
if (group === null) return null;
|
|
40384
|
+
const members = await ports.groupStore.listMembers(groupId);
|
|
40385
|
+
const lites = [];
|
|
40386
|
+
const rows = [];
|
|
40387
|
+
for (const member of members) {
|
|
40388
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40389
|
+
const lite = track === null ? null : trackToLite(track);
|
|
40390
|
+
if (lite !== null) lites.push(lite);
|
|
40391
|
+
rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
|
|
40392
|
+
}
|
|
40393
|
+
return {
|
|
40394
|
+
...toGroupRecord({
|
|
40395
|
+
group,
|
|
40396
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40397
|
+
tracks: lites,
|
|
40398
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40399
|
+
}),
|
|
40400
|
+
members: rows
|
|
40401
|
+
};
|
|
40402
|
+
}
|
|
40403
|
+
async function buildGroupRecord(ports, group) {
|
|
40404
|
+
const members = await ports.groupStore.listMembers(group.id);
|
|
40405
|
+
const lites = [];
|
|
40406
|
+
for (const member of members) {
|
|
40407
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40408
|
+
if (track !== null) lites.push(trackToLite(track));
|
|
40409
|
+
}
|
|
40410
|
+
return toGroupRecord({
|
|
40411
|
+
group,
|
|
40412
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40413
|
+
tracks: lites,
|
|
40414
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40415
|
+
});
|
|
40416
|
+
}
|
|
40417
|
+
/**
|
|
40418
|
+
* Gallery and digest read the same ladder: groupCrop, then the native-frame
|
|
40419
|
+
* pointer, then member subject crops. A multi with no crop still has a
|
|
40420
|
+
* picture if the sealer stored `nativeKey`.
|
|
40421
|
+
*/
|
|
40422
|
+
function digestMediaRows(input) {
|
|
40423
|
+
const owned = [];
|
|
40424
|
+
if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
|
|
40425
|
+
key: input.mediaKey,
|
|
40426
|
+
kind: "groupCrop",
|
|
40427
|
+
timestamp: input.timestamp
|
|
40428
|
+
});
|
|
40429
|
+
if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
|
|
40430
|
+
key: input.nativeKey,
|
|
40431
|
+
kind: "nativeFrame",
|
|
40432
|
+
timestamp: input.timestamp
|
|
40433
|
+
});
|
|
40434
|
+
if (owned.length > 0) return owned;
|
|
40435
|
+
return input.memberRows;
|
|
40436
|
+
}
|
|
40437
|
+
var MATCH_WINDOW_MS = 1e3;
|
|
40438
|
+
var MOVE_EPS = .35;
|
|
40439
|
+
/** Live-assigner window: same predicate, recent positions only. */
|
|
40440
|
+
var LIVE_WINDOW_MS = 3e3;
|
|
40441
|
+
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40442
|
+
function isEligibleMember(track) {
|
|
40443
|
+
if (track.closedReason === "stationary-promotion") return false;
|
|
40444
|
+
if (track.className === "audio") return false;
|
|
40445
|
+
return true;
|
|
40446
|
+
}
|
|
40447
|
+
function diag(bbox) {
|
|
40448
|
+
return Math.hypot(bbox.w, bbox.h);
|
|
40449
|
+
}
|
|
40450
|
+
function nearestPosition(positions, t) {
|
|
40451
|
+
let best;
|
|
40452
|
+
let bestDt = 1001;
|
|
40453
|
+
for (const p of positions) {
|
|
40454
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40455
|
+
if (dt <= 1e3 && dt < bestDt) {
|
|
40456
|
+
best = p;
|
|
40457
|
+
bestDt = dt;
|
|
40458
|
+
}
|
|
40459
|
+
}
|
|
40460
|
+
return best;
|
|
40461
|
+
}
|
|
40462
|
+
function meanDiag(positions) {
|
|
40463
|
+
if (positions.length === 0) return 0;
|
|
40464
|
+
let sum = 0;
|
|
40465
|
+
for (const p of positions) sum += diag(p.bbox);
|
|
40466
|
+
return sum / positions.length;
|
|
40467
|
+
}
|
|
40468
|
+
function vecLen(v) {
|
|
40469
|
+
return Math.hypot(v.x, v.y);
|
|
40470
|
+
}
|
|
40471
|
+
function cosine(a, b) {
|
|
40472
|
+
const la = vecLen(a);
|
|
40473
|
+
const lb = vecLen(b);
|
|
40474
|
+
if (la === 0 || lb === 0) return 1;
|
|
40475
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40476
|
+
}
|
|
40477
|
+
function positionsInWindow(track, start, end) {
|
|
40478
|
+
return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
|
|
40479
|
+
}
|
|
40480
|
+
function windowDisplacement(positions) {
|
|
40481
|
+
const first = positions[0];
|
|
40482
|
+
const last = positions[positions.length - 1];
|
|
40483
|
+
if (!first || !last) return {
|
|
40484
|
+
x: 0,
|
|
40485
|
+
y: 0
|
|
40486
|
+
};
|
|
40487
|
+
const a = bboxCentroid(first.bbox);
|
|
40488
|
+
const b = bboxCentroid(last.bbox);
|
|
40489
|
+
return {
|
|
40490
|
+
x: b.x - a.x,
|
|
40491
|
+
y: b.y - a.y
|
|
40492
|
+
};
|
|
40493
|
+
}
|
|
40494
|
+
function windowRelativeMotion(positions) {
|
|
40495
|
+
const mean = meanDiag(positions);
|
|
40496
|
+
if (mean <= 0) return 0;
|
|
40497
|
+
return vecLen(windowDisplacement(positions)) / mean;
|
|
40498
|
+
}
|
|
40499
|
+
/**
|
|
40500
|
+
* Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
|
|
40501
|
+
* Displacement/cosine use the window, not the whole track.
|
|
40502
|
+
*/
|
|
40503
|
+
function pairTogetherNow(a, b, now) {
|
|
40504
|
+
const start = now - LIVE_WINDOW_MS;
|
|
40505
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
|
|
40506
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
|
|
40507
|
+
if (overlapEnd < overlapStart) return false;
|
|
40508
|
+
let joint = 0;
|
|
40509
|
+
let close = 0;
|
|
40510
|
+
for (let t = overlapStart; t <= overlapEnd; t += 500) {
|
|
40511
|
+
const pa = nearestPosition(a.positions, t);
|
|
40512
|
+
const pb = nearestPosition(b.positions, t);
|
|
40513
|
+
if (!pa || !pb) continue;
|
|
40514
|
+
joint++;
|
|
40515
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40516
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40517
|
+
const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40518
|
+
const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
|
|
40519
|
+
if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
|
|
40520
|
+
}
|
|
40521
|
+
if (joint === 0) return false;
|
|
40522
|
+
if (close / joint < .5) return false;
|
|
40523
|
+
const wa = positionsInWindow(a, start, now);
|
|
40524
|
+
const wb = positionsInWindow(b, start, now);
|
|
40525
|
+
const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
|
|
40526
|
+
const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
|
|
40527
|
+
if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
|
|
40528
|
+
return true;
|
|
40529
|
+
}
|
|
40530
|
+
var GroupLiveAssigner = class {
|
|
40531
|
+
nextId;
|
|
40532
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
40533
|
+
groups = /* @__PURE__ */ new Map();
|
|
40534
|
+
membership = /* @__PURE__ */ new Map();
|
|
40535
|
+
/** `${trackId}>${groupId}` → first instant the join predicate held. */
|
|
40536
|
+
joinSince = /* @__PURE__ */ new Map();
|
|
40537
|
+
/** trackId → first instant they failed pair against every remaining member. */
|
|
40538
|
+
leaveSince = /* @__PURE__ */ new Map();
|
|
40539
|
+
constructor(nextId = () => (0, node_crypto.randomUUID)()) {
|
|
40540
|
+
this.nextId = nextId;
|
|
40541
|
+
}
|
|
40542
|
+
observe(track, now) {
|
|
40543
|
+
this.snapshots.set(track.trackId, track);
|
|
40544
|
+
if (!isEligibleMember(track)) {
|
|
40545
|
+
this.removeFromGroup(track.trackId);
|
|
40546
|
+
return;
|
|
40547
|
+
}
|
|
40548
|
+
if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
|
|
40549
|
+
this.considerLeave(track, now);
|
|
40550
|
+
const groupId = this.membership.get(track.trackId);
|
|
40551
|
+
const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
|
|
40552
|
+
if (group) for (const id of [...group.members]) {
|
|
40553
|
+
if (id === track.trackId) continue;
|
|
40554
|
+
const snap = this.snapshots.get(id);
|
|
40555
|
+
if (snap) this.considerLeave(snap, now);
|
|
40556
|
+
}
|
|
40557
|
+
this.considerJoin(track, now);
|
|
40558
|
+
}
|
|
40559
|
+
groupOf(trackId) {
|
|
40560
|
+
return this.membership.get(trackId);
|
|
40561
|
+
}
|
|
40562
|
+
snapshot(trackId) {
|
|
40563
|
+
return this.snapshots.get(trackId);
|
|
40564
|
+
}
|
|
40565
|
+
liveGroups() {
|
|
40566
|
+
return [...this.groups.values()].map((g) => this.toLive(g));
|
|
40567
|
+
}
|
|
40568
|
+
idleGroups(now, idleMs) {
|
|
40569
|
+
return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
|
|
40570
|
+
}
|
|
40571
|
+
drop(ids) {
|
|
40572
|
+
for (const id of ids) {
|
|
40573
|
+
const g = this.groups.get(id);
|
|
40574
|
+
if (!g) continue;
|
|
40575
|
+
for (const member of g.members) this.membership.delete(member);
|
|
40576
|
+
this.groups.delete(id);
|
|
40577
|
+
}
|
|
40578
|
+
}
|
|
40579
|
+
birthSingleton(track, now) {
|
|
40580
|
+
const id = this.nextId();
|
|
40581
|
+
this.groups.set(id, {
|
|
40582
|
+
id,
|
|
40583
|
+
deviceId: track.deviceId,
|
|
40584
|
+
openedAt: now,
|
|
40585
|
+
members: new Set([track.trackId])
|
|
40586
|
+
});
|
|
40587
|
+
this.membership.set(track.trackId, id);
|
|
40588
|
+
}
|
|
40589
|
+
considerLeave(track, now) {
|
|
40590
|
+
const groupId = this.membership.get(track.trackId);
|
|
40591
|
+
if (groupId === void 0) return;
|
|
40592
|
+
const group = this.groups.get(groupId);
|
|
40593
|
+
if (!group || group.members.size < 2) {
|
|
40594
|
+
this.leaveSince.delete(track.trackId);
|
|
40595
|
+
return;
|
|
40596
|
+
}
|
|
40597
|
+
if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
|
|
40598
|
+
const other = this.snapshots.get(id);
|
|
40599
|
+
return other !== void 0 && pairTogetherNow(track, other, now);
|
|
40600
|
+
})) {
|
|
40601
|
+
this.leaveSince.delete(track.trackId);
|
|
40602
|
+
return;
|
|
40603
|
+
}
|
|
40604
|
+
const since = this.leaveSince.get(track.trackId) ?? now;
|
|
40605
|
+
this.leaveSince.set(track.trackId, since);
|
|
40606
|
+
if (now - since < 3e3) return;
|
|
40607
|
+
this.peel(track, now);
|
|
40608
|
+
}
|
|
40609
|
+
peel(track, now) {
|
|
40610
|
+
this.removeFromGroup(track.trackId);
|
|
40611
|
+
this.leaveSince.delete(track.trackId);
|
|
40612
|
+
this.birthSingleton(track, now);
|
|
40613
|
+
}
|
|
40614
|
+
considerJoin(track, now) {
|
|
40615
|
+
const mineId = this.membership.get(track.trackId);
|
|
40616
|
+
if (mineId === void 0) return;
|
|
40617
|
+
const mine = this.groups.get(mineId);
|
|
40618
|
+
if (!mine || mine.members.size !== 1) {
|
|
40619
|
+
this.clearJoinClocks(track.trackId);
|
|
40620
|
+
return;
|
|
40621
|
+
}
|
|
40622
|
+
let best;
|
|
40623
|
+
for (const group of this.groups.values()) {
|
|
40624
|
+
if (group.id === mineId || group.deviceId !== track.deviceId) continue;
|
|
40625
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
40626
|
+
let together = false;
|
|
40627
|
+
for (const memberId of group.members) {
|
|
40628
|
+
const other = this.snapshots.get(memberId);
|
|
40629
|
+
if (!other) continue;
|
|
40630
|
+
if (pairTogetherNow(track, other, now)) {
|
|
40631
|
+
together = true;
|
|
40632
|
+
gap = Math.min(gap, centroidGap(track, other, now));
|
|
40633
|
+
}
|
|
40634
|
+
}
|
|
40635
|
+
if (!together) continue;
|
|
40636
|
+
if (best === void 0 || gap < best.gap) best = {
|
|
40637
|
+
groupId: group.id,
|
|
40638
|
+
gap
|
|
40639
|
+
};
|
|
40640
|
+
}
|
|
40641
|
+
if (best === void 0) {
|
|
40642
|
+
this.clearJoinClocks(track.trackId);
|
|
40643
|
+
return;
|
|
40644
|
+
}
|
|
40645
|
+
const key = `${track.trackId}>${best.groupId}`;
|
|
40646
|
+
for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
|
|
40647
|
+
const since = this.joinSince.get(key) ?? now;
|
|
40648
|
+
this.joinSince.set(key, since);
|
|
40649
|
+
if (now - since < 1500) return;
|
|
40650
|
+
this.absorb(track.trackId, mineId, best.groupId);
|
|
40651
|
+
}
|
|
40652
|
+
absorb(trackId, fromId, intoId) {
|
|
40653
|
+
const into = this.groups.get(intoId);
|
|
40654
|
+
if (!into) return;
|
|
40655
|
+
into.members.add(trackId);
|
|
40656
|
+
this.membership.set(trackId, intoId);
|
|
40657
|
+
this.groups.delete(fromId);
|
|
40658
|
+
this.clearJoinClocks(trackId);
|
|
40659
|
+
this.leaveSince.delete(trackId);
|
|
40660
|
+
}
|
|
40661
|
+
removeFromGroup(trackId) {
|
|
40662
|
+
const groupId = this.membership.get(trackId);
|
|
40663
|
+
this.membership.delete(trackId);
|
|
40664
|
+
this.clearJoinClocks(trackId);
|
|
40665
|
+
this.leaveSince.delete(trackId);
|
|
40666
|
+
if (groupId === void 0) return;
|
|
40667
|
+
const group = this.groups.get(groupId);
|
|
40668
|
+
if (!group) return;
|
|
40669
|
+
group.members.delete(trackId);
|
|
40670
|
+
if (group.members.size === 0) this.groups.delete(groupId);
|
|
40671
|
+
}
|
|
40672
|
+
clearJoinClocks(trackId) {
|
|
40673
|
+
for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
|
|
40674
|
+
}
|
|
40675
|
+
toLive(group) {
|
|
40676
|
+
let lastActiveAt = group.openedAt;
|
|
40677
|
+
for (const id of group.members) {
|
|
40678
|
+
const snap = this.snapshots.get(id);
|
|
40679
|
+
if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
|
|
40680
|
+
}
|
|
40681
|
+
return {
|
|
40682
|
+
id: group.id,
|
|
40683
|
+
deviceId: group.deviceId,
|
|
40684
|
+
memberTrackIds: [...group.members],
|
|
40685
|
+
openedAt: group.openedAt,
|
|
40686
|
+
lastActiveAt
|
|
40687
|
+
};
|
|
40688
|
+
}
|
|
40689
|
+
};
|
|
40690
|
+
function centroidGap(a, b, now) {
|
|
40691
|
+
const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
|
|
40692
|
+
const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
|
|
40693
|
+
if (!pa || !pb) return Number.POSITIVE_INFINITY;
|
|
40694
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40695
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40696
|
+
return Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40697
|
+
}
|
|
40698
|
+
//#endregion
|
|
40699
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40700
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40701
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40702
|
+
//#endregion
|
|
40703
|
+
//#region src/pipeline-analytics/group/group-live-buffer.ts
|
|
40704
|
+
function toGroupableTrack(track) {
|
|
40705
|
+
return {
|
|
40706
|
+
trackId: track.trackId,
|
|
40707
|
+
deviceId: track.deviceId,
|
|
40708
|
+
className: track.className,
|
|
40709
|
+
firstSeen: track.firstSeen,
|
|
40710
|
+
lastSeen: track.lastSeen,
|
|
40711
|
+
positions: track.positions.map((p) => ({
|
|
40712
|
+
...p,
|
|
40713
|
+
bbox: { ...p.bbox }
|
|
40714
|
+
})),
|
|
40715
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40716
|
+
};
|
|
40717
|
+
}
|
|
40718
|
+
var GroupLiveBuffer = class {
|
|
40719
|
+
assigner;
|
|
40720
|
+
primed = /* @__PURE__ */ new Set();
|
|
40721
|
+
constructor(nextId) {
|
|
40722
|
+
this.assigner = new GroupLiveAssigner(nextId);
|
|
40723
|
+
}
|
|
40724
|
+
observe(track, now) {
|
|
40725
|
+
this.assigner.observe(track, now);
|
|
40726
|
+
}
|
|
40727
|
+
groupOf(trackId) {
|
|
40728
|
+
return this.assigner.groupOf(trackId);
|
|
40729
|
+
}
|
|
40730
|
+
snapshot(trackId) {
|
|
40731
|
+
return this.assigner.snapshot(trackId);
|
|
40732
|
+
}
|
|
40733
|
+
liveGroups() {
|
|
40734
|
+
return this.assigner.liveGroups();
|
|
40735
|
+
}
|
|
40736
|
+
idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40737
|
+
return this.assigner.idleGroups(now, idleMs);
|
|
40738
|
+
}
|
|
40739
|
+
drop(ids) {
|
|
40740
|
+
this.assigner.drop(ids);
|
|
40741
|
+
}
|
|
40742
|
+
isPrimed(deviceId) {
|
|
40743
|
+
return this.primed.has(deviceId);
|
|
40744
|
+
}
|
|
40745
|
+
markPrimed(deviceId) {
|
|
40746
|
+
this.primed.add(deviceId);
|
|
40747
|
+
}
|
|
40748
|
+
};
|
|
40749
|
+
//#endregion
|
|
40750
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40751
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40752
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40753
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40754
|
+
}
|
|
40755
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40756
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40757
|
+
W: frameWidth,
|
|
40758
|
+
H: frameHeight
|
|
40759
|
+
});
|
|
40760
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40761
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40762
|
+
}
|
|
40763
|
+
//#endregion
|
|
40764
|
+
//#region src/pipeline-analytics/group/group-instant.ts
|
|
40765
|
+
/**
|
|
40766
|
+
* Chosen instant + union bbox for a group, at close, over stored positions[]
|
|
40767
|
+
* (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
|
|
40768
|
+
*
|
|
40769
|
+
* Tier order — the first that separates two candidates decides:
|
|
40770
|
+
* 1. completeness (members with a position within ±1 s)
|
|
40771
|
+
* 2. square containment of the union (D212)
|
|
40772
|
+
* 3. semantic (a member's confirmed recognition)
|
|
40773
|
+
* 4. edge-clear (union touches no frame border)
|
|
40774
|
+
* 5. effective score = mean(confidence) × containment × sizeFactor
|
|
40775
|
+
*
|
|
40776
|
+
* Retrospective. No new capture path.
|
|
40777
|
+
*/
|
|
40778
|
+
var GROUP_MEDIA_MATCH_MS = 2e3;
|
|
40779
|
+
function nearest(positions, t, windowMs) {
|
|
40780
|
+
let best;
|
|
40781
|
+
let bestDt = windowMs + 1;
|
|
40782
|
+
for (const p of positions) {
|
|
40783
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40784
|
+
if (dt <= windowMs && dt < bestDt) {
|
|
40785
|
+
best = p;
|
|
40786
|
+
bestDt = dt;
|
|
40787
|
+
}
|
|
40788
|
+
}
|
|
40789
|
+
return best;
|
|
40790
|
+
}
|
|
40791
|
+
function unionOf(boxes) {
|
|
40792
|
+
if (boxes.length === 0) return null;
|
|
40793
|
+
let x0 = Infinity;
|
|
40794
|
+
let y0 = Infinity;
|
|
40795
|
+
let x1 = -Infinity;
|
|
40796
|
+
let y1 = -Infinity;
|
|
40797
|
+
for (const b of boxes) {
|
|
40798
|
+
x0 = Math.min(x0, b.x);
|
|
40799
|
+
y0 = Math.min(y0, b.y);
|
|
40800
|
+
x1 = Math.max(x1, b.x + b.w);
|
|
40801
|
+
y1 = Math.max(y1, b.y + b.h);
|
|
40802
|
+
}
|
|
40803
|
+
return {
|
|
40804
|
+
x: x0,
|
|
40805
|
+
y: y0,
|
|
40806
|
+
w: x1 - x0,
|
|
40807
|
+
h: y1 - y0
|
|
40808
|
+
};
|
|
40809
|
+
}
|
|
40810
|
+
function touchesBorder(box, frame, tol = 1) {
|
|
40811
|
+
return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
|
|
40812
|
+
}
|
|
40813
|
+
function scoreInstant(members, t, frame) {
|
|
40814
|
+
const boxes = [];
|
|
40815
|
+
let semantic = false;
|
|
40816
|
+
let confSum = 0;
|
|
40817
|
+
let confN = 0;
|
|
40818
|
+
for (const m of members) {
|
|
40819
|
+
const p = nearest(m.positions, t, MATCH_WINDOW_MS);
|
|
40820
|
+
if (!p) continue;
|
|
40821
|
+
boxes.push(p.bbox);
|
|
40822
|
+
if (m.semantic === true) semantic = true;
|
|
40823
|
+
if (m.confidence !== void 0) {
|
|
40824
|
+
confSum += m.confidence;
|
|
40825
|
+
confN += 1;
|
|
40826
|
+
}
|
|
40827
|
+
}
|
|
40828
|
+
const union = unionOf(boxes);
|
|
40829
|
+
if (!union) return null;
|
|
40830
|
+
const square = centralSquareContainmentFraction(union, {
|
|
40831
|
+
W: frame.width,
|
|
40832
|
+
H: frame.height
|
|
40833
|
+
});
|
|
40834
|
+
const containment = bboxContainment(union, frame.width, frame.height);
|
|
40835
|
+
const size = bboxSizeScore(union, frame.width, frame.height);
|
|
40836
|
+
const meanConf = confN > 0 ? confSum / confN : 1;
|
|
40837
|
+
return {
|
|
40838
|
+
timestamp: t,
|
|
40839
|
+
union,
|
|
40840
|
+
visibleCount: boxes.length,
|
|
40841
|
+
square,
|
|
40842
|
+
semantic,
|
|
40843
|
+
edgeClear: !touchesBorder(union, frame),
|
|
40844
|
+
effective: meanConf * containment * size
|
|
40845
|
+
};
|
|
40846
|
+
}
|
|
40847
|
+
function better(a, b) {
|
|
40848
|
+
if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
|
|
40849
|
+
if (a.square !== b.square) return a.square > b.square ? a : b;
|
|
40850
|
+
if (a.semantic !== b.semantic) return a.semantic ? a : b;
|
|
40851
|
+
if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
|
|
40852
|
+
if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
|
|
40853
|
+
return a.timestamp <= b.timestamp ? a : b;
|
|
40854
|
+
}
|
|
40855
|
+
function chooseGroupInstant(members, frame) {
|
|
40856
|
+
if (members.length === 0) return null;
|
|
40857
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
40858
|
+
for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
|
|
40859
|
+
let best = null;
|
|
40860
|
+
for (const t of candidates) {
|
|
40861
|
+
const scored = scoreInstant(members, t, frame);
|
|
40862
|
+
if (!scored) continue;
|
|
40863
|
+
best = best === null ? scored : better(scored, best);
|
|
40864
|
+
}
|
|
40865
|
+
if (!best) return null;
|
|
40866
|
+
const w = Math.max(frame.width, 1);
|
|
40867
|
+
const h = Math.max(frame.height, 1);
|
|
40868
|
+
return {
|
|
40869
|
+
timestamp: best.timestamp,
|
|
40870
|
+
union: best.union,
|
|
40871
|
+
ux: best.union.x / w,
|
|
40872
|
+
uy: best.union.y / h,
|
|
40873
|
+
uw: best.union.w / w,
|
|
40874
|
+
uh: best.union.h / h,
|
|
40875
|
+
visibleCount: best.visibleCount
|
|
40876
|
+
};
|
|
40877
|
+
}
|
|
40878
|
+
/** Longest-lived member that owns a clean full frame; ties break on trackId. */
|
|
40879
|
+
function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
40880
|
+
const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
|
|
40881
|
+
if (eligible.length === 0) return null;
|
|
40882
|
+
return eligible.toSorted((a, b) => {
|
|
40883
|
+
const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
|
|
40884
|
+
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40885
|
+
})[0].trackId;
|
|
40886
|
+
}
|
|
40887
|
+
/**
|
|
40888
|
+
* The RGB source for a group crop is a full native frame. Thumbnails are
|
|
40889
|
+
* already single-subject crops and cannot substitute for union-bbox geometry.
|
|
40890
|
+
*
|
|
40891
|
+
* Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
|
|
40892
|
+
* if none, take the nearest keyFrame anyway — dropping the crop because the
|
|
40893
|
+
* best-shot was 3 s off the instant is how a recognised person left the
|
|
40894
|
+
* digest with no crop.
|
|
40895
|
+
*/
|
|
40896
|
+
function pickCleanPixelRow(rows, instantMs) {
|
|
40897
|
+
const ranked = (maxDt) => {
|
|
40898
|
+
const candidates = [];
|
|
40899
|
+
for (const row of rows) {
|
|
40900
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40901
|
+
if (rank < 0) continue;
|
|
40902
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40903
|
+
if (maxDt !== null && dt > maxDt) continue;
|
|
40904
|
+
candidates.push({
|
|
40905
|
+
key: row.key,
|
|
40906
|
+
dt,
|
|
40907
|
+
rank
|
|
40908
|
+
});
|
|
40909
|
+
}
|
|
40910
|
+
return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40911
|
+
};
|
|
40912
|
+
const near = ranked(GROUP_MEDIA_MATCH_MS);
|
|
40913
|
+
if (near !== void 0) return {
|
|
40914
|
+
key: near.key,
|
|
40915
|
+
usedFallback: false
|
|
40916
|
+
};
|
|
40917
|
+
const any = ranked(null);
|
|
40918
|
+
if (any === void 0) return null;
|
|
40919
|
+
return {
|
|
40920
|
+
key: any.key,
|
|
40921
|
+
usedFallback: true
|
|
40922
|
+
};
|
|
40923
|
+
}
|
|
40924
|
+
//#endregion
|
|
40925
|
+
//#region src/pipeline-analytics/group/group-live-replay.ts
|
|
40926
|
+
function sliceTrackAt(track, now) {
|
|
40927
|
+
const positions = track.positions.filter((p) => p.timestamp <= now);
|
|
40928
|
+
const last = positions[positions.length - 1];
|
|
40929
|
+
return {
|
|
40930
|
+
...track,
|
|
40931
|
+
lastSeen: last?.timestamp ?? track.firstSeen,
|
|
40932
|
+
positions
|
|
40933
|
+
};
|
|
40934
|
+
}
|
|
40935
|
+
function replayInto(tracks, observer) {
|
|
40936
|
+
const events = [];
|
|
40937
|
+
const byId = /* @__PURE__ */ new Map();
|
|
40938
|
+
for (const track of tracks) {
|
|
40939
|
+
byId.set(track.trackId, track);
|
|
40940
|
+
if (track.positions.length === 0) {
|
|
40941
|
+
events.push({
|
|
40942
|
+
t: track.lastSeen,
|
|
40943
|
+
trackId: track.trackId
|
|
40944
|
+
});
|
|
40945
|
+
continue;
|
|
40946
|
+
}
|
|
40947
|
+
for (const p of track.positions) events.push({
|
|
40948
|
+
t: p.timestamp,
|
|
40949
|
+
trackId: track.trackId
|
|
40950
|
+
});
|
|
40951
|
+
}
|
|
40952
|
+
events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
|
|
40953
|
+
let lastKey = "";
|
|
40954
|
+
for (const e of events) {
|
|
40955
|
+
const key = `${e.trackId}@${e.t}`;
|
|
40956
|
+
if (key === lastKey) continue;
|
|
40957
|
+
lastKey = key;
|
|
40958
|
+
const track = byId.get(e.trackId);
|
|
40959
|
+
if (!track) continue;
|
|
40960
|
+
observer.observe(sliceTrackAt(track, e.t), e.t);
|
|
40961
|
+
}
|
|
40962
|
+
}
|
|
40963
|
+
function replayLive(tracks, assigner = new GroupLiveAssigner()) {
|
|
40964
|
+
replayInto(tracks, assigner);
|
|
40965
|
+
return assigner;
|
|
40966
|
+
}
|
|
40967
|
+
//#endregion
|
|
40968
|
+
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
40969
|
+
var DEFAULT_FRAME = {
|
|
40970
|
+
width: 640,
|
|
40971
|
+
height: 360
|
|
40972
|
+
};
|
|
40973
|
+
var QUERY_LIMIT = 2e3;
|
|
40974
|
+
/** Crash recover: replay this camera only, on its next activity. */
|
|
40975
|
+
var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
|
|
40976
|
+
function toInstant(t) {
|
|
40977
|
+
return {
|
|
40978
|
+
trackId: t.trackId,
|
|
40979
|
+
deviceId: t.deviceId,
|
|
40980
|
+
className: t.className,
|
|
40981
|
+
firstSeen: t.firstSeen,
|
|
40982
|
+
lastSeen: t.lastSeen,
|
|
40983
|
+
positions: t.positions,
|
|
40984
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
40985
|
+
semantic: t.hasFace === true || t.hasPlate === true,
|
|
40986
|
+
confidence: t.importance
|
|
40987
|
+
};
|
|
40988
|
+
}
|
|
40989
|
+
function toSealer(track) {
|
|
40990
|
+
return {
|
|
40991
|
+
trackId: track.trackId,
|
|
40992
|
+
deviceId: track.deviceId,
|
|
40993
|
+
className: track.className,
|
|
40994
|
+
firstSeen: track.firstSeen,
|
|
40995
|
+
lastSeen: track.lastSeen,
|
|
40996
|
+
positions: track.positions,
|
|
40997
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40998
|
+
};
|
|
40999
|
+
}
|
|
41000
|
+
var GroupSealer = class {
|
|
41001
|
+
deps;
|
|
41002
|
+
constructor(deps) {
|
|
41003
|
+
this.deps = deps;
|
|
41004
|
+
}
|
|
41005
|
+
async flushIdle(buffer, now = this.deps.now()) {
|
|
41006
|
+
let written = 0;
|
|
41007
|
+
for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
|
|
41008
|
+
buffer.drop([group.id]);
|
|
41009
|
+
written += 1;
|
|
41010
|
+
}
|
|
41011
|
+
return written;
|
|
41012
|
+
}
|
|
41013
|
+
/**
|
|
41014
|
+
* First activity on camera D after a crash: replay 2 minutes of that camera
|
|
41015
|
+
* into the live buffer, persist whoever is already idle, keep the rest in RAM.
|
|
41016
|
+
*/
|
|
41017
|
+
async recoverCamera(deviceId, buffer) {
|
|
41018
|
+
if (buffer.isPrimed(deviceId)) return 0;
|
|
41019
|
+
const now = this.deps.now();
|
|
41020
|
+
try {
|
|
41021
|
+
replayInto(await this.deps.trackStore.queryHistorical({
|
|
41022
|
+
deviceId,
|
|
41023
|
+
since: now - GROUP_CRASH_LOOKBACK_MS,
|
|
41024
|
+
until: now,
|
|
41025
|
+
limit: QUERY_LIMIT,
|
|
41026
|
+
projection: "full"
|
|
41027
|
+
}), buffer);
|
|
41028
|
+
} catch (err) {
|
|
41029
|
+
this.deps.logger.warn("group sealer: lookback failed", {
|
|
41030
|
+
tags: { deviceId },
|
|
41031
|
+
meta: { error: String(err) }
|
|
41032
|
+
});
|
|
41033
|
+
} finally {
|
|
41034
|
+
buffer.markPrimed(deviceId);
|
|
41035
|
+
}
|
|
41036
|
+
return this.flushIdle(buffer, now);
|
|
41037
|
+
}
|
|
41038
|
+
/**
|
|
41039
|
+
* Seal ONE camera over a caller-owned window via live replay.
|
|
41040
|
+
*
|
|
41041
|
+
* This is how a digest recovers groups without a boot scan: the producer
|
|
41042
|
+
* already knows the window. Walking every camera for 48h at initialize()
|
|
41043
|
+
* is what wedged hub-main.
|
|
41044
|
+
*/
|
|
41045
|
+
async sealWindow(deviceId, since, until) {
|
|
41046
|
+
try {
|
|
41047
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
41048
|
+
deviceId,
|
|
41049
|
+
since,
|
|
41050
|
+
until,
|
|
41051
|
+
limit: QUERY_LIMIT,
|
|
41052
|
+
projection: "full"
|
|
41053
|
+
});
|
|
41054
|
+
if (closed.length === 0) return 0;
|
|
41055
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41056
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
41057
|
+
if (pending.length === 0) return 0;
|
|
41058
|
+
const assigner = replayLive(pending);
|
|
41059
|
+
const now = this.deps.now();
|
|
41060
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
41061
|
+
let written = 0;
|
|
41062
|
+
for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
|
|
41063
|
+
const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
41064
|
+
if (members.length === 0) continue;
|
|
41065
|
+
try {
|
|
41066
|
+
await this.persistPartition(members, group.openedAt);
|
|
41067
|
+
written += 1;
|
|
41068
|
+
} catch (err) {
|
|
41069
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41070
|
+
tags: { deviceId },
|
|
41071
|
+
meta: {
|
|
41072
|
+
members: members.map((m) => m.trackId),
|
|
41073
|
+
error: String(err)
|
|
41074
|
+
}
|
|
41075
|
+
});
|
|
41076
|
+
}
|
|
41077
|
+
}
|
|
41078
|
+
return written;
|
|
41079
|
+
} catch (err) {
|
|
41080
|
+
this.deps.logger.warn("group sealer: device failed", {
|
|
41081
|
+
tags: { deviceId },
|
|
41082
|
+
meta: { error: String(err) }
|
|
41083
|
+
});
|
|
41084
|
+
return 0;
|
|
41085
|
+
}
|
|
41086
|
+
}
|
|
41087
|
+
async persistLiveGroup(group, buffer) {
|
|
41088
|
+
const deviceId = group.deviceId;
|
|
41089
|
+
const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
|
|
41090
|
+
if (snaps.length === 0) return true;
|
|
41091
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41092
|
+
const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
|
|
41093
|
+
if (pending.length === 0) return true;
|
|
41094
|
+
try {
|
|
41095
|
+
await this.persistPartition(pending, group.openedAt);
|
|
41096
|
+
return true;
|
|
41097
|
+
} catch (err) {
|
|
41098
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41099
|
+
tags: { deviceId },
|
|
41100
|
+
meta: {
|
|
41101
|
+
members: pending.map((m) => m.trackId),
|
|
41102
|
+
error: String(err)
|
|
41103
|
+
}
|
|
41104
|
+
});
|
|
41105
|
+
return false;
|
|
41106
|
+
}
|
|
41107
|
+
}
|
|
41108
|
+
async persistPartition(members, openedAt) {
|
|
41109
|
+
const deviceId = members[0].deviceId;
|
|
41110
|
+
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
41111
|
+
const instantMembers = members.map(toInstant);
|
|
41112
|
+
const instant = chooseGroupInstant(instantMembers, frame);
|
|
41113
|
+
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
41114
|
+
const timestamp = instant?.timestamp ?? closedAt;
|
|
41115
|
+
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
41116
|
+
trackId: m.trackId,
|
|
41117
|
+
rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
|
|
41118
|
+
})));
|
|
41119
|
+
const ownsClean = (trackId) => {
|
|
41120
|
+
return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
|
|
41121
|
+
};
|
|
41122
|
+
const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
|
|
41123
|
+
let nativeKey = null;
|
|
41124
|
+
if (anchorTrackId !== null) {
|
|
41125
|
+
const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
|
|
41126
|
+
nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
|
|
41127
|
+
}
|
|
41128
|
+
const draft = await this.deps.groupStore.insert({
|
|
41129
|
+
deviceId,
|
|
41130
|
+
timestamp,
|
|
41131
|
+
openedAt,
|
|
41132
|
+
closedAt,
|
|
41133
|
+
members: members.map((m) => ({
|
|
41134
|
+
trackId: m.trackId,
|
|
41135
|
+
firstSeen: m.firstSeen
|
|
41136
|
+
})),
|
|
41137
|
+
anchorTrackId,
|
|
41138
|
+
ux: instant?.ux ?? 0,
|
|
41139
|
+
uy: instant?.uy ?? 0,
|
|
41140
|
+
uw: instant?.uw ?? 0,
|
|
41141
|
+
uh: instant?.uh ?? 0,
|
|
41142
|
+
frameWidth: frame.width,
|
|
41143
|
+
frameHeight: frame.height,
|
|
41144
|
+
mediaKey: null,
|
|
41145
|
+
nativeKey
|
|
41146
|
+
});
|
|
41147
|
+
if (members.length < 2 || instant === null) return;
|
|
41148
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
|
|
41149
|
+
if (source === null) {
|
|
41150
|
+
this.deps.logger.info("group sealer: no clean pixels for crop", {
|
|
41151
|
+
tags: { deviceId },
|
|
41152
|
+
meta: {
|
|
41153
|
+
groupId: draft.id,
|
|
41154
|
+
members: members.map((m) => m.trackId)
|
|
41155
|
+
}
|
|
41156
|
+
});
|
|
41157
|
+
return;
|
|
41158
|
+
}
|
|
41159
|
+
try {
|
|
41160
|
+
const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
41161
|
+
if (raw.info.channels !== 3) return;
|
|
41162
|
+
const union = {
|
|
41163
|
+
x: instant.ux * raw.info.width,
|
|
41164
|
+
y: instant.uy * raw.info.height,
|
|
41165
|
+
w: instant.uw * raw.info.width,
|
|
41166
|
+
h: instant.uh * raw.info.height
|
|
41167
|
+
};
|
|
41168
|
+
const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
|
|
41169
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
41170
|
+
deviceId,
|
|
41171
|
+
ownerKind: "group",
|
|
41172
|
+
ownerId: draft.id,
|
|
41173
|
+
kind: "thumbnail",
|
|
41174
|
+
timestamp,
|
|
41175
|
+
data: crop
|
|
41176
|
+
});
|
|
41177
|
+
await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
|
|
41178
|
+
} catch (err) {
|
|
41179
|
+
this.deps.logger.warn("group sealer: crop failed", {
|
|
41180
|
+
tags: { deviceId },
|
|
41181
|
+
meta: {
|
|
41182
|
+
groupId: draft.id,
|
|
41183
|
+
error: String(err)
|
|
41184
|
+
}
|
|
41185
|
+
});
|
|
41186
|
+
}
|
|
41187
|
+
}
|
|
41188
|
+
async pickCleanPixels(mediaInfos, instantMs, deviceId) {
|
|
41189
|
+
const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
|
|
41190
|
+
if (chosen === null) return null;
|
|
41191
|
+
if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
|
|
41192
|
+
tags: { deviceId },
|
|
41193
|
+
meta: {
|
|
41194
|
+
instantMs,
|
|
41195
|
+
mediaKey: chosen.key
|
|
41196
|
+
}
|
|
41197
|
+
});
|
|
41198
|
+
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
41199
|
+
if (file === null) return null;
|
|
41200
|
+
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
41201
|
+
}
|
|
41202
|
+
};
|
|
41203
|
+
//#endregion
|
|
41204
|
+
//#region src/pipeline-analytics/group/group-store.ts
|
|
41205
|
+
/**
|
|
41206
|
+
* The group ENTITY store — a co-moving partition of one session, with one
|
|
41207
|
+
* picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
|
|
41208
|
+
*
|
|
41209
|
+
* Two tables, two questions:
|
|
41210
|
+
*
|
|
41211
|
+
* - `pipeline-analytics:track-groups` is the group row. It has N members and
|
|
41212
|
+
* no `trackId` column; it is owned by its members (owner-indirection).
|
|
41213
|
+
* - `pipeline-analytics:track-group-members` is one row per member. The column
|
|
41214
|
+
* is named exactly `trackId` so the classification derives `track-owned`.
|
|
41215
|
+
*
|
|
41216
|
+
* `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
|
|
41217
|
+
* they are nulled when the anchor member is deleted alone. Everything else is
|
|
41218
|
+
* written once. Re-anchoring is deliberately not done.
|
|
41219
|
+
*
|
|
41220
|
+
* No age clock. A group dies when its last member does; a group with one live
|
|
41221
|
+
* member does not, and it never keeps a dead link.
|
|
41222
|
+
*/
|
|
41223
|
+
/**
|
|
41224
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
41225
|
+
* write="one row per co-moving group, sealed at idle — never a live row.
|
|
41226
|
+
* Membership is the RAM members at persist, not a session partition. mediaKey is
|
|
41227
|
+
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
41228
|
+
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
41229
|
+
* nativeKey are the only fields rewritten after birth."
|
|
41230
|
+
* retention="NO age clock (D61). Rows leave with their last member through
|
|
41231
|
+
* TrackGroupStore.deleteByTracks: memberships first; zero survivors
|
|
41232
|
+
* deletes the group's media then the row; survivors plus a lost anchor
|
|
41233
|
+
* null the pointer. The orphan audit verifies the same via a second
|
|
41234
|
+
* resolver (groupId → any member still present)."
|
|
41235
|
+
*/
|
|
41236
|
+
var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
|
|
41237
|
+
/**
|
|
41238
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
41239
|
+
* write="one row per member at group birth — id is '<groupId>:<trackId>'.
|
|
41240
|
+
* timestamp is the member's firstSeen so the orphan audit has a clock
|
|
41241
|
+
* column. Never rewritten."
|
|
41242
|
+
* retention="TRACK-OWNED and clockless (D61): the column is named exactly
|
|
41243
|
+
* trackId, so classification derives ownership and the cascade deletes
|
|
41244
|
+
* the row with its track. A group with surviving members stays."
|
|
41245
|
+
*/
|
|
41246
|
+
var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
|
|
41247
|
+
var TRACK_GROUPS_COLUMNS = [
|
|
41248
|
+
{
|
|
41249
|
+
name: "id",
|
|
41250
|
+
type: "TEXT",
|
|
41251
|
+
primaryKey: true,
|
|
41252
|
+
notNull: true
|
|
41253
|
+
},
|
|
41254
|
+
{
|
|
41255
|
+
name: "deviceId",
|
|
41256
|
+
type: "INTEGER",
|
|
41257
|
+
notNull: true
|
|
41258
|
+
},
|
|
41259
|
+
{
|
|
41260
|
+
name: "timestamp",
|
|
41261
|
+
type: "INTEGER",
|
|
41262
|
+
notNull: true
|
|
41263
|
+
},
|
|
41264
|
+
{
|
|
41265
|
+
name: "openedAt",
|
|
41266
|
+
type: "INTEGER",
|
|
41267
|
+
notNull: true
|
|
41268
|
+
},
|
|
41269
|
+
{
|
|
41270
|
+
name: "closedAt",
|
|
41271
|
+
type: "INTEGER",
|
|
41272
|
+
notNull: true
|
|
41273
|
+
},
|
|
41274
|
+
{
|
|
41275
|
+
name: "memberCount",
|
|
41276
|
+
type: "INTEGER",
|
|
41277
|
+
notNull: true
|
|
41278
|
+
},
|
|
41279
|
+
{
|
|
41280
|
+
name: "anchorTrackId",
|
|
41281
|
+
type: "TEXT"
|
|
41282
|
+
},
|
|
41283
|
+
{
|
|
41284
|
+
name: "ux",
|
|
41285
|
+
type: "REAL",
|
|
41286
|
+
notNull: true
|
|
41287
|
+
},
|
|
41288
|
+
{
|
|
41289
|
+
name: "uy",
|
|
41290
|
+
type: "REAL",
|
|
41291
|
+
notNull: true
|
|
41292
|
+
},
|
|
41293
|
+
{
|
|
41294
|
+
name: "uw",
|
|
41295
|
+
type: "REAL",
|
|
41296
|
+
notNull: true
|
|
41297
|
+
},
|
|
41298
|
+
{
|
|
41299
|
+
name: "uh",
|
|
41300
|
+
type: "REAL",
|
|
41301
|
+
notNull: true
|
|
41302
|
+
},
|
|
41303
|
+
{
|
|
41304
|
+
name: "frameWidth",
|
|
41305
|
+
type: "INTEGER",
|
|
41306
|
+
notNull: true
|
|
41307
|
+
},
|
|
41308
|
+
{
|
|
41309
|
+
name: "frameHeight",
|
|
41310
|
+
type: "INTEGER",
|
|
41311
|
+
notNull: true
|
|
41312
|
+
},
|
|
41313
|
+
{
|
|
41314
|
+
name: "mediaKey",
|
|
41315
|
+
type: "TEXT"
|
|
41316
|
+
},
|
|
41317
|
+
{
|
|
41318
|
+
name: "nativeKey",
|
|
41319
|
+
type: "TEXT"
|
|
41320
|
+
},
|
|
41321
|
+
{
|
|
41322
|
+
name: "sealed",
|
|
41323
|
+
type: "BOOLEAN",
|
|
41324
|
+
notNull: true
|
|
41325
|
+
}
|
|
41326
|
+
];
|
|
41327
|
+
var TRACK_GROUP_MEMBERS_COLUMNS = [
|
|
41328
|
+
{
|
|
41329
|
+
name: "id",
|
|
41330
|
+
type: "TEXT",
|
|
41331
|
+
primaryKey: true,
|
|
41332
|
+
notNull: true
|
|
41333
|
+
},
|
|
41334
|
+
{
|
|
41335
|
+
name: "groupId",
|
|
41336
|
+
type: "TEXT",
|
|
41337
|
+
notNull: true
|
|
41338
|
+
},
|
|
41339
|
+
{
|
|
41340
|
+
name: "trackId",
|
|
41341
|
+
type: "TEXT",
|
|
41342
|
+
notNull: true
|
|
41343
|
+
},
|
|
41344
|
+
{
|
|
41345
|
+
name: "deviceId",
|
|
41346
|
+
type: "INTEGER",
|
|
41347
|
+
notNull: true
|
|
41348
|
+
},
|
|
41349
|
+
{
|
|
41350
|
+
name: "timestamp",
|
|
41351
|
+
type: "INTEGER",
|
|
41352
|
+
notNull: true
|
|
41353
|
+
}
|
|
41354
|
+
];
|
|
41355
|
+
var TRACK_GROUPS_INDEXES = [{
|
|
41356
|
+
name: "idx_track_groups_device_closed",
|
|
41357
|
+
columns: ["deviceId", "closedAt"]
|
|
41358
|
+
}, {
|
|
41359
|
+
name: "idx_track_groups_device_opened",
|
|
41360
|
+
columns: ["deviceId", "openedAt"]
|
|
41361
|
+
}];
|
|
41362
|
+
var TRACK_GROUP_MEMBERS_INDEXES = [{
|
|
41363
|
+
name: "idx_track_group_members_track",
|
|
41364
|
+
columns: ["trackId"]
|
|
41365
|
+
}, {
|
|
41366
|
+
name: "idx_track_group_members_group",
|
|
41367
|
+
columns: ["groupId"]
|
|
41368
|
+
}];
|
|
41369
|
+
function newGroupId() {
|
|
41370
|
+
return `grp-${(0, node_crypto.randomUUID)()}`;
|
|
41371
|
+
}
|
|
41372
|
+
function memberRowId(groupId, trackId) {
|
|
41373
|
+
return `${groupId}:${trackId}`;
|
|
41374
|
+
}
|
|
41375
|
+
var TrackGroupStore = class {
|
|
41376
|
+
store;
|
|
41377
|
+
logger;
|
|
41378
|
+
media;
|
|
41379
|
+
constructor(deps) {
|
|
41380
|
+
this.store = deps.store;
|
|
41381
|
+
this.logger = deps.logger;
|
|
41382
|
+
this.media = deps.media ?? null;
|
|
41383
|
+
}
|
|
41384
|
+
static async declare(store) {
|
|
41385
|
+
await Promise.all([store.declareCollection.mutate({
|
|
41386
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41387
|
+
columns: [...TRACK_GROUPS_COLUMNS],
|
|
41388
|
+
indexes: [...TRACK_GROUPS_INDEXES]
|
|
41389
|
+
}), store.declareCollection.mutate({
|
|
41390
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41391
|
+
columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
|
|
41392
|
+
indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
|
|
41393
|
+
})]);
|
|
41394
|
+
}
|
|
41395
|
+
async insert(input) {
|
|
41396
|
+
const id = newGroupId();
|
|
41397
|
+
const group = {
|
|
41398
|
+
id,
|
|
41399
|
+
deviceId: input.deviceId,
|
|
41400
|
+
timestamp: input.timestamp,
|
|
41401
|
+
openedAt: input.openedAt,
|
|
41402
|
+
closedAt: input.closedAt,
|
|
41403
|
+
memberCount: input.members.length,
|
|
41404
|
+
anchorTrackId: input.anchorTrackId,
|
|
41405
|
+
ux: input.ux,
|
|
41406
|
+
uy: input.uy,
|
|
41407
|
+
uw: input.uw,
|
|
41408
|
+
uh: input.uh,
|
|
41409
|
+
frameWidth: input.frameWidth,
|
|
41410
|
+
frameHeight: input.frameHeight,
|
|
41411
|
+
mediaKey: input.mediaKey,
|
|
41412
|
+
nativeKey: input.nativeKey,
|
|
41413
|
+
sealed: true
|
|
41414
|
+
};
|
|
41415
|
+
await this.store.set.mutate({
|
|
41416
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41417
|
+
key: id,
|
|
41418
|
+
value: this.toRow(group)
|
|
41419
|
+
});
|
|
41420
|
+
for (const member of input.members) await this.store.set.mutate({
|
|
41421
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41422
|
+
key: memberRowId(id, member.trackId),
|
|
41423
|
+
value: {
|
|
41424
|
+
groupId: id,
|
|
41425
|
+
trackId: member.trackId,
|
|
41426
|
+
deviceId: input.deviceId,
|
|
41427
|
+
timestamp: member.firstSeen
|
|
41428
|
+
}
|
|
41429
|
+
});
|
|
41430
|
+
return group;
|
|
41431
|
+
}
|
|
41432
|
+
/** The crop is written after birth; this is the only post-birth write besides the pointer null. */
|
|
41433
|
+
async setMediaKey(groupId, mediaKey) {
|
|
41434
|
+
const current = await this.get(groupId);
|
|
41435
|
+
if (current === null) return;
|
|
41436
|
+
await this.store.set.mutate({
|
|
41437
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41438
|
+
key: groupId,
|
|
41439
|
+
value: this.toRow({
|
|
41440
|
+
...current,
|
|
41441
|
+
mediaKey
|
|
41442
|
+
})
|
|
41443
|
+
});
|
|
41444
|
+
}
|
|
41445
|
+
async get(groupId) {
|
|
41446
|
+
const raw = await this.store.get.query({
|
|
41447
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41448
|
+
key: groupId
|
|
41449
|
+
});
|
|
41450
|
+
if (!raw) return null;
|
|
41451
|
+
return this.normalizeGroup({
|
|
41452
|
+
id: groupId,
|
|
41453
|
+
data: raw
|
|
41454
|
+
});
|
|
41455
|
+
}
|
|
41456
|
+
async listMembers(groupId) {
|
|
41457
|
+
return (await this.store.query.query({
|
|
41458
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41459
|
+
filter: { where: { groupId } }
|
|
41460
|
+
})).map((r) => this.normalizeMember({
|
|
41461
|
+
id: r.id,
|
|
41462
|
+
data: r.data
|
|
41463
|
+
}));
|
|
41464
|
+
}
|
|
41465
|
+
async membersOfTracks(trackIds) {
|
|
41466
|
+
const out = [];
|
|
41467
|
+
for (const trackId of trackIds) {
|
|
41468
|
+
const rows = await this.store.query.query({
|
|
41469
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41470
|
+
filter: { where: { trackId } }
|
|
41471
|
+
});
|
|
41472
|
+
for (const r of rows) out.push(this.normalizeMember({
|
|
41473
|
+
id: r.id,
|
|
41474
|
+
data: r.data
|
|
41475
|
+
}));
|
|
41476
|
+
}
|
|
41477
|
+
return out;
|
|
41478
|
+
}
|
|
41479
|
+
/**
|
|
41480
|
+
* Newest-closed page for the Library Groups tab.
|
|
41481
|
+
*
|
|
41482
|
+
* Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
|
|
41483
|
+
* same as {@link listOverlapping}. Pagination is applied AFTER the window
|
|
41484
|
+
* so a day cursor is honest rather than a raw-row offset that skips live
|
|
41485
|
+
* groups on a busy camera.
|
|
41486
|
+
*/
|
|
41487
|
+
async listRecent(input) {
|
|
41488
|
+
const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
|
|
41489
|
+
const offset = Math.max(input.offset ?? 0, 0);
|
|
41490
|
+
const slice = (await this.store.query.query({
|
|
41491
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41492
|
+
filter: {
|
|
41493
|
+
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
41494
|
+
orderBy: {
|
|
41495
|
+
field: "closedAt",
|
|
41496
|
+
direction: "desc"
|
|
41497
|
+
},
|
|
41498
|
+
limit: 2e3
|
|
41499
|
+
}
|
|
41500
|
+
})).map((r) => this.normalizeGroup({
|
|
41501
|
+
id: r.id,
|
|
41502
|
+
data: r.data
|
|
41503
|
+
})).filter((g) => {
|
|
41504
|
+
if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
|
|
41505
|
+
if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
|
|
41506
|
+
return true;
|
|
41507
|
+
}).slice(offset, offset + limit + 1);
|
|
41508
|
+
return {
|
|
41509
|
+
groups: slice.slice(0, limit),
|
|
41510
|
+
hasMore: slice.length > limit
|
|
41511
|
+
};
|
|
41512
|
+
}
|
|
41513
|
+
/** Groups whose life overlaps `[since, until]` on one camera. */
|
|
41514
|
+
async listOverlapping(input) {
|
|
41515
|
+
return (await this.store.query.query({
|
|
41516
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41517
|
+
filter: {
|
|
41518
|
+
where: { deviceId: input.deviceId },
|
|
41519
|
+
orderBy: {
|
|
41520
|
+
field: "closedAt",
|
|
41521
|
+
direction: "desc"
|
|
41522
|
+
},
|
|
41523
|
+
limit: input.limit ?? 2e3
|
|
41524
|
+
}
|
|
41525
|
+
})).map((r) => this.normalizeGroup({
|
|
41526
|
+
id: r.id,
|
|
41527
|
+
data: r.data
|
|
41528
|
+
})).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
|
|
41529
|
+
}
|
|
41530
|
+
async existingGroupIds(ids) {
|
|
41531
|
+
const live = /* @__PURE__ */ new Set();
|
|
41532
|
+
for (const id of ids) if (await this.store.get.query({
|
|
41533
|
+
collection: "pipeline-analytics:track-groups",
|
|
41534
|
+
key: id
|
|
41535
|
+
})) live.add(id);
|
|
41536
|
+
return live;
|
|
41537
|
+
}
|
|
41538
|
+
/** Group ids that still have at least one membership row. */
|
|
41539
|
+
async groupsWithMembers(ids) {
|
|
41540
|
+
const live = /* @__PURE__ */ new Set();
|
|
41541
|
+
for (const groupId of ids) if ((await this.store.query.query({
|
|
41542
|
+
collection: "pipeline-analytics:track-group-members",
|
|
41543
|
+
filter: {
|
|
41544
|
+
where: { groupId },
|
|
41545
|
+
limit: 1
|
|
41546
|
+
}
|
|
41547
|
+
})).length > 0) live.add(groupId);
|
|
41548
|
+
return live;
|
|
41549
|
+
}
|
|
41550
|
+
/**
|
|
41551
|
+
* Cascade leaf: delete memberships for the batch; a group with zero survivors
|
|
41552
|
+
* dies (media then row); a group that lost only its anchor nulls the pointer.
|
|
41553
|
+
*/
|
|
41554
|
+
async deleteByTracks(trackIds) {
|
|
41555
|
+
const batch = new Set(trackIds);
|
|
41556
|
+
let memberRows = 0;
|
|
41557
|
+
let groupRows = 0;
|
|
41558
|
+
let mediaRows = 0;
|
|
41559
|
+
let mediaBytes = 0;
|
|
41560
|
+
const affected = /* @__PURE__ */ new Set();
|
|
41561
|
+
try {
|
|
41562
|
+
const memberships = await this.membersOfTracks(trackIds);
|
|
41563
|
+
for (const member of memberships) try {
|
|
41564
|
+
await this.store.delete.mutate({
|
|
41565
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41566
|
+
key: member.id
|
|
41567
|
+
});
|
|
41568
|
+
memberRows += 1;
|
|
41569
|
+
affected.add(member.groupId);
|
|
41570
|
+
} catch (err) {
|
|
41571
|
+
this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
|
|
41572
|
+
memberId: member.id,
|
|
41573
|
+
error: String(err)
|
|
41574
|
+
} });
|
|
41575
|
+
}
|
|
41576
|
+
for (const groupId of affected) try {
|
|
41577
|
+
if ((await this.listMembers(groupId)).length === 0) {
|
|
41578
|
+
if (this.media) {
|
|
41579
|
+
const gone = await this.media.deleteByGroups([groupId]);
|
|
41580
|
+
mediaRows += gone.rows;
|
|
41581
|
+
mediaBytes += gone.bytes;
|
|
41582
|
+
}
|
|
41583
|
+
await this.store.delete.mutate({
|
|
41584
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41585
|
+
key: groupId
|
|
41586
|
+
});
|
|
41587
|
+
groupRows += 1;
|
|
41588
|
+
continue;
|
|
41589
|
+
}
|
|
41590
|
+
const group = await this.get(groupId);
|
|
41591
|
+
if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
|
|
41592
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41593
|
+
key: groupId,
|
|
41594
|
+
value: this.toRow({
|
|
41595
|
+
...group,
|
|
41596
|
+
anchorTrackId: null,
|
|
41597
|
+
nativeKey: null
|
|
41598
|
+
})
|
|
41599
|
+
});
|
|
41600
|
+
} catch (err) {
|
|
41601
|
+
this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
|
|
41602
|
+
groupId,
|
|
41603
|
+
error: String(err)
|
|
41604
|
+
} });
|
|
41605
|
+
}
|
|
41606
|
+
} catch (err) {
|
|
41607
|
+
this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
|
|
41608
|
+
}
|
|
41609
|
+
return [{
|
|
41610
|
+
family: "groups",
|
|
41611
|
+
rows: memberRows + groupRows,
|
|
41612
|
+
bytes: 0
|
|
41613
|
+
}, {
|
|
41614
|
+
family: "media",
|
|
41615
|
+
rows: mediaRows,
|
|
41616
|
+
bytes: mediaBytes
|
|
41617
|
+
}];
|
|
41618
|
+
}
|
|
41619
|
+
toRow(group) {
|
|
41620
|
+
return {
|
|
41621
|
+
deviceId: group.deviceId,
|
|
41622
|
+
timestamp: group.timestamp,
|
|
41623
|
+
openedAt: group.openedAt,
|
|
41624
|
+
closedAt: group.closedAt,
|
|
41625
|
+
memberCount: group.memberCount,
|
|
41626
|
+
anchorTrackId: group.anchorTrackId,
|
|
41627
|
+
ux: group.ux,
|
|
41628
|
+
uy: group.uy,
|
|
41629
|
+
uw: group.uw,
|
|
41630
|
+
uh: group.uh,
|
|
41631
|
+
frameWidth: group.frameWidth,
|
|
41632
|
+
frameHeight: group.frameHeight,
|
|
41633
|
+
mediaKey: group.mediaKey,
|
|
41634
|
+
nativeKey: group.nativeKey,
|
|
41635
|
+
sealed: group.sealed
|
|
41636
|
+
};
|
|
41637
|
+
}
|
|
41638
|
+
normalizeGroup(r) {
|
|
41639
|
+
const data = r.data;
|
|
41640
|
+
return {
|
|
41641
|
+
id: r.id,
|
|
41642
|
+
deviceId: Number(data["deviceId"]),
|
|
41643
|
+
timestamp: Number(data["timestamp"]),
|
|
41644
|
+
openedAt: Number(data["openedAt"]),
|
|
41645
|
+
closedAt: Number(data["closedAt"]),
|
|
41646
|
+
memberCount: Number(data["memberCount"]),
|
|
41647
|
+
anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
|
|
41648
|
+
ux: Number(data["ux"]),
|
|
41649
|
+
uy: Number(data["uy"]),
|
|
41650
|
+
uw: Number(data["uw"]),
|
|
41651
|
+
uh: Number(data["uh"]),
|
|
41652
|
+
frameWidth: Number(data["frameWidth"]),
|
|
41653
|
+
frameHeight: Number(data["frameHeight"]),
|
|
41654
|
+
mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
|
|
41655
|
+
nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
|
|
41656
|
+
sealed: Boolean(data["sealed"])
|
|
41657
|
+
};
|
|
41658
|
+
}
|
|
41659
|
+
normalizeMember(r) {
|
|
41660
|
+
const data = r.data;
|
|
41661
|
+
return {
|
|
41662
|
+
id: r.id,
|
|
41663
|
+
groupId: String(data["groupId"]),
|
|
41664
|
+
trackId: String(data["trackId"]),
|
|
41665
|
+
deviceId: Number(data["deviceId"]),
|
|
41666
|
+
timestamp: Number(data["timestamp"])
|
|
41667
|
+
};
|
|
41668
|
+
}
|
|
41669
|
+
};
|
|
40134
41670
|
//#endregion
|
|
40135
41671
|
//#region src/pipeline-analytics/pipeline/dropout-skip.ts
|
|
40136
41672
|
function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
|
|
@@ -46748,6 +48284,15 @@ function isAfterCursor(row, cursor) {
|
|
|
46748
48284
|
if (row.lastSeen < cursor.lastSeen) return true;
|
|
46749
48285
|
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
46750
48286
|
}
|
|
48287
|
+
function netDisplacementPx(positions) {
|
|
48288
|
+
const first = positions[0];
|
|
48289
|
+
const last = positions[positions.length - 1];
|
|
48290
|
+
if (!first || !last) return 0;
|
|
48291
|
+
return Math.hypot(last.x - first.x, last.y - first.y);
|
|
48292
|
+
}
|
|
48293
|
+
function isShortMotionlessTrack(lifeMs, movedPx) {
|
|
48294
|
+
return lifeMs <= 25e3 && movedPx <= 8;
|
|
48295
|
+
}
|
|
46751
48296
|
//#endregion
|
|
46752
48297
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
46753
48298
|
/**
|
|
@@ -47330,6 +48875,7 @@ var TrackStore = class {
|
|
|
47330
48875
|
for (const [trackId, t] of this.active) {
|
|
47331
48876
|
if (nowMs - t.lastSeen < this.config.ttlMs) continue;
|
|
47332
48877
|
t.active = false;
|
|
48878
|
+
if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
|
|
47333
48879
|
const record = cloneTrack(t);
|
|
47334
48880
|
try {
|
|
47335
48881
|
await this.persistCompleted(record);
|
|
@@ -48079,10 +49625,21 @@ var TrackStore = class {
|
|
|
48079
49625
|
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
48080
49626
|
*/
|
|
48081
49627
|
async listDeviceIds() {
|
|
49628
|
+
return this.collectDeviceIds(0);
|
|
49629
|
+
}
|
|
49630
|
+
/**
|
|
49631
|
+
* Distinct deviceIds with a persisted track whose `lastSeen` is at least
|
|
49632
|
+
* `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
|
|
49633
|
+
* `sealRecent` wedged hub-main every TTL sweep.
|
|
49634
|
+
*/
|
|
49635
|
+
async listDeviceIdsSince(since) {
|
|
49636
|
+
return this.collectDeviceIds(Math.max(0, Math.floor(since)));
|
|
49637
|
+
}
|
|
49638
|
+
async collectDeviceIds(fromLastSeen) {
|
|
48082
49639
|
const seenDevices = /* @__PURE__ */ new Set();
|
|
48083
49640
|
const seenIds = /* @__PURE__ */ new Set();
|
|
48084
49641
|
const PAGE = 500;
|
|
48085
|
-
let cursor =
|
|
49642
|
+
let cursor = fromLastSeen;
|
|
48086
49643
|
try {
|
|
48087
49644
|
for (;;) {
|
|
48088
49645
|
const rows = await this.store.query.query({
|
|
@@ -48878,12 +50435,24 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48878
50435
|
collection: PLATES_COLLECTION,
|
|
48879
50436
|
classification: derived(PLATE_COLUMNS)
|
|
48880
50437
|
},
|
|
50438
|
+
{
|
|
50439
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
50440
|
+
classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
|
|
50441
|
+
},
|
|
48881
50442
|
{
|
|
48882
50443
|
collection: MEDIA_COLLECTION,
|
|
48883
50444
|
classification: {
|
|
48884
50445
|
kind: "owner-indirection",
|
|
48885
50446
|
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."
|
|
50447
|
+
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."
|
|
50448
|
+
}
|
|
50449
|
+
},
|
|
50450
|
+
{
|
|
50451
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
50452
|
+
classification: {
|
|
50453
|
+
kind: "owner-indirection",
|
|
50454
|
+
derivation: "explicit",
|
|
50455
|
+
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
50456
|
}
|
|
48888
50457
|
},
|
|
48889
50458
|
{
|
|
@@ -48995,7 +50564,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48995
50564
|
collection: SCENE_LATCH_COLLECTION,
|
|
48996
50565
|
classification: {
|
|
48997
50566
|
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
|
|
50567
|
+
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
50568
|
}
|
|
49000
50569
|
},
|
|
49001
50570
|
{
|
|
@@ -49083,6 +50652,10 @@ function resolveMediaOwner(row) {
|
|
|
49083
50652
|
kind: "unresolvable",
|
|
49084
50653
|
why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
|
|
49085
50654
|
};
|
|
50655
|
+
case "group": return {
|
|
50656
|
+
kind: "group",
|
|
50657
|
+
groupId: ownerId
|
|
50658
|
+
};
|
|
49086
50659
|
default: return {
|
|
49087
50660
|
kind: "unresolvable",
|
|
49088
50661
|
why: `unknown ownerKind '${kindRaw}'`
|
|
@@ -49094,6 +50667,13 @@ function resolveMediaOwner(row) {
|
|
|
49094
50667
|
* is derived the same way the classification is — an enrolled row is out of
|
|
49095
50668
|
* reach regardless of what happened to the track that produced it.
|
|
49096
50669
|
*/
|
|
50670
|
+
/** A group row lives iff any membership row still names it. */
|
|
50671
|
+
function resolveGroupOwner(row) {
|
|
50672
|
+
return {
|
|
50673
|
+
kind: "group-members",
|
|
50674
|
+
groupId: row.key
|
|
50675
|
+
};
|
|
50676
|
+
}
|
|
49097
50677
|
function resolveDeclaredTrackOwner(row, enrolmentExempt) {
|
|
49098
50678
|
if (enrolmentExempt && row.data["assigned"] === true) return {
|
|
49099
50679
|
kind: "exempt",
|
|
@@ -49113,15 +50693,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
|
|
|
49113
50693
|
const scopes = [];
|
|
49114
50694
|
for (const entry of collections) {
|
|
49115
50695
|
if (entry.classification.kind === "owner-indirection") {
|
|
49116
|
-
if (entry.collection
|
|
49117
|
-
|
|
49118
|
-
|
|
49119
|
-
|
|
49120
|
-
|
|
49121
|
-
|
|
49122
|
-
|
|
49123
|
-
|
|
49124
|
-
|
|
50696
|
+
if (entry.collection === "pipeline-analytics:media") {
|
|
50697
|
+
scopes.push({
|
|
50698
|
+
id: entry.collection,
|
|
50699
|
+
collection: entry.collection,
|
|
50700
|
+
resolve: resolveMediaOwner,
|
|
50701
|
+
timestampField: "timestamp",
|
|
50702
|
+
sizeField: "sizeBytes",
|
|
50703
|
+
deviceIdField: "deviceId"
|
|
50704
|
+
});
|
|
50705
|
+
continue;
|
|
50706
|
+
}
|
|
50707
|
+
if (entry.collection === "pipeline-analytics:track-groups") {
|
|
50708
|
+
scopes.push({
|
|
50709
|
+
id: entry.collection,
|
|
50710
|
+
collection: entry.collection,
|
|
50711
|
+
resolve: resolveGroupOwner,
|
|
50712
|
+
timestampField: "timestamp",
|
|
50713
|
+
sizeField: null,
|
|
50714
|
+
deviceIdField: "deviceId"
|
|
50715
|
+
});
|
|
50716
|
+
continue;
|
|
50717
|
+
}
|
|
49125
50718
|
continue;
|
|
49126
50719
|
}
|
|
49127
50720
|
if (entry.classification.kind !== "track-owned") continue;
|
|
@@ -49153,11 +50746,17 @@ var DEFAULT_ORPHAN_LIMITS = {
|
|
|
49153
50746
|
function questionsFor(refs) {
|
|
49154
50747
|
const trackIds = /* @__PURE__ */ new Set();
|
|
49155
50748
|
const eventIds = /* @__PURE__ */ new Set();
|
|
50749
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
50750
|
+
const groupMemberIds = /* @__PURE__ */ new Set();
|
|
49156
50751
|
for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
|
|
49157
50752
|
else if (ref.kind === "event") eventIds.add(ref.eventId);
|
|
50753
|
+
else if (ref.kind === "group") groupIds.add(ref.groupId);
|
|
50754
|
+
else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
|
|
49158
50755
|
return {
|
|
49159
50756
|
trackIds: [...trackIds],
|
|
49160
|
-
eventIds: [...eventIds]
|
|
50757
|
+
eventIds: [...eventIds],
|
|
50758
|
+
groupIds: [...groupIds],
|
|
50759
|
+
groupMemberIds: [...groupMemberIds]
|
|
49161
50760
|
};
|
|
49162
50761
|
}
|
|
49163
50762
|
/**
|
|
@@ -49189,12 +50788,16 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49189
50788
|
break;
|
|
49190
50789
|
}
|
|
49191
50790
|
const refs = rows.map((row) => scope.resolve(row));
|
|
49192
|
-
const { trackIds, eventIds } = questionsFor(refs);
|
|
50791
|
+
const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
|
|
49193
50792
|
let liveTracks;
|
|
49194
50793
|
let liveEvents;
|
|
50794
|
+
let liveGroups;
|
|
50795
|
+
let liveGroupsWithMembers;
|
|
49195
50796
|
try {
|
|
49196
50797
|
liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
|
|
49197
50798
|
liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
|
|
50799
|
+
liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
|
|
50800
|
+
liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
|
|
49198
50801
|
} catch (err) {
|
|
49199
50802
|
deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
|
|
49200
50803
|
collection: scope.collection,
|
|
@@ -49218,7 +50821,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49218
50821
|
tally.unresolvable += 1;
|
|
49219
50822
|
continue;
|
|
49220
50823
|
}
|
|
49221
|
-
if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
|
|
50824
|
+
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
50825
|
const ts = num(row.data, scope.timestampField);
|
|
49223
50826
|
if (ts !== null && ts > graceFloor) {
|
|
49224
50827
|
tally.withinGrace += 1;
|
|
@@ -51310,6 +52913,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
|
51310
52913
|
};
|
|
51311
52914
|
}
|
|
51312
52915
|
//#endregion
|
|
52916
|
+
//#region src/pipeline-analytics/pipeline/usable-bbox.ts
|
|
52917
|
+
/** A box that can legally drive a crop. The leaving-event fallback
|
|
52918
|
+
* `{0,0,0,0}` must never become a face/plate JPEG. */
|
|
52919
|
+
function isUsableBbox(bbox) {
|
|
52920
|
+
return bbox.w > 0 && bbox.h > 0;
|
|
52921
|
+
}
|
|
52922
|
+
//#endregion
|
|
51313
52923
|
//#region src/pipeline-analytics/services/event-media-dispatcher.ts
|
|
51314
52924
|
/**
|
|
51315
52925
|
* Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
|
|
@@ -51888,26 +53498,29 @@ var EventMediaDispatcher = class {
|
|
|
51888
53498
|
}
|
|
51889
53499
|
}
|
|
51890
53500
|
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: {
|
|
53501
|
+
if (ev.childCrops) for (const child of ev.childCrops) {
|
|
53502
|
+
if (!isUsableBbox(child.bbox)) continue;
|
|
53503
|
+
try {
|
|
53504
|
+
const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
|
|
53505
|
+
if (!childCropData) continue;
|
|
53506
|
+
await this.deps.mediaStore.put({
|
|
51906
53507
|
deviceId,
|
|
51907
|
-
|
|
51908
|
-
|
|
51909
|
-
|
|
51910
|
-
|
|
53508
|
+
ownerKind: "event",
|
|
53509
|
+
ownerId: ev.eventId,
|
|
53510
|
+
kind: child.kind,
|
|
53511
|
+
timestamp: ev.timestamp,
|
|
53512
|
+
data: childCropData
|
|
53513
|
+
});
|
|
53514
|
+
} catch (err) {
|
|
53515
|
+
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
53516
|
+
tags: { deviceId },
|
|
53517
|
+
meta: {
|
|
53518
|
+
deviceId,
|
|
53519
|
+
eventId: ev.eventId,
|
|
53520
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53521
|
+
}
|
|
53522
|
+
});
|
|
53523
|
+
}
|
|
51911
53524
|
}
|
|
51912
53525
|
}
|
|
51913
53526
|
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
@@ -55168,7 +56781,8 @@ var customActions = {
|
|
|
55168
56781
|
...orphanAuditActions,
|
|
55169
56782
|
...viewerSettingsActions,
|
|
55170
56783
|
...photoEnrollActions,
|
|
55171
|
-
...faceRescoreActions
|
|
56784
|
+
...faceRescoreActions,
|
|
56785
|
+
...groupActions
|
|
55172
56786
|
};
|
|
55173
56787
|
/**
|
|
55174
56788
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -55347,11 +56961,6 @@ function decodeEmbeddingBase64(base64) {
|
|
|
55347
56961
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
55348
56962
|
return Array.from(view);
|
|
55349
56963
|
}
|
|
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
56964
|
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
55356
56965
|
* to the pixel still land in one cell. */
|
|
55357
56966
|
var PHANTOM_CELL_PX = 32;
|
|
@@ -55415,6 +57024,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
55415
57024
|
faceStore = null;
|
|
55416
57025
|
faceRecognizer = null;
|
|
55417
57026
|
plateStore = null;
|
|
57027
|
+
groupStore = null;
|
|
57028
|
+
groupSealer = null;
|
|
57029
|
+
groupLive = null;
|
|
57030
|
+
/** Serialise first-activity lookback per camera, then observe in order. */
|
|
57031
|
+
groupObserveTail = /* @__PURE__ */ new Map();
|
|
55418
57032
|
plateRecognizer = null;
|
|
55419
57033
|
objectEmbeddingStore = null;
|
|
55420
57034
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -56232,7 +57846,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56232
57846
|
...orphanAuditActions,
|
|
56233
57847
|
...viewerSettingsActions,
|
|
56234
57848
|
...photoEnrollActions,
|
|
56235
|
-
...faceRescoreActions
|
|
57849
|
+
...faceRescoreActions,
|
|
57850
|
+
...groupActions
|
|
56236
57851
|
},
|
|
56237
57852
|
actionHandlers: {
|
|
56238
57853
|
...ncHandlers,
|
|
@@ -56240,6 +57855,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56240
57855
|
...this.buildDebugActionHandlers(),
|
|
56241
57856
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56242
57857
|
...this.buildFaceRescoreActionHandlers(),
|
|
57858
|
+
...this.buildGroupActionHandlers(),
|
|
56243
57859
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
56244
57860
|
}
|
|
56245
57861
|
} : {}
|
|
@@ -56442,6 +58058,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56442
58058
|
SceneStore.declare(api.settingsStore),
|
|
56443
58059
|
NotificationCenter.declare(api.settingsStore),
|
|
56444
58060
|
ViewerSettingsSnapshotStore.declare(api.settingsStore),
|
|
58061
|
+
TrackGroupStore.declare(api.settingsStore),
|
|
56445
58062
|
declareMigrationMarkers(api.settingsStore)
|
|
56446
58063
|
]);
|
|
56447
58064
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
@@ -56726,6 +58343,31 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56726
58343
|
logger: logger.child("PlateStore")
|
|
56727
58344
|
});
|
|
56728
58345
|
this.plateStore = plateStore;
|
|
58346
|
+
const vehicleStore = new VehicleStore({
|
|
58347
|
+
store: api.settingsStore,
|
|
58348
|
+
logger: logger.child("VehicleStore")
|
|
58349
|
+
});
|
|
58350
|
+
const groupStore = new TrackGroupStore({
|
|
58351
|
+
store: api.settingsStore,
|
|
58352
|
+
logger: logger.child("TrackGroupStore"),
|
|
58353
|
+
media: mediaStore
|
|
58354
|
+
});
|
|
58355
|
+
this.groupStore = groupStore;
|
|
58356
|
+
this.groupLive = new GroupLiveBuffer();
|
|
58357
|
+
this.groupSealer = new GroupSealer({
|
|
58358
|
+
trackStore,
|
|
58359
|
+
groupStore,
|
|
58360
|
+
mediaStore,
|
|
58361
|
+
logger: logger.child("GroupSealer"),
|
|
58362
|
+
frameDims: (deviceId) => {
|
|
58363
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
58364
|
+
return dims ? {
|
|
58365
|
+
width: dims.w,
|
|
58366
|
+
height: dims.h
|
|
58367
|
+
} : void 0;
|
|
58368
|
+
},
|
|
58369
|
+
now: () => Date.now()
|
|
58370
|
+
});
|
|
56729
58371
|
return {
|
|
56730
58372
|
trackStore,
|
|
56731
58373
|
mediaStore,
|
|
@@ -56733,10 +58375,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56733
58375
|
identityStore,
|
|
56734
58376
|
faceStore,
|
|
56735
58377
|
plateStore,
|
|
56736
|
-
vehicleStore
|
|
56737
|
-
|
|
56738
|
-
logger: logger.child("VehicleStore")
|
|
56739
|
-
})
|
|
58378
|
+
vehicleStore,
|
|
58379
|
+
groupStore
|
|
56740
58380
|
};
|
|
56741
58381
|
}
|
|
56742
58382
|
/** Face + plate recognizers, the durable keyFrame-derived plate crop and the
|
|
@@ -57170,29 +58810,71 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
57170
58810
|
buildSummaryPorts(stores) {
|
|
57171
58811
|
return {
|
|
57172
58812
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
57173
|
-
|
|
58813
|
+
await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
|
|
58814
|
+
const groups = await stores.groupStore.listOverlapping({
|
|
58815
|
+
deviceId,
|
|
58816
|
+
sinceMs,
|
|
58817
|
+
untilMs,
|
|
58818
|
+
limit
|
|
58819
|
+
});
|
|
58820
|
+
const windowTracks = await stores.trackStore.queryHistorical({
|
|
57174
58821
|
deviceId,
|
|
57175
58822
|
since: sinceMs,
|
|
57176
58823
|
until: untilMs,
|
|
57177
58824
|
projection: "slim",
|
|
57178
|
-
limit
|
|
58825
|
+
limit: Math.max(limit, 2e3)
|
|
57179
58826
|
});
|
|
58827
|
+
const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
|
|
57180
58828
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
57181
|
-
|
|
57182
|
-
|
|
57183
|
-
|
|
57184
|
-
|
|
57185
|
-
|
|
57186
|
-
|
|
57187
|
-
|
|
57188
|
-
|
|
57189
|
-
|
|
57190
|
-
|
|
57191
|
-
|
|
57192
|
-
|
|
58829
|
+
const out = [];
|
|
58830
|
+
for (const group of groups) {
|
|
58831
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58832
|
+
const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
|
|
58833
|
+
if (tracks.length === 0) continue;
|
|
58834
|
+
const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
|
|
58835
|
+
const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
|
|
58836
|
+
const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
|
|
58837
|
+
const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
|
|
58838
|
+
out.push({
|
|
58839
|
+
trackId: group.id,
|
|
58840
|
+
groupId: group.id,
|
|
58841
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
58842
|
+
deviceId,
|
|
58843
|
+
className: primary.className,
|
|
58844
|
+
firstSeen: group.openedAt,
|
|
58845
|
+
lastSeen: group.closedAt,
|
|
58846
|
+
importance,
|
|
58847
|
+
zones,
|
|
58848
|
+
classes,
|
|
58849
|
+
...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
|
|
58850
|
+
confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
|
|
58851
|
+
});
|
|
58852
|
+
}
|
|
58853
|
+
return out;
|
|
57193
58854
|
},
|
|
57194
|
-
listMediaInfo: async (
|
|
57195
|
-
|
|
58855
|
+
listMediaInfo: async (id) => {
|
|
58856
|
+
const group = await stores.groupStore.get(id);
|
|
58857
|
+
if (group) {
|
|
58858
|
+
if (group.mediaKey || group.nativeKey) return digestMediaRows({
|
|
58859
|
+
mediaKey: group.mediaKey,
|
|
58860
|
+
nativeKey: group.nativeKey,
|
|
58861
|
+
timestamp: group.timestamp,
|
|
58862
|
+
memberRows: []
|
|
58863
|
+
});
|
|
58864
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58865
|
+
const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
|
|
58866
|
+
key: r.key,
|
|
58867
|
+
kind: r.kind,
|
|
58868
|
+
timestamp: r.timestamp
|
|
58869
|
+
})));
|
|
58870
|
+
return digestMediaRows({
|
|
58871
|
+
mediaKey: group.mediaKey,
|
|
58872
|
+
nativeKey: group.nativeKey,
|
|
58873
|
+
timestamp: group.timestamp,
|
|
58874
|
+
memberRows
|
|
58875
|
+
});
|
|
58876
|
+
}
|
|
58877
|
+
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
57196
58878
|
key: r.key,
|
|
57197
58879
|
kind: r.kind,
|
|
57198
58880
|
timestamp: r.timestamp
|
|
@@ -58437,6 +60119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58437
60119
|
state: t.state
|
|
58438
60120
|
});
|
|
58439
60121
|
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
60122
|
+
this.enqueueLiveGroup(upserted, result.timestamp);
|
|
58440
60123
|
}
|
|
58441
60124
|
if (result.rawTrackedDetections.length > 0) {
|
|
58442
60125
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
@@ -59216,8 +60899,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59216
60899
|
const first = track.positions?.[0];
|
|
59217
60900
|
if (!first) return;
|
|
59218
60901
|
const lifeMs = track.lastSeen - track.firstSeen;
|
|
59219
|
-
const moved = track.
|
|
59220
|
-
if (!(lifeMs
|
|
60902
|
+
const moved = netDisplacementPx(track.positions ?? []);
|
|
60903
|
+
if (!isShortMotionlessTrack(lifeMs, moved)) {
|
|
59221
60904
|
this.phantomCells?.noteMovingClose({
|
|
59222
60905
|
deviceId: track.deviceId,
|
|
59223
60906
|
className: track.className,
|
|
@@ -59360,7 +61043,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59360
61043
|
deviceId,
|
|
59361
61044
|
trackId,
|
|
59362
61045
|
timestamp: frame.timestamp,
|
|
59363
|
-
bbox: { ...frame.bbox },
|
|
61046
|
+
bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
|
|
59364
61047
|
frameWidth: frame.frameWidth,
|
|
59365
61048
|
frameHeight: frame.frameHeight,
|
|
59366
61049
|
kind: d.className === "plate" ? "plate" : "classification",
|
|
@@ -59793,7 +61476,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59793
61476
|
retries.push({
|
|
59794
61477
|
trackId: t.trackId,
|
|
59795
61478
|
timestamp,
|
|
59796
|
-
bbox:
|
|
61479
|
+
bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
|
|
59797
61480
|
...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
|
|
59798
61481
|
});
|
|
59799
61482
|
}
|
|
@@ -59850,7 +61533,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59850
61533
|
lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
|
|
59851
61534
|
now: timestamp,
|
|
59852
61535
|
intervalMs: media.snapshotIntervalMs,
|
|
59853
|
-
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
|
|
61536
|
+
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
|
|
61537
|
+
firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
|
|
59854
61538
|
});
|
|
59855
61539
|
const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
|
|
59856
61540
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
@@ -60359,6 +62043,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
60359
62043
|
async sweepExpiredTracks() {
|
|
60360
62044
|
if (this.shuttingDown || !this.trackStore) return;
|
|
60361
62045
|
await this.trackCloser.sweep();
|
|
62046
|
+
if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
|
|
62047
|
+
}
|
|
62048
|
+
/** Hub-only: assign this sighting in RAM. First activity on a camera
|
|
62049
|
+
* replays 2 minutes of that camera (crash recover), then observe is sync. */
|
|
62050
|
+
enqueueLiveGroup(track, now) {
|
|
62051
|
+
if (!this.isPostProcessingNode || !this.groupLive) return;
|
|
62052
|
+
const deviceId = track.deviceId;
|
|
62053
|
+
const snapshot = toGroupableTrack(track);
|
|
62054
|
+
if (this.groupLive.isPrimed(deviceId)) {
|
|
62055
|
+
this.groupLive.observe(snapshot, now);
|
|
62056
|
+
return;
|
|
62057
|
+
}
|
|
62058
|
+
const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
62059
|
+
if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
|
|
62060
|
+
this.groupLive?.observe(snapshot, now);
|
|
62061
|
+
}).catch((err) => {
|
|
62062
|
+
this.ctx.logger.warn("group live observe failed", {
|
|
62063
|
+
tags: { deviceId },
|
|
62064
|
+
meta: { error: String(err) }
|
|
62065
|
+
});
|
|
62066
|
+
});
|
|
62067
|
+
this.groupObserveTail.set(deviceId, next);
|
|
60362
62068
|
}
|
|
60363
62069
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
60364
62070
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -61003,6 +62709,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61003
62709
|
};
|
|
61004
62710
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
61005
62711
|
processor.dropTrack(track.trackId);
|
|
62712
|
+
const peek = this.trackStore?.peekActive(track.trackId);
|
|
62713
|
+
if (peek) this.enqueueLiveGroup(toGroupableTrack({
|
|
62714
|
+
trackId: track.trackId,
|
|
62715
|
+
deviceId,
|
|
62716
|
+
className: track.className,
|
|
62717
|
+
firstSeen: peek.firstSeen,
|
|
62718
|
+
lastSeen: timestamp,
|
|
62719
|
+
positions: peek.positions,
|
|
62720
|
+
closedReason: "stationary-promotion"
|
|
62721
|
+
}), timestamp);
|
|
61006
62722
|
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
61007
62723
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
61008
62724
|
tags: { deviceId },
|
|
@@ -61637,6 +63353,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61637
63353
|
},
|
|
61638
63354
|
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
61639
63355
|
liveEventIds: (ids) => eventStore.existingEventIds(ids),
|
|
63356
|
+
liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
63357
|
+
liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
61640
63358
|
reclaim: async (collection, key) => {
|
|
61641
63359
|
if (collection === "pipeline-analytics:media") {
|
|
61642
63360
|
await mediaStore.deleteByKey(key);
|
|
@@ -62360,6 +64078,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62360
64078
|
buildFaceRescoreActionHandlers() {
|
|
62361
64079
|
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62362
64080
|
}
|
|
64081
|
+
buildGroupActionHandlers() {
|
|
64082
|
+
const ports = () => {
|
|
64083
|
+
const groupStore = this.groupStore;
|
|
64084
|
+
if (groupStore === null) return null;
|
|
64085
|
+
return {
|
|
64086
|
+
groupStore,
|
|
64087
|
+
loadTrack: async (trackId) => {
|
|
64088
|
+
const tracks = this.trackStore;
|
|
64089
|
+
if (!tracks) return null;
|
|
64090
|
+
return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
|
|
64091
|
+
},
|
|
64092
|
+
eventMediaBaseUrl: () => this.eventMediaBaseUrl
|
|
64093
|
+
};
|
|
64094
|
+
};
|
|
64095
|
+
return {
|
|
64096
|
+
"analytics.listGroups": async (input) => {
|
|
64097
|
+
const ready = ports();
|
|
64098
|
+
if (ready === null) return {
|
|
64099
|
+
groups: [],
|
|
64100
|
+
hasMore: false
|
|
64101
|
+
};
|
|
64102
|
+
return listGroupRecords(ready, input);
|
|
64103
|
+
},
|
|
64104
|
+
"analytics.getGroup": async (input) => {
|
|
64105
|
+
const ready = ports();
|
|
64106
|
+
if (ready === null) return { group: null };
|
|
64107
|
+
return { group: await getGroupDetail(ready, input.groupId) };
|
|
64108
|
+
}
|
|
64109
|
+
};
|
|
64110
|
+
}
|
|
62363
64111
|
buildPhotoEnrollActionHandlers() {
|
|
62364
64112
|
return {
|
|
62365
64113
|
"photo.analyze": async (input) => {
|
|
@@ -62590,7 +64338,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62590
64338
|
mediaStore,
|
|
62591
64339
|
faceStore: this.faceStore,
|
|
62592
64340
|
plateStore: this.plateStore,
|
|
62593
|
-
objectEmbeddingStore: this.objectEmbeddingStore
|
|
64341
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
64342
|
+
groupStore: this.groupStore
|
|
62594
64343
|
}),
|
|
62595
64344
|
trackStore
|
|
62596
64345
|
};
|
|
@@ -62739,7 +64488,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62739
64488
|
media: 0,
|
|
62740
64489
|
faces: 0,
|
|
62741
64490
|
plates: 0,
|
|
62742
|
-
embeddings: 0
|
|
64491
|
+
embeddings: 0,
|
|
64492
|
+
groups: 0
|
|
62743
64493
|
};
|
|
62744
64494
|
this.lastPruneBytes = 0;
|
|
62745
64495
|
const cascade = this.buildTrackCascadeRegistry();
|
|
@@ -62799,7 +64549,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62799
64549
|
media: tally.media,
|
|
62800
64550
|
faces: tally.faces,
|
|
62801
64551
|
plates: tally.plates,
|
|
62802
|
-
embeddings: tally.embeddings
|
|
64552
|
+
embeddings: tally.embeddings,
|
|
64553
|
+
groups: tally.groups
|
|
62803
64554
|
};
|
|
62804
64555
|
}
|
|
62805
64556
|
/**
|