@camstack/addon-post-analysis 1.2.129 → 1.2.131
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-DFeeUt_t.js → dist-B8_nKey5.js} +585 -60
- package/dist/{dist-CGhP6T_c.mjs → dist-C8JUWFmR.mjs} +585 -60
- 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-D4G4SLMY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CaoDYCwF.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-C2KWpXJG.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-TOYw2CMN.mjs → hostInit-c_jfj7E4.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +806 -581
- package/dist/pipeline-analytics/index.mjs +806 -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-C8JUWFmR.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";
|
|
@@ -2439,6 +2439,45 @@ var NcArtifactPlane = class {
|
|
|
2439
2439
|
}
|
|
2440
2440
|
}
|
|
2441
2441
|
/**
|
|
2442
|
+
* Mint a FRESH signed URL for an artefact already sitting on the shelf —
|
|
2443
|
+
* no bytes read, no re-store. The other half of {@link publishIndexed}: that
|
|
2444
|
+
* one stores AND mints once at publish time; this one re-mints on demand for
|
|
2445
|
+
* an id a caller already holds (a history row's `artifactIds`), because the
|
|
2446
|
+
* link `publishIndexed` returned has its own TTL and a caller reaching for
|
|
2447
|
+
* the artefact hours later needs a new one.
|
|
2448
|
+
*
|
|
2449
|
+
* `null` on any of three causes, deliberately indistinguishable to the
|
|
2450
|
+
* caller: no index is wired (nothing to resolve against), the id is unknown
|
|
2451
|
+
* or its artefact has expired past the retained shelf's own age bound, or no
|
|
2452
|
+
* externally-reachable base URL exists for this install. A caller (the Home
|
|
2453
|
+
* Assistant export) must render all three as "no image right now" — never as
|
|
2454
|
+
* a broken link, and never by reusing a URL this call refused to mint.
|
|
2455
|
+
*/
|
|
2456
|
+
async mintUrl(artifactId) {
|
|
2457
|
+
const index = this.deps.index;
|
|
2458
|
+
if (index === void 0) return null;
|
|
2459
|
+
try {
|
|
2460
|
+
const record = await index.resolve(artifactId);
|
|
2461
|
+
if (record === null) return null;
|
|
2462
|
+
if (record.expiresAt <= this.now()) return null;
|
|
2463
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
2464
|
+
if (baseUrl === null) return null;
|
|
2465
|
+
return buildArtifactUrl({
|
|
2466
|
+
baseUrl,
|
|
2467
|
+
routePrefix: this.deps.routePrefix,
|
|
2468
|
+
id: artifactId,
|
|
2469
|
+
secret: this.deps.secret,
|
|
2470
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
2471
|
+
});
|
|
2472
|
+
} catch (err) {
|
|
2473
|
+
this.deps.logger.debug("artifact url mint failed", { meta: {
|
|
2474
|
+
artifactId,
|
|
2475
|
+
error: String(err)
|
|
2476
|
+
} });
|
|
2477
|
+
return null;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2442
2481
|
* Absolutize a path against the SAME base URL an artifact link would use.
|
|
2443
2482
|
*
|
|
2444
2483
|
* For links this plane does not host but a RECIPIENT still has to open — a
|
|
@@ -5367,6 +5406,12 @@ function buildAlarmButtons(input) {
|
|
|
5367
5406
|
}];
|
|
5368
5407
|
}
|
|
5369
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
|
|
5370
5415
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
5371
5416
|
/**
|
|
5372
5417
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -8925,7 +8970,7 @@ var NcDispatcher = class {
|
|
|
8925
8970
|
return { ok: true };
|
|
8926
8971
|
}
|
|
8927
8972
|
}
|
|
8928
|
-
const notification = await this.buildNotification(entry, target);
|
|
8973
|
+
const { notification, artifactIds } = await this.buildNotification(entry, target);
|
|
8929
8974
|
const verdict = await this.runConfirmGate(entry, notification);
|
|
8930
8975
|
if (verdict !== null && verdict.decision === "suppressed") {
|
|
8931
8976
|
this.deps.logger.info("notification suppressed by AI confirm", {
|
|
@@ -8983,10 +9028,11 @@ var NcDispatcher = class {
|
|
|
8983
9028
|
...dropped.length > 0 ? { dropped } : {}
|
|
8984
9029
|
}
|
|
8985
9030
|
});
|
|
8986
|
-
return
|
|
9031
|
+
return {
|
|
8987
9032
|
ok: true,
|
|
8988
|
-
confirm: verdict
|
|
8989
|
-
|
|
9033
|
+
...verdict !== null ? { confirm: verdict } : {},
|
|
9034
|
+
...artifactIds.length > 0 ? { artifactIds } : {}
|
|
9035
|
+
};
|
|
8990
9036
|
} catch (err) {
|
|
8991
9037
|
return {
|
|
8992
9038
|
ok: false,
|
|
@@ -9297,6 +9343,12 @@ var NcDispatcher = class {
|
|
|
9297
9343
|
});
|
|
9298
9344
|
return input.frozen ?? "";
|
|
9299
9345
|
}
|
|
9346
|
+
/**
|
|
9347
|
+
* Builds the notification AND returns the artefact ids its media resolve
|
|
9348
|
+
* indexed — see {@link withArtifactUrls}. A tuple rather than a field on
|
|
9349
|
+
* `NcNotificationInput`: the ids are internal (outbox provenance), never
|
|
9350
|
+
* part of what `notification-output` sends to a backend.
|
|
9351
|
+
*/
|
|
9300
9352
|
async buildNotification(entry, target) {
|
|
9301
9353
|
const subject = entry.payload.subject;
|
|
9302
9354
|
const systemEvent = subject.systemEvent;
|
|
@@ -9325,7 +9377,8 @@ var NcDispatcher = class {
|
|
|
9325
9377
|
vars: { body: rendered },
|
|
9326
9378
|
frozen: rendered
|
|
9327
9379
|
}) : rendered;
|
|
9328
|
-
const
|
|
9380
|
+
const resolvedMedia = await this.withArtifactUrls(entry, await this.resolveAttachments(entry));
|
|
9381
|
+
const attachments = [...resolvedMedia.attachments, ...frozenArtifactAttachments(entry)];
|
|
9329
9382
|
const params = pickParams(entry.payload.params);
|
|
9330
9383
|
const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
|
|
9331
9384
|
deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
|
|
@@ -9335,21 +9388,24 @@ var NcDispatcher = class {
|
|
|
9335
9388
|
}) : null;
|
|
9336
9389
|
const actions = await this.mintActions(entry, target);
|
|
9337
9390
|
return {
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
9391
|
+
notification: {
|
|
9392
|
+
body,
|
|
9393
|
+
title,
|
|
9394
|
+
priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
|
|
9395
|
+
...params,
|
|
9396
|
+
...tapUrl !== null ? { clickUrl: tapUrl } : {},
|
|
9397
|
+
tag: entry.payload.group?.key ?? entry.ruleId,
|
|
9398
|
+
...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
|
|
9399
|
+
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
9400
|
+
...systemEvent !== void 0 ? { metadata: {
|
|
9401
|
+
systemEventKind: systemEvent.kind,
|
|
9402
|
+
subject: systemEvent.subject,
|
|
9403
|
+
...systemEvent.data
|
|
9404
|
+
} } : {},
|
|
9405
|
+
...attachments.length > 0 ? { attachments } : {},
|
|
9406
|
+
...actions.length > 0 ? { actions } : {}
|
|
9407
|
+
},
|
|
9408
|
+
artifactIds: resolvedMedia.artifactIds
|
|
9353
9409
|
};
|
|
9354
9410
|
}
|
|
9355
9411
|
/**
|
|
@@ -9393,11 +9449,28 @@ var NcDispatcher = class {
|
|
|
9393
9449
|
}
|
|
9394
9450
|
});
|
|
9395
9451
|
}
|
|
9452
|
+
/**
|
|
9453
|
+
* What one delivery's media resolve produced: the attachments ready to ship
|
|
9454
|
+
* (bytes plus a signed url when the plane could mint one) and the ids the
|
|
9455
|
+
* artefact INDEX assigned them, in the same order — still first, then gif,
|
|
9456
|
+
* then clip. The ids never ride on the attachment objects themselves: those
|
|
9457
|
+
* cross to `notification-output` and a notifier backend has no use for an
|
|
9458
|
+
* internal store id, only for `url`/`bytes`.
|
|
9459
|
+
*
|
|
9460
|
+
* `artifactIds` is what lets a delivery's outbox row later answer "which
|
|
9461
|
+
* artefact was this trigger's picture" (`getHistory.artifactIds`) without a
|
|
9462
|
+
* second write path — the outbox writes back exactly the ids this method
|
|
9463
|
+
* handed to a SUCCESSFUL send, never re-deriving them.
|
|
9464
|
+
*/
|
|
9396
9465
|
async withArtifactUrls(entry, attachments) {
|
|
9397
9466
|
const index = this.deps.indexArtifact;
|
|
9398
9467
|
const publish = this.deps.publishArtifact;
|
|
9399
|
-
if (index === void 0 && publish === void 0) return
|
|
9468
|
+
if (index === void 0 && publish === void 0) return {
|
|
9469
|
+
attachments: [...attachments],
|
|
9470
|
+
artifactIds: []
|
|
9471
|
+
};
|
|
9400
9472
|
const out = [];
|
|
9473
|
+
const artifactIds = [];
|
|
9401
9474
|
let posterId = null;
|
|
9402
9475
|
const deviceId = entry.payload.subject.deviceId;
|
|
9403
9476
|
const common = {
|
|
@@ -9420,6 +9493,7 @@ var NcDispatcher = class {
|
|
|
9420
9493
|
artifactId: null
|
|
9421
9494
|
}));
|
|
9422
9495
|
if (kind === "event-still" && pub.artifactId !== null) posterId = pub.artifactId;
|
|
9496
|
+
if (pub.artifactId !== null) artifactIds.push(pub.artifactId);
|
|
9423
9497
|
out.push(pub.url === null ? { ...att } : {
|
|
9424
9498
|
...att,
|
|
9425
9499
|
url: pub.url
|
|
@@ -9432,7 +9506,10 @@ var NcDispatcher = class {
|
|
|
9432
9506
|
url
|
|
9433
9507
|
});
|
|
9434
9508
|
}
|
|
9435
|
-
return
|
|
9509
|
+
return {
|
|
9510
|
+
attachments: out,
|
|
9511
|
+
artifactIds
|
|
9512
|
+
};
|
|
9436
9513
|
}
|
|
9437
9514
|
/** The full attachment list: the policy still (zone-cropped when the rule
|
|
9438
9515
|
* froze zone ids) plus the footage cut from the broker's clip ring when
|
|
@@ -12536,13 +12613,17 @@ var NcOutbox = class {
|
|
|
12536
12613
|
}
|
|
12537
12614
|
const now = this.now();
|
|
12538
12615
|
if (result.ok) {
|
|
12616
|
+
const payloadPatch = {
|
|
12617
|
+
...result.confirm !== void 0 ? { confirmVerdict: result.confirm } : {},
|
|
12618
|
+
...result.artifactIds !== void 0 && result.artifactIds.length > 0 ? { artifactIds: result.artifactIds } : {}
|
|
12619
|
+
};
|
|
12539
12620
|
const sent = {
|
|
12540
12621
|
...entry,
|
|
12541
12622
|
status: "sent",
|
|
12542
12623
|
updatedAt: now,
|
|
12543
|
-
...
|
|
12624
|
+
...Object.keys(payloadPatch).length > 0 ? { payload: {
|
|
12544
12625
|
...entry.payload,
|
|
12545
|
-
|
|
12626
|
+
...payloadPatch
|
|
12546
12627
|
} } : {}
|
|
12547
12628
|
};
|
|
12548
12629
|
this.pending.delete(entry.id);
|
|
@@ -19561,7 +19642,8 @@ function outboxEntryToHistory(texts, entry) {
|
|
|
19561
19642
|
zones: [...s.zones],
|
|
19562
19643
|
timestamp: s.timestamp,
|
|
19563
19644
|
...s.systemEvent !== void 0 ? { systemEvent: historySystemEvent(texts, s.systemEvent) } : {}
|
|
19564
|
-
}
|
|
19645
|
+
},
|
|
19646
|
+
...entry.payload.artifactIds !== void 0 && entry.payload.artifactIds.length > 0 ? { artifactIds: [...entry.payload.artifactIds] } : {}
|
|
19565
19647
|
};
|
|
19566
19648
|
}
|
|
19567
19649
|
/** The watched question in one grep-able token. */
|
|
@@ -21100,6 +21182,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21100
21182
|
}
|
|
21101
21183
|
return { entries: mapped };
|
|
21102
21184
|
},
|
|
21185
|
+
resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
|
|
21103
21186
|
listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
|
|
21104
21187
|
setDeviceMuted: async ({ deviceId, muted }) => {
|
|
21105
21188
|
await this.deviceMutes.setMuted(deviceId, muted);
|
|
@@ -21109,10 +21192,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21109
21192
|
});
|
|
21110
21193
|
return { success: true };
|
|
21111
21194
|
},
|
|
21112
|
-
listSnoozes: async ({ caller }) =>
|
|
21113
|
-
|
|
21195
|
+
listSnoozes: async ({ caller }) => {
|
|
21196
|
+
const who = requireCapCaller("listSnoozes", caller);
|
|
21197
|
+
return { snoozes: [...this.snoozes.listVisibleTo(who.userId, who.isAdmin)] };
|
|
21198
|
+
},
|
|
21199
|
+
createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, requireCapCaller("createSnooze", caller)) }),
|
|
21114
21200
|
cancelSnooze: async ({ snoozeId, caller }) => {
|
|
21115
|
-
await this.cancelSnooze(snoozeId, caller);
|
|
21201
|
+
await this.cancelSnooze(snoozeId, requireCapCaller("cancelSnooze", caller));
|
|
21116
21202
|
return { success: true };
|
|
21117
21203
|
},
|
|
21118
21204
|
getAlarmConfig: async () => this.alarmConfig(),
|
|
@@ -26737,6 +26823,639 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
26737
26823
|
};
|
|
26738
26824
|
}
|
|
26739
26825
|
//#endregion
|
|
26826
|
+
//#region src/pipeline-analytics/store/face-sort.ts
|
|
26827
|
+
var DEFAULT_FACE_SORT = {
|
|
26828
|
+
by: "timestamp",
|
|
26829
|
+
direction: "desc"
|
|
26830
|
+
};
|
|
26831
|
+
/** Resolve a caller's partial request into a complete order. */
|
|
26832
|
+
function resolveFaceSort(input) {
|
|
26833
|
+
return {
|
|
26834
|
+
by: input?.sortBy ?? DEFAULT_FACE_SORT.by,
|
|
26835
|
+
direction: input?.sortDirection ?? DEFAULT_FACE_SORT.direction
|
|
26836
|
+
};
|
|
26837
|
+
}
|
|
26838
|
+
/**
|
|
26839
|
+
* True when the store's `timestamp`-indexed paging already emits rows in the
|
|
26840
|
+
* requested order, so a paged read may STOP as soon as `limit` rows have passed
|
|
26841
|
+
* the filter.
|
|
26842
|
+
*
|
|
26843
|
+
* False for `suggestionConfidence`: the most certain suggestion may be the
|
|
26844
|
+
* oldest row in the window, so an early stop would silently answer with the
|
|
26845
|
+
* newest N ranked — the exact defect this order was added to remove.
|
|
26846
|
+
*/
|
|
26847
|
+
function sortIsServedByTimestampPaging(order) {
|
|
26848
|
+
return order.by === "timestamp";
|
|
26849
|
+
}
|
|
26850
|
+
/** Certainty, or `null` when the row has none. Null and undefined are the same
|
|
26851
|
+
* answer — the relational backend surfaces an unset REAL column as SQL NULL. */
|
|
26852
|
+
function certaintyOf(face) {
|
|
26853
|
+
return face.suggestedMatchScore ?? null;
|
|
26854
|
+
}
|
|
26855
|
+
/** Newest first, then `id` ascending. The tail of every comparison. */
|
|
26856
|
+
function compareTiebreak(a, b) {
|
|
26857
|
+
if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
|
|
26858
|
+
if (a.id === b.id) return 0;
|
|
26859
|
+
return a.id < b.id ? -1 : 1;
|
|
26860
|
+
}
|
|
26861
|
+
function compareFaces(a, b, order) {
|
|
26862
|
+
if (order.by === "timestamp") {
|
|
26863
|
+
if (a.timestamp !== b.timestamp) return order.direction === "desc" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp;
|
|
26864
|
+
return compareTiebreak(a, b);
|
|
26865
|
+
}
|
|
26866
|
+
const aScore = certaintyOf(a);
|
|
26867
|
+
const bScore = certaintyOf(b);
|
|
26868
|
+
if (aScore === null && bScore !== null) return 1;
|
|
26869
|
+
if (aScore !== null && bScore === null) return -1;
|
|
26870
|
+
if (aScore !== null && bScore !== null && aScore !== bScore) return order.direction === "desc" ? bScore - aScore : aScore - bScore;
|
|
26871
|
+
return compareTiebreak(a, b);
|
|
26872
|
+
}
|
|
26873
|
+
/** Immutable sort — the input array is never reordered in place. */
|
|
26874
|
+
function sortFaces(faces, order) {
|
|
26875
|
+
return faces.toSorted((a, b) => compareFaces(a, b, order));
|
|
26876
|
+
}
|
|
26877
|
+
/**
|
|
26878
|
+
* Rank `faces` and keep at most `limit` of them.
|
|
26879
|
+
*
|
|
26880
|
+
* This is what lets the confidence order stay bounded: a paged sweep folds each
|
|
26881
|
+
* page in through here, so the retained set is the caller's `limit`, never the
|
|
26882
|
+
* corpus — even though the sweep itself has to walk the whole window.
|
|
26883
|
+
*/
|
|
26884
|
+
function rankAndCap(faces, order, limit) {
|
|
26885
|
+
const sorted = sortFaces(faces, order);
|
|
26886
|
+
return limit === void 0 ? sorted : sorted.slice(0, limit);
|
|
26887
|
+
}
|
|
26888
|
+
//#endregion
|
|
26889
|
+
//#region src/pipeline-analytics/store/face-store.ts
|
|
26890
|
+
/**
|
|
26891
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
26892
|
+
* write="one UPSERT per track that produced a face detail — the id is
|
|
26893
|
+
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
26894
|
+
* overwrites it as a better read lands (embedding, match cosine, crop keys).
|
|
26895
|
+
* The row is rewritten again when the operator enrols it, which flips
|
|
26896
|
+
* `assigned` and stamps the identity."
|
|
26897
|
+
* retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
|
|
26898
|
+
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
26899
|
+
* exempt from that and from every sweep — an assignment is a curation act.
|
|
26900
|
+
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
26901
|
+
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
|
|
26902
|
+
* of JSON embedding per row, that cap is what keeps the table small."
|
|
26903
|
+
*/
|
|
26904
|
+
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
26905
|
+
/**
|
|
26906
|
+
* Rows per page for the two whole-collection sweeps below.
|
|
26907
|
+
*
|
|
26908
|
+
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
26909
|
+
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
26910
|
+
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
26911
|
+
* collection in ONE statement with no `limit`.
|
|
26912
|
+
*
|
|
26913
|
+
* To be exact about what that did and did not cause: this table holds **372
|
|
26914
|
+
* rows / 4.0 MB** on the live hub, so it was *not* the read that killed
|
|
26915
|
+
* hub-main on 2026-08-07 (that was the multi-camera track feed — see
|
|
26916
|
+
* `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
|
|
26917
|
+
* grows with a table nothing else bounds, which is the same defect one incident
|
|
26918
|
+
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
26919
|
+
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
26920
|
+
* it were the same loop.
|
|
26921
|
+
*
|
|
26922
|
+
* The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
|
|
26923
|
+
* is not belt-and-braces: without it these two methods would silently see only
|
|
26924
|
+
* the newest N rows and the buffer would grow forever.
|
|
26925
|
+
*
|
|
26926
|
+
* 1 000 keeps peak transfer around 11 MB while staying under the engine's own
|
|
26927
|
+
* cap, so the page is always the caller's bound and never the engine's.
|
|
26928
|
+
*/
|
|
26929
|
+
var FACE_SWEEP_PAGE = 1e3;
|
|
26930
|
+
/**
|
|
26931
|
+
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
26932
|
+
* million faces — far past anything real, and reached only by a bug.
|
|
26933
|
+
*/
|
|
26934
|
+
var FACE_SWEEP_MAX_PAGES = 1e3;
|
|
26935
|
+
var FACE_COLUMNS = [
|
|
26936
|
+
{
|
|
26937
|
+
name: "id",
|
|
26938
|
+
type: "TEXT",
|
|
26939
|
+
primaryKey: true,
|
|
26940
|
+
notNull: true
|
|
26941
|
+
},
|
|
26942
|
+
{
|
|
26943
|
+
name: "deviceId",
|
|
26944
|
+
type: "INTEGER",
|
|
26945
|
+
notNull: true
|
|
26946
|
+
},
|
|
26947
|
+
{
|
|
26948
|
+
name: "trackId",
|
|
26949
|
+
type: "TEXT",
|
|
26950
|
+
notNull: true
|
|
26951
|
+
},
|
|
26952
|
+
{
|
|
26953
|
+
name: "timestamp",
|
|
26954
|
+
type: "INTEGER",
|
|
26955
|
+
notNull: true
|
|
26956
|
+
},
|
|
26957
|
+
{
|
|
26958
|
+
name: "embedding",
|
|
26959
|
+
type: "JSON",
|
|
26960
|
+
notNull: true
|
|
26961
|
+
},
|
|
26962
|
+
{
|
|
26963
|
+
name: "embeddingModelId",
|
|
26964
|
+
type: "TEXT"
|
|
26965
|
+
},
|
|
26966
|
+
{
|
|
26967
|
+
name: "mediaKey",
|
|
26968
|
+
type: "TEXT"
|
|
26969
|
+
},
|
|
26970
|
+
{
|
|
26971
|
+
name: "recognizedIdentityId",
|
|
26972
|
+
type: "TEXT"
|
|
26973
|
+
},
|
|
26974
|
+
{
|
|
26975
|
+
name: "assigned",
|
|
26976
|
+
type: "BOOLEAN",
|
|
26977
|
+
notNull: true
|
|
26978
|
+
},
|
|
26979
|
+
{
|
|
26980
|
+
name: "assignedSampleId",
|
|
26981
|
+
type: "TEXT"
|
|
26982
|
+
},
|
|
26983
|
+
{
|
|
26984
|
+
name: "keyFrameMediaKey",
|
|
26985
|
+
type: "TEXT"
|
|
26986
|
+
},
|
|
26987
|
+
{
|
|
26988
|
+
name: "faceBbox",
|
|
26989
|
+
type: "JSON"
|
|
26990
|
+
},
|
|
26991
|
+
{
|
|
26992
|
+
name: "bestMatchScore",
|
|
26993
|
+
type: "REAL"
|
|
26994
|
+
},
|
|
26995
|
+
{
|
|
26996
|
+
name: "nativeFaceShortSidePx",
|
|
26997
|
+
type: "REAL"
|
|
26998
|
+
},
|
|
26999
|
+
{
|
|
27000
|
+
name: "suggestedIdentityId",
|
|
27001
|
+
type: "TEXT"
|
|
27002
|
+
},
|
|
27003
|
+
{
|
|
27004
|
+
name: "suggestedMatchScore",
|
|
27005
|
+
type: "REAL"
|
|
27006
|
+
}
|
|
27007
|
+
];
|
|
27008
|
+
var FACE_INDEXES = [{
|
|
27009
|
+
name: "idx_faces_device_ts",
|
|
27010
|
+
columns: ["deviceId", "timestamp"]
|
|
27011
|
+
}, {
|
|
27012
|
+
name: "idx_faces_track",
|
|
27013
|
+
columns: ["trackId"]
|
|
27014
|
+
}];
|
|
27015
|
+
/**
|
|
27016
|
+
* `since` / `until` as a store predicate.
|
|
27017
|
+
*
|
|
27018
|
+
* The filter surface has exactly one range form, `whereBetween`, and it is
|
|
27019
|
+
* inclusive on both ends — so a one-sided window is expressed by pinning the
|
|
27020
|
+
* other end to the edge of the safe integer range rather than by omitting it.
|
|
27021
|
+
* Returns `{}` when the caller named no window, so the read stays exactly the
|
|
27022
|
+
* unwindowed one.
|
|
27023
|
+
*/
|
|
27024
|
+
function buildTimestampWindow(input) {
|
|
27025
|
+
if (input.since === void 0 && input.until === void 0) return {};
|
|
27026
|
+
return { whereBetween: { timestamp: [input.since ?? Number.MIN_SAFE_INTEGER, input.until ?? Number.MAX_SAFE_INTEGER] } };
|
|
27027
|
+
}
|
|
27028
|
+
/**
|
|
27029
|
+
* One stored row → one {@link Face}.
|
|
27030
|
+
*
|
|
27031
|
+
* The single documented type boundary of this store: `SettingsRecord.data` is
|
|
27032
|
+
* `Record<string, unknown>` on the wire and the column set is declared right
|
|
27033
|
+
* here in {@link FACE_COLUMNS}, so this is the one place allowed to name the
|
|
27034
|
+
* shape it wrote.
|
|
27035
|
+
*
|
|
27036
|
+
* It also owns the NULL normalisation, and that is not cosmetic: the relational
|
|
27037
|
+
* backend surfaces an unset optional column as SQL NULL, and `FaceInfo`'s
|
|
27038
|
+
* optional strings REJECT null — an un-normalised row fails
|
|
27039
|
+
* `faceGallery.listRecentFaces` output validation and the People page renders
|
|
27040
|
+
* empty. This used to be copied out three times, which is three chances to
|
|
27041
|
+
* forget the next additive column.
|
|
27042
|
+
*/
|
|
27043
|
+
function rowToFace(id, raw) {
|
|
27044
|
+
const data = raw;
|
|
27045
|
+
return {
|
|
27046
|
+
...data,
|
|
27047
|
+
id,
|
|
27048
|
+
assigned: Boolean(data.assigned),
|
|
27049
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
27050
|
+
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
27051
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
27052
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
27053
|
+
faceBbox: data.faceBbox ?? void 0,
|
|
27054
|
+
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
27055
|
+
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
27056
|
+
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
27057
|
+
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
27058
|
+
};
|
|
27059
|
+
}
|
|
27060
|
+
var FaceStore = class {
|
|
27061
|
+
store;
|
|
27062
|
+
logger;
|
|
27063
|
+
constructor(deps) {
|
|
27064
|
+
this.store = deps.store;
|
|
27065
|
+
this.logger = deps.logger;
|
|
27066
|
+
}
|
|
27067
|
+
static async declare(store) {
|
|
27068
|
+
await store.declareCollection.mutate({
|
|
27069
|
+
collection: FACES_COLLECTION,
|
|
27070
|
+
columns: [...FACE_COLUMNS],
|
|
27071
|
+
indexes: [...FACE_INDEXES]
|
|
27072
|
+
});
|
|
27073
|
+
}
|
|
27074
|
+
/**
|
|
27075
|
+
* Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
|
|
27076
|
+
* id per track → one entry per track). `set` replaces an existing row.
|
|
27077
|
+
*/
|
|
27078
|
+
async insert(face) {
|
|
27079
|
+
const { id, ...rest } = face;
|
|
27080
|
+
try {
|
|
27081
|
+
await this.store.set.mutate({
|
|
27082
|
+
collection: FACES_COLLECTION,
|
|
27083
|
+
key: id,
|
|
27084
|
+
value: rest
|
|
27085
|
+
});
|
|
27086
|
+
} catch (err) {
|
|
27087
|
+
this.logger.warn("FaceStore.insert failed", {
|
|
27088
|
+
tags: { deviceId: face.deviceId },
|
|
27089
|
+
meta: {
|
|
27090
|
+
faceId: id,
|
|
27091
|
+
error: String(err)
|
|
27092
|
+
}
|
|
27093
|
+
});
|
|
27094
|
+
}
|
|
27095
|
+
}
|
|
27096
|
+
/**
|
|
27097
|
+
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
27098
|
+
*
|
|
27099
|
+
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
27100
|
+
* SQLite backend (which returns INTEGER 0/1) is handled correctly.
|
|
27101
|
+
*/
|
|
27102
|
+
async listRecentFaces(input) {
|
|
27103
|
+
const where = {};
|
|
27104
|
+
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
27105
|
+
return (await this.store.query.query({
|
|
27106
|
+
collection: FACES_COLLECTION,
|
|
27107
|
+
filter: {
|
|
27108
|
+
where,
|
|
27109
|
+
orderBy: {
|
|
27110
|
+
field: "timestamp",
|
|
27111
|
+
direction: "desc"
|
|
27112
|
+
},
|
|
27113
|
+
limit: input.limit ?? 100
|
|
27114
|
+
}
|
|
27115
|
+
})).map((r) => rowToFace(r.id, r.data)).filter((f) => !f.assigned);
|
|
27116
|
+
}
|
|
27117
|
+
/**
|
|
27118
|
+
* The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
|
|
27119
|
+
* here is GONE (D61). It had no production caller — only tests — and it was
|
|
27120
|
+
* an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
|
|
27121
|
+
* retention model now forbids. A leftover that describes the wrong design
|
|
27122
|
+
* still reads as permission to use it.
|
|
27123
|
+
*/
|
|
27124
|
+
/**
|
|
27125
|
+
* Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
|
|
27126
|
+
*
|
|
27127
|
+
* **This is a CAPACITY bound, not an age one.** It used to be both: a face
|
|
27128
|
+
* was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
|
|
27129
|
+
* The age half was a fifth clock over a track-OWNED entity — a face row
|
|
27130
|
+
* carries a `trackId`, so it belongs to its track and cascades with it — and
|
|
27131
|
+
* it aged crops out from under tracks that were still well inside their own
|
|
27132
|
+
* retention window. It is gone (D61). What remains is the ring-buffer
|
|
27133
|
+
* property the recognizer actually depends on: the gallery of recent
|
|
27134
|
+
* unassigned faces stays small.
|
|
27135
|
+
*
|
|
27136
|
+
* ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
|
|
27137
|
+
* slots. Returns all deleted ids (the caller removes their crops).
|
|
27138
|
+
*/
|
|
27139
|
+
async pruneCapOverflow(input) {
|
|
27140
|
+
/**
|
|
27141
|
+
* Paged, not one statement. Only three scalars per row survive the page —
|
|
27142
|
+
* the `embedding` string that dominates the row is dropped as soon as the
|
|
27143
|
+
* page goes out of scope, so peak heap is one page rather than the whole
|
|
27144
|
+
* face table. The prune still considers EVERY row, which is what makes it a
|
|
27145
|
+
* capacity bound rather than a bound on the newest page.
|
|
27146
|
+
*/
|
|
27147
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
27148
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27149
|
+
const rows = await this.store.query.query({
|
|
27150
|
+
collection: FACES_COLLECTION,
|
|
27151
|
+
filter: {
|
|
27152
|
+
orderBy: {
|
|
27153
|
+
field: "timestamp",
|
|
27154
|
+
direction: "desc"
|
|
27155
|
+
},
|
|
27156
|
+
limit: FACE_SWEEP_PAGE,
|
|
27157
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27158
|
+
}
|
|
27159
|
+
});
|
|
27160
|
+
if (rows.length === 0) break;
|
|
27161
|
+
for (const r of rows) {
|
|
27162
|
+
const data = r.data;
|
|
27163
|
+
if (data.assigned) continue;
|
|
27164
|
+
const deviceId = Number(data.deviceId ?? -1);
|
|
27165
|
+
const list = byDevice.get(deviceId) ?? [];
|
|
27166
|
+
list.push({
|
|
27167
|
+
id: r.id,
|
|
27168
|
+
timestamp: Number(data.timestamp ?? 0)
|
|
27169
|
+
});
|
|
27170
|
+
byDevice.set(deviceId, list);
|
|
27171
|
+
}
|
|
27172
|
+
if (rows.length < 1e3) break;
|
|
27173
|
+
}
|
|
27174
|
+
const toDelete = /* @__PURE__ */ new Set();
|
|
27175
|
+
for (const list of byDevice.values()) {
|
|
27176
|
+
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
27177
|
+
list.forEach((row, idx) => {
|
|
27178
|
+
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
27179
|
+
});
|
|
27180
|
+
}
|
|
27181
|
+
const deleted = [];
|
|
27182
|
+
for (const id of toDelete) try {
|
|
27183
|
+
await this.store.delete.mutate({
|
|
27184
|
+
collection: FACES_COLLECTION,
|
|
27185
|
+
key: id
|
|
27186
|
+
});
|
|
27187
|
+
deleted.push(id);
|
|
27188
|
+
} catch (err) {
|
|
27189
|
+
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
27190
|
+
faceId: id,
|
|
27191
|
+
error: String(err)
|
|
27192
|
+
} });
|
|
27193
|
+
}
|
|
27194
|
+
return deleted;
|
|
27195
|
+
}
|
|
27196
|
+
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
27197
|
+
async markAssigned(faceId, identityId, sampleId) {
|
|
27198
|
+
const data = {
|
|
27199
|
+
assigned: true,
|
|
27200
|
+
recognizedIdentityId: identityId
|
|
27201
|
+
};
|
|
27202
|
+
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
27203
|
+
try {
|
|
27204
|
+
await this.store.update.mutate({
|
|
27205
|
+
collection: FACES_COLLECTION,
|
|
27206
|
+
id: faceId,
|
|
27207
|
+
data
|
|
27208
|
+
});
|
|
27209
|
+
} catch (err) {
|
|
27210
|
+
this.logger.warn("FaceStore.markAssigned failed", { meta: {
|
|
27211
|
+
faceId,
|
|
27212
|
+
error: String(err)
|
|
27213
|
+
} });
|
|
27214
|
+
}
|
|
27215
|
+
}
|
|
27216
|
+
/** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
|
|
27217
|
+
async clearAssignment(faceId) {
|
|
27218
|
+
try {
|
|
27219
|
+
await this.store.update.mutate({
|
|
27220
|
+
collection: FACES_COLLECTION,
|
|
27221
|
+
id: faceId,
|
|
27222
|
+
data: {
|
|
27223
|
+
assigned: false,
|
|
27224
|
+
recognizedIdentityId: null,
|
|
27225
|
+
assignedSampleId: null
|
|
27226
|
+
}
|
|
27227
|
+
});
|
|
27228
|
+
} catch (err) {
|
|
27229
|
+
this.logger.warn("FaceStore.clearAssignment failed", { meta: {
|
|
27230
|
+
faceId,
|
|
27231
|
+
error: String(err)
|
|
27232
|
+
} });
|
|
27233
|
+
}
|
|
27234
|
+
}
|
|
27235
|
+
/**
|
|
27236
|
+
* Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
|
|
27237
|
+
* write that explicitly-requested action makes. `suggestion: null` means
|
|
27238
|
+
* the recompute found no candidate worth offering and CLEARS both columns
|
|
27239
|
+
* (a genuine "the gallery has nothing for this face right now" result, not
|
|
27240
|
+
* a no-op left at whatever they held before). Never touches `assigned` /
|
|
27241
|
+
* `recognizedIdentityId` — a suggestion is never an assignment. The caller
|
|
27242
|
+
* is responsible for never calling this with a non-comparable (`model-
|
|
27243
|
+
* mismatch`) report; this method persists whatever it is given.
|
|
27244
|
+
*/
|
|
27245
|
+
async setSuggestion(faceId, suggestion) {
|
|
27246
|
+
const data = suggestion === null ? {
|
|
27247
|
+
suggestedIdentityId: null,
|
|
27248
|
+
suggestedMatchScore: null
|
|
27249
|
+
} : {
|
|
27250
|
+
suggestedIdentityId: suggestion.identityId,
|
|
27251
|
+
suggestedMatchScore: suggestion.score
|
|
27252
|
+
};
|
|
27253
|
+
try {
|
|
27254
|
+
await this.store.update.mutate({
|
|
27255
|
+
collection: FACES_COLLECTION,
|
|
27256
|
+
id: faceId,
|
|
27257
|
+
data
|
|
27258
|
+
});
|
|
27259
|
+
} catch (err) {
|
|
27260
|
+
this.logger.warn("FaceStore.setSuggestion failed", { meta: {
|
|
27261
|
+
faceId,
|
|
27262
|
+
error: String(err)
|
|
27263
|
+
} });
|
|
27264
|
+
}
|
|
27265
|
+
}
|
|
27266
|
+
/**
|
|
27267
|
+
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
27268
|
+
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
27269
|
+
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
27270
|
+
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
27271
|
+
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
27272
|
+
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
27273
|
+
*/
|
|
27274
|
+
async deleteByTracks(trackIds) {
|
|
27275
|
+
let removed = 0;
|
|
27276
|
+
for (const trackId of trackIds) try {
|
|
27277
|
+
const eligible = (await this.store.query.query({
|
|
27278
|
+
collection: FACES_COLLECTION,
|
|
27279
|
+
filter: { where: { trackId } }
|
|
27280
|
+
})).filter((r) => !r.data.assigned);
|
|
27281
|
+
for (const row of eligible) try {
|
|
27282
|
+
await this.store.delete.mutate({
|
|
27283
|
+
collection: FACES_COLLECTION,
|
|
27284
|
+
key: row.id
|
|
27285
|
+
});
|
|
27286
|
+
removed++;
|
|
27287
|
+
} catch (err) {
|
|
27288
|
+
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
27289
|
+
faceId: row.id,
|
|
27290
|
+
error: String(err)
|
|
27291
|
+
} });
|
|
27292
|
+
}
|
|
27293
|
+
} catch (err) {
|
|
27294
|
+
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
27295
|
+
trackId,
|
|
27296
|
+
error: String(err)
|
|
27297
|
+
} });
|
|
27298
|
+
}
|
|
27299
|
+
return [{
|
|
27300
|
+
family: "faces",
|
|
27301
|
+
rows: removed,
|
|
27302
|
+
bytes: 0
|
|
27303
|
+
}];
|
|
27304
|
+
}
|
|
27305
|
+
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
27306
|
+
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
27307
|
+
async delete(faceId) {
|
|
27308
|
+
try {
|
|
27309
|
+
await this.store.delete.mutate({
|
|
27310
|
+
collection: FACES_COLLECTION,
|
|
27311
|
+
key: faceId
|
|
27312
|
+
});
|
|
27313
|
+
} catch (err) {
|
|
27314
|
+
this.logger.warn("FaceStore.delete failed", { meta: {
|
|
27315
|
+
faceId,
|
|
27316
|
+
error: String(err)
|
|
27317
|
+
} });
|
|
27318
|
+
}
|
|
27319
|
+
}
|
|
27320
|
+
/**
|
|
27321
|
+
* Retrieve a single face by id. Returns null if not found.
|
|
27322
|
+
*/
|
|
27323
|
+
async get(faceId) {
|
|
27324
|
+
const raw = await this.store.get.query({
|
|
27325
|
+
collection: FACES_COLLECTION,
|
|
27326
|
+
key: faceId
|
|
27327
|
+
});
|
|
27328
|
+
if (!raw) return null;
|
|
27329
|
+
return rowToFace(faceId, raw);
|
|
27330
|
+
}
|
|
27331
|
+
/**
|
|
27332
|
+
* Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
|
|
27333
|
+
* Not exposed publicly — intended for internal use and tests only.
|
|
27334
|
+
*/
|
|
27335
|
+
async update(faceId, data) {
|
|
27336
|
+
try {
|
|
27337
|
+
await this.store.update.mutate({
|
|
27338
|
+
collection: FACES_COLLECTION,
|
|
27339
|
+
id: faceId,
|
|
27340
|
+
data
|
|
27341
|
+
});
|
|
27342
|
+
} catch (err) {
|
|
27343
|
+
this.logger.warn("FaceStore.update failed", { meta: {
|
|
27344
|
+
faceId,
|
|
27345
|
+
error: String(err)
|
|
27346
|
+
} });
|
|
27347
|
+
}
|
|
27348
|
+
}
|
|
27349
|
+
/**
|
|
27350
|
+
* List recent faces across the cluster, or across a named set of cameras.
|
|
27351
|
+
*
|
|
27352
|
+
* filter:
|
|
27353
|
+
* - 'all' (default) → every row
|
|
27354
|
+
* - 'unassigned' → !assigned
|
|
27355
|
+
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
27356
|
+
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
27357
|
+
*
|
|
27358
|
+
* `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
|
|
27359
|
+
* what is read, not what is returned. `filter` and the ordering cannot be
|
|
27360
|
+
* (the first is a computed predicate, the second is a contract this backend
|
|
27361
|
+
* does not implement — see `face-sort.ts`), so they are applied here.
|
|
27362
|
+
*
|
|
27363
|
+
* **`limit` counts rows that PASSED the filter, never rows that were
|
|
27364
|
+
* discarded.** That is the whole reason this pages instead of asking the
|
|
27365
|
+
* store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
|
|
27366
|
+
* entirely assigned while matches wait just beyond them.
|
|
27367
|
+
*
|
|
27368
|
+
* The previous answer was to fetch the WHOLE collection and slice after
|
|
27369
|
+
* filtering. That is the read that killed hub-main (`query-bounds.ts`) —
|
|
27370
|
+
* every face row carries a JSON `embedding`, so "all rows" is the widest
|
|
27371
|
+
* table in the database materialised at once, to render a gallery of at most
|
|
27372
|
+
* a few hundred tiles.
|
|
27373
|
+
*
|
|
27374
|
+
* So: PAGE. Under the default `timestamp` order the store's paging already
|
|
27375
|
+
* emits rows in the answer's order, so the sweep stops the moment enough
|
|
27376
|
+
* MATCHES exist and the common case costs one page. Under
|
|
27377
|
+
* `suggestionConfidence` it cannot — the most certain suggestion may be the
|
|
27378
|
+
* oldest row — so the sweep walks the window and folds each page through
|
|
27379
|
+
* `rankAndCap`, which holds the RETAINED set to `limit` even though the read
|
|
27380
|
+
* is wider. Narrow the read with `since` / `until`.
|
|
27381
|
+
*
|
|
27382
|
+
* An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
|
|
27383
|
+
* cameras, not an omission (the `deviceManager.listFleet` contract).
|
|
27384
|
+
*/
|
|
27385
|
+
async listAllRecentFaces(input) {
|
|
27386
|
+
if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
|
|
27387
|
+
const order = resolveFaceSort(input);
|
|
27388
|
+
const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
|
|
27389
|
+
const filterMode = input.filter ?? "all";
|
|
27390
|
+
const matches = (f) => {
|
|
27391
|
+
if (filterMode === "unassigned") return !f.assigned;
|
|
27392
|
+
if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
|
|
27393
|
+
if (filterMode === "identified") return f.recognizedIdentityId != null;
|
|
27394
|
+
return true;
|
|
27395
|
+
};
|
|
27396
|
+
const storeFilter = {
|
|
27397
|
+
...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
|
|
27398
|
+
...buildTimestampWindow(input),
|
|
27399
|
+
orderBy: {
|
|
27400
|
+
field: "timestamp",
|
|
27401
|
+
direction: pagingServesTheOrder ? order.direction : "desc"
|
|
27402
|
+
}
|
|
27403
|
+
};
|
|
27404
|
+
let result = [];
|
|
27405
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27406
|
+
const rows = await this.store.query.query({
|
|
27407
|
+
collection: FACES_COLLECTION,
|
|
27408
|
+
filter: {
|
|
27409
|
+
...storeFilter,
|
|
27410
|
+
limit: FACE_SWEEP_PAGE,
|
|
27411
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27412
|
+
}
|
|
27413
|
+
});
|
|
27414
|
+
if (rows.length === 0) break;
|
|
27415
|
+
const matched = [];
|
|
27416
|
+
for (const r of rows) {
|
|
27417
|
+
const face = rowToFace(r.id, r.data);
|
|
27418
|
+
if (matches(face)) matched.push(face);
|
|
27419
|
+
}
|
|
27420
|
+
if (pagingServesTheOrder) {
|
|
27421
|
+
const merged = [...result, ...matched];
|
|
27422
|
+
if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
|
|
27423
|
+
result = merged;
|
|
27424
|
+
} else result = rankAndCap([...result, ...matched], order, input.limit);
|
|
27425
|
+
if (rows.length < 1e3) break;
|
|
27426
|
+
}
|
|
27427
|
+
return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
|
|
27428
|
+
}
|
|
27429
|
+
/** One real face row per camera currently attributed to an identity. */
|
|
27430
|
+
async listCameraRefsForIdentity(identityId) {
|
|
27431
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
27432
|
+
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
27433
|
+
const rows = await this.store.query.query({
|
|
27434
|
+
collection: FACES_COLLECTION,
|
|
27435
|
+
filter: {
|
|
27436
|
+
where: { recognizedIdentityId: identityId },
|
|
27437
|
+
orderBy: {
|
|
27438
|
+
field: "timestamp",
|
|
27439
|
+
direction: "desc"
|
|
27440
|
+
},
|
|
27441
|
+
limit: FACE_SWEEP_PAGE,
|
|
27442
|
+
offset: page * FACE_SWEEP_PAGE
|
|
27443
|
+
}
|
|
27444
|
+
});
|
|
27445
|
+
if (rows.length === 0) break;
|
|
27446
|
+
for (const row of rows) {
|
|
27447
|
+
const deviceId = Number(row.data["deviceId"]);
|
|
27448
|
+
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
27449
|
+
}
|
|
27450
|
+
if (rows.length < 1e3) break;
|
|
27451
|
+
}
|
|
27452
|
+
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
27453
|
+
deviceId,
|
|
27454
|
+
faceId
|
|
27455
|
+
}));
|
|
27456
|
+
}
|
|
27457
|
+
};
|
|
27458
|
+
//#endregion
|
|
26740
27459
|
//#region src/pipeline-analytics/face-gallery-provider.ts
|
|
26741
27460
|
/** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
|
|
26742
27461
|
var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
@@ -26750,6 +27469,36 @@ var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
|
26750
27469
|
* stalled pipeline.
|
|
26751
27470
|
*/
|
|
26752
27471
|
var FACE_CROP_FETCH_CONCURRENCY = 16;
|
|
27472
|
+
/**
|
|
27473
|
+
* How many unassigned faces `suggestFaceClusters` reads when the caller names
|
|
27474
|
+
* no {@link SuggestFaceClustersInput.maxFacesScanned}.
|
|
27475
|
+
*
|
|
27476
|
+
* Clustering used to read EVERY unassigned face on the hub, unconditionally:
|
|
27477
|
+
* its only bound (`limit`) cut the finished clusters, so it shortened the
|
|
27478
|
+
* ANSWER and never the work. A UI showing a window of 100 paid for a scan of
|
|
27479
|
+
* the whole corpus — on an addon whose disk is under contention — and no caller
|
|
27480
|
+
* had any way to ask for less.
|
|
27481
|
+
*
|
|
27482
|
+
* `FACE_SWEEP_PAGE` is chosen on purpose rather than a round number: it is
|
|
27483
|
+
* exactly ONE store page, so on the live corpus (372 face rows) the default
|
|
27484
|
+
* reads what it read before, one page, and nothing about today's answers
|
|
27485
|
+
* changes. What changes is the ceiling — the unbounded scan cannot come back as
|
|
27486
|
+
* the table grows.
|
|
27487
|
+
*/
|
|
27488
|
+
var SUGGEST_CLUSTERS_DEFAULT_FACE_POOL = FACE_SWEEP_PAGE;
|
|
27489
|
+
/**
|
|
27490
|
+
* The camera set a face listing is scoped to, as ONE value.
|
|
27491
|
+
*
|
|
27492
|
+
* `deviceIds` is the authority whenever it is present — including when it is
|
|
27493
|
+
* EMPTY, which reads nothing (a request for no cameras is a request, not an
|
|
27494
|
+
* omission). `deviceId` is the legacy single-camera form and only applies when
|
|
27495
|
+
* no set was named, so the two can never disagree about the answer.
|
|
27496
|
+
*/
|
|
27497
|
+
function resolveFaceDeviceScope(input) {
|
|
27498
|
+
if (input?.deviceIds !== void 0) return { deviceIds: input.deviceIds };
|
|
27499
|
+
if (input?.deviceId !== void 0) return { deviceIds: [input.deviceId] };
|
|
27500
|
+
return {};
|
|
27501
|
+
}
|
|
26753
27502
|
var FaceGalleryProvider = class {
|
|
26754
27503
|
identityStore;
|
|
26755
27504
|
faceStore;
|
|
@@ -26885,9 +27634,13 @@ var FaceGalleryProvider = class {
|
|
|
26885
27634
|
}
|
|
26886
27635
|
async listRecentFaces(input) {
|
|
26887
27636
|
const faces = await this.faceStore.listAllRecentFaces({
|
|
26888
|
-
|
|
27637
|
+
...resolveFaceDeviceScope(input),
|
|
26889
27638
|
limit: input?.limit,
|
|
26890
|
-
filter: input?.filter
|
|
27639
|
+
filter: input?.filter,
|
|
27640
|
+
since: input?.since,
|
|
27641
|
+
until: input?.until,
|
|
27642
|
+
sortBy: input?.sortBy,
|
|
27643
|
+
sortDirection: input?.sortDirection
|
|
26891
27644
|
});
|
|
26892
27645
|
const identities = await this.identityStore.listIdentities();
|
|
26893
27646
|
const nameMap = new Map(identities.map((i) => [i.id, i.name]));
|
|
@@ -27143,16 +27896,34 @@ var FaceGalleryProvider = class {
|
|
|
27143
27896
|
* Suggest clusters of look-alike UNASSIGNED faces (greedy cosine on their
|
|
27144
27897
|
* embeddings) so the operator can batch-assign a whole group. Embeddings stay
|
|
27145
27898
|
* server-side; only faceId groupings are returned.
|
|
27899
|
+
*
|
|
27900
|
+
* TWO bounds, and they cut different things — which is the whole point of the
|
|
27901
|
+
* rename:
|
|
27902
|
+
* - `maxFacesScanned` bounds the POOL, i.e. the work. Newest unassigned
|
|
27903
|
+
* faces first, default {@link SUGGEST_CLUSTERS_DEFAULT_FACE_POOL}.
|
|
27904
|
+
* - `maxClusters` (formerly the ambiguous `limit`) bounds the ANSWER.
|
|
27905
|
+
* It has never made this method read one row less.
|
|
27146
27906
|
*/
|
|
27147
27907
|
async suggestFaceClusters(input) {
|
|
27148
27908
|
const threshold = input?.threshold ?? .5;
|
|
27149
27909
|
const minClusterSize = input?.minClusterSize ?? 2;
|
|
27150
|
-
|
|
27910
|
+
const maxFacesScanned = input?.maxFacesScanned ?? SUGGEST_CLUSTERS_DEFAULT_FACE_POOL;
|
|
27911
|
+
const maxClusters = input?.maxClusters ?? input?.limit;
|
|
27912
|
+
const faces = await this.faceStore.listAllRecentFaces({
|
|
27913
|
+
filter: "unassigned",
|
|
27914
|
+
limit: maxFacesScanned
|
|
27915
|
+
});
|
|
27916
|
+
if (faces.length >= maxFacesScanned) this.logger.info("suggestFaceClusters pool saturated — older faces not considered", { meta: {
|
|
27917
|
+
maxFacesScanned,
|
|
27918
|
+
scanned: faces.length,
|
|
27919
|
+
threshold,
|
|
27920
|
+
minClusterSize
|
|
27921
|
+
} });
|
|
27922
|
+
const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
|
|
27151
27923
|
id: f.id,
|
|
27152
27924
|
embedding: f.embedding
|
|
27153
27925
|
})), threshold).filter((c) => c.size >= minClusterSize);
|
|
27154
|
-
|
|
27155
|
-
return clusters;
|
|
27926
|
+
return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
|
|
27156
27927
|
}
|
|
27157
27928
|
};
|
|
27158
27929
|
//#endregion
|
|
@@ -47356,553 +48127,6 @@ function stripNulls$1(data) {
|
|
|
47356
48127
|
return out;
|
|
47357
48128
|
}
|
|
47358
48129
|
//#endregion
|
|
47359
|
-
//#region src/pipeline-analytics/store/face-store.ts
|
|
47360
|
-
/**
|
|
47361
|
-
* @durable class=ledger owner=pipeline-analytics
|
|
47362
|
-
* write="one UPSERT per track that produced a face detail — the id is
|
|
47363
|
-
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
47364
|
-
* overwrites it as a better read lands (embedding, match cosine, crop keys).
|
|
47365
|
-
* The row is rewritten again when the operator enrols it, which flips
|
|
47366
|
-
* `assigned` and stamps the identity."
|
|
47367
|
-
* retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
|
|
47368
|
-
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
47369
|
-
* exempt from that and from every sweep — an assignment is a curation act.
|
|
47370
|
-
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
47371
|
-
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
|
|
47372
|
-
* of JSON embedding per row, that cap is what keeps the table small."
|
|
47373
|
-
*/
|
|
47374
|
-
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
47375
|
-
/**
|
|
47376
|
-
* Rows per page for the two whole-collection sweeps below.
|
|
47377
|
-
*
|
|
47378
|
-
* A face row is one of the two WIDEST row shapes in the hub's database: it
|
|
47379
|
-
* carries a JSON `embedding` on every single row, measured at **10.8 KB
|
|
47380
|
-
* average** where a motion event is 78 bytes. Both sweeps used to read the
|
|
47381
|
-
* collection in ONE statement with no `limit`.
|
|
47382
|
-
*
|
|
47383
|
-
* To be exact about what that did and did not cause: this table holds **372
|
|
47384
|
-
* rows / 4.0 MB** on the live hub, so it was *not* the read that killed
|
|
47385
|
-
* hub-main on 2026-08-07 (that was the multi-camera track feed — see
|
|
47386
|
-
* `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
|
|
47387
|
-
* grows with a table nothing else bounds, which is the same defect one incident
|
|
47388
|
-
* earlier. `pruneCapOverflow` is what keeps the row count small — and it was
|
|
47389
|
-
* itself one of the two unbounded readers, so the bound and the thing enforcing
|
|
47390
|
-
* it were the same loop.
|
|
47391
|
-
*
|
|
47392
|
-
* The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
|
|
47393
|
-
* is not belt-and-braces: without it these two methods would silently see only
|
|
47394
|
-
* the newest N rows and the buffer would grow forever.
|
|
47395
|
-
*
|
|
47396
|
-
* 1 000 keeps peak transfer around 11 MB while staying under the engine's own
|
|
47397
|
-
* cap, so the page is always the caller's bound and never the engine's.
|
|
47398
|
-
*/
|
|
47399
|
-
var FACE_SWEEP_PAGE = 1e3;
|
|
47400
|
-
/**
|
|
47401
|
-
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
47402
|
-
* million faces — far past anything real, and reached only by a bug.
|
|
47403
|
-
*/
|
|
47404
|
-
var FACE_SWEEP_MAX_PAGES = 1e3;
|
|
47405
|
-
var FACE_COLUMNS = [
|
|
47406
|
-
{
|
|
47407
|
-
name: "id",
|
|
47408
|
-
type: "TEXT",
|
|
47409
|
-
primaryKey: true,
|
|
47410
|
-
notNull: true
|
|
47411
|
-
},
|
|
47412
|
-
{
|
|
47413
|
-
name: "deviceId",
|
|
47414
|
-
type: "INTEGER",
|
|
47415
|
-
notNull: true
|
|
47416
|
-
},
|
|
47417
|
-
{
|
|
47418
|
-
name: "trackId",
|
|
47419
|
-
type: "TEXT",
|
|
47420
|
-
notNull: true
|
|
47421
|
-
},
|
|
47422
|
-
{
|
|
47423
|
-
name: "timestamp",
|
|
47424
|
-
type: "INTEGER",
|
|
47425
|
-
notNull: true
|
|
47426
|
-
},
|
|
47427
|
-
{
|
|
47428
|
-
name: "embedding",
|
|
47429
|
-
type: "JSON",
|
|
47430
|
-
notNull: true
|
|
47431
|
-
},
|
|
47432
|
-
{
|
|
47433
|
-
name: "embeddingModelId",
|
|
47434
|
-
type: "TEXT"
|
|
47435
|
-
},
|
|
47436
|
-
{
|
|
47437
|
-
name: "mediaKey",
|
|
47438
|
-
type: "TEXT"
|
|
47439
|
-
},
|
|
47440
|
-
{
|
|
47441
|
-
name: "recognizedIdentityId",
|
|
47442
|
-
type: "TEXT"
|
|
47443
|
-
},
|
|
47444
|
-
{
|
|
47445
|
-
name: "assigned",
|
|
47446
|
-
type: "BOOLEAN",
|
|
47447
|
-
notNull: true
|
|
47448
|
-
},
|
|
47449
|
-
{
|
|
47450
|
-
name: "assignedSampleId",
|
|
47451
|
-
type: "TEXT"
|
|
47452
|
-
},
|
|
47453
|
-
{
|
|
47454
|
-
name: "keyFrameMediaKey",
|
|
47455
|
-
type: "TEXT"
|
|
47456
|
-
},
|
|
47457
|
-
{
|
|
47458
|
-
name: "faceBbox",
|
|
47459
|
-
type: "JSON"
|
|
47460
|
-
},
|
|
47461
|
-
{
|
|
47462
|
-
name: "bestMatchScore",
|
|
47463
|
-
type: "REAL"
|
|
47464
|
-
},
|
|
47465
|
-
{
|
|
47466
|
-
name: "nativeFaceShortSidePx",
|
|
47467
|
-
type: "REAL"
|
|
47468
|
-
},
|
|
47469
|
-
{
|
|
47470
|
-
name: "suggestedIdentityId",
|
|
47471
|
-
type: "TEXT"
|
|
47472
|
-
},
|
|
47473
|
-
{
|
|
47474
|
-
name: "suggestedMatchScore",
|
|
47475
|
-
type: "REAL"
|
|
47476
|
-
}
|
|
47477
|
-
];
|
|
47478
|
-
var FACE_INDEXES = [{
|
|
47479
|
-
name: "idx_faces_device_ts",
|
|
47480
|
-
columns: ["deviceId", "timestamp"]
|
|
47481
|
-
}, {
|
|
47482
|
-
name: "idx_faces_track",
|
|
47483
|
-
columns: ["trackId"]
|
|
47484
|
-
}];
|
|
47485
|
-
var FaceStore = class {
|
|
47486
|
-
store;
|
|
47487
|
-
logger;
|
|
47488
|
-
constructor(deps) {
|
|
47489
|
-
this.store = deps.store;
|
|
47490
|
-
this.logger = deps.logger;
|
|
47491
|
-
}
|
|
47492
|
-
static async declare(store) {
|
|
47493
|
-
await store.declareCollection.mutate({
|
|
47494
|
-
collection: FACES_COLLECTION,
|
|
47495
|
-
columns: [...FACE_COLUMNS],
|
|
47496
|
-
indexes: [...FACE_INDEXES]
|
|
47497
|
-
});
|
|
47498
|
-
}
|
|
47499
|
-
/**
|
|
47500
|
-
* Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
|
|
47501
|
-
* id per track → one entry per track). `set` replaces an existing row.
|
|
47502
|
-
*/
|
|
47503
|
-
async insert(face) {
|
|
47504
|
-
const { id, ...rest } = face;
|
|
47505
|
-
try {
|
|
47506
|
-
await this.store.set.mutate({
|
|
47507
|
-
collection: FACES_COLLECTION,
|
|
47508
|
-
key: id,
|
|
47509
|
-
value: rest
|
|
47510
|
-
});
|
|
47511
|
-
} catch (err) {
|
|
47512
|
-
this.logger.warn("FaceStore.insert failed", {
|
|
47513
|
-
tags: { deviceId: face.deviceId },
|
|
47514
|
-
meta: {
|
|
47515
|
-
faceId: id,
|
|
47516
|
-
error: String(err)
|
|
47517
|
-
}
|
|
47518
|
-
});
|
|
47519
|
-
}
|
|
47520
|
-
}
|
|
47521
|
-
/**
|
|
47522
|
-
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
47523
|
-
*
|
|
47524
|
-
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
47525
|
-
* SQLite backend (which returns INTEGER 0/1) is handled correctly.
|
|
47526
|
-
*/
|
|
47527
|
-
async listRecentFaces(input) {
|
|
47528
|
-
const where = {};
|
|
47529
|
-
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
47530
|
-
return (await this.store.query.query({
|
|
47531
|
-
collection: FACES_COLLECTION,
|
|
47532
|
-
filter: {
|
|
47533
|
-
where,
|
|
47534
|
-
orderBy: {
|
|
47535
|
-
field: "timestamp",
|
|
47536
|
-
direction: "desc"
|
|
47537
|
-
},
|
|
47538
|
-
limit: input.limit ?? 100
|
|
47539
|
-
}
|
|
47540
|
-
})).map((r) => {
|
|
47541
|
-
const data = r.data;
|
|
47542
|
-
return {
|
|
47543
|
-
id: r.id,
|
|
47544
|
-
...data,
|
|
47545
|
-
assigned: Boolean(r.data.assigned),
|
|
47546
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47547
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47548
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47549
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47550
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47551
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47552
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47553
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47554
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47555
|
-
};
|
|
47556
|
-
}).filter((f) => !f.assigned);
|
|
47557
|
-
}
|
|
47558
|
-
/**
|
|
47559
|
-
* The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
|
|
47560
|
-
* here is GONE (D61). It had no production caller — only tests — and it was
|
|
47561
|
-
* an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
|
|
47562
|
-
* retention model now forbids. A leftover that describes the wrong design
|
|
47563
|
-
* still reads as permission to use it.
|
|
47564
|
-
*/
|
|
47565
|
-
/**
|
|
47566
|
-
* Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
|
|
47567
|
-
*
|
|
47568
|
-
* **This is a CAPACITY bound, not an age one.** It used to be both: a face
|
|
47569
|
-
* was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
|
|
47570
|
-
* The age half was a fifth clock over a track-OWNED entity — a face row
|
|
47571
|
-
* carries a `trackId`, so it belongs to its track and cascades with it — and
|
|
47572
|
-
* it aged crops out from under tracks that were still well inside their own
|
|
47573
|
-
* retention window. It is gone (D61). What remains is the ring-buffer
|
|
47574
|
-
* property the recognizer actually depends on: the gallery of recent
|
|
47575
|
-
* unassigned faces stays small.
|
|
47576
|
-
*
|
|
47577
|
-
* ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
|
|
47578
|
-
* slots. Returns all deleted ids (the caller removes their crops).
|
|
47579
|
-
*/
|
|
47580
|
-
async pruneCapOverflow(input) {
|
|
47581
|
-
/**
|
|
47582
|
-
* Paged, not one statement. Only three scalars per row survive the page —
|
|
47583
|
-
* the `embedding` string that dominates the row is dropped as soon as the
|
|
47584
|
-
* page goes out of scope, so peak heap is one page rather than the whole
|
|
47585
|
-
* face table. The prune still considers EVERY row, which is what makes it a
|
|
47586
|
-
* capacity bound rather than a bound on the newest page.
|
|
47587
|
-
*/
|
|
47588
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
47589
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47590
|
-
const rows = await this.store.query.query({
|
|
47591
|
-
collection: FACES_COLLECTION,
|
|
47592
|
-
filter: {
|
|
47593
|
-
orderBy: {
|
|
47594
|
-
field: "timestamp",
|
|
47595
|
-
direction: "desc"
|
|
47596
|
-
},
|
|
47597
|
-
limit: FACE_SWEEP_PAGE,
|
|
47598
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47599
|
-
}
|
|
47600
|
-
});
|
|
47601
|
-
if (rows.length === 0) break;
|
|
47602
|
-
for (const r of rows) {
|
|
47603
|
-
const data = r.data;
|
|
47604
|
-
if (data.assigned) continue;
|
|
47605
|
-
const deviceId = Number(data.deviceId ?? -1);
|
|
47606
|
-
const list = byDevice.get(deviceId) ?? [];
|
|
47607
|
-
list.push({
|
|
47608
|
-
id: r.id,
|
|
47609
|
-
timestamp: Number(data.timestamp ?? 0)
|
|
47610
|
-
});
|
|
47611
|
-
byDevice.set(deviceId, list);
|
|
47612
|
-
}
|
|
47613
|
-
if (rows.length < 1e3) break;
|
|
47614
|
-
}
|
|
47615
|
-
const toDelete = /* @__PURE__ */ new Set();
|
|
47616
|
-
for (const list of byDevice.values()) {
|
|
47617
|
-
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
47618
|
-
list.forEach((row, idx) => {
|
|
47619
|
-
if (idx >= input.maxPerDevice) toDelete.add(row.id);
|
|
47620
|
-
});
|
|
47621
|
-
}
|
|
47622
|
-
const deleted = [];
|
|
47623
|
-
for (const id of toDelete) try {
|
|
47624
|
-
await this.store.delete.mutate({
|
|
47625
|
-
collection: FACES_COLLECTION,
|
|
47626
|
-
key: id
|
|
47627
|
-
});
|
|
47628
|
-
deleted.push(id);
|
|
47629
|
-
} catch (err) {
|
|
47630
|
-
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
47631
|
-
faceId: id,
|
|
47632
|
-
error: String(err)
|
|
47633
|
-
} });
|
|
47634
|
-
}
|
|
47635
|
-
return deleted;
|
|
47636
|
-
}
|
|
47637
|
-
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
47638
|
-
async markAssigned(faceId, identityId, sampleId) {
|
|
47639
|
-
const data = {
|
|
47640
|
-
assigned: true,
|
|
47641
|
-
recognizedIdentityId: identityId
|
|
47642
|
-
};
|
|
47643
|
-
if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
|
|
47644
|
-
try {
|
|
47645
|
-
await this.store.update.mutate({
|
|
47646
|
-
collection: FACES_COLLECTION,
|
|
47647
|
-
id: faceId,
|
|
47648
|
-
data
|
|
47649
|
-
});
|
|
47650
|
-
} catch (err) {
|
|
47651
|
-
this.logger.warn("FaceStore.markAssigned failed", { meta: {
|
|
47652
|
-
faceId,
|
|
47653
|
-
error: String(err)
|
|
47654
|
-
} });
|
|
47655
|
-
}
|
|
47656
|
-
}
|
|
47657
|
-
/** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
|
|
47658
|
-
async clearAssignment(faceId) {
|
|
47659
|
-
try {
|
|
47660
|
-
await this.store.update.mutate({
|
|
47661
|
-
collection: FACES_COLLECTION,
|
|
47662
|
-
id: faceId,
|
|
47663
|
-
data: {
|
|
47664
|
-
assigned: false,
|
|
47665
|
-
recognizedIdentityId: null,
|
|
47666
|
-
assignedSampleId: null
|
|
47667
|
-
}
|
|
47668
|
-
});
|
|
47669
|
-
} catch (err) {
|
|
47670
|
-
this.logger.warn("FaceStore.clearAssignment failed", { meta: {
|
|
47671
|
-
faceId,
|
|
47672
|
-
error: String(err)
|
|
47673
|
-
} });
|
|
47674
|
-
}
|
|
47675
|
-
}
|
|
47676
|
-
/**
|
|
47677
|
-
* Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
|
|
47678
|
-
* write that explicitly-requested action makes. `suggestion: null` means
|
|
47679
|
-
* the recompute found no candidate worth offering and CLEARS both columns
|
|
47680
|
-
* (a genuine "the gallery has nothing for this face right now" result, not
|
|
47681
|
-
* a no-op left at whatever they held before). Never touches `assigned` /
|
|
47682
|
-
* `recognizedIdentityId` — a suggestion is never an assignment. The caller
|
|
47683
|
-
* is responsible for never calling this with a non-comparable (`model-
|
|
47684
|
-
* mismatch`) report; this method persists whatever it is given.
|
|
47685
|
-
*/
|
|
47686
|
-
async setSuggestion(faceId, suggestion) {
|
|
47687
|
-
const data = suggestion === null ? {
|
|
47688
|
-
suggestedIdentityId: null,
|
|
47689
|
-
suggestedMatchScore: null
|
|
47690
|
-
} : {
|
|
47691
|
-
suggestedIdentityId: suggestion.identityId,
|
|
47692
|
-
suggestedMatchScore: suggestion.score
|
|
47693
|
-
};
|
|
47694
|
-
try {
|
|
47695
|
-
await this.store.update.mutate({
|
|
47696
|
-
collection: FACES_COLLECTION,
|
|
47697
|
-
id: faceId,
|
|
47698
|
-
data
|
|
47699
|
-
});
|
|
47700
|
-
} catch (err) {
|
|
47701
|
-
this.logger.warn("FaceStore.setSuggestion failed", { meta: {
|
|
47702
|
-
faceId,
|
|
47703
|
-
error: String(err)
|
|
47704
|
-
} });
|
|
47705
|
-
}
|
|
47706
|
-
}
|
|
47707
|
-
/**
|
|
47708
|
-
* `TrackScopedStore` contract for the retention cascade: delete the buffered
|
|
47709
|
-
* face rows of the given tracks that are NOT assigned. Reuses the EXACT
|
|
47710
|
-
* `assigned` exemption that `prune`/`pruneAll` apply (see the
|
|
47711
|
-
* `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
|
|
47712
|
-
* Per-track ISOLATED + best-effort per row — one bad track or row never
|
|
47713
|
-
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
47714
|
-
*/
|
|
47715
|
-
async deleteByTracks(trackIds) {
|
|
47716
|
-
let removed = 0;
|
|
47717
|
-
for (const trackId of trackIds) try {
|
|
47718
|
-
const eligible = (await this.store.query.query({
|
|
47719
|
-
collection: FACES_COLLECTION,
|
|
47720
|
-
filter: { where: { trackId } }
|
|
47721
|
-
})).filter((r) => !r.data.assigned);
|
|
47722
|
-
for (const row of eligible) try {
|
|
47723
|
-
await this.store.delete.mutate({
|
|
47724
|
-
collection: FACES_COLLECTION,
|
|
47725
|
-
key: row.id
|
|
47726
|
-
});
|
|
47727
|
-
removed++;
|
|
47728
|
-
} catch (err) {
|
|
47729
|
-
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
47730
|
-
faceId: row.id,
|
|
47731
|
-
error: String(err)
|
|
47732
|
-
} });
|
|
47733
|
-
}
|
|
47734
|
-
} catch (err) {
|
|
47735
|
-
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
47736
|
-
trackId,
|
|
47737
|
-
error: String(err)
|
|
47738
|
-
} });
|
|
47739
|
-
}
|
|
47740
|
-
return [{
|
|
47741
|
-
family: "faces",
|
|
47742
|
-
rows: removed,
|
|
47743
|
-
bytes: 0
|
|
47744
|
-
}];
|
|
47745
|
-
}
|
|
47746
|
-
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
47747
|
-
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
47748
|
-
async delete(faceId) {
|
|
47749
|
-
try {
|
|
47750
|
-
await this.store.delete.mutate({
|
|
47751
|
-
collection: FACES_COLLECTION,
|
|
47752
|
-
key: faceId
|
|
47753
|
-
});
|
|
47754
|
-
} catch (err) {
|
|
47755
|
-
this.logger.warn("FaceStore.delete failed", { meta: {
|
|
47756
|
-
faceId,
|
|
47757
|
-
error: String(err)
|
|
47758
|
-
} });
|
|
47759
|
-
}
|
|
47760
|
-
}
|
|
47761
|
-
/**
|
|
47762
|
-
* Retrieve a single face by id. Returns null if not found.
|
|
47763
|
-
*/
|
|
47764
|
-
async get(faceId) {
|
|
47765
|
-
const raw = await this.store.get.query({
|
|
47766
|
-
collection: FACES_COLLECTION,
|
|
47767
|
-
key: faceId
|
|
47768
|
-
});
|
|
47769
|
-
if (!raw) return null;
|
|
47770
|
-
const data = raw;
|
|
47771
|
-
return {
|
|
47772
|
-
id: faceId,
|
|
47773
|
-
...data,
|
|
47774
|
-
assigned: Boolean(raw.assigned),
|
|
47775
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47776
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47777
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47778
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47779
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47780
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47781
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47782
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47783
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47784
|
-
};
|
|
47785
|
-
}
|
|
47786
|
-
/**
|
|
47787
|
-
* Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
|
|
47788
|
-
* Not exposed publicly — intended for internal use and tests only.
|
|
47789
|
-
*/
|
|
47790
|
-
async update(faceId, data) {
|
|
47791
|
-
try {
|
|
47792
|
-
await this.store.update.mutate({
|
|
47793
|
-
collection: FACES_COLLECTION,
|
|
47794
|
-
id: faceId,
|
|
47795
|
-
data
|
|
47796
|
-
});
|
|
47797
|
-
} catch (err) {
|
|
47798
|
-
this.logger.warn("FaceStore.update failed", { meta: {
|
|
47799
|
-
faceId,
|
|
47800
|
-
error: String(err)
|
|
47801
|
-
} });
|
|
47802
|
-
}
|
|
47803
|
-
}
|
|
47804
|
-
/**
|
|
47805
|
-
* List ALL recent faces across all devices, newest-first.
|
|
47806
|
-
*
|
|
47807
|
-
* filter:
|
|
47808
|
-
* - 'all' (default) → every row
|
|
47809
|
-
* - 'unassigned' → !assigned
|
|
47810
|
-
* - 'recognized' → !assigned && recognizedIdentityId != null
|
|
47811
|
-
* - 'identified' → recognizedIdentityId != null, including manual assignments
|
|
47812
|
-
*/
|
|
47813
|
-
async listAllRecentFaces(input) {
|
|
47814
|
-
/**
|
|
47815
|
-
* The filter is applied in memory, so a DB `limit` of N cannot be the
|
|
47816
|
-
* caller's N: with `filter:'unassigned'` the newest N rows may be entirely
|
|
47817
|
-
* assigned while matches wait just beyond them.
|
|
47818
|
-
*
|
|
47819
|
-
* The previous answer was to fetch the WHOLE collection and slice after
|
|
47820
|
-
* filtering. That is the read that killed hub-main (`query-bounds.ts`) —
|
|
47821
|
-
* every face row carries a JSON `embedding`, so "all rows" is the widest
|
|
47822
|
-
* table in the database materialised at once, to render a gallery of at
|
|
47823
|
-
* most a few hundred tiles.
|
|
47824
|
-
*
|
|
47825
|
-
* PAGE instead, and stop as soon as enough MATCHES exist. The pathological
|
|
47826
|
-
* case (a filter that matches nothing) walks the whole collection exactly
|
|
47827
|
-
* as before but one bounded page at a time; the common case stops after the
|
|
47828
|
-
* first page. Semantics are unchanged: the limit is still applied after the
|
|
47829
|
-
* filter, never before it.
|
|
47830
|
-
*/
|
|
47831
|
-
const filterMode = input.filter ?? "all";
|
|
47832
|
-
const matches = (f) => {
|
|
47833
|
-
if (filterMode === "unassigned") return !f.assigned;
|
|
47834
|
-
if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
|
|
47835
|
-
if (filterMode === "identified") return f.recognizedIdentityId != null;
|
|
47836
|
-
return true;
|
|
47837
|
-
};
|
|
47838
|
-
const result = [];
|
|
47839
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47840
|
-
const rows = await this.store.query.query({
|
|
47841
|
-
collection: FACES_COLLECTION,
|
|
47842
|
-
filter: {
|
|
47843
|
-
...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
|
|
47844
|
-
orderBy: {
|
|
47845
|
-
field: "timestamp",
|
|
47846
|
-
direction: "desc"
|
|
47847
|
-
},
|
|
47848
|
-
limit: FACE_SWEEP_PAGE,
|
|
47849
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47850
|
-
}
|
|
47851
|
-
});
|
|
47852
|
-
if (rows.length === 0) break;
|
|
47853
|
-
for (const r of rows) {
|
|
47854
|
-
const data = r.data;
|
|
47855
|
-
const face = {
|
|
47856
|
-
id: r.id,
|
|
47857
|
-
...data,
|
|
47858
|
-
assigned: Boolean(r.data.assigned),
|
|
47859
|
-
mediaKey: data.mediaKey ?? void 0,
|
|
47860
|
-
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
47861
|
-
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
47862
|
-
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
47863
|
-
faceBbox: data.faceBbox ?? void 0,
|
|
47864
|
-
bestMatchScore: data.bestMatchScore ?? void 0,
|
|
47865
|
-
nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
|
|
47866
|
-
suggestedIdentityId: data.suggestedIdentityId ?? void 0,
|
|
47867
|
-
suggestedMatchScore: data.suggestedMatchScore ?? void 0
|
|
47868
|
-
};
|
|
47869
|
-
if (matches(face)) result.push(face);
|
|
47870
|
-
if (input.limit !== void 0 && result.length >= input.limit) return result;
|
|
47871
|
-
}
|
|
47872
|
-
if (rows.length < 1e3) break;
|
|
47873
|
-
}
|
|
47874
|
-
return result;
|
|
47875
|
-
}
|
|
47876
|
-
/** One real face row per camera currently attributed to an identity. */
|
|
47877
|
-
async listCameraRefsForIdentity(identityId) {
|
|
47878
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
47879
|
-
for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
|
|
47880
|
-
const rows = await this.store.query.query({
|
|
47881
|
-
collection: FACES_COLLECTION,
|
|
47882
|
-
filter: {
|
|
47883
|
-
where: { recognizedIdentityId: identityId },
|
|
47884
|
-
orderBy: {
|
|
47885
|
-
field: "timestamp",
|
|
47886
|
-
direction: "desc"
|
|
47887
|
-
},
|
|
47888
|
-
limit: FACE_SWEEP_PAGE,
|
|
47889
|
-
offset: page * FACE_SWEEP_PAGE
|
|
47890
|
-
}
|
|
47891
|
-
});
|
|
47892
|
-
if (rows.length === 0) break;
|
|
47893
|
-
for (const row of rows) {
|
|
47894
|
-
const deviceId = Number(row.data["deviceId"]);
|
|
47895
|
-
if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
|
|
47896
|
-
}
|
|
47897
|
-
if (rows.length < 1e3) break;
|
|
47898
|
-
}
|
|
47899
|
-
return [...byDevice].map(([deviceId, faceId]) => ({
|
|
47900
|
-
deviceId,
|
|
47901
|
-
faceId
|
|
47902
|
-
}));
|
|
47903
|
-
}
|
|
47904
|
-
};
|
|
47905
|
-
//#endregion
|
|
47906
48130
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
47907
48131
|
/**
|
|
47908
48132
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -60096,6 +60320,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60096
60320
|
cancelVision: async (requestId) => {
|
|
60097
60321
|
await api.llm.cancel.mutate({ requestId });
|
|
60098
60322
|
},
|
|
60323
|
+
resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
|
|
60099
60324
|
mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
|
|
60100
60325
|
readDeviceStates: async (ids) => {
|
|
60101
60326
|
const out = /* @__PURE__ */ new Map();
|