@camstack/addon-post-analysis 1.2.110 → 1.2.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-BtpxOtGv.mjs → dist-B13sFjR2.mjs} +22 -9
- package/dist/{dist-RzJseCJP.js → dist-C0uPGtBv.js} +22 -9
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DbTCGBNZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-k_ZbrH_x.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BUnWOUjb.mjs → hostInit-DckJUjuL.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1913 -162
- package/dist/pipeline-analytics/index.mjs +1904 -154
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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",
|
|
@@ -15121,22 +15132,29 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15121
15132
|
};
|
|
15122
15133
|
}
|
|
15123
15134
|
/**
|
|
15135
|
+
* Ranked leftover after the quota pass — refill a mosaic that dropped
|
|
15136
|
+
* duplicate frames or missing crops without walking the same track twice.
|
|
15137
|
+
*/
|
|
15138
|
+
function remainingSummaryCandidates(kept, taken, rank) {
|
|
15139
|
+
const takenIds = new Set(taken.map((c) => c.trackId));
|
|
15140
|
+
return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
|
|
15141
|
+
}
|
|
15142
|
+
/**
|
|
15124
15143
|
* The media a tile is drawn from, best first.
|
|
15125
15144
|
*
|
|
15126
|
-
*
|
|
15127
|
-
*
|
|
15128
|
-
*
|
|
15129
|
-
*
|
|
15130
|
-
*
|
|
15131
|
-
*
|
|
15145
|
+
* For a group: `groupCrop` (union crop, the group's own blob) then
|
|
15146
|
+
* `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
|
|
15147
|
+
* cohort may show the native still when the crop was not written; a
|
|
15148
|
+
* member's `keyFrame` listed as itself stays off this ladder so tracker
|
|
15149
|
+
* ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
|
|
15150
|
+
* absent: a drawn rectangle is a permanent lie in a digest tile.
|
|
15132
15151
|
*/
|
|
15133
15152
|
var SUMMARY_MEDIA_LADDER = [
|
|
15134
|
-
"
|
|
15135
|
-
"
|
|
15136
|
-
"
|
|
15137
|
-
"fullFrame",
|
|
15153
|
+
"groupCrop",
|
|
15154
|
+
"nativeFrame",
|
|
15155
|
+
"thumbnail",
|
|
15138
15156
|
"thumbnailSmall",
|
|
15139
|
-
"
|
|
15157
|
+
"snapshot"
|
|
15140
15158
|
];
|
|
15141
15159
|
/**
|
|
15142
15160
|
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
@@ -15154,6 +15172,13 @@ function pickSummaryMedia(rows, aroundMs) {
|
|
|
15154
15172
|
}
|
|
15155
15173
|
return null;
|
|
15156
15174
|
}
|
|
15175
|
+
/**
|
|
15176
|
+
* Same camera, same wall-clock second → same JPEG. Tracker ID churn
|
|
15177
|
+
* otherwise fills a mosaic with eleven copies of one driveway still.
|
|
15178
|
+
*/
|
|
15179
|
+
function frameDedupeKey(deviceId, mediaTimestamp) {
|
|
15180
|
+
return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
|
|
15181
|
+
}
|
|
15157
15182
|
//#endregion
|
|
15158
15183
|
//#region src/notification-center/summary/summary-window.ts
|
|
15159
15184
|
/**
|
|
@@ -15212,7 +15237,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
|
15212
15237
|
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
15213
15238
|
return `summary-${ruleId}-${windowEndMs}`;
|
|
15214
15239
|
}
|
|
15215
|
-
var EMPTY_REPORT = {
|
|
15240
|
+
var EMPTY_REPORT$1 = {
|
|
15216
15241
|
produced: 0,
|
|
15217
15242
|
collecting: 0,
|
|
15218
15243
|
barrier: 0,
|
|
@@ -15276,9 +15301,9 @@ var NcSummaryProducer = class {
|
|
|
15276
15301
|
* must not open ten simultaneous render/publish sequences.
|
|
15277
15302
|
*/
|
|
15278
15303
|
async tick() {
|
|
15279
|
-
if (this.tickInFlight) return EMPTY_REPORT;
|
|
15304
|
+
if (this.tickInFlight) return EMPTY_REPORT$1;
|
|
15280
15305
|
this.tickInFlight = true;
|
|
15281
|
-
const report = { ...EMPTY_REPORT };
|
|
15306
|
+
const report = { ...EMPTY_REPORT$1 };
|
|
15282
15307
|
try {
|
|
15283
15308
|
const now = this.deps.now();
|
|
15284
15309
|
const rules = this.deps.store.listEnabled();
|
|
@@ -15453,7 +15478,12 @@ var NcSummaryProducer = class {
|
|
|
15453
15478
|
droppedStationary: filtered.droppedStationary
|
|
15454
15479
|
};
|
|
15455
15480
|
}
|
|
15456
|
-
const loaded = await this.loadTiles(
|
|
15481
|
+
const loaded = await this.loadTiles({
|
|
15482
|
+
rule,
|
|
15483
|
+
preferred: selection.tiles,
|
|
15484
|
+
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
15485
|
+
maxTiles: rule.maxTiles
|
|
15486
|
+
});
|
|
15457
15487
|
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
15458
15488
|
firstSeen: t.firstSeen,
|
|
15459
15489
|
lastSeen: t.lastSeen,
|
|
@@ -15511,7 +15541,7 @@ var NcSummaryProducer = class {
|
|
|
15511
15541
|
tiles: loaded.length,
|
|
15512
15542
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
15513
15543
|
camerasDropped: selection.camerasDropped,
|
|
15514
|
-
droppedNoMedia:
|
|
15544
|
+
droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
|
|
15515
15545
|
mosaicBytes: mosaic.bytes,
|
|
15516
15546
|
mosaicPublished: mosaic.ref !== null,
|
|
15517
15547
|
enqueued,
|
|
@@ -15575,10 +15605,18 @@ var NcSummaryProducer = class {
|
|
|
15575
15605
|
}
|
|
15576
15606
|
return all;
|
|
15577
15607
|
}
|
|
15578
|
-
/** One blob per tile — the cheap question (index rows) always first.
|
|
15579
|
-
|
|
15608
|
+
/** One blob per tile — the cheap question (index rows) always first.
|
|
15609
|
+
* Duplicate camera-seconds are skipped so tracker ID churn cannot paint
|
|
15610
|
+
* the same driveway eleven times; leftover ranked candidates refill. */
|
|
15611
|
+
async loadTiles(input) {
|
|
15580
15612
|
const out = [];
|
|
15581
|
-
|
|
15613
|
+
const seenFrames = /* @__PURE__ */ new Set();
|
|
15614
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
15615
|
+
const queue = [...input.preferred, ...input.refill];
|
|
15616
|
+
for (const candidate of queue) {
|
|
15617
|
+
if (out.length >= input.maxTiles) break;
|
|
15618
|
+
if (seenIds.has(candidate.trackId)) continue;
|
|
15619
|
+
seenIds.add(candidate.trackId);
|
|
15582
15620
|
const tags = { deviceId: candidate.deviceId };
|
|
15583
15621
|
try {
|
|
15584
15622
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
@@ -15587,25 +15625,38 @@ var NcSummaryProducer = class {
|
|
|
15587
15625
|
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
15588
15626
|
tags,
|
|
15589
15627
|
meta: {
|
|
15590
|
-
ruleId: rule.id,
|
|
15628
|
+
ruleId: input.rule.id,
|
|
15591
15629
|
trackId: candidate.trackId,
|
|
15592
15630
|
mediaRows: rows.length
|
|
15593
15631
|
}
|
|
15594
15632
|
});
|
|
15595
15633
|
continue;
|
|
15596
15634
|
}
|
|
15635
|
+
const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
|
|
15636
|
+
if (seenFrames.has(frameKey)) {
|
|
15637
|
+
this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
|
|
15638
|
+
tags,
|
|
15639
|
+
meta: {
|
|
15640
|
+
ruleId: input.rule.id,
|
|
15641
|
+
trackId: candidate.trackId,
|
|
15642
|
+
frameKey
|
|
15643
|
+
}
|
|
15644
|
+
});
|
|
15645
|
+
continue;
|
|
15646
|
+
}
|
|
15597
15647
|
const jpeg = await this.deps.readMedia(picked.key);
|
|
15598
15648
|
if (jpeg === null || jpeg.byteLength === 0) {
|
|
15599
15649
|
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
15600
15650
|
tags,
|
|
15601
15651
|
meta: {
|
|
15602
|
-
ruleId: rule.id,
|
|
15652
|
+
ruleId: input.rule.id,
|
|
15603
15653
|
trackId: candidate.trackId,
|
|
15604
15654
|
mediaKey: picked.key
|
|
15605
15655
|
}
|
|
15606
15656
|
});
|
|
15607
15657
|
continue;
|
|
15608
15658
|
}
|
|
15659
|
+
seenFrames.add(frameKey);
|
|
15609
15660
|
out.push({
|
|
15610
15661
|
candidate,
|
|
15611
15662
|
jpeg
|
|
@@ -15614,7 +15665,7 @@ var NcSummaryProducer = class {
|
|
|
15614
15665
|
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
15615
15666
|
tags,
|
|
15616
15667
|
meta: {
|
|
15617
|
-
ruleId: rule.id,
|
|
15668
|
+
ruleId: input.rule.id,
|
|
15618
15669
|
trackId: candidate.trackId,
|
|
15619
15670
|
error: String(err)
|
|
15620
15671
|
}
|
|
@@ -15989,7 +16040,7 @@ var NcSummaryRuleInputSchema = object({
|
|
|
15989
16040
|
deviceIds: DeviceIdsField,
|
|
15990
16041
|
window: NcSummaryWindowSchema,
|
|
15991
16042
|
filters: NcSummaryFiltersSchema.optional(),
|
|
15992
|
-
maxTiles: MaxTilesField.default(
|
|
16043
|
+
maxTiles: MaxTilesField.default(12),
|
|
15993
16044
|
maxAiImages: MaxAiImagesField.default(6),
|
|
15994
16045
|
rank: NcSummaryRankSchema.default("importance"),
|
|
15995
16046
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
@@ -18340,6 +18391,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
|
|
|
18340
18391
|
function timelapseEligibilityFloor(rule, deviceId) {
|
|
18341
18392
|
return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
|
|
18342
18393
|
}
|
|
18394
|
+
var EMPTY_REPORT = {
|
|
18395
|
+
produced: 0,
|
|
18396
|
+
waiting: 0,
|
|
18397
|
+
alreadyDone: 0,
|
|
18398
|
+
beforeFloor: 0,
|
|
18399
|
+
failed: 0,
|
|
18400
|
+
idle: 0,
|
|
18401
|
+
skippedInFlight: 0
|
|
18402
|
+
};
|
|
18343
18403
|
/** The in-flight map's key. One render per (rule, camera) pair by construction. */
|
|
18344
18404
|
function renderFlightKey(ruleId, deviceId) {
|
|
18345
18405
|
return `${ruleId}:${String(deviceId)}`;
|
|
@@ -18347,7 +18407,8 @@ function renderFlightKey(ruleId, deviceId) {
|
|
|
18347
18407
|
var TimelapseScheduler = class {
|
|
18348
18408
|
deps;
|
|
18349
18409
|
timer = null;
|
|
18350
|
-
|
|
18410
|
+
/** True only while this tick is scanning eligibility — not while ffmpeg runs. */
|
|
18411
|
+
scanning = false;
|
|
18351
18412
|
inFlight = /* @__PURE__ */ new Map();
|
|
18352
18413
|
settleMs;
|
|
18353
18414
|
lookbackMs;
|
|
@@ -18394,25 +18455,29 @@ var TimelapseScheduler = class {
|
|
|
18394
18455
|
this.timer = null;
|
|
18395
18456
|
}
|
|
18396
18457
|
}
|
|
18397
|
-
/**
|
|
18458
|
+
/**
|
|
18459
|
+
* One pass over every enabled rule × camera.
|
|
18460
|
+
*
|
|
18461
|
+
* Eligibility is scanned under a short `scanning` lock so two ticks do not
|
|
18462
|
+
* double-launch the same pair. Renders themselves are started together —
|
|
18463
|
+
* awaiting them one-by-one made camera B wait for camera A's ffmpeg
|
|
18464
|
+
* (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
|
|
18465
|
+
* A later tick that meets a pair already in `inFlight` counts it and
|
|
18466
|
+
* moves on, so a 30-minute render does not make the producer go deaf.
|
|
18467
|
+
*/
|
|
18398
18468
|
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;
|
|
18469
|
+
if (this.scanning) return { ...EMPTY_REPORT };
|
|
18470
|
+
this.scanning = true;
|
|
18408
18471
|
const report = {
|
|
18409
18472
|
produced: 0,
|
|
18410
18473
|
waiting: 0,
|
|
18411
18474
|
alreadyDone: 0,
|
|
18412
18475
|
beforeFloor: 0,
|
|
18413
18476
|
failed: 0,
|
|
18414
|
-
idle: 0
|
|
18477
|
+
idle: 0,
|
|
18478
|
+
skippedInFlight: 0
|
|
18415
18479
|
};
|
|
18480
|
+
const jobs = [];
|
|
18416
18481
|
try {
|
|
18417
18482
|
const now = this.deps.now();
|
|
18418
18483
|
for (const rule of this.deps.store.listEnabled()) {
|
|
@@ -18447,13 +18512,20 @@ var TimelapseScheduler = class {
|
|
|
18447
18512
|
});
|
|
18448
18513
|
continue;
|
|
18449
18514
|
}
|
|
18450
|
-
if (
|
|
18451
|
-
|
|
18515
|
+
if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
|
|
18516
|
+
report.skippedInFlight += 1;
|
|
18517
|
+
continue;
|
|
18518
|
+
}
|
|
18519
|
+
jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
|
|
18520
|
+
if (ok.produced > 0) report.produced += 1;
|
|
18521
|
+
else report.failed += 1;
|
|
18522
|
+
}));
|
|
18452
18523
|
}
|
|
18453
18524
|
}
|
|
18454
18525
|
} finally {
|
|
18455
|
-
this.
|
|
18526
|
+
this.scanning = false;
|
|
18456
18527
|
}
|
|
18528
|
+
await Promise.all(jobs);
|
|
18457
18529
|
return report;
|
|
18458
18530
|
}
|
|
18459
18531
|
/**
|
|
@@ -27607,6 +27679,35 @@ function pairRiders(detections) {
|
|
|
27607
27679
|
return pairs;
|
|
27608
27680
|
}
|
|
27609
27681
|
//#endregion
|
|
27682
|
+
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
27683
|
+
/** How far the grown box's centroid may sit, as a multiple of the LARGER
|
|
27684
|
+
* diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
|
|
27685
|
+
var GROWTH_CENTROID_FACTOR = 1.35;
|
|
27686
|
+
function intersectionArea(a, b) {
|
|
27687
|
+
const x1 = Math.max(a.x, b.x);
|
|
27688
|
+
const y1 = Math.max(a.y, b.y);
|
|
27689
|
+
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
27690
|
+
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
|
27691
|
+
if (x2 <= x1 || y2 <= y1) return 0;
|
|
27692
|
+
return (x2 - x1) * (y2 - y1);
|
|
27693
|
+
}
|
|
27694
|
+
/** Same-class partial crop growing into (or collapsing from) a full-body box
|
|
27695
|
+
* with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
|
|
27696
|
+
* A SMALL box nested inside a LARGE one is occlusion, not growth — those
|
|
27697
|
+
* stay two tracks (sort-tracker occlusion suite). */
|
|
27698
|
+
function growthAdjacentMatch(track, det) {
|
|
27699
|
+
const trackArea = track.w * track.h;
|
|
27700
|
+
const detArea = det.w * det.h;
|
|
27701
|
+
if (trackArea <= 0 || detArea <= 0) return false;
|
|
27702
|
+
const larger = Math.max(trackArea, detArea);
|
|
27703
|
+
const smaller = Math.min(trackArea, detArea);
|
|
27704
|
+
if (larger / smaller < 1.5) return false;
|
|
27705
|
+
if (intersectionArea(track, det) / smaller >= .5) return false;
|
|
27706
|
+
const tc = bboxCentroid(track);
|
|
27707
|
+
const dc = bboxCentroid(det);
|
|
27708
|
+
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));
|
|
27709
|
+
}
|
|
27710
|
+
//#endregion
|
|
27610
27711
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
27611
27712
|
var DEFAULT_TRACKER_CONFIG = {
|
|
27612
27713
|
iouThreshold: .3,
|
|
@@ -27734,7 +27835,7 @@ var SortTracker = class SortTracker {
|
|
|
27734
27835
|
* and earlier same-frame spawns).
|
|
27735
27836
|
*/
|
|
27736
27837
|
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;
|
|
27838
|
+
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
27839
|
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
27739
27840
|
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
27740
27841
|
}
|
|
@@ -27828,7 +27929,8 @@ var SortTracker = class SortTracker {
|
|
|
27828
27929
|
const dc = bboxCentroid(det.bbox);
|
|
27829
27930
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
27830
27931
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
27831
|
-
|
|
27932
|
+
if (dist <= this.config.rescueCentroidFactor * diag) return true;
|
|
27933
|
+
return growthAdjacentMatch(track.bbox, det.bbox);
|
|
27832
27934
|
}
|
|
27833
27935
|
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
27834
27936
|
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
@@ -28153,6 +28255,7 @@ var StateAnalyzer = class {
|
|
|
28153
28255
|
enteredAt: timestamp,
|
|
28154
28256
|
stationarySince: void 0,
|
|
28155
28257
|
totalDistancePx: 0,
|
|
28258
|
+
startPosition: center,
|
|
28156
28259
|
lastPosition: center,
|
|
28157
28260
|
frameCount: 1
|
|
28158
28261
|
};
|
|
@@ -28172,8 +28275,9 @@ var StateAnalyzer = class {
|
|
|
28172
28275
|
else if (velocity <= this.config.velocityThreshold) {
|
|
28173
28276
|
if (!ts.stationarySince) ts.stationarySince = timestamp;
|
|
28174
28277
|
const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
|
|
28278
|
+
const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
|
|
28175
28279
|
if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
|
|
28176
|
-
else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28280
|
+
else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
|
|
28177
28281
|
else state = "moving";
|
|
28178
28282
|
} else {
|
|
28179
28283
|
ts.stationarySince = void 0;
|
|
@@ -29006,6 +29110,22 @@ function promoteSemanticBestFrame(deps, input) {
|
|
|
29006
29110
|
};
|
|
29007
29111
|
}
|
|
29008
29112
|
//#endregion
|
|
29113
|
+
//#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
|
|
29114
|
+
/** Which box the semantic best-shot should crop: a plate/face DETAIL box
|
|
29115
|
+
* when we have one, otherwise the subject box. */
|
|
29116
|
+
function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
|
|
29117
|
+
if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
|
|
29118
|
+
return subjectBbox;
|
|
29119
|
+
}
|
|
29120
|
+
//#endregion
|
|
29121
|
+
//#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
|
|
29122
|
+
/** Crop box for a late firstFrame retry: the BIRTH geometry, not the
|
|
29123
|
+
* later frozen/coasted box (track 1b659d56). */
|
|
29124
|
+
function firstFrameRetryBbox(current, birth) {
|
|
29125
|
+
if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
|
|
29126
|
+
return { ...current };
|
|
29127
|
+
}
|
|
29128
|
+
//#endregion
|
|
29009
29129
|
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
29010
29130
|
/**
|
|
29011
29131
|
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
@@ -29220,6 +29340,7 @@ var EMPTY_CASCADE_TALLY = {
|
|
|
29220
29340
|
faces: 0,
|
|
29221
29341
|
plates: 0,
|
|
29222
29342
|
embeddings: 0,
|
|
29343
|
+
groups: 0,
|
|
29223
29344
|
bytesReclaimed: 0
|
|
29224
29345
|
};
|
|
29225
29346
|
/** Fold a leaf store's receipts into a running tally. */
|
|
@@ -29263,6 +29384,13 @@ function addLeafCounts(tally, counts) {
|
|
|
29263
29384
|
bytesReclaimed
|
|
29264
29385
|
};
|
|
29265
29386
|
break;
|
|
29387
|
+
case "groups":
|
|
29388
|
+
next = {
|
|
29389
|
+
...next,
|
|
29390
|
+
groups: next.groups + c.rows,
|
|
29391
|
+
bytesReclaimed
|
|
29392
|
+
};
|
|
29393
|
+
break;
|
|
29266
29394
|
}
|
|
29267
29395
|
}
|
|
29268
29396
|
return next;
|
|
@@ -29276,6 +29404,7 @@ function mergeCascadeTallies(a, b) {
|
|
|
29276
29404
|
faces: a.faces + b.faces,
|
|
29277
29405
|
plates: a.plates + b.plates,
|
|
29278
29406
|
embeddings: a.embeddings + b.embeddings,
|
|
29407
|
+
groups: a.groups + b.groups,
|
|
29279
29408
|
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
29280
29409
|
};
|
|
29281
29410
|
}
|
|
@@ -29324,6 +29453,7 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
29324
29453
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
29325
29454
|
if (stores.plateStore) registry.push(stores.plateStore);
|
|
29326
29455
|
if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
|
|
29456
|
+
if (stores.groupStore) registry.push(stores.groupStore);
|
|
29327
29457
|
return registry;
|
|
29328
29458
|
}
|
|
29329
29459
|
/**
|
|
@@ -31034,7 +31164,8 @@ var OWNER_KINDS = [
|
|
|
31034
31164
|
"identity",
|
|
31035
31165
|
"plate",
|
|
31036
31166
|
"vehicle",
|
|
31037
|
-
"scene"
|
|
31167
|
+
"scene",
|
|
31168
|
+
"group"
|
|
31038
31169
|
];
|
|
31039
31170
|
/**
|
|
31040
31171
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -31783,6 +31914,10 @@ var MediaStore = class {
|
|
|
31783
31914
|
async deleteForTracks(trackIds) {
|
|
31784
31915
|
return this.deleteForOwner("track", trackIds);
|
|
31785
31916
|
}
|
|
31917
|
+
/** Delete the group's OWN crop. The native frame is a pointer and is not here. */
|
|
31918
|
+
async deleteByGroups(groupIds) {
|
|
31919
|
+
return this.deleteForOwnerCounted("group", groupIds);
|
|
31920
|
+
}
|
|
31786
31921
|
/**
|
|
31787
31922
|
* `TrackScopedStore` contract for the retention cascade: delete all media
|
|
31788
31923
|
* owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
|
|
@@ -39836,7 +39971,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
39836
39971
|
function planPeriodicMedia(input) {
|
|
39837
39972
|
const appendSnapshot = input.dueSnapshot;
|
|
39838
39973
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
39839
|
-
const
|
|
39974
|
+
const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
39975
|
+
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
39976
|
+
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
39840
39977
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
39841
39978
|
return {
|
|
39842
39979
|
appendSnapshot,
|
|
@@ -40069,6 +40206,1404 @@ var DeferredBirthRegistry = class {
|
|
|
40069
40206
|
this.byDevice.delete(deviceKey);
|
|
40070
40207
|
}
|
|
40071
40208
|
};
|
|
40209
|
+
var AnalyticsGroupRecordSchema = object({
|
|
40210
|
+
id: string(),
|
|
40211
|
+
deviceId: number().int(),
|
|
40212
|
+
openedAt: number().int(),
|
|
40213
|
+
closedAt: number().int(),
|
|
40214
|
+
timestamp: number().int(),
|
|
40215
|
+
memberCount: number().int(),
|
|
40216
|
+
memberTrackIds: array(string()).readonly(),
|
|
40217
|
+
className: string(),
|
|
40218
|
+
classes: array(string()).readonly(),
|
|
40219
|
+
/** Relative event-media path, or null when the group has no picture yet. */
|
|
40220
|
+
mediaUrl: string().nullable(),
|
|
40221
|
+
singleton: boolean()
|
|
40222
|
+
});
|
|
40223
|
+
var AnalyticsGroupMemberSchema = object({
|
|
40224
|
+
trackId: string(),
|
|
40225
|
+
deviceId: number().int(),
|
|
40226
|
+
className: string(),
|
|
40227
|
+
firstSeen: number().int(),
|
|
40228
|
+
lastSeen: number().int(),
|
|
40229
|
+
mediaUrl: string().nullable()
|
|
40230
|
+
});
|
|
40231
|
+
var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: array(AnalyticsGroupMemberSchema).readonly() });
|
|
40232
|
+
var groupActions = defineCustomActions({
|
|
40233
|
+
"analytics.listGroups": customAction(object({
|
|
40234
|
+
deviceId: number().int().optional(),
|
|
40235
|
+
sinceMs: number().int().optional(),
|
|
40236
|
+
untilMs: number().int().optional(),
|
|
40237
|
+
limit: number().int().min(1).max(100).optional(),
|
|
40238
|
+
offset: number().int().min(0).optional()
|
|
40239
|
+
}), object({
|
|
40240
|
+
groups: array(AnalyticsGroupRecordSchema).readonly(),
|
|
40241
|
+
hasMore: boolean()
|
|
40242
|
+
})),
|
|
40243
|
+
"analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
|
|
40244
|
+
});
|
|
40245
|
+
//#endregion
|
|
40246
|
+
//#region src/pipeline-analytics/group/group-query.ts
|
|
40247
|
+
/**
|
|
40248
|
+
* Stamp a relative event-media URL.
|
|
40249
|
+
*
|
|
40250
|
+
* Media keys contain `:` and the data-plane serves them by key. Track ids are
|
|
40251
|
+
* bare and fall through to the existing event/track thumbnail resolver. Never
|
|
40252
|
+
* percent-encode: the handler does not URL-decode the path.
|
|
40253
|
+
*/
|
|
40254
|
+
function stampEventMediaUrl(baseUrl, id) {
|
|
40255
|
+
if (baseUrl === null || baseUrl.length === 0) return null;
|
|
40256
|
+
if (id === null || id === void 0 || id.length === 0) return null;
|
|
40257
|
+
return `${baseUrl}/${id}`;
|
|
40258
|
+
}
|
|
40259
|
+
/** Union of every class on the members; `className` is the first, stable order. */
|
|
40260
|
+
function aggregateGroupClasses(tracks) {
|
|
40261
|
+
const seen = /* @__PURE__ */ new Set();
|
|
40262
|
+
const classes = [];
|
|
40263
|
+
for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
|
|
40264
|
+
if (name.length === 0 || seen.has(name)) continue;
|
|
40265
|
+
seen.add(name);
|
|
40266
|
+
classes.push(name);
|
|
40267
|
+
}
|
|
40268
|
+
return {
|
|
40269
|
+
className: classes[0] ?? "unknown",
|
|
40270
|
+
classes
|
|
40271
|
+
};
|
|
40272
|
+
}
|
|
40273
|
+
function toGroupRecord(input) {
|
|
40274
|
+
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40275
|
+
const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40276
|
+
return {
|
|
40277
|
+
id: input.group.id,
|
|
40278
|
+
deviceId: input.group.deviceId,
|
|
40279
|
+
openedAt: input.group.openedAt,
|
|
40280
|
+
closedAt: input.group.closedAt,
|
|
40281
|
+
timestamp: input.group.timestamp,
|
|
40282
|
+
memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
|
|
40283
|
+
memberTrackIds: [...input.memberTrackIds],
|
|
40284
|
+
className,
|
|
40285
|
+
classes: [...classes],
|
|
40286
|
+
mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
|
|
40287
|
+
singleton: input.memberTrackIds.length <= 1
|
|
40288
|
+
};
|
|
40289
|
+
}
|
|
40290
|
+
function toGroupMember(member, track, eventMediaBaseUrl) {
|
|
40291
|
+
return {
|
|
40292
|
+
trackId: member.trackId,
|
|
40293
|
+
deviceId: member.deviceId,
|
|
40294
|
+
className: track?.className ?? "unknown",
|
|
40295
|
+
firstSeen: track?.firstSeen ?? member.timestamp,
|
|
40296
|
+
lastSeen: track?.lastSeen ?? member.timestamp,
|
|
40297
|
+
mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
|
|
40298
|
+
};
|
|
40299
|
+
}
|
|
40300
|
+
function trackToLite(track) {
|
|
40301
|
+
return {
|
|
40302
|
+
trackId: track.trackId,
|
|
40303
|
+
className: track.className,
|
|
40304
|
+
classes: track.classes,
|
|
40305
|
+
firstSeen: track.firstSeen,
|
|
40306
|
+
lastSeen: track.lastSeen
|
|
40307
|
+
};
|
|
40308
|
+
}
|
|
40309
|
+
async function listGroupRecords(ports, input) {
|
|
40310
|
+
const page = await ports.groupStore.listRecent(input);
|
|
40311
|
+
const groups = [];
|
|
40312
|
+
for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
|
|
40313
|
+
return {
|
|
40314
|
+
groups,
|
|
40315
|
+
hasMore: page.hasMore
|
|
40316
|
+
};
|
|
40317
|
+
}
|
|
40318
|
+
async function getGroupDetail(ports, groupId) {
|
|
40319
|
+
const group = await ports.groupStore.get(groupId);
|
|
40320
|
+
if (group === null) return null;
|
|
40321
|
+
const members = await ports.groupStore.listMembers(groupId);
|
|
40322
|
+
const lites = [];
|
|
40323
|
+
const rows = [];
|
|
40324
|
+
for (const member of members) {
|
|
40325
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40326
|
+
const lite = track === null ? null : trackToLite(track);
|
|
40327
|
+
if (lite !== null) lites.push(lite);
|
|
40328
|
+
rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
|
|
40329
|
+
}
|
|
40330
|
+
return {
|
|
40331
|
+
...toGroupRecord({
|
|
40332
|
+
group,
|
|
40333
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40334
|
+
tracks: lites,
|
|
40335
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40336
|
+
}),
|
|
40337
|
+
members: rows
|
|
40338
|
+
};
|
|
40339
|
+
}
|
|
40340
|
+
async function buildGroupRecord(ports, group) {
|
|
40341
|
+
const members = await ports.groupStore.listMembers(group.id);
|
|
40342
|
+
const lites = [];
|
|
40343
|
+
for (const member of members) {
|
|
40344
|
+
const track = await ports.loadTrack(member.trackId);
|
|
40345
|
+
if (track !== null) lites.push(trackToLite(track));
|
|
40346
|
+
}
|
|
40347
|
+
return toGroupRecord({
|
|
40348
|
+
group,
|
|
40349
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
40350
|
+
tracks: lites,
|
|
40351
|
+
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40352
|
+
});
|
|
40353
|
+
}
|
|
40354
|
+
/**
|
|
40355
|
+
* Gallery and digest read the same ladder: groupCrop, then the native-frame
|
|
40356
|
+
* pointer, then member subject crops. A multi with no crop still has a
|
|
40357
|
+
* picture if the sealer stored `nativeKey`.
|
|
40358
|
+
*/
|
|
40359
|
+
function digestMediaRows(input) {
|
|
40360
|
+
const owned = [];
|
|
40361
|
+
if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
|
|
40362
|
+
key: input.mediaKey,
|
|
40363
|
+
kind: "groupCrop",
|
|
40364
|
+
timestamp: input.timestamp
|
|
40365
|
+
});
|
|
40366
|
+
if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
|
|
40367
|
+
key: input.nativeKey,
|
|
40368
|
+
kind: "nativeFrame",
|
|
40369
|
+
timestamp: input.timestamp
|
|
40370
|
+
});
|
|
40371
|
+
if (owned.length > 0) return owned;
|
|
40372
|
+
return input.memberRows;
|
|
40373
|
+
}
|
|
40374
|
+
var MATCH_WINDOW_MS = 1e3;
|
|
40375
|
+
var MOVE_EPS = .35;
|
|
40376
|
+
/** Live-assigner window: same predicate, recent positions only. */
|
|
40377
|
+
var LIVE_WINDOW_MS = 3e3;
|
|
40378
|
+
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40379
|
+
function isEligibleMember(track) {
|
|
40380
|
+
if (track.closedReason === "stationary-promotion") return false;
|
|
40381
|
+
if (track.className === "audio") return false;
|
|
40382
|
+
return true;
|
|
40383
|
+
}
|
|
40384
|
+
function diag(bbox) {
|
|
40385
|
+
return Math.hypot(bbox.w, bbox.h);
|
|
40386
|
+
}
|
|
40387
|
+
function nearestPosition(positions, t) {
|
|
40388
|
+
let best;
|
|
40389
|
+
let bestDt = 1001;
|
|
40390
|
+
for (const p of positions) {
|
|
40391
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40392
|
+
if (dt <= 1e3 && dt < bestDt) {
|
|
40393
|
+
best = p;
|
|
40394
|
+
bestDt = dt;
|
|
40395
|
+
}
|
|
40396
|
+
}
|
|
40397
|
+
return best;
|
|
40398
|
+
}
|
|
40399
|
+
function meanDiag(positions) {
|
|
40400
|
+
if (positions.length === 0) return 0;
|
|
40401
|
+
let sum = 0;
|
|
40402
|
+
for (const p of positions) sum += diag(p.bbox);
|
|
40403
|
+
return sum / positions.length;
|
|
40404
|
+
}
|
|
40405
|
+
function vecLen(v) {
|
|
40406
|
+
return Math.hypot(v.x, v.y);
|
|
40407
|
+
}
|
|
40408
|
+
function cosine(a, b) {
|
|
40409
|
+
const la = vecLen(a);
|
|
40410
|
+
const lb = vecLen(b);
|
|
40411
|
+
if (la === 0 || lb === 0) return 1;
|
|
40412
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40413
|
+
}
|
|
40414
|
+
function positionsInWindow(track, start, end) {
|
|
40415
|
+
return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
|
|
40416
|
+
}
|
|
40417
|
+
function windowDisplacement(positions) {
|
|
40418
|
+
const first = positions[0];
|
|
40419
|
+
const last = positions[positions.length - 1];
|
|
40420
|
+
if (!first || !last) return {
|
|
40421
|
+
x: 0,
|
|
40422
|
+
y: 0
|
|
40423
|
+
};
|
|
40424
|
+
const a = bboxCentroid(first.bbox);
|
|
40425
|
+
const b = bboxCentroid(last.bbox);
|
|
40426
|
+
return {
|
|
40427
|
+
x: b.x - a.x,
|
|
40428
|
+
y: b.y - a.y
|
|
40429
|
+
};
|
|
40430
|
+
}
|
|
40431
|
+
function windowRelativeMotion(positions) {
|
|
40432
|
+
const mean = meanDiag(positions);
|
|
40433
|
+
if (mean <= 0) return 0;
|
|
40434
|
+
return vecLen(windowDisplacement(positions)) / mean;
|
|
40435
|
+
}
|
|
40436
|
+
/**
|
|
40437
|
+
* Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
|
|
40438
|
+
* Displacement/cosine use the window, not the whole track.
|
|
40439
|
+
*/
|
|
40440
|
+
function pairTogetherNow(a, b, now) {
|
|
40441
|
+
const start = now - LIVE_WINDOW_MS;
|
|
40442
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
|
|
40443
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
|
|
40444
|
+
if (overlapEnd < overlapStart) return false;
|
|
40445
|
+
let joint = 0;
|
|
40446
|
+
let close = 0;
|
|
40447
|
+
for (let t = overlapStart; t <= overlapEnd; t += 500) {
|
|
40448
|
+
const pa = nearestPosition(a.positions, t);
|
|
40449
|
+
const pb = nearestPosition(b.positions, t);
|
|
40450
|
+
if (!pa || !pb) continue;
|
|
40451
|
+
joint++;
|
|
40452
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40453
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40454
|
+
const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40455
|
+
const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
|
|
40456
|
+
if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
|
|
40457
|
+
}
|
|
40458
|
+
if (joint === 0) return false;
|
|
40459
|
+
if (close / joint < .5) return false;
|
|
40460
|
+
const wa = positionsInWindow(a, start, now);
|
|
40461
|
+
const wb = positionsInWindow(b, start, now);
|
|
40462
|
+
const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
|
|
40463
|
+
const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
|
|
40464
|
+
if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
|
|
40465
|
+
return true;
|
|
40466
|
+
}
|
|
40467
|
+
var GroupLiveAssigner = class {
|
|
40468
|
+
nextId;
|
|
40469
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
40470
|
+
groups = /* @__PURE__ */ new Map();
|
|
40471
|
+
membership = /* @__PURE__ */ new Map();
|
|
40472
|
+
/** `${trackId}>${groupId}` → first instant the join predicate held. */
|
|
40473
|
+
joinSince = /* @__PURE__ */ new Map();
|
|
40474
|
+
/** trackId → first instant they failed pair against every remaining member. */
|
|
40475
|
+
leaveSince = /* @__PURE__ */ new Map();
|
|
40476
|
+
constructor(nextId = () => randomUUID()) {
|
|
40477
|
+
this.nextId = nextId;
|
|
40478
|
+
}
|
|
40479
|
+
observe(track, now) {
|
|
40480
|
+
this.snapshots.set(track.trackId, track);
|
|
40481
|
+
if (!isEligibleMember(track)) {
|
|
40482
|
+
this.removeFromGroup(track.trackId);
|
|
40483
|
+
return;
|
|
40484
|
+
}
|
|
40485
|
+
if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
|
|
40486
|
+
this.considerLeave(track, now);
|
|
40487
|
+
const groupId = this.membership.get(track.trackId);
|
|
40488
|
+
const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
|
|
40489
|
+
if (group) for (const id of [...group.members]) {
|
|
40490
|
+
if (id === track.trackId) continue;
|
|
40491
|
+
const snap = this.snapshots.get(id);
|
|
40492
|
+
if (snap) this.considerLeave(snap, now);
|
|
40493
|
+
}
|
|
40494
|
+
this.considerJoin(track, now);
|
|
40495
|
+
}
|
|
40496
|
+
groupOf(trackId) {
|
|
40497
|
+
return this.membership.get(trackId);
|
|
40498
|
+
}
|
|
40499
|
+
snapshot(trackId) {
|
|
40500
|
+
return this.snapshots.get(trackId);
|
|
40501
|
+
}
|
|
40502
|
+
liveGroups() {
|
|
40503
|
+
return [...this.groups.values()].map((g) => this.toLive(g));
|
|
40504
|
+
}
|
|
40505
|
+
idleGroups(now, idleMs) {
|
|
40506
|
+
return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
|
|
40507
|
+
}
|
|
40508
|
+
drop(ids) {
|
|
40509
|
+
for (const id of ids) {
|
|
40510
|
+
const g = this.groups.get(id);
|
|
40511
|
+
if (!g) continue;
|
|
40512
|
+
for (const member of g.members) this.membership.delete(member);
|
|
40513
|
+
this.groups.delete(id);
|
|
40514
|
+
}
|
|
40515
|
+
}
|
|
40516
|
+
birthSingleton(track, now) {
|
|
40517
|
+
const id = this.nextId();
|
|
40518
|
+
this.groups.set(id, {
|
|
40519
|
+
id,
|
|
40520
|
+
deviceId: track.deviceId,
|
|
40521
|
+
openedAt: now,
|
|
40522
|
+
members: new Set([track.trackId])
|
|
40523
|
+
});
|
|
40524
|
+
this.membership.set(track.trackId, id);
|
|
40525
|
+
}
|
|
40526
|
+
considerLeave(track, now) {
|
|
40527
|
+
const groupId = this.membership.get(track.trackId);
|
|
40528
|
+
if (groupId === void 0) return;
|
|
40529
|
+
const group = this.groups.get(groupId);
|
|
40530
|
+
if (!group || group.members.size < 2) {
|
|
40531
|
+
this.leaveSince.delete(track.trackId);
|
|
40532
|
+
return;
|
|
40533
|
+
}
|
|
40534
|
+
if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
|
|
40535
|
+
const other = this.snapshots.get(id);
|
|
40536
|
+
return other !== void 0 && pairTogetherNow(track, other, now);
|
|
40537
|
+
})) {
|
|
40538
|
+
this.leaveSince.delete(track.trackId);
|
|
40539
|
+
return;
|
|
40540
|
+
}
|
|
40541
|
+
const since = this.leaveSince.get(track.trackId) ?? now;
|
|
40542
|
+
this.leaveSince.set(track.trackId, since);
|
|
40543
|
+
if (now - since < 3e3) return;
|
|
40544
|
+
this.peel(track, now);
|
|
40545
|
+
}
|
|
40546
|
+
peel(track, now) {
|
|
40547
|
+
this.removeFromGroup(track.trackId);
|
|
40548
|
+
this.leaveSince.delete(track.trackId);
|
|
40549
|
+
this.birthSingleton(track, now);
|
|
40550
|
+
}
|
|
40551
|
+
considerJoin(track, now) {
|
|
40552
|
+
const mineId = this.membership.get(track.trackId);
|
|
40553
|
+
if (mineId === void 0) return;
|
|
40554
|
+
const mine = this.groups.get(mineId);
|
|
40555
|
+
if (!mine || mine.members.size !== 1) {
|
|
40556
|
+
this.clearJoinClocks(track.trackId);
|
|
40557
|
+
return;
|
|
40558
|
+
}
|
|
40559
|
+
let best;
|
|
40560
|
+
for (const group of this.groups.values()) {
|
|
40561
|
+
if (group.id === mineId || group.deviceId !== track.deviceId) continue;
|
|
40562
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
40563
|
+
let together = false;
|
|
40564
|
+
for (const memberId of group.members) {
|
|
40565
|
+
const other = this.snapshots.get(memberId);
|
|
40566
|
+
if (!other) continue;
|
|
40567
|
+
if (pairTogetherNow(track, other, now)) {
|
|
40568
|
+
together = true;
|
|
40569
|
+
gap = Math.min(gap, centroidGap(track, other, now));
|
|
40570
|
+
}
|
|
40571
|
+
}
|
|
40572
|
+
if (!together) continue;
|
|
40573
|
+
if (best === void 0 || gap < best.gap) best = {
|
|
40574
|
+
groupId: group.id,
|
|
40575
|
+
gap
|
|
40576
|
+
};
|
|
40577
|
+
}
|
|
40578
|
+
if (best === void 0) {
|
|
40579
|
+
this.clearJoinClocks(track.trackId);
|
|
40580
|
+
return;
|
|
40581
|
+
}
|
|
40582
|
+
const key = `${track.trackId}>${best.groupId}`;
|
|
40583
|
+
for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
|
|
40584
|
+
const since = this.joinSince.get(key) ?? now;
|
|
40585
|
+
this.joinSince.set(key, since);
|
|
40586
|
+
if (now - since < 1500) return;
|
|
40587
|
+
this.absorb(track.trackId, mineId, best.groupId);
|
|
40588
|
+
}
|
|
40589
|
+
absorb(trackId, fromId, intoId) {
|
|
40590
|
+
const into = this.groups.get(intoId);
|
|
40591
|
+
if (!into) return;
|
|
40592
|
+
into.members.add(trackId);
|
|
40593
|
+
this.membership.set(trackId, intoId);
|
|
40594
|
+
this.groups.delete(fromId);
|
|
40595
|
+
this.clearJoinClocks(trackId);
|
|
40596
|
+
this.leaveSince.delete(trackId);
|
|
40597
|
+
}
|
|
40598
|
+
removeFromGroup(trackId) {
|
|
40599
|
+
const groupId = this.membership.get(trackId);
|
|
40600
|
+
this.membership.delete(trackId);
|
|
40601
|
+
this.clearJoinClocks(trackId);
|
|
40602
|
+
this.leaveSince.delete(trackId);
|
|
40603
|
+
if (groupId === void 0) return;
|
|
40604
|
+
const group = this.groups.get(groupId);
|
|
40605
|
+
if (!group) return;
|
|
40606
|
+
group.members.delete(trackId);
|
|
40607
|
+
if (group.members.size === 0) this.groups.delete(groupId);
|
|
40608
|
+
}
|
|
40609
|
+
clearJoinClocks(trackId) {
|
|
40610
|
+
for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
|
|
40611
|
+
}
|
|
40612
|
+
toLive(group) {
|
|
40613
|
+
let lastActiveAt = group.openedAt;
|
|
40614
|
+
for (const id of group.members) {
|
|
40615
|
+
const snap = this.snapshots.get(id);
|
|
40616
|
+
if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
|
|
40617
|
+
}
|
|
40618
|
+
return {
|
|
40619
|
+
id: group.id,
|
|
40620
|
+
deviceId: group.deviceId,
|
|
40621
|
+
memberTrackIds: [...group.members],
|
|
40622
|
+
openedAt: group.openedAt,
|
|
40623
|
+
lastActiveAt
|
|
40624
|
+
};
|
|
40625
|
+
}
|
|
40626
|
+
};
|
|
40627
|
+
function centroidGap(a, b, now) {
|
|
40628
|
+
const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
|
|
40629
|
+
const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
|
|
40630
|
+
if (!pa || !pb) return Number.POSITIVE_INFINITY;
|
|
40631
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40632
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40633
|
+
return Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40634
|
+
}
|
|
40635
|
+
//#endregion
|
|
40636
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40637
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40638
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40639
|
+
//#endregion
|
|
40640
|
+
//#region src/pipeline-analytics/group/group-live-buffer.ts
|
|
40641
|
+
function toGroupableTrack(track) {
|
|
40642
|
+
return {
|
|
40643
|
+
trackId: track.trackId,
|
|
40644
|
+
deviceId: track.deviceId,
|
|
40645
|
+
className: track.className,
|
|
40646
|
+
firstSeen: track.firstSeen,
|
|
40647
|
+
lastSeen: track.lastSeen,
|
|
40648
|
+
positions: track.positions.map((p) => ({
|
|
40649
|
+
...p,
|
|
40650
|
+
bbox: { ...p.bbox }
|
|
40651
|
+
})),
|
|
40652
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40653
|
+
};
|
|
40654
|
+
}
|
|
40655
|
+
var GroupLiveBuffer = class {
|
|
40656
|
+
assigner;
|
|
40657
|
+
primed = /* @__PURE__ */ new Set();
|
|
40658
|
+
constructor(nextId) {
|
|
40659
|
+
this.assigner = new GroupLiveAssigner(nextId);
|
|
40660
|
+
}
|
|
40661
|
+
observe(track, now) {
|
|
40662
|
+
this.assigner.observe(track, now);
|
|
40663
|
+
}
|
|
40664
|
+
groupOf(trackId) {
|
|
40665
|
+
return this.assigner.groupOf(trackId);
|
|
40666
|
+
}
|
|
40667
|
+
snapshot(trackId) {
|
|
40668
|
+
return this.assigner.snapshot(trackId);
|
|
40669
|
+
}
|
|
40670
|
+
liveGroups() {
|
|
40671
|
+
return this.assigner.liveGroups();
|
|
40672
|
+
}
|
|
40673
|
+
idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40674
|
+
return this.assigner.idleGroups(now, idleMs);
|
|
40675
|
+
}
|
|
40676
|
+
drop(ids) {
|
|
40677
|
+
this.assigner.drop(ids);
|
|
40678
|
+
}
|
|
40679
|
+
isPrimed(deviceId) {
|
|
40680
|
+
return this.primed.has(deviceId);
|
|
40681
|
+
}
|
|
40682
|
+
markPrimed(deviceId) {
|
|
40683
|
+
this.primed.add(deviceId);
|
|
40684
|
+
}
|
|
40685
|
+
};
|
|
40686
|
+
//#endregion
|
|
40687
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40688
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40689
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40690
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40691
|
+
}
|
|
40692
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40693
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40694
|
+
W: frameWidth,
|
|
40695
|
+
H: frameHeight
|
|
40696
|
+
});
|
|
40697
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40698
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40699
|
+
}
|
|
40700
|
+
//#endregion
|
|
40701
|
+
//#region src/pipeline-analytics/group/group-instant.ts
|
|
40702
|
+
/**
|
|
40703
|
+
* Chosen instant + union bbox for a group, at close, over stored positions[]
|
|
40704
|
+
* (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
|
|
40705
|
+
*
|
|
40706
|
+
* Tier order — the first that separates two candidates decides:
|
|
40707
|
+
* 1. completeness (members with a position within ±1 s)
|
|
40708
|
+
* 2. square containment of the union (D212)
|
|
40709
|
+
* 3. semantic (a member's confirmed recognition)
|
|
40710
|
+
* 4. edge-clear (union touches no frame border)
|
|
40711
|
+
* 5. effective score = mean(confidence) × containment × sizeFactor
|
|
40712
|
+
*
|
|
40713
|
+
* Retrospective. No new capture path.
|
|
40714
|
+
*/
|
|
40715
|
+
var GROUP_MEDIA_MATCH_MS = 2e3;
|
|
40716
|
+
function nearest(positions, t, windowMs) {
|
|
40717
|
+
let best;
|
|
40718
|
+
let bestDt = windowMs + 1;
|
|
40719
|
+
for (const p of positions) {
|
|
40720
|
+
const dt = Math.abs(p.timestamp - t);
|
|
40721
|
+
if (dt <= windowMs && dt < bestDt) {
|
|
40722
|
+
best = p;
|
|
40723
|
+
bestDt = dt;
|
|
40724
|
+
}
|
|
40725
|
+
}
|
|
40726
|
+
return best;
|
|
40727
|
+
}
|
|
40728
|
+
function unionOf(boxes) {
|
|
40729
|
+
if (boxes.length === 0) return null;
|
|
40730
|
+
let x0 = Infinity;
|
|
40731
|
+
let y0 = Infinity;
|
|
40732
|
+
let x1 = -Infinity;
|
|
40733
|
+
let y1 = -Infinity;
|
|
40734
|
+
for (const b of boxes) {
|
|
40735
|
+
x0 = Math.min(x0, b.x);
|
|
40736
|
+
y0 = Math.min(y0, b.y);
|
|
40737
|
+
x1 = Math.max(x1, b.x + b.w);
|
|
40738
|
+
y1 = Math.max(y1, b.y + b.h);
|
|
40739
|
+
}
|
|
40740
|
+
return {
|
|
40741
|
+
x: x0,
|
|
40742
|
+
y: y0,
|
|
40743
|
+
w: x1 - x0,
|
|
40744
|
+
h: y1 - y0
|
|
40745
|
+
};
|
|
40746
|
+
}
|
|
40747
|
+
function touchesBorder(box, frame, tol = 1) {
|
|
40748
|
+
return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
|
|
40749
|
+
}
|
|
40750
|
+
function scoreInstant(members, t, frame) {
|
|
40751
|
+
const boxes = [];
|
|
40752
|
+
let semantic = false;
|
|
40753
|
+
let confSum = 0;
|
|
40754
|
+
let confN = 0;
|
|
40755
|
+
for (const m of members) {
|
|
40756
|
+
const p = nearest(m.positions, t, MATCH_WINDOW_MS);
|
|
40757
|
+
if (!p) continue;
|
|
40758
|
+
boxes.push(p.bbox);
|
|
40759
|
+
if (m.semantic === true) semantic = true;
|
|
40760
|
+
if (m.confidence !== void 0) {
|
|
40761
|
+
confSum += m.confidence;
|
|
40762
|
+
confN += 1;
|
|
40763
|
+
}
|
|
40764
|
+
}
|
|
40765
|
+
const union = unionOf(boxes);
|
|
40766
|
+
if (!union) return null;
|
|
40767
|
+
const square = centralSquareContainmentFraction(union, {
|
|
40768
|
+
W: frame.width,
|
|
40769
|
+
H: frame.height
|
|
40770
|
+
});
|
|
40771
|
+
const containment = bboxContainment(union, frame.width, frame.height);
|
|
40772
|
+
const size = bboxSizeScore(union, frame.width, frame.height);
|
|
40773
|
+
const meanConf = confN > 0 ? confSum / confN : 1;
|
|
40774
|
+
return {
|
|
40775
|
+
timestamp: t,
|
|
40776
|
+
union,
|
|
40777
|
+
visibleCount: boxes.length,
|
|
40778
|
+
square,
|
|
40779
|
+
semantic,
|
|
40780
|
+
edgeClear: !touchesBorder(union, frame),
|
|
40781
|
+
effective: meanConf * containment * size
|
|
40782
|
+
};
|
|
40783
|
+
}
|
|
40784
|
+
function better(a, b) {
|
|
40785
|
+
if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
|
|
40786
|
+
if (a.square !== b.square) return a.square > b.square ? a : b;
|
|
40787
|
+
if (a.semantic !== b.semantic) return a.semantic ? a : b;
|
|
40788
|
+
if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
|
|
40789
|
+
if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
|
|
40790
|
+
return a.timestamp <= b.timestamp ? a : b;
|
|
40791
|
+
}
|
|
40792
|
+
function chooseGroupInstant(members, frame) {
|
|
40793
|
+
if (members.length === 0) return null;
|
|
40794
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
40795
|
+
for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
|
|
40796
|
+
let best = null;
|
|
40797
|
+
for (const t of candidates) {
|
|
40798
|
+
const scored = scoreInstant(members, t, frame);
|
|
40799
|
+
if (!scored) continue;
|
|
40800
|
+
best = best === null ? scored : better(scored, best);
|
|
40801
|
+
}
|
|
40802
|
+
if (!best) return null;
|
|
40803
|
+
const w = Math.max(frame.width, 1);
|
|
40804
|
+
const h = Math.max(frame.height, 1);
|
|
40805
|
+
return {
|
|
40806
|
+
timestamp: best.timestamp,
|
|
40807
|
+
union: best.union,
|
|
40808
|
+
ux: best.union.x / w,
|
|
40809
|
+
uy: best.union.y / h,
|
|
40810
|
+
uw: best.union.w / w,
|
|
40811
|
+
uh: best.union.h / h,
|
|
40812
|
+
visibleCount: best.visibleCount
|
|
40813
|
+
};
|
|
40814
|
+
}
|
|
40815
|
+
/** Longest-lived member that owns a clean full frame; ties break on trackId. */
|
|
40816
|
+
function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
40817
|
+
const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
|
|
40818
|
+
if (eligible.length === 0) return null;
|
|
40819
|
+
return eligible.toSorted((a, b) => {
|
|
40820
|
+
const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
|
|
40821
|
+
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40822
|
+
})[0].trackId;
|
|
40823
|
+
}
|
|
40824
|
+
/**
|
|
40825
|
+
* The RGB source for a group crop is a full native frame. Thumbnails are
|
|
40826
|
+
* already single-subject crops and cannot substitute for union-bbox geometry.
|
|
40827
|
+
*
|
|
40828
|
+
* Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
|
|
40829
|
+
* if none, take the nearest keyFrame anyway — dropping the crop because the
|
|
40830
|
+
* best-shot was 3 s off the instant is how a recognised person left the
|
|
40831
|
+
* digest with no crop.
|
|
40832
|
+
*/
|
|
40833
|
+
function pickCleanPixelRow(rows, instantMs) {
|
|
40834
|
+
const ranked = (maxDt) => {
|
|
40835
|
+
const candidates = [];
|
|
40836
|
+
for (const row of rows) {
|
|
40837
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40838
|
+
if (rank < 0) continue;
|
|
40839
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40840
|
+
if (maxDt !== null && dt > maxDt) continue;
|
|
40841
|
+
candidates.push({
|
|
40842
|
+
key: row.key,
|
|
40843
|
+
dt,
|
|
40844
|
+
rank
|
|
40845
|
+
});
|
|
40846
|
+
}
|
|
40847
|
+
return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40848
|
+
};
|
|
40849
|
+
const near = ranked(GROUP_MEDIA_MATCH_MS);
|
|
40850
|
+
if (near !== void 0) return {
|
|
40851
|
+
key: near.key,
|
|
40852
|
+
usedFallback: false
|
|
40853
|
+
};
|
|
40854
|
+
const any = ranked(null);
|
|
40855
|
+
if (any === void 0) return null;
|
|
40856
|
+
return {
|
|
40857
|
+
key: any.key,
|
|
40858
|
+
usedFallback: true
|
|
40859
|
+
};
|
|
40860
|
+
}
|
|
40861
|
+
//#endregion
|
|
40862
|
+
//#region src/pipeline-analytics/group/group-live-replay.ts
|
|
40863
|
+
function sliceTrackAt(track, now) {
|
|
40864
|
+
const positions = track.positions.filter((p) => p.timestamp <= now);
|
|
40865
|
+
const last = positions[positions.length - 1];
|
|
40866
|
+
return {
|
|
40867
|
+
...track,
|
|
40868
|
+
lastSeen: last?.timestamp ?? track.firstSeen,
|
|
40869
|
+
positions
|
|
40870
|
+
};
|
|
40871
|
+
}
|
|
40872
|
+
function replayInto(tracks, observer) {
|
|
40873
|
+
const events = [];
|
|
40874
|
+
const byId = /* @__PURE__ */ new Map();
|
|
40875
|
+
for (const track of tracks) {
|
|
40876
|
+
byId.set(track.trackId, track);
|
|
40877
|
+
if (track.positions.length === 0) {
|
|
40878
|
+
events.push({
|
|
40879
|
+
t: track.lastSeen,
|
|
40880
|
+
trackId: track.trackId
|
|
40881
|
+
});
|
|
40882
|
+
continue;
|
|
40883
|
+
}
|
|
40884
|
+
for (const p of track.positions) events.push({
|
|
40885
|
+
t: p.timestamp,
|
|
40886
|
+
trackId: track.trackId
|
|
40887
|
+
});
|
|
40888
|
+
}
|
|
40889
|
+
events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
|
|
40890
|
+
let lastKey = "";
|
|
40891
|
+
for (const e of events) {
|
|
40892
|
+
const key = `${e.trackId}@${e.t}`;
|
|
40893
|
+
if (key === lastKey) continue;
|
|
40894
|
+
lastKey = key;
|
|
40895
|
+
const track = byId.get(e.trackId);
|
|
40896
|
+
if (!track) continue;
|
|
40897
|
+
observer.observe(sliceTrackAt(track, e.t), e.t);
|
|
40898
|
+
}
|
|
40899
|
+
}
|
|
40900
|
+
function replayLive(tracks, assigner = new GroupLiveAssigner()) {
|
|
40901
|
+
replayInto(tracks, assigner);
|
|
40902
|
+
return assigner;
|
|
40903
|
+
}
|
|
40904
|
+
//#endregion
|
|
40905
|
+
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
40906
|
+
var DEFAULT_FRAME = {
|
|
40907
|
+
width: 640,
|
|
40908
|
+
height: 360
|
|
40909
|
+
};
|
|
40910
|
+
var QUERY_LIMIT = 2e3;
|
|
40911
|
+
/** Crash recover: replay this camera only, on its next activity. */
|
|
40912
|
+
var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
|
|
40913
|
+
function toInstant(t) {
|
|
40914
|
+
return {
|
|
40915
|
+
trackId: t.trackId,
|
|
40916
|
+
deviceId: t.deviceId,
|
|
40917
|
+
className: t.className,
|
|
40918
|
+
firstSeen: t.firstSeen,
|
|
40919
|
+
lastSeen: t.lastSeen,
|
|
40920
|
+
positions: t.positions,
|
|
40921
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
40922
|
+
semantic: t.hasFace === true || t.hasPlate === true,
|
|
40923
|
+
confidence: t.importance
|
|
40924
|
+
};
|
|
40925
|
+
}
|
|
40926
|
+
function toSealer(track) {
|
|
40927
|
+
return {
|
|
40928
|
+
trackId: track.trackId,
|
|
40929
|
+
deviceId: track.deviceId,
|
|
40930
|
+
className: track.className,
|
|
40931
|
+
firstSeen: track.firstSeen,
|
|
40932
|
+
lastSeen: track.lastSeen,
|
|
40933
|
+
positions: track.positions,
|
|
40934
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40935
|
+
};
|
|
40936
|
+
}
|
|
40937
|
+
var GroupSealer = class {
|
|
40938
|
+
deps;
|
|
40939
|
+
constructor(deps) {
|
|
40940
|
+
this.deps = deps;
|
|
40941
|
+
}
|
|
40942
|
+
async flushIdle(buffer, now = this.deps.now()) {
|
|
40943
|
+
let written = 0;
|
|
40944
|
+
for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
|
|
40945
|
+
buffer.drop([group.id]);
|
|
40946
|
+
written += 1;
|
|
40947
|
+
}
|
|
40948
|
+
return written;
|
|
40949
|
+
}
|
|
40950
|
+
/**
|
|
40951
|
+
* First activity on camera D after a crash: replay 2 minutes of that camera
|
|
40952
|
+
* into the live buffer, persist whoever is already idle, keep the rest in RAM.
|
|
40953
|
+
*/
|
|
40954
|
+
async recoverCamera(deviceId, buffer) {
|
|
40955
|
+
if (buffer.isPrimed(deviceId)) return 0;
|
|
40956
|
+
const now = this.deps.now();
|
|
40957
|
+
try {
|
|
40958
|
+
replayInto(await this.deps.trackStore.queryHistorical({
|
|
40959
|
+
deviceId,
|
|
40960
|
+
since: now - GROUP_CRASH_LOOKBACK_MS,
|
|
40961
|
+
until: now,
|
|
40962
|
+
limit: QUERY_LIMIT,
|
|
40963
|
+
projection: "full"
|
|
40964
|
+
}), buffer);
|
|
40965
|
+
} catch (err) {
|
|
40966
|
+
this.deps.logger.warn("group sealer: lookback failed", {
|
|
40967
|
+
tags: { deviceId },
|
|
40968
|
+
meta: { error: String(err) }
|
|
40969
|
+
});
|
|
40970
|
+
} finally {
|
|
40971
|
+
buffer.markPrimed(deviceId);
|
|
40972
|
+
}
|
|
40973
|
+
return this.flushIdle(buffer, now);
|
|
40974
|
+
}
|
|
40975
|
+
/**
|
|
40976
|
+
* Seal ONE camera over a caller-owned window via live replay.
|
|
40977
|
+
*
|
|
40978
|
+
* This is how a digest recovers groups without a boot scan: the producer
|
|
40979
|
+
* already knows the window. Walking every camera for 48h at initialize()
|
|
40980
|
+
* is what wedged hub-main.
|
|
40981
|
+
*/
|
|
40982
|
+
async sealWindow(deviceId, since, until) {
|
|
40983
|
+
try {
|
|
40984
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
40985
|
+
deviceId,
|
|
40986
|
+
since,
|
|
40987
|
+
until,
|
|
40988
|
+
limit: QUERY_LIMIT,
|
|
40989
|
+
projection: "full"
|
|
40990
|
+
});
|
|
40991
|
+
if (closed.length === 0) return 0;
|
|
40992
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
40993
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
40994
|
+
if (pending.length === 0) return 0;
|
|
40995
|
+
const assigner = replayLive(pending);
|
|
40996
|
+
const now = this.deps.now();
|
|
40997
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40998
|
+
let written = 0;
|
|
40999
|
+
for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
|
|
41000
|
+
const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
41001
|
+
if (members.length === 0) continue;
|
|
41002
|
+
try {
|
|
41003
|
+
await this.persistPartition(members, group.openedAt);
|
|
41004
|
+
written += 1;
|
|
41005
|
+
} catch (err) {
|
|
41006
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41007
|
+
tags: { deviceId },
|
|
41008
|
+
meta: {
|
|
41009
|
+
members: members.map((m) => m.trackId),
|
|
41010
|
+
error: String(err)
|
|
41011
|
+
}
|
|
41012
|
+
});
|
|
41013
|
+
}
|
|
41014
|
+
}
|
|
41015
|
+
return written;
|
|
41016
|
+
} catch (err) {
|
|
41017
|
+
this.deps.logger.warn("group sealer: device failed", {
|
|
41018
|
+
tags: { deviceId },
|
|
41019
|
+
meta: { error: String(err) }
|
|
41020
|
+
});
|
|
41021
|
+
return 0;
|
|
41022
|
+
}
|
|
41023
|
+
}
|
|
41024
|
+
async persistLiveGroup(group, buffer) {
|
|
41025
|
+
const deviceId = group.deviceId;
|
|
41026
|
+
const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
|
|
41027
|
+
if (snaps.length === 0) return true;
|
|
41028
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41029
|
+
const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
|
|
41030
|
+
if (pending.length === 0) return true;
|
|
41031
|
+
try {
|
|
41032
|
+
await this.persistPartition(pending, group.openedAt);
|
|
41033
|
+
return true;
|
|
41034
|
+
} catch (err) {
|
|
41035
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41036
|
+
tags: { deviceId },
|
|
41037
|
+
meta: {
|
|
41038
|
+
members: pending.map((m) => m.trackId),
|
|
41039
|
+
error: String(err)
|
|
41040
|
+
}
|
|
41041
|
+
});
|
|
41042
|
+
return false;
|
|
41043
|
+
}
|
|
41044
|
+
}
|
|
41045
|
+
async persistPartition(members, openedAt) {
|
|
41046
|
+
const deviceId = members[0].deviceId;
|
|
41047
|
+
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
41048
|
+
const instantMembers = members.map(toInstant);
|
|
41049
|
+
const instant = chooseGroupInstant(instantMembers, frame);
|
|
41050
|
+
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
41051
|
+
const timestamp = instant?.timestamp ?? closedAt;
|
|
41052
|
+
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
41053
|
+
trackId: m.trackId,
|
|
41054
|
+
rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
|
|
41055
|
+
})));
|
|
41056
|
+
const ownsClean = (trackId) => {
|
|
41057
|
+
return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
|
|
41058
|
+
};
|
|
41059
|
+
const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
|
|
41060
|
+
let nativeKey = null;
|
|
41061
|
+
if (anchorTrackId !== null) {
|
|
41062
|
+
const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
|
|
41063
|
+
nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
|
|
41064
|
+
}
|
|
41065
|
+
const draft = await this.deps.groupStore.insert({
|
|
41066
|
+
deviceId,
|
|
41067
|
+
timestamp,
|
|
41068
|
+
openedAt,
|
|
41069
|
+
closedAt,
|
|
41070
|
+
members: members.map((m) => ({
|
|
41071
|
+
trackId: m.trackId,
|
|
41072
|
+
firstSeen: m.firstSeen
|
|
41073
|
+
})),
|
|
41074
|
+
anchorTrackId,
|
|
41075
|
+
ux: instant?.ux ?? 0,
|
|
41076
|
+
uy: instant?.uy ?? 0,
|
|
41077
|
+
uw: instant?.uw ?? 0,
|
|
41078
|
+
uh: instant?.uh ?? 0,
|
|
41079
|
+
frameWidth: frame.width,
|
|
41080
|
+
frameHeight: frame.height,
|
|
41081
|
+
mediaKey: null,
|
|
41082
|
+
nativeKey
|
|
41083
|
+
});
|
|
41084
|
+
if (members.length < 2 || instant === null) return;
|
|
41085
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
|
|
41086
|
+
if (source === null) {
|
|
41087
|
+
this.deps.logger.info("group sealer: no clean pixels for crop", {
|
|
41088
|
+
tags: { deviceId },
|
|
41089
|
+
meta: {
|
|
41090
|
+
groupId: draft.id,
|
|
41091
|
+
members: members.map((m) => m.trackId)
|
|
41092
|
+
}
|
|
41093
|
+
});
|
|
41094
|
+
return;
|
|
41095
|
+
}
|
|
41096
|
+
try {
|
|
41097
|
+
const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
41098
|
+
if (raw.info.channels !== 3) return;
|
|
41099
|
+
const union = {
|
|
41100
|
+
x: instant.ux * raw.info.width,
|
|
41101
|
+
y: instant.uy * raw.info.height,
|
|
41102
|
+
w: instant.uw * raw.info.width,
|
|
41103
|
+
h: instant.uh * raw.info.height
|
|
41104
|
+
};
|
|
41105
|
+
const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
|
|
41106
|
+
const mediaKey = await this.deps.mediaStore.put({
|
|
41107
|
+
deviceId,
|
|
41108
|
+
ownerKind: "group",
|
|
41109
|
+
ownerId: draft.id,
|
|
41110
|
+
kind: "thumbnail",
|
|
41111
|
+
timestamp,
|
|
41112
|
+
data: crop
|
|
41113
|
+
});
|
|
41114
|
+
await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
|
|
41115
|
+
} catch (err) {
|
|
41116
|
+
this.deps.logger.warn("group sealer: crop failed", {
|
|
41117
|
+
tags: { deviceId },
|
|
41118
|
+
meta: {
|
|
41119
|
+
groupId: draft.id,
|
|
41120
|
+
error: String(err)
|
|
41121
|
+
}
|
|
41122
|
+
});
|
|
41123
|
+
}
|
|
41124
|
+
}
|
|
41125
|
+
async pickCleanPixels(mediaInfos, instantMs, deviceId) {
|
|
41126
|
+
const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
|
|
41127
|
+
if (chosen === null) return null;
|
|
41128
|
+
if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
|
|
41129
|
+
tags: { deviceId },
|
|
41130
|
+
meta: {
|
|
41131
|
+
instantMs,
|
|
41132
|
+
mediaKey: chosen.key
|
|
41133
|
+
}
|
|
41134
|
+
});
|
|
41135
|
+
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
41136
|
+
if (file === null) return null;
|
|
41137
|
+
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
41138
|
+
}
|
|
41139
|
+
};
|
|
41140
|
+
//#endregion
|
|
41141
|
+
//#region src/pipeline-analytics/group/group-store.ts
|
|
41142
|
+
/**
|
|
41143
|
+
* The group ENTITY store — a co-moving partition of one session, with one
|
|
41144
|
+
* picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
|
|
41145
|
+
*
|
|
41146
|
+
* Two tables, two questions:
|
|
41147
|
+
*
|
|
41148
|
+
* - `pipeline-analytics:track-groups` is the group row. It has N members and
|
|
41149
|
+
* no `trackId` column; it is owned by its members (owner-indirection).
|
|
41150
|
+
* - `pipeline-analytics:track-group-members` is one row per member. The column
|
|
41151
|
+
* is named exactly `trackId` so the classification derives `track-owned`.
|
|
41152
|
+
*
|
|
41153
|
+
* `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
|
|
41154
|
+
* they are nulled when the anchor member is deleted alone. Everything else is
|
|
41155
|
+
* written once. Re-anchoring is deliberately not done.
|
|
41156
|
+
*
|
|
41157
|
+
* No age clock. A group dies when its last member does; a group with one live
|
|
41158
|
+
* member does not, and it never keeps a dead link.
|
|
41159
|
+
*/
|
|
41160
|
+
/**
|
|
41161
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
41162
|
+
* write="one row per co-moving group, sealed at idle — never a live row.
|
|
41163
|
+
* Membership is the RAM members at persist, not a session partition. mediaKey is
|
|
41164
|
+
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
41165
|
+
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
41166
|
+
* nativeKey are the only fields rewritten after birth."
|
|
41167
|
+
* retention="NO age clock (D61). Rows leave with their last member through
|
|
41168
|
+
* TrackGroupStore.deleteByTracks: memberships first; zero survivors
|
|
41169
|
+
* deletes the group's media then the row; survivors plus a lost anchor
|
|
41170
|
+
* null the pointer. The orphan audit verifies the same via a second
|
|
41171
|
+
* resolver (groupId → any member still present)."
|
|
41172
|
+
*/
|
|
41173
|
+
var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
|
|
41174
|
+
/**
|
|
41175
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
41176
|
+
* write="one row per member at group birth — id is '<groupId>:<trackId>'.
|
|
41177
|
+
* timestamp is the member's firstSeen so the orphan audit has a clock
|
|
41178
|
+
* column. Never rewritten."
|
|
41179
|
+
* retention="TRACK-OWNED and clockless (D61): the column is named exactly
|
|
41180
|
+
* trackId, so classification derives ownership and the cascade deletes
|
|
41181
|
+
* the row with its track. A group with surviving members stays."
|
|
41182
|
+
*/
|
|
41183
|
+
var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
|
|
41184
|
+
var TRACK_GROUPS_COLUMNS = [
|
|
41185
|
+
{
|
|
41186
|
+
name: "id",
|
|
41187
|
+
type: "TEXT",
|
|
41188
|
+
primaryKey: true,
|
|
41189
|
+
notNull: true
|
|
41190
|
+
},
|
|
41191
|
+
{
|
|
41192
|
+
name: "deviceId",
|
|
41193
|
+
type: "INTEGER",
|
|
41194
|
+
notNull: true
|
|
41195
|
+
},
|
|
41196
|
+
{
|
|
41197
|
+
name: "timestamp",
|
|
41198
|
+
type: "INTEGER",
|
|
41199
|
+
notNull: true
|
|
41200
|
+
},
|
|
41201
|
+
{
|
|
41202
|
+
name: "openedAt",
|
|
41203
|
+
type: "INTEGER",
|
|
41204
|
+
notNull: true
|
|
41205
|
+
},
|
|
41206
|
+
{
|
|
41207
|
+
name: "closedAt",
|
|
41208
|
+
type: "INTEGER",
|
|
41209
|
+
notNull: true
|
|
41210
|
+
},
|
|
41211
|
+
{
|
|
41212
|
+
name: "memberCount",
|
|
41213
|
+
type: "INTEGER",
|
|
41214
|
+
notNull: true
|
|
41215
|
+
},
|
|
41216
|
+
{
|
|
41217
|
+
name: "anchorTrackId",
|
|
41218
|
+
type: "TEXT"
|
|
41219
|
+
},
|
|
41220
|
+
{
|
|
41221
|
+
name: "ux",
|
|
41222
|
+
type: "REAL",
|
|
41223
|
+
notNull: true
|
|
41224
|
+
},
|
|
41225
|
+
{
|
|
41226
|
+
name: "uy",
|
|
41227
|
+
type: "REAL",
|
|
41228
|
+
notNull: true
|
|
41229
|
+
},
|
|
41230
|
+
{
|
|
41231
|
+
name: "uw",
|
|
41232
|
+
type: "REAL",
|
|
41233
|
+
notNull: true
|
|
41234
|
+
},
|
|
41235
|
+
{
|
|
41236
|
+
name: "uh",
|
|
41237
|
+
type: "REAL",
|
|
41238
|
+
notNull: true
|
|
41239
|
+
},
|
|
41240
|
+
{
|
|
41241
|
+
name: "frameWidth",
|
|
41242
|
+
type: "INTEGER",
|
|
41243
|
+
notNull: true
|
|
41244
|
+
},
|
|
41245
|
+
{
|
|
41246
|
+
name: "frameHeight",
|
|
41247
|
+
type: "INTEGER",
|
|
41248
|
+
notNull: true
|
|
41249
|
+
},
|
|
41250
|
+
{
|
|
41251
|
+
name: "mediaKey",
|
|
41252
|
+
type: "TEXT"
|
|
41253
|
+
},
|
|
41254
|
+
{
|
|
41255
|
+
name: "nativeKey",
|
|
41256
|
+
type: "TEXT"
|
|
41257
|
+
},
|
|
41258
|
+
{
|
|
41259
|
+
name: "sealed",
|
|
41260
|
+
type: "BOOLEAN",
|
|
41261
|
+
notNull: true
|
|
41262
|
+
}
|
|
41263
|
+
];
|
|
41264
|
+
var TRACK_GROUP_MEMBERS_COLUMNS = [
|
|
41265
|
+
{
|
|
41266
|
+
name: "id",
|
|
41267
|
+
type: "TEXT",
|
|
41268
|
+
primaryKey: true,
|
|
41269
|
+
notNull: true
|
|
41270
|
+
},
|
|
41271
|
+
{
|
|
41272
|
+
name: "groupId",
|
|
41273
|
+
type: "TEXT",
|
|
41274
|
+
notNull: true
|
|
41275
|
+
},
|
|
41276
|
+
{
|
|
41277
|
+
name: "trackId",
|
|
41278
|
+
type: "TEXT",
|
|
41279
|
+
notNull: true
|
|
41280
|
+
},
|
|
41281
|
+
{
|
|
41282
|
+
name: "deviceId",
|
|
41283
|
+
type: "INTEGER",
|
|
41284
|
+
notNull: true
|
|
41285
|
+
},
|
|
41286
|
+
{
|
|
41287
|
+
name: "timestamp",
|
|
41288
|
+
type: "INTEGER",
|
|
41289
|
+
notNull: true
|
|
41290
|
+
}
|
|
41291
|
+
];
|
|
41292
|
+
var TRACK_GROUPS_INDEXES = [{
|
|
41293
|
+
name: "idx_track_groups_device_closed",
|
|
41294
|
+
columns: ["deviceId", "closedAt"]
|
|
41295
|
+
}, {
|
|
41296
|
+
name: "idx_track_groups_device_opened",
|
|
41297
|
+
columns: ["deviceId", "openedAt"]
|
|
41298
|
+
}];
|
|
41299
|
+
var TRACK_GROUP_MEMBERS_INDEXES = [{
|
|
41300
|
+
name: "idx_track_group_members_track",
|
|
41301
|
+
columns: ["trackId"]
|
|
41302
|
+
}, {
|
|
41303
|
+
name: "idx_track_group_members_group",
|
|
41304
|
+
columns: ["groupId"]
|
|
41305
|
+
}];
|
|
41306
|
+
function newGroupId() {
|
|
41307
|
+
return `grp-${randomUUID()}`;
|
|
41308
|
+
}
|
|
41309
|
+
function memberRowId(groupId, trackId) {
|
|
41310
|
+
return `${groupId}:${trackId}`;
|
|
41311
|
+
}
|
|
41312
|
+
var TrackGroupStore = class {
|
|
41313
|
+
store;
|
|
41314
|
+
logger;
|
|
41315
|
+
media;
|
|
41316
|
+
constructor(deps) {
|
|
41317
|
+
this.store = deps.store;
|
|
41318
|
+
this.logger = deps.logger;
|
|
41319
|
+
this.media = deps.media ?? null;
|
|
41320
|
+
}
|
|
41321
|
+
static async declare(store) {
|
|
41322
|
+
await Promise.all([store.declareCollection.mutate({
|
|
41323
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41324
|
+
columns: [...TRACK_GROUPS_COLUMNS],
|
|
41325
|
+
indexes: [...TRACK_GROUPS_INDEXES]
|
|
41326
|
+
}), store.declareCollection.mutate({
|
|
41327
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41328
|
+
columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
|
|
41329
|
+
indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
|
|
41330
|
+
})]);
|
|
41331
|
+
}
|
|
41332
|
+
async insert(input) {
|
|
41333
|
+
const id = newGroupId();
|
|
41334
|
+
const group = {
|
|
41335
|
+
id,
|
|
41336
|
+
deviceId: input.deviceId,
|
|
41337
|
+
timestamp: input.timestamp,
|
|
41338
|
+
openedAt: input.openedAt,
|
|
41339
|
+
closedAt: input.closedAt,
|
|
41340
|
+
memberCount: input.members.length,
|
|
41341
|
+
anchorTrackId: input.anchorTrackId,
|
|
41342
|
+
ux: input.ux,
|
|
41343
|
+
uy: input.uy,
|
|
41344
|
+
uw: input.uw,
|
|
41345
|
+
uh: input.uh,
|
|
41346
|
+
frameWidth: input.frameWidth,
|
|
41347
|
+
frameHeight: input.frameHeight,
|
|
41348
|
+
mediaKey: input.mediaKey,
|
|
41349
|
+
nativeKey: input.nativeKey,
|
|
41350
|
+
sealed: true
|
|
41351
|
+
};
|
|
41352
|
+
await this.store.set.mutate({
|
|
41353
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41354
|
+
key: id,
|
|
41355
|
+
value: this.toRow(group)
|
|
41356
|
+
});
|
|
41357
|
+
for (const member of input.members) await this.store.set.mutate({
|
|
41358
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41359
|
+
key: memberRowId(id, member.trackId),
|
|
41360
|
+
value: {
|
|
41361
|
+
groupId: id,
|
|
41362
|
+
trackId: member.trackId,
|
|
41363
|
+
deviceId: input.deviceId,
|
|
41364
|
+
timestamp: member.firstSeen
|
|
41365
|
+
}
|
|
41366
|
+
});
|
|
41367
|
+
return group;
|
|
41368
|
+
}
|
|
41369
|
+
/** The crop is written after birth; this is the only post-birth write besides the pointer null. */
|
|
41370
|
+
async setMediaKey(groupId, mediaKey) {
|
|
41371
|
+
const current = await this.get(groupId);
|
|
41372
|
+
if (current === null) return;
|
|
41373
|
+
await this.store.set.mutate({
|
|
41374
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41375
|
+
key: groupId,
|
|
41376
|
+
value: this.toRow({
|
|
41377
|
+
...current,
|
|
41378
|
+
mediaKey
|
|
41379
|
+
})
|
|
41380
|
+
});
|
|
41381
|
+
}
|
|
41382
|
+
async get(groupId) {
|
|
41383
|
+
const raw = await this.store.get.query({
|
|
41384
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41385
|
+
key: groupId
|
|
41386
|
+
});
|
|
41387
|
+
if (!raw) return null;
|
|
41388
|
+
return this.normalizeGroup({
|
|
41389
|
+
id: groupId,
|
|
41390
|
+
data: raw
|
|
41391
|
+
});
|
|
41392
|
+
}
|
|
41393
|
+
async listMembers(groupId) {
|
|
41394
|
+
return (await this.store.query.query({
|
|
41395
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41396
|
+
filter: { where: { groupId } }
|
|
41397
|
+
})).map((r) => this.normalizeMember({
|
|
41398
|
+
id: r.id,
|
|
41399
|
+
data: r.data
|
|
41400
|
+
}));
|
|
41401
|
+
}
|
|
41402
|
+
async membersOfTracks(trackIds) {
|
|
41403
|
+
const out = [];
|
|
41404
|
+
for (const trackId of trackIds) {
|
|
41405
|
+
const rows = await this.store.query.query({
|
|
41406
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41407
|
+
filter: { where: { trackId } }
|
|
41408
|
+
});
|
|
41409
|
+
for (const r of rows) out.push(this.normalizeMember({
|
|
41410
|
+
id: r.id,
|
|
41411
|
+
data: r.data
|
|
41412
|
+
}));
|
|
41413
|
+
}
|
|
41414
|
+
return out;
|
|
41415
|
+
}
|
|
41416
|
+
/**
|
|
41417
|
+
* Newest-closed page for the Library Groups tab.
|
|
41418
|
+
*
|
|
41419
|
+
* Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
|
|
41420
|
+
* same as {@link listOverlapping}. Pagination is applied AFTER the window
|
|
41421
|
+
* so a day cursor is honest rather than a raw-row offset that skips live
|
|
41422
|
+
* groups on a busy camera.
|
|
41423
|
+
*/
|
|
41424
|
+
async listRecent(input) {
|
|
41425
|
+
const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
|
|
41426
|
+
const offset = Math.max(input.offset ?? 0, 0);
|
|
41427
|
+
const slice = (await this.store.query.query({
|
|
41428
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41429
|
+
filter: {
|
|
41430
|
+
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
41431
|
+
orderBy: {
|
|
41432
|
+
field: "closedAt",
|
|
41433
|
+
direction: "desc"
|
|
41434
|
+
},
|
|
41435
|
+
limit: 2e3
|
|
41436
|
+
}
|
|
41437
|
+
})).map((r) => this.normalizeGroup({
|
|
41438
|
+
id: r.id,
|
|
41439
|
+
data: r.data
|
|
41440
|
+
})).filter((g) => {
|
|
41441
|
+
if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
|
|
41442
|
+
if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
|
|
41443
|
+
return true;
|
|
41444
|
+
}).slice(offset, offset + limit + 1);
|
|
41445
|
+
return {
|
|
41446
|
+
groups: slice.slice(0, limit),
|
|
41447
|
+
hasMore: slice.length > limit
|
|
41448
|
+
};
|
|
41449
|
+
}
|
|
41450
|
+
/** Groups whose life overlaps `[since, until]` on one camera. */
|
|
41451
|
+
async listOverlapping(input) {
|
|
41452
|
+
return (await this.store.query.query({
|
|
41453
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41454
|
+
filter: {
|
|
41455
|
+
where: { deviceId: input.deviceId },
|
|
41456
|
+
orderBy: {
|
|
41457
|
+
field: "closedAt",
|
|
41458
|
+
direction: "desc"
|
|
41459
|
+
},
|
|
41460
|
+
limit: input.limit ?? 2e3
|
|
41461
|
+
}
|
|
41462
|
+
})).map((r) => this.normalizeGroup({
|
|
41463
|
+
id: r.id,
|
|
41464
|
+
data: r.data
|
|
41465
|
+
})).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
|
|
41466
|
+
}
|
|
41467
|
+
async existingGroupIds(ids) {
|
|
41468
|
+
const live = /* @__PURE__ */ new Set();
|
|
41469
|
+
for (const id of ids) if (await this.store.get.query({
|
|
41470
|
+
collection: "pipeline-analytics:track-groups",
|
|
41471
|
+
key: id
|
|
41472
|
+
})) live.add(id);
|
|
41473
|
+
return live;
|
|
41474
|
+
}
|
|
41475
|
+
/** Group ids that still have at least one membership row. */
|
|
41476
|
+
async groupsWithMembers(ids) {
|
|
41477
|
+
const live = /* @__PURE__ */ new Set();
|
|
41478
|
+
for (const groupId of ids) if ((await this.store.query.query({
|
|
41479
|
+
collection: "pipeline-analytics:track-group-members",
|
|
41480
|
+
filter: {
|
|
41481
|
+
where: { groupId },
|
|
41482
|
+
limit: 1
|
|
41483
|
+
}
|
|
41484
|
+
})).length > 0) live.add(groupId);
|
|
41485
|
+
return live;
|
|
41486
|
+
}
|
|
41487
|
+
/**
|
|
41488
|
+
* Cascade leaf: delete memberships for the batch; a group with zero survivors
|
|
41489
|
+
* dies (media then row); a group that lost only its anchor nulls the pointer.
|
|
41490
|
+
*/
|
|
41491
|
+
async deleteByTracks(trackIds) {
|
|
41492
|
+
const batch = new Set(trackIds);
|
|
41493
|
+
let memberRows = 0;
|
|
41494
|
+
let groupRows = 0;
|
|
41495
|
+
let mediaRows = 0;
|
|
41496
|
+
let mediaBytes = 0;
|
|
41497
|
+
const affected = /* @__PURE__ */ new Set();
|
|
41498
|
+
try {
|
|
41499
|
+
const memberships = await this.membersOfTracks(trackIds);
|
|
41500
|
+
for (const member of memberships) try {
|
|
41501
|
+
await this.store.delete.mutate({
|
|
41502
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
41503
|
+
key: member.id
|
|
41504
|
+
});
|
|
41505
|
+
memberRows += 1;
|
|
41506
|
+
affected.add(member.groupId);
|
|
41507
|
+
} catch (err) {
|
|
41508
|
+
this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
|
|
41509
|
+
memberId: member.id,
|
|
41510
|
+
error: String(err)
|
|
41511
|
+
} });
|
|
41512
|
+
}
|
|
41513
|
+
for (const groupId of affected) try {
|
|
41514
|
+
if ((await this.listMembers(groupId)).length === 0) {
|
|
41515
|
+
if (this.media) {
|
|
41516
|
+
const gone = await this.media.deleteByGroups([groupId]);
|
|
41517
|
+
mediaRows += gone.rows;
|
|
41518
|
+
mediaBytes += gone.bytes;
|
|
41519
|
+
}
|
|
41520
|
+
await this.store.delete.mutate({
|
|
41521
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41522
|
+
key: groupId
|
|
41523
|
+
});
|
|
41524
|
+
groupRows += 1;
|
|
41525
|
+
continue;
|
|
41526
|
+
}
|
|
41527
|
+
const group = await this.get(groupId);
|
|
41528
|
+
if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
|
|
41529
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
41530
|
+
key: groupId,
|
|
41531
|
+
value: this.toRow({
|
|
41532
|
+
...group,
|
|
41533
|
+
anchorTrackId: null,
|
|
41534
|
+
nativeKey: null
|
|
41535
|
+
})
|
|
41536
|
+
});
|
|
41537
|
+
} catch (err) {
|
|
41538
|
+
this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
|
|
41539
|
+
groupId,
|
|
41540
|
+
error: String(err)
|
|
41541
|
+
} });
|
|
41542
|
+
}
|
|
41543
|
+
} catch (err) {
|
|
41544
|
+
this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
|
|
41545
|
+
}
|
|
41546
|
+
return [{
|
|
41547
|
+
family: "groups",
|
|
41548
|
+
rows: memberRows + groupRows,
|
|
41549
|
+
bytes: 0
|
|
41550
|
+
}, {
|
|
41551
|
+
family: "media",
|
|
41552
|
+
rows: mediaRows,
|
|
41553
|
+
bytes: mediaBytes
|
|
41554
|
+
}];
|
|
41555
|
+
}
|
|
41556
|
+
toRow(group) {
|
|
41557
|
+
return {
|
|
41558
|
+
deviceId: group.deviceId,
|
|
41559
|
+
timestamp: group.timestamp,
|
|
41560
|
+
openedAt: group.openedAt,
|
|
41561
|
+
closedAt: group.closedAt,
|
|
41562
|
+
memberCount: group.memberCount,
|
|
41563
|
+
anchorTrackId: group.anchorTrackId,
|
|
41564
|
+
ux: group.ux,
|
|
41565
|
+
uy: group.uy,
|
|
41566
|
+
uw: group.uw,
|
|
41567
|
+
uh: group.uh,
|
|
41568
|
+
frameWidth: group.frameWidth,
|
|
41569
|
+
frameHeight: group.frameHeight,
|
|
41570
|
+
mediaKey: group.mediaKey,
|
|
41571
|
+
nativeKey: group.nativeKey,
|
|
41572
|
+
sealed: group.sealed
|
|
41573
|
+
};
|
|
41574
|
+
}
|
|
41575
|
+
normalizeGroup(r) {
|
|
41576
|
+
const data = r.data;
|
|
41577
|
+
return {
|
|
41578
|
+
id: r.id,
|
|
41579
|
+
deviceId: Number(data["deviceId"]),
|
|
41580
|
+
timestamp: Number(data["timestamp"]),
|
|
41581
|
+
openedAt: Number(data["openedAt"]),
|
|
41582
|
+
closedAt: Number(data["closedAt"]),
|
|
41583
|
+
memberCount: Number(data["memberCount"]),
|
|
41584
|
+
anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
|
|
41585
|
+
ux: Number(data["ux"]),
|
|
41586
|
+
uy: Number(data["uy"]),
|
|
41587
|
+
uw: Number(data["uw"]),
|
|
41588
|
+
uh: Number(data["uh"]),
|
|
41589
|
+
frameWidth: Number(data["frameWidth"]),
|
|
41590
|
+
frameHeight: Number(data["frameHeight"]),
|
|
41591
|
+
mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
|
|
41592
|
+
nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
|
|
41593
|
+
sealed: Boolean(data["sealed"])
|
|
41594
|
+
};
|
|
41595
|
+
}
|
|
41596
|
+
normalizeMember(r) {
|
|
41597
|
+
const data = r.data;
|
|
41598
|
+
return {
|
|
41599
|
+
id: r.id,
|
|
41600
|
+
groupId: String(data["groupId"]),
|
|
41601
|
+
trackId: String(data["trackId"]),
|
|
41602
|
+
deviceId: Number(data["deviceId"]),
|
|
41603
|
+
timestamp: Number(data["timestamp"])
|
|
41604
|
+
};
|
|
41605
|
+
}
|
|
41606
|
+
};
|
|
40072
41607
|
//#endregion
|
|
40073
41608
|
//#region src/pipeline-analytics/pipeline/dropout-skip.ts
|
|
40074
41609
|
function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
|
|
@@ -46686,6 +48221,15 @@ function isAfterCursor(row, cursor) {
|
|
|
46686
48221
|
if (row.lastSeen < cursor.lastSeen) return true;
|
|
46687
48222
|
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
46688
48223
|
}
|
|
48224
|
+
function netDisplacementPx(positions) {
|
|
48225
|
+
const first = positions[0];
|
|
48226
|
+
const last = positions[positions.length - 1];
|
|
48227
|
+
if (!first || !last) return 0;
|
|
48228
|
+
return Math.hypot(last.x - first.x, last.y - first.y);
|
|
48229
|
+
}
|
|
48230
|
+
function isShortMotionlessTrack(lifeMs, movedPx) {
|
|
48231
|
+
return lifeMs <= 25e3 && movedPx <= 8;
|
|
48232
|
+
}
|
|
46689
48233
|
//#endregion
|
|
46690
48234
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
46691
48235
|
/**
|
|
@@ -47268,6 +48812,7 @@ var TrackStore = class {
|
|
|
47268
48812
|
for (const [trackId, t] of this.active) {
|
|
47269
48813
|
if (nowMs - t.lastSeen < this.config.ttlMs) continue;
|
|
47270
48814
|
t.active = false;
|
|
48815
|
+
if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
|
|
47271
48816
|
const record = cloneTrack(t);
|
|
47272
48817
|
try {
|
|
47273
48818
|
await this.persistCompleted(record);
|
|
@@ -48017,10 +49562,21 @@ var TrackStore = class {
|
|
|
48017
49562
|
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
48018
49563
|
*/
|
|
48019
49564
|
async listDeviceIds() {
|
|
49565
|
+
return this.collectDeviceIds(0);
|
|
49566
|
+
}
|
|
49567
|
+
/**
|
|
49568
|
+
* Distinct deviceIds with a persisted track whose `lastSeen` is at least
|
|
49569
|
+
* `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
|
|
49570
|
+
* `sealRecent` wedged hub-main every TTL sweep.
|
|
49571
|
+
*/
|
|
49572
|
+
async listDeviceIdsSince(since) {
|
|
49573
|
+
return this.collectDeviceIds(Math.max(0, Math.floor(since)));
|
|
49574
|
+
}
|
|
49575
|
+
async collectDeviceIds(fromLastSeen) {
|
|
48020
49576
|
const seenDevices = /* @__PURE__ */ new Set();
|
|
48021
49577
|
const seenIds = /* @__PURE__ */ new Set();
|
|
48022
49578
|
const PAGE = 500;
|
|
48023
|
-
let cursor =
|
|
49579
|
+
let cursor = fromLastSeen;
|
|
48024
49580
|
try {
|
|
48025
49581
|
for (;;) {
|
|
48026
49582
|
const rows = await this.store.query.query({
|
|
@@ -48816,12 +50372,24 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48816
50372
|
collection: PLATES_COLLECTION,
|
|
48817
50373
|
classification: derived(PLATE_COLUMNS)
|
|
48818
50374
|
},
|
|
50375
|
+
{
|
|
50376
|
+
collection: TRACK_GROUP_MEMBERS_COLLECTION,
|
|
50377
|
+
classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
|
|
50378
|
+
},
|
|
48819
50379
|
{
|
|
48820
50380
|
collection: MEDIA_COLLECTION,
|
|
48821
50381
|
classification: {
|
|
48822
50382
|
kind: "owner-indirection",
|
|
48823
50383
|
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."
|
|
50384
|
+
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."
|
|
50385
|
+
}
|
|
50386
|
+
},
|
|
50387
|
+
{
|
|
50388
|
+
collection: TRACK_GROUPS_COLLECTION,
|
|
50389
|
+
classification: {
|
|
50390
|
+
kind: "owner-indirection",
|
|
50391
|
+
derivation: "explicit",
|
|
50392
|
+
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
50393
|
}
|
|
48826
50394
|
},
|
|
48827
50395
|
{
|
|
@@ -48933,7 +50501,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
48933
50501
|
collection: SCENE_LATCH_COLLECTION,
|
|
48934
50502
|
classification: {
|
|
48935
50503
|
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
|
|
50504
|
+
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
50505
|
}
|
|
48938
50506
|
},
|
|
48939
50507
|
{
|
|
@@ -49021,6 +50589,10 @@ function resolveMediaOwner(row) {
|
|
|
49021
50589
|
kind: "unresolvable",
|
|
49022
50590
|
why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
|
|
49023
50591
|
};
|
|
50592
|
+
case "group": return {
|
|
50593
|
+
kind: "group",
|
|
50594
|
+
groupId: ownerId
|
|
50595
|
+
};
|
|
49024
50596
|
default: return {
|
|
49025
50597
|
kind: "unresolvable",
|
|
49026
50598
|
why: `unknown ownerKind '${kindRaw}'`
|
|
@@ -49032,6 +50604,13 @@ function resolveMediaOwner(row) {
|
|
|
49032
50604
|
* is derived the same way the classification is — an enrolled row is out of
|
|
49033
50605
|
* reach regardless of what happened to the track that produced it.
|
|
49034
50606
|
*/
|
|
50607
|
+
/** A group row lives iff any membership row still names it. */
|
|
50608
|
+
function resolveGroupOwner(row) {
|
|
50609
|
+
return {
|
|
50610
|
+
kind: "group-members",
|
|
50611
|
+
groupId: row.key
|
|
50612
|
+
};
|
|
50613
|
+
}
|
|
49035
50614
|
function resolveDeclaredTrackOwner(row, enrolmentExempt) {
|
|
49036
50615
|
if (enrolmentExempt && row.data["assigned"] === true) return {
|
|
49037
50616
|
kind: "exempt",
|
|
@@ -49051,15 +50630,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
|
|
|
49051
50630
|
const scopes = [];
|
|
49052
50631
|
for (const entry of collections) {
|
|
49053
50632
|
if (entry.classification.kind === "owner-indirection") {
|
|
49054
|
-
if (entry.collection
|
|
49055
|
-
|
|
49056
|
-
|
|
49057
|
-
|
|
49058
|
-
|
|
49059
|
-
|
|
49060
|
-
|
|
49061
|
-
|
|
49062
|
-
|
|
50633
|
+
if (entry.collection === "pipeline-analytics:media") {
|
|
50634
|
+
scopes.push({
|
|
50635
|
+
id: entry.collection,
|
|
50636
|
+
collection: entry.collection,
|
|
50637
|
+
resolve: resolveMediaOwner,
|
|
50638
|
+
timestampField: "timestamp",
|
|
50639
|
+
sizeField: "sizeBytes",
|
|
50640
|
+
deviceIdField: "deviceId"
|
|
50641
|
+
});
|
|
50642
|
+
continue;
|
|
50643
|
+
}
|
|
50644
|
+
if (entry.collection === "pipeline-analytics:track-groups") {
|
|
50645
|
+
scopes.push({
|
|
50646
|
+
id: entry.collection,
|
|
50647
|
+
collection: entry.collection,
|
|
50648
|
+
resolve: resolveGroupOwner,
|
|
50649
|
+
timestampField: "timestamp",
|
|
50650
|
+
sizeField: null,
|
|
50651
|
+
deviceIdField: "deviceId"
|
|
50652
|
+
});
|
|
50653
|
+
continue;
|
|
50654
|
+
}
|
|
49063
50655
|
continue;
|
|
49064
50656
|
}
|
|
49065
50657
|
if (entry.classification.kind !== "track-owned") continue;
|
|
@@ -49091,11 +50683,17 @@ var DEFAULT_ORPHAN_LIMITS = {
|
|
|
49091
50683
|
function questionsFor(refs) {
|
|
49092
50684
|
const trackIds = /* @__PURE__ */ new Set();
|
|
49093
50685
|
const eventIds = /* @__PURE__ */ new Set();
|
|
50686
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
50687
|
+
const groupMemberIds = /* @__PURE__ */ new Set();
|
|
49094
50688
|
for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
|
|
49095
50689
|
else if (ref.kind === "event") eventIds.add(ref.eventId);
|
|
50690
|
+
else if (ref.kind === "group") groupIds.add(ref.groupId);
|
|
50691
|
+
else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
|
|
49096
50692
|
return {
|
|
49097
50693
|
trackIds: [...trackIds],
|
|
49098
|
-
eventIds: [...eventIds]
|
|
50694
|
+
eventIds: [...eventIds],
|
|
50695
|
+
groupIds: [...groupIds],
|
|
50696
|
+
groupMemberIds: [...groupMemberIds]
|
|
49099
50697
|
};
|
|
49100
50698
|
}
|
|
49101
50699
|
/**
|
|
@@ -49127,12 +50725,16 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49127
50725
|
break;
|
|
49128
50726
|
}
|
|
49129
50727
|
const refs = rows.map((row) => scope.resolve(row));
|
|
49130
|
-
const { trackIds, eventIds } = questionsFor(refs);
|
|
50728
|
+
const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
|
|
49131
50729
|
let liveTracks;
|
|
49132
50730
|
let liveEvents;
|
|
50731
|
+
let liveGroups;
|
|
50732
|
+
let liveGroupsWithMembers;
|
|
49133
50733
|
try {
|
|
49134
50734
|
liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
|
|
49135
50735
|
liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
|
|
50736
|
+
liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
|
|
50737
|
+
liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
|
|
49136
50738
|
} catch (err) {
|
|
49137
50739
|
deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
|
|
49138
50740
|
collection: scope.collection,
|
|
@@ -49156,7 +50758,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
49156
50758
|
tally.unresolvable += 1;
|
|
49157
50759
|
continue;
|
|
49158
50760
|
}
|
|
49159
|
-
if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
|
|
50761
|
+
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
50762
|
const ts = num(row.data, scope.timestampField);
|
|
49161
50763
|
if (ts !== null && ts > graceFloor) {
|
|
49162
50764
|
tally.withinGrace += 1;
|
|
@@ -51248,6 +52850,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
|
|
|
51248
52850
|
};
|
|
51249
52851
|
}
|
|
51250
52852
|
//#endregion
|
|
52853
|
+
//#region src/pipeline-analytics/pipeline/usable-bbox.ts
|
|
52854
|
+
/** A box that can legally drive a crop. The leaving-event fallback
|
|
52855
|
+
* `{0,0,0,0}` must never become a face/plate JPEG. */
|
|
52856
|
+
function isUsableBbox(bbox) {
|
|
52857
|
+
return bbox.w > 0 && bbox.h > 0;
|
|
52858
|
+
}
|
|
52859
|
+
//#endregion
|
|
51251
52860
|
//#region src/pipeline-analytics/services/event-media-dispatcher.ts
|
|
51252
52861
|
/**
|
|
51253
52862
|
* Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
|
|
@@ -51826,26 +53435,29 @@ var EventMediaDispatcher = class {
|
|
|
51826
53435
|
}
|
|
51827
53436
|
}
|
|
51828
53437
|
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: {
|
|
53438
|
+
if (ev.childCrops) for (const child of ev.childCrops) {
|
|
53439
|
+
if (!isUsableBbox(child.bbox)) continue;
|
|
53440
|
+
try {
|
|
53441
|
+
const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
|
|
53442
|
+
if (!childCropData) continue;
|
|
53443
|
+
await this.deps.mediaStore.put({
|
|
51844
53444
|
deviceId,
|
|
51845
|
-
|
|
51846
|
-
|
|
51847
|
-
|
|
51848
|
-
|
|
53445
|
+
ownerKind: "event",
|
|
53446
|
+
ownerId: ev.eventId,
|
|
53447
|
+
kind: child.kind,
|
|
53448
|
+
timestamp: ev.timestamp,
|
|
53449
|
+
data: childCropData
|
|
53450
|
+
});
|
|
53451
|
+
} catch (err) {
|
|
53452
|
+
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
53453
|
+
tags: { deviceId },
|
|
53454
|
+
meta: {
|
|
53455
|
+
deviceId,
|
|
53456
|
+
eventId: ev.eventId,
|
|
53457
|
+
error: err instanceof Error ? err.message : String(err)
|
|
53458
|
+
}
|
|
53459
|
+
});
|
|
53460
|
+
}
|
|
51849
53461
|
}
|
|
51850
53462
|
}
|
|
51851
53463
|
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
@@ -55106,7 +56718,8 @@ var customActions = {
|
|
|
55106
56718
|
...orphanAuditActions,
|
|
55107
56719
|
...viewerSettingsActions,
|
|
55108
56720
|
...photoEnrollActions,
|
|
55109
|
-
...faceRescoreActions
|
|
56721
|
+
...faceRescoreActions,
|
|
56722
|
+
...groupActions
|
|
55110
56723
|
};
|
|
55111
56724
|
/**
|
|
55112
56725
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -55285,11 +56898,6 @@ function decodeEmbeddingBase64(base64) {
|
|
|
55285
56898
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
55286
56899
|
return Array.from(view);
|
|
55287
56900
|
}
|
|
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
56901
|
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
55294
56902
|
* to the pixel still land in one cell. */
|
|
55295
56903
|
var PHANTOM_CELL_PX = 32;
|
|
@@ -55353,6 +56961,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
55353
56961
|
faceStore = null;
|
|
55354
56962
|
faceRecognizer = null;
|
|
55355
56963
|
plateStore = null;
|
|
56964
|
+
groupStore = null;
|
|
56965
|
+
groupSealer = null;
|
|
56966
|
+
groupLive = null;
|
|
56967
|
+
/** Serialise first-activity lookback per camera, then observe in order. */
|
|
56968
|
+
groupObserveTail = /* @__PURE__ */ new Map();
|
|
55356
56969
|
plateRecognizer = null;
|
|
55357
56970
|
objectEmbeddingStore = null;
|
|
55358
56971
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -56170,7 +57783,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56170
57783
|
...orphanAuditActions,
|
|
56171
57784
|
...viewerSettingsActions,
|
|
56172
57785
|
...photoEnrollActions,
|
|
56173
|
-
...faceRescoreActions
|
|
57786
|
+
...faceRescoreActions,
|
|
57787
|
+
...groupActions
|
|
56174
57788
|
},
|
|
56175
57789
|
actionHandlers: {
|
|
56176
57790
|
...ncHandlers,
|
|
@@ -56178,6 +57792,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56178
57792
|
...this.buildDebugActionHandlers(),
|
|
56179
57793
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56180
57794
|
...this.buildFaceRescoreActionHandlers(),
|
|
57795
|
+
...this.buildGroupActionHandlers(),
|
|
56181
57796
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
56182
57797
|
}
|
|
56183
57798
|
} : {}
|
|
@@ -56380,6 +57995,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56380
57995
|
SceneStore.declare(api.settingsStore),
|
|
56381
57996
|
NotificationCenter.declare(api.settingsStore),
|
|
56382
57997
|
ViewerSettingsSnapshotStore.declare(api.settingsStore),
|
|
57998
|
+
TrackGroupStore.declare(api.settingsStore),
|
|
56383
57999
|
declareMigrationMarkers(api.settingsStore)
|
|
56384
58000
|
]);
|
|
56385
58001
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
@@ -56664,6 +58280,31 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56664
58280
|
logger: logger.child("PlateStore")
|
|
56665
58281
|
});
|
|
56666
58282
|
this.plateStore = plateStore;
|
|
58283
|
+
const vehicleStore = new VehicleStore({
|
|
58284
|
+
store: api.settingsStore,
|
|
58285
|
+
logger: logger.child("VehicleStore")
|
|
58286
|
+
});
|
|
58287
|
+
const groupStore = new TrackGroupStore({
|
|
58288
|
+
store: api.settingsStore,
|
|
58289
|
+
logger: logger.child("TrackGroupStore"),
|
|
58290
|
+
media: mediaStore
|
|
58291
|
+
});
|
|
58292
|
+
this.groupStore = groupStore;
|
|
58293
|
+
this.groupLive = new GroupLiveBuffer();
|
|
58294
|
+
this.groupSealer = new GroupSealer({
|
|
58295
|
+
trackStore,
|
|
58296
|
+
groupStore,
|
|
58297
|
+
mediaStore,
|
|
58298
|
+
logger: logger.child("GroupSealer"),
|
|
58299
|
+
frameDims: (deviceId) => {
|
|
58300
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
58301
|
+
return dims ? {
|
|
58302
|
+
width: dims.w,
|
|
58303
|
+
height: dims.h
|
|
58304
|
+
} : void 0;
|
|
58305
|
+
},
|
|
58306
|
+
now: () => Date.now()
|
|
58307
|
+
});
|
|
56667
58308
|
return {
|
|
56668
58309
|
trackStore,
|
|
56669
58310
|
mediaStore,
|
|
@@ -56671,10 +58312,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56671
58312
|
identityStore,
|
|
56672
58313
|
faceStore,
|
|
56673
58314
|
plateStore,
|
|
56674
|
-
vehicleStore
|
|
56675
|
-
|
|
56676
|
-
logger: logger.child("VehicleStore")
|
|
56677
|
-
})
|
|
58315
|
+
vehicleStore,
|
|
58316
|
+
groupStore
|
|
56678
58317
|
};
|
|
56679
58318
|
}
|
|
56680
58319
|
/** Face + plate recognizers, the durable keyFrame-derived plate crop and the
|
|
@@ -57108,29 +58747,71 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
57108
58747
|
buildSummaryPorts(stores) {
|
|
57109
58748
|
return {
|
|
57110
58749
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
57111
|
-
|
|
58750
|
+
await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
|
|
58751
|
+
const groups = await stores.groupStore.listOverlapping({
|
|
58752
|
+
deviceId,
|
|
58753
|
+
sinceMs,
|
|
58754
|
+
untilMs,
|
|
58755
|
+
limit
|
|
58756
|
+
});
|
|
58757
|
+
const windowTracks = await stores.trackStore.queryHistorical({
|
|
57112
58758
|
deviceId,
|
|
57113
58759
|
since: sinceMs,
|
|
57114
58760
|
until: untilMs,
|
|
57115
58761
|
projection: "slim",
|
|
57116
|
-
limit
|
|
58762
|
+
limit: Math.max(limit, 2e3)
|
|
57117
58763
|
});
|
|
58764
|
+
const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
|
|
57118
58765
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
57119
|
-
|
|
57120
|
-
|
|
57121
|
-
|
|
57122
|
-
|
|
57123
|
-
|
|
57124
|
-
|
|
57125
|
-
|
|
57126
|
-
|
|
57127
|
-
|
|
57128
|
-
|
|
57129
|
-
|
|
57130
|
-
|
|
58766
|
+
const out = [];
|
|
58767
|
+
for (const group of groups) {
|
|
58768
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58769
|
+
const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
|
|
58770
|
+
if (tracks.length === 0) continue;
|
|
58771
|
+
const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
|
|
58772
|
+
const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
|
|
58773
|
+
const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
|
|
58774
|
+
const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
|
|
58775
|
+
out.push({
|
|
58776
|
+
trackId: group.id,
|
|
58777
|
+
groupId: group.id,
|
|
58778
|
+
memberTrackIds: members.map((m) => m.trackId),
|
|
58779
|
+
deviceId,
|
|
58780
|
+
className: primary.className,
|
|
58781
|
+
firstSeen: group.openedAt,
|
|
58782
|
+
lastSeen: group.closedAt,
|
|
58783
|
+
importance,
|
|
58784
|
+
zones,
|
|
58785
|
+
classes,
|
|
58786
|
+
...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
|
|
58787
|
+
confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
|
|
58788
|
+
});
|
|
58789
|
+
}
|
|
58790
|
+
return out;
|
|
57131
58791
|
},
|
|
57132
|
-
listMediaInfo: async (
|
|
57133
|
-
|
|
58792
|
+
listMediaInfo: async (id) => {
|
|
58793
|
+
const group = await stores.groupStore.get(id);
|
|
58794
|
+
if (group) {
|
|
58795
|
+
if (group.mediaKey || group.nativeKey) return digestMediaRows({
|
|
58796
|
+
mediaKey: group.mediaKey,
|
|
58797
|
+
nativeKey: group.nativeKey,
|
|
58798
|
+
timestamp: group.timestamp,
|
|
58799
|
+
memberRows: []
|
|
58800
|
+
});
|
|
58801
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58802
|
+
const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
|
|
58803
|
+
key: r.key,
|
|
58804
|
+
kind: r.kind,
|
|
58805
|
+
timestamp: r.timestamp
|
|
58806
|
+
})));
|
|
58807
|
+
return digestMediaRows({
|
|
58808
|
+
mediaKey: group.mediaKey,
|
|
58809
|
+
nativeKey: group.nativeKey,
|
|
58810
|
+
timestamp: group.timestamp,
|
|
58811
|
+
memberRows
|
|
58812
|
+
});
|
|
58813
|
+
}
|
|
58814
|
+
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
57134
58815
|
key: r.key,
|
|
57135
58816
|
kind: r.kind,
|
|
57136
58817
|
timestamp: r.timestamp
|
|
@@ -58375,6 +60056,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58375
60056
|
state: t.state
|
|
58376
60057
|
});
|
|
58377
60058
|
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
60059
|
+
this.enqueueLiveGroup(upserted, result.timestamp);
|
|
58378
60060
|
}
|
|
58379
60061
|
if (result.rawTrackedDetections.length > 0) {
|
|
58380
60062
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
@@ -59154,8 +60836,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59154
60836
|
const first = track.positions?.[0];
|
|
59155
60837
|
if (!first) return;
|
|
59156
60838
|
const lifeMs = track.lastSeen - track.firstSeen;
|
|
59157
|
-
const moved = track.
|
|
59158
|
-
if (!(lifeMs
|
|
60839
|
+
const moved = netDisplacementPx(track.positions ?? []);
|
|
60840
|
+
if (!isShortMotionlessTrack(lifeMs, moved)) {
|
|
59159
60841
|
this.phantomCells?.noteMovingClose({
|
|
59160
60842
|
deviceId: track.deviceId,
|
|
59161
60843
|
className: track.className,
|
|
@@ -59298,7 +60980,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59298
60980
|
deviceId,
|
|
59299
60981
|
trackId,
|
|
59300
60982
|
timestamp: frame.timestamp,
|
|
59301
|
-
bbox: { ...frame.bbox },
|
|
60983
|
+
bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
|
|
59302
60984
|
frameWidth: frame.frameWidth,
|
|
59303
60985
|
frameHeight: frame.frameHeight,
|
|
59304
60986
|
kind: d.className === "plate" ? "plate" : "classification",
|
|
@@ -59731,7 +61413,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59731
61413
|
retries.push({
|
|
59732
61414
|
trackId: t.trackId,
|
|
59733
61415
|
timestamp,
|
|
59734
|
-
bbox:
|
|
61416
|
+
bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
|
|
59735
61417
|
...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
|
|
59736
61418
|
});
|
|
59737
61419
|
}
|
|
@@ -59788,7 +61470,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59788
61470
|
lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
|
|
59789
61471
|
now: timestamp,
|
|
59790
61472
|
intervalMs: media.snapshotIntervalMs,
|
|
59791
|
-
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
|
|
61473
|
+
lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
|
|
61474
|
+
firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
|
|
59792
61475
|
});
|
|
59793
61476
|
const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
|
|
59794
61477
|
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
@@ -60297,6 +61980,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60297
61980
|
async sweepExpiredTracks() {
|
|
60298
61981
|
if (this.shuttingDown || !this.trackStore) return;
|
|
60299
61982
|
await this.trackCloser.sweep();
|
|
61983
|
+
if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
|
|
61984
|
+
}
|
|
61985
|
+
/** Hub-only: assign this sighting in RAM. First activity on a camera
|
|
61986
|
+
* replays 2 minutes of that camera (crash recover), then observe is sync. */
|
|
61987
|
+
enqueueLiveGroup(track, now) {
|
|
61988
|
+
if (!this.isPostProcessingNode || !this.groupLive) return;
|
|
61989
|
+
const deviceId = track.deviceId;
|
|
61990
|
+
const snapshot = toGroupableTrack(track);
|
|
61991
|
+
if (this.groupLive.isPrimed(deviceId)) {
|
|
61992
|
+
this.groupLive.observe(snapshot, now);
|
|
61993
|
+
return;
|
|
61994
|
+
}
|
|
61995
|
+
const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
61996
|
+
if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
|
|
61997
|
+
this.groupLive?.observe(snapshot, now);
|
|
61998
|
+
}).catch((err) => {
|
|
61999
|
+
this.ctx.logger.warn("group live observe failed", {
|
|
62000
|
+
tags: { deviceId },
|
|
62001
|
+
meta: { error: String(err) }
|
|
62002
|
+
});
|
|
62003
|
+
});
|
|
62004
|
+
this.groupObserveTail.set(deviceId, next);
|
|
60300
62005
|
}
|
|
60301
62006
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
60302
62007
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -60941,6 +62646,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60941
62646
|
};
|
|
60942
62647
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
60943
62648
|
processor.dropTrack(track.trackId);
|
|
62649
|
+
const peek = this.trackStore?.peekActive(track.trackId);
|
|
62650
|
+
if (peek) this.enqueueLiveGroup(toGroupableTrack({
|
|
62651
|
+
trackId: track.trackId,
|
|
62652
|
+
deviceId,
|
|
62653
|
+
className: track.className,
|
|
62654
|
+
firstSeen: peek.firstSeen,
|
|
62655
|
+
lastSeen: timestamp,
|
|
62656
|
+
positions: peek.positions,
|
|
62657
|
+
closedReason: "stationary-promotion"
|
|
62658
|
+
}), timestamp);
|
|
60944
62659
|
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
60945
62660
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
60946
62661
|
tags: { deviceId },
|
|
@@ -61575,6 +63290,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61575
63290
|
},
|
|
61576
63291
|
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
61577
63292
|
liveEventIds: (ids) => eventStore.existingEventIds(ids),
|
|
63293
|
+
liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
63294
|
+
liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
|
|
61578
63295
|
reclaim: async (collection, key) => {
|
|
61579
63296
|
if (collection === "pipeline-analytics:media") {
|
|
61580
63297
|
await mediaStore.deleteByKey(key);
|
|
@@ -62298,6 +64015,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62298
64015
|
buildFaceRescoreActionHandlers() {
|
|
62299
64016
|
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62300
64017
|
}
|
|
64018
|
+
buildGroupActionHandlers() {
|
|
64019
|
+
const ports = () => {
|
|
64020
|
+
const groupStore = this.groupStore;
|
|
64021
|
+
if (groupStore === null) return null;
|
|
64022
|
+
return {
|
|
64023
|
+
groupStore,
|
|
64024
|
+
loadTrack: async (trackId) => {
|
|
64025
|
+
const tracks = this.trackStore;
|
|
64026
|
+
if (!tracks) return null;
|
|
64027
|
+
return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
|
|
64028
|
+
},
|
|
64029
|
+
eventMediaBaseUrl: () => this.eventMediaBaseUrl
|
|
64030
|
+
};
|
|
64031
|
+
};
|
|
64032
|
+
return {
|
|
64033
|
+
"analytics.listGroups": async (input) => {
|
|
64034
|
+
const ready = ports();
|
|
64035
|
+
if (ready === null) return {
|
|
64036
|
+
groups: [],
|
|
64037
|
+
hasMore: false
|
|
64038
|
+
};
|
|
64039
|
+
return listGroupRecords(ready, input);
|
|
64040
|
+
},
|
|
64041
|
+
"analytics.getGroup": async (input) => {
|
|
64042
|
+
const ready = ports();
|
|
64043
|
+
if (ready === null) return { group: null };
|
|
64044
|
+
return { group: await getGroupDetail(ready, input.groupId) };
|
|
64045
|
+
}
|
|
64046
|
+
};
|
|
64047
|
+
}
|
|
62301
64048
|
buildPhotoEnrollActionHandlers() {
|
|
62302
64049
|
return {
|
|
62303
64050
|
"photo.analyze": async (input) => {
|
|
@@ -62528,7 +64275,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62528
64275
|
mediaStore,
|
|
62529
64276
|
faceStore: this.faceStore,
|
|
62530
64277
|
plateStore: this.plateStore,
|
|
62531
|
-
objectEmbeddingStore: this.objectEmbeddingStore
|
|
64278
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
64279
|
+
groupStore: this.groupStore
|
|
62532
64280
|
}),
|
|
62533
64281
|
trackStore
|
|
62534
64282
|
};
|
|
@@ -62677,7 +64425,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62677
64425
|
media: 0,
|
|
62678
64426
|
faces: 0,
|
|
62679
64427
|
plates: 0,
|
|
62680
|
-
embeddings: 0
|
|
64428
|
+
embeddings: 0,
|
|
64429
|
+
groups: 0
|
|
62681
64430
|
};
|
|
62682
64431
|
this.lastPruneBytes = 0;
|
|
62683
64432
|
const cascade = this.buildTrackCascadeRegistry();
|
|
@@ -62737,7 +64486,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62737
64486
|
media: tally.media,
|
|
62738
64487
|
faces: tally.faces,
|
|
62739
64488
|
plates: tally.plates,
|
|
62740
|
-
embeddings: tally.embeddings
|
|
64489
|
+
embeddings: tally.embeddings,
|
|
64490
|
+
groups: tally.groups
|
|
62741
64491
|
};
|
|
62742
64492
|
}
|
|
62743
64493
|
/**
|