@camstack/addon-post-analysis 1.2.110 → 1.2.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-BtpxOtGv.mjs → dist-B13sFjR2.mjs} +22 -9
- package/dist/{dist-RzJseCJP.js → dist-C0uPGtBv.js} +22 -9
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DbTCGBNZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-k_ZbrH_x.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BUnWOUjb.mjs → hostInit-DckJUjuL.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1574 -148
- package/dist/pipeline-analytics/index.mjs +1565 -140
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-
|
|
1
|
+
import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-B13sFjR2.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -5347,24 +5347,17 @@ function buildAlarmButtons(input) {
|
|
|
5347
5347
|
*
|
|
5348
5348
|
* ── TWO EXCLUSIVE MODES (operator decision 2026-08-14, D157) ───────────────
|
|
5349
5349
|
*
|
|
5350
|
-
* **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms
|
|
5351
|
-
*
|
|
5352
|
-
*
|
|
5353
|
-
*
|
|
5354
|
-
*
|
|
5355
|
-
*
|
|
5356
|
-
*
|
|
5357
|
-
*
|
|
5358
|
-
*
|
|
5359
|
-
*
|
|
5360
|
-
*
|
|
5361
|
-
* applies — it is the analyzer's own `classificationMinScore`, applied per
|
|
5362
|
-
* device before a label ever reaches this module.
|
|
5363
|
-
*
|
|
5364
|
-
* The rule's `throttle` cooldown is the ONLY brake in this mode. There is
|
|
5365
|
-
* deliberately no re-arm timer here: a second brake nobody can see in the
|
|
5366
|
-
* editor is how a rule ends up silently not firing, which is the failure this
|
|
5367
|
-
* change exists to remove.
|
|
5350
|
+
* **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms when
|
|
5351
|
+
* `confirmHits` labelled frames land inside `confirmWindowSec` (default 2
|
|
5352
|
+
* in 5 s). That is a COUNT of sparse frames, not a percentage of the
|
|
5353
|
+
* stream: YAMNet still labels only one to three frames per episode, so a
|
|
5354
|
+
* 60%-of-10s window can never fire, but two of those frames two seconds
|
|
5355
|
+
* apart can. A single-frame false positive (the live "Pianti" pattern)
|
|
5356
|
+
* never reaches 2. `confirmHits: 1` is the old first-frame behaviour.
|
|
5357
|
+
*
|
|
5358
|
+
* The per-label confidence floor still applies — it is the analyzer's own
|
|
5359
|
+
* `classificationMinScore`, applied per device before a label ever reaches
|
|
5360
|
+
* this module. The rule's `throttle` cooldown is the OTHER brake.
|
|
5368
5361
|
*
|
|
5369
5362
|
* **LEVEL mode** — the rule NAMES A LEVEL (`dbThreshold`). Unchanged, and the
|
|
5370
5363
|
* window is the whole point: over `samplingSeconds`, at least `hitPercent`% of
|
|
@@ -5427,7 +5420,9 @@ function audioSpecFromCondition(condition) {
|
|
|
5427
5420
|
const mode = audioModeOf(condition);
|
|
5428
5421
|
if (mode === "label") return {
|
|
5429
5422
|
mode: "label",
|
|
5430
|
-
labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort()
|
|
5423
|
+
labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort(),
|
|
5424
|
+
confirmHits: condition.confirmHits ?? 2,
|
|
5425
|
+
confirmWindowSec: condition.confirmWindowSec ?? 5
|
|
5431
5426
|
};
|
|
5432
5427
|
const dbThreshold = condition.dbThreshold;
|
|
5433
5428
|
if (mode === "level" && dbThreshold !== void 0) return {
|
|
@@ -5440,7 +5435,7 @@ function audioSpecFromCondition(condition) {
|
|
|
5440
5435
|
}
|
|
5441
5436
|
/** Stable, device-agnostic key of a spec — rules that agree share a window. */
|
|
5442
5437
|
function audioSpecKey(spec) {
|
|
5443
|
-
return spec.mode === "label" ? `label|${spec.labels.join(",")}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
|
|
5438
|
+
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)}`;
|
|
5444
5439
|
}
|
|
5445
5440
|
/**
|
|
5446
5441
|
* The watched labels this sample carries, normalized and sorted. Empty = no
|
|
@@ -5465,6 +5460,8 @@ var AudioWatcher = class {
|
|
|
5465
5460
|
watched = /* @__PURE__ */ new Map();
|
|
5466
5461
|
/** LEVEL windows, keyed `${deviceId}|${specKey}`. */
|
|
5467
5462
|
windows = /* @__PURE__ */ new Map();
|
|
5463
|
+
/** LABEL pending hits, keyed `${deviceId}|${specKey}` — timestamps only. */
|
|
5464
|
+
labelHits = /* @__PURE__ */ new Map();
|
|
5468
5465
|
/**
|
|
5469
5466
|
* Replace the watched spec set (rule-driven — recomputed on every rule
|
|
5470
5467
|
* reload). Windows whose spec is no longer watched are DROPPED: a rule
|
|
@@ -5475,9 +5472,9 @@ var AudioWatcher = class {
|
|
|
5475
5472
|
setWatchedSpecs(specs) {
|
|
5476
5473
|
this.watched.clear();
|
|
5477
5474
|
for (const spec of specs) this.watched.set(audioSpecKey(spec), spec);
|
|
5478
|
-
for (const
|
|
5475
|
+
for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) {
|
|
5479
5476
|
const specKey = key.slice(key.indexOf("|") + 1);
|
|
5480
|
-
if (!this.watched.has(specKey))
|
|
5477
|
+
if (!this.watched.has(specKey)) store.delete(key);
|
|
5481
5478
|
}
|
|
5482
5479
|
}
|
|
5483
5480
|
/** True when at least one spec is watched — the caller's cheap no-op gate. */
|
|
@@ -5493,7 +5490,7 @@ var AudioWatcher = class {
|
|
|
5493
5490
|
if (this.watched.size === 0) return [];
|
|
5494
5491
|
const confirmed = [];
|
|
5495
5492
|
for (const [specKey, spec] of this.watched) {
|
|
5496
|
-
const hit = spec.mode === "label" ? observeLabel(deviceId, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
|
|
5493
|
+
const hit = spec.mode === "label" ? this.observeLabel(deviceId, specKey, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
|
|
5497
5494
|
if (hit !== null) confirmed.push(hit);
|
|
5498
5495
|
}
|
|
5499
5496
|
return confirmed;
|
|
@@ -5501,7 +5498,7 @@ var AudioWatcher = class {
|
|
|
5501
5498
|
/** Drop every window of a camera (unbound, removed, audio switched off). */
|
|
5502
5499
|
forgetDevice(deviceId) {
|
|
5503
5500
|
const prefix = `${deviceId}|`;
|
|
5504
|
-
for (const
|
|
5501
|
+
for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) if (key.startsWith(prefix)) store.delete(key);
|
|
5505
5502
|
}
|
|
5506
5503
|
observeLevel(deviceId, specKey, spec, sample, now) {
|
|
5507
5504
|
const key = `${deviceId}|${specKey}`;
|
|
@@ -5550,23 +5547,34 @@ var AudioWatcher = class {
|
|
|
5550
5547
|
spec
|
|
5551
5548
|
};
|
|
5552
5549
|
}
|
|
5550
|
+
/**
|
|
5551
|
+
* LABEL mode: confirm when `confirmHits` labelled frames land inside
|
|
5552
|
+
* `confirmWindowSec`. `confirmHits <= 1` is first-frame fire (stateless).
|
|
5553
|
+
* Confirming re-arms — the next episode needs a fresh count.
|
|
5554
|
+
*/
|
|
5555
|
+
observeLabel(deviceId, specKey, spec, sample, now) {
|
|
5556
|
+
const matched = matchedAudioLabels(sample, spec.labels);
|
|
5557
|
+
if (matched.length === 0) return null;
|
|
5558
|
+
const hit = {
|
|
5559
|
+
deviceId,
|
|
5560
|
+
timestamp: now,
|
|
5561
|
+
labels: matched,
|
|
5562
|
+
...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
|
|
5563
|
+
spec
|
|
5564
|
+
};
|
|
5565
|
+
if (spec.confirmHits <= 1) return hit;
|
|
5566
|
+
const key = `${deviceId}|${specKey}`;
|
|
5567
|
+
const cutoff = now - spec.confirmWindowSec * 1e3;
|
|
5568
|
+
const kept = (this.labelHits.get(key) ?? []).filter((at) => at > cutoff);
|
|
5569
|
+
kept.push(now);
|
|
5570
|
+
if (kept.length < spec.confirmHits) {
|
|
5571
|
+
this.labelHits.set(key, kept);
|
|
5572
|
+
return null;
|
|
5573
|
+
}
|
|
5574
|
+
this.labelHits.delete(key);
|
|
5575
|
+
return hit;
|
|
5576
|
+
}
|
|
5553
5577
|
};
|
|
5554
|
-
/**
|
|
5555
|
-
* LABEL mode: confirm on the first frame carrying a watched label. Stateless
|
|
5556
|
-
* by construction — nothing is remembered between frames, so there is no
|
|
5557
|
-
* window to be not-full and no percentage to fall short of.
|
|
5558
|
-
*/
|
|
5559
|
-
function observeLabel(deviceId, spec, sample, now) {
|
|
5560
|
-
const matched = matchedAudioLabels(sample, spec.labels);
|
|
5561
|
-
if (matched.length === 0) return null;
|
|
5562
|
-
return {
|
|
5563
|
-
deviceId,
|
|
5564
|
-
timestamp: now,
|
|
5565
|
-
labels: matched,
|
|
5566
|
-
...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
|
|
5567
|
-
spec
|
|
5568
|
-
};
|
|
5569
|
-
}
|
|
5570
5578
|
//#endregion
|
|
5571
5579
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5572
5580
|
/**
|
|
@@ -7913,7 +7921,10 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
7913
7921
|
if (policy === "keyFrame") return ownerKind === "track" ? [
|
|
7914
7922
|
"keyFrameSmall",
|
|
7915
7923
|
"keyFrame",
|
|
7916
|
-
"fullFrameBoxed"
|
|
7924
|
+
"fullFrameBoxed",
|
|
7925
|
+
"firstFrame",
|
|
7926
|
+
"lastFrame",
|
|
7927
|
+
"snapshot"
|
|
7917
7928
|
] : [
|
|
7918
7929
|
"fullFrameBoxed",
|
|
7919
7930
|
"fullFrame",
|
|
@@ -15123,14 +15134,13 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15123
15134
|
/**
|
|
15124
15135
|
* The media a tile is drawn from, best first.
|
|
15125
15136
|
*
|
|
15126
|
-
* `
|
|
15127
|
-
*
|
|
15128
|
-
*
|
|
15129
|
-
*
|
|
15130
|
-
* P2) a box burnt into a pixel is text inside an image — an injection channel
|
|
15131
|
-
* and a source of hallucination. Boxes stay data.
|
|
15137
|
+
* `groupCrop` heads the ladder: it is the group's own clean wide-central-square
|
|
15138
|
+
* over the union bbox, and a drawn rectangle is a permanent lie in a digest
|
|
15139
|
+
* tile. Singletons have no groupCrop and fall through to the member ladder.
|
|
15140
|
+
* `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
|
|
15132
15141
|
*/
|
|
15133
15142
|
var SUMMARY_MEDIA_LADDER = [
|
|
15143
|
+
"groupCrop",
|
|
15134
15144
|
"keyFrameSmall",
|
|
15135
15145
|
"keyFrame",
|
|
15136
15146
|
"snapshot",
|
|
@@ -15212,7 +15222,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
|
15212
15222
|
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
15213
15223
|
return `summary-${ruleId}-${windowEndMs}`;
|
|
15214
15224
|
}
|
|
15215
|
-
var EMPTY_REPORT = {
|
|
15225
|
+
var EMPTY_REPORT$1 = {
|
|
15216
15226
|
produced: 0,
|
|
15217
15227
|
collecting: 0,
|
|
15218
15228
|
barrier: 0,
|
|
@@ -15276,9 +15286,9 @@ var NcSummaryProducer = class {
|
|
|
15276
15286
|
* must not open ten simultaneous render/publish sequences.
|
|
15277
15287
|
*/
|
|
15278
15288
|
async tick() {
|
|
15279
|
-
if (this.tickInFlight) return EMPTY_REPORT;
|
|
15289
|
+
if (this.tickInFlight) return EMPTY_REPORT$1;
|
|
15280
15290
|
this.tickInFlight = true;
|
|
15281
|
-
const report = { ...EMPTY_REPORT };
|
|
15291
|
+
const report = { ...EMPTY_REPORT$1 };
|
|
15282
15292
|
try {
|
|
15283
15293
|
const now = this.deps.now();
|
|
15284
15294
|
const rules = this.deps.store.listEnabled();
|
|
@@ -15989,7 +15999,7 @@ var NcSummaryRuleInputSchema = object({
|
|
|
15989
15999
|
deviceIds: DeviceIdsField,
|
|
15990
16000
|
window: NcSummaryWindowSchema,
|
|
15991
16001
|
filters: NcSummaryFiltersSchema.optional(),
|
|
15992
|
-
maxTiles: MaxTilesField.default(
|
|
16002
|
+
maxTiles: MaxTilesField.default(12),
|
|
15993
16003
|
maxAiImages: MaxAiImagesField.default(6),
|
|
15994
16004
|
rank: NcSummaryRankSchema.default("importance"),
|
|
15995
16005
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
@@ -18340,6 +18350,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
|
18340
18350
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
18341
18351
|
return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
18342
18352
|
}
|
|
18353
|
+
var EMPTY_REPORT = {
|
|
18354
|
+
produced: 0,
|
|
18355
|
+
waiting: 0,
|
|
18356
|
+
alreadyDone: 0,
|
|
18357
|
+
beforeFloor: 0,
|
|
18358
|
+
failed: 0,
|
|
18359
|
+
idle: 0,
|
|
18360
|
+
skippedInFlight: 0
|
|
18361
|
+
};
|
|
18343
18362
|
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
18344
18363
|
function renderFlightKey(ruleId, deviceId) {
|
|
18345
18364
|
return `${ruleId}:${String(deviceId)}`;
|
|
@@ -18347,7 +18366,8 @@ function renderFlightKey(ruleId, deviceId) {
|
|
|
18347
18366
|
var TimelapseScheduler = class {
|
|
18348
18367
|
deps;
|
|
18349
18368
|
timer = null;
|
|
18350
|
-
|
|
18369
|
+
/** True only while this tick is scanning eligibility — not while ffmpeg runs. */
|
|
18370
|
+
scanning = false;
|
|
18351
18371
|
inFlight = /* @__PURE__ */ new Map();
|
|
18352
18372
|
settleMs;
|
|
18353
18373
|
lookbackMs;
|
|
@@ -18394,25 +18414,29 @@ var TimelapseScheduler = class {
|
|
|
18394
18414
|
this.timer = null;
|
|
18395
18415
|
}
|
|
18396
18416
|
}
|
|
18397
|
-
/**
|
|
18417
|
+
/**
|
|
18418
|
+
* One pass over every enabled rule × camera.
|
|
18419
|
+
*
|
|
18420
|
+
* Eligibility is scanned under a short `scanning` lock so two ticks do not
|
|
18421
|
+
* double-launch the same pair. Renders themselves are started together —
|
|
18422
|
+
* awaiting them one-by-one made camera B wait for camera A's ffmpeg
|
|
18423
|
+
* (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
|
|
18424
|
+
* A later tick that meets a pair already in `inFlight` counts it and
|
|
18425
|
+
* moves on, so a 30-minute render does not make the producer go deaf.
|
|
18426
|
+
*/
|
|
18398
18427
|
async tick() {
|
|
18399
|
-
if (this.
|
|
18400
|
-
|
|
18401
|
-
waiting: 0,
|
|
18402
|
-
alreadyDone: 0,
|
|
18403
|
-
beforeFloor: 0,
|
|
18404
|
-
failed: 0,
|
|
18405
|
-
idle: 0
|
|
18406
|
-
};
|
|
18407
|
-
this.tickInFlight = true;
|
|
18428
|
+
if (this.scanning) return { ...EMPTY_REPORT };
|
|
18429
|
+
this.scanning = true;
|
|
18408
18430
|
const report = {
|
|
18409
18431
|
produced: 0,
|
|
18410
18432
|
waiting: 0,
|
|
18411
18433
|
alreadyDone: 0,
|
|
18412
18434
|
beforeFloor: 0,
|
|
18413
18435
|
failed: 0,
|
|
18414
|
-
idle: 0
|
|
18436
|
+
idle: 0,
|
|
18437
|
+
skippedInFlight: 0
|
|
18415
18438
|
};
|
|
18439
|
+
const jobs = [];
|
|
18416
18440
|
try {
|
|
18417
18441
|
const now = this.deps.now();
|
|
18418
18442
|
for (const rule of this.deps.store.listEnabled()) {
|
|
@@ -18447,13 +18471,20 @@ var TimelapseScheduler = class {
|
|
|
18447
18471
|
});
|
|
18448
18472
|
continue;
|
|
18449
18473
|
}
|
|
18450
|
-
if (
|
|
18451
|
-
|
|
18474
|
+
if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
|
|
18475
|
+
report.skippedInFlight += 1;
|
|
18476
|
+
continue;
|
|
18477
|
+
}
|
|
18478
|
+
jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
|
|
18479
|
+
if (ok.produced > 0) report.produced += 1;
|
|
18480
|
+
else report.failed += 1;
|
|
18481
|
+
}));
|
|
18452
18482
|
}
|
|
18453
18483
|
}
|
|
18454
18484
|
} finally {
|
|
18455
|
-
this.
|
|
18485
|
+
this.scanning = false;
|
|
18456
18486
|
}
|
|
18487
|
+
await Promise.all(jobs);
|
|
18457
18488
|
return report;
|
|
18458
18489
|
}
|
|
18459
18490
|
/**
|
|
@@ -27607,6 +27638,35 @@ function pairRiders(detections) {
|
|
|
27607
27638
|
return pairs;
|
|
27608
27639
|
}
|
|
27609
27640
|
//#endregion
|
|
27641
|
+
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
27642
|
+
/** How far the grown box's centroid may sit, as a multiple of the LARGER
|
|
27643
|
+
* diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
|
|
27644
|
+
var GROWTH_CENTROID_FACTOR = 1.35;
|
|
27645
|
+
function intersectionArea(a, b) {
|
|
27646
|
+
const x1 = Math.max(a.x, b.x);
|
|
27647
|
+
const y1 = Math.max(a.y, b.y);
|
|
27648
|
+
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
27649
|
+
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
|
27650
|
+
if (x2 <= x1 || y2 <= y1) return 0;
|
|
27651
|
+
return (x2 - x1) * (y2 - y1);
|
|
27652
|
+
}
|
|
27653
|
+
/** Same-class partial crop growing into (or collapsing from) a full-body box
|
|
27654
|
+
* with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
|
|
27655
|
+
* A SMALL box nested inside a LARGE one is occlusion, not growth — those
|
|
27656
|
+
* stay two tracks (sort-tracker occlusion suite). */
|
|
27657
|
+
function growthAdjacentMatch(track, det) {
|
|
27658
|
+
const trackArea = track.w * track.h;
|
|
27659
|
+
const detArea = det.w * det.h;
|
|
27660
|
+
if (trackArea <= 0 || detArea <= 0) return false;
|
|
27661
|
+
const larger = Math.max(trackArea, detArea);
|
|
27662
|
+
const smaller = Math.min(trackArea, detArea);
|
|
27663
|
+
if (larger / smaller < 1.5) return false;
|
|
27664
|
+
if (intersectionArea(track, det) / smaller >= .5) return false;
|
|
27665
|
+
const tc = bboxCentroid(track);
|
|
27666
|
+
const dc = bboxCentroid(det);
|
|
27667
|
+
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));
|
|
27668
|
+
}
|
|
27669
|
+
//#endregion
|
|
27610
27670
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
27611
27671
|
var DEFAULT_TRACKER_CONFIG = {
|
|
27612
27672
|
iouThreshold: .3,
|
|
@@ -27734,7 +27794,7 @@ var SortTracker = class SortTracker {
|
|
|
27734
27794
|
* and earlier same-frame spawns).
|
|
27735
27795
|
*/
|
|
27736
27796
|
isDuplicateSpawn(det, live) {
|
|
27737
|
-
for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
|
|
27797
|
+
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;
|
|
27738
27798
|
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
27739
27799
|
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
27740
27800
|
}
|
|
@@ -27828,7 +27888,8 @@ var SortTracker = class SortTracker {
|
|
|
27828
27888
|
const dc = bboxCentroid(det.bbox);
|
|
27829
27889
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
27830
27890
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
27831
|
-
|
|
27891
|
+
if (dist <= this.config.rescueCentroidFactor * diag) return true;
|
|
27892
|
+
return growthAdjacentMatch(track.bbox, det.bbox);
|
|
27832
27893
|
}
|
|
27833
27894
|
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
27834
27895
|
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
@@ -28153,6 +28214,7 @@ var StateAnalyzer = class {
|
|
|
28153
28214
|
enteredAt: timestamp,
|
|
28154
28215
|
stationarySince: void 0,
|
|
28155
28216
|
totalDistancePx: 0,
|
|
28217
|
+
startPosition: center,
|
|
28156
28218
|
lastPosition: center,
|
|
28157
28219
|
frameCount: 1
|
|
28158
28220
|
};
|
|
@@ -28172,8 +28234,9 @@ var StateAnalyzer = class {
|
|
|
28172
28234
|
else if (velocity <= this.config.velocityThreshold) {
|
|
28173
28235
|
if (!ts.stationarySince) ts.stationarySince = timestamp;
|
|
28174
28236
|
const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
|
|
28237
|
+
const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
|
|
28175
28238
|
if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
|
|
28176
|
-
else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28239
|
+
else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28177
28240
|
else state = "moving";
|
|
28178
28241
|
} else {
|
|
28179
28242
|
ts.stationarySince = void 0;
|
|
@@ -29006,6 +29069,22 @@ function promoteSemanticBestFrame(deps, input) {
|
|
|
29006
29069
|
};
|
|
29007
29070
|
}
|
|
29008
29071
|
//#endregion
|
|
29072
|
+
//#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
|
|
29073
|
+
/** Which box the semantic best-shot should crop: a plate/face DETAIL box
|
|
29074
|
+
* when we have one, otherwise the subject box. */
|
|
29075
|
+
function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
|
|
29076
|
+
if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
|
|
29077
|
+
return subjectBbox;
|
|
29078
|
+
}
|
|
29079
|
+
//#endregion
|
|
29080
|
+
//#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
|
|
29081
|
+
/** Crop box for a late firstFrame retry: the BIRTH geometry, not the
|
|
29082
|
+
* later frozen/coasted box (track 1b659d56). */
|
|
29083
|
+
function firstFrameRetryBbox(current, birth) {
|
|
29084
|
+
if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
|
|
29085
|
+
return { ...current };
|
|
29086
|
+
}
|
|
29087
|
+
//#endregion
|
|
29009
29088
|
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
29010
29089
|
/**
|
|
29011
29090
|
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
@@ -29220,6 +29299,7 @@ var EMPTY_CASCADE_TALLY = {
|
|
|
29220
29299
|
faces: 0,
|
|
29221
29300
|
plates: 0,
|
|
29222
29301
|
embeddings: 0,
|
|
29302
|
+
groups: 0,
|
|
29223
29303
|
bytesReclaimed: 0
|
|
29224
29304
|
};
|
|
29225
29305
|
/** Fold a leaf store's receipts into a running tally. */
|
|
@@ -29263,6 +29343,13 @@ function addLeafCounts(tally, counts) {
|
|
|
29263
29343
|
bytesReclaimed
|
|
29264
29344
|
};
|
|
29265
29345
|
break;
|
|
29346
|
+
case "groups":
|
|
29347
|
+
next = {
|
|
29348
|
+
...next,
|
|
29349
|
+
groups: next.groups + c.rows,
|
|
29350
|
+
bytesReclaimed
|
|
29351
|
+
};
|
|
29352
|
+
break;
|
|
29266
29353
|
}
|
|
29267
29354
|
}
|
|
29268
29355
|
return next;
|
|
@@ -29276,6 +29363,7 @@ function mergeCascadeTallies(a, b) {
|
|
|
29276
29363
|
faces: a.faces + b.faces,
|
|
29277
29364
|
plates: a.plates + b.plates,
|
|
29278
29365
|
embeddings: a.embeddings + b.embeddings,
|
|
29366
|
+
groups: a.groups + b.groups,
|
|
29279
29367
|
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
29280
29368
|
};
|
|
29281
29369
|
}
|
|
@@ -29324,6 +29412,7 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
29324
29412
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
29325
29413
|
if (stores.plateStore) registry.push(stores.plateStore);
|
|
29326
29414
|
if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
|
|
29415
|
+
if (stores.groupStore) registry.push(stores.groupStore);
|
|
29327
29416
|
return registry;
|
|
29328
29417
|
}
|
|
29329
29418
|
/**
|
|
@@ -31034,7 +31123,8 @@ var OWNER_KINDS = [
|
|
|
31034
31123
|
"identity",
|
|
31035
31124
|
"plate",
|
|
31036
31125
|
"vehicle",
|
|
31037
|
-
"scene"
|
|
31126
|
+
"scene",
|
|
31127
|
+
"group"
|
|
31038
31128
|
];
|
|
31039
31129
|
/**
|
|
31040
31130
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -31783,6 +31873,10 @@ var MediaStore = class {
|
|
|
31783
31873
|
async deleteForTracks(trackIds) {
|
|
31784
31874
|
return this.deleteForOwner("track", trackIds);
|
|
31785
31875
|
}
|
|
31876
|
+
/** Delete the group's OWN crop. The native frame is a pointer and is not here. */
|
|
31877
|
+
async deleteByGroups(groupIds) {
|
|
31878
|
+
return this.deleteForOwnerCounted("group", groupIds);
|
|
31879
|
+
}
|
|
31786
31880
|
/**
|
|
31787
31881
|
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
31788
31882
|
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
@@ -39836,7 +39930,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
39836
39930
|
function planPeriodicMedia(input) {
|
|
39837
39931
|
const appendSnapshot = input.dueSnapshot;
|
|
39838
39932
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
39839
|
-
const
|
|
39933
|
+
const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
39934
|
+
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
39935
|
+
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
39840
39936
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
39841
39937
|
return {
|
|
39842
39938
|
appendSnapshot,
|
|
@@ -40069,6 +40165,1170 @@ var DeferredBirthRegistry = class {
|
|
|
40069
40165
|
this.byDevice.delete(deviceKey);
|
|
40070
40166
|
}
|
|
40071
40167
|
};
|
|
40168
|
+
var AnalyticsGroupRecordSchema = object({
|
|
40169
|
+
id: string(),
|
|
40170
|
+
deviceId: number().int(),
|
|
40171
|
+
openedAt: number().int(),
|
|
40172
|
+
closedAt: number().int(),
|
|
40173
|
+
timestamp: number().int(),
|
|
40174
|
+
memberCount: number().int(),
|
|
40175
|
+
memberTrackIds: array(string()).readonly(),
|
|
40176
|
+
className: string(),
|
|
40177
|
+
classes: array(string()).readonly(),
|
|
40178
|
+
/** Relative event-media path, or null when the group has no picture yet. */
|
|
40179
|
+
mediaUrl: string().nullable(),
|
|
40180
|
+
singleton: boolean()
|
|
40181
|
+
});
|
|
40182
|
+
var AnalyticsGroupMemberSchema = object({
|
|
40183
|
+
trackId: string(),
|
|
40184
|
+
deviceId: number().int(),
|
|
40185
|
+
className: string(),
|
|
40186
|
+
firstSeen: number().int(),
|
|
40187
|
+
lastSeen: number().int(),
|
|
40188
|
+
mediaUrl: string().nullable()
|
|
40189
|
+
});
|
|
40190
|
+
var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: array(AnalyticsGroupMemberSchema).readonly() });
|
|
40191
|
+
var groupActions = defineCustomActions({
|
|
40192
|
+
"analytics.listGroups": customAction(object({
|
|
40193
|
+
deviceId: number().int().optional(),
|
|
40194
|
+
sinceMs: number().int().optional(),
|
|
40195
|
+
untilMs: number().int().optional(),
|
|
40196
|
+
limit: number().int().min(1).max(100).optional(),
|
|
40197
|
+
offset: number().int().min(0).optional()
|
|
40198
|
+
}), object({
|
|
40199
|
+
groups: array(AnalyticsGroupRecordSchema).readonly(),
|
|
40200
|
+
hasMore: boolean()
|
|
40201
|
+
})),
|
|
40202
|
+
"analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
|
|
40203
|
+
});
|
|
40204
|
+
//#endregion
|
|
40205
|
+
//#region src/pipeline-analytics/group/group-query.ts
|
|
40206
|
+
/**
|
|
40207
|
+
* Stamp a relative event-media URL.
|
|
40208
|
+
*
|
|
40209
|
+
* Media keys contain `:` and the data-plane serves them by key. Track ids are
|
|
40210
|
+
* bare and fall through to the existing event/track thumbnail resolver. Never
|
|
40211
|
+
* percent-encode: the handler does not URL-decode the path.
|
|
40212
|
+
*/
|
|
40213
|
+
function stampEventMediaUrl(baseUrl, id) {
|
|
40214
|
+
if (baseUrl === null || baseUrl.length === 0) return null;
|
|
40215
|
+
if (id === null || id === void 0 || id.length === 0) return null;
|
|
40216
|
+
return `${baseUrl}/${id}`;
|
|
40217
|
+
}
|
|
40218
|
+
/** Union of every class on the members; `className` is the first, stable order. */
|
|
40219
|
+
function aggregateGroupClasses(tracks) {
|
|
40220
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40221
|
+
const classes = [];
|
|
40222
|
+
for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
|
|
40223
|
+
if (name.length === 0 || seen.has(name)) continue;
|
|
40224
|
+
seen.add(name);
|
|
40225
|
+
classes.push(name);
|
|
40226
|
+
}
|
|
40227
|
+
return {
|
|
40228
|
+
className: classes[0] ?? "unknown",
|
|
40229
|
+
classes
|
|
40230
|
+
};
|
|
40231
|
+
}
|
|
40232
|
+
function toGroupRecord(input) {
|
|
40233
|
+
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40234
|
+
const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40235
|
+
return {
|
|
40236
|
+
id: input.group.id,
|
|
40237
|
+
deviceId: input.group.deviceId,
|
|
40238
|
+
openedAt: input.group.openedAt,
|
|
40239
|
+
closedAt: input.group.closedAt,
|
|
40240
|
+
timestamp: input.group.timestamp,
|
|
40241
|
+
memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
|
|
40242
|
+
memberTrackIds: [...input.memberTrackIds],
|
|
40243
|
+
className,
|
|
40244
|
+
classes: [...classes],
|
|
40245
|
+
mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
|
|
40246
|
+
singleton: input.memberTrackIds.length <= 1
|
|
40247
|
+
};
|
|
40248
|
+
}
|
|
40249
|
+
function toGroupMember(member, track, eventMediaBaseUrl) {
|
|
40250
|
+
return {
|
|
40251
|
+
trackId: member.trackId,
|
|
40252
|
+
deviceId: member.deviceId,
|
|
40253
|
+
className: track?.className ?? "unknown",
|
|
40254
|
+
firstSeen: track?.firstSeen ?? member.timestamp,
|
|
40255
|
+
lastSeen: track?.lastSeen ?? member.timestamp,
|
|
40256
|
+
mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
|
|
40257
|
+
};
|
|
40258
|
+
}
|
|
40259
|
+
function trackToLite(track) {
|
|
40260
|
+
return {
|
|
40261
|
+
trackId: track.trackId,
|
|
40262
|
+
className: track.className,
|
|
40263
|
+
classes: track.classes,
|
|
40264
|
+
firstSeen: track.firstSeen,
|
|
40265
|
+
lastSeen: track.lastSeen
|
|
40266
|
+
};
|
|
40267
|
+
}
|
|
40268
|
+
async function listGroupRecords(ports, input) {
|
|
40269
|
+
const page = await ports.groupStore.listRecent(input);
|
|
40270
|
+
const groups = [];
|
|
40271
|
+
for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
|
|
40272
|
+
return {
|
|
40273
|
+
groups,
|
|
40274
|
+
hasMore: page.hasMore
|
|
40275
|
+
};
|
|
40276
|
+
}
|
|
40277
|
+
async function getGroupDetail(ports, groupId) {
|
|
40278
|
+
const group = await ports.groupStore.get(groupId);
|
|
40279
|
+
if (group === null) return null;
|
|
40280
|
+
const members = await ports.groupStore.listMembers(groupId);
|
|
40281
|
+
const lites = [];
|
|
40282
|
+
const rows = [];
|
|
40283
|
+
for (const member of members) {
|
|
40284
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40285
|
+
const lite = track === null ? null : trackToLite(track);
|
|
40286
|
+
if (lite !== null) lites.push(lite);
|
|
40287
|
+
rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
|
|
40288
|
+
}
|
|
40289
|
+
return {
|
|
40290
|
+
...toGroupRecord({
|
|
40291
|
+
group,
|
|
40292
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40293
|
+
tracks: lites,
|
|
40294
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40295
|
+
}),
|
|
40296
|
+
members: rows
|
|
40297
|
+
};
|
|
40298
|
+
}
|
|
40299
|
+
async function buildGroupRecord(ports, group) {
|
|
40300
|
+
const members = await ports.groupStore.listMembers(group.id);
|
|
40301
|
+
const lites = [];
|
|
40302
|
+
for (const member of members) {
|
|
40303
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40304
|
+
if (track !== null) lites.push(trackToLite(track));
|
|
40305
|
+
}
|
|
40306
|
+
return toGroupRecord({
|
|
40307
|
+
group,
|
|
40308
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40309
|
+
tracks: lites,
|
|
40310
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40311
|
+
});
|
|
40312
|
+
}
|
|
40313
|
+
//#endregion
|
|
40314
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40315
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40316
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40317
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40318
|
+
}
|
|
40319
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40320
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40321
|
+
W: frameWidth,
|
|
40322
|
+
H: frameHeight
|
|
40323
|
+
});
|
|
40324
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40325
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40326
|
+
}
|
|
40327
|
+
var MATCH_WINDOW_MS = 1e3;
|
|
40328
|
+
var MOVE_EPS = .35;
|
|
40329
|
+
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40330
|
+
function isEligibleMember(track) {
|
|
40331
|
+
if (track.closedReason === "stationary-promotion") return false;
|
|
40332
|
+
if (track.className === "audio") return false;
|
|
40333
|
+
return true;
|
|
40334
|
+
}
|
|
40335
|
+
function temporallyOverlap(a, b, slackMs) {
|
|
40336
|
+
return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
|
|
40337
|
+
}
|
|
40338
|
+
/** Connected components of temporal overlap (+ slack) per camera. */
|
|
40339
|
+
function sessionsOf(tracks) {
|
|
40340
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
40341
|
+
for (const t of tracks) {
|
|
40342
|
+
const list = byDevice.get(t.deviceId);
|
|
40343
|
+
if (list) list.push(t);
|
|
40344
|
+
else byDevice.set(t.deviceId, [t]);
|
|
40345
|
+
}
|
|
40346
|
+
const sessions = [];
|
|
40347
|
+
for (const deviceTracks of byDevice.values()) {
|
|
40348
|
+
const n = deviceTracks.length;
|
|
40349
|
+
const parent = deviceTracks.map((_, i) => i);
|
|
40350
|
+
const find = (i) => {
|
|
40351
|
+
let cur = i;
|
|
40352
|
+
while (parent[cur] !== cur) cur = parent[cur];
|
|
40353
|
+
let walk = i;
|
|
40354
|
+
while (parent[walk] !== cur) {
|
|
40355
|
+
const next = parent[walk];
|
|
40356
|
+
parent[walk] = cur;
|
|
40357
|
+
walk = next;
|
|
40358
|
+
}
|
|
40359
|
+
return cur;
|
|
40360
|
+
};
|
|
40361
|
+
const union = (i, j) => {
|
|
40362
|
+
const ri = find(i);
|
|
40363
|
+
const rj = find(j);
|
|
40364
|
+
if (ri !== rj) parent[ri] = rj;
|
|
40365
|
+
};
|
|
40366
|
+
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
|
|
40367
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
40368
|
+
for (let i = 0; i < n; i++) {
|
|
40369
|
+
const root = find(i);
|
|
40370
|
+
const bucket = buckets.get(root);
|
|
40371
|
+
if (bucket) bucket.push(deviceTracks[i]);
|
|
40372
|
+
else buckets.set(root, [deviceTracks[i]]);
|
|
40373
|
+
}
|
|
40374
|
+
sessions.push(...buckets.values());
|
|
40375
|
+
}
|
|
40376
|
+
return sessions;
|
|
40377
|
+
}
|
|
40378
|
+
function diag(bbox) {
|
|
40379
|
+
return Math.hypot(bbox.w, bbox.h);
|
|
40380
|
+
}
|
|
40381
|
+
function nearestPosition(positions, t) {
|
|
40382
|
+
let best;
|
|
40383
|
+
let bestDt = 1001;
|
|
40384
|
+
for (const p of positions) {
|
|
40385
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40386
|
+
if (dt <= 1e3 && dt < bestDt) {
|
|
40387
|
+
best = p;
|
|
40388
|
+
bestDt = dt;
|
|
40389
|
+
}
|
|
40390
|
+
}
|
|
40391
|
+
return best;
|
|
40392
|
+
}
|
|
40393
|
+
function meanDiag(positions) {
|
|
40394
|
+
if (positions.length === 0) return 0;
|
|
40395
|
+
let sum = 0;
|
|
40396
|
+
for (const p of positions) sum += diag(p.bbox);
|
|
40397
|
+
return sum / positions.length;
|
|
40398
|
+
}
|
|
40399
|
+
function netDisplacement(track) {
|
|
40400
|
+
const first = track.positions[0];
|
|
40401
|
+
const last = track.positions[track.positions.length - 1];
|
|
40402
|
+
if (!first || !last) return {
|
|
40403
|
+
x: 0,
|
|
40404
|
+
y: 0
|
|
40405
|
+
};
|
|
40406
|
+
const a = bboxCentroid(first.bbox);
|
|
40407
|
+
const b = bboxCentroid(last.bbox);
|
|
40408
|
+
return {
|
|
40409
|
+
x: b.x - a.x,
|
|
40410
|
+
y: b.y - a.y
|
|
40411
|
+
};
|
|
40412
|
+
}
|
|
40413
|
+
function vecLen(v) {
|
|
40414
|
+
return Math.hypot(v.x, v.y);
|
|
40415
|
+
}
|
|
40416
|
+
function cosine(a, b) {
|
|
40417
|
+
const la = vecLen(a);
|
|
40418
|
+
const lb = vecLen(b);
|
|
40419
|
+
if (la === 0 || lb === 0) return 1;
|
|
40420
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40421
|
+
}
|
|
40422
|
+
function relativeMotion(track) {
|
|
40423
|
+
const mean = meanDiag(track.positions);
|
|
40424
|
+
if (mean <= 0) return 0;
|
|
40425
|
+
return vecLen(netDisplacement(track)) / mean;
|
|
40426
|
+
}
|
|
40427
|
+
/** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
|
|
40428
|
+
function pairTogether(a, b) {
|
|
40429
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen);
|
|
40430
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
|
|
40431
|
+
if (overlapEnd < overlapStart) return false;
|
|
40432
|
+
let joint = 0;
|
|
40433
|
+
let close = 0;
|
|
40434
|
+
for (let t = overlapStart; t <= overlapEnd; t += 500) {
|
|
40435
|
+
const pa = nearestPosition(a.positions, t);
|
|
40436
|
+
const pb = nearestPosition(b.positions, t);
|
|
40437
|
+
if (!pa || !pb) continue;
|
|
40438
|
+
joint++;
|
|
40439
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40440
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40441
|
+
const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40442
|
+
const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
|
|
40443
|
+
if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
|
|
40444
|
+
}
|
|
40445
|
+
if (joint === 0) return false;
|
|
40446
|
+
if (close / joint < .5) return false;
|
|
40447
|
+
const aMoves = relativeMotion(a) >= MOVE_EPS;
|
|
40448
|
+
const bMoves = relativeMotion(b) >= MOVE_EPS;
|
|
40449
|
+
if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
|
|
40450
|
+
return true;
|
|
40451
|
+
}
|
|
40452
|
+
function clusterGroups(tracks) {
|
|
40453
|
+
const eligible = tracks.filter(isEligibleMember);
|
|
40454
|
+
const out = [];
|
|
40455
|
+
for (const session of sessionsOf(eligible)) {
|
|
40456
|
+
const pairable = [];
|
|
40457
|
+
for (const t of session) if (t.positions.length < 2) out.push({
|
|
40458
|
+
deviceId: t.deviceId,
|
|
40459
|
+
memberTrackIds: [t.trackId]
|
|
40460
|
+
});
|
|
40461
|
+
else pairable.push(t);
|
|
40462
|
+
if (pairable.length === 0) continue;
|
|
40463
|
+
const parent = pairable.map((_, i) => i);
|
|
40464
|
+
const find = (i) => {
|
|
40465
|
+
let cur = i;
|
|
40466
|
+
while (parent[cur] !== cur) cur = parent[cur];
|
|
40467
|
+
let walk = i;
|
|
40468
|
+
while (parent[walk] !== cur) {
|
|
40469
|
+
const next = parent[walk];
|
|
40470
|
+
parent[walk] = cur;
|
|
40471
|
+
walk = next;
|
|
40472
|
+
}
|
|
40473
|
+
return cur;
|
|
40474
|
+
};
|
|
40475
|
+
const union = (i, j) => {
|
|
40476
|
+
const ri = find(i);
|
|
40477
|
+
const rj = find(j);
|
|
40478
|
+
if (ri !== rj) parent[ri] = rj;
|
|
40479
|
+
};
|
|
40480
|
+
for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
|
|
40481
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
40482
|
+
for (let i = 0; i < pairable.length; i++) {
|
|
40483
|
+
const root = find(i);
|
|
40484
|
+
const bucket = buckets.get(root);
|
|
40485
|
+
const id = pairable[i].trackId;
|
|
40486
|
+
if (bucket) bucket.push(id);
|
|
40487
|
+
else buckets.set(root, [id]);
|
|
40488
|
+
}
|
|
40489
|
+
const deviceId = pairable[0].deviceId;
|
|
40490
|
+
for (const memberTrackIds of buckets.values()) out.push({
|
|
40491
|
+
deviceId,
|
|
40492
|
+
memberTrackIds
|
|
40493
|
+
});
|
|
40494
|
+
}
|
|
40495
|
+
return out;
|
|
40496
|
+
}
|
|
40497
|
+
//#endregion
|
|
40498
|
+
//#region src/pipeline-analytics/group/group-instant.ts
|
|
40499
|
+
/**
|
|
40500
|
+
* Chosen instant + union bbox for a group, at close, over stored positions[]
|
|
40501
|
+
* (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
|
|
40502
|
+
*
|
|
40503
|
+
* Tier order — the first that separates two candidates decides:
|
|
40504
|
+
* 1. completeness (members with a position within ±1 s)
|
|
40505
|
+
* 2. square containment of the union (D212)
|
|
40506
|
+
* 3. semantic (a member's confirmed recognition)
|
|
40507
|
+
* 4. edge-clear (union touches no frame border)
|
|
40508
|
+
* 5. effective score = mean(confidence) × containment × sizeFactor
|
|
40509
|
+
*
|
|
40510
|
+
* Retrospective. No new capture path.
|
|
40511
|
+
*/
|
|
40512
|
+
function nearest(positions, t, windowMs) {
|
|
40513
|
+
let best;
|
|
40514
|
+
let bestDt = windowMs + 1;
|
|
40515
|
+
for (const p of positions) {
|
|
40516
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40517
|
+
if (dt <= windowMs && dt < bestDt) {
|
|
40518
|
+
best = p;
|
|
40519
|
+
bestDt = dt;
|
|
40520
|
+
}
|
|
40521
|
+
}
|
|
40522
|
+
return best;
|
|
40523
|
+
}
|
|
40524
|
+
function unionOf(boxes) {
|
|
40525
|
+
if (boxes.length === 0) return null;
|
|
40526
|
+
let x0 = Infinity;
|
|
40527
|
+
let y0 = Infinity;
|
|
40528
|
+
let x1 = -Infinity;
|
|
40529
|
+
let y1 = -Infinity;
|
|
40530
|
+
for (const b of boxes) {
|
|
40531
|
+
x0 = Math.min(x0, b.x);
|
|
40532
|
+
y0 = Math.min(y0, b.y);
|
|
40533
|
+
x1 = Math.max(x1, b.x + b.w);
|
|
40534
|
+
y1 = Math.max(y1, b.y + b.h);
|
|
40535
|
+
}
|
|
40536
|
+
return {
|
|
40537
|
+
x: x0,
|
|
40538
|
+
y: y0,
|
|
40539
|
+
w: x1 - x0,
|
|
40540
|
+
h: y1 - y0
|
|
40541
|
+
};
|
|
40542
|
+
}
|
|
40543
|
+
function touchesBorder(box, frame, tol = 1) {
|
|
40544
|
+
return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
|
|
40545
|
+
}
|
|
40546
|
+
function scoreInstant(members, t, frame) {
|
|
40547
|
+
const boxes = [];
|
|
40548
|
+
let semantic = false;
|
|
40549
|
+
let confSum = 0;
|
|
40550
|
+
let confN = 0;
|
|
40551
|
+
for (const m of members) {
|
|
40552
|
+
const p = nearest(m.positions, t, MATCH_WINDOW_MS);
|
|
40553
|
+
if (!p) continue;
|
|
40554
|
+
boxes.push(p.bbox);
|
|
40555
|
+
if (m.semantic === true) semantic = true;
|
|
40556
|
+
if (m.confidence !== void 0) {
|
|
40557
|
+
confSum += m.confidence;
|
|
40558
|
+
confN += 1;
|
|
40559
|
+
}
|
|
40560
|
+
}
|
|
40561
|
+
const union = unionOf(boxes);
|
|
40562
|
+
if (!union) return null;
|
|
40563
|
+
const square = centralSquareContainmentFraction(union, {
|
|
40564
|
+
W: frame.width,
|
|
40565
|
+
H: frame.height
|
|
40566
|
+
});
|
|
40567
|
+
const containment = bboxContainment(union, frame.width, frame.height);
|
|
40568
|
+
const size = bboxSizeScore(union, frame.width, frame.height);
|
|
40569
|
+
const meanConf = confN > 0 ? confSum / confN : 1;
|
|
40570
|
+
return {
|
|
40571
|
+
timestamp: t,
|
|
40572
|
+
union,
|
|
40573
|
+
visibleCount: boxes.length,
|
|
40574
|
+
square,
|
|
40575
|
+
semantic,
|
|
40576
|
+
edgeClear: !touchesBorder(union, frame),
|
|
40577
|
+
effective: meanConf * containment * size
|
|
40578
|
+
};
|
|
40579
|
+
}
|
|
40580
|
+
function better(a, b) {
|
|
40581
|
+
if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
|
|
40582
|
+
if (a.square !== b.square) return a.square > b.square ? a : b;
|
|
40583
|
+
if (a.semantic !== b.semantic) return a.semantic ? a : b;
|
|
40584
|
+
if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
|
|
40585
|
+
if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
|
|
40586
|
+
return a.timestamp <= b.timestamp ? a : b;
|
|
40587
|
+
}
|
|
40588
|
+
function chooseGroupInstant(members, frame) {
|
|
40589
|
+
if (members.length === 0) return null;
|
|
40590
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
40591
|
+
for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
|
|
40592
|
+
let best = null;
|
|
40593
|
+
for (const t of candidates) {
|
|
40594
|
+
const scored = scoreInstant(members, t, frame);
|
|
40595
|
+
if (!scored) continue;
|
|
40596
|
+
best = best === null ? scored : better(scored, best);
|
|
40597
|
+
}
|
|
40598
|
+
if (!best) return null;
|
|
40599
|
+
const w = Math.max(frame.width, 1);
|
|
40600
|
+
const h = Math.max(frame.height, 1);
|
|
40601
|
+
return {
|
|
40602
|
+
timestamp: best.timestamp,
|
|
40603
|
+
union: best.union,
|
|
40604
|
+
ux: best.union.x / w,
|
|
40605
|
+
uy: best.union.y / h,
|
|
40606
|
+
uw: best.union.w / w,
|
|
40607
|
+
uh: best.union.h / h,
|
|
40608
|
+
visibleCount: best.visibleCount
|
|
40609
|
+
};
|
|
40610
|
+
}
|
|
40611
|
+
/** Longest-lived member that owns a clean full frame; ties break on trackId. */
|
|
40612
|
+
function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
40613
|
+
const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
|
|
40614
|
+
if (eligible.length === 0) return null;
|
|
40615
|
+
return eligible.toSorted((a, b) => {
|
|
40616
|
+
const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
|
|
40617
|
+
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40618
|
+
})[0].trackId;
|
|
40619
|
+
}
|
|
40620
|
+
//#endregion
|
|
40621
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40622
|
+
/**
|
|
40623
|
+
* Session close → sealed groups. A group is born here, never live.
|
|
40624
|
+
*
|
|
40625
|
+
* Session = temporal chain + 3 s slack, per camera. Close = every overlapping
|
|
40626
|
+
* track is persisted AND the newest lastSeen is older than the idle cutoff
|
|
40627
|
+
* (the Phase-1 burst's 30 s, reused — not a second clock).
|
|
40628
|
+
*
|
|
40629
|
+
* Merge and split cannot occur: connected components of a closed session is a
|
|
40630
|
+
* partition. Say it out loud so a later live-group attempt does not reach
|
|
40631
|
+
* for them here.
|
|
40632
|
+
*/
|
|
40633
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40634
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40635
|
+
function overlaps(a, b) {
|
|
40636
|
+
return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
|
|
40637
|
+
}
|
|
40638
|
+
function sessionSpan(tracks) {
|
|
40639
|
+
return {
|
|
40640
|
+
firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
|
|
40641
|
+
lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
|
|
40642
|
+
};
|
|
40643
|
+
}
|
|
40644
|
+
/**
|
|
40645
|
+
* Sessions whose members are all in `closed` and whose newest lastSeen is
|
|
40646
|
+
* older than `idleMs`. An overlapping ACTIVE track keeps the session open.
|
|
40647
|
+
*/
|
|
40648
|
+
function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40649
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
40650
|
+
for (const t of closed) {
|
|
40651
|
+
const list = byDevice.get(t.deviceId);
|
|
40652
|
+
if (list) list.push(t);
|
|
40653
|
+
else byDevice.set(t.deviceId, [t]);
|
|
40654
|
+
}
|
|
40655
|
+
const ready = [];
|
|
40656
|
+
for (const [deviceId, tracks] of byDevice) {
|
|
40657
|
+
const deviceActive = active.filter((a) => a.deviceId === deviceId);
|
|
40658
|
+
for (const session of sessionsOf(tracks)) {
|
|
40659
|
+
const span = sessionSpan(session);
|
|
40660
|
+
if (now < span.lastSeen + idleMs) continue;
|
|
40661
|
+
if (deviceActive.some((a) => overlaps(span, a))) continue;
|
|
40662
|
+
ready.push(session);
|
|
40663
|
+
}
|
|
40664
|
+
}
|
|
40665
|
+
return ready;
|
|
40666
|
+
}
|
|
40667
|
+
function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40668
|
+
return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
|
|
40669
|
+
}
|
|
40670
|
+
//#endregion
|
|
40671
|
+
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
40672
|
+
var DEFAULT_FRAME = {
|
|
40673
|
+
width: 640,
|
|
40674
|
+
height: 360
|
|
40675
|
+
};
|
|
40676
|
+
var SEAL_LOOKBACK_MS = 10 * 6e4;
|
|
40677
|
+
var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
|
|
40678
|
+
var QUERY_LIMIT = 2e3;
|
|
40679
|
+
function toInstant(t) {
|
|
40680
|
+
return {
|
|
40681
|
+
trackId: t.trackId,
|
|
40682
|
+
deviceId: t.deviceId,
|
|
40683
|
+
className: t.className,
|
|
40684
|
+
firstSeen: t.firstSeen,
|
|
40685
|
+
lastSeen: t.lastSeen,
|
|
40686
|
+
positions: t.positions,
|
|
40687
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
40688
|
+
semantic: t.hasFace === true || t.hasPlate === true,
|
|
40689
|
+
confidence: t.importance
|
|
40690
|
+
};
|
|
40691
|
+
}
|
|
40692
|
+
var GroupSealer = class {
|
|
40693
|
+
deps;
|
|
40694
|
+
constructor(deps) {
|
|
40695
|
+
this.deps = deps;
|
|
40696
|
+
}
|
|
40697
|
+
async sealRecent() {
|
|
40698
|
+
const now = this.deps.now();
|
|
40699
|
+
return this.sealSince(now - SEAL_LOOKBACK_MS, now);
|
|
40700
|
+
}
|
|
40701
|
+
async backfill() {
|
|
40702
|
+
const now = this.deps.now();
|
|
40703
|
+
return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
|
|
40704
|
+
}
|
|
40705
|
+
async sealSince(since, now) {
|
|
40706
|
+
let written = 0;
|
|
40707
|
+
let devices;
|
|
40708
|
+
try {
|
|
40709
|
+
devices = await this.deps.trackStore.listDeviceIds();
|
|
40710
|
+
} catch (err) {
|
|
40711
|
+
this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
|
|
40712
|
+
return 0;
|
|
40713
|
+
}
|
|
40714
|
+
for (const deviceId of devices) try {
|
|
40715
|
+
written += await this.sealDevice(deviceId, since, now);
|
|
40716
|
+
} catch (err) {
|
|
40717
|
+
this.deps.logger.warn("group sealer: device failed", {
|
|
40718
|
+
tags: { deviceId },
|
|
40719
|
+
meta: { error: String(err) }
|
|
40720
|
+
});
|
|
40721
|
+
}
|
|
40722
|
+
return written;
|
|
40723
|
+
}
|
|
40724
|
+
async sealDevice(deviceId, since, now) {
|
|
40725
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
40726
|
+
deviceId,
|
|
40727
|
+
since,
|
|
40728
|
+
until: now,
|
|
40729
|
+
limit: QUERY_LIMIT,
|
|
40730
|
+
projection: "full"
|
|
40731
|
+
});
|
|
40732
|
+
if (closed.length === 0) return 0;
|
|
40733
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
40734
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
40735
|
+
if (pending.length === 0) return 0;
|
|
40736
|
+
const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
|
|
40737
|
+
deviceId,
|
|
40738
|
+
firstSeen: t.firstSeen,
|
|
40739
|
+
lastSeen: t.lastSeen
|
|
40740
|
+
}));
|
|
40741
|
+
const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
|
|
40742
|
+
if (partitions.length === 0) {
|
|
40743
|
+
if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
|
|
40744
|
+
tags: { deviceId },
|
|
40745
|
+
meta: {
|
|
40746
|
+
dropped: pending.length,
|
|
40747
|
+
reason: "no-eligible-members"
|
|
40748
|
+
}
|
|
40749
|
+
});
|
|
40750
|
+
return 0;
|
|
40751
|
+
}
|
|
40752
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40753
|
+
let written = 0;
|
|
40754
|
+
for (const part of partitions) {
|
|
40755
|
+
const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
40756
|
+
if (members.length === 0) continue;
|
|
40757
|
+
if (members.every((m) => already.has(m.trackId))) continue;
|
|
40758
|
+
try {
|
|
40759
|
+
await this.persistPartition(members);
|
|
40760
|
+
written += 1;
|
|
40761
|
+
} catch (err) {
|
|
40762
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
40763
|
+
tags: { deviceId },
|
|
40764
|
+
meta: {
|
|
40765
|
+
members: members.map((m) => m.trackId),
|
|
40766
|
+
error: String(err)
|
|
40767
|
+
}
|
|
40768
|
+
});
|
|
40769
|
+
}
|
|
40770
|
+
}
|
|
40771
|
+
return written;
|
|
40772
|
+
}
|
|
40773
|
+
async persistPartition(members) {
|
|
40774
|
+
const deviceId = members[0].deviceId;
|
|
40775
|
+
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
40776
|
+
const instantMembers = members.map(toInstant);
|
|
40777
|
+
const instant = chooseGroupInstant(instantMembers, frame);
|
|
40778
|
+
const openedAt = Math.min(...members.map((m) => m.firstSeen));
|
|
40779
|
+
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
40780
|
+
const timestamp = instant?.timestamp ?? closedAt;
|
|
40781
|
+
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
40782
|
+
trackId: m.trackId,
|
|
40783
|
+
rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
|
|
40784
|
+
})));
|
|
40785
|
+
const ownsClean = (trackId) => {
|
|
40786
|
+
return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
|
|
40787
|
+
};
|
|
40788
|
+
const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
|
|
40789
|
+
let nativeKey = null;
|
|
40790
|
+
if (anchorTrackId !== null) {
|
|
40791
|
+
const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
|
|
40792
|
+
nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
|
|
40793
|
+
}
|
|
40794
|
+
const draft = await this.deps.groupStore.insert({
|
|
40795
|
+
deviceId,
|
|
40796
|
+
timestamp,
|
|
40797
|
+
openedAt,
|
|
40798
|
+
closedAt,
|
|
40799
|
+
members: members.map((m) => ({
|
|
40800
|
+
trackId: m.trackId,
|
|
40801
|
+
firstSeen: m.firstSeen
|
|
40802
|
+
})),
|
|
40803
|
+
anchorTrackId,
|
|
40804
|
+
ux: instant?.ux ?? 0,
|
|
40805
|
+
uy: instant?.uy ?? 0,
|
|
40806
|
+
uw: instant?.uw ?? 0,
|
|
40807
|
+
uh: instant?.uh ?? 0,
|
|
40808
|
+
frameWidth: frame.width,
|
|
40809
|
+
frameHeight: frame.height,
|
|
40810
|
+
mediaKey: null,
|
|
40811
|
+
nativeKey
|
|
40812
|
+
});
|
|
40813
|
+
if (members.length < 2 || instant === null) return;
|
|
40814
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp);
|
|
40815
|
+
if (source === null) return;
|
|
40816
|
+
try {
|
|
40817
|
+
const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
40818
|
+
if (raw.info.channels !== 3) return;
|
|
40819
|
+
const union = {
|
|
40820
|
+
x: instant.ux * raw.info.width,
|
|
40821
|
+
y: instant.uy * raw.info.height,
|
|
40822
|
+
w: instant.uw * raw.info.width,
|
|
40823
|
+
h: instant.uh * raw.info.height
|
|
40824
|
+
};
|
|
40825
|
+
const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
|
|
40826
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
40827
|
+
deviceId,
|
|
40828
|
+
ownerKind: "group",
|
|
40829
|
+
ownerId: draft.id,
|
|
40830
|
+
kind: "thumbnail",
|
|
40831
|
+
timestamp,
|
|
40832
|
+
data: crop
|
|
40833
|
+
});
|
|
40834
|
+
await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
|
|
40835
|
+
} catch (err) {
|
|
40836
|
+
this.deps.logger.warn("group sealer: crop failed", {
|
|
40837
|
+
tags: { deviceId },
|
|
40838
|
+
meta: {
|
|
40839
|
+
groupId: draft.id,
|
|
40840
|
+
error: String(err)
|
|
40841
|
+
}
|
|
40842
|
+
});
|
|
40843
|
+
}
|
|
40844
|
+
}
|
|
40845
|
+
async pickCleanPixels(mediaInfos, instantMs) {
|
|
40846
|
+
const candidates = [];
|
|
40847
|
+
for (const info of mediaInfos) for (const row of info.rows) {
|
|
40848
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40849
|
+
if (rank < 0) continue;
|
|
40850
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40851
|
+
if (dt > 2e3) continue;
|
|
40852
|
+
candidates.push({
|
|
40853
|
+
key: row.key,
|
|
40854
|
+
dt,
|
|
40855
|
+
rank
|
|
40856
|
+
});
|
|
40857
|
+
}
|
|
40858
|
+
const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40859
|
+
if (chosen === void 0) return null;
|
|
40860
|
+
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
40861
|
+
if (file === null) return null;
|
|
40862
|
+
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
40863
|
+
}
|
|
40864
|
+
};
|
|
40865
|
+
//#endregion
|
|
40866
|
+
//#region src/pipeline-analytics/group/group-store.ts
|
|
40867
|
+
/**
|
|
40868
|
+
* The group ENTITY store — a co-moving partition of one session, with one
|
|
40869
|
+
* picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
|
|
40870
|
+
*
|
|
40871
|
+
* Two tables, two questions:
|
|
40872
|
+
*
|
|
40873
|
+
* - `pipeline-analytics:track-groups` is the group row. It has N members and
|
|
40874
|
+
* no `trackId` column; it is owned by its members (owner-indirection).
|
|
40875
|
+
* - `pipeline-analytics:track-group-members` is one row per member. The column
|
|
40876
|
+
* is named exactly `trackId` so the classification derives `track-owned`.
|
|
40877
|
+
*
|
|
40878
|
+
* `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
|
|
40879
|
+
* they are nulled when the anchor member is deleted alone. Everything else is
|
|
40880
|
+
* written once. Re-anchoring is deliberately not done.
|
|
40881
|
+
*
|
|
40882
|
+
* No age clock. A group dies when its last member does; a group with one live
|
|
40883
|
+
* member does not, and it never keeps a dead link.
|
|
40884
|
+
*/
|
|
40885
|
+
/**
|
|
40886
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
40887
|
+
* write="one row per co-moving group at session close — sealed, never live.
|
|
40888
|
+
* Membership is a partition of one camera's eligible tracks. mediaKey is
|
|
40889
|
+
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
40890
|
+
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
40891
|
+
* nativeKey are the only fields rewritten after birth."
|
|
40892
|
+
* retention="NO age clock (D61). Rows leave with their last member through
|
|
40893
|
+
* TrackGroupStore.deleteByTracks: memberships first; zero survivors
|
|
40894
|
+
* deletes the group's media then the row; survivors plus a lost anchor
|
|
40895
|
+
* null the pointer. The orphan audit verifies the same via a second
|
|
40896
|
+
* resolver (groupId → any member still present)."
|
|
40897
|
+
*/
|
|
40898
|
+
var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
|
|
40899
|
+
/**
|
|
40900
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
40901
|
+
* write="one row per member at group birth — id is '<groupId>:<trackId>'.
|
|
40902
|
+
* timestamp is the member's firstSeen so the orphan audit has a clock
|
|
40903
|
+
* column. Never rewritten."
|
|
40904
|
+
* retention="TRACK-OWNED and clockless (D61): the column is named exactly
|
|
40905
|
+
* trackId, so classification derives ownership and the cascade deletes
|
|
40906
|
+
* the row with its track. A group with surviving members stays."
|
|
40907
|
+
*/
|
|
40908
|
+
var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
|
|
40909
|
+
var TRACK_GROUPS_COLUMNS = [
|
|
40910
|
+
{
|
|
40911
|
+
name: "id",
|
|
40912
|
+
type: "TEXT",
|
|
40913
|
+
primaryKey: true,
|
|
40914
|
+
notNull: true
|
|
40915
|
+
},
|
|
40916
|
+
{
|
|
40917
|
+
name: "deviceId",
|
|
40918
|
+
type: "INTEGER",
|
|
40919
|
+
notNull: true
|
|
40920
|
+
},
|
|
40921
|
+
{
|
|
40922
|
+
name: "timestamp",
|
|
40923
|
+
type: "INTEGER",
|
|
40924
|
+
notNull: true
|
|
40925
|
+
},
|
|
40926
|
+
{
|
|
40927
|
+
name: "openedAt",
|
|
40928
|
+
type: "INTEGER",
|
|
40929
|
+
notNull: true
|
|
40930
|
+
},
|
|
40931
|
+
{
|
|
40932
|
+
name: "closedAt",
|
|
40933
|
+
type: "INTEGER",
|
|
40934
|
+
notNull: true
|
|
40935
|
+
},
|
|
40936
|
+
{
|
|
40937
|
+
name: "memberCount",
|
|
40938
|
+
type: "INTEGER",
|
|
40939
|
+
notNull: true
|
|
40940
|
+
},
|
|
40941
|
+
{
|
|
40942
|
+
name: "anchorTrackId",
|
|
40943
|
+
type: "TEXT"
|
|
40944
|
+
},
|
|
40945
|
+
{
|
|
40946
|
+
name: "ux",
|
|
40947
|
+
type: "REAL",
|
|
40948
|
+
notNull: true
|
|
40949
|
+
},
|
|
40950
|
+
{
|
|
40951
|
+
name: "uy",
|
|
40952
|
+
type: "REAL",
|
|
40953
|
+
notNull: true
|
|
40954
|
+
},
|
|
40955
|
+
{
|
|
40956
|
+
name: "uw",
|
|
40957
|
+
type: "REAL",
|
|
40958
|
+
notNull: true
|
|
40959
|
+
},
|
|
40960
|
+
{
|
|
40961
|
+
name: "uh",
|
|
40962
|
+
type: "REAL",
|
|
40963
|
+
notNull: true
|
|
40964
|
+
},
|
|
40965
|
+
{
|
|
40966
|
+
name: "frameWidth",
|
|
40967
|
+
type: "INTEGER",
|
|
40968
|
+
notNull: true
|
|
40969
|
+
},
|
|
40970
|
+
{
|
|
40971
|
+
name: "frameHeight",
|
|
40972
|
+
type: "INTEGER",
|
|
40973
|
+
notNull: true
|
|
40974
|
+
},
|
|
40975
|
+
{
|
|
40976
|
+
name: "mediaKey",
|
|
40977
|
+
type: "TEXT"
|
|
40978
|
+
},
|
|
40979
|
+
{
|
|
40980
|
+
name: "nativeKey",
|
|
40981
|
+
type: "TEXT"
|
|
40982
|
+
},
|
|
40983
|
+
{
|
|
40984
|
+
name: "sealed",
|
|
40985
|
+
type: "BOOLEAN",
|
|
40986
|
+
notNull: true
|
|
40987
|
+
}
|
|
40988
|
+
];
|
|
40989
|
+
var TRACK_GROUP_MEMBERS_COLUMNS = [
|
|
40990
|
+
{
|
|
40991
|
+
name: "id",
|
|
40992
|
+
type: "TEXT",
|
|
40993
|
+
primaryKey: true,
|
|
40994
|
+
notNull: true
|
|
40995
|
+
},
|
|
40996
|
+
{
|
|
40997
|
+
name: "groupId",
|
|
40998
|
+
type: "TEXT",
|
|
40999
|
+
notNull: true
|
|
41000
|
+
},
|
|
41001
|
+
{
|
|
41002
|
+
name: "trackId",
|
|
41003
|
+
type: "TEXT",
|
|
41004
|
+
notNull: true
|
|
41005
|
+
},
|
|
41006
|
+
{
|
|
41007
|
+
name: "deviceId",
|
|
41008
|
+
type: "INTEGER",
|
|
41009
|
+
notNull: true
|
|
41010
|
+
},
|
|
41011
|
+
{
|
|
41012
|
+
name: "timestamp",
|
|
41013
|
+
type: "INTEGER",
|
|
41014
|
+
notNull: true
|
|
41015
|
+
}
|
|
41016
|
+
];
|
|
41017
|
+
var TRACK_GROUPS_INDEXES = [{
|
|
41018
|
+
name: "idx_track_groups_device_closed",
|
|
41019
|
+
columns: ["deviceId", "closedAt"]
|
|
41020
|
+
}, {
|
|
41021
|
+
name: "idx_track_groups_device_opened",
|
|
41022
|
+
columns: ["deviceId", "openedAt"]
|
|
41023
|
+
}];
|
|
41024
|
+
var TRACK_GROUP_MEMBERS_INDEXES = [{
|
|
41025
|
+
name: "idx_track_group_members_track",
|
|
41026
|
+
columns: ["trackId"]
|
|
41027
|
+
}, {
|
|
41028
|
+
name: "idx_track_group_members_group",
|
|
41029
|
+
columns: ["groupId"]
|
|
41030
|
+
}];
|
|
41031
|
+
function newGroupId() {
|
|
41032
|
+
return `grp-${randomUUID()}`;
|
|
41033
|
+
}
|
|
41034
|
+
function memberRowId(groupId, trackId) {
|
|
41035
|
+
return `${groupId}:${trackId}`;
|
|
41036
|
+
}
|
|
41037
|
+
var TrackGroupStore = class {
|
|
41038
|
+
store;
|
|
41039
|
+
logger;
|
|
41040
|
+
media;
|
|
41041
|
+
constructor(deps) {
|
|
41042
|
+
this.store = deps.store;
|
|
41043
|
+
this.logger = deps.logger;
|
|
41044
|
+
this.media = deps.media ?? null;
|
|
41045
|
+
}
|
|
41046
|
+
static async declare(store) {
|
|
41047
|
+
await Promise.all([store.declareCollection.mutate({
|
|
41048
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41049
|
+
columns: [...TRACK_GROUPS_COLUMNS],
|
|
41050
|
+
indexes: [...TRACK_GROUPS_INDEXES]
|
|
41051
|
+
}), store.declareCollection.mutate({
|
|
41052
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41053
|
+
columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
|
|
41054
|
+
indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
|
|
41055
|
+
})]);
|
|
41056
|
+
}
|
|
41057
|
+
async insert(input) {
|
|
41058
|
+
const id = newGroupId();
|
|
41059
|
+
const group = {
|
|
41060
|
+
id,
|
|
41061
|
+
deviceId: input.deviceId,
|
|
41062
|
+
timestamp: input.timestamp,
|
|
41063
|
+
openedAt: input.openedAt,
|
|
41064
|
+
closedAt: input.closedAt,
|
|
41065
|
+
memberCount: input.members.length,
|
|
41066
|
+
anchorTrackId: input.anchorTrackId,
|
|
41067
|
+
ux: input.ux,
|
|
41068
|
+
uy: input.uy,
|
|
41069
|
+
uw: input.uw,
|
|
41070
|
+
uh: input.uh,
|
|
41071
|
+
frameWidth: input.frameWidth,
|
|
41072
|
+
frameHeight: input.frameHeight,
|
|
41073
|
+
mediaKey: input.mediaKey,
|
|
41074
|
+
nativeKey: input.nativeKey,
|
|
41075
|
+
sealed: true
|
|
41076
|
+
};
|
|
41077
|
+
await this.store.set.mutate({
|
|
41078
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41079
|
+
key: id,
|
|
41080
|
+
value: this.toRow(group)
|
|
41081
|
+
});
|
|
41082
|
+
for (const member of input.members) await this.store.set.mutate({
|
|
41083
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41084
|
+
key: memberRowId(id, member.trackId),
|
|
41085
|
+
value: {
|
|
41086
|
+
groupId: id,
|
|
41087
|
+
trackId: member.trackId,
|
|
41088
|
+
deviceId: input.deviceId,
|
|
41089
|
+
timestamp: member.firstSeen
|
|
41090
|
+
}
|
|
41091
|
+
});
|
|
41092
|
+
return group;
|
|
41093
|
+
}
|
|
41094
|
+
/** The crop is written after birth; this is the only post-birth write besides the pointer null. */
|
|
41095
|
+
async setMediaKey(groupId, mediaKey) {
|
|
41096
|
+
const current = await this.get(groupId);
|
|
41097
|
+
if (current === null) return;
|
|
41098
|
+
await this.store.set.mutate({
|
|
41099
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41100
|
+
key: groupId,
|
|
41101
|
+
value: this.toRow({
|
|
41102
|
+
...current,
|
|
41103
|
+
mediaKey
|
|
41104
|
+
})
|
|
41105
|
+
});
|
|
41106
|
+
}
|
|
41107
|
+
async get(groupId) {
|
|
41108
|
+
const raw = await this.store.get.query({
|
|
41109
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41110
|
+
key: groupId
|
|
41111
|
+
});
|
|
41112
|
+
if (!raw) return null;
|
|
41113
|
+
return this.normalizeGroup({
|
|
41114
|
+
id: groupId,
|
|
41115
|
+
data: raw
|
|
41116
|
+
});
|
|
41117
|
+
}
|
|
41118
|
+
async listMembers(groupId) {
|
|
41119
|
+
return (await this.store.query.query({
|
|
41120
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41121
|
+
filter: { where: { groupId } }
|
|
41122
|
+
})).map((r) => this.normalizeMember({
|
|
41123
|
+
id: r.id,
|
|
41124
|
+
data: r.data
|
|
41125
|
+
}));
|
|
41126
|
+
}
|
|
41127
|
+
async membersOfTracks(trackIds) {
|
|
41128
|
+
const out = [];
|
|
41129
|
+
for (const trackId of trackIds) {
|
|
41130
|
+
const rows = await this.store.query.query({
|
|
41131
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41132
|
+
filter: { where: { trackId } }
|
|
41133
|
+
});
|
|
41134
|
+
for (const r of rows) out.push(this.normalizeMember({
|
|
41135
|
+
id: r.id,
|
|
41136
|
+
data: r.data
|
|
41137
|
+
}));
|
|
41138
|
+
}
|
|
41139
|
+
return out;
|
|
41140
|
+
}
|
|
41141
|
+
/**
|
|
41142
|
+
* Newest-closed page for the Library Groups tab.
|
|
41143
|
+
*
|
|
41144
|
+
* Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
|
|
41145
|
+
* same as {@link listOverlapping}. Pagination is applied AFTER the window
|
|
41146
|
+
* so a day cursor is honest rather than a raw-row offset that skips live
|
|
41147
|
+
* groups on a busy camera.
|
|
41148
|
+
*/
|
|
41149
|
+
async listRecent(input) {
|
|
41150
|
+
const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
|
|
41151
|
+
const offset = Math.max(input.offset ?? 0, 0);
|
|
41152
|
+
const slice = (await this.store.query.query({
|
|
41153
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41154
|
+
filter: {
|
|
41155
|
+
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
41156
|
+
orderBy: {
|
|
41157
|
+
field: "closedAt",
|
|
41158
|
+
direction: "desc"
|
|
41159
|
+
},
|
|
41160
|
+
limit: 2e3
|
|
41161
|
+
}
|
|
41162
|
+
})).map((r) => this.normalizeGroup({
|
|
41163
|
+
id: r.id,
|
|
41164
|
+
data: r.data
|
|
41165
|
+
})).filter((g) => {
|
|
41166
|
+
if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
|
|
41167
|
+
if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
|
|
41168
|
+
return true;
|
|
41169
|
+
}).slice(offset, offset + limit + 1);
|
|
41170
|
+
return {
|
|
41171
|
+
groups: slice.slice(0, limit),
|
|
41172
|
+
hasMore: slice.length > limit
|
|
41173
|
+
};
|
|
41174
|
+
}
|
|
41175
|
+
/** Groups whose life overlaps `[since, until]` on one camera. */
|
|
41176
|
+
async listOverlapping(input) {
|
|
41177
|
+
return (await this.store.query.query({
|
|
41178
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41179
|
+
filter: {
|
|
41180
|
+
where: { deviceId: input.deviceId },
|
|
41181
|
+
orderBy: {
|
|
41182
|
+
field: "closedAt",
|
|
41183
|
+
direction: "desc"
|
|
41184
|
+
},
|
|
41185
|
+
limit: input.limit ?? 2e3
|
|
41186
|
+
}
|
|
41187
|
+
})).map((r) => this.normalizeGroup({
|
|
41188
|
+
id: r.id,
|
|
41189
|
+
data: r.data
|
|
41190
|
+
})).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
|
|
41191
|
+
}
|
|
41192
|
+
async existingGroupIds(ids) {
|
|
41193
|
+
const live = /* @__PURE__ */ new Set();
|
|
41194
|
+
for (const id of ids) if (await this.store.get.query({
|
|
41195
|
+
collection: "pipeline-analytics:track-groups",
|
|
41196
|
+
key: id
|
|
41197
|
+
})) live.add(id);
|
|
41198
|
+
return live;
|
|
41199
|
+
}
|
|
41200
|
+
/** Group ids that still have at least one membership row. */
|
|
41201
|
+
async groupsWithMembers(ids) {
|
|
41202
|
+
const live = /* @__PURE__ */ new Set();
|
|
41203
|
+
for (const groupId of ids) if ((await this.store.query.query({
|
|
41204
|
+
collection: "pipeline-analytics:track-group-members",
|
|
41205
|
+
filter: {
|
|
41206
|
+
where: { groupId },
|
|
41207
|
+
limit: 1
|
|
41208
|
+
}
|
|
41209
|
+
})).length > 0) live.add(groupId);
|
|
41210
|
+
return live;
|
|
41211
|
+
}
|
|
41212
|
+
/**
|
|
41213
|
+
* Cascade leaf: delete memberships for the batch; a group with zero survivors
|
|
41214
|
+
* dies (media then row); a group that lost only its anchor nulls the pointer.
|
|
41215
|
+
*/
|
|
41216
|
+
async deleteByTracks(trackIds) {
|
|
41217
|
+
const batch = new Set(trackIds);
|
|
41218
|
+
let memberRows = 0;
|
|
41219
|
+
let groupRows = 0;
|
|
41220
|
+
let mediaRows = 0;
|
|
41221
|
+
let mediaBytes = 0;
|
|
41222
|
+
const affected = /* @__PURE__ */ new Set();
|
|
41223
|
+
try {
|
|
41224
|
+
const memberships = await this.membersOfTracks(trackIds);
|
|
41225
|
+
for (const member of memberships) try {
|
|
41226
|
+
await this.store.delete.mutate({
|
|
41227
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41228
|
+
key: member.id
|
|
41229
|
+
});
|
|
41230
|
+
memberRows += 1;
|
|
41231
|
+
affected.add(member.groupId);
|
|
41232
|
+
} catch (err) {
|
|
41233
|
+
this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
|
|
41234
|
+
memberId: member.id,
|
|
41235
|
+
error: String(err)
|
|
41236
|
+
} });
|
|
41237
|
+
}
|
|
41238
|
+
for (const groupId of affected) try {
|
|
41239
|
+
if ((await this.listMembers(groupId)).length === 0) {
|
|
41240
|
+
if (this.media) {
|
|
41241
|
+
const gone = await this.media.deleteByGroups([groupId]);
|
|
41242
|
+
mediaRows += gone.rows;
|
|
41243
|
+
mediaBytes += gone.bytes;
|
|
41244
|
+
}
|
|
41245
|
+
await this.store.delete.mutate({
|
|
41246
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41247
|
+
key: groupId
|
|
41248
|
+
});
|
|
41249
|
+
groupRows += 1;
|
|
41250
|
+
continue;
|
|
41251
|
+
}
|
|
41252
|
+
const group = await this.get(groupId);
|
|
41253
|
+
if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
|
|
41254
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41255
|
+
key: groupId,
|
|
41256
|
+
value: this.toRow({
|
|
41257
|
+
...group,
|
|
41258
|
+
anchorTrackId: null,
|
|
41259
|
+
nativeKey: null
|
|
41260
|
+
})
|
|
41261
|
+
});
|
|
41262
|
+
} catch (err) {
|
|
41263
|
+
this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
|
|
41264
|
+
groupId,
|
|
41265
|
+
error: String(err)
|
|
41266
|
+
} });
|
|
41267
|
+
}
|
|
41268
|
+
} catch (err) {
|
|
41269
|
+
this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
|
|
41270
|
+
}
|
|
41271
|
+
return [{
|
|
41272
|
+
family: "groups",
|
|
41273
|
+
rows: memberRows + groupRows,
|
|
41274
|
+
bytes: 0
|
|
41275
|
+
}, {
|
|
41276
|
+
family: "media",
|
|
41277
|
+
rows: mediaRows,
|
|
41278
|
+
bytes: mediaBytes
|
|
41279
|
+
}];
|
|
41280
|
+
}
|
|
41281
|
+
toRow(group) {
|
|
41282
|
+
return {
|
|
41283
|
+
deviceId: group.deviceId,
|
|
41284
|
+
timestamp: group.timestamp,
|
|
41285
|
+
openedAt: group.openedAt,
|
|
41286
|
+
closedAt: group.closedAt,
|
|
41287
|
+
memberCount: group.memberCount,
|
|
41288
|
+
anchorTrackId: group.anchorTrackId,
|
|
41289
|
+
ux: group.ux,
|
|
41290
|
+
uy: group.uy,
|
|
41291
|
+
uw: group.uw,
|
|
41292
|
+
uh: group.uh,
|
|
41293
|
+
frameWidth: group.frameWidth,
|
|
41294
|
+
frameHeight: group.frameHeight,
|
|
41295
|
+
mediaKey: group.mediaKey,
|
|
41296
|
+
nativeKey: group.nativeKey,
|
|
41297
|
+
sealed: group.sealed
|
|
41298
|
+
};
|
|
41299
|
+
}
|
|
41300
|
+
normalizeGroup(r) {
|
|
41301
|
+
const data = r.data;
|
|
41302
|
+
return {
|
|
41303
|
+
id: r.id,
|
|
41304
|
+
deviceId: Number(data["deviceId"]),
|
|
41305
|
+
timestamp: Number(data["timestamp"]),
|
|
41306
|
+
openedAt: Number(data["openedAt"]),
|
|
41307
|
+
closedAt: Number(data["closedAt"]),
|
|
41308
|
+
memberCount: Number(data["memberCount"]),
|
|
41309
|
+
anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
|
|
41310
|
+
ux: Number(data["ux"]),
|
|
41311
|
+
uy: Number(data["uy"]),
|
|
41312
|
+
uw: Number(data["uw"]),
|
|
41313
|
+
uh: Number(data["uh"]),
|
|
41314
|
+
frameWidth: Number(data["frameWidth"]),
|
|
41315
|
+
frameHeight: Number(data["frameHeight"]),
|
|
41316
|
+
mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
|
|
41317
|
+
nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
|
|
41318
|
+
sealed: Boolean(data["sealed"])
|
|
41319
|
+
};
|
|
41320
|
+
}
|
|
41321
|
+
normalizeMember(r) {
|
|
41322
|
+
const data = r.data;
|
|
41323
|
+
return {
|
|
41324
|
+
id: r.id,
|
|
41325
|
+
groupId: String(data["groupId"]),
|
|
41326
|
+
trackId: String(data["trackId"]),
|
|
41327
|
+
deviceId: Number(data["deviceId"]),
|
|
41328
|
+
timestamp: Number(data["timestamp"])
|
|
41329
|
+
};
|
|
41330
|
+
}
|
|
41331
|
+
};
|
|
40072
41332
|
//#endregion
|
|
40073
41333
|
//#region src/pipeline-analytics/pipeline/dropout-skip.ts
|
|
40074
41334
|
function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
|
|
@@ -46686,6 +47946,15 @@ function isAfterCursor(row, cursor) {
|
|
|
46686
47946
|
if (row.lastSeen < cursor.lastSeen) return true;
|
|
46687
47947
|
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
46688
47948
|
}
|
|
47949
|
+
function netDisplacementPx(positions) {
|
|
47950
|
+
const first = positions[0];
|
|
47951
|
+
const last = positions[positions.length - 1];
|
|
47952
|
+
if (!first || !last) return 0;
|
|
47953
|
+
return Math.hypot(last.x - first.x, last.y - first.y);
|
|
47954
|
+
}
|
|
47955
|
+
function isShortMotionlessTrack(lifeMs, movedPx) {
|
|
47956
|
+
return lifeMs <= 25e3 && movedPx <= 8;
|
|
47957
|
+
}
|
|
46689
47958
|
//#endregion
|
|
46690
47959
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
46691
47960
|
/**
|
|
@@ -47268,6 +48537,7 @@ var TrackStore = class {
|
|
|
47268
48537
|
for (const [trackId, t] of this.active) {
|
|
47269
48538
|
if (nowMs - t.lastSeen < this.config.ttlMs) continue;
|
|
47270
48539
|
t.active = false;
|
|
48540
|
+
if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
|
|
47271
48541
|
const record = cloneTrack(t);
|
|
47272
48542
|
try {
|
|
47273
48543
|
await this.persistCompleted(record);
|
|
@@ -48816,12 +50086,24 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48816
50086
|
collection: PLATES_COLLECTION,
|
|
48817
50087
|
classification: derived(PLATE_COLUMNS)
|
|
48818
50088
|
},
|
|
50089
|
+
{
|
|
50090
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
50091
|
+
classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
|
|
50092
|
+
},
|
|
48819
50093
|
{
|
|
48820
50094
|
collection: MEDIA_COLLECTION,
|
|
48821
50095
|
classification: {
|
|
48822
50096
|
kind: "owner-indirection",
|
|
48823
50097
|
derivation: "explicit",
|
|
48824
|
-
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."
|
|
50098
|
+
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."
|
|
50099
|
+
}
|
|
50100
|
+
},
|
|
50101
|
+
{
|
|
50102
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
50103
|
+
classification: {
|
|
50104
|
+
kind: "owner-indirection",
|
|
50105
|
+
derivation: "explicit",
|
|
50106
|
+
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."
|
|
48825
50107
|
}
|
|
48826
50108
|
},
|
|
48827
50109
|
{
|
|
@@ -48933,7 +50215,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48933
50215
|
collection: SCENE_LATCH_COLLECTION,
|
|
48934
50216
|
classification: {
|
|
48935
50217
|
kind: "never-orphaned",
|
|
48936
|
-
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
|
|
50218
|
+
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`)."
|
|
48937
50219
|
}
|
|
48938
50220
|
},
|
|
48939
50221
|
{
|
|
@@ -49021,6 +50303,10 @@ function resolveMediaOwner(row) {
|
|
|
49021
50303
|
kind: "unresolvable",
|
|
49022
50304
|
why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
|
|
49023
50305
|
};
|
|
50306
|
+
case "group": return {
|
|
50307
|
+
kind: "group",
|
|
50308
|
+
groupId: ownerId
|
|
50309
|
+
};
|
|
49024
50310
|
default: return {
|
|
49025
50311
|
kind: "unresolvable",
|
|
49026
50312
|
why: `unknown ownerKind '${kindRaw}'`
|
|
@@ -49032,6 +50318,13 @@ function resolveMediaOwner(row) {
|
|
|
49032
50318
|
* is derived the same way the classification is — an enrolled row is out of
|
|
49033
50319
|
* reach regardless of what happened to the track that produced it.
|
|
49034
50320
|
*/
|
|
50321
|
+
/** A group row lives iff any membership row still names it. */
|
|
50322
|
+
function resolveGroupOwner(row) {
|
|
50323
|
+
return {
|
|
50324
|
+
kind: "group-members",
|
|
50325
|
+
groupId: row.key
|
|
50326
|
+
};
|
|
50327
|
+
}
|
|
49035
50328
|
function resolveDeclaredTrackOwner(row, enrolmentExempt) {
|
|
49036
50329
|
if (enrolmentExempt && row.data["assigned"] === true) return {
|
|
49037
50330
|
kind: "exempt",
|
|
@@ -49051,15 +50344,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
|
|
|
49051
50344
|
const scopes = [];
|
|
49052
50345
|
for (const entry of collections) {
|
|
49053
50346
|
if (entry.classification.kind === "owner-indirection") {
|
|
49054
|
-
if (entry.collection
|
|
49055
|
-
|
|
49056
|
-
|
|
49057
|
-
|
|
49058
|
-
|
|
49059
|
-
|
|
49060
|
-
|
|
49061
|
-
|
|
49062
|
-
|
|
50347
|
+
if (entry.collection === "pipeline-analytics:media") {
|
|
50348
|
+
scopes.push({
|
|
50349
|
+
id: entry.collection,
|
|
50350
|
+
collection: entry.collection,
|
|
50351
|
+
resolve: resolveMediaOwner,
|
|
50352
|
+
timestampField: "timestamp",
|
|
50353
|
+
sizeField: "sizeBytes",
|
|
50354
|
+
deviceIdField: "deviceId"
|
|
50355
|
+
});
|
|
50356
|
+
continue;
|
|
50357
|
+
}
|
|
50358
|
+
if (entry.collection === "pipeline-analytics:track-groups") {
|
|
50359
|
+
scopes.push({
|
|
50360
|
+
id: entry.collection,
|
|
50361
|
+
collection: entry.collection,
|
|
50362
|
+
resolve: resolveGroupOwner,
|
|
50363
|
+
timestampField: "timestamp",
|
|
50364
|
+
sizeField: null,
|
|
50365
|
+
deviceIdField: "deviceId"
|
|
50366
|
+
});
|
|
50367
|
+
continue;
|
|
50368
|
+
}
|
|
49063
50369
|
continue;
|
|
49064
50370
|
}
|
|
49065
50371
|
if (entry.classification.kind !== "track-owned") continue;
|
|
@@ -49091,11 +50397,17 @@ var DEFAULT_ORPHAN_LIMITS = {
|
|
|
49091
50397
|
function questionsFor(refs) {
|
|
49092
50398
|
const trackIds = /* @__PURE__ */ new Set();
|
|
49093
50399
|
const eventIds = /* @__PURE__ */ new Set();
|
|
50400
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
50401
|
+
const groupMemberIds = /* @__PURE__ */ new Set();
|
|
49094
50402
|
for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
|
|
49095
50403
|
else if (ref.kind === "event") eventIds.add(ref.eventId);
|
|
50404
|
+
else if (ref.kind === "group") groupIds.add(ref.groupId);
|
|
50405
|
+
else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
|
|
49096
50406
|
return {
|
|
49097
50407
|
trackIds: [...trackIds],
|
|
49098
|
-
eventIds: [...eventIds]
|
|
50408
|
+
eventIds: [...eventIds],
|
|
50409
|
+
groupIds: [...groupIds],
|
|
50410
|
+
groupMemberIds: [...groupMemberIds]
|
|
49099
50411
|
};
|
|
49100
50412
|
}
|
|
49101
50413
|
/**
|
|
@@ -49127,12 +50439,16 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49127
50439
|
break;
|
|
49128
50440
|
}
|
|
49129
50441
|
const refs = rows.map((row) => scope.resolve(row));
|
|
49130
|
-
const { trackIds, eventIds } = questionsFor(refs);
|
|
50442
|
+
const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
|
|
49131
50443
|
let liveTracks;
|
|
49132
50444
|
let liveEvents;
|
|
50445
|
+
let liveGroups;
|
|
50446
|
+
let liveGroupsWithMembers;
|
|
49133
50447
|
try {
|
|
49134
50448
|
liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
|
|
49135
50449
|
liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
|
|
50450
|
+
liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
|
|
50451
|
+
liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
|
|
49136
50452
|
} catch (err) {
|
|
49137
50453
|
deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
|
|
49138
50454
|
collection: scope.collection,
|
|
@@ -49156,7 +50472,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49156
50472
|
tally.unresolvable += 1;
|
|
49157
50473
|
continue;
|
|
49158
50474
|
}
|
|
49159
|
-
if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
|
|
50475
|
+
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;
|
|
49160
50476
|
const ts = num(row.data, scope.timestampField);
|
|
49161
50477
|
if (ts !== null && ts > graceFloor) {
|
|
49162
50478
|
tally.withinGrace += 1;
|
|
@@ -51248,6 +52564,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
|
51248
52564
|
};
|
|
51249
52565
|
}
|
|
51250
52566
|
//#endregion
|
|
52567
|
+
//#region src/pipeline-analytics/pipeline/usable-bbox.ts
|
|
52568
|
+
/** A box that can legally drive a crop. The leaving-event fallback
|
|
52569
|
+
* `{0,0,0,0}` must never become a face/plate JPEG. */
|
|
52570
|
+
function isUsableBbox(bbox) {
|
|
52571
|
+
return bbox.w > 0 && bbox.h > 0;
|
|
52572
|
+
}
|
|
52573
|
+
//#endregion
|
|
51251
52574
|
//#region src/pipeline-analytics/services/event-media-dispatcher.ts
|
|
51252
52575
|
/**
|
|
51253
52576
|
* Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
|
|
@@ -51826,26 +53149,29 @@ var EventMediaDispatcher = class {
|
|
|
51826
53149
|
}
|
|
51827
53150
|
}
|
|
51828
53151
|
async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
|
|
51829
|
-
if (ev.childCrops) for (const child of ev.childCrops)
|
|
51830
|
-
|
|
51831
|
-
|
|
51832
|
-
|
|
51833
|
-
|
|
51834
|
-
|
|
51835
|
-
ownerId: ev.eventId,
|
|
51836
|
-
kind: child.kind,
|
|
51837
|
-
timestamp: ev.timestamp,
|
|
51838
|
-
data: childCropData
|
|
51839
|
-
});
|
|
51840
|
-
} catch (err) {
|
|
51841
|
-
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
51842
|
-
tags: { deviceId },
|
|
51843
|
-
meta: {
|
|
53152
|
+
if (ev.childCrops) for (const child of ev.childCrops) {
|
|
53153
|
+
if (!isUsableBbox(child.bbox)) continue;
|
|
53154
|
+
try {
|
|
53155
|
+
const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
|
|
53156
|
+
if (!childCropData) continue;
|
|
53157
|
+
await this.deps.mediaStore.put({
|
|
51844
53158
|
deviceId,
|
|
51845
|
-
|
|
51846
|
-
|
|
51847
|
-
|
|
51848
|
-
|
|
53159
|
+
ownerKind: "event",
|
|
53160
|
+
ownerId: ev.eventId,
|
|
53161
|
+
kind: child.kind,
|
|
53162
|
+
timestamp: ev.timestamp,
|
|
53163
|
+
data: childCropData
|
|
53164
|
+
});
|
|
53165
|
+
} catch (err) {
|
|
53166
|
+
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
53167
|
+
tags: { deviceId },
|
|
53168
|
+
meta: {
|
|
53169
|
+
deviceId,
|
|
53170
|
+
eventId: ev.eventId,
|
|
53171
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53172
|
+
}
|
|
53173
|
+
});
|
|
53174
|
+
}
|
|
51849
53175
|
}
|
|
51850
53176
|
}
|
|
51851
53177
|
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
@@ -55106,7 +56432,8 @@ var customActions = {
|
|
|
55106
56432
|
...orphanAuditActions,
|
|
55107
56433
|
...viewerSettingsActions,
|
|
55108
56434
|
...photoEnrollActions,
|
|
55109
|
-
...faceRescoreActions
|
|
56435
|
+
...faceRescoreActions,
|
|
56436
|
+
...groupActions
|
|
55110
56437
|
};
|
|
55111
56438
|
/**
|
|
55112
56439
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -55285,11 +56612,6 @@ function decodeEmbeddingBase64(base64) {
|
|
|
55285
56612
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
55286
56613
|
return Array.from(view);
|
|
55287
56614
|
}
|
|
55288
|
-
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
55289
|
-
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
55290
|
-
var SHORT_TRACK_MAX_MS = 25e3;
|
|
55291
|
-
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
55292
|
-
var MOTIONLESS_MAX_PX = 8;
|
|
55293
56615
|
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
55294
56616
|
* to the pixel still land in one cell. */
|
|
55295
56617
|
var PHANTOM_CELL_PX = 32;
|
|
@@ -55353,6 +56675,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
55353
56675
|
faceStore = null;
|
|
55354
56676
|
faceRecognizer = null;
|
|
55355
56677
|
plateStore = null;
|
|
56678
|
+
groupStore = null;
|
|
56679
|
+
groupSealer = null;
|
|
55356
56680
|
plateRecognizer = null;
|
|
55357
56681
|
objectEmbeddingStore = null;
|
|
55358
56682
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -56170,7 +57494,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56170
57494
|
...orphanAuditActions,
|
|
56171
57495
|
...viewerSettingsActions,
|
|
56172
57496
|
...photoEnrollActions,
|
|
56173
|
-
...faceRescoreActions
|
|
57497
|
+
...faceRescoreActions,
|
|
57498
|
+
...groupActions
|
|
56174
57499
|
},
|
|
56175
57500
|
actionHandlers: {
|
|
56176
57501
|
...ncHandlers,
|
|
@@ -56178,6 +57503,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56178
57503
|
...this.buildDebugActionHandlers(),
|
|
56179
57504
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56180
57505
|
...this.buildFaceRescoreActionHandlers(),
|
|
57506
|
+
...this.buildGroupActionHandlers(),
|
|
56181
57507
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
56182
57508
|
}
|
|
56183
57509
|
} : {}
|
|
@@ -56380,6 +57706,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56380
57706
|
SceneStore.declare(api.settingsStore),
|
|
56381
57707
|
NotificationCenter.declare(api.settingsStore),
|
|
56382
57708
|
ViewerSettingsSnapshotStore.declare(api.settingsStore),
|
|
57709
|
+
TrackGroupStore.declare(api.settingsStore),
|
|
56383
57710
|
declareMigrationMarkers(api.settingsStore)
|
|
56384
57711
|
]);
|
|
56385
57712
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
@@ -56664,6 +57991,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56664
57991
|
logger: logger.child("PlateStore")
|
|
56665
57992
|
});
|
|
56666
57993
|
this.plateStore = plateStore;
|
|
57994
|
+
const vehicleStore = new VehicleStore({
|
|
57995
|
+
store: api.settingsStore,
|
|
57996
|
+
logger: logger.child("VehicleStore")
|
|
57997
|
+
});
|
|
57998
|
+
const groupStore = new TrackGroupStore({
|
|
57999
|
+
store: api.settingsStore,
|
|
58000
|
+
logger: logger.child("TrackGroupStore"),
|
|
58001
|
+
media: mediaStore
|
|
58002
|
+
});
|
|
58003
|
+
this.groupStore = groupStore;
|
|
58004
|
+
this.groupSealer = new GroupSealer({
|
|
58005
|
+
trackStore,
|
|
58006
|
+
groupStore,
|
|
58007
|
+
mediaStore,
|
|
58008
|
+
logger: logger.child("GroupSealer"),
|
|
58009
|
+
frameDims: (deviceId) => {
|
|
58010
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
58011
|
+
return dims ? {
|
|
58012
|
+
width: dims.w,
|
|
58013
|
+
height: dims.h
|
|
58014
|
+
} : void 0;
|
|
58015
|
+
},
|
|
58016
|
+
now: () => Date.now()
|
|
58017
|
+
});
|
|
58018
|
+
this.groupSealer.backfill().catch((err) => {
|
|
58019
|
+
logger.warn("group sealer backfill failed", { meta: { error: errMsg(err) } });
|
|
58020
|
+
});
|
|
56667
58021
|
return {
|
|
56668
58022
|
trackStore,
|
|
56669
58023
|
mediaStore,
|
|
@@ -56671,10 +58025,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56671
58025
|
identityStore,
|
|
56672
58026
|
faceStore,
|
|
56673
58027
|
plateStore,
|
|
56674
|
-
vehicleStore
|
|
56675
|
-
|
|
56676
|
-
logger: logger.child("VehicleStore")
|
|
56677
|
-
})
|
|
58028
|
+
vehicleStore,
|
|
58029
|
+
groupStore
|
|
56678
58030
|
};
|
|
56679
58031
|
}
|
|
56680
58032
|
/** Face + plate recognizers, the durable keyFrame-derived plate crop and the
|
|
@@ -57108,29 +58460,65 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
57108
58460
|
buildSummaryPorts(stores) {
|
|
57109
58461
|
return {
|
|
57110
58462
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
57111
|
-
const
|
|
58463
|
+
const groups = await stores.groupStore.listOverlapping({
|
|
58464
|
+
deviceId,
|
|
58465
|
+
sinceMs,
|
|
58466
|
+
untilMs,
|
|
58467
|
+
limit
|
|
58468
|
+
});
|
|
58469
|
+
const windowTracks = await stores.trackStore.queryHistorical({
|
|
57112
58470
|
deviceId,
|
|
57113
58471
|
since: sinceMs,
|
|
57114
58472
|
until: untilMs,
|
|
57115
58473
|
projection: "slim",
|
|
57116
|
-
limit
|
|
58474
|
+
limit: Math.max(limit, 2e3)
|
|
57117
58475
|
});
|
|
58476
|
+
const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
|
|
57118
58477
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
57119
|
-
|
|
57120
|
-
|
|
57121
|
-
|
|
57122
|
-
|
|
57123
|
-
|
|
57124
|
-
|
|
57125
|
-
|
|
57126
|
-
|
|
57127
|
-
|
|
57128
|
-
|
|
57129
|
-
|
|
57130
|
-
|
|
58478
|
+
const out = [];
|
|
58479
|
+
for (const group of groups) {
|
|
58480
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58481
|
+
const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
|
|
58482
|
+
if (tracks.length === 0) continue;
|
|
58483
|
+
const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
|
|
58484
|
+
const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
|
|
58485
|
+
const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
|
|
58486
|
+
const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
|
|
58487
|
+
out.push({
|
|
58488
|
+
trackId: group.id,
|
|
58489
|
+
groupId: group.id,
|
|
58490
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
58491
|
+
deviceId,
|
|
58492
|
+
className: primary.className,
|
|
58493
|
+
firstSeen: group.openedAt,
|
|
58494
|
+
lastSeen: group.closedAt,
|
|
58495
|
+
importance,
|
|
58496
|
+
zones,
|
|
58497
|
+
classes,
|
|
58498
|
+
...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
|
|
58499
|
+
confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
|
|
58500
|
+
});
|
|
58501
|
+
}
|
|
58502
|
+
return out;
|
|
57131
58503
|
},
|
|
57132
|
-
listMediaInfo: async (
|
|
57133
|
-
|
|
58504
|
+
listMediaInfo: async (id) => {
|
|
58505
|
+
const group = await stores.groupStore.get(id);
|
|
58506
|
+
if (group?.mediaKey) return [{
|
|
58507
|
+
key: group.mediaKey,
|
|
58508
|
+
kind: "groupCrop",
|
|
58509
|
+
timestamp: group.timestamp
|
|
58510
|
+
}];
|
|
58511
|
+
if (group && group.memberCount === 1) {
|
|
58512
|
+
const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
|
|
58513
|
+
if (!memberId) return [];
|
|
58514
|
+
return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
|
|
58515
|
+
key: r.key,
|
|
58516
|
+
kind: r.kind,
|
|
58517
|
+
timestamp: r.timestamp
|
|
58518
|
+
}));
|
|
58519
|
+
}
|
|
58520
|
+
if (group) return [];
|
|
58521
|
+
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
57134
58522
|
key: r.key,
|
|
57135
58523
|
kind: r.kind,
|
|
57136
58524
|
timestamp: r.timestamp
|
|
@@ -59154,8 +60542,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59154
60542
|
const first = track.positions?.[0];
|
|
59155
60543
|
if (!first) return;
|
|
59156
60544
|
const lifeMs = track.lastSeen - track.firstSeen;
|
|
59157
|
-
const moved = track.
|
|
59158
|
-
if (!(lifeMs
|
|
60545
|
+
const moved = netDisplacementPx(track.positions ?? []);
|
|
60546
|
+
if (!isShortMotionlessTrack(lifeMs, moved)) {
|
|
59159
60547
|
this.phantomCells?.noteMovingClose({
|
|
59160
60548
|
deviceId: track.deviceId,
|
|
59161
60549
|
className: track.className,
|
|
@@ -59298,7 +60686,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59298
60686
|
deviceId,
|
|
59299
60687
|
trackId,
|
|
59300
60688
|
timestamp: frame.timestamp,
|
|
59301
|
-
bbox: { ...frame.bbox },
|
|
60689
|
+
bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
|
|
59302
60690
|
frameWidth: frame.frameWidth,
|
|
59303
60691
|
frameHeight: frame.frameHeight,
|
|
59304
60692
|
kind: d.className === "plate" ? "plate" : "classification",
|
|
@@ -59731,7 +61119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59731
61119
|
retries.push({
|
|
59732
61120
|
trackId: t.trackId,
|
|
59733
61121
|
timestamp,
|
|
59734
|
-
bbox:
|
|
61122
|
+
bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
|
|
59735
61123
|
...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
|
|
59736
61124
|
});
|
|
59737
61125
|
}
|
|
@@ -59788,7 +61176,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59788
61176
|
lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
|
|
59789
61177
|
now: timestamp,
|
|
59790
61178
|
intervalMs: media.snapshotIntervalMs,
|
|
59791
|
-
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
|
|
61179
|
+
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
|
|
61180
|
+
firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
|
|
59792
61181
|
});
|
|
59793
61182
|
const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
|
|
59794
61183
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
@@ -60297,6 +61686,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60297
61686
|
async sweepExpiredTracks() {
|
|
60298
61687
|
if (this.shuttingDown || !this.trackStore) return;
|
|
60299
61688
|
await this.trackCloser.sweep();
|
|
61689
|
+
this.groupSealer?.sealRecent();
|
|
60300
61690
|
}
|
|
60301
61691
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
60302
61692
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -61575,6 +62965,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61575
62965
|
},
|
|
61576
62966
|
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
61577
62967
|
liveEventIds: (ids) => eventStore.existingEventIds(ids),
|
|
62968
|
+
liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
62969
|
+
liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
61578
62970
|
reclaim: async (collection, key) => {
|
|
61579
62971
|
if (collection === "pipeline-analytics:media") {
|
|
61580
62972
|
await mediaStore.deleteByKey(key);
|
|
@@ -62298,6 +63690,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62298
63690
|
buildFaceRescoreActionHandlers() {
|
|
62299
63691
|
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62300
63692
|
}
|
|
63693
|
+
buildGroupActionHandlers() {
|
|
63694
|
+
const ports = () => {
|
|
63695
|
+
const groupStore = this.groupStore;
|
|
63696
|
+
if (groupStore === null) return null;
|
|
63697
|
+
return {
|
|
63698
|
+
groupStore,
|
|
63699
|
+
loadTrack: async (trackId) => {
|
|
63700
|
+
const tracks = this.trackStore;
|
|
63701
|
+
if (!tracks) return null;
|
|
63702
|
+
return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
|
|
63703
|
+
},
|
|
63704
|
+
eventMediaBaseUrl: () => this.eventMediaBaseUrl
|
|
63705
|
+
};
|
|
63706
|
+
};
|
|
63707
|
+
return {
|
|
63708
|
+
"analytics.listGroups": async (input) => {
|
|
63709
|
+
const ready = ports();
|
|
63710
|
+
if (ready === null) return {
|
|
63711
|
+
groups: [],
|
|
63712
|
+
hasMore: false
|
|
63713
|
+
};
|
|
63714
|
+
return listGroupRecords(ready, input);
|
|
63715
|
+
},
|
|
63716
|
+
"analytics.getGroup": async (input) => {
|
|
63717
|
+
const ready = ports();
|
|
63718
|
+
if (ready === null) return { group: null };
|
|
63719
|
+
return { group: await getGroupDetail(ready, input.groupId) };
|
|
63720
|
+
}
|
|
63721
|
+
};
|
|
63722
|
+
}
|
|
62301
63723
|
buildPhotoEnrollActionHandlers() {
|
|
62302
63724
|
return {
|
|
62303
63725
|
"photo.analyze": async (input) => {
|
|
@@ -62528,7 +63950,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62528
63950
|
mediaStore,
|
|
62529
63951
|
faceStore: this.faceStore,
|
|
62530
63952
|
plateStore: this.plateStore,
|
|
62531
|
-
objectEmbeddingStore: this.objectEmbeddingStore
|
|
63953
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
63954
|
+
groupStore: this.groupStore
|
|
62532
63955
|
}),
|
|
62533
63956
|
trackStore
|
|
62534
63957
|
};
|
|
@@ -62677,7 +64100,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62677
64100
|
media: 0,
|
|
62678
64101
|
faces: 0,
|
|
62679
64102
|
plates: 0,
|
|
62680
|
-
embeddings: 0
|
|
64103
|
+
embeddings: 0,
|
|
64104
|
+
groups: 0
|
|
62681
64105
|
};
|
|
62682
64106
|
this.lastPruneBytes = 0;
|
|
62683
64107
|
const cascade = this.buildTrackCascadeRegistry();
|
|
@@ -62737,7 +64161,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62737
64161
|
media: tally.media,
|
|
62738
64162
|
faces: tally.faces,
|
|
62739
64163
|
plates: tally.plates,
|
|
62740
|
-
embeddings: tally.embeddings
|
|
64164
|
+
embeddings: tally.embeddings,
|
|
64165
|
+
groups: tally.groups
|
|
62741
64166
|
};
|
|
62742
64167
|
}
|
|
62743
64168
|
/**
|