@camstack/addon-post-analysis 1.2.130 → 1.2.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-CDHL-0gR.js → dist-BB-Ol201.js} +577 -59
- package/dist/{dist-CBCs0-eP.mjs → dist-XrPlCmxK.mjs} +577 -59
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CMk3S5nA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-_ZUR9O1b.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Doc16Tif.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D09rnimL.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-B6sMoPF2.mjs → hostInit-BrwrIFi_.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +812 -581
- package/dist/pipeline-analytics/index.mjs +812 -581
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-
|
|
1
|
+
import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-XrPlCmxK.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -5406,6 +5406,12 @@ function buildAlarmButtons(input) {
|
|
|
5406
5406
|
}];
|
|
5407
5407
|
}
|
|
5408
5408
|
//#endregion
|
|
5409
|
+
//#region src/notification-center/cap-caller.ts
|
|
5410
|
+
function requireCapCaller(method, caller) {
|
|
5411
|
+
if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
|
|
5412
|
+
return caller;
|
|
5413
|
+
}
|
|
5414
|
+
//#endregion
|
|
5409
5415
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
5410
5416
|
/**
|
|
5411
5417
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -15352,33 +15358,95 @@ function remainingSummaryCandidates(kept, taken, rank) {
|
|
|
15352
15358
|
/**
|
|
15353
15359
|
* The media a tile is drawn from, best first.
|
|
15354
15360
|
*
|
|
15355
|
-
*
|
|
15356
|
-
*
|
|
15357
|
-
*
|
|
15358
|
-
*
|
|
15359
|
-
*
|
|
15360
|
-
*
|
|
15361
|
-
*
|
|
15362
|
-
*
|
|
15361
|
+
* A digest tile is a SUBJECT, so the crops come first: the group's first-level
|
|
15362
|
+
* crop (`groupCrop`), the group's native-frame pointer, then the TRACK's own
|
|
15363
|
+
* subject crop (`thumbnail`, then its `thumbnailSmall` downscale), and only
|
|
15364
|
+
* then the clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
|
|
15365
|
+
*
|
|
15366
|
+
* ⚠ The whole-scene rungs are a FALLBACK and stay on the ladder. Measured on
|
|
15367
|
+
* 60 real tracks (615/617, 24 h): 52 carry a crop, 43 carry a scene rung, and
|
|
15368
|
+
* 2 carry a scene rung and NO crop — dropping the fallback would delete those
|
|
15369
|
+
* two tiles for nothing. A track with neither is dropped, never drawn as an
|
|
15370
|
+
* empty cell (see `NcSummaryProducer.loadTiles`).
|
|
15371
|
+
*
|
|
15372
|
+
* ## Why `thumbnail` is back (2026-08-27)
|
|
15373
|
+
*
|
|
15374
|
+
* It was removed on 2026-08-24 (160200681) because a successful plate read
|
|
15375
|
+
* overwrites the vehicle first-level crop under the same `thumbnail` kind.
|
|
15376
|
+
* **That collision belongs to the GROUP entity**: the sealer is what writes
|
|
15377
|
+
* `ownerKind: 'group', kind: 'thumbnail'` (`group/group-sealer.ts`), and the
|
|
15378
|
+
* group process is OFF (`GROUPS_PROCESS_ENABLED`, `group/group-enabled.ts`).
|
|
15379
|
+
* On a TRACK, plates live under `ownerKind: 'plate'` / `kind: 'plateCrop'` and
|
|
15380
|
+
* never touch `thumbnail`. The same commit turned the group process off, so
|
|
15381
|
+
* `groupCrop` and `nativeFrame` became unreachable and every digest tile fell
|
|
15382
|
+
* through to `keyFrame` — a whole frame — for three days.
|
|
15383
|
+
* `scripts/check-summary-ladder-reachable.ts` is the guard that refuses that
|
|
15384
|
+
* shape now.
|
|
15385
|
+
*
|
|
15386
|
+
* Boxed frames stay absent: a drawn rectangle is a permanent lie in a digest
|
|
15387
|
+
* tile. Plate and face crops stay absent: a digest tile of a targa is the
|
|
15388
|
+
* wrong picture. Duplicate driveways from tracker ID churn are collapsed by
|
|
15389
|
+
* `frameDedupeKey`, not by hiding the whole-scene fallback.
|
|
15363
15390
|
*/
|
|
15364
15391
|
var SUMMARY_MEDIA_LADDER = [
|
|
15365
15392
|
"groupCrop",
|
|
15366
15393
|
"nativeFrame",
|
|
15394
|
+
"thumbnail",
|
|
15395
|
+
"thumbnailSmall",
|
|
15367
15396
|
"keyFrame",
|
|
15368
15397
|
"keyFrameSmall",
|
|
15369
15398
|
"fullFrame"
|
|
15370
15399
|
];
|
|
15371
15400
|
/**
|
|
15401
|
+
* The floor below which a `thumbnail` row is NOT a subject crop.
|
|
15402
|
+
*
|
|
15403
|
+
* `copyFaceCropIfTrackHasNoThumbnail` (`pipeline-analytics/copy-face-crop-preview.ts`)
|
|
15404
|
+
* copies the persisted face crop onto a track that has no preview of its own,
|
|
15405
|
+
* and it stores it under `kind: 'thumbnail'` — so an ArcFace **112×112**
|
|
15406
|
+
* alignment template lands on the ladder wearing the same kind as a real
|
|
15407
|
+
* subject crop. Measured: 3 of 52 track thumbnails (5.8%) are 3–10 KB
|
|
15408
|
+
* templates. Delivering one is a face in a digest of passages.
|
|
15409
|
+
*
|
|
15410
|
+
* 20 KiB is chosen as the smallest number that is unambiguous in BOTH
|
|
15411
|
+
* directions: it is 2× the largest template observed (10 KB) and above the
|
|
15412
|
+
* theoretical ceiling of a 112×112 JPEG at any quality, while a native-
|
|
15413
|
+
* resolution subject crop measures in the hundreds of KB. The second reason
|
|
15414
|
+
* the number is safe: a genuine crop small enough to fall under it is a
|
|
15415
|
+
* subject a few dozen pixels across, which is a smear in a mosaic cell — the
|
|
15416
|
+
* whole-scene rung below is the better picture either way.
|
|
15417
|
+
*
|
|
15418
|
+
* The check costs NOTHING: `MediaFileInfo.sizeBytes` is already on the index
|
|
15419
|
+
* row, so no blob is fetched and nothing is decoded to apply it.
|
|
15420
|
+
*/
|
|
15421
|
+
var SUMMARY_THUMBNAIL_MIN_BYTES = 20480;
|
|
15422
|
+
/** The rungs {@link SUMMARY_THUMBNAIL_MIN_BYTES} is applied to. */
|
|
15423
|
+
var SUMMARY_SIZE_GATED_RUNGS = new Set(["thumbnail"]);
|
|
15424
|
+
/**
|
|
15425
|
+
* Is this row a picture, or a template wearing a picture's kind?
|
|
15426
|
+
*
|
|
15427
|
+
* Only a size the index REPORTED can demote a row. A `null` size is unknown,
|
|
15428
|
+
* and demoting on unknown would silently delete every group-owned pointer.
|
|
15429
|
+
*/
|
|
15430
|
+
function rowClearsSizeFloor(row, rung) {
|
|
15431
|
+
if (!SUMMARY_SIZE_GATED_RUNGS.has(rung)) return true;
|
|
15432
|
+
if (typeof row.sizeBytes !== "number") return true;
|
|
15433
|
+
return row.sizeBytes >= SUMMARY_THUMBNAIL_MIN_BYTES;
|
|
15434
|
+
}
|
|
15435
|
+
/**
|
|
15372
15436
|
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
15373
15437
|
* so exactly one blob is ever read.
|
|
15374
15438
|
*
|
|
15375
15439
|
* Within a rung the row CLOSEST to the track's peak is chosen; a member may
|
|
15376
15440
|
* carry more than one full-frame still, so "the first one" would systematically
|
|
15377
15441
|
* pick the moment the subject entered rather than the moment it was best seen.
|
|
15442
|
+
*
|
|
15443
|
+
* A `thumbnail` under {@link SUMMARY_THUMBNAIL_MIN_BYTES} DESCENDS to the next
|
|
15444
|
+
* rung rather than being drawn — see that constant for the face template it
|
|
15445
|
+
* exists to catch.
|
|
15378
15446
|
*/
|
|
15379
15447
|
function pickSummaryMedia(rows, aroundMs) {
|
|
15380
15448
|
for (const rung of SUMMARY_MEDIA_LADDER) {
|
|
15381
|
-
const matches = rows.filter((row) => row.kind === rung);
|
|
15449
|
+
const matches = rows.filter((row) => row.kind === rung && rowClearsSizeFloor(row, rung));
|
|
15382
15450
|
if (matches.length === 0) continue;
|
|
15383
15451
|
return matches.reduce((best, row) => Math.abs(row.timestamp - aroundMs) < Math.abs(best.timestamp - aroundMs) ? row : best);
|
|
15384
15452
|
}
|
|
@@ -15690,12 +15758,13 @@ var NcSummaryProducer = class {
|
|
|
15690
15758
|
droppedStationary: filtered.droppedStationary
|
|
15691
15759
|
};
|
|
15692
15760
|
}
|
|
15693
|
-
const
|
|
15761
|
+
const tileLoad = await this.loadTiles({
|
|
15694
15762
|
rule,
|
|
15695
15763
|
preferred: selection.tiles,
|
|
15696
15764
|
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
15697
15765
|
maxTiles: rule.maxTiles
|
|
15698
15766
|
});
|
|
15767
|
+
const loaded = tileLoad.tiles;
|
|
15699
15768
|
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
15700
15769
|
firstSeen: t.firstSeen,
|
|
15701
15770
|
lastSeen: t.lastSeen,
|
|
@@ -15753,7 +15822,8 @@ var NcSummaryProducer = class {
|
|
|
15753
15822
|
tiles: loaded.length,
|
|
15754
15823
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
15755
15824
|
camerasDropped: selection.camerasDropped,
|
|
15756
|
-
droppedNoMedia:
|
|
15825
|
+
droppedNoMedia: tileLoad.droppedNoMedia,
|
|
15826
|
+
droppedDuplicateFrame: tileLoad.droppedDuplicateFrame,
|
|
15757
15827
|
mosaicBytes: mosaic.bytes,
|
|
15758
15828
|
mosaicPublished: mosaic.ref !== null,
|
|
15759
15829
|
enqueued,
|
|
@@ -15819,14 +15889,20 @@ var NcSummaryProducer = class {
|
|
|
15819
15889
|
}
|
|
15820
15890
|
/** One blob per tile — the cheap question (index rows) always first.
|
|
15821
15891
|
* Duplicate camera-seconds are skipped so tracker ID churn cannot paint
|
|
15822
|
-
* the same driveway eleven times; leftover ranked candidates refill.
|
|
15892
|
+
* the same driveway eleven times; leftover ranked candidates refill.
|
|
15893
|
+
*
|
|
15894
|
+
* It COUNTS what it drops, per reason, because only this loop knows: the
|
|
15895
|
+
* caller sees tiles and has no way to tell a track that had no picture from
|
|
15896
|
+
* a rule whose quota was simply larger than the night. */
|
|
15823
15897
|
async loadTiles(input) {
|
|
15824
|
-
const
|
|
15898
|
+
const tiles = [];
|
|
15825
15899
|
const seenFrames = /* @__PURE__ */ new Set();
|
|
15826
15900
|
const seenIds = /* @__PURE__ */ new Set();
|
|
15901
|
+
let droppedNoMedia = 0;
|
|
15902
|
+
let droppedDuplicateFrame = 0;
|
|
15827
15903
|
const queue = [...input.preferred, ...input.refill];
|
|
15828
15904
|
for (const candidate of queue) {
|
|
15829
|
-
if (
|
|
15905
|
+
if (tiles.length >= input.maxTiles) break;
|
|
15830
15906
|
if (seenIds.has(candidate.trackId)) continue;
|
|
15831
15907
|
seenIds.add(candidate.trackId);
|
|
15832
15908
|
const tags = { deviceId: candidate.deviceId };
|
|
@@ -15834,19 +15910,22 @@ var NcSummaryProducer = class {
|
|
|
15834
15910
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
15835
15911
|
const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
|
|
15836
15912
|
if (picked === null) {
|
|
15837
|
-
|
|
15913
|
+
droppedNoMedia += 1;
|
|
15914
|
+
this.deps.logger.info("summary tile dropped: the track carries no clean media", {
|
|
15838
15915
|
tags,
|
|
15839
15916
|
meta: {
|
|
15840
15917
|
ruleId: input.rule.id,
|
|
15841
15918
|
trackId: candidate.trackId,
|
|
15842
|
-
mediaRows: rows.length
|
|
15919
|
+
mediaRows: rows.length,
|
|
15920
|
+
mediaKinds: [...new Set(rows.map((row) => row.kind))].sort()
|
|
15843
15921
|
}
|
|
15844
15922
|
});
|
|
15845
15923
|
continue;
|
|
15846
15924
|
}
|
|
15847
15925
|
const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
|
|
15848
15926
|
if (seenFrames.has(frameKey)) {
|
|
15849
|
-
|
|
15927
|
+
droppedDuplicateFrame += 1;
|
|
15928
|
+
this.deps.logger.info("summary tile dropped: same frame as an earlier tile", {
|
|
15850
15929
|
tags,
|
|
15851
15930
|
meta: {
|
|
15852
15931
|
ruleId: input.rule.id,
|
|
@@ -15858,22 +15937,25 @@ var NcSummaryProducer = class {
|
|
|
15858
15937
|
}
|
|
15859
15938
|
const jpeg = await this.deps.readMedia(picked.key);
|
|
15860
15939
|
if (jpeg === null || jpeg.byteLength === 0) {
|
|
15861
|
-
|
|
15940
|
+
droppedNoMedia += 1;
|
|
15941
|
+
this.deps.logger.info("summary tile dropped: the media blob could not be read", {
|
|
15862
15942
|
tags,
|
|
15863
15943
|
meta: {
|
|
15864
15944
|
ruleId: input.rule.id,
|
|
15865
15945
|
trackId: candidate.trackId,
|
|
15866
|
-
mediaKey: picked.key
|
|
15946
|
+
mediaKey: picked.key,
|
|
15947
|
+
mediaKind: picked.kind
|
|
15867
15948
|
}
|
|
15868
15949
|
});
|
|
15869
15950
|
continue;
|
|
15870
15951
|
}
|
|
15871
15952
|
seenFrames.add(frameKey);
|
|
15872
|
-
|
|
15953
|
+
tiles.push({
|
|
15873
15954
|
candidate,
|
|
15874
15955
|
jpeg
|
|
15875
15956
|
});
|
|
15876
15957
|
} catch (err) {
|
|
15958
|
+
droppedNoMedia += 1;
|
|
15877
15959
|
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
15878
15960
|
tags,
|
|
15879
15961
|
meta: {
|
|
@@ -15884,7 +15966,11 @@ var NcSummaryProducer = class {
|
|
|
15884
15966
|
});
|
|
15885
15967
|
}
|
|
15886
15968
|
}
|
|
15887
|
-
return
|
|
15969
|
+
return {
|
|
15970
|
+
tiles,
|
|
15971
|
+
droppedNoMedia,
|
|
15972
|
+
droppedDuplicateFrame
|
|
15973
|
+
};
|
|
15888
15974
|
}
|
|
15889
15975
|
/**
|
|
15890
15976
|
* Render the mosaic, file it where event media lives, and mint the public
|
|
@@ -21186,10 +21272,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21186
21272
|
});
|
|
21187
21273
|
return { success: true };
|
|
21188
21274
|
},
|
|
21189
|
-
listSnoozes: async ({ caller }) =>
|
|
21190
|
-
|
|
21275
|
+
listSnoozes: async ({ caller }) => {
|
|
21276
|
+
const who = requireCapCaller("listSnoozes", caller);
|
|
21277
|
+
return { snoozes: [...this.snoozes.listVisibleTo(who.userId, who.isAdmin)] };
|
|
21278
|
+
},
|
|
21279
|
+
createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, requireCapCaller("createSnooze", caller)) }),
|
|
21191
21280
|
cancelSnooze: async ({ snoozeId, caller }) => {
|
|
21192
|
-
await this.cancelSnooze(snoozeId, caller);
|
|
21281
|
+
await this.cancelSnooze(snoozeId, requireCapCaller("cancelSnooze", caller));
|
|
21193
21282
|
return { success: true };
|
|
21194
21283
|
},
|
|
21195
21284
|
getAlarmConfig: async () => this.alarmConfig(),
|
|
@@ -26814,6 +26903,639 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
26814
26903
|
};
|
|
26815
26904
|
}
|
|
26816
26905
|
//#endregion
|
|
26906
|
+
//#region src/pipeline-analytics/store/face-sort.ts
|
|
26907
|
+
var DEFAULT_FACE_SORT = {
|
|
26908
|
+
by: "timestamp",
|
|
26909
|
+
direction: "desc"
|
|
26910
|
+
};
|
|
26911
|
+
/** Resolve a caller's partial request into a complete order. */
|
|
26912
|
+
function resolveFaceSort(input) {
|
|
26913
|
+
return {
|
|
26914
|
+
by: input?.sortBy ?? DEFAULT_FACE_SORT.by,
|
|
26915
|
+
direction: input?.sortDirection ?? DEFAULT_FACE_SORT.direction
|
|
26916
|
+
};
|
|
26917
|
+
}
|
|
26918
|
+
/**
|
|
26919
|
+
* True when the store's `timestamp`-indexed paging already emits rows in the
|
|
26920
|
+
* requested order, so a paged read may STOP as soon as `limit` rows have passed
|
|
26921
|
+
* the filter.
|
|
26922
|
+
*
|
|
26923
|
+
* False for `suggestionConfidence`: the most certain suggestion may be the
|
|
26924
|
+
* oldest row in the window, so an early stop would silently answer with the
|
|
26925
|
+
* newest N ranked — the exact defect this order was added to remove.
|
|
26926
|
+
*/
|
|
26927
|
+
function sortIsServedByTimestampPaging(order) {
|
|
26928
|
+
return order.by === "timestamp";
|
|
26929
|
+
}
|
|
26930
|
+
/** Certainty, or `null` when the row has none. Null and undefined are the same
|
|
26931
|
+
* answer — the relational backend surfaces an unset REAL column as SQL NULL. */
|
|
26932
|
+
function certaintyOf(face) {
|
|
26933
|
+
return face.suggestedMatchScore ?? null;
|
|
26934
|
+
}
|
|
26935
|
+
/** Newest first, then `id` ascending. The tail of every comparison. */
|
|
26936
|
+
function compareTiebreak(a, b) {
|
|
26937
|
+
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
26938
|
+
if (a.id === b.id) return 0;
|
|
26939
|
+
return a.id < b.id ? -1 : 1;
|
|
26940
|
+
}
|
|
26941
|
+
function compareFaces(a, b, order) {
|
|
26942
|
+
if (order.by === "timestamp") {
|
|
26943
|
+
if (a.timestamp !== b.timestamp) return order.direction === "desc" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp;
|
|
26944
|
+
return compareTiebreak(a, b);
|
|
26945
|
+
}
|
|
26946
|
+
const aScore = certaintyOf(a);
|
|
26947
|
+
const bScore = certaintyOf(b);
|
|
26948
|
+
if (aScore === null && bScore !== null) return 1;
|
|
26949
|
+
if (aScore !== null && bScore === null) return -1;
|
|
26950
|
+
if (aScore !== null && bScore !== null && aScore !== bScore) return order.direction === "desc" ? bScore - aScore : aScore - bScore;
|
|
26951
|
+
return compareTiebreak(a, b);
|
|
26952
|
+
}
|
|
26953
|
+
/** Immutable sort — the input array is never reordered in place. */
|
|
26954
|
+
function sortFaces(faces, order) {
|
|
26955
|
+
return faces.toSorted((a, b) => compareFaces(a, b, order));
|
|
26956
|
+
}
|
|
26957
|
+
/**
|
|
26958
|
+
* Rank `faces` and keep at most `limit` of them.
|
|
26959
|
+
*
|
|
26960
|
+
* This is what lets the confidence order stay bounded: a paged sweep folds each
|
|
26961
|
+
* page in through here, so the retained set is the caller's `limit`, never the
|
|
26962
|
+
* corpus — even though the sweep itself has to walk the whole window.
|
|
26963
|
+
*/
|
|
26964
|
+
function rankAndCap(faces, order, limit) {
|
|
26965
|
+
const sorted = sortFaces(faces, order);
|
|
26966
|
+
return limit === void 0 ? sorted : sorted.slice(0, limit);
|
|
26967
|
+
}
|
|
26968
|
+
//#endregion
|
|
26969
|
+
//#region src/pipeline-analytics/store/face-store.ts
|
|
26970
|
+
/**
|
|
26971
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
26972
|
+
* write="one UPSERT per track that produced a face detail — the id is
|
|
26973
|
+
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
26974
|
+
* overwrites it as a better read lands (embedding, match cosine, crop keys).
|
|
26975
|
+
* The row is rewritten again when the operator enrols it, which flips
|
|
26976
|
+
* `assigned` and stamps the identity."
|
|
26977
|
+
* retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
|
|
26978
|
+
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
26979
|
+
* exempt from that and from every sweep — an assignment is a curation act.
|
|
26980
|
+
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
26981
|
+
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
|
|
26982
|
+
* of JSON embedding per row, that cap is what keeps the table small."
|
|
26983
|
+
*/
|
|
26984
|
+
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
26985
|
+
/**
|
|
26986
|
+
* Rows per page for the two whole-collection sweeps below.
|
|
26987
|
+
*
|
|
26988
|
+
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
26989
|
+
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
26990
|
+
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
26991
|
+
* collection in ONE statement with no `limit`.
|
|
26992
|
+
*
|
|
26993
|
+
* To be exact about what that did and did not cause: this table holds **372
|
|
26994
|
+
* rows / 4.0 MB** on the live hub, so it was *not* the read that killed
|
|
26995
|
+
* hub-main on 2026-08-07 (that was the multi-camera track feed — see
|
|
26996
|
+
* `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
|
|
26997
|
+
* grows with a table nothing else bounds, which is the same defect one incident
|
|
26998
|
+
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
26999
|
+
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
27000
|
+
* it were the same loop.
|
|
27001
|
+
*
|
|
27002
|
+
* The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
|
|
27003
|
+
* is not belt-and-braces: without it these two methods would silently see only
|
|
27004
|
+
* the newest N rows and the buffer would grow forever.
|
|
27005
|
+
*
|
|
27006
|
+
* 1 000 keeps peak transfer around 11 MB while staying under the engine's own
|
|
27007
|
+
* cap, so the page is always the caller's bound and never the engine's.
|
|
27008
|
+
*/
|
|
27009
|
+
var FACE_SWEEP_PAGE = 1e3;
|
|
27010
|
+
/**
|
|
27011
|
+
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
27012
|
+
* million faces — far past anything real, and reached only by a bug.
|
|
27013
|
+
*/
|
|
27014
|
+
var FACE_SWEEP_MAX_PAGES = 1e3;
|
|
27015
|
+
var FACE_COLUMNS = [
|
|
27016
|
+
{
|
|
27017
|
+
name: "id",
|
|
27018
|
+
type: "TEXT",
|
|
27019
|
+
primaryKey: true,
|
|
27020
|
+
notNull: true
|
|
27021
|
+
},
|
|
27022
|
+
{
|
|
27023
|
+
name: "deviceId",
|
|
27024
|
+
type: "INTEGER",
|
|
27025
|
+
notNull: true
|
|
27026
|
+
},
|
|
27027
|
+
{
|
|
27028
|
+
name: "trackId",
|
|
27029
|
+
type: "TEXT",
|
|
27030
|
+
notNull: true
|
|
27031
|
+
},
|
|
27032
|
+
{
|
|
27033
|
+
name: "timestamp",
|
|
27034
|
+
type: "INTEGER",
|
|
27035
|
+
notNull: true
|
|
27036
|
+
},
|
|
27037
|
+
{
|
|
27038
|
+
name: "embedding",
|
|
27039
|
+
type: "JSON",
|
|
27040
|
+
notNull: true
|
|
27041
|
+
},
|
|
27042
|
+
{
|
|
27043
|
+
name: "embeddingModelId",
|
|
27044
|
+
type: "TEXT"
|
|
27045
|
+
},
|
|
27046
|
+
{
|
|
27047
|
+
name: "mediaKey",
|
|
27048
|
+
type: "TEXT"
|
|
27049
|
+
},
|
|
27050
|
+
{
|
|
27051
|
+
name: "recognizedIdentityId",
|
|
27052
|
+
type: "TEXT"
|
|
27053
|
+
},
|
|
27054
|
+
{
|
|
27055
|
+
name: "assigned",
|
|
27056
|
+
type: "BOOLEAN",
|
|
27057
|
+
notNull: true
|
|
27058
|
+
},
|
|
27059
|
+
{
|
|
27060
|
+
name: "assignedSampleId",
|
|
27061
|
+
type: "TEXT"
|
|
27062
|
+
},
|
|
27063
|
+
{
|
|
27064
|
+
name: "keyFrameMediaKey",
|
|
27065
|
+
type: "TEXT"
|
|
27066
|
+
},
|
|
27067
|
+
{
|
|
27068
|
+
name: "faceBbox",
|
|
27069
|
+
type: "JSON"
|
|
27070
|
+
},
|
|
27071
|
+
{
|
|
27072
|
+
name: "bestMatchScore",
|
|
27073
|
+
type: "REAL"
|
|
27074
|
+
},
|
|
27075
|
+
{
|
|
27076
|
+
name: "nativeFaceShortSidePx",
|
|
27077
|
+
type: "REAL"
|
|
27078
|
+
},
|
|
27079
|
+
{
|
|
27080
|
+
name: "suggestedIdentityId",
|
|
27081
|
+
type: "TEXT"
|
|
27082
|
+
},
|
|
27083
|
+
{
|
|
27084
|
+
name: "suggestedMatchScore",
|
|
27085
|
+
type: "REAL"
|
|
27086
|
+
}
|
|
27087
|
+
];
|
|
27088
|
+
var FACE_INDEXES = [{
|
|
27089
|
+
name: "idx_faces_device_ts",
|
|
27090
|
+
columns: ["deviceId", "timestamp"]
|
|
27091
|
+
}, {
|
|
27092
|
+
name: "idx_faces_track",
|
|
27093
|
+
columns: ["trackId"]
|
|
27094
|
+
}];
|
|
27095
|
+
/**
|
|
27096
|
+
* `since` / `until` as a store predicate.
|
|
27097
|
+
*
|
|
27098
|
+
* The filter surface has exactly one range form, `whereBetween`, and it is
|
|
27099
|
+
* inclusive on both ends — so a one-sided window is expressed by pinning the
|
|
27100
|
+
* other end to the edge of the safe integer range rather than by omitting it.
|
|
27101
|
+
* Returns `{}` when the caller named no window, so the read stays exactly the
|
|
27102
|
+
* unwindowed one.
|
|
27103
|
+
*/
|
|
27104
|
+
function buildTimestampWindow(input) {
|
|
27105
|
+
if (input.since === void 0 && input.until === void 0) return {};
|
|
27106
|
+
return { whereBetween: { timestamp: [input.since ?? Number.MIN_SAFE_INTEGER, input.until ?? Number.MAX_SAFE_INTEGER] } };
|
|
27107
|
+
}
|
|
27108
|
+
/**
|
|
27109
|
+
* One stored row → one {@link Face}.
|
|
27110
|
+
*
|
|
27111
|
+
* The single documented type boundary of this store: `SettingsRecord.data` is
|
|
27112
|
+
* `Record<string, unknown>` on the wire and the column set is declared right
|
|
27113
|
+
* here in {@link FACE_COLUMNS}, so this is the one place allowed to name the
|
|
27114
|
+
* shape it wrote.
|
|
27115
|
+
*
|
|
27116
|
+
* It also owns the NULL normalisation, and that is not cosmetic: the relational
|
|
27117
|
+
* backend surfaces an unset optional column as SQL NULL, and `FaceInfo`'s
|
|
27118
|
+
* optional strings REJECT null — an un-normalised row fails
|
|
27119
|
+
* `faceGallery.listRecentFaces` output validation and the People page renders
|
|
27120
|
+
* empty. This used to be copied out three times, which is three chances to
|
|
27121
|
+
* forget the next additive column.
|
|
27122
|
+
*/
|
|
27123
|
+
function rowToFace(id, raw) {
|
|
27124
|
+
const data = raw;
|
|
27125
|
+
return {
|
|
27126
|
+
...data,
|
|
27127
|
+
id,
|
|
27128
|
+
assigned: Boolean(data.assigned),
|
|
27129
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
27130
|
+
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
27131
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
27132
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
27133
|
+
faceBbox: data.faceBbox ?? void 0,
|
|
27134
|
+
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
27135
|
+
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
27136
|
+
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
27137
|
+
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
27138
|
+
};
|
|
27139
|
+
}
|
|
27140
|
+
var FaceStore = class {
|
|
27141
|
+
store;
|
|
27142
|
+
logger;
|
|
27143
|
+
constructor(deps) {
|
|
27144
|
+
this.store = deps.store;
|
|
27145
|
+
this.logger = deps.logger;
|
|
27146
|
+
}
|
|
27147
|
+
static async declare(store) {
|
|
27148
|
+
await store.declareCollection.mutate({
|
|
27149
|
+
collection: FACES_COLLECTION,
|
|
27150
|
+
columns: [...FACE_COLUMNS],
|
|
27151
|
+
indexes: [...FACE_INDEXES]
|
|
27152
|
+
});
|
|
27153
|
+
}
|
|
27154
|
+
/**
|
|
27155
|
+
* Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
|
|
27156
|
+
* id per track → one entry per track). `set` replaces an existing row.
|
|
27157
|
+
*/
|
|
27158
|
+
async insert(face) {
|
|
27159
|
+
const { id, ...rest } = face;
|
|
27160
|
+
try {
|
|
27161
|
+
await this.store.set.mutate({
|
|
27162
|
+
collection: FACES_COLLECTION,
|
|
27163
|
+
key: id,
|
|
27164
|
+
value: rest
|
|
27165
|
+
});
|
|
27166
|
+
} catch (err) {
|
|
27167
|
+
this.logger.warn("FaceStore.insert failed", {
|
|
27168
|
+
tags: { deviceId: face.deviceId },
|
|
27169
|
+
meta: {
|
|
27170
|
+
faceId: id,
|
|
27171
|
+
error: String(err)
|
|
27172
|
+
}
|
|
27173
|
+
});
|
|
27174
|
+
}
|
|
27175
|
+
}
|
|
27176
|
+
/**
|
|
27177
|
+
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
27178
|
+
*
|
|
27179
|
+
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
27180
|
+
* SQLite backend (which returns INTEGER 0/1) is handled correctly.
|
|
27181
|
+
*/
|
|
27182
|
+
async listRecentFaces(input) {
|
|
27183
|
+
const where = {};
|
|
27184
|
+
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
27185
|
+
return (await this.store.query.query({
|
|
27186
|
+
collection: FACES_COLLECTION,
|
|
27187
|
+
filter: {
|
|
27188
|
+
where,
|
|
27189
|
+
orderBy: {
|
|
27190
|
+
field: "timestamp",
|
|
27191
|
+
direction: "desc"
|
|
27192
|
+
},
|
|
27193
|
+
limit: input.limit ?? 100
|
|
27194
|
+
}
|
|
27195
|
+
})).map((r) => rowToFace(r.id, r.data)).filter((f) => !f.assigned);
|
|
27196
|
+
}
|
|
27197
|
+
/**
|
|
27198
|
+
* The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
|
|
27199
|
+
* here is GONE (D61). It had no production caller — only tests — and it was
|
|
27200
|
+
* an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
|
|
27201
|
+
* retention model now forbids. A leftover that describes the wrong design
|
|
27202
|
+
* still reads as permission to use it.
|
|
27203
|
+
*/
|
|
27204
|
+
/**
|
|
27205
|
+
* Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
|
|
27206
|
+
*
|
|
27207
|
+
* **This is a CAPACITY bound, not an age one.** It used to be both: a face
|
|
27208
|
+
* was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
|
|
27209
|
+
* The age half was a fifth clock over a track-OWNED entity — a face row
|
|
27210
|
+
* carries a `trackId`, so it belongs to its track and cascades with it — and
|
|
27211
|
+
* it aged crops out from under tracks that were still well inside their own
|
|
27212
|
+
* retention window. It is gone (D61). What remains is the ring-buffer
|
|
27213
|
+
* property the recognizer actually depends on: the gallery of recent
|
|
27214
|
+
* unassigned faces stays small.
|
|
27215
|
+
*
|
|
27216
|
+
* ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
|
|
27217
|
+
* slots. Returns all deleted ids (the caller removes their crops).
|
|
27218
|
+
*/
|
|
27219
|
+
async pruneCapOverflow(input) {
|
|
27220
|
+
/**
|
|
27221
|
+
* Paged, not one statement. Only three scalars per row survive the page —
|
|
27222
|
+
* the `embedding` string that dominates the row is dropped as soon as the
|
|
27223
|
+
* page goes out of scope, so peak heap is one page rather than the whole
|
|
27224
|
+
* face table. The prune still considers EVERY row, which is what makes it a
|
|
27225
|
+
* capacity bound rather than a bound on the newest page.
|
|
27226
|
+
*/
|
|
27227
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
27228
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27229
|
+
const rows = await this.store.query.query({
|
|
27230
|
+
collection: FACES_COLLECTION,
|
|
27231
|
+
filter: {
|
|
27232
|
+
orderBy: {
|
|
27233
|
+
field: "timestamp",
|
|
27234
|
+
direction: "desc"
|
|
27235
|
+
},
|
|
27236
|
+
limit: FACE_SWEEP_PAGE,
|
|
27237
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27238
|
+
}
|
|
27239
|
+
});
|
|
27240
|
+
if (rows.length === 0) break;
|
|
27241
|
+
for (const r of rows) {
|
|
27242
|
+
const data = r.data;
|
|
27243
|
+
if (data.assigned) continue;
|
|
27244
|
+
const deviceId = Number(data.deviceId ?? -1);
|
|
27245
|
+
const list = byDevice.get(deviceId) ?? [];
|
|
27246
|
+
list.push({
|
|
27247
|
+
id: r.id,
|
|
27248
|
+
timestamp: Number(data.timestamp ?? 0)
|
|
27249
|
+
});
|
|
27250
|
+
byDevice.set(deviceId, list);
|
|
27251
|
+
}
|
|
27252
|
+
if (rows.length < 1e3) break;
|
|
27253
|
+
}
|
|
27254
|
+
const toDelete = /* @__PURE__ */ new Set();
|
|
27255
|
+
for (const list of byDevice.values()) {
|
|
27256
|
+
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
27257
|
+
list.forEach((row, idx) => {
|
|
27258
|
+
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
27259
|
+
});
|
|
27260
|
+
}
|
|
27261
|
+
const deleted = [];
|
|
27262
|
+
for (const id of toDelete) try {
|
|
27263
|
+
await this.store.delete.mutate({
|
|
27264
|
+
collection: FACES_COLLECTION,
|
|
27265
|
+
key: id
|
|
27266
|
+
});
|
|
27267
|
+
deleted.push(id);
|
|
27268
|
+
} catch (err) {
|
|
27269
|
+
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
27270
|
+
faceId: id,
|
|
27271
|
+
error: String(err)
|
|
27272
|
+
} });
|
|
27273
|
+
}
|
|
27274
|
+
return deleted;
|
|
27275
|
+
}
|
|
27276
|
+
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
27277
|
+
async markAssigned(faceId, identityId, sampleId) {
|
|
27278
|
+
const data = {
|
|
27279
|
+
assigned: true,
|
|
27280
|
+
recognizedIdentityId: identityId
|
|
27281
|
+
};
|
|
27282
|
+
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
27283
|
+
try {
|
|
27284
|
+
await this.store.update.mutate({
|
|
27285
|
+
collection: FACES_COLLECTION,
|
|
27286
|
+
id: faceId,
|
|
27287
|
+
data
|
|
27288
|
+
});
|
|
27289
|
+
} catch (err) {
|
|
27290
|
+
this.logger.warn("FaceStore.markAssigned failed", { meta: {
|
|
27291
|
+
faceId,
|
|
27292
|
+
error: String(err)
|
|
27293
|
+
} });
|
|
27294
|
+
}
|
|
27295
|
+
}
|
|
27296
|
+
/** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
|
|
27297
|
+
async clearAssignment(faceId) {
|
|
27298
|
+
try {
|
|
27299
|
+
await this.store.update.mutate({
|
|
27300
|
+
collection: FACES_COLLECTION,
|
|
27301
|
+
id: faceId,
|
|
27302
|
+
data: {
|
|
27303
|
+
assigned: false,
|
|
27304
|
+
recognizedIdentityId: null,
|
|
27305
|
+
assignedSampleId: null
|
|
27306
|
+
}
|
|
27307
|
+
});
|
|
27308
|
+
} catch (err) {
|
|
27309
|
+
this.logger.warn("FaceStore.clearAssignment failed", { meta: {
|
|
27310
|
+
faceId,
|
|
27311
|
+
error: String(err)
|
|
27312
|
+
} });
|
|
27313
|
+
}
|
|
27314
|
+
}
|
|
27315
|
+
/**
|
|
27316
|
+
* Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
|
|
27317
|
+
* write that explicitly-requested action makes. `suggestion: null` means
|
|
27318
|
+
* the recompute found no candidate worth offering and CLEARS both columns
|
|
27319
|
+
* (a genuine "the gallery has nothing for this face right now" result, not
|
|
27320
|
+
* a no-op left at whatever they held before). Never touches `assigned` /
|
|
27321
|
+
* `recognizedIdentityId` — a suggestion is never an assignment. The caller
|
|
27322
|
+
* is responsible for never calling this with a non-comparable (`model-
|
|
27323
|
+
* mismatch`) report; this method persists whatever it is given.
|
|
27324
|
+
*/
|
|
27325
|
+
async setSuggestion(faceId, suggestion) {
|
|
27326
|
+
const data = suggestion === null ? {
|
|
27327
|
+
suggestedIdentityId: null,
|
|
27328
|
+
suggestedMatchScore: null
|
|
27329
|
+
} : {
|
|
27330
|
+
suggestedIdentityId: suggestion.identityId,
|
|
27331
|
+
suggestedMatchScore: suggestion.score
|
|
27332
|
+
};
|
|
27333
|
+
try {
|
|
27334
|
+
await this.store.update.mutate({
|
|
27335
|
+
collection: FACES_COLLECTION,
|
|
27336
|
+
id: faceId,
|
|
27337
|
+
data
|
|
27338
|
+
});
|
|
27339
|
+
} catch (err) {
|
|
27340
|
+
this.logger.warn("FaceStore.setSuggestion failed", { meta: {
|
|
27341
|
+
faceId,
|
|
27342
|
+
error: String(err)
|
|
27343
|
+
} });
|
|
27344
|
+
}
|
|
27345
|
+
}
|
|
27346
|
+
/**
|
|
27347
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
27348
|
+
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
27349
|
+
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
27350
|
+
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
27351
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
27352
|
+
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
27353
|
+
*/
|
|
27354
|
+
async deleteByTracks(trackIds) {
|
|
27355
|
+
let removed = 0;
|
|
27356
|
+
for (const trackId of trackIds) try {
|
|
27357
|
+
const eligible = (await this.store.query.query({
|
|
27358
|
+
collection: FACES_COLLECTION,
|
|
27359
|
+
filter: { where: { trackId } }
|
|
27360
|
+
})).filter((r) => !r.data.assigned);
|
|
27361
|
+
for (const row of eligible) try {
|
|
27362
|
+
await this.store.delete.mutate({
|
|
27363
|
+
collection: FACES_COLLECTION,
|
|
27364
|
+
key: row.id
|
|
27365
|
+
});
|
|
27366
|
+
removed++;
|
|
27367
|
+
} catch (err) {
|
|
27368
|
+
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
27369
|
+
faceId: row.id,
|
|
27370
|
+
error: String(err)
|
|
27371
|
+
} });
|
|
27372
|
+
}
|
|
27373
|
+
} catch (err) {
|
|
27374
|
+
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
27375
|
+
trackId,
|
|
27376
|
+
error: String(err)
|
|
27377
|
+
} });
|
|
27378
|
+
}
|
|
27379
|
+
return [{
|
|
27380
|
+
family: "faces",
|
|
27381
|
+
rows: removed,
|
|
27382
|
+
bytes: 0
|
|
27383
|
+
}];
|
|
27384
|
+
}
|
|
27385
|
+
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
27386
|
+
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
27387
|
+
async delete(faceId) {
|
|
27388
|
+
try {
|
|
27389
|
+
await this.store.delete.mutate({
|
|
27390
|
+
collection: FACES_COLLECTION,
|
|
27391
|
+
key: faceId
|
|
27392
|
+
});
|
|
27393
|
+
} catch (err) {
|
|
27394
|
+
this.logger.warn("FaceStore.delete failed", { meta: {
|
|
27395
|
+
faceId,
|
|
27396
|
+
error: String(err)
|
|
27397
|
+
} });
|
|
27398
|
+
}
|
|
27399
|
+
}
|
|
27400
|
+
/**
|
|
27401
|
+
* Retrieve a single face by id. Returns null if not found.
|
|
27402
|
+
*/
|
|
27403
|
+
async get(faceId) {
|
|
27404
|
+
const raw = await this.store.get.query({
|
|
27405
|
+
collection: FACES_COLLECTION,
|
|
27406
|
+
key: faceId
|
|
27407
|
+
});
|
|
27408
|
+
if (!raw) return null;
|
|
27409
|
+
return rowToFace(faceId, raw);
|
|
27410
|
+
}
|
|
27411
|
+
/**
|
|
27412
|
+
* Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
|
|
27413
|
+
* Not exposed publicly — intended for internal use and tests only.
|
|
27414
|
+
*/
|
|
27415
|
+
async update(faceId, data) {
|
|
27416
|
+
try {
|
|
27417
|
+
await this.store.update.mutate({
|
|
27418
|
+
collection: FACES_COLLECTION,
|
|
27419
|
+
id: faceId,
|
|
27420
|
+
data
|
|
27421
|
+
});
|
|
27422
|
+
} catch (err) {
|
|
27423
|
+
this.logger.warn("FaceStore.update failed", { meta: {
|
|
27424
|
+
faceId,
|
|
27425
|
+
error: String(err)
|
|
27426
|
+
} });
|
|
27427
|
+
}
|
|
27428
|
+
}
|
|
27429
|
+
/**
|
|
27430
|
+
* List recent faces across the cluster, or across a named set of cameras.
|
|
27431
|
+
*
|
|
27432
|
+
* filter:
|
|
27433
|
+
* - 'all' (default) → every row
|
|
27434
|
+
* - 'unassigned' → !assigned
|
|
27435
|
+
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
27436
|
+
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
27437
|
+
*
|
|
27438
|
+
* `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
|
|
27439
|
+
* what is read, not what is returned. `filter` and the ordering cannot be
|
|
27440
|
+
* (the first is a computed predicate, the second is a contract this backend
|
|
27441
|
+
* does not implement — see `face-sort.ts`), so they are applied here.
|
|
27442
|
+
*
|
|
27443
|
+
* **`limit` counts rows that PASSED the filter, never rows that were
|
|
27444
|
+
* discarded.** That is the whole reason this pages instead of asking the
|
|
27445
|
+
* store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
|
|
27446
|
+
* entirely assigned while matches wait just beyond them.
|
|
27447
|
+
*
|
|
27448
|
+
* The previous answer was to fetch the WHOLE collection and slice after
|
|
27449
|
+
* filtering. That is the read that killed hub-main (`query-bounds.ts`) —
|
|
27450
|
+
* every face row carries a JSON `embedding`, so "all rows" is the widest
|
|
27451
|
+
* table in the database materialised at once, to render a gallery of at most
|
|
27452
|
+
* a few hundred tiles.
|
|
27453
|
+
*
|
|
27454
|
+
* So: PAGE. Under the default `timestamp` order the store's paging already
|
|
27455
|
+
* emits rows in the answer's order, so the sweep stops the moment enough
|
|
27456
|
+
* MATCHES exist and the common case costs one page. Under
|
|
27457
|
+
* `suggestionConfidence` it cannot — the most certain suggestion may be the
|
|
27458
|
+
* oldest row — so the sweep walks the window and folds each page through
|
|
27459
|
+
* `rankAndCap`, which holds the RETAINED set to `limit` even though the read
|
|
27460
|
+
* is wider. Narrow the read with `since` / `until`.
|
|
27461
|
+
*
|
|
27462
|
+
* An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
|
|
27463
|
+
* cameras, not an omission (the `deviceManager.listFleet` contract).
|
|
27464
|
+
*/
|
|
27465
|
+
async listAllRecentFaces(input) {
|
|
27466
|
+
if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
|
|
27467
|
+
const order = resolveFaceSort(input);
|
|
27468
|
+
const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
|
|
27469
|
+
const filterMode = input.filter ?? "all";
|
|
27470
|
+
const matches = (f) => {
|
|
27471
|
+
if (filterMode === "unassigned") return !f.assigned;
|
|
27472
|
+
if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
|
|
27473
|
+
if (filterMode === "identified") return f.recognizedIdentityId != null;
|
|
27474
|
+
return true;
|
|
27475
|
+
};
|
|
27476
|
+
const storeFilter = {
|
|
27477
|
+
...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
|
|
27478
|
+
...buildTimestampWindow(input),
|
|
27479
|
+
orderBy: {
|
|
27480
|
+
field: "timestamp",
|
|
27481
|
+
direction: pagingServesTheOrder ? order.direction : "desc"
|
|
27482
|
+
}
|
|
27483
|
+
};
|
|
27484
|
+
let result = [];
|
|
27485
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27486
|
+
const rows = await this.store.query.query({
|
|
27487
|
+
collection: FACES_COLLECTION,
|
|
27488
|
+
filter: {
|
|
27489
|
+
...storeFilter,
|
|
27490
|
+
limit: FACE_SWEEP_PAGE,
|
|
27491
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27492
|
+
}
|
|
27493
|
+
});
|
|
27494
|
+
if (rows.length === 0) break;
|
|
27495
|
+
const matched = [];
|
|
27496
|
+
for (const r of rows) {
|
|
27497
|
+
const face = rowToFace(r.id, r.data);
|
|
27498
|
+
if (matches(face)) matched.push(face);
|
|
27499
|
+
}
|
|
27500
|
+
if (pagingServesTheOrder) {
|
|
27501
|
+
const merged = [...result, ...matched];
|
|
27502
|
+
if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
|
|
27503
|
+
result = merged;
|
|
27504
|
+
} else result = rankAndCap([...result, ...matched], order, input.limit);
|
|
27505
|
+
if (rows.length < 1e3) break;
|
|
27506
|
+
}
|
|
27507
|
+
return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
|
|
27508
|
+
}
|
|
27509
|
+
/** One real face row per camera currently attributed to an identity. */
|
|
27510
|
+
async listCameraRefsForIdentity(identityId) {
|
|
27511
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
27512
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27513
|
+
const rows = await this.store.query.query({
|
|
27514
|
+
collection: FACES_COLLECTION,
|
|
27515
|
+
filter: {
|
|
27516
|
+
where: { recognizedIdentityId: identityId },
|
|
27517
|
+
orderBy: {
|
|
27518
|
+
field: "timestamp",
|
|
27519
|
+
direction: "desc"
|
|
27520
|
+
},
|
|
27521
|
+
limit: FACE_SWEEP_PAGE,
|
|
27522
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27523
|
+
}
|
|
27524
|
+
});
|
|
27525
|
+
if (rows.length === 0) break;
|
|
27526
|
+
for (const row of rows) {
|
|
27527
|
+
const deviceId = Number(row.data["deviceId"]);
|
|
27528
|
+
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
27529
|
+
}
|
|
27530
|
+
if (rows.length < 1e3) break;
|
|
27531
|
+
}
|
|
27532
|
+
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
27533
|
+
deviceId,
|
|
27534
|
+
faceId
|
|
27535
|
+
}));
|
|
27536
|
+
}
|
|
27537
|
+
};
|
|
27538
|
+
//#endregion
|
|
26817
27539
|
//#region src/pipeline-analytics/face-gallery-provider.ts
|
|
26818
27540
|
/** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
|
|
26819
27541
|
var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
@@ -26827,6 +27549,36 @@ var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
|
26827
27549
|
* stalled pipeline.
|
|
26828
27550
|
*/
|
|
26829
27551
|
var FACE_CROP_FETCH_CONCURRENCY = 16;
|
|
27552
|
+
/**
|
|
27553
|
+
* How many unassigned faces `suggestFaceClusters` reads when the caller names
|
|
27554
|
+
* no {@link SuggestFaceClustersInput.maxFacesScanned}.
|
|
27555
|
+
*
|
|
27556
|
+
* Clustering used to read EVERY unassigned face on the hub, unconditionally:
|
|
27557
|
+
* its only bound (`limit`) cut the finished clusters, so it shortened the
|
|
27558
|
+
* ANSWER and never the work. A UI showing a window of 100 paid for a scan of
|
|
27559
|
+
* the whole corpus — on an addon whose disk is under contention — and no caller
|
|
27560
|
+
* had any way to ask for less.
|
|
27561
|
+
*
|
|
27562
|
+
* `FACE_SWEEP_PAGE` is chosen on purpose rather than a round number: it is
|
|
27563
|
+
* exactly ONE store page, so on the live corpus (372 face rows) the default
|
|
27564
|
+
* reads what it read before, one page, and nothing about today's answers
|
|
27565
|
+
* changes. What changes is the ceiling — the unbounded scan cannot come back as
|
|
27566
|
+
* the table grows.
|
|
27567
|
+
*/
|
|
27568
|
+
var SUGGEST_CLUSTERS_DEFAULT_FACE_POOL = FACE_SWEEP_PAGE;
|
|
27569
|
+
/**
|
|
27570
|
+
* The camera set a face listing is scoped to, as ONE value.
|
|
27571
|
+
*
|
|
27572
|
+
* `deviceIds` is the authority whenever it is present — including when it is
|
|
27573
|
+
* EMPTY, which reads nothing (a request for no cameras is a request, not an
|
|
27574
|
+
* omission). `deviceId` is the legacy single-camera form and only applies when
|
|
27575
|
+
* no set was named, so the two can never disagree about the answer.
|
|
27576
|
+
*/
|
|
27577
|
+
function resolveFaceDeviceScope(input) {
|
|
27578
|
+
if (input?.deviceIds !== void 0) return { deviceIds: input.deviceIds };
|
|
27579
|
+
if (input?.deviceId !== void 0) return { deviceIds: [input.deviceId] };
|
|
27580
|
+
return {};
|
|
27581
|
+
}
|
|
26830
27582
|
var FaceGalleryProvider = class {
|
|
26831
27583
|
identityStore;
|
|
26832
27584
|
faceStore;
|
|
@@ -26962,9 +27714,13 @@ var FaceGalleryProvider = class {
|
|
|
26962
27714
|
}
|
|
26963
27715
|
async listRecentFaces(input) {
|
|
26964
27716
|
const faces = await this.faceStore.listAllRecentFaces({
|
|
26965
|
-
|
|
27717
|
+
...resolveFaceDeviceScope(input),
|
|
26966
27718
|
limit: input?.limit,
|
|
26967
|
-
filter: input?.filter
|
|
27719
|
+
filter: input?.filter,
|
|
27720
|
+
since: input?.since,
|
|
27721
|
+
until: input?.until,
|
|
27722
|
+
sortBy: input?.sortBy,
|
|
27723
|
+
sortDirection: input?.sortDirection
|
|
26968
27724
|
});
|
|
26969
27725
|
const identities = await this.identityStore.listIdentities();
|
|
26970
27726
|
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
@@ -27220,16 +27976,34 @@ var FaceGalleryProvider = class {
|
|
|
27220
27976
|
* Suggest clusters of look-alike UNASSIGNED faces (greedy cosine on their
|
|
27221
27977
|
* embeddings) so the operator can batch-assign a whole group. Embeddings stay
|
|
27222
27978
|
* server-side; only faceId groupings are returned.
|
|
27979
|
+
*
|
|
27980
|
+
* TWO bounds, and they cut different things — which is the whole point of the
|
|
27981
|
+
* rename:
|
|
27982
|
+
* - `maxFacesScanned` bounds the POOL, i.e. the work. Newest unassigned
|
|
27983
|
+
* faces first, default {@link SUGGEST_CLUSTERS_DEFAULT_FACE_POOL}.
|
|
27984
|
+
* - `maxClusters` (formerly the ambiguous `limit`) bounds the ANSWER.
|
|
27985
|
+
* It has never made this method read one row less.
|
|
27223
27986
|
*/
|
|
27224
27987
|
async suggestFaceClusters(input) {
|
|
27225
27988
|
const threshold = input?.threshold ?? .5;
|
|
27226
27989
|
const minClusterSize = input?.minClusterSize ?? 2;
|
|
27227
|
-
|
|
27990
|
+
const maxFacesScanned = input?.maxFacesScanned ?? SUGGEST_CLUSTERS_DEFAULT_FACE_POOL;
|
|
27991
|
+
const maxClusters = input?.maxClusters ?? input?.limit;
|
|
27992
|
+
const faces = await this.faceStore.listAllRecentFaces({
|
|
27993
|
+
filter: "unassigned",
|
|
27994
|
+
limit: maxFacesScanned
|
|
27995
|
+
});
|
|
27996
|
+
if (faces.length >= maxFacesScanned) this.logger.info("suggestFaceClusters pool saturated — older faces not considered", { meta: {
|
|
27997
|
+
maxFacesScanned,
|
|
27998
|
+
scanned: faces.length,
|
|
27999
|
+
threshold,
|
|
28000
|
+
minClusterSize
|
|
28001
|
+
} });
|
|
28002
|
+
const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
|
|
27228
28003
|
id: f.id,
|
|
27229
28004
|
embedding: f.embedding
|
|
27230
28005
|
})), threshold).filter((c) => c.size >= minClusterSize);
|
|
27231
|
-
|
|
27232
|
-
return clusters;
|
|
28006
|
+
return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
|
|
27233
28007
|
}
|
|
27234
28008
|
};
|
|
27235
28009
|
//#endregion
|
|
@@ -40543,12 +41317,14 @@ function digestMediaRows(input) {
|
|
|
40543
41317
|
if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
|
|
40544
41318
|
key: input.mediaKey,
|
|
40545
41319
|
kind: "groupCrop",
|
|
40546
|
-
timestamp: input.timestamp
|
|
41320
|
+
timestamp: input.timestamp,
|
|
41321
|
+
sizeBytes: null
|
|
40547
41322
|
});
|
|
40548
41323
|
if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
|
|
40549
41324
|
key: input.nativeKey,
|
|
40550
41325
|
kind: "nativeFrame",
|
|
40551
|
-
timestamp: input.timestamp
|
|
41326
|
+
timestamp: input.timestamp,
|
|
41327
|
+
sizeBytes: null
|
|
40552
41328
|
});
|
|
40553
41329
|
if (owned.length > 0) return owned;
|
|
40554
41330
|
return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
|
|
@@ -47433,553 +48209,6 @@ function stripNulls$1(data) {
|
|
|
47433
48209
|
return out;
|
|
47434
48210
|
}
|
|
47435
48211
|
//#endregion
|
|
47436
|
-
//#region src/pipeline-analytics/store/face-store.ts
|
|
47437
|
-
/**
|
|
47438
|
-
* @durable class=ledger owner=pipeline-analytics
|
|
47439
|
-
* write="one UPSERT per track that produced a face detail — the id is
|
|
47440
|
-
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
47441
|
-
* overwrites it as a better read lands (embedding, match cosine, crop keys).
|
|
47442
|
-
* The row is rewritten again when the operator enrols it, which flips
|
|
47443
|
-
* `assigned` and stamps the identity."
|
|
47444
|
-
* retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
|
|
47445
|
-
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
47446
|
-
* exempt from that and from every sweep — an assignment is a curation act.
|
|
47447
|
-
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
47448
|
-
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
|
|
47449
|
-
* of JSON embedding per row, that cap is what keeps the table small."
|
|
47450
|
-
*/
|
|
47451
|
-
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
47452
|
-
/**
|
|
47453
|
-
* Rows per page for the two whole-collection sweeps below.
|
|
47454
|
-
*
|
|
47455
|
-
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
47456
|
-
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
47457
|
-
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
47458
|
-
* collection in ONE statement with no `limit`.
|
|
47459
|
-
*
|
|
47460
|
-
* To be exact about what that did and did not cause: this table holds **372
|
|
47461
|
-
* rows / 4.0 MB** on the live hub, so it was *not* the read that killed
|
|
47462
|
-
* hub-main on 2026-08-07 (that was the multi-camera track feed — see
|
|
47463
|
-
* `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
|
|
47464
|
-
* grows with a table nothing else bounds, which is the same defect one incident
|
|
47465
|
-
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
47466
|
-
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
47467
|
-
* it were the same loop.
|
|
47468
|
-
*
|
|
47469
|
-
* The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
|
|
47470
|
-
* is not belt-and-braces: without it these two methods would silently see only
|
|
47471
|
-
* the newest N rows and the buffer would grow forever.
|
|
47472
|
-
*
|
|
47473
|
-
* 1 000 keeps peak transfer around 11 MB while staying under the engine's own
|
|
47474
|
-
* cap, so the page is always the caller's bound and never the engine's.
|
|
47475
|
-
*/
|
|
47476
|
-
var FACE_SWEEP_PAGE = 1e3;
|
|
47477
|
-
/**
|
|
47478
|
-
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
47479
|
-
* million faces — far past anything real, and reached only by a bug.
|
|
47480
|
-
*/
|
|
47481
|
-
var FACE_SWEEP_MAX_PAGES = 1e3;
|
|
47482
|
-
var FACE_COLUMNS = [
|
|
47483
|
-
{
|
|
47484
|
-
name: "id",
|
|
47485
|
-
type: "TEXT",
|
|
47486
|
-
primaryKey: true,
|
|
47487
|
-
notNull: true
|
|
47488
|
-
},
|
|
47489
|
-
{
|
|
47490
|
-
name: "deviceId",
|
|
47491
|
-
type: "INTEGER",
|
|
47492
|
-
notNull: true
|
|
47493
|
-
},
|
|
47494
|
-
{
|
|
47495
|
-
name: "trackId",
|
|
47496
|
-
type: "TEXT",
|
|
47497
|
-
notNull: true
|
|
47498
|
-
},
|
|
47499
|
-
{
|
|
47500
|
-
name: "timestamp",
|
|
47501
|
-
type: "INTEGER",
|
|
47502
|
-
notNull: true
|
|
47503
|
-
},
|
|
47504
|
-
{
|
|
47505
|
-
name: "embedding",
|
|
47506
|
-
type: "JSON",
|
|
47507
|
-
notNull: true
|
|
47508
|
-
},
|
|
47509
|
-
{
|
|
47510
|
-
name: "embeddingModelId",
|
|
47511
|
-
type: "TEXT"
|
|
47512
|
-
},
|
|
47513
|
-
{
|
|
47514
|
-
name: "mediaKey",
|
|
47515
|
-
type: "TEXT"
|
|
47516
|
-
},
|
|
47517
|
-
{
|
|
47518
|
-
name: "recognizedIdentityId",
|
|
47519
|
-
type: "TEXT"
|
|
47520
|
-
},
|
|
47521
|
-
{
|
|
47522
|
-
name: "assigned",
|
|
47523
|
-
type: "BOOLEAN",
|
|
47524
|
-
notNull: true
|
|
47525
|
-
},
|
|
47526
|
-
{
|
|
47527
|
-
name: "assignedSampleId",
|
|
47528
|
-
type: "TEXT"
|
|
47529
|
-
},
|
|
47530
|
-
{
|
|
47531
|
-
name: "keyFrameMediaKey",
|
|
47532
|
-
type: "TEXT"
|
|
47533
|
-
},
|
|
47534
|
-
{
|
|
47535
|
-
name: "faceBbox",
|
|
47536
|
-
type: "JSON"
|
|
47537
|
-
},
|
|
47538
|
-
{
|
|
47539
|
-
name: "bestMatchScore",
|
|
47540
|
-
type: "REAL"
|
|
47541
|
-
},
|
|
47542
|
-
{
|
|
47543
|
-
name: "nativeFaceShortSidePx",
|
|
47544
|
-
type: "REAL"
|
|
47545
|
-
},
|
|
47546
|
-
{
|
|
47547
|
-
name: "suggestedIdentityId",
|
|
47548
|
-
type: "TEXT"
|
|
47549
|
-
},
|
|
47550
|
-
{
|
|
47551
|
-
name: "suggestedMatchScore",
|
|
47552
|
-
type: "REAL"
|
|
47553
|
-
}
|
|
47554
|
-
];
|
|
47555
|
-
var FACE_INDEXES = [{
|
|
47556
|
-
name: "idx_faces_device_ts",
|
|
47557
|
-
columns: ["deviceId", "timestamp"]
|
|
47558
|
-
}, {
|
|
47559
|
-
name: "idx_faces_track",
|
|
47560
|
-
columns: ["trackId"]
|
|
47561
|
-
}];
|
|
47562
|
-
var FaceStore = class {
|
|
47563
|
-
store;
|
|
47564
|
-
logger;
|
|
47565
|
-
constructor(deps) {
|
|
47566
|
-
this.store = deps.store;
|
|
47567
|
-
this.logger = deps.logger;
|
|
47568
|
-
}
|
|
47569
|
-
static async declare(store) {
|
|
47570
|
-
await store.declareCollection.mutate({
|
|
47571
|
-
collection: FACES_COLLECTION,
|
|
47572
|
-
columns: [...FACE_COLUMNS],
|
|
47573
|
-
indexes: [...FACE_INDEXES]
|
|
47574
|
-
});
|
|
47575
|
-
}
|
|
47576
|
-
/**
|
|
47577
|
-
* Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
|
|
47578
|
-
* id per track → one entry per track). `set` replaces an existing row.
|
|
47579
|
-
*/
|
|
47580
|
-
async insert(face) {
|
|
47581
|
-
const { id, ...rest } = face;
|
|
47582
|
-
try {
|
|
47583
|
-
await this.store.set.mutate({
|
|
47584
|
-
collection: FACES_COLLECTION,
|
|
47585
|
-
key: id,
|
|
47586
|
-
value: rest
|
|
47587
|
-
});
|
|
47588
|
-
} catch (err) {
|
|
47589
|
-
this.logger.warn("FaceStore.insert failed", {
|
|
47590
|
-
tags: { deviceId: face.deviceId },
|
|
47591
|
-
meta: {
|
|
47592
|
-
faceId: id,
|
|
47593
|
-
error: String(err)
|
|
47594
|
-
}
|
|
47595
|
-
});
|
|
47596
|
-
}
|
|
47597
|
-
}
|
|
47598
|
-
/**
|
|
47599
|
-
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
47600
|
-
*
|
|
47601
|
-
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
47602
|
-
* SQLite backend (which returns INTEGER 0/1) is handled correctly.
|
|
47603
|
-
*/
|
|
47604
|
-
async listRecentFaces(input) {
|
|
47605
|
-
const where = {};
|
|
47606
|
-
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
47607
|
-
return (await this.store.query.query({
|
|
47608
|
-
collection: FACES_COLLECTION,
|
|
47609
|
-
filter: {
|
|
47610
|
-
where,
|
|
47611
|
-
orderBy: {
|
|
47612
|
-
field: "timestamp",
|
|
47613
|
-
direction: "desc"
|
|
47614
|
-
},
|
|
47615
|
-
limit: input.limit ?? 100
|
|
47616
|
-
}
|
|
47617
|
-
})).map((r) => {
|
|
47618
|
-
const data = r.data;
|
|
47619
|
-
return {
|
|
47620
|
-
id: r.id,
|
|
47621
|
-
...data,
|
|
47622
|
-
assigned: Boolean(r.data.assigned),
|
|
47623
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47624
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47625
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47626
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47627
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47628
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47629
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47630
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47631
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47632
|
-
};
|
|
47633
|
-
}).filter((f) => !f.assigned);
|
|
47634
|
-
}
|
|
47635
|
-
/**
|
|
47636
|
-
* The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
|
|
47637
|
-
* here is GONE (D61). It had no production caller — only tests — and it was
|
|
47638
|
-
* an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
|
|
47639
|
-
* retention model now forbids. A leftover that describes the wrong design
|
|
47640
|
-
* still reads as permission to use it.
|
|
47641
|
-
*/
|
|
47642
|
-
/**
|
|
47643
|
-
* Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
|
|
47644
|
-
*
|
|
47645
|
-
* **This is a CAPACITY bound, not an age one.** It used to be both: a face
|
|
47646
|
-
* was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
|
|
47647
|
-
* The age half was a fifth clock over a track-OWNED entity — a face row
|
|
47648
|
-
* carries a `trackId`, so it belongs to its track and cascades with it — and
|
|
47649
|
-
* it aged crops out from under tracks that were still well inside their own
|
|
47650
|
-
* retention window. It is gone (D61). What remains is the ring-buffer
|
|
47651
|
-
* property the recognizer actually depends on: the gallery of recent
|
|
47652
|
-
* unassigned faces stays small.
|
|
47653
|
-
*
|
|
47654
|
-
* ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
|
|
47655
|
-
* slots. Returns all deleted ids (the caller removes their crops).
|
|
47656
|
-
*/
|
|
47657
|
-
async pruneCapOverflow(input) {
|
|
47658
|
-
/**
|
|
47659
|
-
* Paged, not one statement. Only three scalars per row survive the page —
|
|
47660
|
-
* the `embedding` string that dominates the row is dropped as soon as the
|
|
47661
|
-
* page goes out of scope, so peak heap is one page rather than the whole
|
|
47662
|
-
* face table. The prune still considers EVERY row, which is what makes it a
|
|
47663
|
-
* capacity bound rather than a bound on the newest page.
|
|
47664
|
-
*/
|
|
47665
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
47666
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47667
|
-
const rows = await this.store.query.query({
|
|
47668
|
-
collection: FACES_COLLECTION,
|
|
47669
|
-
filter: {
|
|
47670
|
-
orderBy: {
|
|
47671
|
-
field: "timestamp",
|
|
47672
|
-
direction: "desc"
|
|
47673
|
-
},
|
|
47674
|
-
limit: FACE_SWEEP_PAGE,
|
|
47675
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47676
|
-
}
|
|
47677
|
-
});
|
|
47678
|
-
if (rows.length === 0) break;
|
|
47679
|
-
for (const r of rows) {
|
|
47680
|
-
const data = r.data;
|
|
47681
|
-
if (data.assigned) continue;
|
|
47682
|
-
const deviceId = Number(data.deviceId ?? -1);
|
|
47683
|
-
const list = byDevice.get(deviceId) ?? [];
|
|
47684
|
-
list.push({
|
|
47685
|
-
id: r.id,
|
|
47686
|
-
timestamp: Number(data.timestamp ?? 0)
|
|
47687
|
-
});
|
|
47688
|
-
byDevice.set(deviceId, list);
|
|
47689
|
-
}
|
|
47690
|
-
if (rows.length < 1e3) break;
|
|
47691
|
-
}
|
|
47692
|
-
const toDelete = /* @__PURE__ */ new Set();
|
|
47693
|
-
for (const list of byDevice.values()) {
|
|
47694
|
-
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
47695
|
-
list.forEach((row, idx) => {
|
|
47696
|
-
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
47697
|
-
});
|
|
47698
|
-
}
|
|
47699
|
-
const deleted = [];
|
|
47700
|
-
for (const id of toDelete) try {
|
|
47701
|
-
await this.store.delete.mutate({
|
|
47702
|
-
collection: FACES_COLLECTION,
|
|
47703
|
-
key: id
|
|
47704
|
-
});
|
|
47705
|
-
deleted.push(id);
|
|
47706
|
-
} catch (err) {
|
|
47707
|
-
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
47708
|
-
faceId: id,
|
|
47709
|
-
error: String(err)
|
|
47710
|
-
} });
|
|
47711
|
-
}
|
|
47712
|
-
return deleted;
|
|
47713
|
-
}
|
|
47714
|
-
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
47715
|
-
async markAssigned(faceId, identityId, sampleId) {
|
|
47716
|
-
const data = {
|
|
47717
|
-
assigned: true,
|
|
47718
|
-
recognizedIdentityId: identityId
|
|
47719
|
-
};
|
|
47720
|
-
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
47721
|
-
try {
|
|
47722
|
-
await this.store.update.mutate({
|
|
47723
|
-
collection: FACES_COLLECTION,
|
|
47724
|
-
id: faceId,
|
|
47725
|
-
data
|
|
47726
|
-
});
|
|
47727
|
-
} catch (err) {
|
|
47728
|
-
this.logger.warn("FaceStore.markAssigned failed", { meta: {
|
|
47729
|
-
faceId,
|
|
47730
|
-
error: String(err)
|
|
47731
|
-
} });
|
|
47732
|
-
}
|
|
47733
|
-
}
|
|
47734
|
-
/** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
|
|
47735
|
-
async clearAssignment(faceId) {
|
|
47736
|
-
try {
|
|
47737
|
-
await this.store.update.mutate({
|
|
47738
|
-
collection: FACES_COLLECTION,
|
|
47739
|
-
id: faceId,
|
|
47740
|
-
data: {
|
|
47741
|
-
assigned: false,
|
|
47742
|
-
recognizedIdentityId: null,
|
|
47743
|
-
assignedSampleId: null
|
|
47744
|
-
}
|
|
47745
|
-
});
|
|
47746
|
-
} catch (err) {
|
|
47747
|
-
this.logger.warn("FaceStore.clearAssignment failed", { meta: {
|
|
47748
|
-
faceId,
|
|
47749
|
-
error: String(err)
|
|
47750
|
-
} });
|
|
47751
|
-
}
|
|
47752
|
-
}
|
|
47753
|
-
/**
|
|
47754
|
-
* Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
|
|
47755
|
-
* write that explicitly-requested action makes. `suggestion: null` means
|
|
47756
|
-
* the recompute found no candidate worth offering and CLEARS both columns
|
|
47757
|
-
* (a genuine "the gallery has nothing for this face right now" result, not
|
|
47758
|
-
* a no-op left at whatever they held before). Never touches `assigned` /
|
|
47759
|
-
* `recognizedIdentityId` — a suggestion is never an assignment. The caller
|
|
47760
|
-
* is responsible for never calling this with a non-comparable (`model-
|
|
47761
|
-
* mismatch`) report; this method persists whatever it is given.
|
|
47762
|
-
*/
|
|
47763
|
-
async setSuggestion(faceId, suggestion) {
|
|
47764
|
-
const data = suggestion === null ? {
|
|
47765
|
-
suggestedIdentityId: null,
|
|
47766
|
-
suggestedMatchScore: null
|
|
47767
|
-
} : {
|
|
47768
|
-
suggestedIdentityId: suggestion.identityId,
|
|
47769
|
-
suggestedMatchScore: suggestion.score
|
|
47770
|
-
};
|
|
47771
|
-
try {
|
|
47772
|
-
await this.store.update.mutate({
|
|
47773
|
-
collection: FACES_COLLECTION,
|
|
47774
|
-
id: faceId,
|
|
47775
|
-
data
|
|
47776
|
-
});
|
|
47777
|
-
} catch (err) {
|
|
47778
|
-
this.logger.warn("FaceStore.setSuggestion failed", { meta: {
|
|
47779
|
-
faceId,
|
|
47780
|
-
error: String(err)
|
|
47781
|
-
} });
|
|
47782
|
-
}
|
|
47783
|
-
}
|
|
47784
|
-
/**
|
|
47785
|
-
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
47786
|
-
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
47787
|
-
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
47788
|
-
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
47789
|
-
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
47790
|
-
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
47791
|
-
*/
|
|
47792
|
-
async deleteByTracks(trackIds) {
|
|
47793
|
-
let removed = 0;
|
|
47794
|
-
for (const trackId of trackIds) try {
|
|
47795
|
-
const eligible = (await this.store.query.query({
|
|
47796
|
-
collection: FACES_COLLECTION,
|
|
47797
|
-
filter: { where: { trackId } }
|
|
47798
|
-
})).filter((r) => !r.data.assigned);
|
|
47799
|
-
for (const row of eligible) try {
|
|
47800
|
-
await this.store.delete.mutate({
|
|
47801
|
-
collection: FACES_COLLECTION,
|
|
47802
|
-
key: row.id
|
|
47803
|
-
});
|
|
47804
|
-
removed++;
|
|
47805
|
-
} catch (err) {
|
|
47806
|
-
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
47807
|
-
faceId: row.id,
|
|
47808
|
-
error: String(err)
|
|
47809
|
-
} });
|
|
47810
|
-
}
|
|
47811
|
-
} catch (err) {
|
|
47812
|
-
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
47813
|
-
trackId,
|
|
47814
|
-
error: String(err)
|
|
47815
|
-
} });
|
|
47816
|
-
}
|
|
47817
|
-
return [{
|
|
47818
|
-
family: "faces",
|
|
47819
|
-
rows: removed,
|
|
47820
|
-
bytes: 0
|
|
47821
|
-
}];
|
|
47822
|
-
}
|
|
47823
|
-
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
47824
|
-
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
47825
|
-
async delete(faceId) {
|
|
47826
|
-
try {
|
|
47827
|
-
await this.store.delete.mutate({
|
|
47828
|
-
collection: FACES_COLLECTION,
|
|
47829
|
-
key: faceId
|
|
47830
|
-
});
|
|
47831
|
-
} catch (err) {
|
|
47832
|
-
this.logger.warn("FaceStore.delete failed", { meta: {
|
|
47833
|
-
faceId,
|
|
47834
|
-
error: String(err)
|
|
47835
|
-
} });
|
|
47836
|
-
}
|
|
47837
|
-
}
|
|
47838
|
-
/**
|
|
47839
|
-
* Retrieve a single face by id. Returns null if not found.
|
|
47840
|
-
*/
|
|
47841
|
-
async get(faceId) {
|
|
47842
|
-
const raw = await this.store.get.query({
|
|
47843
|
-
collection: FACES_COLLECTION,
|
|
47844
|
-
key: faceId
|
|
47845
|
-
});
|
|
47846
|
-
if (!raw) return null;
|
|
47847
|
-
const data = raw;
|
|
47848
|
-
return {
|
|
47849
|
-
id: faceId,
|
|
47850
|
-
...data,
|
|
47851
|
-
assigned: Boolean(raw.assigned),
|
|
47852
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47853
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47854
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47855
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47856
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47857
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47858
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47859
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47860
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47861
|
-
};
|
|
47862
|
-
}
|
|
47863
|
-
/**
|
|
47864
|
-
* Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
|
|
47865
|
-
* Not exposed publicly — intended for internal use and tests only.
|
|
47866
|
-
*/
|
|
47867
|
-
async update(faceId, data) {
|
|
47868
|
-
try {
|
|
47869
|
-
await this.store.update.mutate({
|
|
47870
|
-
collection: FACES_COLLECTION,
|
|
47871
|
-
id: faceId,
|
|
47872
|
-
data
|
|
47873
|
-
});
|
|
47874
|
-
} catch (err) {
|
|
47875
|
-
this.logger.warn("FaceStore.update failed", { meta: {
|
|
47876
|
-
faceId,
|
|
47877
|
-
error: String(err)
|
|
47878
|
-
} });
|
|
47879
|
-
}
|
|
47880
|
-
}
|
|
47881
|
-
/**
|
|
47882
|
-
* List ALL recent faces across all devices, newest-first.
|
|
47883
|
-
*
|
|
47884
|
-
* filter:
|
|
47885
|
-
* - 'all' (default) → every row
|
|
47886
|
-
* - 'unassigned' → !assigned
|
|
47887
|
-
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
47888
|
-
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
47889
|
-
*/
|
|
47890
|
-
async listAllRecentFaces(input) {
|
|
47891
|
-
/**
|
|
47892
|
-
* The filter is applied in memory, so a DB `limit` of N cannot be the
|
|
47893
|
-
* caller's N: with `filter:'unassigned'` the newest N rows may be entirely
|
|
47894
|
-
* assigned while matches wait just beyond them.
|
|
47895
|
-
*
|
|
47896
|
-
* The previous answer was to fetch the WHOLE collection and slice after
|
|
47897
|
-
* filtering. That is the read that killed hub-main (`query-bounds.ts`) —
|
|
47898
|
-
* every face row carries a JSON `embedding`, so "all rows" is the widest
|
|
47899
|
-
* table in the database materialised at once, to render a gallery of at
|
|
47900
|
-
* most a few hundred tiles.
|
|
47901
|
-
*
|
|
47902
|
-
* PAGE instead, and stop as soon as enough MATCHES exist. The pathological
|
|
47903
|
-
* case (a filter that matches nothing) walks the whole collection exactly
|
|
47904
|
-
* as before but one bounded page at a time; the common case stops after the
|
|
47905
|
-
* first page. Semantics are unchanged: the limit is still applied after the
|
|
47906
|
-
* filter, never before it.
|
|
47907
|
-
*/
|
|
47908
|
-
const filterMode = input.filter ?? "all";
|
|
47909
|
-
const matches = (f) => {
|
|
47910
|
-
if (filterMode === "unassigned") return !f.assigned;
|
|
47911
|
-
if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
|
|
47912
|
-
if (filterMode === "identified") return f.recognizedIdentityId != null;
|
|
47913
|
-
return true;
|
|
47914
|
-
};
|
|
47915
|
-
const result = [];
|
|
47916
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47917
|
-
const rows = await this.store.query.query({
|
|
47918
|
-
collection: FACES_COLLECTION,
|
|
47919
|
-
filter: {
|
|
47920
|
-
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
47921
|
-
orderBy: {
|
|
47922
|
-
field: "timestamp",
|
|
47923
|
-
direction: "desc"
|
|
47924
|
-
},
|
|
47925
|
-
limit: FACE_SWEEP_PAGE,
|
|
47926
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47927
|
-
}
|
|
47928
|
-
});
|
|
47929
|
-
if (rows.length === 0) break;
|
|
47930
|
-
for (const r of rows) {
|
|
47931
|
-
const data = r.data;
|
|
47932
|
-
const face = {
|
|
47933
|
-
id: r.id,
|
|
47934
|
-
...data,
|
|
47935
|
-
assigned: Boolean(r.data.assigned),
|
|
47936
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47937
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47938
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47939
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47940
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47941
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47942
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47943
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47944
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47945
|
-
};
|
|
47946
|
-
if (matches(face)) result.push(face);
|
|
47947
|
-
if (input.limit !== void 0 && result.length >= input.limit) return result;
|
|
47948
|
-
}
|
|
47949
|
-
if (rows.length < 1e3) break;
|
|
47950
|
-
}
|
|
47951
|
-
return result;
|
|
47952
|
-
}
|
|
47953
|
-
/** One real face row per camera currently attributed to an identity. */
|
|
47954
|
-
async listCameraRefsForIdentity(identityId) {
|
|
47955
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
47956
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47957
|
-
const rows = await this.store.query.query({
|
|
47958
|
-
collection: FACES_COLLECTION,
|
|
47959
|
-
filter: {
|
|
47960
|
-
where: { recognizedIdentityId: identityId },
|
|
47961
|
-
orderBy: {
|
|
47962
|
-
field: "timestamp",
|
|
47963
|
-
direction: "desc"
|
|
47964
|
-
},
|
|
47965
|
-
limit: FACE_SWEEP_PAGE,
|
|
47966
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47967
|
-
}
|
|
47968
|
-
});
|
|
47969
|
-
if (rows.length === 0) break;
|
|
47970
|
-
for (const row of rows) {
|
|
47971
|
-
const deviceId = Number(row.data["deviceId"]);
|
|
47972
|
-
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
47973
|
-
}
|
|
47974
|
-
if (rows.length < 1e3) break;
|
|
47975
|
-
}
|
|
47976
|
-
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
47977
|
-
deviceId,
|
|
47978
|
-
faceId
|
|
47979
|
-
}));
|
|
47980
|
-
}
|
|
47981
|
-
};
|
|
47982
|
-
//#endregion
|
|
47983
48212
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
47984
48213
|
/**
|
|
47985
48214
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -60358,7 +60587,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60358
60587
|
const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
|
|
60359
60588
|
key: r.key,
|
|
60360
60589
|
kind: r.kind,
|
|
60361
|
-
timestamp: r.timestamp
|
|
60590
|
+
timestamp: r.timestamp,
|
|
60591
|
+
sizeBytes: r.sizeBytes
|
|
60362
60592
|
})));
|
|
60363
60593
|
return digestMediaRows({
|
|
60364
60594
|
mediaKey: group.mediaKey,
|
|
@@ -60370,7 +60600,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60370
60600
|
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
60371
60601
|
key: r.key,
|
|
60372
60602
|
kind: r.kind,
|
|
60373
|
-
timestamp: r.timestamp
|
|
60603
|
+
timestamp: r.timestamp,
|
|
60604
|
+
sizeBytes: r.sizeBytes
|
|
60374
60605
|
}));
|
|
60375
60606
|
},
|
|
60376
60607
|
readMedia: async (key) => {
|