@camstack/addon-post-analysis 1.2.296 → 1.2.298
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/{clip-model-registry-BP_o2Oi4.mjs → clip-model-registry-D9WaswEs.mjs} +1 -164
- package/dist/{clip-model-registry-BTpYUJzK.js → clip-model-registry-olxNKN7T.js} +1 -164
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C9vZfpmu.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bk7ACano.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BVgWHpfo.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1CfMkm2.mjs} +1 -1
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-KXwf-qxG.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Ckt8VrEv.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BwZmL8b9.mjs → hostInit-DtPqVNsM.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +284 -2615
- package/dist/pipeline-analytics/index.mjs +347 -2678
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_clip_model_registry = require("../clip-model-registry-
|
|
5
|
+
const require_clip_model_registry = require("../clip-model-registry-olxNKN7T.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_clip_model_registry.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -2715,8 +2715,8 @@ var NC_ARTIFACTS_INDEXES = [
|
|
|
2715
2715
|
}
|
|
2716
2716
|
];
|
|
2717
2717
|
/** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
|
|
2718
|
-
var TIME_MAX
|
|
2719
|
-
var TIME_MIN
|
|
2718
|
+
var TIME_MAX = 864e13;
|
|
2719
|
+
var TIME_MIN = -864e13;
|
|
2720
2720
|
/** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
|
|
2721
2721
|
* entries by default), so this only has to be comfortably above it. */
|
|
2722
2722
|
var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
|
|
@@ -2956,7 +2956,7 @@ var NcArtifactIndex = class {
|
|
|
2956
2956
|
try {
|
|
2957
2957
|
const expired = await this.store.deleteWhere.mutate({
|
|
2958
2958
|
collection: NC_ARTIFACTS_COLLECTION,
|
|
2959
|
-
filter: { whereBetween: { expiresAt: [TIME_MIN
|
|
2959
|
+
filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
|
|
2960
2960
|
});
|
|
2961
2961
|
removed += expired.deleted;
|
|
2962
2962
|
if (keptIds === null) {
|
|
@@ -2998,8 +2998,8 @@ var NcArtifactIndex = class {
|
|
|
2998
2998
|
whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
|
|
2999
2999
|
if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
|
|
3000
3000
|
const whereBetween = {
|
|
3001
|
-
capturedAt: [query.fromMs ?? TIME_MIN
|
|
3002
|
-
expiresAt: [this.now(), TIME_MAX
|
|
3001
|
+
capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
|
|
3002
|
+
expiresAt: [this.now(), TIME_MAX]
|
|
3003
3003
|
};
|
|
3004
3004
|
return {
|
|
3005
3005
|
...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
|
|
@@ -15441,21 +15441,6 @@ var NcSummaryRuleInputSchema = require_clip_model_registry.object({
|
|
|
15441
15441
|
deviceIds: DeviceIdsField,
|
|
15442
15442
|
window: NcSummaryWindowSchema,
|
|
15443
15443
|
filters: NcSummaryFiltersSchema.optional(),
|
|
15444
|
-
/**
|
|
15445
|
-
* What ONE line of this digest is: a subject, or a group of subjects that
|
|
15446
|
-
* arrived together.
|
|
15447
|
-
*
|
|
15448
|
-
* `tracks` is what the digest has always done and stays the default — the
|
|
15449
|
-
* operator's instruction when groups were added was that the first behaviour
|
|
15450
|
-
* remains. `groups` collapses the members of each session summary to one
|
|
15451
|
-
* entry, so a family walking in is one line and not four
|
|
15452
|
-
* (`summary-group-collapse.ts`).
|
|
15453
|
-
*
|
|
15454
|
-
* `.default('tracks')` is safe here where `NcSummaryFiltersSchema` fields are
|
|
15455
|
-
* not: this is a field of the RULE, not of the partial `filters` object that
|
|
15456
|
-
* a patch materialises wholesale.
|
|
15457
|
-
*/
|
|
15458
|
-
subject: require_clip_model_registry._enum(["tracks", "groups"]).default("tracks"),
|
|
15459
15444
|
maxTiles: MaxTilesField.default(12),
|
|
15460
15445
|
maxAiImages: MaxAiImagesField.default(6),
|
|
15461
15446
|
rank: NcSummaryRankSchema.default("importance"),
|
|
@@ -15490,7 +15475,6 @@ var NcSummaryRulePatchSchema = require_clip_model_registry.object({
|
|
|
15490
15475
|
maxTiles: MaxTilesField.optional(),
|
|
15491
15476
|
maxAiImages: MaxAiImagesField.optional(),
|
|
15492
15477
|
rank: NcSummaryRankSchema.optional(),
|
|
15493
|
-
subject: require_clip_model_registry._enum(["tracks", "groups"]).optional(),
|
|
15494
15478
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
15495
15479
|
targets: TargetsField.optional(),
|
|
15496
15480
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
@@ -26299,52 +26283,6 @@ function buildSummaryOutboxInputs(input) {
|
|
|
26299
26283
|
});
|
|
26300
26284
|
}
|
|
26301
26285
|
//#endregion
|
|
26302
|
-
//#region src/notification-center/summary/summary-group-collapse.ts
|
|
26303
|
-
function collapseToGroups(candidates, groups) {
|
|
26304
|
-
const claim = /* @__PURE__ */ new Map();
|
|
26305
|
-
for (const group of groups) for (const trackId of group.memberTrackIds) if (!claim.has(trackId)) claim.set(trackId, group);
|
|
26306
|
-
const survivorsByGroup = /* @__PURE__ */ new Map();
|
|
26307
|
-
for (const candidate of candidates) {
|
|
26308
|
-
const group = claim.get(candidate.trackId);
|
|
26309
|
-
if (group === void 0) continue;
|
|
26310
|
-
const bucket = survivorsByGroup.get(group.summaryId);
|
|
26311
|
-
if (bucket === void 0) survivorsByGroup.set(group.summaryId, [candidate]);
|
|
26312
|
-
else bucket.push(candidate);
|
|
26313
|
-
}
|
|
26314
|
-
const representative = /* @__PURE__ */ new Map();
|
|
26315
|
-
for (const [summaryId, survivors] of survivorsByGroup) {
|
|
26316
|
-
const group = groups.find((g) => g.summaryId === summaryId);
|
|
26317
|
-
if (group === void 0) continue;
|
|
26318
|
-
const chosen = survivors.find((c) => c.trackId === group.primaryTrackId) ?? survivors[0];
|
|
26319
|
-
if (chosen === void 0) continue;
|
|
26320
|
-
representative.set(summaryId, {
|
|
26321
|
-
...chosen,
|
|
26322
|
-
groupSize: survivors.length,
|
|
26323
|
-
...group.groupShotMediaId !== null ? { groupShotMediaId: group.groupShotMediaId } : {}
|
|
26324
|
-
});
|
|
26325
|
-
}
|
|
26326
|
-
const kept = [];
|
|
26327
|
-
let collapsed = 0;
|
|
26328
|
-
for (const candidate of candidates) {
|
|
26329
|
-
const group = claim.get(candidate.trackId);
|
|
26330
|
-
if (group === void 0) {
|
|
26331
|
-
kept.push(candidate);
|
|
26332
|
-
continue;
|
|
26333
|
-
}
|
|
26334
|
-
const chosen = representative.get(group.summaryId);
|
|
26335
|
-
if (chosen === void 0) {
|
|
26336
|
-
kept.push(candidate);
|
|
26337
|
-
continue;
|
|
26338
|
-
}
|
|
26339
|
-
if (chosen.trackId === candidate.trackId) kept.push(chosen);
|
|
26340
|
-
else collapsed += 1;
|
|
26341
|
-
}
|
|
26342
|
-
return {
|
|
26343
|
-
kept,
|
|
26344
|
-
collapsed
|
|
26345
|
-
};
|
|
26346
|
-
}
|
|
26347
|
-
//#endregion
|
|
26348
26286
|
//#region src/notification-center/summary/summary-selection.ts
|
|
26349
26287
|
/**
|
|
26350
26288
|
* Which tracks become tiles — filter, rank, and the per-camera quota.
|
|
@@ -26938,8 +26876,7 @@ var NcSummaryProducer = class {
|
|
|
26938
26876
|
try {
|
|
26939
26877
|
const candidates = await this.collectCandidates(rule, window);
|
|
26940
26878
|
const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
|
|
26941
|
-
const
|
|
26942
|
-
const selection = selectSummaryTiles(grouped.kept, {
|
|
26879
|
+
const selection = selectSummaryTiles(filtered.kept, {
|
|
26943
26880
|
maxTiles: rule.maxTiles,
|
|
26944
26881
|
rank: rule.rank
|
|
26945
26882
|
});
|
|
@@ -26968,7 +26905,7 @@ var NcSummaryProducer = class {
|
|
|
26968
26905
|
const tileLoad = await this.loadTiles({
|
|
26969
26906
|
rule,
|
|
26970
26907
|
preferred: selection.tiles,
|
|
26971
|
-
refill: remainingSummaryCandidates(
|
|
26908
|
+
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
26972
26909
|
maxTiles: rule.maxTiles
|
|
26973
26910
|
});
|
|
26974
26911
|
const loaded = tileLoad.tiles;
|
|
@@ -27009,7 +26946,7 @@ var NcSummaryProducer = class {
|
|
|
27009
26946
|
priority: rule.priority,
|
|
27010
26947
|
tileCount: loaded.length,
|
|
27011
26948
|
cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
27012
|
-
matchedCount:
|
|
26949
|
+
matchedCount: filtered.kept.length,
|
|
27013
26950
|
detections,
|
|
27014
26951
|
...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
|
|
27015
26952
|
generatedAt: this.deps.now(),
|
|
@@ -27021,7 +26958,7 @@ var NcSummaryProducer = class {
|
|
|
27021
26958
|
this.deps.logger.info("summary produced", { meta: {
|
|
27022
26959
|
...meta,
|
|
27023
26960
|
scanned: candidates.length,
|
|
27024
|
-
matched:
|
|
26961
|
+
matched: filtered.kept.length,
|
|
27025
26962
|
droppedStationary: filtered.droppedStationary,
|
|
27026
26963
|
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
27027
26964
|
unknownConfirmation: filtered.unknownConfirmation,
|
|
@@ -27044,7 +26981,7 @@ var NcSummaryProducer = class {
|
|
|
27044
26981
|
window,
|
|
27045
26982
|
tiles: loaded.length,
|
|
27046
26983
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
27047
|
-
matched:
|
|
26984
|
+
matched: filtered.kept.length,
|
|
27048
26985
|
droppedStationary: filtered.droppedStationary,
|
|
27049
26986
|
enqueued,
|
|
27050
26987
|
...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
|
|
@@ -27065,54 +27002,6 @@ var NcSummaryProducer = class {
|
|
|
27065
27002
|
};
|
|
27066
27003
|
}
|
|
27067
27004
|
}
|
|
27068
|
-
/**
|
|
27069
|
-
* Fold each session summary's surviving members into one entry — or do
|
|
27070
|
-
* nothing at all, which is what `tracks` (the default) means.
|
|
27071
|
-
*
|
|
27072
|
-
* A failure to read the groups does NOT fail the digest: it falls back to
|
|
27073
|
-
* subjects and says so. A digest of four lines instead of one is a worse
|
|
27074
|
-
* reading, not a lost one, and losing the night's record over a store read
|
|
27075
|
-
* is the wrong trade.
|
|
27076
|
-
*/
|
|
27077
|
-
async collapseIfGrouping(rule, window, kept) {
|
|
27078
|
-
if (rule.subject !== "groups") return {
|
|
27079
|
-
kept,
|
|
27080
|
-
collapsed: 0
|
|
27081
|
-
};
|
|
27082
|
-
const listGroups = this.deps.listSummaryGroups;
|
|
27083
|
-
if (listGroups === void 0) {
|
|
27084
|
-
this.deps.logger.warn("summary rule asks for groups but this node cannot read them", { meta: { ruleId: rule.id } });
|
|
27085
|
-
return {
|
|
27086
|
-
kept,
|
|
27087
|
-
collapsed: 0
|
|
27088
|
-
};
|
|
27089
|
-
}
|
|
27090
|
-
const groups = [];
|
|
27091
|
-
for (const deviceId of rule.deviceIds) try {
|
|
27092
|
-
groups.push(...await listGroups({
|
|
27093
|
-
deviceId,
|
|
27094
|
-
sinceMs: window.startMs,
|
|
27095
|
-
untilMs: window.endMs
|
|
27096
|
-
}));
|
|
27097
|
-
} catch (err) {
|
|
27098
|
-
this.deps.logger.warn("summary could not read a camera’s groups — read as subjects", {
|
|
27099
|
-
tags: { deviceId },
|
|
27100
|
-
meta: {
|
|
27101
|
-
ruleId: rule.id,
|
|
27102
|
-
error: String(err)
|
|
27103
|
-
}
|
|
27104
|
-
});
|
|
27105
|
-
}
|
|
27106
|
-
const outcome = collapseToGroups(kept, groups);
|
|
27107
|
-
this.deps.logger.info("summary read as groups", { meta: {
|
|
27108
|
-
ruleId: rule.id,
|
|
27109
|
-
groups: groups.length,
|
|
27110
|
-
subjects: kept.length,
|
|
27111
|
-
lines: outcome.kept.length,
|
|
27112
|
-
collapsed: outcome.collapsed
|
|
27113
|
-
} });
|
|
27114
|
-
return outcome;
|
|
27115
|
-
}
|
|
27116
27005
|
/** Every camera's tracks in the window, capped per camera and counted. */
|
|
27117
27006
|
async collectCandidates(rule, window) {
|
|
27118
27007
|
const all = [];
|
|
@@ -27163,25 +27052,6 @@ var NcSummaryProducer = class {
|
|
|
27163
27052
|
seenIds.add(candidate.trackId);
|
|
27164
27053
|
const tags = { deviceId: candidate.deviceId };
|
|
27165
27054
|
try {
|
|
27166
|
-
const groupShot = candidate.groupShotMediaId;
|
|
27167
|
-
if (groupShot !== void 0) {
|
|
27168
|
-
const jpeg = await this.deps.readMedia(groupShot);
|
|
27169
|
-
if (jpeg !== null && jpeg.byteLength > 0) {
|
|
27170
|
-
tiles.push({
|
|
27171
|
-
candidate,
|
|
27172
|
-
jpeg
|
|
27173
|
-
});
|
|
27174
|
-
continue;
|
|
27175
|
-
}
|
|
27176
|
-
this.deps.logger.info("summary tile: group shot unreadable — using a member", {
|
|
27177
|
-
tags,
|
|
27178
|
-
meta: {
|
|
27179
|
-
ruleId: input.rule.id,
|
|
27180
|
-
trackId: candidate.trackId,
|
|
27181
|
-
mediaId: groupShot
|
|
27182
|
-
}
|
|
27183
|
-
});
|
|
27184
|
-
}
|
|
27185
27055
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
27186
27056
|
const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
|
|
27187
27057
|
if (picked === null) {
|
|
@@ -35457,7 +35327,6 @@ var StoragePressureTracker = class {
|
|
|
35457
35327
|
*/
|
|
35458
35328
|
var OWNER_KINDS = [
|
|
35459
35329
|
"track",
|
|
35460
|
-
"summary",
|
|
35461
35330
|
"face",
|
|
35462
35331
|
"identity",
|
|
35463
35332
|
"plate",
|
|
@@ -35519,10 +35388,19 @@ function isWriterBoundedOwnerKind(value) {
|
|
|
35519
35388
|
* `{ kind: 'retired-owner' }` and the orphan audit reclaims it under the same
|
|
35520
35389
|
* grace window and ceilings as any other orphan.
|
|
35521
35390
|
*
|
|
35391
|
+
* `'summary'` is the same case one entity later: the session summary (the
|
|
35392
|
+
* proximity group, D489/D490) was deleted on 2026-09-29 (D680) with
|
|
35393
|
+
* `pipeline-analytics:summaries` / `:summary-members`. Its group shots were
|
|
35394
|
+
* `summary`-owned media rows; they are reclaimed the same way.
|
|
35395
|
+
*
|
|
35522
35396
|
* Deliberately NOT a member of {@link OWNER_KINDS}: the type is what stops a
|
|
35523
35397
|
* new writer, and this list is only what the READ side must still recognise.
|
|
35524
35398
|
*/
|
|
35525
|
-
var RETIRED_OWNER_KINDS = [
|
|
35399
|
+
var RETIRED_OWNER_KINDS = [
|
|
35400
|
+
"group",
|
|
35401
|
+
"summary",
|
|
35402
|
+
"event"
|
|
35403
|
+
];
|
|
35526
35404
|
/** Is this an owner kind whose owning code is gone? See {@link RETIRED_OWNER_KINDS}. */
|
|
35527
35405
|
function isRetiredMediaOwnerKind(value) {
|
|
35528
35406
|
return RETIRED_OWNER_KINDS.includes(value);
|
|
@@ -42892,6 +42770,33 @@ var FaceSourceStats = class {
|
|
|
42892
42770
|
};
|
|
42893
42771
|
//#endregion
|
|
42894
42772
|
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
42773
|
+
/**
|
|
42774
|
+
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
42775
|
+
* (Design B — one native full-frame per track at its best-detection moment).
|
|
42776
|
+
*
|
|
42777
|
+
* ## Why this exists (the missing native keyFrame)
|
|
42778
|
+
*
|
|
42779
|
+
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
42780
|
+
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
42781
|
+
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
42782
|
+
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
42783
|
+
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
42784
|
+
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
42785
|
+
*
|
|
42786
|
+
* The fix decouples the `keyFrame` from the clip path: it is selected on the
|
|
42787
|
+
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
42788
|
+
* `thumbnail`) by {@link selectKeyFrameCaptures}.
|
|
42789
|
+
*
|
|
42790
|
+
* ## How the pixels arrive, since D422
|
|
42791
|
+
*
|
|
42792
|
+
* Not by handle. At the best instant the owning node PARKS the whole frame at
|
|
42793
|
+
* {@link KEYFRAME_NATIVE_MAX_WIDTH} (`track-park.ts` orders it; the runner
|
|
42794
|
+
* refuses a ≤640 `ram-fullframe` answer at park time), and `persistKeyFrames`
|
|
42795
|
+
* stores that parcel verbatim as `keyFrame` (`key-frame-persist.ts`). The web
|
|
42796
|
+
* companion is {@link deriveKeyFrameSmall} of the same bytes — never a second
|
|
42797
|
+
* fetch. A retrieval keyed by a track never falls back to a handle
|
|
42798
|
+
* (`scripts/check-parked-frame-no-handle-fallback.ts`).
|
|
42799
|
+
*/
|
|
42895
42800
|
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
42896
42801
|
* Native resolution is the point: a 4K cam (3840) resolves TRUE native (no
|
|
42897
42802
|
* resize, 3840 ≤ 4096); an 8K cam is bounded to 4096 (still far past the retired
|
|
@@ -42917,47 +42822,10 @@ function selectKeyFrameCaptures(targets) {
|
|
|
42917
42822
|
return selected;
|
|
42918
42823
|
}
|
|
42919
42824
|
/**
|
|
42920
|
-
* Gate for the clean best-shot native `keyFrame` (design 2026-07-21): the keyFrame
|
|
42921
|
-
* is the track's CLEAN native best frame and MUST NEVER be persisted from the
|
|
42922
|
-
* ≤640 RAM fallback (`tier: 'ram-fullframe'`) — that would pin the stored keyFrame
|
|
42923
|
-
* at detection resolution. On a `ram-fullframe` tier the caller drops the write
|
|
42924
|
-
* and the per-frame re-fire retries a real native surface later.
|
|
42925
|
-
*
|
|
42926
|
-
* An ABSENT tier (`undefined`, from a pre-tier runner on a mixed-version cluster)
|
|
42927
|
-
* is ACCEPTED exactly as before the tier field existed — back-compat, never break
|
|
42928
|
-
* mixed-version clusters. Only an explicit `ram-fullframe` is rejected.
|
|
42929
|
-
*
|
|
42930
|
-
* NOTE: this gates the native `keyFrame` ONLY. The 960 boxed TIMELINE tiles keep
|
|
42931
|
-
* accepting the ≤640 fallback (an honest low-res timeline is acceptable there).
|
|
42932
|
-
*/
|
|
42933
|
-
function keyFrameAcceptsTier(tier) {
|
|
42934
|
-
return tier !== "ram-fullframe";
|
|
42935
|
-
}
|
|
42936
|
-
/**
|
|
42937
|
-
* Encode BOTH key-frame variants from a single decoded native full frame. No box
|
|
42938
|
-
* is drawn (the `keyFrame` is the clean best full frame); `drawBoxedFrame([])`
|
|
42939
|
-
* is used purely as the raw-rgb → JPEG (+ optional downscale) encoder so the two
|
|
42940
|
-
* variants share one code path. The caller has ALREADY fetched the native frame
|
|
42941
|
-
* at {@link KEYFRAME_NATIVE_MAX_WIDTH}, so `keyFrame` re-encodes it verbatim
|
|
42942
|
-
* (no resize) and `keyFrameSmall` downscales the same buffer to ≤960 —
|
|
42943
|
-
* `withoutEnlargement` never upscales a sub-960 native frame.
|
|
42944
|
-
*/
|
|
42945
|
-
async function encodeKeyFrameVariants(native) {
|
|
42946
|
-
return {
|
|
42947
|
-
keyFrame: await drawBoxedFrame(native.data, native.width, native.height, [], { quality: 88 }),
|
|
42948
|
-
keyFrameSmall: await drawBoxedFrame(native.data, native.width, native.height, [], {
|
|
42949
|
-
quality: 88,
|
|
42950
|
-
maxWidth: 960
|
|
42951
|
-
})
|
|
42952
|
-
};
|
|
42953
|
-
}
|
|
42954
|
-
/**
|
|
42955
42825
|
* The web companion from the PARKED key frame's own JPEG (D422): the parcel is
|
|
42956
42826
|
* stored verbatim as `keyFrame`, and `keyFrameSmall` is this — the same bytes
|
|
42957
42827
|
* downscaled to {@link KEYFRAME_SMALL_MAX_WIDTH}. One decode, one encode,
|
|
42958
42828
|
* never a second fetch; `withoutEnlargement` never upscales a sub-960 frame.
|
|
42959
|
-
* The by-handle twin ({@link encodeKeyFrameVariants}) remains for the
|
|
42960
|
-
* summary group shot, which still fetches a native surface by handle.
|
|
42961
42829
|
*/
|
|
42962
42830
|
async function deriveKeyFrameSmall(keyFrameJpeg) {
|
|
42963
42831
|
return (0, sharp.default)(keyFrameJpeg).resize(960, null, { withoutEnlargement: true }).jpeg({ quality: 88 }).toBuffer();
|
|
@@ -43180,7 +43048,7 @@ async function mapWithConcurrency(items, limit, mapper) {
|
|
|
43180
43048
|
* all-signals-maxed track scores exactly 1.0 (the clamp is then a no-op) and an
|
|
43181
43049
|
* all-zero track scores 0.
|
|
43182
43050
|
*/
|
|
43183
|
-
var WEIGHT_CONFIDENCE
|
|
43051
|
+
var WEIGHT_CONFIDENCE = .3;
|
|
43184
43052
|
var WEIGHT_CLASS = .2;
|
|
43185
43053
|
var WEIGHT_DWELL = .2;
|
|
43186
43054
|
var WEIGHT_PROXIMITY = .12;
|
|
@@ -43218,7 +43086,7 @@ var ANIMAL_CLASSES = new Set([
|
|
|
43218
43086
|
"cat"
|
|
43219
43087
|
]);
|
|
43220
43088
|
/** Clamp to [0,1]. */
|
|
43221
|
-
function clamp01$
|
|
43089
|
+
function clamp01$1(x) {
|
|
43222
43090
|
if (Number.isNaN(x)) return 0;
|
|
43223
43091
|
if (x < 0) return 0;
|
|
43224
43092
|
if (x > 1) return 1;
|
|
@@ -43252,7 +43120,7 @@ function computeImportance(input) {
|
|
|
43252
43120
|
const terms = [
|
|
43253
43121
|
{
|
|
43254
43122
|
reason: "confidence",
|
|
43255
|
-
value: WEIGHT_CONFIDENCE
|
|
43123
|
+
value: WEIGHT_CONFIDENCE * clamp01$1(input.peakConfidence)
|
|
43256
43124
|
},
|
|
43257
43125
|
{
|
|
43258
43126
|
reason: "class",
|
|
@@ -43260,15 +43128,15 @@ function computeImportance(input) {
|
|
|
43260
43128
|
},
|
|
43261
43129
|
{
|
|
43262
43130
|
reason: "dwell",
|
|
43263
|
-
value: WEIGHT_DWELL * clamp01$
|
|
43131
|
+
value: WEIGHT_DWELL * clamp01$1(input.durationMs / DWELL_FULL_MS)
|
|
43264
43132
|
},
|
|
43265
43133
|
{
|
|
43266
43134
|
reason: "proximity",
|
|
43267
|
-
value: WEIGHT_PROXIMITY * clamp01$
|
|
43135
|
+
value: WEIGHT_PROXIMITY * clamp01$1(input.peakBboxAreaFrac / SIZE_FULL)
|
|
43268
43136
|
},
|
|
43269
43137
|
{
|
|
43270
43138
|
reason: "travel",
|
|
43271
|
-
value: WEIGHT_TRAVEL * clamp01$
|
|
43139
|
+
value: WEIGHT_TRAVEL * clamp01$1(input.totalDistance / 2)
|
|
43272
43140
|
},
|
|
43273
43141
|
{
|
|
43274
43142
|
reason: "zone",
|
|
@@ -43287,11 +43155,11 @@ function computeImportance(input) {
|
|
|
43287
43155
|
}
|
|
43288
43156
|
const identified = input.identityLabel !== void 0 && input.identityLabel.length > 0;
|
|
43289
43157
|
if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
|
|
43290
|
-
importance: clamp01$
|
|
43158
|
+
importance: clamp01$1(sum * STATIC_IMPORTANCE_MULTIPLIER),
|
|
43291
43159
|
reason: best.reason
|
|
43292
43160
|
};
|
|
43293
43161
|
return {
|
|
43294
|
-
importance: clamp01$
|
|
43162
|
+
importance: clamp01$1(sum),
|
|
43295
43163
|
reason: best.reason
|
|
43296
43164
|
};
|
|
43297
43165
|
}
|
|
@@ -46283,7 +46151,7 @@ var ClosedRowMediaGate = class {
|
|
|
46283
46151
|
* it, so it can be loose enough to see the near misses.
|
|
46284
46152
|
*/
|
|
46285
46153
|
var CO_MOVING_MIN_CONTAINMENT = .6;
|
|
46286
|
-
function intersectionArea$
|
|
46154
|
+
function intersectionArea$3(a, b) {
|
|
46287
46155
|
return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
46288
46156
|
}
|
|
46289
46157
|
/**
|
|
@@ -46303,7 +46171,7 @@ function findCoMovingPairs(born, live, minContainment = CO_MOVING_MIN_CONTAINMEN
|
|
|
46303
46171
|
if (o.className === b.className) continue;
|
|
46304
46172
|
const areaO = o.bbox.w * o.bbox.h;
|
|
46305
46173
|
if (areaO <= 0) continue;
|
|
46306
|
-
const inter = intersectionArea$
|
|
46174
|
+
const inter = intersectionArea$3(b.bbox, o.bbox);
|
|
46307
46175
|
if (inter <= 0) continue;
|
|
46308
46176
|
const union = areaB + areaO - inter;
|
|
46309
46177
|
const containment = inter / Math.min(areaB, areaO);
|
|
@@ -47053,10 +46921,127 @@ function pairRiders(detections) {
|
|
|
47053
46921
|
}
|
|
47054
46922
|
return pairs;
|
|
47055
46923
|
}
|
|
46924
|
+
/**
|
|
46925
|
+
* Floor on the predictive-drift clamp, in box sides.
|
|
46926
|
+
*
|
|
46927
|
+
* The scaled term collapses at short intervals — at the 56 ms minimum measured
|
|
46928
|
+
* on camera 615 it is 0.11 sides — and a clamp that tight turns ordinary
|
|
46929
|
+
* detector box jitter into a refused match. A quarter of a side is slack, not
|
|
46930
|
+
* travel.
|
|
46931
|
+
*/
|
|
46932
|
+
var DRIFT_MIN_SIDES = .25;
|
|
46933
|
+
/**
|
|
46934
|
+
* Re-acquisition reach, in box DIAGONALS per second.
|
|
46935
|
+
*
|
|
46936
|
+
* Answers `267840a1` again, on the gate that actually refused it: 908.4 px of
|
|
46937
|
+
* centroid travel in 880 ms against a `hypot(513.6, 721.2)` = 885.5 px diagonal
|
|
46938
|
+
* is 1.166 diagonals per second. 1.5 clears it with ~29 % margin and is
|
|
46939
|
+
* physically bounded — 1.5 of a subject's own body-diagonal per second is a
|
|
46940
|
+
* brisk run, and being scale-free it means the same thing for a far-field
|
|
46941
|
+
* person with a 90-px box as for a near one with a 900-px box.
|
|
46942
|
+
*
|
|
46943
|
+
* The number it replaces (`rescueCentroidFactor` 0.75 × diagonal, flat) was
|
|
46944
|
+
* doing two jobs at once: at a 2135 ms poll gap it was far too tight to bridge
|
|
46945
|
+
* a walking subject, and at a 150 ms burst interval it was loose enough to hand
|
|
46946
|
+
* one subject's id to another 452 px away (track `27855a56`, a second car, at
|
|
46947
|
+
* IoU 0.058). One number, two opposite failures.
|
|
46948
|
+
*/
|
|
46949
|
+
var REACH_DIAGONALS_PER_SEC = 1.5;
|
|
46950
|
+
/**
|
|
46951
|
+
* Floor on the re-acquisition reach, in box diagonals.
|
|
46952
|
+
*
|
|
46953
|
+
* Deliberately TIGHTER than the 0.75 flat factor it replaces. At a 150 ms burst
|
|
46954
|
+
* interval the scaled term is 0.225 diagonals; the floor lifts it to 0.3, which
|
|
46955
|
+
* is 221 px on `27855a56`'s 738-px diagonal — comfortably below the 452 px that
|
|
46956
|
+
* bought that track a second car. A short gap is evidence that the subject
|
|
46957
|
+
* cannot have gone far, and the gate now says so.
|
|
46958
|
+
*/
|
|
46959
|
+
var REACH_MIN_DIAGONALS = .3;
|
|
46960
|
+
/**
|
|
46961
|
+
* Ceiling on the re-acquisition reach, in box diagonals.
|
|
46962
|
+
*
|
|
46963
|
+
* `REACH_DIAGONALS_PER_SEC × BUDGET_CEILING_MS`. A subject unseen for the full
|
|
46964
|
+
* `maxMissedMs` gets 1.5 diagonals of reach, not 6.
|
|
46965
|
+
*/
|
|
46966
|
+
var REACH_MAX_DIAGONALS = 1.5;
|
|
46967
|
+
function clampRange(value, min, max) {
|
|
46968
|
+
return Math.max(min, Math.min(max, value));
|
|
46969
|
+
}
|
|
46970
|
+
/**
|
|
46971
|
+
* How far a coasting box's velocity extrapolation may carry it on one axis.
|
|
46972
|
+
*
|
|
46973
|
+
* `side` is the box's width for the x drift and its height for the y drift, so
|
|
46974
|
+
* the allowance keeps the same meaning for a tall person and a wide car.
|
|
46975
|
+
*/
|
|
46976
|
+
function driftAllowancePx(side, elapsedMs) {
|
|
46977
|
+
const elapsed = Math.max(0, elapsedMs);
|
|
46978
|
+
return clampRange(side * 2 * (elapsed / 1e3), side * DRIFT_MIN_SIDES, side * 2);
|
|
46979
|
+
}
|
|
46980
|
+
/**
|
|
46981
|
+
* How far from where a track is EXPECTED a detection may sit and still be
|
|
46982
|
+
* claimed as that track by the loose (rescue / resurrection) reach clause.
|
|
46983
|
+
*
|
|
46984
|
+
* Scale-free: the budget is a multiple of the track's own diagonal, so it does
|
|
46985
|
+
* not have to know the frame size, the camera, or how far away the subject is.
|
|
46986
|
+
*/
|
|
46987
|
+
function reacquireReachPx(bbox, elapsedMs) {
|
|
46988
|
+
const diag = Math.hypot(bbox.w, bbox.h);
|
|
46989
|
+
const elapsed = Math.max(0, elapsedMs);
|
|
46990
|
+
return clampRange(diag * REACH_DIAGONALS_PER_SEC * (elapsed / 1e3), diag * REACH_MIN_DIAGONALS, diag * REACH_MAX_DIAGONALS);
|
|
46991
|
+
}
|
|
46992
|
+
/**
|
|
46993
|
+
* Resurrection reach, in box DIAGONALS per second of gap (D665).
|
|
46994
|
+
*
|
|
46995
|
+
* A corpse is reached only after `maxMissedMs` (4 s) without a box, so the
|
|
46996
|
+
* rescue rate above — which is at its 1.5-diagonal ceiling by 1 s — says
|
|
46997
|
+
* nothing useful here. A subject the detector has not boxed for four seconds
|
|
46998
|
+
* is, on this fleet's evidence, one that stood still or stood half-hidden,
|
|
46999
|
+
* not one striding across the frame (a striding person is detected): 0.25
|
|
47000
|
+
* diagonals per second is a slow walk for a mid-field person and a pause for
|
|
47001
|
+
* a near one. At the 4 s floor of the graveyard band it gives 1.0 diagonal;
|
|
47002
|
+
* camera 590's refused returns sat at 1.05–1.82× the old flat 0.75 reach,
|
|
47003
|
+
* i.e. 0.79–1.37 diagonals, which this covers from about 5.5 s of gap.
|
|
47004
|
+
*/
|
|
47005
|
+
var RESURRECTION_REACH_DIAGONALS_PER_SEC = .25;
|
|
47006
|
+
/** Never tighter than the flat `rescueCentroidFactor` default it replaces. */
|
|
47007
|
+
var RESURRECTION_REACH_MIN_DIAGONALS = .75;
|
|
47008
|
+
/**
|
|
47009
|
+
* How far from a corpse's last box a detection may sit and still resurrect it
|
|
47010
|
+
* on the proximity clause, for a corpse last seen `gapMs` ago.
|
|
47011
|
+
*/
|
|
47012
|
+
function resurrectionReachPx(bbox, gapMs) {
|
|
47013
|
+
const diag = Math.hypot(bbox.w, bbox.h);
|
|
47014
|
+
return clampRange(diag * RESURRECTION_REACH_DIAGONALS_PER_SEC * (Math.max(0, gapMs) / 1e3), diag * RESURRECTION_REACH_MIN_DIAGONALS, diag * 2);
|
|
47015
|
+
}
|
|
47016
|
+
/**
|
|
47017
|
+
* The classes D665's two relaxations are for: the low-score continuation tier
|
|
47018
|
+
* and the time-scaled resurrection gate. Defined ONCE, here, and read by both
|
|
47019
|
+
* (`low-score-continuation.ts`, `SortTracker.resurrectionTimeScaledFor`),
|
|
47020
|
+
* because the argument for each is the same argument about the same subjects
|
|
47021
|
+
* (D665, D678).
|
|
47022
|
+
*
|
|
47023
|
+
* D665 grew the graveyard reach with the gap on the argument that a subject
|
|
47024
|
+
* nobody has boxed for more than 4 s stood still or stood half-hidden — true
|
|
47025
|
+
* of a person and of an animal, and what the refused returns on camera 590
|
|
47026
|
+
* were. A car does not stand half-hidden for ten seconds and then reappear
|
|
47027
|
+
* one and a half diagonals away: the next car arrives. Camera 617,
|
|
47028
|
+
* 2026-09-28 08:47:32Z: a car at the gate inherited the id of the car that
|
|
47029
|
+
* had left the lot 9.85 s earlier, 1.51 diagonals away. A `vehicle` corpse
|
|
47030
|
+
* keeps the flat `rescueCentroidFactor` reach and the plausible-birth veto
|
|
47031
|
+
* it had before D665 — the same exclusion D665 made for the low-score tier,
|
|
47032
|
+
* where a sub-floor box on a parked car beside a passing one is the D410 hop.
|
|
47033
|
+
*/
|
|
47034
|
+
var LINGERING_SUBJECT_CLASSES = new Set(["person", "animal"]);
|
|
47035
|
+
/** Larger area / smaller area; `Infinity` when either box is empty. */
|
|
47036
|
+
function boxAreaRatio(a, b) {
|
|
47037
|
+
const areaA = a.w * a.h;
|
|
47038
|
+
const areaB = b.w * b.h;
|
|
47039
|
+
const smaller = Math.min(areaA, areaB);
|
|
47040
|
+
return smaller > 0 ? Math.max(areaA, areaB) / smaller : Number.POSITIVE_INFINITY;
|
|
47041
|
+
}
|
|
47056
47042
|
//#endregion
|
|
47057
47043
|
//#region src/pipeline-analytics/pipeline/tracker/low-score-continuation.ts
|
|
47058
47044
|
var LOW_SCORE_CONTINUATION_FLOOR_DEFAULT = .35;
|
|
47059
|
-
var LOW_SCORE_CONTINUATION_CLASSES = new Set(["person", "animal"]);
|
|
47060
47045
|
/** `[x1, y1, x2, y2]` (the discard trail's tuple) → the tracker's rectangle. */
|
|
47061
47046
|
function toRect(bbox) {
|
|
47062
47047
|
const [x1, y1, x2, y2] = bbox;
|
|
@@ -47077,7 +47062,7 @@ function selectLowScoreContinuations(discarded, floor) {
|
|
|
47077
47062
|
for (const d of discarded) {
|
|
47078
47063
|
if (d.reason !== "below-threshold") continue;
|
|
47079
47064
|
if (d.score < floor) continue;
|
|
47080
|
-
if (!
|
|
47065
|
+
if (!LINGERING_SUBJECT_CLASSES.has(d.macroClass)) continue;
|
|
47081
47066
|
const bbox = toRect(d.bbox);
|
|
47082
47067
|
if (bbox.w <= 0 || bbox.h <= 0) continue;
|
|
47083
47068
|
out.push({
|
|
@@ -47158,10 +47143,12 @@ function newTrackId() {
|
|
|
47158
47143
|
* the counts are per camera and per detection source. */
|
|
47159
47144
|
var LooseBindSampler = class {
|
|
47160
47145
|
counts = /* @__PURE__ */ new Map();
|
|
47161
|
-
/** Increment the (stage, gate) counter and answer with the running
|
|
47162
|
-
* this decision should be reported, or `undefined` when it is
|
|
47163
|
-
|
|
47164
|
-
|
|
47146
|
+
/** Increment the (stage, gate, refusal) counter and answer with the running
|
|
47147
|
+
* total when this decision should be reported, or `undefined` when it is
|
|
47148
|
+
* sampled out. The refusal is part of the key so a rare refusal is not
|
|
47149
|
+
* sampled away behind a frequent one on the same gate. */
|
|
47150
|
+
next(stage, gate, refusal) {
|
|
47151
|
+
const key = `${stage}|${gate}|${refusal ?? ""}`;
|
|
47165
47152
|
const count = (this.counts.get(key) ?? 0) + 1;
|
|
47166
47153
|
this.counts.set(key, count);
|
|
47167
47154
|
if (count === 1 || count % 50 === 0) return count;
|
|
@@ -47170,98 +47157,6 @@ var LooseBindSampler = class {
|
|
|
47170
47157
|
this.counts.clear();
|
|
47171
47158
|
}
|
|
47172
47159
|
};
|
|
47173
|
-
/**
|
|
47174
|
-
* Floor on the predictive-drift clamp, in box sides.
|
|
47175
|
-
*
|
|
47176
|
-
* The scaled term collapses at short intervals — at the 56 ms minimum measured
|
|
47177
|
-
* on camera 615 it is 0.11 sides — and a clamp that tight turns ordinary
|
|
47178
|
-
* detector box jitter into a refused match. A quarter of a side is slack, not
|
|
47179
|
-
* travel.
|
|
47180
|
-
*/
|
|
47181
|
-
var DRIFT_MIN_SIDES = .25;
|
|
47182
|
-
/**
|
|
47183
|
-
* Re-acquisition reach, in box DIAGONALS per second.
|
|
47184
|
-
*
|
|
47185
|
-
* Answers `267840a1` again, on the gate that actually refused it: 908.4 px of
|
|
47186
|
-
* centroid travel in 880 ms against a `hypot(513.6, 721.2)` = 885.5 px diagonal
|
|
47187
|
-
* is 1.166 diagonals per second. 1.5 clears it with ~29 % margin and is
|
|
47188
|
-
* physically bounded — 1.5 of a subject's own body-diagonal per second is a
|
|
47189
|
-
* brisk run, and being scale-free it means the same thing for a far-field
|
|
47190
|
-
* person with a 90-px box as for a near one with a 900-px box.
|
|
47191
|
-
*
|
|
47192
|
-
* The number it replaces (`rescueCentroidFactor` 0.75 × diagonal, flat) was
|
|
47193
|
-
* doing two jobs at once: at a 2135 ms poll gap it was far too tight to bridge
|
|
47194
|
-
* a walking subject, and at a 150 ms burst interval it was loose enough to hand
|
|
47195
|
-
* one subject's id to another 452 px away (track `27855a56`, a second car, at
|
|
47196
|
-
* IoU 0.058). One number, two opposite failures.
|
|
47197
|
-
*/
|
|
47198
|
-
var REACH_DIAGONALS_PER_SEC = 1.5;
|
|
47199
|
-
/**
|
|
47200
|
-
* Floor on the re-acquisition reach, in box diagonals.
|
|
47201
|
-
*
|
|
47202
|
-
* Deliberately TIGHTER than the 0.75 flat factor it replaces. At a 150 ms burst
|
|
47203
|
-
* interval the scaled term is 0.225 diagonals; the floor lifts it to 0.3, which
|
|
47204
|
-
* is 221 px on `27855a56`'s 738-px diagonal — comfortably below the 452 px that
|
|
47205
|
-
* bought that track a second car. A short gap is evidence that the subject
|
|
47206
|
-
* cannot have gone far, and the gate now says so.
|
|
47207
|
-
*/
|
|
47208
|
-
var REACH_MIN_DIAGONALS = .3;
|
|
47209
|
-
/**
|
|
47210
|
-
* Ceiling on the re-acquisition reach, in box diagonals.
|
|
47211
|
-
*
|
|
47212
|
-
* `REACH_DIAGONALS_PER_SEC × BUDGET_CEILING_MS`. A subject unseen for the full
|
|
47213
|
-
* `maxMissedMs` gets 1.5 diagonals of reach, not 6.
|
|
47214
|
-
*/
|
|
47215
|
-
var REACH_MAX_DIAGONALS = 1.5;
|
|
47216
|
-
function clampRange(value, min, max) {
|
|
47217
|
-
return Math.max(min, Math.min(max, value));
|
|
47218
|
-
}
|
|
47219
|
-
/**
|
|
47220
|
-
* How far a coasting box's velocity extrapolation may carry it on one axis.
|
|
47221
|
-
*
|
|
47222
|
-
* `side` is the box's width for the x drift and its height for the y drift, so
|
|
47223
|
-
* the allowance keeps the same meaning for a tall person and a wide car.
|
|
47224
|
-
*/
|
|
47225
|
-
function driftAllowancePx(side, elapsedMs) {
|
|
47226
|
-
const elapsed = Math.max(0, elapsedMs);
|
|
47227
|
-
return clampRange(side * 2 * (elapsed / 1e3), side * DRIFT_MIN_SIDES, side * 2);
|
|
47228
|
-
}
|
|
47229
|
-
/**
|
|
47230
|
-
* How far from where a track is EXPECTED a detection may sit and still be
|
|
47231
|
-
* claimed as that track by the loose (rescue / resurrection) reach clause.
|
|
47232
|
-
*
|
|
47233
|
-
* Scale-free: the budget is a multiple of the track's own diagonal, so it does
|
|
47234
|
-
* not have to know the frame size, the camera, or how far away the subject is.
|
|
47235
|
-
*/
|
|
47236
|
-
function reacquireReachPx(bbox, elapsedMs) {
|
|
47237
|
-
const diag = Math.hypot(bbox.w, bbox.h);
|
|
47238
|
-
const elapsed = Math.max(0, elapsedMs);
|
|
47239
|
-
return clampRange(diag * REACH_DIAGONALS_PER_SEC * (elapsed / 1e3), diag * REACH_MIN_DIAGONALS, diag * REACH_MAX_DIAGONALS);
|
|
47240
|
-
}
|
|
47241
|
-
/**
|
|
47242
|
-
* Resurrection reach, in box DIAGONALS per second of gap (D665).
|
|
47243
|
-
*
|
|
47244
|
-
* A corpse is reached only after `maxMissedMs` (4 s) without a box, so the
|
|
47245
|
-
* rescue rate above — which is at its 1.5-diagonal ceiling by 1 s — says
|
|
47246
|
-
* nothing useful here. A subject the detector has not boxed for four seconds
|
|
47247
|
-
* is, on this fleet's evidence, one that stood still or stood half-hidden,
|
|
47248
|
-
* not one striding across the frame (a striding person is detected): 0.25
|
|
47249
|
-
* diagonals per second is a slow walk for a mid-field person and a pause for
|
|
47250
|
-
* a near one. At the 4 s floor of the graveyard band it gives 1.0 diagonal;
|
|
47251
|
-
* camera 590's refused returns sat at 1.05–1.82× the old flat 0.75 reach,
|
|
47252
|
-
* i.e. 0.79–1.37 diagonals, which this covers from about 5.5 s of gap.
|
|
47253
|
-
*/
|
|
47254
|
-
var RESURRECTION_REACH_DIAGONALS_PER_SEC = .25;
|
|
47255
|
-
/** Never tighter than the flat `rescueCentroidFactor` default it replaces. */
|
|
47256
|
-
var RESURRECTION_REACH_MIN_DIAGONALS = .75;
|
|
47257
|
-
/**
|
|
47258
|
-
* How far from a corpse's last box a detection may sit and still resurrect it
|
|
47259
|
-
* on the proximity clause, for a corpse last seen `gapMs` ago.
|
|
47260
|
-
*/
|
|
47261
|
-
function resurrectionReachPx(bbox, gapMs) {
|
|
47262
|
-
const diag = Math.hypot(bbox.w, bbox.h);
|
|
47263
|
-
return clampRange(diag * RESURRECTION_REACH_DIAGONALS_PER_SEC * (Math.max(0, gapMs) / 1e3), diag * RESURRECTION_REACH_MIN_DIAGONALS, diag * 2);
|
|
47264
|
-
}
|
|
47265
47160
|
//#endregion
|
|
47266
47161
|
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
47267
47162
|
/**
|
|
@@ -47276,7 +47171,7 @@ function resurrectionReachPx(bbox, gapMs) {
|
|
|
47276
47171
|
* diagonal = 2.085) with the same ~10% headroom the old 1.35 gave it.
|
|
47277
47172
|
*/
|
|
47278
47173
|
var GROWTH_CENTROID_FACTOR = 2.3;
|
|
47279
|
-
function intersectionArea$
|
|
47174
|
+
function intersectionArea$2(a, b) {
|
|
47280
47175
|
const x1 = Math.max(a.x, b.x);
|
|
47281
47176
|
const y1 = Math.max(a.y, b.y);
|
|
47282
47177
|
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
|
@@ -47315,7 +47210,7 @@ function growthAdjacentMatch(track, det, ctx) {
|
|
|
47315
47210
|
const larger = Math.max(trackArea, detArea);
|
|
47316
47211
|
const smaller = Math.min(trackArea, detArea);
|
|
47317
47212
|
if (larger / smaller < 1.5) return false;
|
|
47318
|
-
if (intersectionArea$
|
|
47213
|
+
if (intersectionArea$2(track, det) / smaller >= .5) return false;
|
|
47319
47214
|
const tc = bboxCentroid(track);
|
|
47320
47215
|
const dc = bboxCentroid(det);
|
|
47321
47216
|
return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
|
|
@@ -47714,10 +47609,10 @@ var SortTracker = class SortTracker {
|
|
|
47714
47609
|
}
|
|
47715
47610
|
/** Emit one salvage decision, subject to {@link LOOSE_BIND_LOG_SAMPLE_EVERY}.
|
|
47716
47611
|
* Nothing is computed when no observer is wired. */
|
|
47717
|
-
reportBind(stage, gate, track, det, timestamp) {
|
|
47612
|
+
reportBind(stage, gate, track, det, timestamp, refusal) {
|
|
47718
47613
|
const observer = this.bindObserver;
|
|
47719
47614
|
if (!observer) return;
|
|
47720
|
-
const bindCount = this.bindSampler.next(stage, gate);
|
|
47615
|
+
const bindCount = this.bindSampler.next(stage, gate, refusal);
|
|
47721
47616
|
if (bindCount === void 0) return;
|
|
47722
47617
|
const tc = bboxCentroid(track.bbox);
|
|
47723
47618
|
const dc = bboxCentroid(det.bbox);
|
|
@@ -47751,6 +47646,7 @@ var SortTracker = class SortTracker {
|
|
|
47751
47646
|
frameHeight: this.frameHeightPx,
|
|
47752
47647
|
dtMs: timestamp - track.lastSeen,
|
|
47753
47648
|
trackAgeMs: timestamp - track.firstSeen,
|
|
47649
|
+
...refusal !== void 0 ? { refusal } : {},
|
|
47754
47650
|
bindCount,
|
|
47755
47651
|
sampledEvery: 50
|
|
47756
47652
|
});
|
|
@@ -48091,15 +47987,45 @@ var SortTracker = class SortTracker {
|
|
|
48091
47987
|
* was written for; not of the pass as it runs: on camera 590 (2026-09-27)
|
|
48092
47988
|
* 38% of all re-splits were PEOPLE returning after 4–12 s, further than the
|
|
48093
47989
|
* flat gate, with frames flowing the whole time. `resurrectionTimeScaled`
|
|
48094
|
-
* off restores the flat gate
|
|
47990
|
+
* off restores the flat gate — and a `vehicle` corpse always has it (D678,
|
|
47991
|
+
* {@link resurrectionTimeScaledFor}).
|
|
48095
47992
|
*/
|
|
48096
47993
|
reacquireReach(track, timestamp, pass) {
|
|
48097
47994
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
48098
|
-
if (pass === "resurrection") return this.
|
|
47995
|
+
if (pass === "resurrection") return this.resurrectionTimeScaledFor(track.class) ? resurrectionReachPx(track.bbox, timestamp - track.lastSeen) : this.config.rescueCentroidFactor * diag;
|
|
48099
47996
|
if (!this.config.reacquireTimeScaled) return this.config.rescueCentroidFactor * diag;
|
|
48100
47997
|
return reacquireReachPx(track.bbox, timestamp - track.lastSeen);
|
|
48101
47998
|
}
|
|
48102
47999
|
/**
|
|
48000
|
+
* Whether D665's graveyard gate — the reach that grows with the gap, and the
|
|
48001
|
+
* plausible-birth veto lifted for a sole subject — applies to a corpse of
|
|
48002
|
+
* `cls`. People and animals only (D678): a car gone for ten seconds did not
|
|
48003
|
+
* stand half-hidden, the next one arrived.
|
|
48004
|
+
*/
|
|
48005
|
+
resurrectionTimeScaledFor(cls) {
|
|
48006
|
+
return this.config.resurrectionTimeScaled && LINGERING_SUBJECT_CLASSES.has(cls);
|
|
48007
|
+
}
|
|
48008
|
+
/**
|
|
48009
|
+
* The size check a PROXIMITY-only resurrection needs (D678), for a pair the
|
|
48010
|
+
* loose gate accepted on its `centroid` clause and the plausible-birth veto
|
|
48011
|
+
* let through.
|
|
48012
|
+
*
|
|
48013
|
+
* The `centroid` clause says where a box is and nothing about what it is;
|
|
48014
|
+
* a box more than {@link RESURRECTION_MAX_AREA_RATIO} larger or smaller than
|
|
48015
|
+
* the corpse's last box is refused on it, and the refusal is returned so the
|
|
48016
|
+
* caller can name it. The pair may still bind on `growth`, the partial-crop
|
|
48017
|
+
* rule whose whole subject IS a size change and which `looseMatch` would
|
|
48018
|
+
* have reached had the centroid clause not answered first. With the default
|
|
48019
|
+
* coast budget that never happens — `growth` refuses a pair more than
|
|
48020
|
+
* `GROWTH_MAX_GAP_MS` (1 s) apart and a corpse has been unseen for
|
|
48021
|
+
* `maxMissedMs` (4 s) — but `maxMissedMs` is a per-camera setting down to 0.
|
|
48022
|
+
*/
|
|
48023
|
+
proximitySizeVerdict(corpse, det, timestamp) {
|
|
48024
|
+
if (boxAreaRatio(corpse.bbox, det.bbox) <= 2.5) return "centroid";
|
|
48025
|
+
if (growthAdjacentMatch(corpse.bbox, det.bbox, this.growthContext(corpse, timestamp))) return "growth";
|
|
48026
|
+
return "size-incompatible";
|
|
48027
|
+
}
|
|
48028
|
+
/**
|
|
48103
48029
|
* Whether a REACH bind would be unambiguous: this coasting track is the only
|
|
48104
48030
|
* subject of its association group the scene holds, and `det` is the only box
|
|
48105
48031
|
* of that group on the frame.
|
|
@@ -48513,13 +48439,26 @@ var SortTracker = class SortTracker {
|
|
|
48513
48439
|
let refused;
|
|
48514
48440
|
let refusedGate = "iou";
|
|
48515
48441
|
let refusedScore = -1;
|
|
48442
|
+
let refusedBy = "crowd-guard";
|
|
48516
48443
|
for (const lost of this.lostTracks) {
|
|
48517
48444
|
if (!lost.resurrectable) continue;
|
|
48518
48445
|
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
48519
|
-
const
|
|
48520
|
-
if (
|
|
48521
|
-
const
|
|
48522
|
-
if (
|
|
48446
|
+
const loose = this.looseMatch(lost, det, timestamp, "resurrection");
|
|
48447
|
+
if (loose === null) continue;
|
|
48448
|
+
const vetoApplies = loose === "centroid" && this.isPlausibleBirth(det, frameArea);
|
|
48449
|
+
if (vetoApplies && !(this.resurrectionTimeScaledFor(lost.class) && this.isSoleSubjectResurrection(lost, det, detections, surviving, lowScore))) continue;
|
|
48450
|
+
const gate = loose === "centroid" ? this.proximitySizeVerdict(lost, det, timestamp) : loose;
|
|
48451
|
+
if (gate === "size-incompatible") {
|
|
48452
|
+
const score = iou$2(lost.bbox, det.bbox);
|
|
48453
|
+
if (score > refusedScore) {
|
|
48454
|
+
refused = lost;
|
|
48455
|
+
refusedGate = "centroid";
|
|
48456
|
+
refusedScore = score;
|
|
48457
|
+
refusedBy = "size-incompatible";
|
|
48458
|
+
}
|
|
48459
|
+
continue;
|
|
48460
|
+
}
|
|
48461
|
+
const vetoLifted = vetoApplies && gate === "centroid";
|
|
48523
48462
|
const score = iou$2(lost.bbox, det.bbox);
|
|
48524
48463
|
const peers = this.sameClassPeerCount(lost, surviving);
|
|
48525
48464
|
if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
|
|
@@ -48527,6 +48466,7 @@ var SortTracker = class SortTracker {
|
|
|
48527
48466
|
refused = lost;
|
|
48528
48467
|
refusedGate = gate;
|
|
48529
48468
|
refusedScore = score;
|
|
48469
|
+
refusedBy = "crowd-guard";
|
|
48530
48470
|
}
|
|
48531
48471
|
continue;
|
|
48532
48472
|
}
|
|
@@ -48539,7 +48479,7 @@ var SortTracker = class SortTracker {
|
|
|
48539
48479
|
}
|
|
48540
48480
|
}
|
|
48541
48481
|
if (!best) {
|
|
48542
|
-
if (refused !== void 0) this.reportBind("resurrection-refused", refusedGate, refused, det, timestamp);
|
|
48482
|
+
if (refused !== void 0) this.reportBind("resurrection-refused", refusedGate, refused, det, timestamp, refusedBy);
|
|
48543
48483
|
continue;
|
|
48544
48484
|
}
|
|
48545
48485
|
resurrectedScene.set(best, bestPeers > 0 ? "multi-subject" : bestVetoLifted ? "sole-subject-veto-lifted" : "sole-subject");
|
|
@@ -50394,7 +50334,6 @@ var EMPTY_CASCADE_TALLY = {
|
|
|
50394
50334
|
faces: 0,
|
|
50395
50335
|
plates: 0,
|
|
50396
50336
|
embeddings: 0,
|
|
50397
|
-
summaries: 0,
|
|
50398
50337
|
bytesReclaimed: 0
|
|
50399
50338
|
};
|
|
50400
50339
|
/** Fold a leaf store's receipts into a running tally. */
|
|
@@ -50438,13 +50377,6 @@ function addLeafCounts(tally, counts) {
|
|
|
50438
50377
|
bytesReclaimed
|
|
50439
50378
|
};
|
|
50440
50379
|
break;
|
|
50441
|
-
case "summaries":
|
|
50442
|
-
next = {
|
|
50443
|
-
...next,
|
|
50444
|
-
summaries: next.summaries + c.rows,
|
|
50445
|
-
bytesReclaimed
|
|
50446
|
-
};
|
|
50447
|
-
break;
|
|
50448
50380
|
}
|
|
50449
50381
|
}
|
|
50450
50382
|
return next;
|
|
@@ -50458,7 +50390,6 @@ function mergeCascadeTallies(a, b) {
|
|
|
50458
50390
|
faces: a.faces + b.faces,
|
|
50459
50391
|
plates: a.plates + b.plates,
|
|
50460
50392
|
embeddings: a.embeddings + b.embeddings,
|
|
50461
|
-
summaries: a.summaries + b.summaries,
|
|
50462
50393
|
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
50463
50394
|
};
|
|
50464
50395
|
}
|
|
@@ -50521,7 +50452,6 @@ function absentCascadeLegs(stores) {
|
|
|
50521
50452
|
if (!stores.faceStore) absent.push("faces");
|
|
50522
50453
|
if (!stores.plateStore) absent.push("plates");
|
|
50523
50454
|
if (!stores.objectEmbeddingStore) absent.push("embeddings");
|
|
50524
|
-
if (!stores.summaryStore) absent.push("summaries");
|
|
50525
50455
|
return absent;
|
|
50526
50456
|
}
|
|
50527
50457
|
function buildAnalyticsCascadeRegistry(stores) {
|
|
@@ -50529,7 +50459,6 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
50529
50459
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
50530
50460
|
if (stores.plateStore) registry.push(stores.plateStore);
|
|
50531
50461
|
if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
|
|
50532
|
-
if (stores.summaryStore) registry.push(stores.summaryStore);
|
|
50533
50462
|
return registry;
|
|
50534
50463
|
}
|
|
50535
50464
|
/**
|
|
@@ -55050,10 +54979,6 @@ function trackColumnsFor(projection, needsPositions = false) {
|
|
|
55050
54979
|
* whatever order the engine felt like and a cursor that cannot resume.
|
|
55051
54980
|
*/
|
|
55052
54981
|
var RECENT_IDS_COLUMNS = ["lastSeen"];
|
|
55053
|
-
/** How many track ids one filter pass may collect per tier. Bounded for the
|
|
55054
|
-
* same reason every bulk read here is: an unbounded scan of this table is
|
|
55055
|
-
* what D447 measured holding the settings thread for seconds. */
|
|
55056
|
-
var TRACK_MATCH_SCAN_LIMIT = 2e4;
|
|
55057
54982
|
/** Spreadable `columns` for a store query — omitted entirely for `full`. */
|
|
55058
54983
|
function projectionArg(projection, needsPositions = false) {
|
|
55059
54984
|
const columns = trackColumnsFor(projection, needsPositions);
|
|
@@ -56170,42 +56095,6 @@ var TrackStore = class {
|
|
|
56170
56095
|
* current subject out from under it. The orphan audit's grace window would
|
|
56171
56096
|
* hide this for an hour and then stop hiding it.
|
|
56172
56097
|
*/
|
|
56173
|
-
/**
|
|
56174
|
-
* Track ids whose CLASS, sub-class or instance matches any of `terms`.
|
|
56175
|
-
*
|
|
56176
|
-
* The summaries' filter asks its question here rather than on a copy: a
|
|
56177
|
-
* label is a fact about the TRACK, it is decided while the track lives and
|
|
56178
|
-
* can still be re-decided afterwards (class voting, a late face match, an
|
|
56179
|
-
* operator enrolling a face and renaming that person). A copy stamped onto
|
|
56180
|
-
* the summary would answer with whatever was true at one instant and never
|
|
56181
|
-
* learn — which is the whole reason this lookup exists instead.
|
|
56182
|
-
*
|
|
56183
|
-
* Three queries because the filter compiler has no OR across fields, and one
|
|
56184
|
-
* term can be any of the three tiers: `animal` is a class, `dog` is a
|
|
56185
|
-
* sub-class, `Roberta` is an instance. Bounded, like every other bulk read
|
|
56186
|
-
* here.
|
|
56187
|
-
*/
|
|
56188
|
-
async trackIdsMatching(terms) {
|
|
56189
|
-
const wanted = terms.filter((t) => t.length > 0);
|
|
56190
|
-
if (wanted.length === 0) return [];
|
|
56191
|
-
const ids = /* @__PURE__ */ new Set();
|
|
56192
|
-
for (const field of [
|
|
56193
|
-
"className",
|
|
56194
|
-
"label",
|
|
56195
|
-
"subLabel"
|
|
56196
|
-
]) {
|
|
56197
|
-
const rows = await this.store.query.query({
|
|
56198
|
-
collection: TRACKS_COLLECTION,
|
|
56199
|
-
filter: {
|
|
56200
|
-
whereIn: { [field]: [...wanted] },
|
|
56201
|
-
limit: TRACK_MATCH_SCAN_LIMIT
|
|
56202
|
-
},
|
|
56203
|
-
columns: ["id"]
|
|
56204
|
-
});
|
|
56205
|
-
for (const row of rows) ids.add(row.id);
|
|
56206
|
-
}
|
|
56207
|
-
return [...ids];
|
|
56208
|
-
}
|
|
56209
56098
|
async existingTrackIds(trackIds) {
|
|
56210
56099
|
const PAGE = 500;
|
|
56211
56100
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -65144,7 +65033,7 @@ var RetrainAnnotationStore = class {
|
|
|
65144
65033
|
};
|
|
65145
65034
|
//#endregion
|
|
65146
65035
|
//#region src/pipeline-analytics/retrain/retrain-geometry.ts
|
|
65147
|
-
function clamp01
|
|
65036
|
+
function clamp01(v) {
|
|
65148
65037
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
65149
65038
|
}
|
|
65150
65039
|
/** Is this a usable rectangle at all? A zero-area box is not a subject. */
|
|
@@ -65220,10 +65109,10 @@ function deriveZoneCropRect(bbox, zone) {
|
|
|
65220
65109
|
function deriveSubjectCropRect(bbox, padding) {
|
|
65221
65110
|
if (!isUsableRect(bbox)) return null;
|
|
65222
65111
|
const pad = Number.isFinite(padding) && padding > 0 ? padding : 0;
|
|
65223
|
-
const cropX = clamp01
|
|
65224
|
-
const cropY = clamp01
|
|
65225
|
-
const cropRight = clamp01
|
|
65226
|
-
const cropBottom = clamp01
|
|
65112
|
+
const cropX = clamp01(bbox.x - bbox.w * pad);
|
|
65113
|
+
const cropY = clamp01(bbox.y - bbox.h * pad);
|
|
65114
|
+
const cropRight = clamp01(bbox.x + bbox.w * (1 + pad));
|
|
65115
|
+
const cropBottom = clamp01(bbox.y + bbox.h * (1 + pad));
|
|
65227
65116
|
const cropW = cropRight - cropX;
|
|
65228
65117
|
const cropH = cropBottom - cropY;
|
|
65229
65118
|
if (!(cropW > 0) || !(cropH > 0)) return null;
|
|
@@ -69800,7 +69689,7 @@ function isOversizeBox(bbox, frameWidth, frameHeight) {
|
|
|
69800
69689
|
const frame = frameWidth * frameHeight;
|
|
69801
69690
|
return frame > 0 && bbox.w * bbox.h / frame >= .3;
|
|
69802
69691
|
}
|
|
69803
|
-
function intersectionArea$
|
|
69692
|
+
function intersectionArea$1(a, b) {
|
|
69804
69693
|
const ix1 = Math.max(a.x, b.x);
|
|
69805
69694
|
const iy1 = Math.max(a.y, b.y);
|
|
69806
69695
|
const ix2 = Math.min(a.x + a.w, b.x + b.w);
|
|
@@ -69813,7 +69702,7 @@ function detectionSwallowsEntry(det, parked) {
|
|
|
69813
69702
|
const detArea = det.w * det.h;
|
|
69814
69703
|
if (entryArea <= 0 || detArea < entryArea * 1.35) return false;
|
|
69815
69704
|
if (!centroidInside(det, centroid(parked))) return false;
|
|
69816
|
-
return intersectionArea$
|
|
69705
|
+
return intersectionArea$1(parked, det) / entryArea >= SWALLOW_MIN_COVERAGE;
|
|
69817
69706
|
}
|
|
69818
69707
|
/**
|
|
69819
69708
|
* True when `det` is a FRAGMENT of the parked object — much smaller than the
|
|
@@ -69836,7 +69725,7 @@ function entrySwallowsDetection(parked, det) {
|
|
|
69836
69725
|
const detArea = det.w * det.h;
|
|
69837
69726
|
if (entryArea <= 0 || detArea <= 0) return false;
|
|
69838
69727
|
if (detArea > entryArea / 1.35) return false;
|
|
69839
|
-
return intersectionArea$
|
|
69728
|
+
return intersectionArea$1(parked, det) / detArea >= FRAGMENT_MIN_CONTAINMENT;
|
|
69840
69729
|
}
|
|
69841
69730
|
function parkedEntryHoldsDetection(entry, detBbox, config) {
|
|
69842
69731
|
const overlap = iou(entry.bbox, detBbox);
|
|
@@ -70777,7 +70666,7 @@ var VEHICLE_CLASSES = [
|
|
|
70777
70666
|
"vehicle"
|
|
70778
70667
|
];
|
|
70779
70668
|
/** Area of the intersection of two boxes. `0` when they do not overlap. */
|
|
70780
|
-
function intersectionArea
|
|
70669
|
+
function intersectionArea(a, b) {
|
|
70781
70670
|
const left = Math.max(a.x, b.x);
|
|
70782
70671
|
const right = Math.min(a.x + a.w, b.x + b.w);
|
|
70783
70672
|
const top = Math.max(a.y, b.y);
|
|
@@ -70794,7 +70683,7 @@ function intersectionArea$1(a, b) {
|
|
|
70794
70683
|
function containedFraction(person, vehicle) {
|
|
70795
70684
|
const area = person.w * person.h;
|
|
70796
70685
|
if (area <= 0) return 0;
|
|
70797
|
-
return intersectionArea
|
|
70686
|
+
return intersectionArea(person, vehicle) / area;
|
|
70798
70687
|
}
|
|
70799
70688
|
/**
|
|
70800
70689
|
* Is this person box contained in any vehicle box in the same frame?
|
|
@@ -74390,895 +74279,6 @@ async function reconcileGallerySamples(deps, options) {
|
|
|
74390
74279
|
stoppedByCeiling
|
|
74391
74280
|
};
|
|
74392
74281
|
}
|
|
74393
|
-
//#endregion
|
|
74394
|
-
//#region src/pipeline-analytics/summary/summary-membership.ts
|
|
74395
|
-
/**
|
|
74396
|
-
* Who belongs to a summary, and which member is its primary.
|
|
74397
|
-
*
|
|
74398
|
-
* ## What this corrects
|
|
74399
|
-
*
|
|
74400
|
-
* `openOrAbsorb` absorbed ANY track born while a summary was open on the
|
|
74401
|
-
* camera, and `primary` went to whoever opened it. Both were measured wrong on
|
|
74402
|
-
* the live hub on 2026-09-13: synthetic sensor tracks (`pa-synth-…`) sat inside
|
|
74403
|
-
* summaries describing "who was here together", and a dog that walked in ahead
|
|
74404
|
-
* of its owner outranked the person behind it.
|
|
74405
|
-
*
|
|
74406
|
-
* The operator's rule: "persona animale veicolo la priorità. Gli eventi extra
|
|
74407
|
-
* sono sempre separati come eventi sintetici di sensori e pacchi."
|
|
74408
|
-
*
|
|
74409
|
-
* ## Why exclusion and priority live together
|
|
74410
|
-
*
|
|
74411
|
-
* They are the same question asked twice — "what is a subject in a group" —
|
|
74412
|
-
* and answering it in two files is how the two drift. A class that stops being
|
|
74413
|
-
* a member must not keep a rank, and a class that gains a rank must be a
|
|
74414
|
-
* member.
|
|
74415
|
-
*
|
|
74416
|
-
* Pure, so the surprising cases are pinned rather than discovered: the dog that
|
|
74417
|
-
* arrives first, the tie between two people, and the member whose class is not
|
|
74418
|
-
* known yet.
|
|
74419
|
-
*/
|
|
74420
|
-
/** The synthetic marker. `synthetic-track.ts` mints these and nothing parses it
|
|
74421
|
-
* except this — which is the one place it must be parsed. */
|
|
74422
|
-
var SYNTHETIC_TRACK_PREFIX = "pa-synth-";
|
|
74423
|
-
/**
|
|
74424
|
-
* Classes the operator keeps OUT of a group, by name.
|
|
74425
|
-
*
|
|
74426
|
-
* A delivered package is an event about a PLACE, not a member of something that
|
|
74427
|
-
* arrived together — the operator named it alongside the sensors.
|
|
74428
|
-
*/
|
|
74429
|
-
var NON_MEMBER_CLASSES = new Set(["package"]);
|
|
74430
|
-
/**
|
|
74431
|
-
* Is this track a member of a group at all?
|
|
74432
|
-
*
|
|
74433
|
-
* An unknown class is NOT a reason to refuse. The grouping is the point and the
|
|
74434
|
-
* label is decoration, and nothing revisits this decision — a member dropped
|
|
74435
|
-
* here is dropped for the life of the summary.
|
|
74436
|
-
*/
|
|
74437
|
-
function isSummaryEligible(trackId, className) {
|
|
74438
|
-
if (trackId.startsWith(SYNTHETIC_TRACK_PREFIX)) return false;
|
|
74439
|
-
return className === null || className === void 0 || !NON_MEMBER_CLASSES.has(className);
|
|
74440
|
-
}
|
|
74441
|
-
/**
|
|
74442
|
-
* The operator's order: person › animal › vehicle › everything else.
|
|
74443
|
-
*
|
|
74444
|
-
* Higher is more important. Everything unnamed — including a class that has not
|
|
74445
|
-
* resolved yet — sits below all three rather than above them: promoting an
|
|
74446
|
-
* unknown would let a momentary mis-read take the group's identity, and the
|
|
74447
|
-
* group shot with it.
|
|
74448
|
-
*/
|
|
74449
|
-
var CLASS_RANK = new Map([
|
|
74450
|
-
["person", 3],
|
|
74451
|
-
["animal", 2],
|
|
74452
|
-
["vehicle", 1]
|
|
74453
|
-
]);
|
|
74454
|
-
function classPriority(className) {
|
|
74455
|
-
if (className === null || className === void 0) return 0;
|
|
74456
|
-
return CLASS_RANK.get(className) ?? 0;
|
|
74457
|
-
}
|
|
74458
|
-
/**
|
|
74459
|
-
* Which member is the primary: highest class rank, ties broken by EARLIEST
|
|
74460
|
-
* arrival.
|
|
74461
|
-
*
|
|
74462
|
-
* The tie-break matters more than it looks. Two people are equal, so without it
|
|
74463
|
-
* the primary would flip on every join between equals — and the primary is what
|
|
74464
|
-
* a consumer reads to name the group, so it would rename itself for no reason
|
|
74465
|
-
* an operator could see.
|
|
74466
|
-
*
|
|
74467
|
-
* `null` for no members, never a fabricated id.
|
|
74468
|
-
*/
|
|
74469
|
-
function primaryTrackId(members) {
|
|
74470
|
-
let best = null;
|
|
74471
|
-
for (const m of members) {
|
|
74472
|
-
if (best === null) {
|
|
74473
|
-
best = m;
|
|
74474
|
-
continue;
|
|
74475
|
-
}
|
|
74476
|
-
const rank = classPriority(m.className);
|
|
74477
|
-
const bestRank = classPriority(best.className);
|
|
74478
|
-
if (rank > bestRank || rank === bestRank && m.joinedAt < best.joinedAt) best = m;
|
|
74479
|
-
}
|
|
74480
|
-
return best?.trackId ?? null;
|
|
74481
|
-
}
|
|
74482
|
-
//#endregion
|
|
74483
|
-
//#region src/pipeline-analytics/summary/summary-store.ts
|
|
74484
|
-
/**
|
|
74485
|
-
* @durable class=ledger owner=pipeline-analytics
|
|
74486
|
-
* write="one row per SUMMARY — a window of co-present tracks on one camera
|
|
74487
|
-
* group. Opened by `openOrAbsorb` when the first member arrives, updated
|
|
74488
|
-
* in place while the window absorbs more, and finalised by `seal` when the
|
|
74489
|
-
* window closes. Per WINDOW, never per frame or per track: the sealed
|
|
74490
|
-
* summary is what a notification rule reads."
|
|
74491
|
-
* retention="age-keyed on `sealedAt`, swept with the rest of the analytics
|
|
74492
|
-
* retention. An unsealed row belongs to a window still open and is never
|
|
74493
|
-
* swept by age — it is closed or cascaded away with its members."
|
|
74494
|
-
*/
|
|
74495
|
-
var SUMMARIES_COLLECTION = "pipeline-analytics:summaries";
|
|
74496
|
-
/**
|
|
74497
|
-
* @durable class=ledger owner=pipeline-analytics
|
|
74498
|
-
* write="one row per (summary, track) membership, written as a window
|
|
74499
|
-
* absorbs a track. The join that lets a sealed summary name its subjects
|
|
74500
|
-
* without re-reading the track store."
|
|
74501
|
-
* retention="cascaded — a member dies with its summary (`delete-track-cascade`
|
|
74502
|
-
* counts them as leaves) or with the track it names. It carries no clock of
|
|
74503
|
-
* its own: an age sweep here could outlive or predecease the summary the
|
|
74504
|
-
* row only means anything inside of."
|
|
74505
|
-
*/
|
|
74506
|
-
var SUMMARY_MEMBERS_COLLECTION$1 = "pipeline-analytics:summary-members";
|
|
74507
|
-
var SUMMARIES_COLUMNS = [
|
|
74508
|
-
{
|
|
74509
|
-
name: "id",
|
|
74510
|
-
type: "TEXT",
|
|
74511
|
-
primaryKey: true,
|
|
74512
|
-
notNull: true
|
|
74513
|
-
},
|
|
74514
|
-
{
|
|
74515
|
-
name: "deviceId",
|
|
74516
|
-
type: "INTEGER",
|
|
74517
|
-
notNull: true
|
|
74518
|
-
},
|
|
74519
|
-
{
|
|
74520
|
-
name: "openedAt",
|
|
74521
|
-
type: "INTEGER",
|
|
74522
|
-
notNull: true
|
|
74523
|
-
},
|
|
74524
|
-
{
|
|
74525
|
-
name: "lastActiveAt",
|
|
74526
|
-
type: "INTEGER",
|
|
74527
|
-
notNull: true
|
|
74528
|
-
},
|
|
74529
|
-
{
|
|
74530
|
-
name: "sealedAt",
|
|
74531
|
-
type: "INTEGER"
|
|
74532
|
-
},
|
|
74533
|
-
(
|
|
74534
|
-
/** `deviceId` while open, NULL once sealed — the unique "one open per camera". */
|
|
74535
|
-
{
|
|
74536
|
-
name: "openDeviceId",
|
|
74537
|
-
type: "INTEGER"
|
|
74538
|
-
}),
|
|
74539
|
-
{
|
|
74540
|
-
name: "memberCount",
|
|
74541
|
-
type: "INTEGER",
|
|
74542
|
-
notNull: true
|
|
74543
|
-
}
|
|
74544
|
-
];
|
|
74545
|
-
var SUMMARIES_INDEXES = [(
|
|
74546
|
-
/**
|
|
74547
|
-
* NOT unique since 2026-09-13. A summary is a GROUP, not a camera session, so
|
|
74548
|
-
* one camera can hold several open at once — a family at the door while
|
|
74549
|
-
* somebody else crosses the far end of the drive, or a group that split and
|
|
74550
|
-
* whose halves are now two groups.
|
|
74551
|
-
*
|
|
74552
|
-
* The uniqueness was D359's third guarantee, and the other two survive it in
|
|
74553
|
-
* a stricter form: an open is still idempotent, now keyed on the BIRTH
|
|
74554
|
-
* (`deviceId`, `openedAt`, founding track) rather than on the camera and the
|
|
74555
|
-
* millisecond — two subjects detected in one frame are two groups, not one —
|
|
74556
|
-
* and sealing is still a conditional transition, addressed by id because
|
|
74557
|
-
* `(openDeviceId, openedAt)` no longer names one row. Those are what stopped
|
|
74558
|
-
* the 2026-08-24 incident of eleven sealed rows for one group; "one open per
|
|
74559
|
-
* camera" was the part that also forbade the model the operator asked for.
|
|
74560
|
-
*
|
|
74561
|
-
* The index itself is create-only in this system — `declareCollection` emits
|
|
74562
|
-
* `CREATE ... IF NOT EXISTS` and nothing anywhere drops an index — so on a
|
|
74563
|
-
* database that already has the UNIQUE version this declaration changes
|
|
74564
|
-
* nothing until that index is dropped by hand. Until then `openOrAbsorb`
|
|
74565
|
-
* degrades to exactly the old behaviour (the unique violation is caught and
|
|
74566
|
-
* the newcomer joins the winner), which is why the drop is a switch and not a
|
|
74567
|
-
* prerequisite.
|
|
74568
|
-
*/
|
|
74569
|
-
{
|
|
74570
|
-
name: "summaries_open_device",
|
|
74571
|
-
columns: ["openDeviceId"]
|
|
74572
|
-
}), {
|
|
74573
|
-
name: "summaries_device_opened",
|
|
74574
|
-
columns: ["deviceId", "openedAt"]
|
|
74575
|
-
}];
|
|
74576
|
-
var SUMMARY_MEMBERS_COLUMNS = [
|
|
74577
|
-
{
|
|
74578
|
-
name: "id",
|
|
74579
|
-
type: "TEXT",
|
|
74580
|
-
primaryKey: true,
|
|
74581
|
-
notNull: true
|
|
74582
|
-
},
|
|
74583
|
-
{
|
|
74584
|
-
name: "summaryId",
|
|
74585
|
-
type: "TEXT",
|
|
74586
|
-
notNull: true
|
|
74587
|
-
},
|
|
74588
|
-
{
|
|
74589
|
-
name: "trackId",
|
|
74590
|
-
type: "TEXT",
|
|
74591
|
-
notNull: true
|
|
74592
|
-
},
|
|
74593
|
-
{
|
|
74594
|
-
name: "deviceId",
|
|
74595
|
-
type: "INTEGER",
|
|
74596
|
-
notNull: true
|
|
74597
|
-
},
|
|
74598
|
-
(
|
|
74599
|
-
/** When the track joined — named `timestamp` so the orphan audit's declared-owner scope reads it. */
|
|
74600
|
-
{
|
|
74601
|
-
name: "timestamp",
|
|
74602
|
-
type: "INTEGER",
|
|
74603
|
-
notNull: true
|
|
74604
|
-
}),
|
|
74605
|
-
(
|
|
74606
|
-
/**
|
|
74607
|
-
* What this member IS to the summary: see {@link SummaryMemberRole}.
|
|
74608
|
-
*
|
|
74609
|
-
* NULLABLE, and not because a role is optional. `declareCollection` is
|
|
74610
|
-
* additive — it ALTERs the column in — and SQLite refuses to add a `NOT NULL`
|
|
74611
|
-
* column with no default to a table that already has rows. A default would be
|
|
74612
|
-
* worse than null: it would stamp `primary` onto every member written before
|
|
74613
|
-
* roles existed, including the siblings, and nothing downstream could tell
|
|
74614
|
-
* the fabricated ones from the real ones.
|
|
74615
|
-
*/
|
|
74616
|
-
{
|
|
74617
|
-
name: "role",
|
|
74618
|
-
type: "TEXT"
|
|
74619
|
-
}),
|
|
74620
|
-
(
|
|
74621
|
-
/**
|
|
74622
|
-
* The member's subject class, so "which classes were present" is answerable
|
|
74623
|
-
* without joining `tracks`. Nullable for the same additive reason, AND
|
|
74624
|
-
* because a birth genuinely may not know a class yet — the membership is the
|
|
74625
|
-
* point, the label is decoration.
|
|
74626
|
-
*/
|
|
74627
|
-
{
|
|
74628
|
-
name: "className",
|
|
74629
|
-
type: "TEXT"
|
|
74630
|
-
})
|
|
74631
|
-
];
|
|
74632
|
-
var SUMMARY_MEMBERS_INDEXES = [
|
|
74633
|
-
{
|
|
74634
|
-
name: "summary_members_summary",
|
|
74635
|
-
columns: ["summaryId"]
|
|
74636
|
-
},
|
|
74637
|
-
{
|
|
74638
|
-
name: "summary_members_track",
|
|
74639
|
-
columns: ["trackId"]
|
|
74640
|
-
},
|
|
74641
|
-
(
|
|
74642
|
-
/** The filter's own index: "which groups hold an `animal`" walks members by
|
|
74643
|
-
* class, never summaries by hope. */
|
|
74644
|
-
{
|
|
74645
|
-
name: "summary_members_class",
|
|
74646
|
-
columns: ["className"]
|
|
74647
|
-
})
|
|
74648
|
-
];
|
|
74649
|
-
/**
|
|
74650
|
-
* A stored role, or `null`. An unrecognised value reads as `null` rather than
|
|
74651
|
-
* being passed through: a consumer switching on this must not receive a string
|
|
74652
|
-
* outside the union just because a row was hand-edited or written by a newer
|
|
74653
|
-
* release.
|
|
74654
|
-
*/
|
|
74655
|
-
function readRole(raw) {
|
|
74656
|
-
return raw === "primary" || raw === "sibling" || raw === "child" ? raw : null;
|
|
74657
|
-
}
|
|
74658
|
-
function readClassName(raw) {
|
|
74659
|
-
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
74660
|
-
}
|
|
74661
|
-
/**
|
|
74662
|
-
* The key of an open, derived from the birth that founded it.
|
|
74663
|
-
*
|
|
74664
|
-
* The founding TRACK is part of it, and that is the whole point: the key used
|
|
74665
|
-
* to be `(deviceId, openedAt)`, which made an open idempotent — a retry writes
|
|
74666
|
-
* the same row — but also made two subjects detected in the SAME frame one
|
|
74667
|
-
* group, because they are born in the same millisecond. That is the ordinary
|
|
74668
|
-
* case on a gate camera, not a race. With the track in the key a retry still
|
|
74669
|
-
* collapses (same birth, same track) and two births stay two groups.
|
|
74670
|
-
*/
|
|
74671
|
-
/** The widest a JS Date can name — the open end of a half-bounded window. */
|
|
74672
|
-
var TIME_MIN = -864e13;
|
|
74673
|
-
var TIME_MAX = 864e13;
|
|
74674
|
-
/**
|
|
74675
|
-
* How many rows one paged read may materialise before the merge.
|
|
74676
|
-
*
|
|
74677
|
-
* The page is cut AFTER ordering, so the read must be able to serve any cursor
|
|
74678
|
-
* position — but it must also be bounded, because an unbounded read of this
|
|
74679
|
-
* table on a busy hub is the shape D447 measured holding the settings thread
|
|
74680
|
-
* for seconds. Summaries are far fewer than tracks by construction (14 940
|
|
74681
|
-
* against 30 583 on this hub), so this ceiling is generous.
|
|
74682
|
-
*/
|
|
74683
|
-
var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
|
|
74684
|
-
/** How many member rows one filter pass may read per tier. Bounded for the same
|
|
74685
|
-
* reason as the scan above, and generous: members are one row per subject. */
|
|
74686
|
-
var MEMBER_MATCH_SCAN_LIMIT = 2e4;
|
|
74687
|
-
function summaryId(deviceId, openedAt, foundingTrackId) {
|
|
74688
|
-
return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
|
|
74689
|
-
}
|
|
74690
|
-
function memberRowId(summaryIdValue, trackId) {
|
|
74691
|
-
return `${summaryIdValue}:${trackId}`;
|
|
74692
|
-
}
|
|
74693
|
-
function num$1(value, fallback = 0) {
|
|
74694
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
74695
|
-
}
|
|
74696
|
-
function toRow$1(id, data) {
|
|
74697
|
-
return {
|
|
74698
|
-
id,
|
|
74699
|
-
deviceId: num$1(data["deviceId"]),
|
|
74700
|
-
openedAt: num$1(data["openedAt"]),
|
|
74701
|
-
lastActiveAt: num$1(data["lastActiveAt"]),
|
|
74702
|
-
sealedAt: typeof data["sealedAt"] === "number" ? data["sealedAt"] : null,
|
|
74703
|
-
memberCount: num$1(data["memberCount"])
|
|
74704
|
-
};
|
|
74705
|
-
}
|
|
74706
|
-
function isUniqueViolation(err) {
|
|
74707
|
-
return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
|
|
74708
|
-
}
|
|
74709
|
-
var SummaryStore = class {
|
|
74710
|
-
store;
|
|
74711
|
-
logger;
|
|
74712
|
-
onEvict;
|
|
74713
|
-
trackIdsMatching;
|
|
74714
|
-
constructor(deps) {
|
|
74715
|
-
this.store = deps.store;
|
|
74716
|
-
this.logger = deps.logger;
|
|
74717
|
-
this.onEvict = deps.onEvict;
|
|
74718
|
-
this.trackIdsMatching = deps.trackIdsMatching;
|
|
74719
|
-
}
|
|
74720
|
-
static async declare(store) {
|
|
74721
|
-
await Promise.all([store.declareCollection.mutate({
|
|
74722
|
-
collection: SUMMARIES_COLLECTION,
|
|
74723
|
-
columns: [...SUMMARIES_COLUMNS],
|
|
74724
|
-
indexes: [...SUMMARIES_INDEXES]
|
|
74725
|
-
}), store.declareCollection.mutate({
|
|
74726
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74727
|
-
columns: [...SUMMARY_MEMBERS_COLUMNS],
|
|
74728
|
-
indexes: [...SUMMARY_MEMBERS_INDEXES]
|
|
74729
|
-
})]);
|
|
74730
|
-
}
|
|
74731
|
-
/** The camera's OPEN summary, from the row — `null` when none. */
|
|
74732
|
-
async findOpen(deviceId) {
|
|
74733
|
-
const first = (await this.store.query.query({
|
|
74734
|
-
collection: SUMMARIES_COLLECTION,
|
|
74735
|
-
filter: {
|
|
74736
|
-
where: { openDeviceId: deviceId },
|
|
74737
|
-
limit: 1
|
|
74738
|
-
}
|
|
74739
|
-
}))[0];
|
|
74740
|
-
return first === void 0 ? null : toRow$1(first.id, first.data);
|
|
74741
|
-
}
|
|
74742
|
-
/**
|
|
74743
|
-
* Every OPEN group, or just one camera's when `deviceId` is given.
|
|
74744
|
-
*
|
|
74745
|
-
* Plural per camera since 2026-09-13: a summary is a group, not a camera
|
|
74746
|
-
* session, so "the open summary of this camera" stopped being a question with
|
|
74747
|
-
* an answer. Ordered oldest-first, so a caller with no better rule takes the
|
|
74748
|
-
* one that has been there longest — which is what `openOrAbsorb` does, and is
|
|
74749
|
-
* byte-for-byte what shipped when only one could exist.
|
|
74750
|
-
*/
|
|
74751
|
-
async listOpen(deviceId) {
|
|
74752
|
-
return (await this.store.query.query({
|
|
74753
|
-
collection: SUMMARIES_COLLECTION,
|
|
74754
|
-
filter: {
|
|
74755
|
-
where: deviceId === void 0 ? { sealedAt: null } : { openDeviceId: deviceId },
|
|
74756
|
-
limit: 1e4
|
|
74757
|
-
}
|
|
74758
|
-
})).filter((r) => r.data["openDeviceId"] !== null).map((r) => toRow$1(r.id, r.data)).toSorted((a, b) => a.openedAt - b.openedAt);
|
|
74759
|
-
}
|
|
74760
|
-
async membersOf(summaryIdValue) {
|
|
74761
|
-
return (await this.store.query.query({
|
|
74762
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74763
|
-
filter: {
|
|
74764
|
-
where: { summaryId: summaryIdValue },
|
|
74765
|
-
limit: 1e4
|
|
74766
|
-
}
|
|
74767
|
-
})).map((r) => ({
|
|
74768
|
-
summaryId: summaryIdValue,
|
|
74769
|
-
trackId: String(r.data["trackId"] ?? ""),
|
|
74770
|
-
deviceId: num$1(r.data["deviceId"]),
|
|
74771
|
-
joinedAt: num$1(r.data["timestamp"]),
|
|
74772
|
-
role: readRole(r.data["role"]),
|
|
74773
|
-
className: readClassName(r.data["className"])
|
|
74774
|
-
}));
|
|
74775
|
-
}
|
|
74776
|
-
/**
|
|
74777
|
-
* Put a track into the camera's open summary, opening one when there is
|
|
74778
|
-
* none. Racing opens are settled by the store: the loser's insert hits the
|
|
74779
|
-
* unique `openDeviceId` and is re-read as an absorb.
|
|
74780
|
-
*/
|
|
74781
|
-
/**
|
|
74782
|
-
* Open a NEW group for this track.
|
|
74783
|
-
*
|
|
74784
|
-
* The unique-violation branch is kept even though `summaries_open_device` is
|
|
74785
|
-
* no longer UNIQUE: the id is `f(deviceId, openedAt)`, so two births in the
|
|
74786
|
-
* SAME millisecond still collide on the primary key, and the right answer
|
|
74787
|
-
* there is still "join the row that won" — it is the same group.
|
|
74788
|
-
*/
|
|
74789
|
-
async openNew(input) {
|
|
74790
|
-
const id = summaryId(input.deviceId, input.at, input.trackId);
|
|
74791
|
-
try {
|
|
74792
|
-
await this.store.set.mutate({
|
|
74793
|
-
collection: SUMMARIES_COLLECTION,
|
|
74794
|
-
key: id,
|
|
74795
|
-
value: {
|
|
74796
|
-
deviceId: input.deviceId,
|
|
74797
|
-
openedAt: input.at,
|
|
74798
|
-
lastActiveAt: input.at,
|
|
74799
|
-
sealedAt: null,
|
|
74800
|
-
openDeviceId: input.deviceId,
|
|
74801
|
-
memberCount: 1
|
|
74802
|
-
}
|
|
74803
|
-
});
|
|
74804
|
-
} catch (err) {
|
|
74805
|
-
if (!isUniqueViolation(err)) throw err;
|
|
74806
|
-
const winner = (await this.listOpen(input.deviceId)).find((s) => s.id === id);
|
|
74807
|
-
if (winner === void 0) throw err;
|
|
74808
|
-
return this.absorbInto(winner, input);
|
|
74809
|
-
}
|
|
74810
|
-
await this.addMember(id, input, "primary");
|
|
74811
|
-
return {
|
|
74812
|
-
id,
|
|
74813
|
-
deviceId: input.deviceId,
|
|
74814
|
-
openedAt: input.at,
|
|
74815
|
-
lastActiveAt: input.at,
|
|
74816
|
-
sealedAt: null,
|
|
74817
|
-
memberCount: 1
|
|
74818
|
-
};
|
|
74819
|
-
}
|
|
74820
|
-
/**
|
|
74821
|
-
* Join the camera's oldest open group, or open one.
|
|
74822
|
-
*
|
|
74823
|
-
* The default policy, for callers with no better rule. The rule that DOES
|
|
74824
|
-
* know better — proximity — lives with the caller that can see positions, and
|
|
74825
|
-
* reaches the store through {@link listOpen} + {@link absorbInto} instead.
|
|
74826
|
-
*/
|
|
74827
|
-
async openOrAbsorb(input) {
|
|
74828
|
-
const first = (await this.listOpen(input.deviceId))[0];
|
|
74829
|
-
return first === void 0 ? this.openNew(input) : this.absorbInto(first, input);
|
|
74830
|
-
}
|
|
74831
|
-
async absorbInto(summary, input) {
|
|
74832
|
-
const members = await this.membersOf(summary.id);
|
|
74833
|
-
if (members.some((m) => m.trackId === input.trackId)) return summary;
|
|
74834
|
-
const joined = {
|
|
74835
|
-
trackId: input.trackId,
|
|
74836
|
-
className: input.className ?? null,
|
|
74837
|
-
joinedAt: input.at
|
|
74838
|
-
};
|
|
74839
|
-
const primary = primaryTrackId([...members.map((m) => ({
|
|
74840
|
-
trackId: m.trackId,
|
|
74841
|
-
className: m.className,
|
|
74842
|
-
joinedAt: m.joinedAt
|
|
74843
|
-
})), joined]);
|
|
74844
|
-
await this.addMember(summary.id, input, primary === input.trackId ? "primary" : "sibling");
|
|
74845
|
-
if (primary === input.trackId) await this.demoteOtherPrimaries(summary.id, input.trackId, members);
|
|
74846
|
-
const memberCount = members.length + 1;
|
|
74847
|
-
const lastActiveAt = Math.max(summary.lastActiveAt, input.at);
|
|
74848
|
-
await this.store.updateWhere.mutate({
|
|
74849
|
-
collection: SUMMARIES_COLLECTION,
|
|
74850
|
-
filter: { where: {
|
|
74851
|
-
id: summary.id,
|
|
74852
|
-
openDeviceId: summary.deviceId
|
|
74853
|
-
} },
|
|
74854
|
-
data: {
|
|
74855
|
-
memberCount,
|
|
74856
|
-
lastActiveAt
|
|
74857
|
-
}
|
|
74858
|
-
});
|
|
74859
|
-
return {
|
|
74860
|
-
...summary,
|
|
74861
|
-
memberCount,
|
|
74862
|
-
lastActiveAt
|
|
74863
|
-
};
|
|
74864
|
-
}
|
|
74865
|
-
/**
|
|
74866
|
-
* Move a set of members out of an open group into a NEW open group.
|
|
74867
|
-
*
|
|
74868
|
-
* The two sides of a split. The departing members keep their join times, so
|
|
74869
|
-
* the group that leaves opens at the moment its earliest member joined the
|
|
74870
|
-
* old one — it existed then; what changed is that we can now tell.
|
|
74871
|
-
*
|
|
74872
|
-
* Order is deliberate: the new rows are written BEFORE the old ones are
|
|
74873
|
-
* dropped. A failure between the two leaves a member in both groups, which a
|
|
74874
|
-
* reader can see and a later pass can repair; the other order loses the
|
|
74875
|
-
* membership, and a lost member is the one wrong this store exists to
|
|
74876
|
-
* prevent (the same reasoning as {@link demoteOtherPrimaries}).
|
|
74877
|
-
*
|
|
74878
|
-
* `null` when there is nothing to do: no member matched, or ALL of them did.
|
|
74879
|
-
* Moving everybody is not a split — it would leave an empty group behind and
|
|
74880
|
-
* a new one holding exactly what the old one held.
|
|
74881
|
-
*/
|
|
74882
|
-
async splitOff(summary, departingTrackIds, at) {
|
|
74883
|
-
const members = await this.membersOf(summary.id);
|
|
74884
|
-
const leaving = members.filter((m) => departingTrackIds.includes(m.trackId));
|
|
74885
|
-
const staying = members.filter((m) => !departingTrackIds.includes(m.trackId));
|
|
74886
|
-
if (leaving.length === 0 || staying.length === 0) return null;
|
|
74887
|
-
const openedAt = Math.min(...leaving.map((m) => m.joinedAt));
|
|
74888
|
-
const founder = leaving.find((m) => m.joinedAt === openedAt) ?? leaving[0];
|
|
74889
|
-
const id = summaryId(summary.deviceId, openedAt, founder.trackId);
|
|
74890
|
-
await this.store.set.mutate({
|
|
74891
|
-
collection: SUMMARIES_COLLECTION,
|
|
74892
|
-
key: id,
|
|
74893
|
-
value: {
|
|
74894
|
-
deviceId: summary.deviceId,
|
|
74895
|
-
openedAt,
|
|
74896
|
-
lastActiveAt: summary.lastActiveAt,
|
|
74897
|
-
sealedAt: null,
|
|
74898
|
-
openDeviceId: summary.deviceId,
|
|
74899
|
-
memberCount: leaving.length
|
|
74900
|
-
}
|
|
74901
|
-
});
|
|
74902
|
-
const newPrimary = primaryTrackId(leaving);
|
|
74903
|
-
await this.store.insertMany.mutate({
|
|
74904
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74905
|
-
records: leaving.map((m) => ({
|
|
74906
|
-
id: memberRowId(id, m.trackId),
|
|
74907
|
-
data: {
|
|
74908
|
-
summaryId: id,
|
|
74909
|
-
trackId: m.trackId,
|
|
74910
|
-
deviceId: m.deviceId,
|
|
74911
|
-
timestamp: m.joinedAt,
|
|
74912
|
-
role: m.trackId === newPrimary ? "primary" : "sibling",
|
|
74913
|
-
className: m.className
|
|
74914
|
-
}
|
|
74915
|
-
}))
|
|
74916
|
-
});
|
|
74917
|
-
await this.store.deleteWhere.mutate({
|
|
74918
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74919
|
-
filter: {
|
|
74920
|
-
where: { summaryId: summary.id },
|
|
74921
|
-
whereIn: { trackId: leaving.map((m) => m.trackId) }
|
|
74922
|
-
}
|
|
74923
|
-
});
|
|
74924
|
-
const keptPrimary = primaryTrackId(staying);
|
|
74925
|
-
for (const m of staying) {
|
|
74926
|
-
const role = m.trackId === keptPrimary ? "primary" : "sibling";
|
|
74927
|
-
if (m.role === role) continue;
|
|
74928
|
-
await this.store.update.mutate({
|
|
74929
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74930
|
-
id: memberRowId(summary.id, m.trackId),
|
|
74931
|
-
data: { role }
|
|
74932
|
-
});
|
|
74933
|
-
}
|
|
74934
|
-
await this.store.updateWhere.mutate({
|
|
74935
|
-
collection: SUMMARIES_COLLECTION,
|
|
74936
|
-
filter: { where: {
|
|
74937
|
-
id: summary.id,
|
|
74938
|
-
openDeviceId: summary.deviceId
|
|
74939
|
-
} },
|
|
74940
|
-
data: { memberCount: staying.length }
|
|
74941
|
-
});
|
|
74942
|
-
this.logger.info("summary: a group walked apart", {
|
|
74943
|
-
tags: { deviceId: summary.deviceId },
|
|
74944
|
-
meta: {
|
|
74945
|
-
summaryId: summary.id,
|
|
74946
|
-
bornId: id,
|
|
74947
|
-
left: leaving.map((m) => m.trackId),
|
|
74948
|
-
stayed: staying.map((m) => m.trackId),
|
|
74949
|
-
at
|
|
74950
|
-
}
|
|
74951
|
-
});
|
|
74952
|
-
return {
|
|
74953
|
-
id,
|
|
74954
|
-
deviceId: summary.deviceId,
|
|
74955
|
-
openedAt,
|
|
74956
|
-
lastActiveAt: summary.lastActiveAt,
|
|
74957
|
-
sealedAt: null,
|
|
74958
|
-
memberCount: leaving.length
|
|
74959
|
-
};
|
|
74960
|
-
}
|
|
74961
|
-
/**
|
|
74962
|
-
* Put every OTHER member back to `sibling` after a new primary took the role.
|
|
74963
|
-
*
|
|
74964
|
-
* Best-effort per row and loud about a row it could not move: two primaries
|
|
74965
|
-
* is a wrong a consumer can see, but it is smaller than a lost member, so a
|
|
74966
|
-
* failure here never propagates to the join.
|
|
74967
|
-
*/
|
|
74968
|
-
async demoteOtherPrimaries(summaryIdValue, primaryTrack, previous) {
|
|
74969
|
-
for (const m of previous) {
|
|
74970
|
-
if (m.trackId === primaryTrack || m.role !== "primary") continue;
|
|
74971
|
-
try {
|
|
74972
|
-
await this.store.update.mutate({
|
|
74973
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74974
|
-
id: memberRowId(summaryIdValue, m.trackId),
|
|
74975
|
-
data: { role: "sibling" }
|
|
74976
|
-
});
|
|
74977
|
-
} catch (err) {
|
|
74978
|
-
this.logger.warn("summary: could not demote the previous primary — two remain", { meta: {
|
|
74979
|
-
summaryId: summaryIdValue,
|
|
74980
|
-
trackId: m.trackId,
|
|
74981
|
-
error: String(err)
|
|
74982
|
-
} });
|
|
74983
|
-
}
|
|
74984
|
-
}
|
|
74985
|
-
}
|
|
74986
|
-
async addMember(summaryIdValue, input, role) {
|
|
74987
|
-
await this.store.insertMany.mutate({
|
|
74988
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
74989
|
-
records: [{
|
|
74990
|
-
id: memberRowId(summaryIdValue, input.trackId),
|
|
74991
|
-
data: {
|
|
74992
|
-
summaryId: summaryIdValue,
|
|
74993
|
-
trackId: input.trackId,
|
|
74994
|
-
deviceId: input.deviceId,
|
|
74995
|
-
timestamp: input.at,
|
|
74996
|
-
role,
|
|
74997
|
-
className: input.className ?? null
|
|
74998
|
-
}
|
|
74999
|
-
}]
|
|
75000
|
-
});
|
|
75001
|
-
}
|
|
75002
|
-
/**
|
|
75003
|
-
* Seal in place. `true` when THIS call sealed it; `false` when the row was
|
|
75004
|
-
* already sealed (or gone) — the second sealer learns, it does not write.
|
|
75005
|
-
*/
|
|
75006
|
-
async seal(summary, sealedAt) {
|
|
75007
|
-
const { updated } = await this.store.updateWhere.mutate({
|
|
75008
|
-
collection: SUMMARIES_COLLECTION,
|
|
75009
|
-
filter: { where: {
|
|
75010
|
-
id: summary.id,
|
|
75011
|
-
openDeviceId: summary.deviceId
|
|
75012
|
-
} },
|
|
75013
|
-
data: {
|
|
75014
|
-
sealedAt,
|
|
75015
|
-
openDeviceId: null
|
|
75016
|
-
}
|
|
75017
|
-
});
|
|
75018
|
-
if (updated > 1) this.logger.error("summary seal touched more than one row — the open key is not unique", {
|
|
75019
|
-
tags: { deviceId: summary.deviceId },
|
|
75020
|
-
meta: {
|
|
75021
|
-
summaryId: summary.id,
|
|
75022
|
-
updated
|
|
75023
|
-
}
|
|
75024
|
-
});
|
|
75025
|
-
return updated >= 1;
|
|
75026
|
-
}
|
|
75027
|
-
/**
|
|
75028
|
-
* Track cascade leaf: a member row whose track is gone is gone too, and
|
|
75029
|
-
* the summary's count follows. The envelope itself outlives its members
|
|
75030
|
-
* — it is an age-keyed root (`RETENTION_ROOTS`, `summary`).
|
|
75031
|
-
*/
|
|
75032
|
-
async deleteByTracks(trackIds) {
|
|
75033
|
-
if (trackIds.length === 0) return [{
|
|
75034
|
-
family: "summaries",
|
|
75035
|
-
rows: 0,
|
|
75036
|
-
bytes: 0
|
|
75037
|
-
}];
|
|
75038
|
-
const members = await this.store.query.query({
|
|
75039
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
75040
|
-
filter: {
|
|
75041
|
-
whereIn: { trackId: [...trackIds] },
|
|
75042
|
-
limit: 1e4
|
|
75043
|
-
}
|
|
75044
|
-
});
|
|
75045
|
-
if (members.length === 0) return [{
|
|
75046
|
-
family: "summaries",
|
|
75047
|
-
rows: 0,
|
|
75048
|
-
bytes: 0
|
|
75049
|
-
}];
|
|
75050
|
-
const { deleted } = await this.store.deleteWhere.mutate({
|
|
75051
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
75052
|
-
filter: { whereIn: { trackId: [...trackIds] } }
|
|
75053
|
-
});
|
|
75054
|
-
const touched = new Set(members.map((m) => String(m.data["summaryId"] ?? "")));
|
|
75055
|
-
for (const summaryIdValue of touched) {
|
|
75056
|
-
const remaining = await this.membersOf(summaryIdValue);
|
|
75057
|
-
const raw = await this.store.get.query({
|
|
75058
|
-
collection: SUMMARIES_COLLECTION,
|
|
75059
|
-
key: summaryIdValue
|
|
75060
|
-
});
|
|
75061
|
-
if (typeof raw !== "object" || raw === null) continue;
|
|
75062
|
-
const row = raw;
|
|
75063
|
-
await this.store.updateWhere.mutate({
|
|
75064
|
-
collection: SUMMARIES_COLLECTION,
|
|
75065
|
-
filter: { where: {
|
|
75066
|
-
deviceId: num$1(row["deviceId"]),
|
|
75067
|
-
openedAt: num$1(row["openedAt"])
|
|
75068
|
-
} },
|
|
75069
|
-
data: { memberCount: remaining.length }
|
|
75070
|
-
});
|
|
75071
|
-
}
|
|
75072
|
-
return [{
|
|
75073
|
-
family: "summaries",
|
|
75074
|
-
rows: deleted,
|
|
75075
|
-
bytes: 0
|
|
75076
|
-
}];
|
|
75077
|
-
}
|
|
75078
|
-
/**
|
|
75079
|
-
* The surface's page (D359 phase 4): a camera set, newest first by
|
|
75080
|
-
* `openedAt`, inside an optional window, bounded. Ordered and bounded HERE
|
|
75081
|
-
* as well as in the query, so a store that ignores `orderBy` (the fake
|
|
75082
|
-
* does) cannot hand the surface an unordered page.
|
|
75083
|
-
*/
|
|
75084
|
-
async listRecent(input) {
|
|
75085
|
-
if (input.deviceIds.length === 0) return [];
|
|
75086
|
-
const matching = await this.summaryIdsMatching(input.classes);
|
|
75087
|
-
if (matching !== null && matching.size === 0) return [];
|
|
75088
|
-
const limit = Math.max(1, input.limit);
|
|
75089
|
-
return (await this.store.query.query({
|
|
75090
|
-
collection: SUMMARIES_COLLECTION,
|
|
75091
|
-
filter: {
|
|
75092
|
-
whereIn: { deviceId: [...input.deviceIds] },
|
|
75093
|
-
...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { openedAt: [input.since ?? 0, input.until ?? Number.MAX_SAFE_INTEGER] } } : {},
|
|
75094
|
-
orderBy: {
|
|
75095
|
-
field: "openedAt",
|
|
75096
|
-
direction: "desc"
|
|
75097
|
-
},
|
|
75098
|
-
limit
|
|
75099
|
-
}
|
|
75100
|
-
})).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
|
|
75101
|
-
}
|
|
75102
|
-
/** One summary with its members, or `null`. */
|
|
75103
|
-
/**
|
|
75104
|
-
* One PAGE of summaries across a camera set, newest activity first.
|
|
75105
|
-
*
|
|
75106
|
-
* The same contract as `listRecentTracks` and for the same consumers: the
|
|
75107
|
-
* reel on the cameras page and the Events gallery both ride a cursor, so a
|
|
75108
|
-
* feed that could only answer "the last N" would make groups available in the
|
|
75109
|
-
* timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
|
|
75110
|
-
* ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
|
|
75111
|
-
* second implementation of that is a second place for two feeds to disagree
|
|
75112
|
-
* about what "newest" means.
|
|
75113
|
-
*
|
|
75114
|
-
* The window is on LAST ACTIVITY, not on the open: a session that started
|
|
75115
|
-
* before the window and ran into it belongs to the window, because it
|
|
75116
|
-
* happened there.
|
|
75117
|
-
*
|
|
75118
|
-
* Members for the WHOLE page come back in one read. One query per row is how
|
|
75119
|
-
* a page of two hundred becomes two hundred round trips — the shape that made
|
|
75120
|
-
* the old digest read two thousand tracks a camera.
|
|
75121
|
-
*/
|
|
75122
|
-
async queryRecent(input) {
|
|
75123
|
-
const deviceIds = [...new Set(input.deviceIds)];
|
|
75124
|
-
if (deviceIds.length === 0) return {
|
|
75125
|
-
summaries: [],
|
|
75126
|
-
nextCursor: null
|
|
75127
|
-
};
|
|
75128
|
-
const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
|
|
75129
|
-
const matching = await this.summaryIdsMatching(input.classes);
|
|
75130
|
-
if (matching !== null && matching.size === 0) return {
|
|
75131
|
-
summaries: [],
|
|
75132
|
-
nextCursor: null
|
|
75133
|
-
};
|
|
75134
|
-
const ordered = (await this.store.query.query({
|
|
75135
|
-
collection: SUMMARIES_COLLECTION,
|
|
75136
|
-
filter: {
|
|
75137
|
-
whereIn: { deviceId: deviceIds },
|
|
75138
|
-
...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
|
|
75139
|
-
limit: RECENT_SUMMARY_SCAN_LIMIT
|
|
75140
|
-
}
|
|
75141
|
-
})).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
|
|
75142
|
-
row,
|
|
75143
|
-
key: {
|
|
75144
|
-
lastSeen: row.lastActiveAt,
|
|
75145
|
-
id: row.id
|
|
75146
|
-
}
|
|
75147
|
-
})).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
|
|
75148
|
-
const page = ordered.slice(0, input.limit);
|
|
75149
|
-
const last = page.at(-1);
|
|
75150
|
-
const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
|
|
75151
|
-
lastSeen: last.row.lastActiveAt,
|
|
75152
|
-
trackId: last.row.id
|
|
75153
|
-
}) : null;
|
|
75154
|
-
const members = await this.membersOfMany(page.map((e) => e.row.id));
|
|
75155
|
-
return {
|
|
75156
|
-
summaries: page.map((e) => ({
|
|
75157
|
-
summary: e.row,
|
|
75158
|
-
members: members.get(e.row.id) ?? []
|
|
75159
|
-
})),
|
|
75160
|
-
nextCursor
|
|
75161
|
-
};
|
|
75162
|
-
}
|
|
75163
|
-
/**
|
|
75164
|
-
* The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
|
|
75165
|
-
*
|
|
75166
|
-
* TRACKS first, then the members that name them, then the summaries. The
|
|
75167
|
-
* label is a fact about the track and it is asked of the track — a summary
|
|
75168
|
-
* that stored its own copy would answer with whatever was true when it
|
|
75169
|
-
* sealed, and would never learn that a face was enrolled and a person named
|
|
75170
|
-
* an hour later. The operator's rule: *a summary keeps the minimum
|
|
75171
|
-
* information necessary*, and a duplicated label is not the minimum, it is a
|
|
75172
|
-
* second truth with an expiry date.
|
|
75173
|
-
*
|
|
75174
|
-
* Narrows before the page is cut. Filtering the other way round — a page of
|
|
75175
|
-
* summaries, then their members — returns a short or empty page while matches
|
|
75176
|
-
* sit further down the list, and the operator reads that as "there were none".
|
|
75177
|
-
*/
|
|
75178
|
-
async summaryIdsMatching(classes) {
|
|
75179
|
-
const wanted = (classes ?? []).filter((t) => t.length > 0);
|
|
75180
|
-
if (wanted.length === 0) return null;
|
|
75181
|
-
const matchTracks = this.trackIdsMatching;
|
|
75182
|
-
if (matchTracks === void 0) {
|
|
75183
|
-
this.logger.warn("summary filter: no track matcher wired — refusing to answer unfiltered", { meta: { classes: wanted.length } });
|
|
75184
|
-
return /* @__PURE__ */ new Set();
|
|
75185
|
-
}
|
|
75186
|
-
const trackIds = await matchTracks(wanted);
|
|
75187
|
-
if (trackIds.length === 0) return /* @__PURE__ */ new Set();
|
|
75188
|
-
const ids = /* @__PURE__ */ new Set();
|
|
75189
|
-
const PAGE = 500;
|
|
75190
|
-
for (let i = 0; i < trackIds.length; i += PAGE) {
|
|
75191
|
-
const chunk = trackIds.slice(i, i + PAGE);
|
|
75192
|
-
const rows = await this.store.query.query({
|
|
75193
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
75194
|
-
filter: {
|
|
75195
|
-
whereIn: { trackId: [...chunk] },
|
|
75196
|
-
limit: MEMBER_MATCH_SCAN_LIMIT
|
|
75197
|
-
}
|
|
75198
|
-
});
|
|
75199
|
-
for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
|
|
75200
|
-
}
|
|
75201
|
-
ids.delete("");
|
|
75202
|
-
return ids;
|
|
75203
|
-
}
|
|
75204
|
-
/** Members of many summaries, in ONE read, grouped by summary id. */
|
|
75205
|
-
async membersOfMany(summaryIds) {
|
|
75206
|
-
const out = /* @__PURE__ */ new Map();
|
|
75207
|
-
if (summaryIds.length === 0) return out;
|
|
75208
|
-
const rows = await this.store.query.query({
|
|
75209
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
75210
|
-
filter: {
|
|
75211
|
-
whereIn: { summaryId: [...summaryIds] },
|
|
75212
|
-
limit: 1e4
|
|
75213
|
-
}
|
|
75214
|
-
});
|
|
75215
|
-
for (const r of rows) {
|
|
75216
|
-
const summaryIdValue = String(r.data["summaryId"] ?? "");
|
|
75217
|
-
const member = {
|
|
75218
|
-
summaryId: summaryIdValue,
|
|
75219
|
-
trackId: String(r.data["trackId"] ?? ""),
|
|
75220
|
-
deviceId: num$1(r.data["deviceId"]),
|
|
75221
|
-
joinedAt: num$1(r.data["timestamp"]),
|
|
75222
|
-
role: readRole(r.data["role"]),
|
|
75223
|
-
className: readClassName(r.data["className"])
|
|
75224
|
-
};
|
|
75225
|
-
const bucket = out.get(summaryIdValue);
|
|
75226
|
-
if (bucket === void 0) out.set(summaryIdValue, [member]);
|
|
75227
|
-
else bucket.push(member);
|
|
75228
|
-
}
|
|
75229
|
-
for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
|
|
75230
|
-
return out;
|
|
75231
|
-
}
|
|
75232
|
-
async get(id) {
|
|
75233
|
-
const raw = await this.store.get.query({
|
|
75234
|
-
collection: SUMMARIES_COLLECTION,
|
|
75235
|
-
key: id
|
|
75236
|
-
});
|
|
75237
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
75238
|
-
return {
|
|
75239
|
-
summary: toRow$1(id, raw),
|
|
75240
|
-
members: await this.membersOf(id)
|
|
75241
|
-
};
|
|
75242
|
-
}
|
|
75243
|
-
/** The subset of `ids` that still have a row — the orphan audit's probe. Throws on a failed read. */
|
|
75244
|
-
async existingIds(ids) {
|
|
75245
|
-
const found = /* @__PURE__ */ new Set();
|
|
75246
|
-
await Promise.all(ids.map(async (id) => {
|
|
75247
|
-
const raw = await this.store.get.query({
|
|
75248
|
-
collection: SUMMARIES_COLLECTION,
|
|
75249
|
-
key: id
|
|
75250
|
-
});
|
|
75251
|
-
if (typeof raw === "object" && raw !== null) found.add(id);
|
|
75252
|
-
}));
|
|
75253
|
-
return found;
|
|
75254
|
-
}
|
|
75255
|
-
/** Age sweep on the device window: everything opened before `cutoffMs`, members included. */
|
|
75256
|
-
async evictBefore(deviceId, cutoffMs) {
|
|
75257
|
-
const rows = await this.store.query.query({
|
|
75258
|
-
collection: SUMMARIES_COLLECTION,
|
|
75259
|
-
filter: {
|
|
75260
|
-
where: { deviceId },
|
|
75261
|
-
whereBetween: { openedAt: [0, cutoffMs - 1] },
|
|
75262
|
-
limit: 1e4
|
|
75263
|
-
}
|
|
75264
|
-
});
|
|
75265
|
-
if (rows.length === 0) return 0;
|
|
75266
|
-
const ids = rows.map((r) => r.id);
|
|
75267
|
-
await this.onEvict?.(ids);
|
|
75268
|
-
await this.store.deleteWhere.mutate({
|
|
75269
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
75270
|
-
filter: { whereIn: { summaryId: ids } }
|
|
75271
|
-
});
|
|
75272
|
-
const { deleted } = await this.store.deleteWhere.mutate({
|
|
75273
|
-
collection: SUMMARIES_COLLECTION,
|
|
75274
|
-
filter: {
|
|
75275
|
-
where: { deviceId },
|
|
75276
|
-
whereBetween: { openedAt: [0, cutoffMs - 1] }
|
|
75277
|
-
}
|
|
75278
|
-
});
|
|
75279
|
-
return deleted;
|
|
75280
|
-
}
|
|
75281
|
-
};
|
|
75282
74282
|
/**
|
|
75283
74283
|
* Where the completion marker lives.
|
|
75284
74284
|
*
|
|
@@ -78552,10 +77552,6 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
78552
77552
|
collection: PLATES_COLLECTION$1,
|
|
78553
77553
|
classification: derived(PLATE_COLUMNS)
|
|
78554
77554
|
},
|
|
78555
|
-
{
|
|
78556
|
-
collection: SUMMARY_MEMBERS_COLLECTION$1,
|
|
78557
|
-
classification: derived(SUMMARY_MEMBERS_COLUMNS)
|
|
78558
|
-
},
|
|
78559
77555
|
{
|
|
78560
77556
|
collection: MEDIA_COLLECTION,
|
|
78561
77557
|
classification: {
|
|
@@ -78599,13 +77595,6 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
78599
77595
|
rootId: "ops-log"
|
|
78600
77596
|
}
|
|
78601
77597
|
},
|
|
78602
|
-
{
|
|
78603
|
-
collection: SUMMARIES_COLLECTION,
|
|
78604
|
-
classification: {
|
|
78605
|
-
kind: "retention-root",
|
|
78606
|
-
rootId: "summary"
|
|
78607
|
-
}
|
|
78608
|
-
},
|
|
78609
77598
|
{
|
|
78610
77599
|
collection: IDENTITIES_COLLECTION,
|
|
78611
77600
|
classification: {
|
|
@@ -78794,10 +77783,6 @@ function resolveMediaOwner(row) {
|
|
|
78794
77783
|
table: ownerKind,
|
|
78795
77784
|
eventId: ownerId
|
|
78796
77785
|
};
|
|
78797
|
-
case "summary": return {
|
|
78798
|
-
kind: "summary",
|
|
78799
|
-
summaryId: ownerId
|
|
78800
|
-
};
|
|
78801
77786
|
case "face": return ownerId.startsWith("face-") ? {
|
|
78802
77787
|
kind: "track",
|
|
78803
77788
|
trackId: ownerId.slice(FACE_MEDIA_OWNER_PREFIX.length)
|
|
@@ -78905,17 +77890,15 @@ var DEFAULT_ORPHAN_LIMITS = {
|
|
|
78905
77890
|
function questionsFor(refs) {
|
|
78906
77891
|
const trackIds = /* @__PURE__ */ new Set();
|
|
78907
77892
|
const eventIds = /* @__PURE__ */ new Map();
|
|
78908
|
-
const summaryIds = /* @__PURE__ */ new Set();
|
|
78909
77893
|
for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
|
|
78910
77894
|
else if (ref.kind === "event") {
|
|
78911
77895
|
const perTable = eventIds.get(ref.table) ?? /* @__PURE__ */ new Set();
|
|
78912
77896
|
perTable.add(ref.eventId);
|
|
78913
77897
|
eventIds.set(ref.table, perTable);
|
|
78914
|
-
}
|
|
77898
|
+
}
|
|
78915
77899
|
return {
|
|
78916
77900
|
trackIds: [...trackIds],
|
|
78917
|
-
eventIdsByTable: new Map([...eventIds].map(([table, ids]) => [table, [...ids]]))
|
|
78918
|
-
summaryIds: [...summaryIds]
|
|
77901
|
+
eventIdsByTable: new Map([...eventIds].map(([table, ids]) => [table, [...ids]]))
|
|
78919
77902
|
};
|
|
78920
77903
|
}
|
|
78921
77904
|
/**
|
|
@@ -78959,10 +77942,9 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
78959
77942
|
break;
|
|
78960
77943
|
}
|
|
78961
77944
|
const refs = rows.map((row) => scope.resolve(row));
|
|
78962
|
-
const { trackIds, eventIdsByTable
|
|
77945
|
+
const { trackIds, eventIdsByTable } = questionsFor(refs);
|
|
78963
77946
|
let liveTracks;
|
|
78964
77947
|
const liveEvents = /* @__PURE__ */ new Map();
|
|
78965
|
-
let liveSummaries;
|
|
78966
77948
|
let keptMedia = /* @__PURE__ */ new Set();
|
|
78967
77949
|
try {
|
|
78968
77950
|
liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
|
|
@@ -78971,7 +77953,6 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
78971
77953
|
if (ids.length === 0) continue;
|
|
78972
77954
|
liveEvents.set(table, await deps.liveEventIds(table, ids));
|
|
78973
77955
|
}
|
|
78974
|
-
liveSummaries = summaryIds.length > 0 ? await deps.liveSummaryIds(summaryIds) : /* @__PURE__ */ new Set();
|
|
78975
77956
|
} catch (err) {
|
|
78976
77957
|
deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
|
|
78977
77958
|
collection: scope.collection,
|
|
@@ -78999,7 +77980,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
78999
77980
|
tally.unresolvable += 1;
|
|
79000
77981
|
continue;
|
|
79001
77982
|
}
|
|
79002
|
-
if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false :
|
|
77983
|
+
if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : false) continue;
|
|
79003
77984
|
const ts = num(row.data, scope.timestampField);
|
|
79004
77985
|
if (ts !== null && ts > graceFloor) {
|
|
79005
77986
|
tally.withinGrace += 1;
|
|
@@ -79169,11 +78150,7 @@ var ORPHAN_AUDIT_DEFAULTS = {
|
|
|
79169
78150
|
intervalHours: OrphanAuditIntervalHoursSchema.parse(void 0),
|
|
79170
78151
|
pageSize: OrphanAuditPageSizeSchema.parse(void 0)
|
|
79171
78152
|
};
|
|
79172
|
-
var RAW_DELETE_COLLECTIONS = new Set([
|
|
79173
|
-
"pipeline-analytics:object-events",
|
|
79174
|
-
"pipeline-analytics:plates",
|
|
79175
|
-
"pipeline-analytics:summary-members"
|
|
79176
|
-
]);
|
|
78153
|
+
var RAW_DELETE_COLLECTIONS = new Set(["pipeline-analytics:object-events", "pipeline-analytics:plates"]);
|
|
79177
78154
|
function buildOrphanReclaimer(ports) {
|
|
79178
78155
|
return async (collection, key) => {
|
|
79179
78156
|
if (collection === "pipeline-analytics:media") {
|
|
@@ -79314,14 +78291,12 @@ async function sweepTracklessRoots(deps) {
|
|
|
79314
78291
|
cutoffMs
|
|
79315
78292
|
});
|
|
79316
78293
|
const sensor = deps.evictSensorEvents ? await deps.evictSensorEvents(deviceId, cutoffMs) : 0;
|
|
79317
|
-
const summaries = deps.evictSummaries ? await deps.evictSummaries(deviceId, cutoffMs) : 0;
|
|
79318
78294
|
const evicted = {
|
|
79319
78295
|
motion: events.motion,
|
|
79320
78296
|
audio: events.audio,
|
|
79321
|
-
sensor
|
|
79322
|
-
summaries
|
|
78297
|
+
sensor
|
|
79323
78298
|
};
|
|
79324
|
-
if (evicted.motion.length + evicted.audio.length + evicted.sensor
|
|
78299
|
+
if (evicted.motion.length + evicted.audio.length + evicted.sensor > 0) await deps.onEvicted(deviceId, evicted, cutoffs);
|
|
79325
78300
|
} catch (err) {
|
|
79326
78301
|
deps.onError(deviceId, err);
|
|
79327
78302
|
}
|
|
@@ -80098,11 +79073,12 @@ function isFullFrameBbox(bbox) {
|
|
|
80098
79073
|
/** Throttle for the native-crop HIT/FALLBACK metric window line. */
|
|
80099
79074
|
var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
|
|
80100
79075
|
/**
|
|
80101
|
-
* Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (
|
|
80102
|
-
*
|
|
80103
|
-
*
|
|
80104
|
-
*
|
|
80105
|
-
*
|
|
79076
|
+
* Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (~1–1.5 MB
|
|
79077
|
+
* on a 4K camera) rather than a boxed timeline tile or a pinned ≤640 detection
|
|
79078
|
+
* frame (tens of kB). The two belong in different counters or the window line
|
|
79079
|
+
* says "full frames" and means two payload classes three orders of magnitude
|
|
79080
|
+
* apart. (D470) No caller asks for that rung since D422 (the key frame) and
|
|
79081
|
+
* D680 (the group shot); the counter stays so the next one is seen.
|
|
80106
79082
|
*/
|
|
80107
79083
|
var FULL_FRAME_TILE_WIDTH_CEILING = 1920;
|
|
80108
79084
|
function createNativeFrameTransport(deps) {
|
|
@@ -80179,8 +79155,8 @@ function createNativeFrameTransport(deps) {
|
|
|
80179
79155
|
};
|
|
80180
79156
|
};
|
|
80181
79157
|
const getRemoteFrame = async (handle) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame));
|
|
79158
|
+
const getNativeFullFrameRgb = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToFullFrame));
|
|
80182
79159
|
const fetchNativeFullFrameTiered = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame));
|
|
80183
|
-
const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
|
|
80184
79160
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
80185
79161
|
let nativeHits = 0;
|
|
80186
79162
|
let nativeFallbacks = 0;
|
|
@@ -80196,8 +79172,8 @@ function createNativeFrameTransport(deps) {
|
|
|
80196
79172
|
/**
|
|
80197
79173
|
* Publish the window once `request` has SETTLED — never before, or the line
|
|
80198
79174
|
* carries a window whose own outcome has not been counted yet and throttles
|
|
80199
|
-
* away the one that has. `finally`, so the deliberate throw of
|
|
80200
|
-
*
|
|
79175
|
+
* away the one that has. `finally`, so the deliberate throw of the
|
|
79176
|
+
* full-frame fetches still reaches their callers.
|
|
80201
79177
|
*/
|
|
80202
79178
|
const closeWindowAfter = async (request) => {
|
|
80203
79179
|
try {
|
|
@@ -80268,7 +79244,7 @@ function createNativeFrameTransport(deps) {
|
|
|
80268
79244
|
bumpCropMetric(false);
|
|
80269
79245
|
return null;
|
|
80270
79246
|
}
|
|
80271
|
-
if (
|
|
79247
|
+
if (native.tier === "ram-fullframe") {
|
|
80272
79248
|
bumpCropMetric(false);
|
|
80273
79249
|
cropMetricLogger.info("subject crop refused: sub-native tier (will retry, no masquerade)", {
|
|
80274
79250
|
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
@@ -80288,7 +79264,6 @@ function createNativeFrameTransport(deps) {
|
|
|
80288
79264
|
return {
|
|
80289
79265
|
getRemoteFrame,
|
|
80290
79266
|
getNativeFullFrameRgb,
|
|
80291
|
-
getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
|
|
80292
79267
|
getNativeCropJpeg,
|
|
80293
79268
|
captureCrop: createCaptureCrop({
|
|
80294
79269
|
tryNativeCrop,
|
|
@@ -86364,947 +85339,6 @@ var RawGlobalStoreMirror = class {
|
|
|
86364
85339
|
}
|
|
86365
85340
|
};
|
|
86366
85341
|
//#endregion
|
|
86367
|
-
//#region src/pipeline-analytics/summary/group-crop.ts
|
|
86368
|
-
/** Padding around the union, as a fraction of its own size. */
|
|
86369
|
-
var GROUP_CROP_PADDING = .12;
|
|
86370
|
-
/**
|
|
86371
|
-
* Smallest crop, per axis, as a fraction of the frame.
|
|
86372
|
-
*
|
|
86373
|
-
* A quarter. Below that a thumbnail stops being legible at the sizes the
|
|
86374
|
-
* gallery draws it, and the extra context costs nothing — the subjects stay the
|
|
86375
|
-
* dominant thing in the picture at this size.
|
|
86376
|
-
*/
|
|
86377
|
-
var MIN_CROP_FRACTION = .25;
|
|
86378
|
-
/**
|
|
86379
|
-
* The crop, or `null` when there is nothing to cut to — no visible member, or
|
|
86380
|
-
* a frame with no dimensions. `null` means the caller keeps the full frame,
|
|
86381
|
-
* which is the only honest answer when nobody could say where the group was.
|
|
86382
|
-
*/
|
|
86383
|
-
function groupCropRect(input) {
|
|
86384
|
-
const { frameWidth: fw, frameHeight: fh } = input;
|
|
86385
|
-
if (fw <= 0 || fh <= 0) return null;
|
|
86386
|
-
const boxes = input.boxes.filter((b) => b.width > 0 && b.height > 0);
|
|
86387
|
-
if (boxes.length === 0) return null;
|
|
86388
|
-
let left = Number.POSITIVE_INFINITY;
|
|
86389
|
-
let top = Number.POSITIVE_INFINITY;
|
|
86390
|
-
let right = Number.NEGATIVE_INFINITY;
|
|
86391
|
-
let bottom = Number.NEGATIVE_INFINITY;
|
|
86392
|
-
for (const b of boxes) {
|
|
86393
|
-
left = Math.min(left, b.x);
|
|
86394
|
-
top = Math.min(top, b.y);
|
|
86395
|
-
right = Math.max(right, b.x + b.width);
|
|
86396
|
-
bottom = Math.max(bottom, b.y + b.height);
|
|
86397
|
-
}
|
|
86398
|
-
const padX = (right - left) * GROUP_CROP_PADDING;
|
|
86399
|
-
const padY = (bottom - top) * GROUP_CROP_PADDING;
|
|
86400
|
-
let x0 = left - padX;
|
|
86401
|
-
let y0 = top - padY;
|
|
86402
|
-
let x1 = right + padX;
|
|
86403
|
-
let y1 = bottom + padY;
|
|
86404
|
-
const minW = fw * MIN_CROP_FRACTION;
|
|
86405
|
-
const minH = fh * MIN_CROP_FRACTION;
|
|
86406
|
-
if (x1 - x0 < minW) {
|
|
86407
|
-
const cx = (x0 + x1) / 2;
|
|
86408
|
-
x0 = cx - minW / 2;
|
|
86409
|
-
x1 = cx + minW / 2;
|
|
86410
|
-
}
|
|
86411
|
-
if (y1 - y0 < minH) {
|
|
86412
|
-
const cy = (y0 + y1) / 2;
|
|
86413
|
-
y0 = cy - minH / 2;
|
|
86414
|
-
y1 = cy + minH / 2;
|
|
86415
|
-
}
|
|
86416
|
-
const width = Math.min(x1 - x0, fw);
|
|
86417
|
-
const height = Math.min(y1 - y0, fh);
|
|
86418
|
-
const x = Math.max(0, Math.min(x0, fw - width));
|
|
86419
|
-
const y = Math.max(0, Math.min(y0, fh - height));
|
|
86420
|
-
return {
|
|
86421
|
-
x: Math.round(x),
|
|
86422
|
-
y: Math.round(y),
|
|
86423
|
-
width: Math.round(width),
|
|
86424
|
-
height: Math.round(height)
|
|
86425
|
-
};
|
|
86426
|
-
}
|
|
86427
|
-
/**
|
|
86428
|
-
* Carry a crop computed on the DETECTION frame onto the NATIVE surface.
|
|
86429
|
-
*
|
|
86430
|
-
* The two are different sizes by construction: detection runs on a downscaled
|
|
86431
|
-
* frame and the group shot is fetched at up to 4096 px. Applying the rectangle
|
|
86432
|
-
* raw would cut a corner of the picture and call it the group — the failure
|
|
86433
|
-
* mode is silent and looks like a framing bug.
|
|
86434
|
-
*
|
|
86435
|
-
* Scaled per axis rather than by one factor, because the two surfaces are not
|
|
86436
|
-
* guaranteed to share an aspect, and clamped to the native frame so rounding
|
|
86437
|
-
* can never ask for a pixel that is not there.
|
|
86438
|
-
*/
|
|
86439
|
-
function scaleCropToFrame(crop, from, to) {
|
|
86440
|
-
if (from.width <= 0 || from.height <= 0 || to.width <= 0 || to.height <= 0) return null;
|
|
86441
|
-
const sx = to.width / from.width;
|
|
86442
|
-
const sy = to.height / from.height;
|
|
86443
|
-
const x = Math.max(0, Math.min(Math.round(crop.x * sx), to.width - 1));
|
|
86444
|
-
const y = Math.max(0, Math.min(Math.round(crop.y * sy), to.height - 1));
|
|
86445
|
-
return {
|
|
86446
|
-
x,
|
|
86447
|
-
y,
|
|
86448
|
-
width: Math.max(1, Math.min(Math.round(crop.width * sx), to.width - x)),
|
|
86449
|
-
height: Math.max(1, Math.min(Math.round(crop.height * sy), to.height - y))
|
|
86450
|
-
};
|
|
86451
|
-
}
|
|
86452
|
-
//#endregion
|
|
86453
|
-
//#region src/pipeline-analytics/summary/summary-score.ts
|
|
86454
|
-
/** A member whose own box is covered this much by others is hidden. */
|
|
86455
|
-
var OCCLUDED_COVER_FRACTION = .5;
|
|
86456
|
-
/** A box covering this share of the frame scores full marks on area. */
|
|
86457
|
-
var FULL_AREA_SHARE = .25;
|
|
86458
|
-
var WEIGHT_CONFIDENCE = .3;
|
|
86459
|
-
var WEIGHT_AREA = .25;
|
|
86460
|
-
var WEIGHT_RECOGNITION = .25;
|
|
86461
|
-
var WEIGHT_PLACEMENT = .2;
|
|
86462
|
-
function intersectionArea(a, b) {
|
|
86463
|
-
const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
|
|
86464
|
-
const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
|
|
86465
|
-
return w > 0 && h > 0 ? w * h : 0;
|
|
86466
|
-
}
|
|
86467
|
-
/**
|
|
86468
|
-
* Share of `own` covered by `others`, 0..1. Pairwise intersections summed and
|
|
86469
|
-
* capped at the own area: when two others overlap each other on top of this
|
|
86470
|
-
* box the sum over-counts, which only ever calls a member MORE hidden, never
|
|
86471
|
-
* less — the safe side for a shot meant to show everyone.
|
|
86472
|
-
*/
|
|
86473
|
-
function coveredFraction(own, others) {
|
|
86474
|
-
const area = own.width * own.height;
|
|
86475
|
-
if (area <= 0) return 1;
|
|
86476
|
-
const covered = others.reduce((sum, other) => sum + intersectionArea(own, other), 0);
|
|
86477
|
-
return Math.min(1, covered / area);
|
|
86478
|
-
}
|
|
86479
|
-
function clamp01(value) {
|
|
86480
|
-
return Math.max(0, Math.min(1, value));
|
|
86481
|
-
}
|
|
86482
|
-
function placement(box, frameWidth, frameHeight) {
|
|
86483
|
-
const cx = box.x + box.width / 2;
|
|
86484
|
-
const cy = box.y + box.height / 2;
|
|
86485
|
-
const dx = cx - frameWidth / 2;
|
|
86486
|
-
const dy = cy - frameHeight / 2;
|
|
86487
|
-
const halfDiagonal = Math.hypot(frameWidth, frameHeight) / 2;
|
|
86488
|
-
return halfDiagonal <= 0 ? 0 : 1 - clamp01(Math.hypot(dx, dy) / halfDiagonal);
|
|
86489
|
-
}
|
|
86490
|
-
function memberQuality(d, frameWidth, frameHeight) {
|
|
86491
|
-
const frameArea = frameWidth * frameHeight;
|
|
86492
|
-
const cappedArea = clamp01((frameArea <= 0 ? 0 : d.bbox.width * d.bbox.height / frameArea) / FULL_AREA_SHARE);
|
|
86493
|
-
return WEIGHT_CONFIDENCE * clamp01(d.confidence) + WEIGHT_AREA * cappedArea + WEIGHT_RECOGNITION * (d.recognized ? 1 : 0) + WEIGHT_PLACEMENT * placement(d.bbox, frameWidth, frameHeight);
|
|
86494
|
-
}
|
|
86495
|
-
/** Score one frame for one summary. Pure; never throws on empty input. */
|
|
86496
|
-
function scoreSummaryFrame(input) {
|
|
86497
|
-
const present = input.detections.filter((d) => input.members.has(d.trackId));
|
|
86498
|
-
const visible = present.filter((d) => coveredFraction(d.bbox, present.filter((o) => o !== d).map((o) => o.bbox)) < OCCLUDED_COVER_FRACTION);
|
|
86499
|
-
if (visible.length === 0) return {
|
|
86500
|
-
visible: 0,
|
|
86501
|
-
quality: 0,
|
|
86502
|
-
crop: null
|
|
86503
|
-
};
|
|
86504
|
-
const quality = visible.reduce((sum, d) => sum + memberQuality(d, input.frameWidth, input.frameHeight), 0) / visible.length;
|
|
86505
|
-
const crop = groupCropRect({
|
|
86506
|
-
boxes: visible.map((d) => ({
|
|
86507
|
-
x: d.bbox.x,
|
|
86508
|
-
y: d.bbox.y,
|
|
86509
|
-
width: d.bbox.width,
|
|
86510
|
-
height: d.bbox.height
|
|
86511
|
-
})),
|
|
86512
|
-
frameWidth: input.frameWidth,
|
|
86513
|
-
frameHeight: input.frameHeight
|
|
86514
|
-
});
|
|
86515
|
-
return {
|
|
86516
|
-
visible: visible.length,
|
|
86517
|
-
quality,
|
|
86518
|
-
crop
|
|
86519
|
-
};
|
|
86520
|
-
}
|
|
86521
|
-
/**
|
|
86522
|
-
* Lexicographic: more visible members wins outright; only equal counts fall
|
|
86523
|
-
* through to quality. Positive when `a` is the better shot.
|
|
86524
|
-
*/
|
|
86525
|
-
function compareSummaryScores(a, b) {
|
|
86526
|
-
if (a.visible !== b.visible) return a.visible - b.visible;
|
|
86527
|
-
return a.quality - b.quality;
|
|
86528
|
-
}
|
|
86529
|
-
var GroupShotTracker = class {
|
|
86530
|
-
states = /* @__PURE__ */ new Map();
|
|
86531
|
-
offered = 0;
|
|
86532
|
-
materialised = 0;
|
|
86533
|
-
byGap = 0;
|
|
86534
|
-
byMargin = 0;
|
|
86535
|
-
byWorse = 0;
|
|
86536
|
-
/**
|
|
86537
|
-
* Offer a frame. Returns `true` when it is worth materialising NOW — the
|
|
86538
|
-
* caller's cue to fetch the pixels for THIS frame, while its handle is
|
|
86539
|
-
* still leased.
|
|
86540
|
-
*
|
|
86541
|
-
* The clock is `frame.timestamp`; the caller injects no second one.
|
|
86542
|
-
*/
|
|
86543
|
-
offer(summaryId, score, frame) {
|
|
86544
|
-
if (score.visible === 0) return false;
|
|
86545
|
-
this.offered += 1;
|
|
86546
|
-
const state = this.states.get(summaryId);
|
|
86547
|
-
if (state === void 0) {
|
|
86548
|
-
this.states.set(summaryId, {
|
|
86549
|
-
delivered: null,
|
|
86550
|
-
lastAttemptAt: frame.timestamp,
|
|
86551
|
-
unlanded: 1
|
|
86552
|
-
});
|
|
86553
|
-
this.materialised += 1;
|
|
86554
|
-
return true;
|
|
86555
|
-
}
|
|
86556
|
-
if (state.lastAttemptAt !== void 0 && frame.timestamp - state.lastAttemptAt < 2e3) {
|
|
86557
|
-
this.byGap += 1;
|
|
86558
|
-
return false;
|
|
86559
|
-
}
|
|
86560
|
-
if (!this.isWorthPixels(state, score)) return false;
|
|
86561
|
-
state.lastAttemptAt = frame.timestamp;
|
|
86562
|
-
state.unlanded += 1;
|
|
86563
|
-
this.materialised += 1;
|
|
86564
|
-
return true;
|
|
86565
|
-
}
|
|
86566
|
-
/**
|
|
86567
|
-
* Nothing has landed yet → keep trying (D58) until the budget is spent.
|
|
86568
|
-
* Something has landed → beat it, by the margin on the quality tiebreak.
|
|
86569
|
-
*/
|
|
86570
|
-
isWorthPixels(state, score) {
|
|
86571
|
-
const delivered = state.delivered;
|
|
86572
|
-
if (delivered === null) {
|
|
86573
|
-
if (state.unlanded >= 4) {
|
|
86574
|
-
this.byMargin += 1;
|
|
86575
|
-
return false;
|
|
86576
|
-
}
|
|
86577
|
-
return true;
|
|
86578
|
-
}
|
|
86579
|
-
if (compareSummaryScores(score, delivered.score) <= 0) {
|
|
86580
|
-
this.byWorse += 1;
|
|
86581
|
-
return false;
|
|
86582
|
-
}
|
|
86583
|
-
if (score.visible === delivered.score.visible && score.quality - delivered.score.quality < .05) {
|
|
86584
|
-
this.byMargin += 1;
|
|
86585
|
-
return false;
|
|
86586
|
-
}
|
|
86587
|
-
return true;
|
|
86588
|
-
}
|
|
86589
|
-
/**
|
|
86590
|
-
* A materialisation LANDED. Only this advances the baseline the next
|
|
86591
|
-
* candidate must beat — a fetch that missed leaves the summary exactly as
|
|
86592
|
-
* picture-less as it was, and the next frame may be worse and still worth
|
|
86593
|
-
* taking.
|
|
86594
|
-
*/
|
|
86595
|
-
noteLanded(summaryId, score, frame) {
|
|
86596
|
-
const state = this.states.get(summaryId);
|
|
86597
|
-
if (state === void 0) return;
|
|
86598
|
-
state.delivered = {
|
|
86599
|
-
score,
|
|
86600
|
-
frame
|
|
86601
|
-
};
|
|
86602
|
-
state.unlanded = 0;
|
|
86603
|
-
}
|
|
86604
|
-
/** The shot on disk for this summary, as this process last saw it land. */
|
|
86605
|
-
deliveredOf(summaryId) {
|
|
86606
|
-
return this.states.get(summaryId)?.delivered ?? null;
|
|
86607
|
-
}
|
|
86608
|
-
/** A sealed or evicted summary takes no more frames; drop its state. */
|
|
86609
|
-
forget(summaryId) {
|
|
86610
|
-
this.states.delete(summaryId);
|
|
86611
|
-
}
|
|
86612
|
-
/**
|
|
86613
|
-
* What the gate did since the last read. Shipped WITH the gate: without it
|
|
86614
|
-
* the only evidence for a fetch that did NOT happen is hub-main's byte
|
|
86615
|
-
* counter, which answers a different question a minute later.
|
|
86616
|
-
*/
|
|
86617
|
-
drainCounts() {
|
|
86618
|
-
const counts = {
|
|
86619
|
-
offered: this.offered,
|
|
86620
|
-
materialised: this.materialised,
|
|
86621
|
-
byGap: this.byGap,
|
|
86622
|
-
byMargin: this.byMargin,
|
|
86623
|
-
byWorse: this.byWorse
|
|
86624
|
-
};
|
|
86625
|
-
this.offered = 0;
|
|
86626
|
-
this.materialised = 0;
|
|
86627
|
-
this.byGap = 0;
|
|
86628
|
-
this.byMargin = 0;
|
|
86629
|
-
this.byWorse = 0;
|
|
86630
|
-
return counts;
|
|
86631
|
-
}
|
|
86632
|
-
get size() {
|
|
86633
|
-
return this.states.size;
|
|
86634
|
-
}
|
|
86635
|
-
};
|
|
86636
|
-
//#endregion
|
|
86637
|
-
//#region src/pipeline-analytics/summary/group-shot-materialiser.ts
|
|
86638
|
-
/**
|
|
86639
|
-
* Apply the group crop to the native surface, or hand it back untouched.
|
|
86640
|
-
*
|
|
86641
|
-
* Every reason to skip is a reason NOT to guess: no rectangle, no detection
|
|
86642
|
-
* geometry to scale from, or a cropper this deployment does not have. The full
|
|
86643
|
-
* frame is worse than a crop and far better than a wrong crop.
|
|
86644
|
-
*/
|
|
86645
|
-
async function cropNative(native, input, deps) {
|
|
86646
|
-
const crop = input.crop;
|
|
86647
|
-
if (!crop || !deps.cropFrame) return native;
|
|
86648
|
-
const fw = input.cropFrameWidth;
|
|
86649
|
-
const fh = input.cropFrameHeight;
|
|
86650
|
-
if (fw === void 0 || fh === void 0) return native;
|
|
86651
|
-
const scaled = scaleCropToFrame(crop, {
|
|
86652
|
-
width: fw,
|
|
86653
|
-
height: fh
|
|
86654
|
-
}, native);
|
|
86655
|
-
if (scaled === null) return native;
|
|
86656
|
-
if (scaled.width >= native.width && scaled.height >= native.height) return native;
|
|
86657
|
-
try {
|
|
86658
|
-
const cut = await deps.cropFrame(native, scaled);
|
|
86659
|
-
if (cut === null) return native;
|
|
86660
|
-
const expected = cut.width * cut.height * 3;
|
|
86661
|
-
if (cut.format === "rgb" && cut.data.length !== expected) {
|
|
86662
|
-
deps.logger.warn("group shot crop returned a surface that is not its geometry", {
|
|
86663
|
-
tags: { deviceId: input.deviceId },
|
|
86664
|
-
meta: {
|
|
86665
|
-
summaryId: input.summaryId,
|
|
86666
|
-
width: cut.width,
|
|
86667
|
-
height: cut.height,
|
|
86668
|
-
expectedBytes: expected,
|
|
86669
|
-
gotBytes: cut.data.length
|
|
86670
|
-
}
|
|
86671
|
-
});
|
|
86672
|
-
return native;
|
|
86673
|
-
}
|
|
86674
|
-
return cut;
|
|
86675
|
-
} catch (err) {
|
|
86676
|
-
deps.logger.warn("group shot crop failed — storing the full frame", {
|
|
86677
|
-
tags: { deviceId: input.deviceId },
|
|
86678
|
-
meta: {
|
|
86679
|
-
summaryId: input.summaryId,
|
|
86680
|
-
error: require_clip_model_registry.errMsg(err)
|
|
86681
|
-
}
|
|
86682
|
-
});
|
|
86683
|
-
return native;
|
|
86684
|
-
}
|
|
86685
|
-
}
|
|
86686
|
-
async function persistSummaryGroupShot(deps, input) {
|
|
86687
|
-
const { deviceId, summaryId, frameHandle } = input;
|
|
86688
|
-
const drop = {
|
|
86689
|
-
tags: { deviceId },
|
|
86690
|
-
meta: {
|
|
86691
|
-
summaryId,
|
|
86692
|
-
nodeId: frameHandle.nodeId
|
|
86693
|
-
}
|
|
86694
|
-
};
|
|
86695
|
-
try {
|
|
86696
|
-
const result = await deps.fetchNativeFullFrame(frameHandle, input.maxWidth);
|
|
86697
|
-
const native = result?.frame;
|
|
86698
|
-
if (!native || native.format !== "rgb" || native.width <= 0 || native.height <= 0) {
|
|
86699
|
-
deps.logger.warn("group shot native miss — the previous shot stands", {
|
|
86700
|
-
...drop,
|
|
86701
|
-
meta: {
|
|
86702
|
-
...drop.meta,
|
|
86703
|
-
shmId: frameHandle.shmId
|
|
86704
|
-
}
|
|
86705
|
-
});
|
|
86706
|
-
return { kind: "native-miss" };
|
|
86707
|
-
}
|
|
86708
|
-
if (!keyFrameAcceptsTier(result.tier)) {
|
|
86709
|
-
deps.logger.warn("group shot RAM tier rejected — the previous shot stands", {
|
|
86710
|
-
...drop,
|
|
86711
|
-
meta: {
|
|
86712
|
-
...drop.meta,
|
|
86713
|
-
shmId: frameHandle.shmId,
|
|
86714
|
-
tier: result.tier
|
|
86715
|
-
}
|
|
86716
|
-
});
|
|
86717
|
-
return { kind: "ram-tier" };
|
|
86718
|
-
}
|
|
86719
|
-
const surface = await cropNative(native, input, deps);
|
|
86720
|
-
const { keyFrame, keyFrameSmall } = await deps.encodeVariants(surface);
|
|
86721
|
-
const keyFrameMediaId = await deps.media.putReplacing({
|
|
86722
|
-
deviceId,
|
|
86723
|
-
ownerKind: "summary",
|
|
86724
|
-
ownerId: summaryId,
|
|
86725
|
-
kind: "keyFrame",
|
|
86726
|
-
timestamp: input.timestamp,
|
|
86727
|
-
data: keyFrame
|
|
86728
|
-
});
|
|
86729
|
-
try {
|
|
86730
|
-
await deps.media.putReplacing({
|
|
86731
|
-
deviceId,
|
|
86732
|
-
ownerKind: "summary",
|
|
86733
|
-
ownerId: summaryId,
|
|
86734
|
-
kind: "keyFrameSmall",
|
|
86735
|
-
timestamp: input.timestamp,
|
|
86736
|
-
data: keyFrameSmall
|
|
86737
|
-
});
|
|
86738
|
-
} catch (err) {
|
|
86739
|
-
deps.logger.warn("group shot web companion write failed — the native shot is stored", {
|
|
86740
|
-
...drop,
|
|
86741
|
-
meta: {
|
|
86742
|
-
...drop.meta,
|
|
86743
|
-
error: require_clip_model_registry.errMsg(err)
|
|
86744
|
-
}
|
|
86745
|
-
});
|
|
86746
|
-
}
|
|
86747
|
-
return {
|
|
86748
|
-
kind: "landed",
|
|
86749
|
-
keyFrameMediaId
|
|
86750
|
-
};
|
|
86751
|
-
} catch (err) {
|
|
86752
|
-
const error = require_clip_model_registry.errMsg(err);
|
|
86753
|
-
deps.logger.warn("group shot capture failed", {
|
|
86754
|
-
...drop,
|
|
86755
|
-
meta: {
|
|
86756
|
-
...drop.meta,
|
|
86757
|
-
error
|
|
86758
|
-
}
|
|
86759
|
-
});
|
|
86760
|
-
return {
|
|
86761
|
-
kind: "error",
|
|
86762
|
-
error
|
|
86763
|
-
};
|
|
86764
|
-
}
|
|
86765
|
-
}
|
|
86766
|
-
//#endregion
|
|
86767
|
-
//#region src/pipeline-analytics/summary/summary-composition.ts
|
|
86768
|
-
function summaryComposition(members) {
|
|
86769
|
-
const counts = /* @__PURE__ */ new Map();
|
|
86770
|
-
for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
|
|
86771
|
-
return { classes: [...counts].map(([className, count]) => ({
|
|
86772
|
-
className,
|
|
86773
|
-
count
|
|
86774
|
-
})).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
|
|
86775
|
-
}
|
|
86776
|
-
//#endregion
|
|
86777
|
-
//#region src/pipeline-analytics/summary/summary-member-labels.ts
|
|
86778
|
-
function present(value) {
|
|
86779
|
-
return typeof value === "string" && value.length > 0;
|
|
86780
|
-
}
|
|
86781
|
-
function withMemberLabels(members, tracks) {
|
|
86782
|
-
const byId = new Map(tracks.map((t) => [t.trackId, t]));
|
|
86783
|
-
return members.map((member) => {
|
|
86784
|
-
const track = byId.get(member.trackId);
|
|
86785
|
-
return {
|
|
86786
|
-
...member,
|
|
86787
|
-
className: member.className ?? (present(track?.className) ? track.className : null),
|
|
86788
|
-
...present(track?.label) ? { label: track.label } : {},
|
|
86789
|
-
...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
|
|
86790
|
-
};
|
|
86791
|
-
});
|
|
86792
|
-
}
|
|
86793
|
-
//#endregion
|
|
86794
|
-
//#region src/pipeline-analytics/summary/summary-proximity.ts
|
|
86795
|
-
/**
|
|
86796
|
-
* The measured gap, in fractions of the frame's diagonal-free normalisation
|
|
86797
|
-
* (x by width, y by height). See the arithmetic above before moving it.
|
|
86798
|
-
*/
|
|
86799
|
-
var DEFAULT_TOGETHER_FRACTION = .15;
|
|
86800
|
-
/**
|
|
86801
|
-
* Distance between two points as a fraction of the frame, or `null` when the
|
|
86802
|
-
* frame is unknown. Each axis is normalised by its OWN dimension, so the answer
|
|
86803
|
-
* does not change when a camera's aspect does.
|
|
86804
|
-
*/
|
|
86805
|
-
function normalisedDistance(a, b, frame) {
|
|
86806
|
-
if (!(frame.width > 0) || !(frame.height > 0)) return null;
|
|
86807
|
-
const dx = (a.x - b.x) / frame.width;
|
|
86808
|
-
const dy = (a.y - b.y) / frame.height;
|
|
86809
|
-
return Math.sqrt(dx * dx + dy * dy);
|
|
86810
|
-
}
|
|
86811
|
-
//#endregion
|
|
86812
|
-
//#region src/pipeline-analytics/summary/summary-join.ts
|
|
86813
|
-
/**
|
|
86814
|
-
* Which open group a newborn track joins — or none, and it opens its own.
|
|
86815
|
-
*
|
|
86816
|
-
* ## Why this is a separate decision from the store
|
|
86817
|
-
*
|
|
86818
|
-
* The store knows rows; only the caller can see where subjects ARE. Keeping the
|
|
86819
|
-
* choice here means the store's `listOpen` + `absorbInto` stay a mechanism and
|
|
86820
|
-
* this stays the policy, which is what let the policy change without the store
|
|
86821
|
-
* changing underneath it.
|
|
86822
|
-
*
|
|
86823
|
-
* ## The two cases this exists for
|
|
86824
|
-
*
|
|
86825
|
-
* A summary used to be one per camera, so a subject at the far end of the drive
|
|
86826
|
-
* was absorbed into the group at the door — a group it was never part of — and
|
|
86827
|
-
* the halves of a group that split had nowhere to go. Both are the same need:
|
|
86828
|
-
* a summary is a GROUP, not a camera session.
|
|
86829
|
-
*
|
|
86830
|
-
* ## Nearest, not first
|
|
86831
|
-
*
|
|
86832
|
-
* When two groups are within reach the closest wins. `listOpen` orders by
|
|
86833
|
-
* `openedAt`, and which group opened first has nothing to do with who this
|
|
86834
|
-
* subject is walking with — taking the first would make the answer depend on
|
|
86835
|
-
* list order, which is the kind of tie-break that reads as random in a log.
|
|
86836
|
-
*
|
|
86837
|
-
* ## Knowing nothing is not a reason to take somebody in
|
|
86838
|
-
*
|
|
86839
|
-
* A group whose members have no position in RAM — one open across a restart —
|
|
86840
|
-
* is skipped rather than joined. Joining on absent evidence is exactly how a
|
|
86841
|
-
* far subject ends up in the wrong group, and nothing revisits that decision.
|
|
86842
|
-
*/
|
|
86843
|
-
/**
|
|
86844
|
-
* The id of the group to join, or `null` to open a new one.
|
|
86845
|
-
*
|
|
86846
|
-
* `null` on an unknown frame too: refusing is the recoverable direction, since
|
|
86847
|
-
* the subject then gets its own group, while a wrong join merges two strangers.
|
|
86848
|
-
*/
|
|
86849
|
-
function chooseGroupToJoin(subject, groups, frame, maxFraction) {
|
|
86850
|
-
let bestId = null;
|
|
86851
|
-
let bestDistance = Number.POSITIVE_INFINITY;
|
|
86852
|
-
for (const g of groups) for (const m of g.memberPositions) {
|
|
86853
|
-
const d = normalisedDistance(subject, m, frame);
|
|
86854
|
-
if (d === null || d > maxFraction) continue;
|
|
86855
|
-
if (d < bestDistance) {
|
|
86856
|
-
bestDistance = d;
|
|
86857
|
-
bestId = g.id;
|
|
86858
|
-
}
|
|
86859
|
-
}
|
|
86860
|
-
return bestId;
|
|
86861
|
-
}
|
|
86862
|
-
//#endregion
|
|
86863
|
-
//#region src/pipeline-analytics/summary/summary-split.ts
|
|
86864
|
-
/**
|
|
86865
|
-
* A group that walked apart — who leaves, and who keeps the summary.
|
|
86866
|
-
*
|
|
86867
|
-
* ## The case
|
|
86868
|
-
*
|
|
86869
|
-
* Membership is decided ONCE, at birth, by proximity (D489). That answers "who
|
|
86870
|
-
* arrived together" and says nothing about what happens next: two people who
|
|
86871
|
-
* meet at the gate and then go separate ways stay one group until the camera
|
|
86872
|
-
* falls quiet, and the summary then claims they were together for the whole
|
|
86873
|
-
* session. The operator asked for this case by name alongside the concurrent
|
|
86874
|
-
* ones — "un gruppo che si divide" — and they are the same need, because the
|
|
86875
|
-
* halves of a split have to be able to BE groups.
|
|
86876
|
-
*
|
|
86877
|
-
* ## Single linkage, deliberately
|
|
86878
|
-
*
|
|
86879
|
-
* A family in single file is a chain: the last of them is nowhere near the
|
|
86880
|
-
* first, and requiring every pair to be close would tear the group apart the
|
|
86881
|
-
* moment it grew past two. So two members are together when a chain of
|
|
86882
|
-
* within-threshold hops connects them — the same "any, not all" rule
|
|
86883
|
-
* {@link isTogetherWith} uses at birth, applied to the whole set.
|
|
86884
|
-
*
|
|
86885
|
-
* ## What an absent position means
|
|
86886
|
-
*
|
|
86887
|
-
* Nothing. A member whose track has closed has no live position, and that is
|
|
86888
|
-
* not evidence it walked off — it is the ordinary end of a track. Unplaced
|
|
86889
|
-
* members are never candidates to leave; they stay with whoever keeps the
|
|
86890
|
-
* summary. Likewise an unknown frame yields a `null` distance, and a null is
|
|
86891
|
-
* not "far": it produces ONE cluster, never a split (D393).
|
|
86892
|
-
*
|
|
86893
|
-
* ## Which side keeps the row
|
|
86894
|
-
*
|
|
86895
|
-
* The primary's cluster, because the summary's identity and its group shot are
|
|
86896
|
-
* already the primary's. When the primary cannot be placed — its track closed —
|
|
86897
|
-
* the largest cluster keeps it instead; refusing to split there would freeze
|
|
86898
|
-
* every group the moment its primary left the frame, which is the common way
|
|
86899
|
-
* for a group to break up in the first place.
|
|
86900
|
-
*/
|
|
86901
|
-
/**
|
|
86902
|
-
* Members grouped by single-linkage proximity. One cluster when nothing can be
|
|
86903
|
-
* measured — an unknown frame is not a reason to tear a group in half.
|
|
86904
|
-
*/
|
|
86905
|
-
function clusterByProximity(members, frame, maxFraction) {
|
|
86906
|
-
const parent = members.map((_, i) => i);
|
|
86907
|
-
const find = (i) => {
|
|
86908
|
-
let root = i;
|
|
86909
|
-
while (parent[root] !== root) root = parent[root];
|
|
86910
|
-
return root;
|
|
86911
|
-
};
|
|
86912
|
-
const union = (a, b) => {
|
|
86913
|
-
const ra = find(a);
|
|
86914
|
-
const rb = find(b);
|
|
86915
|
-
if (ra !== rb) parent[rb] = ra;
|
|
86916
|
-
};
|
|
86917
|
-
let measured = false;
|
|
86918
|
-
for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) {
|
|
86919
|
-
const d = normalisedDistance(members[i].position, members[j].position, frame);
|
|
86920
|
-
if (d === null) continue;
|
|
86921
|
-
measured = true;
|
|
86922
|
-
if (d <= maxFraction) union(i, j);
|
|
86923
|
-
}
|
|
86924
|
-
if (!measured && members.length > 1) return [members.map((m) => m.trackId)];
|
|
86925
|
-
const byRoot = /* @__PURE__ */ new Map();
|
|
86926
|
-
for (let i = 0; i < members.length; i++) {
|
|
86927
|
-
const root = find(i);
|
|
86928
|
-
const bucket = byRoot.get(root);
|
|
86929
|
-
const trackId = members[i].trackId;
|
|
86930
|
-
if (bucket === void 0) byRoot.set(root, [trackId]);
|
|
86931
|
-
else bucket.push(trackId);
|
|
86932
|
-
}
|
|
86933
|
-
return [...byRoot.values()];
|
|
86934
|
-
}
|
|
86935
|
-
/**
|
|
86936
|
-
* The departures, or `null` when the group is still whole.
|
|
86937
|
-
*
|
|
86938
|
-
* Order is the input's: the clusters come out in the order their first member
|
|
86939
|
-
* appears, so a caller logging a split reads the same order twice running.
|
|
86940
|
-
*/
|
|
86941
|
-
function planSplit(subject, frame, maxFraction) {
|
|
86942
|
-
if (subject.placed.length < 2) return null;
|
|
86943
|
-
const clusters = clusterByProximity(subject.placed, frame, maxFraction);
|
|
86944
|
-
if (clusters.length < 2) return null;
|
|
86945
|
-
const keeper = clusters.find((c) => subject.primaryTrackId !== null && c.includes(subject.primaryTrackId)) ?? largest(clusters);
|
|
86946
|
-
return { departures: clusters.filter((c) => c !== keeper) };
|
|
86947
|
-
}
|
|
86948
|
-
/** The biggest cluster; ties go to the one that appeared first. */
|
|
86949
|
-
function largest(clusters) {
|
|
86950
|
-
let best = clusters[0];
|
|
86951
|
-
for (const c of clusters) if (c.length > best.length) best = c;
|
|
86952
|
-
return best;
|
|
86953
|
-
}
|
|
86954
|
-
function errMsg(err) {
|
|
86955
|
-
return err instanceof Error ? err.message : String(err);
|
|
86956
|
-
}
|
|
86957
|
-
var SummarySealer = class {
|
|
86958
|
-
deps;
|
|
86959
|
-
sweepInFlight = false;
|
|
86960
|
-
/**
|
|
86961
|
-
* What the FRAME path may ask at frame-rate: EVERY open group of a camera,
|
|
86962
|
-
* with the members it has seen THIS process open. Fed only by this class's
|
|
86963
|
-
* own writes, never the authority (D224): a fresh process answers an empty
|
|
86964
|
-
* list until a track opens, and a summary open across a restart is scored on
|
|
86965
|
-
* the members that open after it — a weaker shot, never a wrong one.
|
|
86966
|
-
*
|
|
86967
|
-
* Keyed by summary id INSIDE the camera, because a camera holds as many
|
|
86968
|
-
* groups as it has (D489). It was one entry per camera for one release, and
|
|
86969
|
-
* that silently threw away the group shot of every group but the last to
|
|
86970
|
-
* open — the frame path scored the newest group's members and no other.
|
|
86971
|
-
*/
|
|
86972
|
-
openMirror = /* @__PURE__ */ new Map();
|
|
86973
|
-
/**
|
|
86974
|
-
* When each open group was FIRST seen divided, by summary id. Cleared the
|
|
86975
|
-
* moment it is whole again, so a separation has to hold without interruption
|
|
86976
|
-
* — a group that oscillates never accumulates its window.
|
|
86977
|
-
*
|
|
86978
|
-
* RAM only, and deliberately: after a restart a group that is still apart
|
|
86979
|
-
* simply waits {@link SPLIT_CONFIRM_MS} again. Persisting it would buy one
|
|
86980
|
-
* earlier split and cost a column nothing else reads.
|
|
86981
|
-
*/
|
|
86982
|
-
apartSince = /* @__PURE__ */ new Map();
|
|
86983
|
-
constructor(deps) {
|
|
86984
|
-
this.deps = deps;
|
|
86985
|
-
}
|
|
86986
|
-
/** See {@link SUMMARY_GROUPS_ENABLED}; production never overrides it. */
|
|
86987
|
-
get groupsEnabled() {
|
|
86988
|
-
return this.deps.groupsEnabled ?? false;
|
|
86989
|
-
}
|
|
86990
|
-
/**
|
|
86991
|
-
* The camera's open groups, oldest entry first. Empty when none is mirrored —
|
|
86992
|
-
* which means "this process has opened none", not "the camera has none".
|
|
86993
|
-
*/
|
|
86994
|
-
openGroupsFor(deviceId) {
|
|
86995
|
-
const groups = this.openMirror.get(deviceId);
|
|
86996
|
-
if (groups === void 0) return [];
|
|
86997
|
-
return [...groups].map(([id, members]) => ({
|
|
86998
|
-
id,
|
|
86999
|
-
members
|
|
87000
|
-
}));
|
|
87001
|
-
}
|
|
87002
|
-
/**
|
|
87003
|
-
* Join the open group this subject is walking WITH, or open its own.
|
|
87004
|
-
*
|
|
87005
|
-
* Falls back to the store's own policy (the camera's oldest open group)
|
|
87006
|
-
* whenever the evidence is missing — no birth position, no frame size, no
|
|
87007
|
-
* member positions. That fallback is today's behaviour, so an unknowable
|
|
87008
|
-
* case degrades to what shipped rather than to nothing.
|
|
87009
|
-
*/
|
|
87010
|
-
async joinOrOpen(input, position) {
|
|
87011
|
-
const frame = this.deps.frameSize(input.deviceId);
|
|
87012
|
-
if (position === void 0 || frame === null) return await this.deps.summaries.openOrAbsorb(input);
|
|
87013
|
-
const open = await this.deps.summaries.listOpen(input.deviceId);
|
|
87014
|
-
if (open.length === 0) return await this.deps.summaries.openNew(input);
|
|
87015
|
-
const groups = [];
|
|
87016
|
-
for (const summary of open) {
|
|
87017
|
-
const members = await this.deps.summaries.membersOf(summary.id);
|
|
87018
|
-
const positions = await this.deps.latestPositions(members.map((m) => m.trackId));
|
|
87019
|
-
groups.push({
|
|
87020
|
-
id: summary.id,
|
|
87021
|
-
memberPositions: [...positions.values()]
|
|
87022
|
-
});
|
|
87023
|
-
}
|
|
87024
|
-
const chosenId = chooseGroupToJoin(position, groups, frame, this.deps.togetherFraction(input.deviceId));
|
|
87025
|
-
const chosen = open.find((s) => s.id === chosenId);
|
|
87026
|
-
if (chosen === void 0) {
|
|
87027
|
-
this.deps.logger.debug("summary: nobody to walk with — opening a group", {
|
|
87028
|
-
tags: { deviceId: input.deviceId },
|
|
87029
|
-
meta: {
|
|
87030
|
-
trackId: input.trackId,
|
|
87031
|
-
openGroups: open.length
|
|
87032
|
-
}
|
|
87033
|
-
});
|
|
87034
|
-
return await this.deps.summaries.openNew(input);
|
|
87035
|
-
}
|
|
87036
|
-
return await this.deps.summaries.absorbInto(chosen, input);
|
|
87037
|
-
}
|
|
87038
|
-
async onTrackOpened(input) {
|
|
87039
|
-
if (!this.groupsEnabled) return;
|
|
87040
|
-
try {
|
|
87041
|
-
if (!isSummaryEligible(input.trackId, input.className)) {
|
|
87042
|
-
this.deps.logger.debug("summary: track is not a group member — left standalone", {
|
|
87043
|
-
tags: { deviceId: input.deviceId },
|
|
87044
|
-
meta: {
|
|
87045
|
-
trackId: input.trackId,
|
|
87046
|
-
className: input.className ?? null
|
|
87047
|
-
}
|
|
87048
|
-
});
|
|
87049
|
-
return;
|
|
87050
|
-
}
|
|
87051
|
-
const at = this.deps.now();
|
|
87052
|
-
const open = {
|
|
87053
|
-
deviceId: input.deviceId,
|
|
87054
|
-
trackId: input.trackId,
|
|
87055
|
-
at,
|
|
87056
|
-
className: input.className
|
|
87057
|
-
};
|
|
87058
|
-
const row = await this.joinOrOpen(open, input.position);
|
|
87059
|
-
this.rememberMember(input.deviceId, row.id, input.trackId);
|
|
87060
|
-
} catch (err) {
|
|
87061
|
-
this.deps.logger.warn("summary: could not open or absorb for a track", {
|
|
87062
|
-
tags: { deviceId: input.deviceId },
|
|
87063
|
-
meta: {
|
|
87064
|
-
trackId: input.trackId,
|
|
87065
|
-
error: errMsg(err)
|
|
87066
|
-
}
|
|
87067
|
-
});
|
|
87068
|
-
}
|
|
87069
|
-
}
|
|
87070
|
-
/** One sweep at a time (THE latch). A tick that finds one in flight returns. */
|
|
87071
|
-
async sweep() {
|
|
87072
|
-
if (this.sweepInFlight) return;
|
|
87073
|
-
this.sweepInFlight = true;
|
|
87074
|
-
try {
|
|
87075
|
-
const mine = this.deps.analysedDeviceIds();
|
|
87076
|
-
const open = [];
|
|
87077
|
-
for (const deviceId of mine) open.push(...await this.deps.summaries.listOpen(deviceId));
|
|
87078
|
-
for (const summary of open) {
|
|
87079
|
-
const born = await this.splitIfApart(summary);
|
|
87080
|
-
await this.sealIfDone(summary);
|
|
87081
|
-
if (born !== null) await this.sealIfDone(born);
|
|
87082
|
-
}
|
|
87083
|
-
} catch (err) {
|
|
87084
|
-
this.deps.logger.warn("summary sweep: could not list open summaries", { meta: { error: errMsg(err) } });
|
|
87085
|
-
} finally {
|
|
87086
|
-
this.sweepInFlight = false;
|
|
87087
|
-
}
|
|
87088
|
-
}
|
|
87089
|
-
/**
|
|
87090
|
-
* Split an open group whose members have walked apart and STAYED apart.
|
|
87091
|
-
*
|
|
87092
|
-
* Returns the last group born, so the sweep can consider it for sealing in
|
|
87093
|
-
* the same pass — a group that divided as everybody left would otherwise sit
|
|
87094
|
-
* open until the next tick with nothing alive in it.
|
|
87095
|
-
*
|
|
87096
|
-
* Every failure here is swallowed into a log: a split is an improvement to a
|
|
87097
|
-
* record that is already correct about WHO was present, and losing it is
|
|
87098
|
-
* never worth losing the seal that follows.
|
|
87099
|
-
*/
|
|
87100
|
-
async splitIfApart(summary) {
|
|
87101
|
-
const { id: summaryId, deviceId } = summary;
|
|
87102
|
-
try {
|
|
87103
|
-
const frame = this.deps.frameSize(deviceId);
|
|
87104
|
-
if (frame === null) {
|
|
87105
|
-
this.apartSince.delete(summaryId);
|
|
87106
|
-
return null;
|
|
87107
|
-
}
|
|
87108
|
-
const members = await this.deps.summaries.membersOf(summaryId);
|
|
87109
|
-
if (members.length < 2) {
|
|
87110
|
-
this.apartSince.delete(summaryId);
|
|
87111
|
-
return null;
|
|
87112
|
-
}
|
|
87113
|
-
const positions = await this.deps.latestPositions(members.map((m) => m.trackId));
|
|
87114
|
-
const placed = [];
|
|
87115
|
-
for (const m of members) {
|
|
87116
|
-
const position = positions.get(m.trackId);
|
|
87117
|
-
if (position !== void 0) placed.push({
|
|
87118
|
-
trackId: m.trackId,
|
|
87119
|
-
position
|
|
87120
|
-
});
|
|
87121
|
-
}
|
|
87122
|
-
const plan = planSplit({
|
|
87123
|
-
placed,
|
|
87124
|
-
primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null
|
|
87125
|
-
}, frame, this.deps.togetherFraction(deviceId));
|
|
87126
|
-
if (plan === null) {
|
|
87127
|
-
this.apartSince.delete(summaryId);
|
|
87128
|
-
return null;
|
|
87129
|
-
}
|
|
87130
|
-
const firstSeen = this.apartSince.get(summaryId);
|
|
87131
|
-
const now = this.deps.now();
|
|
87132
|
-
if (firstSeen === void 0) {
|
|
87133
|
-
this.apartSince.set(summaryId, now);
|
|
87134
|
-
return null;
|
|
87135
|
-
}
|
|
87136
|
-
if (now - firstSeen < 0) return null;
|
|
87137
|
-
let last = null;
|
|
87138
|
-
let row = summary;
|
|
87139
|
-
for (const departing of plan.departures) {
|
|
87140
|
-
const born = await this.deps.summaries.splitOff(row, departing, now);
|
|
87141
|
-
if (born === null) continue;
|
|
87142
|
-
this.moveMirrorMembers(deviceId, summaryId, born.id, departing);
|
|
87143
|
-
row = {
|
|
87144
|
-
...row,
|
|
87145
|
-
memberCount: row.memberCount - departing.length
|
|
87146
|
-
};
|
|
87147
|
-
last = born;
|
|
87148
|
-
}
|
|
87149
|
-
this.apartSince.delete(summaryId);
|
|
87150
|
-
return last;
|
|
87151
|
-
} catch (err) {
|
|
87152
|
-
this.deps.logger.warn("summary: could not divide a group that walked apart", {
|
|
87153
|
-
tags: { deviceId },
|
|
87154
|
-
meta: {
|
|
87155
|
-
summaryId,
|
|
87156
|
-
error: errMsg(err)
|
|
87157
|
-
}
|
|
87158
|
-
});
|
|
87159
|
-
return null;
|
|
87160
|
-
}
|
|
87161
|
-
}
|
|
87162
|
-
/**
|
|
87163
|
-
* Is this group finished?
|
|
87164
|
-
*
|
|
87165
|
-
* Both questions are about THIS group's members, not about the camera. A
|
|
87166
|
-
* camera holds as many groups as it has (D489), so a camera-wide gate means
|
|
87167
|
-
* no group seals until every subject on the camera has gone — and on a busy
|
|
87168
|
-
* camera they never all go at once. Measured: eight summaries open on one
|
|
87169
|
-
* camera over seventeen minutes, none sealed, each new subject opening a
|
|
87170
|
-
* ninth because a group whose members have closed has no positions left for
|
|
87171
|
-
* the proximity rule to judge.
|
|
87172
|
-
*
|
|
87173
|
-
* `null` means "could not find out", and the caller treats that as NOT done —
|
|
87174
|
-
* a read that failed must never seal a live session (D49).
|
|
87175
|
-
*/
|
|
87176
|
-
async isGroupFinished(summary, members) {
|
|
87177
|
-
const { id: summaryId, deviceId } = summary;
|
|
87178
|
-
let openTracks;
|
|
87179
|
-
try {
|
|
87180
|
-
openTracks = await this.deps.openTrackIds(deviceId);
|
|
87181
|
-
} catch (err) {
|
|
87182
|
-
this.deps.logger.warn("summary sweep: could not read open tracks — seal aborted this sweep", {
|
|
87183
|
-
tags: { deviceId },
|
|
87184
|
-
meta: {
|
|
87185
|
-
summaryId,
|
|
87186
|
-
error: errMsg(err)
|
|
87187
|
-
}
|
|
87188
|
-
});
|
|
87189
|
-
return null;
|
|
87190
|
-
}
|
|
87191
|
-
const stillOpen = new Set(openTracks);
|
|
87192
|
-
if (members.some((m) => stillOpen.has(m))) return false;
|
|
87193
|
-
let parked;
|
|
87194
|
-
try {
|
|
87195
|
-
parked = await this.deps.parkedTrackIds(deviceId);
|
|
87196
|
-
} catch (err) {
|
|
87197
|
-
this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
|
|
87198
|
-
tags: { deviceId },
|
|
87199
|
-
meta: {
|
|
87200
|
-
summaryId,
|
|
87201
|
-
error: errMsg(err)
|
|
87202
|
-
}
|
|
87203
|
-
});
|
|
87204
|
-
return null;
|
|
87205
|
-
}
|
|
87206
|
-
return !members.some((m) => parked.has(m));
|
|
87207
|
-
}
|
|
87208
|
-
async sealIfDone(summary) {
|
|
87209
|
-
const { id: summaryId, deviceId } = summary;
|
|
87210
|
-
let members;
|
|
87211
|
-
try {
|
|
87212
|
-
members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
|
|
87213
|
-
} catch (err) {
|
|
87214
|
-
this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
|
|
87215
|
-
tags: { deviceId },
|
|
87216
|
-
meta: {
|
|
87217
|
-
summaryId,
|
|
87218
|
-
error: errMsg(err)
|
|
87219
|
-
}
|
|
87220
|
-
});
|
|
87221
|
-
return;
|
|
87222
|
-
}
|
|
87223
|
-
if (await this.isGroupFinished(summary, members) !== true) return;
|
|
87224
|
-
const sealedAt = this.deps.now();
|
|
87225
|
-
try {
|
|
87226
|
-
if (await this.deps.summaries.seal(summary, sealedAt)) {
|
|
87227
|
-
const members = this.forgetGroup(deviceId, summaryId);
|
|
87228
|
-
this.deps.onSealed?.(summaryId, members);
|
|
87229
|
-
this.deps.logger.info("summary sealed", {
|
|
87230
|
-
tags: { deviceId },
|
|
87231
|
-
meta: {
|
|
87232
|
-
summaryId,
|
|
87233
|
-
sealedAt
|
|
87234
|
-
}
|
|
87235
|
-
});
|
|
87236
|
-
}
|
|
87237
|
-
} catch (err) {
|
|
87238
|
-
this.deps.logger.warn("summary sweep: seal failed — the row stays open for the next sweep", {
|
|
87239
|
-
tags: { deviceId },
|
|
87240
|
-
meta: {
|
|
87241
|
-
summaryId,
|
|
87242
|
-
error: errMsg(err)
|
|
87243
|
-
}
|
|
87244
|
-
});
|
|
87245
|
-
}
|
|
87246
|
-
}
|
|
87247
|
-
rememberMember(deviceId, summaryIdValue, trackId) {
|
|
87248
|
-
let groups = this.openMirror.get(deviceId);
|
|
87249
|
-
if (groups === void 0) {
|
|
87250
|
-
groups = /* @__PURE__ */ new Map();
|
|
87251
|
-
this.openMirror.set(deviceId, groups);
|
|
87252
|
-
}
|
|
87253
|
-
const members = groups.get(summaryIdValue);
|
|
87254
|
-
if (members === void 0) groups.set(summaryIdValue, new Set([trackId]));
|
|
87255
|
-
else members.add(trackId);
|
|
87256
|
-
}
|
|
87257
|
-
/** Follows a split in the mirror, so the frame path scores each half on its
|
|
87258
|
-
* own members from the very next frame. */
|
|
87259
|
-
moveMirrorMembers(deviceId, fromId, toId, trackIds) {
|
|
87260
|
-
const groups = this.openMirror.get(deviceId);
|
|
87261
|
-
const from = groups?.get(fromId);
|
|
87262
|
-
for (const trackId of trackIds) {
|
|
87263
|
-
from?.delete(trackId);
|
|
87264
|
-
this.rememberMember(deviceId, toId, trackId);
|
|
87265
|
-
}
|
|
87266
|
-
if (from !== void 0 && from.size === 0) groups?.delete(fromId);
|
|
87267
|
-
}
|
|
87268
|
-
/** Drops one group from the mirror and returns the members it held. */
|
|
87269
|
-
forgetGroup(deviceId, summaryIdValue) {
|
|
87270
|
-
this.apartSince.delete(summaryIdValue);
|
|
87271
|
-
const groups = this.openMirror.get(deviceId);
|
|
87272
|
-
const members = groups?.get(summaryIdValue) ?? /* @__PURE__ */ new Set();
|
|
87273
|
-
groups?.delete(summaryIdValue);
|
|
87274
|
-
if (groups !== void 0 && groups.size === 0) this.openMirror.delete(deviceId);
|
|
87275
|
-
return members;
|
|
87276
|
-
}
|
|
87277
|
-
};
|
|
87278
|
-
//#endregion
|
|
87279
|
-
//#region src/pipeline-analytics/summary-settings.ts
|
|
87280
|
-
/**
|
|
87281
|
-
* Per-device summary (group) settings. Cascade: a per-device override on top of
|
|
87282
|
-
* the global default, resolved per field — an invalid or missing value falls
|
|
87283
|
-
* back to its default and parsing never throws. Mirrors `stationary-settings` /
|
|
87284
|
-
* `media-settings`.
|
|
87285
|
-
*
|
|
87286
|
-
* The default IS the constant the proximity rule shipped with, imported and not
|
|
87287
|
-
* copied, so an unset value and a reset value both resolve to exactly today's
|
|
87288
|
-
* behaviour. It is per camera because a threshold belongs to whoever generates
|
|
87289
|
-
* the event: a gate camera framing three metres of path and a drive camera
|
|
87290
|
-
* framing twenty disagree about what "walking together" looks like in a
|
|
87291
|
-
* fraction of the frame, and the fleet measurement behind the default is a
|
|
87292
|
-
* median across both kinds.
|
|
87293
|
-
*/
|
|
87294
|
-
var SummarySettingsSchema = require_clip_model_registry.object({
|
|
87295
|
-
/**
|
|
87296
|
-
* How close two subjects must be to count as one group, as a fraction of the
|
|
87297
|
-
* frame. The floor is not zero: zero means "only at the identical pixel",
|
|
87298
|
-
* which ends grouping on that camera while looking like a tuning choice.
|
|
87299
|
-
* See `summary/summary-proximity.ts` for where the default came from.
|
|
87300
|
-
*/
|
|
87301
|
-
summaryTogetherFraction: require_clip_model_registry.number().min(.02).max(1).default(DEFAULT_TOGETHER_FRACTION) });
|
|
87302
|
-
var SUMMARY_DEFAULTS = { togetherFraction: SummarySettingsSchema.parse({}).summaryTogetherFraction };
|
|
87303
|
-
function resolveSummarySettings(raw) {
|
|
87304
|
-
const parsed = SummarySettingsSchema.shape.summaryTogetherFraction.safeParse(raw["summaryTogetherFraction"]);
|
|
87305
|
-
return { togetherFraction: parsed.success ? parsed.data : SUMMARY_DEFAULTS.togetherFraction };
|
|
87306
|
-
}
|
|
87307
|
-
//#endregion
|
|
87308
85342
|
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
87309
85343
|
/**
|
|
87310
85344
|
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
@@ -88310,8 +86344,6 @@ var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
|
88310
86344
|
* the others) — a longer drain would be killed mid-way and log nothing.
|
|
88311
86345
|
*/
|
|
88312
86346
|
var SENSOR_HANDLER_DRAIN_MS = 2700;
|
|
88313
|
-
/** Throttle for the group-shot gate's window line (D470). */
|
|
88314
|
-
var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
|
|
88315
86347
|
/**
|
|
88316
86348
|
* How often the addon asks "is a gallery sample outside the cluster face
|
|
88317
86349
|
* space?". A flip is rare and the check is two cheap reads — a minute keeps
|
|
@@ -88589,21 +86621,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
88589
86621
|
storageMigrationLeaseId = null;
|
|
88590
86622
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
88591
86623
|
sensorEventStore = null;
|
|
88592
|
-
/** The per-camera session envelope (D359). Bound with the other stores at boot. */
|
|
88593
|
-
summaryStore = null;
|
|
88594
|
-
/** The ONLY writer of a summary's lifecycle (D359). */
|
|
88595
|
-
summarySealer = null;
|
|
88596
|
-
/** The materialisation gate on every open summary's group shot (D359 phase 3, D470). RAM only. */
|
|
88597
|
-
groupShots = new GroupShotTracker();
|
|
88598
|
-
/** Throttle for the group-shot gate's window line (D470). */
|
|
88599
|
-
lastGroupShotMetricAt = 0;
|
|
88600
|
-
/**
|
|
88601
|
-
* Tracks a gallery match has named, for the group-shot score's recognition
|
|
88602
|
-
* term. "Recognised so far" rather than "at that instant": the match arrives
|
|
88603
|
-
* on the detail plane, after the frame; the frames that follow carry it.
|
|
88604
|
-
* Pruned when the track's summary seals, so it is bounded by open summaries.
|
|
88605
|
-
*/
|
|
88606
|
-
recognizedTrackIds = /* @__PURE__ */ new Set();
|
|
88607
86624
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
88608
86625
|
* so the DeviceStateChanged handler stays cheap. */
|
|
88609
86626
|
linkedCamerasCache = null;
|
|
@@ -89125,11 +87142,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
89125
87142
|
* the partition + promotion + sweep call sites — an operator change takes
|
|
89126
87143
|
* effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
|
|
89127
87144
|
stationarySettingsCache = this.settingsCaches.registerDeviceCache("stationary", SETTINGS_CACHE_TTL_MS);
|
|
89128
|
-
/** Per-device summary (group) settings — the proximity threshold. Read
|
|
89129
|
-
* SYNCHRONOUSLY by the sealer's birth and split paths, so it is warmed on
|
|
89130
|
-
* the frame path like the stationary one and a cold miss answers the
|
|
89131
|
-
* measured default. */
|
|
89132
|
-
summarySettingsCache = this.settingsCaches.registerDeviceCache("summary", SETTINGS_CACHE_TTL_MS);
|
|
89133
87145
|
/** Per-device STILLNESS SCALE cache — the measured "how slow is settled" bar
|
|
89134
87146
|
* (`stillness-settings.ts`). Read once per FrameProcessor build, so this is
|
|
89135
87147
|
* a cold-path cache; it exists so a calibration applied through
|
|
@@ -89259,9 +87271,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
89259
87271
|
* line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
|
|
89260
87272
|
mediaCaptureLog = new MediaCaptureLogAggregator();
|
|
89261
87273
|
/** Per-device hit/miss window for the native `keyFrame` path (2026-08-06).
|
|
89262
|
-
* Before it there was NO metric here at all —
|
|
89263
|
-
*
|
|
89264
|
-
*
|
|
87274
|
+
* Before it there was NO metric here at all — the by-handle key-frame fetch
|
|
87275
|
+
* in `runtime/native-frame-transport.ts` (retired by D422) never touched
|
|
87276
|
+
* `bumpCropMetric` — so whether a key-frame fix worked could only be
|
|
87277
|
+
* answered by hand-counting Loki lines. See `key-frame-metrics.ts`. */
|
|
89265
87278
|
keyFrameCaptureLog = new KeyFrameCaptureLog();
|
|
89266
87279
|
/** The registry's own liveness answer, mirrored off `ClusterTopologySnapshot`,
|
|
89267
87280
|
* so a capture is never dispatched at a node the hub already called dead.
|
|
@@ -89395,12 +87408,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
89395
87408
|
});
|
|
89396
87409
|
}
|
|
89397
87410
|
captureCrop = null;
|
|
89398
|
-
/** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
|
|
89399
|
-
* to the owning runner, PLUS the source `tier`. Captured in the async init
|
|
89400
|
-
* block so `persistKeyFrames` can fetch the native full frame for the
|
|
89401
|
-
* `keyFrame` + `keyFrameSmall` writes in the same live-frame window AND reject
|
|
89402
|
-
* a ≤640 RAM fallback (`tier: 'ram-fullframe'`). Null until init completes. */
|
|
89403
|
-
getNativeKeyFrameRgb = null;
|
|
89404
87411
|
shuttingDown = false;
|
|
89405
87412
|
/**
|
|
89406
87413
|
* Sensor handlers the bus dispatched under `void` that have not finished.
|
|
@@ -89900,7 +87907,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
89900
87907
|
logger,
|
|
89901
87908
|
noteCropOutcome: (deviceId, reason) => this.failureReport.noteEnrichmentCrop(deviceId, reason)
|
|
89902
87909
|
});
|
|
89903
|
-
this.getNativeKeyFrameRgb = transport.getNativeKeyFrameRgb;
|
|
89904
87910
|
this.parkedFrames = createParkedFrameClient({
|
|
89905
87911
|
api: api.pipelineRunner,
|
|
89906
87912
|
logger: logger.child("ParkedFrames"),
|
|
@@ -90237,7 +88243,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
90237
88243
|
MediaStore.declare(api.settingsStore),
|
|
90238
88244
|
EventStore.declare(api.settingsStore),
|
|
90239
88245
|
SensorEventStore.declare(api.settingsStore),
|
|
90240
|
-
SummaryStore.declare(api.settingsStore),
|
|
90241
88246
|
IdentityStore.declare(api.settingsStore),
|
|
90242
88247
|
FaceStore.declare(api.settingsStore),
|
|
90243
88248
|
PlateStore.declare(api.settingsStore),
|
|
@@ -90545,49 +88550,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
90545
88550
|
store: api.settingsStore,
|
|
90546
88551
|
logger: logger.child("SensorEventStore")
|
|
90547
88552
|
});
|
|
90548
|
-
this.summaryStore = new SummaryStore({
|
|
90549
|
-
trackIdsMatching: async (terms) => await this.trackStore?.trackIdsMatching(terms) ?? [],
|
|
90550
|
-
store: api.settingsStore,
|
|
90551
|
-
logger: this.ctx.logger.child("summary"),
|
|
90552
|
-
onEvict: async (ids) => {
|
|
90553
|
-
await this.mediaStore?.deleteForOwner("summary", ids);
|
|
90554
|
-
}
|
|
90555
|
-
});
|
|
90556
|
-
this.summarySealer = new SummarySealer({
|
|
90557
|
-
summaries: this.summaryStore,
|
|
90558
|
-
analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
|
|
90559
|
-
openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
|
|
90560
|
-
parkedTrackIds: async (deviceId) => {
|
|
90561
|
-
const ids = /* @__PURE__ */ new Set();
|
|
90562
|
-
for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
|
|
90563
|
-
return ids;
|
|
90564
|
-
},
|
|
90565
|
-
now: () => Date.now(),
|
|
90566
|
-
logger: this.ctx.logger.child("summary-sealer"),
|
|
90567
|
-
frameSize: (deviceId) => {
|
|
90568
|
-
const d = this.lastFrameDimsByDevice.get(deviceId);
|
|
90569
|
-
return d === void 0 ? null : {
|
|
90570
|
-
width: d.w,
|
|
90571
|
-
height: d.h
|
|
90572
|
-
};
|
|
90573
|
-
},
|
|
90574
|
-
latestPositions: async (trackIds) => {
|
|
90575
|
-
const out = /* @__PURE__ */ new Map();
|
|
90576
|
-
for (const id of trackIds) {
|
|
90577
|
-
const last = (this.trackStore?.peekActive(id))?.positions.at(-1);
|
|
90578
|
-
if (last !== void 0) out.set(id, {
|
|
90579
|
-
x: last.x,
|
|
90580
|
-
y: last.y
|
|
90581
|
-
});
|
|
90582
|
-
}
|
|
90583
|
-
return out;
|
|
90584
|
-
},
|
|
90585
|
-
togetherFraction: (deviceId) => this.summarySettingsFromCache(deviceId).togetherFraction,
|
|
90586
|
-
onSealed: (summaryId, members) => {
|
|
90587
|
-
this.groupShots.forget(summaryId);
|
|
90588
|
-
for (const trackId of members) this.recognizedTrackIds.delete(trackId);
|
|
90589
|
-
}
|
|
90590
|
-
});
|
|
90591
88553
|
this.packageDropDetector = new PackageDropDetector({
|
|
90592
88554
|
events: eventStore,
|
|
90593
88555
|
emit: {
|
|
@@ -90787,7 +88749,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
90787
88749
|
score: face.matchScore ?? face.confidence,
|
|
90788
88750
|
confidence: face.confidence,
|
|
90789
88751
|
...face.frameHandle !== void 0 ? { frameHandle: face.frameHandle } : {}
|
|
90790
|
-
}
|
|
88752
|
+
}),
|
|
90791
88753
|
onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
|
|
90792
88754
|
faceSourceFrames: new FaceSourceFrameKeeper({
|
|
90793
88755
|
port: createFaceSourceParkPort({
|
|
@@ -91315,30 +89277,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
91315
89277
|
confirmed: t.observations !== void 0 && t.observations >= minHits
|
|
91316
89278
|
}));
|
|
91317
89279
|
},
|
|
91318
|
-
listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
|
|
91319
|
-
const summaryStore = this.summaryStore;
|
|
91320
|
-
if (!summaryStore) return [];
|
|
91321
|
-
const rows = await summaryStore.listRecent({
|
|
91322
|
-
deviceIds: [deviceId],
|
|
91323
|
-
since: sinceMs,
|
|
91324
|
-
until: untilMs,
|
|
91325
|
-
limit: 500
|
|
91326
|
-
});
|
|
91327
|
-
const out = [];
|
|
91328
|
-
for (const row of rows) {
|
|
91329
|
-
const members = await summaryStore.membersOf(row.id);
|
|
91330
|
-
const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
|
|
91331
|
-
const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
|
|
91332
|
-
out.push({
|
|
91333
|
-
summaryId: row.id,
|
|
91334
|
-
deviceId: row.deviceId,
|
|
91335
|
-
memberTrackIds: members.map((m) => m.trackId),
|
|
91336
|
-
primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
|
|
91337
|
-
groupShotMediaId: shot?.mediaId ?? null
|
|
91338
|
-
});
|
|
91339
|
-
}
|
|
91340
|
-
return out;
|
|
91341
|
-
},
|
|
91342
89280
|
listMediaInfo: async (id) => {
|
|
91343
89281
|
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
91344
89282
|
mediaId: r.mediaId,
|
|
@@ -92762,7 +90700,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
92762
90700
|
if (planeRunsStationary(source) && this.stationaryRegistry) {
|
|
92763
90701
|
await this.resolveDeviceStationarySettings(deviceId);
|
|
92764
90702
|
await this.resolveDevicePackageDropSettings(deviceId);
|
|
92765
|
-
await this.resolveDeviceSummarySettings(deviceId);
|
|
92766
90703
|
}
|
|
92767
90704
|
const processor = await this.getOrCreateProcessor(deviceId, source);
|
|
92768
90705
|
const proxy = await this.ensureProxy(deviceId);
|
|
@@ -93138,15 +91075,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
93138
91075
|
className: t.className
|
|
93139
91076
|
}
|
|
93140
91077
|
});
|
|
93141
|
-
this.summarySealer?.onTrackOpened({
|
|
93142
|
-
deviceId,
|
|
93143
|
-
trackId: id,
|
|
93144
|
-
className: t.className,
|
|
93145
|
-
position: {
|
|
93146
|
-
x: t.bbox.x + t.bbox.w / 2,
|
|
93147
|
-
y: t.bbox.y + t.bbox.h / 2
|
|
93148
|
-
}
|
|
93149
|
-
});
|
|
93150
91078
|
const startPayload = buildTrackLifecyclePayload({
|
|
93151
91079
|
deviceId,
|
|
93152
91080
|
trackId: id,
|
|
@@ -93382,7 +91310,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
93382
91310
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
93383
91311
|
else plateCrops += 1;
|
|
93384
91312
|
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight, frameHandle);
|
|
93385
|
-
this.offerGroupShot(deviceId, result, frameHandle);
|
|
93386
91313
|
for (const retry of this.collectFirstFrameRetries(deviceId, result.tracked, result.timestamp, firstFrameTargets)) firstFrameTargets.push(retry);
|
|
93387
91314
|
const targetedThisFrame = new Set([...firstFrameTargets.map((t) => t.trackId), ...snapshotTargets.map((s) => s.trackId)]);
|
|
93388
91315
|
const rasterFallbackCandidates = [];
|
|
@@ -94098,15 +92025,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
94098
92025
|
stationarySettingsFromCache(deviceId) {
|
|
94099
92026
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
94100
92027
|
}
|
|
94101
|
-
/** Synchronous read of the per-device summary settings — warmed by
|
|
94102
|
-
* `processFrame`. A cold miss is today's constant, never a zero: a zero
|
|
94103
|
-
* threshold would quietly end grouping on that camera. */
|
|
94104
|
-
summarySettingsFromCache(deviceId) {
|
|
94105
|
-
return this.summarySettingsCache.peek(deviceId) ?? SUMMARY_DEFAULTS;
|
|
94106
|
-
}
|
|
94107
|
-
async resolveDeviceSummarySettings(deviceId) {
|
|
94108
|
-
return this.summarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveSummarySettings));
|
|
94109
|
-
}
|
|
94110
92028
|
/** Synchronous read of the per-device package-drop settings — warmed by
|
|
94111
92029
|
* `processFrame` before the promotion loop, exactly like the stationary one. */
|
|
94112
92030
|
packageDropSettingsFromCache(deviceId) {
|
|
@@ -94190,12 +92108,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
94190
92108
|
} } : {}
|
|
94191
92109
|
});
|
|
94192
92110
|
}
|
|
94193
|
-
if (detailSemanticTrigger(d.className) === "recognition") this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame)
|
|
92111
|
+
if (detailSemanticTrigger(d.className) === "recognition") this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame));
|
|
94194
92112
|
}
|
|
94195
92113
|
const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
|
|
94196
92114
|
if (resolved !== null && resolved.text !== void 0) {
|
|
94197
92115
|
const applied = await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
|
|
94198
|
-
if (detailSemanticTrigger(d.className) === "label-write" && applied.trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame)
|
|
92116
|
+
if (detailSemanticTrigger(d.className) === "label-write" && applied.trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame));
|
|
94199
92117
|
}
|
|
94200
92118
|
} else if (d.className === "plate") this.failureReport.notePlateRead(deviceId, d.bbox === void 0 ? REASON_NO_BBOX : REASON_EMPTY_READ);
|
|
94201
92119
|
} catch (err) {
|
|
@@ -94388,139 +92306,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
94388
92306
|
});
|
|
94389
92307
|
}
|
|
94390
92308
|
/**
|
|
94391
|
-
* Capture the CLEAN best full frame per given track at this best-detection
|
|
94392
|
-
* moment and store BOTH variants (`putReplacing` → one row per track, per
|
|
94393
|
-
* kind): the native `keyFrame` (full frame fetched at `KEYFRAME_NATIVE_MAX_WIDTH`
|
|
94394
|
-
* — true native on a 4K cam) plus its 960 companion `keyFrameSmall`, encoded
|
|
94395
|
-
* from the SAME native surface (no second fetch). No box is drawn — these are
|
|
94396
|
-
* the clean hero images (`keyFrameSmall` is the web-friendly one).
|
|
94397
|
-
*
|
|
94398
|
-
* NATIVE-OR-NOTHING: a miss writes nothing (no upscale, no ≤640 stand-in) and
|
|
94399
|
-
* the track's BOUNDED catch-up retry re-fires on a later frame. Until
|
|
94400
|
-
* 2026-08-06 the only re-fire was the next genuine new-best PEAK, which for
|
|
94401
|
-
* 80% of the tracks that missed never came — see `key-frame-retry.ts`. The
|
|
94402
|
-
* `keyFrame` key is recorded on the track's resident state so the face /
|
|
94403
|
-
* plate / object-embedding rows LINK the SAME native key frame (Design B).
|
|
94404
|
-
* Issued in the live-frame window so the native lease is still held.
|
|
94405
|
-
* Best-effort (D8) — a per-track failure is logged and never thrown.
|
|
94406
|
-
*
|
|
94407
|
-
* The capture BODY lives in `pipeline/key-frame-persist.ts`; what stays here
|
|
94408
|
-
* is the scheduling (coalescing, priority, budget) and the bookkeeping the
|
|
94409
|
-
* retry gate reads.
|
|
94410
|
-
*/
|
|
94411
|
-
/**
|
|
94412
|
-
* Offer one processed frame to the camera's OPEN summary (D359 phase 3).
|
|
94413
|
-
* Metadata only, at frame-rate: the frame is scored against the members the
|
|
94414
|
-
* sealer has mirrored, and the pixels are fetched — by handle, at the owning
|
|
94415
|
-
* node, as a native key frame — only when this frame beats the best so far.
|
|
94416
|
-
*/
|
|
94417
|
-
offerGroupShot(deviceId, result, frameHandle) {
|
|
94418
|
-
if (frameHandle === void 0) return;
|
|
94419
|
-
const groups = this.summarySealer?.openGroupsFor(deviceId) ?? [];
|
|
94420
|
-
for (const open of groups) this.offerGroupShotTo(deviceId, open, result, frameHandle);
|
|
94421
|
-
if (groups.length > 0) this.logGroupShotWindow(deviceId);
|
|
94422
|
-
}
|
|
94423
|
-
/** One group's turn at the frame. Split out of {@link offerGroupShot} so the
|
|
94424
|
-
* scoring reads the members of the group it is about, and nothing else. */
|
|
94425
|
-
offerGroupShotTo(deviceId, open, result, frameHandle) {
|
|
94426
|
-
const score = scoreSummaryFrame({
|
|
94427
|
-
members: open.members,
|
|
94428
|
-
frameWidth: result.frameWidth,
|
|
94429
|
-
frameHeight: result.frameHeight,
|
|
94430
|
-
detections: result.tracked.map((t) => ({
|
|
94431
|
-
trackId: t.trackId,
|
|
94432
|
-
bbox: {
|
|
94433
|
-
x: t.bbox.x,
|
|
94434
|
-
y: t.bbox.y,
|
|
94435
|
-
width: t.bbox.w,
|
|
94436
|
-
height: t.bbox.h
|
|
94437
|
-
},
|
|
94438
|
-
confidence: t.confidence,
|
|
94439
|
-
recognized: this.recognizedTrackIds.has(t.trackId)
|
|
94440
|
-
}))
|
|
94441
|
-
});
|
|
94442
|
-
const frameRef = { timestamp: result.timestamp };
|
|
94443
|
-
if (!this.groupShots.offer(open.id, score, frameRef)) return;
|
|
94444
|
-
const getNative = this.getNativeKeyFrameRgb;
|
|
94445
|
-
const mediaStore = this.mediaStore;
|
|
94446
|
-
if (!getNative || !mediaStore) return;
|
|
94447
|
-
const summaryId = open.id;
|
|
94448
|
-
this.captureScheduler.request({
|
|
94449
|
-
deviceId,
|
|
94450
|
-
kind: "keyFrame",
|
|
94451
|
-
coalesceKey: `summary:${summaryId}:keyFrame`,
|
|
94452
|
-
exec: () => persistSummaryGroupShot({
|
|
94453
|
-
fetchNativeFullFrame: getNative,
|
|
94454
|
-
encodeVariants: encodeKeyFrameVariants,
|
|
94455
|
-
media: mediaStore,
|
|
94456
|
-
logger: this.ctx.logger,
|
|
94457
|
-
cropFrame: async (frame, rect) => {
|
|
94458
|
-
const out = await extractCropRaw(frame.data, frame.width, frame.height, {
|
|
94459
|
-
x: rect.x / frame.width,
|
|
94460
|
-
y: rect.y / frame.height,
|
|
94461
|
-
w: rect.width / frame.width,
|
|
94462
|
-
h: rect.height / frame.height
|
|
94463
|
-
});
|
|
94464
|
-
return {
|
|
94465
|
-
...frame,
|
|
94466
|
-
data: out.data,
|
|
94467
|
-
width: out.width,
|
|
94468
|
-
height: out.height
|
|
94469
|
-
};
|
|
94470
|
-
}
|
|
94471
|
-
}, {
|
|
94472
|
-
deviceId,
|
|
94473
|
-
summaryId,
|
|
94474
|
-
timestamp: result.timestamp,
|
|
94475
|
-
frameHandle,
|
|
94476
|
-
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH,
|
|
94477
|
-
crop: score.crop,
|
|
94478
|
-
cropFrameWidth: result.frameWidth,
|
|
94479
|
-
cropFrameHeight: result.frameHeight
|
|
94480
|
-
})
|
|
94481
|
-
}).then((outcome) => {
|
|
94482
|
-
if (outcome?.kind === "landed") {
|
|
94483
|
-
this.groupShots.noteLanded(summaryId, score, frameRef);
|
|
94484
|
-
this.ctx.logger.debug("summary group shot landed", {
|
|
94485
|
-
tags: { deviceId },
|
|
94486
|
-
meta: {
|
|
94487
|
-
summaryId,
|
|
94488
|
-
visible: score.visible,
|
|
94489
|
-
quality: Number(score.quality.toFixed(3))
|
|
94490
|
-
}
|
|
94491
|
-
});
|
|
94492
|
-
}
|
|
94493
|
-
}).catch(() => {});
|
|
94494
|
-
}
|
|
94495
|
-
/**
|
|
94496
|
-
* The group-shot gate's window, throttled to one line per
|
|
94497
|
-
* {@link GROUP_SHOT_METRIC_INTERVAL_MS}. Process-global counts, tagged with
|
|
94498
|
-
* the device that happened to close the window — the counters are the
|
|
94499
|
-
* gate's, not the camera's, and the tag is there because every line about a
|
|
94500
|
-
* device carries one.
|
|
94501
|
-
*
|
|
94502
|
-
* `materialised` is the number of 4096-px native full-frame fetches this
|
|
94503
|
-
* path actually spent; `byGap` + `byMargin` + `byWorse` is what it did not.
|
|
94504
|
-
*/
|
|
94505
|
-
logGroupShotWindow(deviceId) {
|
|
94506
|
-
const now = Date.now();
|
|
94507
|
-
if (now - this.lastGroupShotMetricAt < GROUP_SHOT_METRIC_INTERVAL_MS) return;
|
|
94508
|
-
this.lastGroupShotMetricAt = now;
|
|
94509
|
-
const counts = this.groupShots.drainCounts();
|
|
94510
|
-
if (counts.offered === 0) return;
|
|
94511
|
-
this.ctx.logger.info("group shot window", {
|
|
94512
|
-
tags: { deviceId },
|
|
94513
|
-
meta: {
|
|
94514
|
-
offered: counts.offered,
|
|
94515
|
-
materialised: counts.materialised,
|
|
94516
|
-
suppressedByGap: counts.byGap,
|
|
94517
|
-
suppressedByMargin: counts.byMargin,
|
|
94518
|
-
suppressedByWorse: counts.byWorse,
|
|
94519
|
-
openSummaries: this.groupShots.size
|
|
94520
|
-
}
|
|
94521
|
-
});
|
|
94522
|
-
}
|
|
94523
|
-
/**
|
|
94524
92309
|
* Order the parcels a best-frame decision wants (D422). Skipped entirely at
|
|
94525
92310
|
* a node the registry already called offline (D58) — logged on the
|
|
94526
92311
|
* transition by `noteCapturesGated`, counted in the key-frame window.
|
|
@@ -94557,6 +92342,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
94557
92342
|
timestamp: parcel.timestamp
|
|
94558
92343
|
};
|
|
94559
92344
|
}
|
|
92345
|
+
/**
|
|
92346
|
+
* Store each given track's clean native `keyFrame` + `keyFrameSmall` from its
|
|
92347
|
+
* PARKED parcel (D422) — never a by-handle fetch. The body is
|
|
92348
|
+
* `pipeline/key-frame-persist.ts`; what stays here is the scheduling
|
|
92349
|
+
* (coalescing, priority, budget) and the bookkeeping the retry gate reads.
|
|
92350
|
+
* Best-effort (D8): a per-track failure is logged and never thrown.
|
|
92351
|
+
*/
|
|
94560
92352
|
async persistKeyFrames(deviceId, timestamp, captures) {
|
|
94561
92353
|
const mediaStore = this.mediaStore;
|
|
94562
92354
|
if (this.parkedFrames === null || !mediaStore) return;
|
|
@@ -94724,8 +92516,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
94724
92516
|
* when it wins and the frame's pixels are still reachable. Callers: the
|
|
94725
92517
|
* FaceRecognizer's per-frame auto-match, and `routeDetailResults`' plate /
|
|
94726
92518
|
* fine-classification attribution. */
|
|
94727
|
-
promoteSemanticRecognition(input
|
|
94728
|
-
if (origin === "identified") this.recognizedTrackIds.add(input.trackId);
|
|
92519
|
+
promoteSemanticRecognition(input) {
|
|
94729
92520
|
const ctx = this.ctxIfReady;
|
|
94730
92521
|
if (ctx === null) return;
|
|
94731
92522
|
promoteSemanticBestFrame({
|
|
@@ -95622,7 +93413,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
95622
93413
|
async sweepExpiredTracks() {
|
|
95623
93414
|
if (this.shuttingDown || !this.trackStore) return;
|
|
95624
93415
|
await this.trackCloser.sweep();
|
|
95625
|
-
await this.summarySealer?.sweep();
|
|
95626
93416
|
}
|
|
95627
93417
|
/**
|
|
95628
93418
|
* The follow-recordings floor per camera, read from the ARCHIVE's hour ledger
|
|
@@ -95709,7 +93499,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
95709
93499
|
await this.runTrackRetentionSweep(deviceIds, now);
|
|
95710
93500
|
const eventStore = this.eventStore;
|
|
95711
93501
|
const sensorEventStore = this.sensorEventStore;
|
|
95712
|
-
const summaryStore = this.summaryStore;
|
|
95713
93502
|
await sweepTracklessRoots({
|
|
95714
93503
|
deviceIds,
|
|
95715
93504
|
nowMs: now,
|
|
@@ -95719,16 +93508,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
95719
93508
|
cutoffMs
|
|
95720
93509
|
}),
|
|
95721
93510
|
evictSensorEvents: sensorEventStore ? (deviceId, cutoffMs) => sensorEventStore.evictBefore(deviceId, cutoffMs) : null,
|
|
95722
|
-
evictSummaries: summaryStore ? (deviceId, cutoffMs) => summaryStore.evictBefore(deviceId, cutoffMs) : null,
|
|
95723
93511
|
onEvicted: async (deviceId, evicted, cutoffs) => {
|
|
95724
|
-
const total = evicted.motion.length + evicted.audio.length + evicted.sensor
|
|
93512
|
+
const total = evicted.motion.length + evicted.audio.length + evicted.sensor;
|
|
95725
93513
|
await this.eventsOpsLog?.append({
|
|
95726
93514
|
op: "prune",
|
|
95727
93515
|
reason: "retention",
|
|
95728
93516
|
deviceId,
|
|
95729
93517
|
itemsAffected: total,
|
|
95730
93518
|
bytesReclaimed: 0,
|
|
95731
|
-
detail: `trackless age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.audio.length} audio, ${evicted.sensor} sensor
|
|
93519
|
+
detail: `trackless age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.audio.length} audio, ${evicted.sensor} sensor`
|
|
95732
93520
|
});
|
|
95733
93521
|
this.ctx.logger.info("analytics trackless event eviction (age sweep)", {
|
|
95734
93522
|
tags: { deviceId },
|
|
@@ -95736,7 +93524,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
95736
93524
|
motion: evicted.motion.length,
|
|
95737
93525
|
audio: evicted.audio.length,
|
|
95738
93526
|
sensor: evicted.sensor,
|
|
95739
|
-
summaries: evicted.summaries,
|
|
95740
93527
|
mode: cutoffs.effectiveMode,
|
|
95741
93528
|
cutoffMs: cutoffs.cutoffMs
|
|
95742
93529
|
}
|
|
@@ -96690,118 +94477,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
96690
94477
|
async listRecentTracks(input) {
|
|
96691
94478
|
return this.queryFacade.listRecentTracks(input);
|
|
96692
94479
|
}
|
|
96693
|
-
/** Newest first for a camera set; each row carries its group-shot media key. */
|
|
96694
|
-
async listSummaries(input) {
|
|
96695
|
-
const store = this.summaryStore;
|
|
96696
|
-
if (!store) return { summaries: [] };
|
|
96697
|
-
const rows = await store.listRecent({
|
|
96698
|
-
deviceIds: input.deviceIds,
|
|
96699
|
-
...input.since !== void 0 ? { since: input.since } : {},
|
|
96700
|
-
...input.until !== void 0 ? { until: input.until } : {},
|
|
96701
|
-
limit: input.limit ?? 50,
|
|
96702
|
-
...input.classes !== void 0 ? { classes: input.classes } : {}
|
|
96703
|
-
});
|
|
96704
|
-
return { summaries: await this.listItemsFor(rows) };
|
|
96705
|
-
}
|
|
96706
|
-
/**
|
|
96707
|
-
* Rows → what a LIST draws: the envelope plus the composition.
|
|
96708
|
-
*
|
|
96709
|
-
* Two bulk reads for the whole page, never per row: the members in one, their
|
|
96710
|
-
* tracks (for the names) in one. The MEMBERS themselves stay off the list —
|
|
96711
|
-
* a page of two hundred groups would otherwise carry a thousand records so
|
|
96712
|
-
* the client could count them. `getSummary` is where the links live.
|
|
96713
|
-
*/
|
|
96714
|
-
async listItemsFor(rows) {
|
|
96715
|
-
const store = this.summaryStore;
|
|
96716
|
-
if (!store || rows.length === 0) return [];
|
|
96717
|
-
const members = await store.membersOfMany(rows.map((r) => r.id));
|
|
96718
|
-
return await Promise.all(rows.map(async (row) => ({
|
|
96719
|
-
summary: await this.toSummary(row),
|
|
96720
|
-
...summaryComposition(members.get(row.id) ?? [])
|
|
96721
|
-
})));
|
|
96722
|
-
}
|
|
96723
|
-
/** The members of a page of summaries, with the labels a card can show —
|
|
96724
|
-
* in ONE read of the members and ONE of their tracks. */
|
|
96725
|
-
async labelledMembersFor(rows) {
|
|
96726
|
-
const out = /* @__PURE__ */ new Map();
|
|
96727
|
-
const store = this.summaryStore;
|
|
96728
|
-
if (!store || rows.length === 0) return out;
|
|
96729
|
-
const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
|
|
96730
|
-
const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
|
|
96731
|
-
let tracks = [];
|
|
96732
|
-
try {
|
|
96733
|
-
tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
|
|
96734
|
-
} catch (err) {
|
|
96735
|
-
this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
|
|
96736
|
-
summaries: rows.length,
|
|
96737
|
-
members: trackIds.length,
|
|
96738
|
-
error: require_clip_model_registry.errMsg(err)
|
|
96739
|
-
} });
|
|
96740
|
-
}
|
|
96741
|
-
for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
|
|
96742
|
-
return out;
|
|
96743
|
-
}
|
|
96744
|
-
/**
|
|
96745
|
-
* Rows → the shape every summary surface answers with: the envelope, its
|
|
96746
|
-
* members, and what a thumbnail can render for each of them.
|
|
96747
|
-
*
|
|
96748
|
-
* THREE reads for the whole page, never per row: the members in one, their
|
|
96749
|
-
* tracks in one, and the group shots through `toSummary`. A read per member
|
|
96750
|
-
* is what made a sixty-group page take twenty seconds on the events feed.
|
|
96751
|
-
*/
|
|
96752
|
-
/** ONE summary, with every member and the link each of them carries. */
|
|
96753
|
-
async detailFor(row) {
|
|
96754
|
-
const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
|
|
96755
|
-
return {
|
|
96756
|
-
summary: await this.toSummary(row),
|
|
96757
|
-
members: labelled.map((m) => ({
|
|
96758
|
-
trackId: m.trackId,
|
|
96759
|
-
className: m.className,
|
|
96760
|
-
role: m.role,
|
|
96761
|
-
joinedAt: m.joinedAt,
|
|
96762
|
-
...m.label !== void 0 ? { label: m.label } : {},
|
|
96763
|
-
...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
|
|
96764
|
-
}))
|
|
96765
|
-
};
|
|
96766
|
-
}
|
|
96767
|
-
async listRecentSummaries(input) {
|
|
96768
|
-
const store = this.summaryStore;
|
|
96769
|
-
if (!store) return {
|
|
96770
|
-
summaries: [],
|
|
96771
|
-
nextCursor: null
|
|
96772
|
-
};
|
|
96773
|
-
const page = await store.queryRecent({
|
|
96774
|
-
deviceIds: input.deviceIds,
|
|
96775
|
-
...input.since !== void 0 ? { since: input.since } : {},
|
|
96776
|
-
...input.until !== void 0 ? { until: input.until } : {},
|
|
96777
|
-
limit: input.limit,
|
|
96778
|
-
...input.cursor !== void 0 ? { cursor: input.cursor } : {},
|
|
96779
|
-
...input.classes !== void 0 ? { classes: input.classes } : {}
|
|
96780
|
-
});
|
|
96781
|
-
return {
|
|
96782
|
-
summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
|
|
96783
|
-
nextCursor: page.nextCursor
|
|
96784
|
-
};
|
|
96785
|
-
}
|
|
96786
|
-
async getSummary(input) {
|
|
96787
|
-
const detail = await this.summaryStore?.get(input.id);
|
|
96788
|
-
if (!detail) return null;
|
|
96789
|
-
return await this.detailFor(detail.summary);
|
|
96790
|
-
}
|
|
96791
|
-
/** The row plus its group shot: the web companion when it landed, else the native key frame. */
|
|
96792
|
-
async toSummary(row) {
|
|
96793
|
-
const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
|
|
96794
|
-
const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
|
|
96795
|
-
return {
|
|
96796
|
-
id: row.id,
|
|
96797
|
-
deviceId: row.deviceId,
|
|
96798
|
-
openedAt: row.openedAt,
|
|
96799
|
-
lastActiveAt: row.lastActiveAt,
|
|
96800
|
-
sealedAt: row.sealedAt,
|
|
96801
|
-
memberCount: row.memberCount,
|
|
96802
|
-
groupShotMediaId: shot?.mediaId ?? null
|
|
96803
|
-
};
|
|
96804
|
-
}
|
|
96805
94480
|
/**
|
|
96806
94481
|
* Every event kind the device can produce: built-ins (motion + audio), the
|
|
96807
94482
|
* detection classes its CONFIGURED pipeline can emit, and sensor kinds from
|
|
@@ -97585,11 +95260,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
97585
95260
|
},
|
|
97586
95261
|
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
97587
95262
|
liveEventIds: (table, ids) => eventStore.existingEventIdsIn(table, ids),
|
|
97588
|
-
liveSummaryIds: (ids) => {
|
|
97589
|
-
const summaries = this.summaryStore;
|
|
97590
|
-
if (!summaries) throw new Error("summary store not built — probe unavailable");
|
|
97591
|
-
return summaries.existingIds(ids);
|
|
97592
|
-
},
|
|
97593
95263
|
keptMediaKeys: async (keys) => this.keptStationaryMediaKeys(keys),
|
|
97594
95264
|
reclaim: buildOrphanReclaimer({
|
|
97595
95265
|
deleteFace: async (faceId) => {
|
|
@@ -99278,8 +96948,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
99278
96948
|
mediaStore,
|
|
99279
96949
|
faceStore: this.faceStore,
|
|
99280
96950
|
plateStore: this.plateStore,
|
|
99281
|
-
objectEmbeddingStore: this.objectEmbeddingStore
|
|
99282
|
-
summaryStore: this.summaryStore
|
|
96951
|
+
objectEmbeddingStore: this.objectEmbeddingStore
|
|
99283
96952
|
};
|
|
99284
96953
|
const absent = absentCascadeLegs(stores);
|
|
99285
96954
|
if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
|