@camstack/addon-post-analysis 1.2.147 → 1.2.150
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-1xYuXmDS.js → dist-BTviHGUC.js} +502 -11
- package/dist/{dist-4eP-xmjY.mjs → dist-C8cA7PX_.mjs} +485 -12
- 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-B8VSQDMs.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DZGvAMuh.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-C7KsMvFR.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D8CtX8cU.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-Dt9TGWyM.mjs → hostInit-DgkTmoHq.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +2418 -266
- package/dist/pipeline-analytics/index.mjs +2417 -267
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as discriminatedUnion, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as nodePin, G as TimelapseRulePatchSchema, Gt as record, H as SceneMonitorSchema, Ht as number, I as RECORDING_EXPORT_MAX_READ_BYTES, It as sleep, J as VISIT_MERGE_GAP_MS, Jt as EventCategory, K as TimelapseRuleSchema, Kt as string, L as RetrainStatusSchema, Lt as _enum, M as NcSystemEventKindSchema, Mt as createEvent, N as NcTaxonomySchema, Nt as hydrateSchema, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as isDeviceScopedCap, Q as audioMetricsCapability, Rt as array, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as object, V as SCENE_DIVERGED, Vt as literal, W as TimelapseRuleInputSchema, Wt as partialRecord, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Z as assertTimelapseCadences, _ as MACRO_LABELS, _t as pickClusterStepModels, a as COCO_TO_MACRO, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, ct as faceGalleryCapability, d as DeclaredDevices, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_KIND_BY_CAP, ft as isScheduleActive, g as LabelAttributionSchema, h as FailureCounters, ht as notificationRulesCapability, i as CLUSTER_MODEL_SCOPED_STEPS, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as DeviceType, k as NcSnoozeInputSchema, kt as errMsg, l as DETECTION_MACRO_CLASSES, lt as failureContributionCapability, m as FULL_IMAGE_BBOX, mt as kebabToCamel, n as AnalyticsGroupRecordSchema, nt as customAction, o as DEFAULT_EVENT_COLOR, ot as encodeVectorBase64, p as EVENT_PAD_MS, pt as isSourceCap, q as TrackSourceSchema, qt as unknown, r as BaseDevice, rt as defineCustomActions, st as evaluateSensorEdge, t as AnalyticsGroupDetailSchema, tt as cosineSimilarity$1, v as NC_ALARM_SYSTEM_EVENT_KINDS, vt as pipelineAnalyticsCapability, w as NcRuleInputSchema, wt as subKindsOf, xt as readTimelapseGeneratedAt, y as NC_CONDITION_CATALOG, yt as plateGalleryCapability, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as boolean } from "../dist-C8cA7PX_.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";
|
|
@@ -25790,6 +25790,112 @@ var MediaStore = class {
|
|
|
25790
25790
|
}
|
|
25791
25791
|
}
|
|
25792
25792
|
/**
|
|
25793
|
+
* Delete one row and its blob, and CONFIRM the blob is gone before the row
|
|
25794
|
+
* is forgotten (D148, read for this store).
|
|
25795
|
+
*
|
|
25796
|
+
* Every other delete on this class is best-effort: `deleteByKeyUnchecked`
|
|
25797
|
+
* swallows the unlink error and drops the row anyway, and the underlying
|
|
25798
|
+
* `fs.rm(…, { force: true })` cannot distinguish "removed it" from "there
|
|
25799
|
+
* was nothing there". That is tolerable for a cascade whose whole subtree is
|
|
25800
|
+
* going, and it is NOT tolerable for a bulk reclaim: a row deleted while its
|
|
25801
|
+
* blob survives is 95 KiB that nothing will ever name again — the orphan
|
|
25802
|
+
* class this codebase has spent the week sweeping.
|
|
25803
|
+
*
|
|
25804
|
+
* So the order is: unlink, re-probe, and only then delete the row. A probe
|
|
25805
|
+
* that cannot answer (unreachable volume) THROWS, exactly like
|
|
25806
|
+
* {@link referencedBlobState} — the caller must refuse, not assume absence.
|
|
25807
|
+
*
|
|
25808
|
+
* A row with no `path` is a row whose blob was never written; there is
|
|
25809
|
+
* nothing to confirm and the row is removable.
|
|
25810
|
+
*/
|
|
25811
|
+
async deleteConfirmed(key) {
|
|
25812
|
+
return this.gated(async () => {
|
|
25813
|
+
const row = await this.store.get.query({
|
|
25814
|
+
collection: MEDIA_COLLECTION,
|
|
25815
|
+
key
|
|
25816
|
+
});
|
|
25817
|
+
if (!row) return {
|
|
25818
|
+
rows: 0,
|
|
25819
|
+
bytes: 0
|
|
25820
|
+
};
|
|
25821
|
+
const data = row;
|
|
25822
|
+
const relativePath = String(data["path"] ?? "");
|
|
25823
|
+
const bytes = Number(data["sizeBytes"] ?? 0);
|
|
25824
|
+
if (relativePath.length > 0) {
|
|
25825
|
+
const location = mediaRowLocation(data);
|
|
25826
|
+
await this.storage.delete({
|
|
25827
|
+
location,
|
|
25828
|
+
relativePath
|
|
25829
|
+
});
|
|
25830
|
+
if (this.storage.exists) {
|
|
25831
|
+
if (await this.storage.exists({
|
|
25832
|
+
location,
|
|
25833
|
+
relativePath
|
|
25834
|
+
})) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
|
|
25835
|
+
} else {
|
|
25836
|
+
let readable = false;
|
|
25837
|
+
try {
|
|
25838
|
+
await this.storage.read({
|
|
25839
|
+
location,
|
|
25840
|
+
relativePath
|
|
25841
|
+
});
|
|
25842
|
+
readable = true;
|
|
25843
|
+
} catch {}
|
|
25844
|
+
if (readable) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
|
|
25845
|
+
}
|
|
25846
|
+
}
|
|
25847
|
+
await this.store.delete.mutate({
|
|
25848
|
+
collection: MEDIA_COLLECTION,
|
|
25849
|
+
key
|
|
25850
|
+
});
|
|
25851
|
+
return {
|
|
25852
|
+
rows: 1,
|
|
25853
|
+
bytes: Number.isFinite(bytes) ? bytes : 0
|
|
25854
|
+
};
|
|
25855
|
+
});
|
|
25856
|
+
}
|
|
25857
|
+
/**
|
|
25858
|
+
* A page of media rows of ONE kind for ONE device, timestamp-ascending from
|
|
25859
|
+
* `afterMs`. The reclaim's cursor.
|
|
25860
|
+
*
|
|
25861
|
+
* `(deviceId, timestamp)` is `idx_media_device_ts`, so this is an index
|
|
25862
|
+
* range scan — unlike a fleet-wide `kind = 'snapshot'` predicate, which has
|
|
25863
|
+
* no index at all (`MEDIA_INDEXES`) and would table-scan 1.26 M rows on
|
|
25864
|
+
* hub-main's single synchronous SQLite thread.
|
|
25865
|
+
*/
|
|
25866
|
+
async pageByKindForDevice(input) {
|
|
25867
|
+
return (await this.store.query.query({
|
|
25868
|
+
collection: MEDIA_COLLECTION,
|
|
25869
|
+
columns: [
|
|
25870
|
+
"id",
|
|
25871
|
+
"ownerId",
|
|
25872
|
+
"timestamp",
|
|
25873
|
+
"sizeBytes"
|
|
25874
|
+
],
|
|
25875
|
+
filter: {
|
|
25876
|
+
where: {
|
|
25877
|
+
deviceId: input.deviceId,
|
|
25878
|
+
ownerKind: input.ownerKind,
|
|
25879
|
+
kind: input.kind
|
|
25880
|
+
},
|
|
25881
|
+
whereBetween: { timestamp: [input.afterMs, Number.MAX_SAFE_INTEGER] },
|
|
25882
|
+
orderBy: {
|
|
25883
|
+
field: "timestamp",
|
|
25884
|
+
direction: "asc"
|
|
25885
|
+
},
|
|
25886
|
+
limit: input.limit
|
|
25887
|
+
}
|
|
25888
|
+
})).map((row) => {
|
|
25889
|
+
const data = row.data;
|
|
25890
|
+
return {
|
|
25891
|
+
key: row.id,
|
|
25892
|
+
ownerId: String(data["ownerId"] ?? ""),
|
|
25893
|
+
timestamp: Number(data["timestamp"] ?? 0),
|
|
25894
|
+
sizeBytes: Number(data["sizeBytes"] ?? 0)
|
|
25895
|
+
};
|
|
25896
|
+
});
|
|
25897
|
+
}
|
|
25898
|
+
/**
|
|
25793
25899
|
* `deviceId` is the AUTHORIZATION filter: every media row is stamped with
|
|
25794
25900
|
* its device at write time, so scoping the query by it makes a media read
|
|
25795
25901
|
* for a device the caller cannot name return nothing — the DB itself
|
|
@@ -28232,6 +28338,212 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
28232
28338
|
};
|
|
28233
28339
|
}
|
|
28234
28340
|
//#endregion
|
|
28341
|
+
//#region src/pipeline-analytics/store/embedding-vector-index.ts
|
|
28342
|
+
/**
|
|
28343
|
+
* The vector indexes this addon owns, and the one client that talks to them.
|
|
28344
|
+
*
|
|
28345
|
+
* ## The three indexes, and what a row in each one IS
|
|
28346
|
+
*
|
|
28347
|
+
* | index | dim | encoder | one row is | ranked against |
|
|
28348
|
+
* | --- | --- | --- | --- | --- |
|
|
28349
|
+
* | `object-clip` | 512 | MobileCLIP image tower (`mobileclip-*`) | a TRACK's best-confidence object crop | a CLIP **text** vector — semantic search over footage |
|
|
28350
|
+
* | `face-arcface` | 512 | ArcFace (`arcface-r100` and successors) | the best face read of a TRACK, aligned to 112×112 | the enrolled gallery, to suggest or auto-confirm an identity |
|
|
28351
|
+
* | `identity-arcface` | 512 | the SAME ArcFace model | one ENROLLED face of a person | every live face detection, at recognition time |
|
|
28352
|
+
*
|
|
28353
|
+
* `plates`, `vehicles`, `vehicle-samples` and `identities` hold no vector of
|
|
28354
|
+
* any kind — a plate is a STRING and a score, a vehicle and an identity are a
|
|
28355
|
+
* name. They are named here because `vector-store.cap.ts` once claimed six
|
|
28356
|
+
* stores were waiting to migrate, and five of them being absent from this table
|
|
28357
|
+
* is the answer to "where are the other ones".
|
|
28358
|
+
*
|
|
28359
|
+
* ## Why `face-arcface` and `identity-arcface` are two indexes
|
|
28360
|
+
*
|
|
28361
|
+
* They hold the same 512 dims from the same model, and they are still separate.
|
|
28362
|
+
* An index is a set of rows that get ranked against each other and that live and
|
|
28363
|
+
* die together, and these are neither:
|
|
28364
|
+
*
|
|
28365
|
+
* - a `face-arcface` row is TRACK-OWNED. It cascades away with its track and is
|
|
28366
|
+
* capped per camera; it is a CANDIDATE.
|
|
28367
|
+
* - an `identity-arcface` row is retention-EXEMPT forever. It is the ANSWER
|
|
28368
|
+
* every candidate is measured against.
|
|
28369
|
+
*
|
|
28370
|
+
* Merged, every gallery load and every reconcile would carry a discriminating
|
|
28371
|
+
* filter, and the two ways that filter can fail are "a face is ranked against
|
|
28372
|
+
* itself" and "an enrolled person's only sample is reclaimed as a track orphan".
|
|
28373
|
+
* Sharing a dimension is not a reason to share an index.
|
|
28374
|
+
*/
|
|
28375
|
+
/** The buffered per-track ArcFace face vector (`pipeline-analytics:faces`). */
|
|
28376
|
+
var FACE_ARCFACE_INDEX = "face-arcface";
|
|
28377
|
+
/** The ENROLLED gallery (`pipeline-analytics:identity-samples`). */
|
|
28378
|
+
var IDENTITY_ARCFACE_INDEX = "identity-arcface";
|
|
28379
|
+
/**
|
|
28380
|
+
* The `className` each index stamps on its rows.
|
|
28381
|
+
*
|
|
28382
|
+
* A constant per index, exactly as `object-clip` stamps `embedding`. It is not
|
|
28383
|
+
* a subject class and must never be filtered on as one: it says which PRODUCER
|
|
28384
|
+
* wrote the row, which is what keeps a face vector out of a CLIP ranking.
|
|
28385
|
+
*/
|
|
28386
|
+
var FACE_VECTOR_CLASS = "face";
|
|
28387
|
+
var IDENTITY_VECTOR_CLASS = "identity";
|
|
28388
|
+
/**
|
|
28389
|
+
* One index, plus the lazy declaration every caller would otherwise re-derive.
|
|
28390
|
+
*
|
|
28391
|
+
* The dimension is not known at construction — it comes from whatever encoder
|
|
28392
|
+
* is active — so the index is declared on the first vector, exactly as
|
|
28393
|
+
* `ObjectEmbeddingStore` does it, and a failed declaration DISABLES writes for
|
|
28394
|
+
* that dim rather than letting an upsert fail against an index the provider has
|
|
28395
|
+
* never seen. A failed write clears the cache so the next one re-declares:
|
|
28396
|
+
* a provider swap under a running hub once left that cache warm and every write
|
|
28397
|
+
* throwing for hours.
|
|
28398
|
+
*/
|
|
28399
|
+
var EmbeddingVectorIndex = class {
|
|
28400
|
+
index;
|
|
28401
|
+
client;
|
|
28402
|
+
logger;
|
|
28403
|
+
declaredDim = null;
|
|
28404
|
+
constructor(deps) {
|
|
28405
|
+
this.index = deps.index;
|
|
28406
|
+
this.client = deps.client;
|
|
28407
|
+
this.logger = deps.logger;
|
|
28408
|
+
}
|
|
28409
|
+
/** The index name, for a log line that has to name which store dropped work. */
|
|
28410
|
+
get name() {
|
|
28411
|
+
return this.index;
|
|
28412
|
+
}
|
|
28413
|
+
async ensureIndex(dim) {
|
|
28414
|
+
if (this.declaredDim === dim) return true;
|
|
28415
|
+
try {
|
|
28416
|
+
await this.client.declareIndex.mutate({
|
|
28417
|
+
index: this.index,
|
|
28418
|
+
dim,
|
|
28419
|
+
metric: "cosine"
|
|
28420
|
+
});
|
|
28421
|
+
this.declaredDim = dim;
|
|
28422
|
+
return true;
|
|
28423
|
+
} catch (err) {
|
|
28424
|
+
this.logger.warn("vector index declaration failed — writes disabled for this dim", { meta: {
|
|
28425
|
+
index: this.index,
|
|
28426
|
+
dim,
|
|
28427
|
+
error: String(err)
|
|
28428
|
+
} });
|
|
28429
|
+
return false;
|
|
28430
|
+
}
|
|
28431
|
+
}
|
|
28432
|
+
/**
|
|
28433
|
+
* Write one vector. Returns whether it LANDED, never whether the call
|
|
28434
|
+
* returned — the caller uses this to decide it may stop keeping its own copy,
|
|
28435
|
+
* and "the mutate resolved" is not the same statement (D58).
|
|
28436
|
+
*/
|
|
28437
|
+
async put(input) {
|
|
28438
|
+
if (input.embedding.length === 0) {
|
|
28439
|
+
this.logger.warn("refused to index an empty vector", { meta: {
|
|
28440
|
+
index: this.index,
|
|
28441
|
+
id: input.id
|
|
28442
|
+
} });
|
|
28443
|
+
return false;
|
|
28444
|
+
}
|
|
28445
|
+
if (!await this.ensureIndex(input.embedding.length)) return false;
|
|
28446
|
+
const metadata = {
|
|
28447
|
+
...input.extra,
|
|
28448
|
+
className: input.className,
|
|
28449
|
+
modelId: input.modelId,
|
|
28450
|
+
timestamp: input.timestamp,
|
|
28451
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
|
|
28452
|
+
};
|
|
28453
|
+
try {
|
|
28454
|
+
const result = await this.client.upsert.mutate({
|
|
28455
|
+
index: this.index,
|
|
28456
|
+
items: [{
|
|
28457
|
+
id: input.id,
|
|
28458
|
+
vector: encodeVectorBase64(input.embedding),
|
|
28459
|
+
metadata
|
|
28460
|
+
}]
|
|
28461
|
+
});
|
|
28462
|
+
if (result.upserted === 0) {
|
|
28463
|
+
this.logger.warn("vector upsert rejected", {
|
|
28464
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
28465
|
+
meta: {
|
|
28466
|
+
index: this.index,
|
|
28467
|
+
id: input.id,
|
|
28468
|
+
rejected: result.rejected
|
|
28469
|
+
}
|
|
28470
|
+
});
|
|
28471
|
+
return false;
|
|
28472
|
+
}
|
|
28473
|
+
return true;
|
|
28474
|
+
} catch (err) {
|
|
28475
|
+
this.declaredDim = null;
|
|
28476
|
+
this.logger.warn("vector upsert failed", {
|
|
28477
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
28478
|
+
meta: {
|
|
28479
|
+
index: this.index,
|
|
28480
|
+
id: input.id,
|
|
28481
|
+
error: String(err)
|
|
28482
|
+
}
|
|
28483
|
+
});
|
|
28484
|
+
return false;
|
|
28485
|
+
}
|
|
28486
|
+
}
|
|
28487
|
+
/**
|
|
28488
|
+
* Read named vectors back as float arrays.
|
|
28489
|
+
*
|
|
28490
|
+
* An id with no row is simply ABSENT from the map — never a zero vector. A
|
|
28491
|
+
* zero vector scores 0 against everything and would quietly un-enrol a person
|
|
28492
|
+
* instead of failing, which is the reason `fetchByIds` drops an unreadable
|
|
28493
|
+
* row rather than padding it.
|
|
28494
|
+
*
|
|
28495
|
+
* On a read failure the map is EMPTY and the failure is logged. An empty
|
|
28496
|
+
* gallery is a visible outage; a partial one silently forgets people.
|
|
28497
|
+
*/
|
|
28498
|
+
async load(ids) {
|
|
28499
|
+
const out = /* @__PURE__ */ new Map();
|
|
28500
|
+
if (ids.length === 0) return out;
|
|
28501
|
+
try {
|
|
28502
|
+
const result = await this.client.fetchByIds.query({
|
|
28503
|
+
index: this.index,
|
|
28504
|
+
ids: [...ids]
|
|
28505
|
+
});
|
|
28506
|
+
for (const item of result.items) {
|
|
28507
|
+
const buf = Buffer.from(item.vector, "base64");
|
|
28508
|
+
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) continue;
|
|
28509
|
+
const view = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
28510
|
+
out.set(item.id, Array.from(view));
|
|
28511
|
+
}
|
|
28512
|
+
} catch (err) {
|
|
28513
|
+
this.logger.warn("vector fetch failed — no vectors returned for this call", { meta: {
|
|
28514
|
+
index: this.index,
|
|
28515
|
+
ids: ids.length,
|
|
28516
|
+
error: String(err)
|
|
28517
|
+
} });
|
|
28518
|
+
return /* @__PURE__ */ new Map();
|
|
28519
|
+
}
|
|
28520
|
+
return out;
|
|
28521
|
+
}
|
|
28522
|
+
/**
|
|
28523
|
+
* Drop named vectors.
|
|
28524
|
+
*
|
|
28525
|
+
* Best-effort and LOUD: these rows have exactly one owner and it is being
|
|
28526
|
+
* deleted, so a delete that quietly did nothing leaves a vector nothing can
|
|
28527
|
+
* ever reach by ownership again.
|
|
28528
|
+
*/
|
|
28529
|
+
async drop(ids) {
|
|
28530
|
+
if (ids.length === 0) return;
|
|
28531
|
+
try {
|
|
28532
|
+
await this.client.deleteByIds.mutate({
|
|
28533
|
+
index: this.index,
|
|
28534
|
+
ids: [...ids]
|
|
28535
|
+
});
|
|
28536
|
+
} catch (err) {
|
|
28537
|
+
this.declaredDim = null;
|
|
28538
|
+
this.logger.warn("vector delete failed — rows left behind", { meta: {
|
|
28539
|
+
index: this.index,
|
|
28540
|
+
ids: ids.length,
|
|
28541
|
+
error: String(err)
|
|
28542
|
+
} });
|
|
28543
|
+
}
|
|
28544
|
+
}
|
|
28545
|
+
};
|
|
28546
|
+
//#endregion
|
|
28235
28547
|
//#region src/pipeline-analytics/store/face-sort.ts
|
|
28236
28548
|
var DEFAULT_FACE_SORT = {
|
|
28237
28549
|
by: "timestamp",
|
|
@@ -28297,6 +28609,15 @@ function rankAndCap(faces, order, limit) {
|
|
|
28297
28609
|
//#endregion
|
|
28298
28610
|
//#region src/pipeline-analytics/store/face-store.ts
|
|
28299
28611
|
/**
|
|
28612
|
+
* The model a row written before 2026-08-20 was produced by.
|
|
28613
|
+
*
|
|
28614
|
+
* Those rows predate `embeddingModelId`, and they were all this. The vector
|
|
28615
|
+
* index stamps a `modelId` on every row it holds, so a converted legacy row has
|
|
28616
|
+
* to be stamped with something true rather than left blank — a blank modelId in
|
|
28617
|
+
* the gallery is a vector the matcher cannot decide is comparable.
|
|
28618
|
+
*/
|
|
28619
|
+
var LEGACY_FACE_MODEL_ID = "arcface-r100";
|
|
28620
|
+
/**
|
|
28300
28621
|
* @durable class=ledger owner=pipeline-analytics
|
|
28301
28622
|
* write="one UPSERT per track that produced a face detail — the id is
|
|
28302
28623
|
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
@@ -28378,11 +28699,23 @@ var FACE_COLUMNS = [
|
|
|
28378
28699
|
type: "INTEGER",
|
|
28379
28700
|
notNull: true
|
|
28380
28701
|
},
|
|
28702
|
+
(
|
|
28703
|
+
/**
|
|
28704
|
+
* LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
|
|
28705
|
+
* `face-arcface` vector index now, keyed by this row's id.
|
|
28706
|
+
*
|
|
28707
|
+
* The column stays declared and stays `notNull` because rows written before
|
|
28708
|
+
* the conversion still carry their array and the conversion has to be able to
|
|
28709
|
+
* read them. A CONVERTED row holds `[]` — two bytes, and the marker that says
|
|
28710
|
+
* "the vector is in the index", which is what makes the conversion idempotent
|
|
28711
|
+
* and observable. Nothing reads this column except the conversion and the
|
|
28712
|
+
* fallback in {@link FaceStore.getWithEmbedding}.
|
|
28713
|
+
*/
|
|
28381
28714
|
{
|
|
28382
28715
|
name: "embedding",
|
|
28383
28716
|
type: "JSON",
|
|
28384
28717
|
notNull: true
|
|
28385
|
-
},
|
|
28718
|
+
}),
|
|
28386
28719
|
{
|
|
28387
28720
|
name: "embeddingModelId",
|
|
28388
28721
|
type: "TEXT"
|
|
@@ -28466,8 +28799,9 @@ function buildTimestampWindow(input) {
|
|
|
28466
28799
|
*/
|
|
28467
28800
|
function rowToFace(id, raw) {
|
|
28468
28801
|
const data = raw;
|
|
28802
|
+
const { embedding: _legacyEmbedding, ...rest } = data;
|
|
28469
28803
|
return {
|
|
28470
|
-
...
|
|
28804
|
+
...rest,
|
|
28471
28805
|
id,
|
|
28472
28806
|
assigned: Boolean(data.assigned),
|
|
28473
28807
|
mediaKey: data.mediaKey ?? void 0,
|
|
@@ -28483,9 +28817,11 @@ function rowToFace(id, raw) {
|
|
|
28483
28817
|
}
|
|
28484
28818
|
var FaceStore = class {
|
|
28485
28819
|
store;
|
|
28820
|
+
vectors;
|
|
28486
28821
|
logger;
|
|
28487
28822
|
constructor(deps) {
|
|
28488
28823
|
this.store = deps.store;
|
|
28824
|
+
this.vectors = deps.vectors;
|
|
28489
28825
|
this.logger = deps.logger;
|
|
28490
28826
|
}
|
|
28491
28827
|
static async declare(store) {
|
|
@@ -28500,12 +28836,31 @@ var FaceStore = class {
|
|
|
28500
28836
|
* id per track → one entry per track). `set` replaces an existing row.
|
|
28501
28837
|
*/
|
|
28502
28838
|
async insert(face) {
|
|
28503
|
-
const { id, ...rest } = face;
|
|
28839
|
+
const { id, embedding, ...rest } = face;
|
|
28840
|
+
if (!await this.vectors.put({
|
|
28841
|
+
id,
|
|
28842
|
+
embedding,
|
|
28843
|
+
className: "face",
|
|
28844
|
+
modelId: face.embeddingModelId ?? "arcface-r100",
|
|
28845
|
+
timestamp: face.timestamp,
|
|
28846
|
+
deviceId: face.deviceId,
|
|
28847
|
+
extra: { trackId: face.trackId }
|
|
28848
|
+
})) this.logger.warn("face row written WITHOUT a searchable vector", {
|
|
28849
|
+
tags: { deviceId: face.deviceId },
|
|
28850
|
+
meta: {
|
|
28851
|
+
faceId: id,
|
|
28852
|
+
index: this.vectors.name,
|
|
28853
|
+
trackId: face.trackId
|
|
28854
|
+
}
|
|
28855
|
+
});
|
|
28504
28856
|
try {
|
|
28505
28857
|
await this.store.set.mutate({
|
|
28506
28858
|
collection: FACES_COLLECTION,
|
|
28507
28859
|
key: id,
|
|
28508
|
-
value:
|
|
28860
|
+
value: {
|
|
28861
|
+
...rest,
|
|
28862
|
+
embedding: []
|
|
28863
|
+
}
|
|
28509
28864
|
});
|
|
28510
28865
|
} catch (err) {
|
|
28511
28866
|
this.logger.warn("FaceStore.insert failed", {
|
|
@@ -28518,6 +28873,111 @@ var FaceStore = class {
|
|
|
28518
28873
|
}
|
|
28519
28874
|
}
|
|
28520
28875
|
/**
|
|
28876
|
+
* How an UNCONVERTED `faces` row becomes an index row.
|
|
28877
|
+
*
|
|
28878
|
+
* The only judgement here is the modelId: a row written before 2026-08-20
|
|
28879
|
+
* carries no `embeddingModelId`, and those rows were all `arcface-r100`, so
|
|
28880
|
+
* that is what they are stamped with — a KNOWN historical fact, not a
|
|
28881
|
+
* fallback guess. A row that names some other model keeps its own.
|
|
28882
|
+
*/
|
|
28883
|
+
conversionSpec() {
|
|
28884
|
+
return {
|
|
28885
|
+
collection: FACES_COLLECTION,
|
|
28886
|
+
vectors: this.vectors,
|
|
28887
|
+
write: (id, embedding, data) => {
|
|
28888
|
+
const deviceId = data["deviceId"];
|
|
28889
|
+
const timestamp = data["timestamp"];
|
|
28890
|
+
const trackId = data["trackId"];
|
|
28891
|
+
const modelId = data["embeddingModelId"];
|
|
28892
|
+
return {
|
|
28893
|
+
id,
|
|
28894
|
+
embedding,
|
|
28895
|
+
className: FACE_VECTOR_CLASS,
|
|
28896
|
+
modelId: typeof modelId === "string" && modelId.length > 0 ? modelId : LEGACY_FACE_MODEL_ID,
|
|
28897
|
+
timestamp: typeof timestamp === "number" ? timestamp : 0,
|
|
28898
|
+
...typeof deviceId === "number" ? { deviceId } : {},
|
|
28899
|
+
...typeof trackId === "string" ? { extra: { trackId } } : {}
|
|
28900
|
+
};
|
|
28901
|
+
}
|
|
28902
|
+
};
|
|
28903
|
+
}
|
|
28904
|
+
/**
|
|
28905
|
+
* One face WITH its ArcFace vector — the enrolment and re-assignment read.
|
|
28906
|
+
*
|
|
28907
|
+
* Falls back to the row's legacy JSON column when the index has no vector for
|
|
28908
|
+
* this id, which is exactly the window in which a row written before the
|
|
28909
|
+
* conversion has not been converted yet. The fallback is LOGGED: it is
|
|
28910
|
+
* correct, it is temporary, and a fallback nobody can count is a fallback that
|
|
28911
|
+
* becomes permanent.
|
|
28912
|
+
*/
|
|
28913
|
+
async getWithEmbedding(faceId) {
|
|
28914
|
+
const raw = await this.store.get.query({
|
|
28915
|
+
collection: FACES_COLLECTION,
|
|
28916
|
+
key: faceId
|
|
28917
|
+
});
|
|
28918
|
+
if (!raw) return null;
|
|
28919
|
+
const face = rowToFace(faceId, raw);
|
|
28920
|
+
const fromIndex = (await this.vectors.load([faceId])).get(faceId);
|
|
28921
|
+
if (fromIndex !== void 0) return {
|
|
28922
|
+
...face,
|
|
28923
|
+
embedding: fromIndex
|
|
28924
|
+
};
|
|
28925
|
+
const legacy = raw.embedding;
|
|
28926
|
+
if (Array.isArray(legacy) && legacy.length > 0 && legacy.every((n) => typeof n === "number")) {
|
|
28927
|
+
this.logger.info("face vector served from the legacy JSON column", {
|
|
28928
|
+
tags: { deviceId: face.deviceId },
|
|
28929
|
+
meta: {
|
|
28930
|
+
faceId,
|
|
28931
|
+
why: "this row has not been converted into the vector index yet"
|
|
28932
|
+
}
|
|
28933
|
+
});
|
|
28934
|
+
return {
|
|
28935
|
+
...face,
|
|
28936
|
+
embedding: legacy
|
|
28937
|
+
};
|
|
28938
|
+
}
|
|
28939
|
+
this.logger.warn("face row has no vector in either home", {
|
|
28940
|
+
tags: { deviceId: face.deviceId },
|
|
28941
|
+
meta: {
|
|
28942
|
+
faceId,
|
|
28943
|
+
index: this.vectors.name
|
|
28944
|
+
}
|
|
28945
|
+
});
|
|
28946
|
+
return null;
|
|
28947
|
+
}
|
|
28948
|
+
/**
|
|
28949
|
+
* The ArcFace vectors of named rows, for the CLUSTERING pass.
|
|
28950
|
+
*
|
|
28951
|
+
* Ids with no vector are ABSENT from the map, never present with an empty
|
|
28952
|
+
* array — the clustering loop skips a zero-length vector, so an empty array
|
|
28953
|
+
* would turn "the index did not answer" into "these faces are not clusterable"
|
|
28954
|
+
* and produce a confident zero.
|
|
28955
|
+
*/
|
|
28956
|
+
async loadEmbeddings(faceIds) {
|
|
28957
|
+
const indexed = await this.vectors.load(faceIds);
|
|
28958
|
+
const missing = faceIds.filter((id) => !indexed.has(id));
|
|
28959
|
+
if (missing.length === 0) return indexed;
|
|
28960
|
+
const out = new Map(indexed);
|
|
28961
|
+
let fromLegacyColumn = 0;
|
|
28962
|
+
for (const id of missing) {
|
|
28963
|
+
const legacy = (await this.store.get.query({
|
|
28964
|
+
collection: FACES_COLLECTION,
|
|
28965
|
+
key: id
|
|
28966
|
+
}))?.embedding;
|
|
28967
|
+
if (!Array.isArray(legacy) || legacy.length === 0 || !legacy.every((n) => typeof n === "number")) continue;
|
|
28968
|
+
out.set(id, legacy);
|
|
28969
|
+
fromLegacyColumn += 1;
|
|
28970
|
+
}
|
|
28971
|
+
this.logger.info("face vectors served partly from the legacy JSON column", { meta: {
|
|
28972
|
+
index: this.vectors.name,
|
|
28973
|
+
asked: faceIds.length,
|
|
28974
|
+
fromIndex: indexed.size,
|
|
28975
|
+
fromLegacyColumn,
|
|
28976
|
+
unavailable: missing.length - fromLegacyColumn
|
|
28977
|
+
} });
|
|
28978
|
+
return out;
|
|
28979
|
+
}
|
|
28980
|
+
/**
|
|
28521
28981
|
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
28522
28982
|
*
|
|
28523
28983
|
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
@@ -28637,6 +29097,7 @@ var FaceStore = class {
|
|
|
28637
29097
|
error: String(err)
|
|
28638
29098
|
} });
|
|
28639
29099
|
}
|
|
29100
|
+
await this.vectors.drop([...deleted]);
|
|
28640
29101
|
this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
|
|
28641
29102
|
return [...deleted];
|
|
28642
29103
|
}
|
|
@@ -28667,6 +29128,45 @@ var FaceStore = class {
|
|
|
28667
29128
|
});
|
|
28668
29129
|
}
|
|
28669
29130
|
}
|
|
29131
|
+
/**
|
|
29132
|
+
* Replace a buffered face's vector AND its model stamp, then confirm BOTH.
|
|
29133
|
+
*
|
|
29134
|
+
* The vector and the stamp live in two homes — the index and the row — and
|
|
29135
|
+
* they must move together or not at all: a row stamped with the cluster model
|
|
29136
|
+
* beside a vector from the previous one is a face the matcher believes is
|
|
29137
|
+
* comparable and is not, which is worse than a face it refuses to compare.
|
|
29138
|
+
*
|
|
29139
|
+
* So: vector first (refuse if it does not land), row second, then a read-back
|
|
29140
|
+
* of the stamp. Returns whether the whole move landed — never whether the
|
|
29141
|
+
* calls returned (D58).
|
|
29142
|
+
*/
|
|
29143
|
+
async restampEmbedding(input) {
|
|
29144
|
+
if (!await this.vectors.put({
|
|
29145
|
+
id: input.faceId,
|
|
29146
|
+
embedding: input.embedding,
|
|
29147
|
+
className: "face",
|
|
29148
|
+
modelId: input.modelId,
|
|
29149
|
+
timestamp: input.timestamp,
|
|
29150
|
+
deviceId: input.deviceId,
|
|
29151
|
+
extra: { trackId: input.trackId }
|
|
29152
|
+
})) {
|
|
29153
|
+
this.logger.warn("face re-embed refused: the vector did not reach the index", {
|
|
29154
|
+
tags: { deviceId: input.deviceId },
|
|
29155
|
+
meta: {
|
|
29156
|
+
faceId: input.faceId,
|
|
29157
|
+
index: this.vectors.name
|
|
29158
|
+
}
|
|
29159
|
+
});
|
|
29160
|
+
return false;
|
|
29161
|
+
}
|
|
29162
|
+
await this.update(input.faceId, { embeddingModelId: input.modelId });
|
|
29163
|
+
await this.store.update.mutate({
|
|
29164
|
+
collection: FACES_COLLECTION,
|
|
29165
|
+
id: input.faceId,
|
|
29166
|
+
data: { embedding: [] }
|
|
29167
|
+
});
|
|
29168
|
+
return (await this.get(input.faceId))?.embeddingModelId === input.modelId;
|
|
29169
|
+
}
|
|
28670
29170
|
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
28671
29171
|
async markAssigned(faceId, identityId, sampleId) {
|
|
28672
29172
|
const data = {
|
|
@@ -28752,11 +29252,13 @@ var FaceStore = class {
|
|
|
28752
29252
|
collection: FACES_COLLECTION,
|
|
28753
29253
|
filter: { where: { trackId } }
|
|
28754
29254
|
})).filter((r) => !r.data.assigned);
|
|
29255
|
+
const dropped = [];
|
|
28755
29256
|
for (const row of eligible) try {
|
|
28756
29257
|
await this.store.delete.mutate({
|
|
28757
29258
|
collection: FACES_COLLECTION,
|
|
28758
29259
|
key: row.id
|
|
28759
29260
|
});
|
|
29261
|
+
dropped.push(row.id);
|
|
28760
29262
|
removed++;
|
|
28761
29263
|
} catch (err) {
|
|
28762
29264
|
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
@@ -28764,6 +29266,7 @@ var FaceStore = class {
|
|
|
28764
29266
|
error: String(err)
|
|
28765
29267
|
} });
|
|
28766
29268
|
}
|
|
29269
|
+
await this.vectors.drop(dropped);
|
|
28767
29270
|
} catch (err) {
|
|
28768
29271
|
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
28769
29272
|
trackId,
|
|
@@ -28789,7 +29292,9 @@ var FaceStore = class {
|
|
|
28789
29292
|
faceId,
|
|
28790
29293
|
error: String(err)
|
|
28791
29294
|
} });
|
|
29295
|
+
return;
|
|
28792
29296
|
}
|
|
29297
|
+
await this.vectors.drop([faceId]);
|
|
28793
29298
|
}
|
|
28794
29299
|
/**
|
|
28795
29300
|
* Retrieve a single face by id. Returns null if not found.
|
|
@@ -29208,7 +29713,7 @@ var FaceGalleryProvider = class {
|
|
|
29208
29713
|
await this.faceStore.clearAssignment(faceId);
|
|
29209
29714
|
await this.faceStore.update(faceId, { mediaKey: null });
|
|
29210
29715
|
}
|
|
29211
|
-
const currentFace = await this.faceStore.
|
|
29716
|
+
const currentFace = await this.faceStore.getWithEmbedding(faceId);
|
|
29212
29717
|
if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
|
|
29213
29718
|
const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
|
|
29214
29719
|
const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
|
|
@@ -29393,10 +29898,21 @@ var FaceGalleryProvider = class {
|
|
|
29393
29898
|
threshold,
|
|
29394
29899
|
minClusterSize
|
|
29395
29900
|
} });
|
|
29396
|
-
const
|
|
29397
|
-
|
|
29398
|
-
|
|
29399
|
-
|
|
29901
|
+
const vectors = await this.faceStore.loadEmbeddings(faces.map((f) => f.id));
|
|
29902
|
+
const items = [];
|
|
29903
|
+
for (const face of faces) {
|
|
29904
|
+
const embedding = vectors.get(face.id);
|
|
29905
|
+
if (embedding === void 0 || embedding.length === 0) continue;
|
|
29906
|
+
items.push({
|
|
29907
|
+
id: face.id,
|
|
29908
|
+
embedding
|
|
29909
|
+
});
|
|
29910
|
+
}
|
|
29911
|
+
if (items.length < faces.length) this.logger.warn("suggestFaceClusters skipped faces with no vector in the index", { meta: {
|
|
29912
|
+
scanned: faces.length,
|
|
29913
|
+
clusterable: items.length
|
|
29914
|
+
} });
|
|
29915
|
+
const clusters = clusterByEmbedding(items, threshold).filter((c) => c.size >= minClusterSize);
|
|
29400
29916
|
return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
|
|
29401
29917
|
}
|
|
29402
29918
|
};
|
|
@@ -32156,7 +32672,19 @@ async function runPreviewUpgrade(deps, input) {
|
|
|
32156
32672
|
});
|
|
32157
32673
|
return "no-bbox";
|
|
32158
32674
|
}
|
|
32675
|
+
if (pending.bestSeen.timestamp === void 0) {
|
|
32676
|
+
deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
|
|
32677
|
+
tags: { deviceId },
|
|
32678
|
+
meta: {
|
|
32679
|
+
trackId,
|
|
32680
|
+
keyFrameKey: input.keyFrameKey,
|
|
32681
|
+
heldProvenance: held
|
|
32682
|
+
}
|
|
32683
|
+
});
|
|
32684
|
+
return "no-bbox";
|
|
32685
|
+
}
|
|
32159
32686
|
const derived = await deps.derive({
|
|
32687
|
+
deviceId,
|
|
32160
32688
|
mediaKey: input.keyFrameKey,
|
|
32161
32689
|
bbox: {
|
|
32162
32690
|
x: pending.bestSeen.x,
|
|
@@ -32166,7 +32694,7 @@ async function runPreviewUpgrade(deps, input) {
|
|
|
32166
32694
|
},
|
|
32167
32695
|
frameWidth: pending.bestSeen.frameWidth,
|
|
32168
32696
|
frameHeight: pending.bestSeen.frameHeight,
|
|
32169
|
-
timestamp:
|
|
32697
|
+
timestamp: pending.bestSeen.timestamp
|
|
32170
32698
|
});
|
|
32171
32699
|
if (derived === null) {
|
|
32172
32700
|
deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
|
|
@@ -32530,6 +33058,28 @@ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
|
32530
33058
|
* `onInitialize`, not because their families are optional — a missing one means
|
|
32531
33059
|
* the addon is not fully up, and the sweep does not run in that state.
|
|
32532
33060
|
*/
|
|
33061
|
+
/**
|
|
33062
|
+
* The optional legs that are NOT in the registry this call would build.
|
|
33063
|
+
*
|
|
33064
|
+
* `buildAnalyticsCascadeRegistry` pushes each optional store only `if` it is
|
|
33065
|
+
* there, and that `if` is silent. A hub whose `vector-store` capability was
|
|
33066
|
+
* unavailable at boot builds an embedding-less cascade and then prunes tracks
|
|
33067
|
+
* with it — every pass, for the whole life of the process — while the only
|
|
33068
|
+
* evidence is one warn at startup that scrolled away hours earlier. Every CLIP
|
|
33069
|
+
* vector for every track deleted in that window becomes an orphan that nothing
|
|
33070
|
+
* can reach by ownership again, because its owner is gone.
|
|
33071
|
+
*
|
|
33072
|
+
* So the omission is named and returned, and the caller LOGS it. A branch that
|
|
33073
|
+
* drops work says so; silence reads as "there was nothing to drop".
|
|
33074
|
+
*/
|
|
33075
|
+
function absentCascadeLegs(stores) {
|
|
33076
|
+
const absent = [];
|
|
33077
|
+
if (!stores.faceStore) absent.push("faces");
|
|
33078
|
+
if (!stores.plateStore) absent.push("plates");
|
|
33079
|
+
if (!stores.objectEmbeddingStore) absent.push("embeddings");
|
|
33080
|
+
if (!stores.groupStore) absent.push("groups");
|
|
33081
|
+
return absent;
|
|
33082
|
+
}
|
|
32533
33083
|
function buildAnalyticsCascadeRegistry(stores) {
|
|
32534
33084
|
const registry = [stores.eventStore, stores.mediaStore];
|
|
32535
33085
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
@@ -32806,7 +33356,8 @@ var TrackCloser = class {
|
|
|
32806
33356
|
w: bestSeen.w,
|
|
32807
33357
|
h: bestSeen.h,
|
|
32808
33358
|
frameWidth: bestSeen.frameWidth,
|
|
32809
|
-
frameHeight: bestSeen.frameHeight
|
|
33359
|
+
frameHeight: bestSeen.frameHeight,
|
|
33360
|
+
timestamp: bestSeen.timestamp
|
|
32810
33361
|
} } : {},
|
|
32811
33362
|
recordedAtMs: this.now()
|
|
32812
33363
|
});
|
|
@@ -32822,7 +33373,7 @@ var TrackCloser = class {
|
|
|
32822
33373
|
});
|
|
32823
33374
|
const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
|
|
32824
33375
|
const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
|
|
32825
|
-
this.fireRecognizerEnds(t.deviceId, t.trackId);
|
|
33376
|
+
this.fireRecognizerEnds(t.deviceId, t.trackId, t.positions);
|
|
32826
33377
|
const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
|
|
32827
33378
|
const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
|
|
32828
33379
|
this.deps.residents.close(t.trackId, "end");
|
|
@@ -32881,14 +33432,14 @@ var TrackCloser = class {
|
|
|
32881
33432
|
/** Start face/plate `onTrackEnd` and wire the Design-B DEFERRED
|
|
32882
33433
|
* keyFrame-key drop (drop only once the face promise settles — or
|
|
32883
33434
|
* immediately when no face recognizer is active). */
|
|
32884
|
-
fireRecognizerEnds(deviceId, trackId) {
|
|
33435
|
+
fireRecognizerEnds(deviceId, trackId, subjectPath) {
|
|
32885
33436
|
const faceEnd = this.deps.faceRecognizer()?.onTrackEnd(deviceId, trackId);
|
|
32886
33437
|
const dropKeyFrameKey = () => {
|
|
32887
33438
|
this.deps.residents.dropKeyFrameKey(trackId);
|
|
32888
33439
|
};
|
|
32889
33440
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
32890
33441
|
else dropKeyFrameKey();
|
|
32891
|
-
this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId);
|
|
33442
|
+
this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId, subjectPath);
|
|
32892
33443
|
}
|
|
32893
33444
|
/** Last-resort preview: commit the retained detection-raster subject crop
|
|
32894
33445
|
* as the track's `thumbnail` (honest sub-native, never upscaled). */
|
|
@@ -32958,6 +33509,7 @@ var TrackCloser = class {
|
|
|
32958
33509
|
}
|
|
32959
33510
|
try {
|
|
32960
33511
|
const derived = await derive({
|
|
33512
|
+
deviceId: t.deviceId,
|
|
32961
33513
|
mediaKey: keyFrame.key,
|
|
32962
33514
|
bbox: {
|
|
32963
33515
|
x: bestSeen.x,
|
|
@@ -32967,7 +33519,8 @@ var TrackCloser = class {
|
|
|
32967
33519
|
},
|
|
32968
33520
|
frameWidth: bestSeen.frameWidth,
|
|
32969
33521
|
frameHeight: bestSeen.frameHeight,
|
|
32970
|
-
timestamp:
|
|
33522
|
+
timestamp: bestSeen.timestamp,
|
|
33523
|
+
subjectPath: t.positions
|
|
32971
33524
|
});
|
|
32972
33525
|
if (!derived) {
|
|
32973
33526
|
if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
|
|
@@ -33008,6 +33561,8 @@ var TrackCloser = class {
|
|
|
33008
33561
|
trackId: t.trackId,
|
|
33009
33562
|
keyFrameKey: keyFrame.key,
|
|
33010
33563
|
skewMs: derived.skewMs,
|
|
33564
|
+
motion: derived.motion ?? null,
|
|
33565
|
+
subjectIou: derived.subjectIou ?? null,
|
|
33011
33566
|
replaced: held?.provenance ?? null
|
|
33012
33567
|
}
|
|
33013
33568
|
});
|
|
@@ -34310,6 +34865,303 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
|
|
|
34310
34865
|
events: doomedEvents.length
|
|
34311
34866
|
};
|
|
34312
34867
|
}
|
|
34868
|
+
//#endregion
|
|
34869
|
+
//#region src/pipeline-analytics/retention/media-reclaim-actions.ts
|
|
34870
|
+
/**
|
|
34871
|
+
* The operator button for the debug-media reclaim.
|
|
34872
|
+
*
|
|
34873
|
+
* The mechanism ships armed with nothing: **arming it is the operator's
|
|
34874
|
+
* call**. `mode` omitted means `report` — it counts, logs one line per camera
|
|
34875
|
+
* and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
|
|
34876
|
+
* that has to be typed.
|
|
34877
|
+
*
|
|
34878
|
+
* Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
|
|
34879
|
+
* earned it: dry-run default, explicit pacing, explicit blast radius, and a
|
|
34880
|
+
* report you can read instead of a log line you have to find.
|
|
34881
|
+
*/
|
|
34882
|
+
/**
|
|
34883
|
+
* `report` counts; `reclaim` deletes.
|
|
34884
|
+
*
|
|
34885
|
+
* `.default('report')` is load-bearing: the settings form sends `{}` for a
|
|
34886
|
+
* button with no fields, and the safe reading of an unstated intent is the one
|
|
34887
|
+
* that destroys nothing.
|
|
34888
|
+
*/
|
|
34889
|
+
var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
|
|
34890
|
+
var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
|
|
34891
|
+
var MediaReclaimActionInputSchema = object({
|
|
34892
|
+
mode: MediaReclaimActionModeSchema,
|
|
34893
|
+
/** Which populations to walk. Omitted = both. */
|
|
34894
|
+
scopes: array(MediaReclaimScopeSchema).min(1).optional(),
|
|
34895
|
+
/** Restrict to these cameras. Omitted = every camera in retention scope. */
|
|
34896
|
+
deviceIds: array(number().int()).min(1).optional(),
|
|
34897
|
+
/** Start every device from the top instead of resuming its cursor. */
|
|
34898
|
+
restart: boolean().optional(),
|
|
34899
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
34900
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
34901
|
+
/** Rows EXAMINED per camera in this pass — the pacing bound. */
|
|
34902
|
+
maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
|
|
34903
|
+
/** Rows DELETED per camera in this pass — the blast-radius bound. */
|
|
34904
|
+
maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
|
|
34905
|
+
/** Bytes released across the whole run — the disk-contention bound. */
|
|
34906
|
+
maxBytesPerRun: number().int().min(1).optional(),
|
|
34907
|
+
/** Wall-clock ceiling for the run. */
|
|
34908
|
+
budgetMinutes: number().int().min(1).max(720).optional(),
|
|
34909
|
+
/**
|
|
34910
|
+
* Bytes per second the pass may release. The recordings volume sustains
|
|
34911
|
+
* ~4 MB/s and the archive needs it more than this does; the default is half
|
|
34912
|
+
* of that.
|
|
34913
|
+
*/
|
|
34914
|
+
throttleBytesPerSec: number().int().min(64 * 1024).optional(),
|
|
34915
|
+
/**
|
|
34916
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
34917
|
+
* exclusively save rows, never select them.
|
|
34918
|
+
*/
|
|
34919
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
34920
|
+
});
|
|
34921
|
+
var MediaReclaimDeviceReportSchema = object({
|
|
34922
|
+
deviceId: number(),
|
|
34923
|
+
scope: MediaReclaimScopeSchema,
|
|
34924
|
+
examined: number(),
|
|
34925
|
+
eligible: number(),
|
|
34926
|
+
reclaimed: number(),
|
|
34927
|
+
bytesReclaimed: number(),
|
|
34928
|
+
/** Rows kept because the owning track is pinned (`staging`/`favourited`). */
|
|
34929
|
+
held: number(),
|
|
34930
|
+
/** Rows kept because the owner could not be shown to be a motion event. */
|
|
34931
|
+
unresolved: number(),
|
|
34932
|
+
withinGrace: number(),
|
|
34933
|
+
/** Deletes that REFUSED — the blob survived or the volume could not answer.
|
|
34934
|
+
* The row was kept and the cursor did not pass it. */
|
|
34935
|
+
refused: number(),
|
|
34936
|
+
stoppedBy: string(),
|
|
34937
|
+
/** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
|
|
34938
|
+
nextCursor: number()
|
|
34939
|
+
});
|
|
34940
|
+
var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
|
|
34941
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
34942
|
+
ran: boolean(),
|
|
34943
|
+
mode: _enum(["report", "reclaim"]),
|
|
34944
|
+
devices: array(MediaReclaimDeviceReportSchema),
|
|
34945
|
+
totalExamined: number(),
|
|
34946
|
+
totalEligible: number(),
|
|
34947
|
+
totalReclaimed: number(),
|
|
34948
|
+
totalBytesReclaimed: number(),
|
|
34949
|
+
totalRefused: number(),
|
|
34950
|
+
durationMs: number(),
|
|
34951
|
+
/** Every device+scope reported `exhausted` — there is nothing left to do. */
|
|
34952
|
+
complete: boolean()
|
|
34953
|
+
}), { auth: "admin" }) });
|
|
34954
|
+
//#endregion
|
|
34955
|
+
//#region src/pipeline-analytics/retention/media-reclaim.ts
|
|
34956
|
+
var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
|
|
34957
|
+
var DEFAULT_MEDIA_RECLAIM_LIMITS = {
|
|
34958
|
+
mode: "report",
|
|
34959
|
+
pageSize: 500,
|
|
34960
|
+
maxRowsPerDevice: 5e4,
|
|
34961
|
+
maxReclaimPerDevice: 5e4,
|
|
34962
|
+
maxBytesPerRun: 20 * 1024 * 1024 * 1024,
|
|
34963
|
+
budgetMs: 30 * 6e4,
|
|
34964
|
+
graceMs: 60 * 6e4,
|
|
34965
|
+
throttleBytesPerSec: 2 * 1024 * 1024
|
|
34966
|
+
};
|
|
34967
|
+
function emptyTally() {
|
|
34968
|
+
return {
|
|
34969
|
+
examined: 0,
|
|
34970
|
+
eligible: 0,
|
|
34971
|
+
reclaimed: 0,
|
|
34972
|
+
bytesReclaimed: 0,
|
|
34973
|
+
held: 0,
|
|
34974
|
+
unresolved: 0,
|
|
34975
|
+
withinGrace: 0,
|
|
34976
|
+
refused: 0
|
|
34977
|
+
};
|
|
34978
|
+
}
|
|
34979
|
+
/**
|
|
34980
|
+
* Walk ONE device within ONE scope. Never throws: a probe failure ends the
|
|
34981
|
+
* device with what it has and a cursor that has not moved past anything
|
|
34982
|
+
* unfinished.
|
|
34983
|
+
*/
|
|
34984
|
+
async function reclaimDeviceScope(input) {
|
|
34985
|
+
const { deviceId, scope, deps, limits } = input;
|
|
34986
|
+
const tally = emptyTally();
|
|
34987
|
+
const graceFloor = deps.now() - limits.graceMs;
|
|
34988
|
+
let cursor = Math.max(0, input.cursor);
|
|
34989
|
+
let byteBudget = input.byteBudget;
|
|
34990
|
+
let stoppedBy = "exhausted";
|
|
34991
|
+
for (;;) {
|
|
34992
|
+
if (deps.now() >= input.deadlineMs) {
|
|
34993
|
+
stoppedBy = "budget";
|
|
34994
|
+
break;
|
|
34995
|
+
}
|
|
34996
|
+
if (tally.examined >= limits.maxRowsPerDevice) {
|
|
34997
|
+
stoppedBy = "row-ceiling";
|
|
34998
|
+
break;
|
|
34999
|
+
}
|
|
35000
|
+
const rows = await deps.page({
|
|
35001
|
+
deviceId,
|
|
35002
|
+
scope,
|
|
35003
|
+
afterMs: cursor,
|
|
35004
|
+
limit: limits.pageSize
|
|
35005
|
+
});
|
|
35006
|
+
if (rows.length === 0) break;
|
|
35007
|
+
const ownerIds = [...new Set(rows.map((r) => r.ownerId).filter((id) => id.length > 0))];
|
|
35008
|
+
let motionOwners = /* @__PURE__ */ new Set();
|
|
35009
|
+
let pinnedOwners = /* @__PURE__ */ new Set();
|
|
35010
|
+
try {
|
|
35011
|
+
if (scope === "motion-still") motionOwners = ownerIds.length > 0 ? await deps.motionEventIds(ownerIds) : /* @__PURE__ */ new Set();
|
|
35012
|
+
else pinnedOwners = ownerIds.length > 0 ? await deps.pinnedTrackIds(ownerIds) : /* @__PURE__ */ new Set();
|
|
35013
|
+
} catch (err) {
|
|
35014
|
+
deps.logger.warn("media reclaim: ownership probe failed — device abandoned for this run", {
|
|
35015
|
+
tags: { deviceId },
|
|
35016
|
+
meta: {
|
|
35017
|
+
scope,
|
|
35018
|
+
cursor,
|
|
35019
|
+
error: String(err)
|
|
35020
|
+
}
|
|
35021
|
+
});
|
|
35022
|
+
stoppedBy = "probe-failed";
|
|
35023
|
+
break;
|
|
35024
|
+
}
|
|
35025
|
+
let pageBytes = 0;
|
|
35026
|
+
let halted = null;
|
|
35027
|
+
for (const row of rows) {
|
|
35028
|
+
tally.examined += 1;
|
|
35029
|
+
const advanceTo = row.timestamp;
|
|
35030
|
+
if (row.timestamp > graceFloor) {
|
|
35031
|
+
tally.withinGrace += 1;
|
|
35032
|
+
cursor = advanceTo;
|
|
35033
|
+
continue;
|
|
35034
|
+
}
|
|
35035
|
+
if (scope === "motion-still" && !motionOwners.has(row.ownerId)) {
|
|
35036
|
+
tally.unresolved += 1;
|
|
35037
|
+
cursor = advanceTo;
|
|
35038
|
+
continue;
|
|
35039
|
+
}
|
|
35040
|
+
if (scope === "track-filmstrip" && pinnedOwners.has(row.ownerId)) {
|
|
35041
|
+
tally.held += 1;
|
|
35042
|
+
cursor = advanceTo;
|
|
35043
|
+
continue;
|
|
35044
|
+
}
|
|
35045
|
+
tally.eligible += 1;
|
|
35046
|
+
if (limits.mode === "report") {
|
|
35047
|
+
cursor = advanceTo;
|
|
35048
|
+
continue;
|
|
35049
|
+
}
|
|
35050
|
+
if (tally.reclaimed >= limits.maxReclaimPerDevice) {
|
|
35051
|
+
halted = "reclaim-ceiling";
|
|
35052
|
+
break;
|
|
35053
|
+
}
|
|
35054
|
+
if (byteBudget <= 0) {
|
|
35055
|
+
halted = "byte-ceiling";
|
|
35056
|
+
break;
|
|
35057
|
+
}
|
|
35058
|
+
try {
|
|
35059
|
+
const gone = await deps.reclaim(row.key);
|
|
35060
|
+
tally.reclaimed += gone.rows;
|
|
35061
|
+
tally.bytesReclaimed += gone.bytes;
|
|
35062
|
+
byteBudget -= gone.bytes;
|
|
35063
|
+
pageBytes += gone.bytes;
|
|
35064
|
+
cursor = advanceTo;
|
|
35065
|
+
} catch (err) {
|
|
35066
|
+
tally.refused += 1;
|
|
35067
|
+
deps.logger.warn("media reclaim: delete refused — row kept", {
|
|
35068
|
+
tags: { deviceId },
|
|
35069
|
+
meta: {
|
|
35070
|
+
scope,
|
|
35071
|
+
key: row.key,
|
|
35072
|
+
error: String(err)
|
|
35073
|
+
}
|
|
35074
|
+
});
|
|
35075
|
+
halted = "delete-refused";
|
|
35076
|
+
break;
|
|
35077
|
+
}
|
|
35078
|
+
}
|
|
35079
|
+
if (halted !== null) {
|
|
35080
|
+
stoppedBy = halted;
|
|
35081
|
+
break;
|
|
35082
|
+
}
|
|
35083
|
+
if (limits.throttleBytesPerSec > 0 && pageBytes > 0 && deps.pace) await deps.pace(pageBytes / limits.throttleBytesPerSec * 1e3);
|
|
35084
|
+
if (deps.breathe) await deps.breathe();
|
|
35085
|
+
if (rows.length < limits.pageSize) break;
|
|
35086
|
+
}
|
|
35087
|
+
return {
|
|
35088
|
+
deviceId,
|
|
35089
|
+
scope,
|
|
35090
|
+
examined: tally.examined,
|
|
35091
|
+
eligible: tally.eligible,
|
|
35092
|
+
reclaimed: tally.reclaimed,
|
|
35093
|
+
bytesReclaimed: tally.bytesReclaimed,
|
|
35094
|
+
held: tally.held,
|
|
35095
|
+
unresolved: tally.unresolved,
|
|
35096
|
+
withinGrace: tally.withinGrace,
|
|
35097
|
+
refused: tally.refused,
|
|
35098
|
+
stoppedBy,
|
|
35099
|
+
nextCursor: cursor
|
|
35100
|
+
};
|
|
35101
|
+
}
|
|
35102
|
+
/** Cursor key for one `(device, scope)` pair. */
|
|
35103
|
+
function mediaReclaimCursorKey(deviceId, scope) {
|
|
35104
|
+
return `${scope}:${deviceId}`;
|
|
35105
|
+
}
|
|
35106
|
+
/**
|
|
35107
|
+
* Walk every `(device, scope)` pair under one shared byte budget and one
|
|
35108
|
+
* shared deadline, and log ONE summary line per device — never a line per row.
|
|
35109
|
+
*/
|
|
35110
|
+
async function runMediaReclaim(input) {
|
|
35111
|
+
const startedAt = input.deps.now();
|
|
35112
|
+
const deadlineMs = startedAt + input.limits.budgetMs;
|
|
35113
|
+
const nextCursors = new Map(input.cursors);
|
|
35114
|
+
const devices = [];
|
|
35115
|
+
let byteBudget = input.limits.maxBytesPerRun;
|
|
35116
|
+
for (const scope of input.scopes) for (const deviceId of input.deviceIds) {
|
|
35117
|
+
if (input.deps.now() >= deadlineMs) break;
|
|
35118
|
+
const key = mediaReclaimCursorKey(deviceId, scope);
|
|
35119
|
+
const report = await reclaimDeviceScope({
|
|
35120
|
+
deviceId,
|
|
35121
|
+
scope,
|
|
35122
|
+
cursor: input.cursors.get(key) ?? 0,
|
|
35123
|
+
deps: input.deps,
|
|
35124
|
+
limits: input.limits,
|
|
35125
|
+
deadlineMs,
|
|
35126
|
+
byteBudget
|
|
35127
|
+
});
|
|
35128
|
+
byteBudget -= report.bytesReclaimed;
|
|
35129
|
+
nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
|
|
35130
|
+
devices.push(report);
|
|
35131
|
+
if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
|
|
35132
|
+
tags: { deviceId },
|
|
35133
|
+
meta: {
|
|
35134
|
+
scope,
|
|
35135
|
+
mode: input.limits.mode,
|
|
35136
|
+
examined: report.examined,
|
|
35137
|
+
eligible: report.eligible,
|
|
35138
|
+
reclaimed: report.reclaimed,
|
|
35139
|
+
bytesReclaimed: report.bytesReclaimed,
|
|
35140
|
+
held: report.held,
|
|
35141
|
+
unresolved: report.unresolved,
|
|
35142
|
+
withinGrace: report.withinGrace,
|
|
35143
|
+
refused: report.refused,
|
|
35144
|
+
stoppedBy: report.stoppedBy,
|
|
35145
|
+
nextCursor: report.nextCursor
|
|
35146
|
+
}
|
|
35147
|
+
});
|
|
35148
|
+
}
|
|
35149
|
+
const total = (pick) => devices.reduce((acc, r) => acc + pick(r), 0);
|
|
35150
|
+
return {
|
|
35151
|
+
report: {
|
|
35152
|
+
mode: input.limits.mode,
|
|
35153
|
+
devices,
|
|
35154
|
+
totalExamined: total((r) => r.examined),
|
|
35155
|
+
totalEligible: total((r) => r.eligible),
|
|
35156
|
+
totalReclaimed: total((r) => r.reclaimed),
|
|
35157
|
+
totalBytesReclaimed: total((r) => r.bytesReclaimed),
|
|
35158
|
+
totalRefused: total((r) => r.refused),
|
|
35159
|
+
durationMs: input.deps.now() - startedAt,
|
|
35160
|
+
complete: devices.every((r) => r.stoppedBy === "exhausted")
|
|
35161
|
+
},
|
|
35162
|
+
cursors: nextCursors
|
|
35163
|
+
};
|
|
35164
|
+
}
|
|
34313
35165
|
var OrphanAuditActionInputSchema = object({
|
|
34314
35166
|
mode: _enum(["report", "reclaim"]).default("report"),
|
|
34315
35167
|
/**
|
|
@@ -43478,7 +44330,7 @@ function snapshot(j) {
|
|
|
43478
44330
|
entities: [...entitiesFor(j.mode)],
|
|
43479
44331
|
filesMoved: j.filesMoved,
|
|
43480
44332
|
bytesMoved: j.bytesMoved,
|
|
43481
|
-
filesTotal:
|
|
44333
|
+
filesTotal: j.filesTotal,
|
|
43482
44334
|
startedAt: j.startedAt,
|
|
43483
44335
|
finishedAt: j.finishedAt,
|
|
43484
44336
|
error: j.error
|
|
@@ -43500,6 +44352,40 @@ function rowIsGallery(data) {
|
|
|
43500
44352
|
const ownerKind = String(data["ownerKind"] ?? "");
|
|
43501
44353
|
return isMediaOwnerKind(ownerKind) && isRetentionExemptOwnerKind(ownerKind);
|
|
43502
44354
|
}
|
|
44355
|
+
/**
|
|
44356
|
+
* Which collections a pass walks.
|
|
44357
|
+
*
|
|
44358
|
+
* ONE derivation, shared by the pass and by its pre-count. Two lists would let
|
|
44359
|
+
* an M counted over both collections face an N moved across one — the shape
|
|
44360
|
+
* D295 calls "a guard nobody believes".
|
|
44361
|
+
*/
|
|
44362
|
+
function collectionsFor(mode) {
|
|
44363
|
+
if (mode === "gallery") return [{
|
|
44364
|
+
name: MEDIA_COLLECTION,
|
|
44365
|
+
order: "timestamp"
|
|
44366
|
+
}];
|
|
44367
|
+
return [{
|
|
44368
|
+
name: MEDIA_COLLECTION,
|
|
44369
|
+
order: "timestamp"
|
|
44370
|
+
}, {
|
|
44371
|
+
name: RETRAIN_FRAMES_COLLECTION,
|
|
44372
|
+
order: "copiedAt"
|
|
44373
|
+
}];
|
|
44374
|
+
}
|
|
44375
|
+
/**
|
|
44376
|
+
* Does this pass act on this row at all?
|
|
44377
|
+
*
|
|
44378
|
+
* A free function of `(mode, target)` rather than a method on the job, so the
|
|
44379
|
+
* pre-count that produces `filesTotal` and the walk that produces `filesMoved`
|
|
44380
|
+
* ask the SAME question. A separately-written predicate is how a denominator
|
|
44381
|
+
* ends up counting a different population than the numerator.
|
|
44382
|
+
*/
|
|
44383
|
+
function selectsRow(mode, toLocationId, data, isRetrain) {
|
|
44384
|
+
if (mode === "seal") return rowIsUnstamped(data);
|
|
44385
|
+
if (mediaRowLocation(data) === toLocationId) return false;
|
|
44386
|
+
if (mode === "gallery") return !isRetrain && rowIsGallery(data);
|
|
44387
|
+
return isRetrain || !rowIsGallery(data);
|
|
44388
|
+
}
|
|
43503
44389
|
var MediaRelocateEngine = class {
|
|
43504
44390
|
deps;
|
|
43505
44391
|
jobs = /* @__PURE__ */ new Map();
|
|
@@ -43548,6 +44434,7 @@ var MediaRelocateEngine = class {
|
|
|
43548
44434
|
deviceId: null,
|
|
43549
44435
|
filesMoved: 0,
|
|
43550
44436
|
bytesMoved: 0,
|
|
44437
|
+
filesTotal: null,
|
|
43551
44438
|
startedAt: this.deps.now(),
|
|
43552
44439
|
finishedAt: null,
|
|
43553
44440
|
error: null,
|
|
@@ -43596,12 +44483,39 @@ var MediaRelocateEngine = class {
|
|
|
43596
44483
|
if (rows.length < PAGE_SIZE) return;
|
|
43597
44484
|
}
|
|
43598
44485
|
}
|
|
43599
|
-
/**
|
|
43600
|
-
|
|
43601
|
-
|
|
43602
|
-
|
|
43603
|
-
|
|
43604
|
-
|
|
44486
|
+
/**
|
|
44487
|
+
* How many rows a pass would still act on against `toLocationId`.
|
|
44488
|
+
*
|
|
44489
|
+
* The media lane's **M**, and — read on its own — its residue: "how much of
|
|
44490
|
+
* `eventMedia` is still not on the current default". Both come from here so
|
|
44491
|
+
* neither can drift from what the pass actually moves.
|
|
44492
|
+
*
|
|
44493
|
+
* `null` is "the count could not be taken", never `0`. A zero here reads as
|
|
44494
|
+
* "there is nothing left to move", which is precisely the claim a failed read
|
|
44495
|
+
* must not be allowed to make (D295).
|
|
44496
|
+
*/
|
|
44497
|
+
async countSelectable(input) {
|
|
44498
|
+
return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
|
|
44499
|
+
}
|
|
44500
|
+
async countSelected(mode, toLocationId, stop) {
|
|
44501
|
+
try {
|
|
44502
|
+
let rows = 0;
|
|
44503
|
+
for (const collection of collectionsFor(mode)) {
|
|
44504
|
+
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
44505
|
+
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
44506
|
+
if (stop()) return null;
|
|
44507
|
+
if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
|
|
44508
|
+
}
|
|
44509
|
+
}
|
|
44510
|
+
return { rows };
|
|
44511
|
+
} catch (err) {
|
|
44512
|
+
this.deps.logger.warn("media relocate: row count failed — this pass cannot say how much of the collection it saw", { meta: {
|
|
44513
|
+
mode,
|
|
44514
|
+
toLocationId,
|
|
44515
|
+
error: String(err)
|
|
44516
|
+
} });
|
|
44517
|
+
return null;
|
|
44518
|
+
}
|
|
43605
44519
|
}
|
|
43606
44520
|
async run(job, throttleMbps) {
|
|
43607
44521
|
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -43615,23 +44529,14 @@ var MediaRelocateEngine = class {
|
|
|
43615
44529
|
* rows in silence reads as "there were none", which is how a migration
|
|
43616
44530
|
* gets believed. */
|
|
43617
44531
|
let pathlessRows = 0;
|
|
43618
|
-
|
|
43619
|
-
|
|
43620
|
-
order: "timestamp"
|
|
43621
|
-
}] : [{
|
|
43622
|
-
name: MEDIA_COLLECTION,
|
|
43623
|
-
order: "timestamp"
|
|
43624
|
-
}, {
|
|
43625
|
-
name: RETRAIN_FRAMES_COLLECTION,
|
|
43626
|
-
order: "copiedAt"
|
|
43627
|
-
}];
|
|
43628
|
-
for (const collection of collections) {
|
|
44532
|
+
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
44533
|
+
for (const collection of collectionsFor(job.mode)) {
|
|
43629
44534
|
if (job.cancelRequested) break;
|
|
43630
44535
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
43631
44536
|
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
43632
44537
|
if (job.cancelRequested) break;
|
|
43633
44538
|
const data = row.data;
|
|
43634
|
-
if (!
|
|
44539
|
+
if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
|
|
43635
44540
|
if (job.mode === "seal") {
|
|
43636
44541
|
try {
|
|
43637
44542
|
await this.stamp(collection.name, row.id, data, job.toLocationId);
|
|
@@ -43744,10 +44649,38 @@ var MediaSettingsSchema = object({
|
|
|
43744
44649
|
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
43745
44650
|
* max 2 (200%). */
|
|
43746
44651
|
cropPadding: number().min(0).max(2).default(.15),
|
|
43747
|
-
/**
|
|
43748
|
-
*
|
|
43749
|
-
*
|
|
43750
|
-
|
|
44652
|
+
/**
|
|
44653
|
+
* Per-camera DEBUG media. Default **false**, and it absorbs the retired
|
|
44654
|
+
* `saveThumbnails` rather than sitting beside it (D62: a second switch that
|
|
44655
|
+
* disagrees with the first is worse than no switch).
|
|
44656
|
+
*
|
|
44657
|
+
* What it gates, and only this:
|
|
44658
|
+
* - the periodic per-track `snapshot` filmstrip (`periodic-media-plan.ts`);
|
|
44659
|
+
* - the per-EDGE motion still — the debug surplus that
|
|
44660
|
+
* {@link decideMotionStill} otherwise reduces to one marker per
|
|
44661
|
+
* {@link MediaSettings.motionMarkerQuietMs} drought window.
|
|
44662
|
+
*
|
|
44663
|
+
* What it never gates, at any value: `firstFrame`, the rolling `lastFrame`,
|
|
44664
|
+
* `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
|
|
44665
|
+
* buffers, gallery media, the CLIP search `crop` — and the quiet-period
|
|
44666
|
+
* motion marker itself, which is the operator's fallback and must survive
|
|
44667
|
+
* the default (a blank timeline on a quiet camera reads as broken).
|
|
44668
|
+
*
|
|
44669
|
+
* The rolling `lastFrame` deliberately moved OUT from under the old
|
|
44670
|
+
* `saveThumbnails`: it is in the keep-set, and a debug switch may not take
|
|
44671
|
+
* it away.
|
|
44672
|
+
*/
|
|
44673
|
+
debugMediaEnabled: boolean().default(false),
|
|
44674
|
+
/**
|
|
44675
|
+
* The detection-drought window N for the motion marker: with debug media
|
|
44676
|
+
* off, a motion off→on edge produces a still only when NO detection has run
|
|
44677
|
+
* on this camera for this long AND no marker was produced in the last such
|
|
44678
|
+
* window. Chosen from the observed fleet distribution of detection-free
|
|
44679
|
+
* stretches (median 42 s, p75 201 s, p90 770 s) — 900 s sits just past p90,
|
|
44680
|
+
* so nine stretches in ten produce nothing. See
|
|
44681
|
+
* `services/motion-marker-policy.ts`.
|
|
44682
|
+
*/
|
|
44683
|
+
motionMarkerQuietMs: number().int().min(6e4).max(360 * 6e4).default(9e5),
|
|
43751
44684
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
43752
44685
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
43753
44686
|
* has elapsed since its previous one. */
|
|
@@ -43776,7 +44709,8 @@ function resolveMediaSettings(raw) {
|
|
|
43776
44709
|
};
|
|
43777
44710
|
return {
|
|
43778
44711
|
cropPadding: pick("cropPadding"),
|
|
43779
|
-
|
|
44712
|
+
debugMediaEnabled: pick("debugMediaEnabled"),
|
|
44713
|
+
motionMarkerQuietMs: pick("motionMarkerQuietMs"),
|
|
43780
44714
|
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
43781
44715
|
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
43782
44716
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
@@ -44020,9 +44954,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
44020
44954
|
* can never duplicate one.
|
|
44021
44955
|
*/
|
|
44022
44956
|
function planPeriodicMedia(input) {
|
|
44023
|
-
const appendSnapshot = input.dueSnapshot;
|
|
44957
|
+
const appendSnapshot = input.debugMediaEnabled && input.dueSnapshot;
|
|
44024
44958
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
44025
|
-
const lastFrameElapsed =
|
|
44959
|
+
const lastFrameElapsed = Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
44026
44960
|
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
44027
44961
|
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
44028
44962
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
@@ -44693,6 +45627,90 @@ function padBbox(bbox, padding) {
|
|
|
44693
45627
|
};
|
|
44694
45628
|
}
|
|
44695
45629
|
//#endregion
|
|
45630
|
+
//#region src/pipeline-analytics/pipeline/subject-agreement.ts
|
|
45631
|
+
/**
|
|
45632
|
+
* `subjectAgreement` — the ONE predicate for "a rectangle measured on frame A
|
|
45633
|
+
* is still honest against the pixels of frame B".
|
|
45634
|
+
*
|
|
45635
|
+
* Two durable display crops cut a rectangle out of a frame that is NOT the
|
|
45636
|
+
* frame the rectangle was measured on: the close-time thumbnail derive (the
|
|
45637
|
+
* best-seen subject box, cut from the persisted keyFrame) and the plate
|
|
45638
|
+
* gallery's last-resort crop (the plate rect at the best OCR read, cut from the
|
|
45639
|
+
* same keyFrame). Both were guarded on TIME alone, and time is the wrong
|
|
45640
|
+
* quantity: 1.5 s is nothing for a parked car and hundreds of native pixels for
|
|
45641
|
+
* a car crossing the frame. Confirmed on the operator's debug-marked tracks
|
|
45642
|
+
* (2026-08-30 triage): `e9c024bb` passed the time bound at 531–1179 ms and
|
|
45643
|
+
* stored a windshield and a patch of gravel, while `7613c0fa` — the same path,
|
|
45644
|
+
* 2579 ms of skew, a stationary vehicle — stored a perfectly framed plate.
|
|
45645
|
+
*
|
|
45646
|
+
* The quantity that separates those cases is MOTION, and the track's own
|
|
45647
|
+
* `positions` trail already records it. This asks the trail one question:
|
|
45648
|
+
*
|
|
45649
|
+
* between the instant the rectangle was measured and the instant the pixels
|
|
45650
|
+
* were captured, did the subject stay where it was?
|
|
45651
|
+
*
|
|
45652
|
+
* IoU over the two subject boxes answers it, and because both boxes come from
|
|
45653
|
+
* the SAME trail they are in the same detection-frame pixel space by
|
|
45654
|
+
* construction — there is no cross-space normalization to get wrong.
|
|
45655
|
+
*
|
|
45656
|
+
* D52's one-derivation discipline applied to the guard: this is the only place
|
|
45657
|
+
* the question is answered, and {@link createKeyFrameCrop} is its only caller,
|
|
45658
|
+
* so both defects share one contract instead of two drifting copies.
|
|
45659
|
+
*
|
|
45660
|
+
* `unknown` is a real verdict, not a failure: a track with no trail, or a trail
|
|
45661
|
+
* downsampled away from one of the two instants, carries NO motion evidence.
|
|
45662
|
+
* The caller then falls back to the temporal bound it always had — an absent
|
|
45663
|
+
* read changes nothing (D49).
|
|
45664
|
+
*/
|
|
45665
|
+
function nearestSample(path, atMs, maxSampleGapMs) {
|
|
45666
|
+
let best;
|
|
45667
|
+
let bestGap = Number.POSITIVE_INFINITY;
|
|
45668
|
+
for (const sample of path) {
|
|
45669
|
+
const gap = Math.abs(sample.timestamp - atMs);
|
|
45670
|
+
if (gap < bestGap) {
|
|
45671
|
+
bestGap = gap;
|
|
45672
|
+
best = sample;
|
|
45673
|
+
}
|
|
45674
|
+
}
|
|
45675
|
+
if (best === void 0 || bestGap > maxSampleGapMs) return void 0;
|
|
45676
|
+
return {
|
|
45677
|
+
bbox: best.bbox,
|
|
45678
|
+
gapMs: bestGap
|
|
45679
|
+
};
|
|
45680
|
+
}
|
|
45681
|
+
/**
|
|
45682
|
+
* Answer "did the subject stay put between the two instants?" from the track's
|
|
45683
|
+
* own position trail. Pure — no IO, no clock, no logging: the caller owns the
|
|
45684
|
+
* consequence and the line it writes.
|
|
45685
|
+
*/
|
|
45686
|
+
function subjectAgreement(query) {
|
|
45687
|
+
if (query.path.length === 0) return {
|
|
45688
|
+
kind: "unknown",
|
|
45689
|
+
reason: "no-path"
|
|
45690
|
+
};
|
|
45691
|
+
const atMeasure = nearestSample(query.path, query.measuredAtMs, query.maxSampleGapMs);
|
|
45692
|
+
if (atMeasure === void 0) return {
|
|
45693
|
+
kind: "unknown",
|
|
45694
|
+
reason: "no-sample-near-measure"
|
|
45695
|
+
};
|
|
45696
|
+
const atCut = nearestSample(query.path, query.cutFromMs, query.maxSampleGapMs);
|
|
45697
|
+
if (atCut === void 0) return {
|
|
45698
|
+
kind: "unknown",
|
|
45699
|
+
reason: "no-sample-near-cut"
|
|
45700
|
+
};
|
|
45701
|
+
const overlap = iou$1(atMeasure.bbox, atCut.bbox);
|
|
45702
|
+
const sampleGapMs = Math.max(atMeasure.gapMs, atCut.gapMs);
|
|
45703
|
+
return overlap >= query.minIou ? {
|
|
45704
|
+
kind: "agrees",
|
|
45705
|
+
iou: overlap,
|
|
45706
|
+
sampleGapMs
|
|
45707
|
+
} : {
|
|
45708
|
+
kind: "moved",
|
|
45709
|
+
iou: overlap,
|
|
45710
|
+
sampleGapMs
|
|
45711
|
+
};
|
|
45712
|
+
}
|
|
45713
|
+
//#endregion
|
|
44696
45714
|
//#region src/pipeline-analytics/pipeline/keyframe-crop.ts
|
|
44697
45715
|
function createKeyFrameCrop(deps) {
|
|
44698
45716
|
return async (input) => {
|
|
@@ -44700,12 +45718,36 @@ function createKeyFrameCrop(deps) {
|
|
|
44700
45718
|
const media = await deps.getMedia(input.mediaKey);
|
|
44701
45719
|
if (!media) return null;
|
|
44702
45720
|
const skewMs = Math.abs(media.timestamp - input.timestamp);
|
|
44703
|
-
|
|
44704
|
-
|
|
44705
|
-
|
|
44706
|
-
|
|
44707
|
-
|
|
44708
|
-
|
|
45721
|
+
const verdict = subjectAgreement({
|
|
45722
|
+
path: input.subjectPath ?? [],
|
|
45723
|
+
measuredAtMs: input.timestamp,
|
|
45724
|
+
cutFromMs: media.timestamp,
|
|
45725
|
+
minIou: deps.minSubjectIou,
|
|
45726
|
+
maxSampleGapMs: deps.maxSubjectSampleGapMs
|
|
45727
|
+
});
|
|
45728
|
+
if (verdict.kind === "moved") {
|
|
45729
|
+
deps.logger.info("keyframe crop refused — subject moved between the two instants", {
|
|
45730
|
+
tags: { deviceId: input.deviceId },
|
|
45731
|
+
meta: {
|
|
45732
|
+
mediaKey: input.mediaKey,
|
|
45733
|
+
skewMs,
|
|
45734
|
+
subjectIou: verdict.iou,
|
|
45735
|
+
minSubjectIou: deps.minSubjectIou,
|
|
45736
|
+
sampleGapMs: verdict.sampleGapMs
|
|
45737
|
+
}
|
|
45738
|
+
});
|
|
45739
|
+
return null;
|
|
45740
|
+
}
|
|
45741
|
+
if (verdict.kind === "unknown" && skewMs > deps.maxSkewMs) {
|
|
45742
|
+
deps.logger.info("keyframe crop refused — temporal skew too large, no motion evidence", {
|
|
45743
|
+
tags: { deviceId: input.deviceId },
|
|
45744
|
+
meta: {
|
|
45745
|
+
mediaKey: input.mediaKey,
|
|
45746
|
+
skewMs,
|
|
45747
|
+
maxSkewMs: deps.maxSkewMs,
|
|
45748
|
+
unknownReason: verdict.reason
|
|
45749
|
+
}
|
|
45750
|
+
});
|
|
44709
45751
|
return null;
|
|
44710
45752
|
}
|
|
44711
45753
|
try {
|
|
@@ -44717,15 +45759,25 @@ function createKeyFrameCrop(deps) {
|
|
|
44717
45759
|
w: input.bbox.w / input.frameWidth,
|
|
44718
45760
|
h: input.bbox.h / input.frameHeight
|
|
44719
45761
|
}, input.padding);
|
|
44720
|
-
|
|
44721
|
-
|
|
44722
|
-
|
|
45762
|
+
const jpeg = await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm);
|
|
45763
|
+
return verdict.kind === "agrees" ? {
|
|
45764
|
+
jpeg,
|
|
45765
|
+
skewMs,
|
|
45766
|
+
motion: "agrees",
|
|
45767
|
+
subjectIou: verdict.iou
|
|
45768
|
+
} : {
|
|
45769
|
+
jpeg,
|
|
45770
|
+
skewMs,
|
|
45771
|
+
motion: "unknown"
|
|
44723
45772
|
};
|
|
44724
45773
|
} catch (err) {
|
|
44725
|
-
deps.logger.debug("keyframe crop derive failed", {
|
|
44726
|
-
|
|
44727
|
-
|
|
44728
|
-
|
|
45774
|
+
deps.logger.debug("keyframe crop derive failed", {
|
|
45775
|
+
tags: { deviceId: input.deviceId },
|
|
45776
|
+
meta: {
|
|
45777
|
+
mediaKey: input.mediaKey,
|
|
45778
|
+
error: err instanceof Error ? err.message : String(err)
|
|
45779
|
+
}
|
|
45780
|
+
});
|
|
44729
45781
|
return null;
|
|
44730
45782
|
}
|
|
44731
45783
|
};
|
|
@@ -47568,6 +48620,52 @@ async function applyTrackFlags(deps, input) {
|
|
|
47568
48620
|
};
|
|
47569
48621
|
}
|
|
47570
48622
|
//#endregion
|
|
48623
|
+
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
48624
|
+
/**
|
|
48625
|
+
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
48626
|
+
*
|
|
48627
|
+
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
48628
|
+
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
48629
|
+
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
48630
|
+
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
48631
|
+
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
48632
|
+
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
48633
|
+
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
48634
|
+
* "Prefer unassigned over wrong vehicle."
|
|
48635
|
+
*/
|
|
48636
|
+
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
48637
|
+
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
48638
|
+
maxDistance: 1,
|
|
48639
|
+
lowScoreFloor: .5,
|
|
48640
|
+
lowScoreMaxDistance: 0
|
|
48641
|
+
};
|
|
48642
|
+
function matchPlateText(probe, gallery, opts) {
|
|
48643
|
+
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
48644
|
+
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
48645
|
+
for (const s of gallery) {
|
|
48646
|
+
const dist = plateDistance(probe.text, s.text);
|
|
48647
|
+
const prev = bestByVehicle.get(s.vehicleId);
|
|
48648
|
+
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
48649
|
+
}
|
|
48650
|
+
if (bestByVehicle.size === 0) return null;
|
|
48651
|
+
let bestId = null;
|
|
48652
|
+
let bestDist = Infinity;
|
|
48653
|
+
let tie = false;
|
|
48654
|
+
for (const [vehicleId, dist] of bestByVehicle) {
|
|
48655
|
+
if (dist > allowed) continue;
|
|
48656
|
+
if (dist < bestDist) {
|
|
48657
|
+
bestDist = dist;
|
|
48658
|
+
bestId = vehicleId;
|
|
48659
|
+
tie = false;
|
|
48660
|
+
} else if (dist === bestDist) tie = true;
|
|
48661
|
+
}
|
|
48662
|
+
if (bestId === null || tie) return null;
|
|
48663
|
+
return {
|
|
48664
|
+
vehicleId: bestId,
|
|
48665
|
+
distance: bestDist
|
|
48666
|
+
};
|
|
48667
|
+
}
|
|
48668
|
+
//#endregion
|
|
47571
48669
|
//#region src/pipeline-analytics/pipeline/plate-vote.ts
|
|
47572
48670
|
/**
|
|
47573
48671
|
* Per-character VOTE over a track's plate reads.
|
|
@@ -47709,52 +48807,6 @@ function votePlateText(reads) {
|
|
|
47709
48807
|
return out;
|
|
47710
48808
|
}
|
|
47711
48809
|
//#endregion
|
|
47712
|
-
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
47713
|
-
/**
|
|
47714
|
-
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
47715
|
-
*
|
|
47716
|
-
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
47717
|
-
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
47718
|
-
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
47719
|
-
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
47720
|
-
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
47721
|
-
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
47722
|
-
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
47723
|
-
* "Prefer unassigned over wrong vehicle."
|
|
47724
|
-
*/
|
|
47725
|
-
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
47726
|
-
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
47727
|
-
maxDistance: 1,
|
|
47728
|
-
lowScoreFloor: .5,
|
|
47729
|
-
lowScoreMaxDistance: 0
|
|
47730
|
-
};
|
|
47731
|
-
function matchPlateText(probe, gallery, opts) {
|
|
47732
|
-
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
47733
|
-
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
47734
|
-
for (const s of gallery) {
|
|
47735
|
-
const dist = plateDistance(probe.text, s.text);
|
|
47736
|
-
const prev = bestByVehicle.get(s.vehicleId);
|
|
47737
|
-
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
47738
|
-
}
|
|
47739
|
-
if (bestByVehicle.size === 0) return null;
|
|
47740
|
-
let bestId = null;
|
|
47741
|
-
let bestDist = Infinity;
|
|
47742
|
-
let tie = false;
|
|
47743
|
-
for (const [vehicleId, dist] of bestByVehicle) {
|
|
47744
|
-
if (dist > allowed) continue;
|
|
47745
|
-
if (dist < bestDist) {
|
|
47746
|
-
bestDist = dist;
|
|
47747
|
-
bestId = vehicleId;
|
|
47748
|
-
tie = false;
|
|
47749
|
-
} else if (dist === bestDist) tie = true;
|
|
47750
|
-
}
|
|
47751
|
-
if (bestId === null || tie) return null;
|
|
47752
|
-
return {
|
|
47753
|
-
vehicleId: bestId,
|
|
47754
|
-
distance: bestDist
|
|
47755
|
-
};
|
|
47756
|
-
}
|
|
47757
|
-
//#endregion
|
|
47758
48810
|
//#region src/pipeline-analytics/runtime/failure-report.ts
|
|
47759
48811
|
/**
|
|
47760
48812
|
* What `pipeline-analytics` reports through `failure-contribution` — the four
|
|
@@ -48086,9 +49138,16 @@ var PlateRecognizer = class {
|
|
|
48086
49138
|
bestLabelMatchConfidence(trackId) {
|
|
48087
49139
|
return this.bestPlate.get(trackId)?.score;
|
|
48088
49140
|
}
|
|
48089
|
-
/**
|
|
48090
|
-
*
|
|
48091
|
-
|
|
49141
|
+
/**
|
|
49142
|
+
* Persist the held best plate for a finished track as one PlateStore row
|
|
49143
|
+
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state.
|
|
49144
|
+
*
|
|
49145
|
+
* @param subjectPath the closing track's position trail, supplied by the
|
|
49146
|
+
* closer. It is what tells a parked car (whose plate rect is still honest
|
|
49147
|
+
* seconds later) from a crossing one (whose rect points at the road it has
|
|
49148
|
+
* already left) — the distinction the pure time bound could not make.
|
|
49149
|
+
*/
|
|
49150
|
+
async onTrackEnd(deviceId, trackId, subjectPath) {
|
|
48092
49151
|
const held = this.bestPlate.get(trackId);
|
|
48093
49152
|
this.bestPlate.delete(trackId);
|
|
48094
49153
|
if (held === void 0) return;
|
|
@@ -48097,12 +49156,14 @@ var PlateRecognizer = class {
|
|
|
48097
49156
|
let cropData = held.crop;
|
|
48098
49157
|
if (cropData === void 0 && keyFrameMediaKey !== void 0 && this.deps.deriveCropFromKeyFrame) try {
|
|
48099
49158
|
const derived = await this.deps.deriveCropFromKeyFrame({
|
|
49159
|
+
deviceId,
|
|
48100
49160
|
mediaKey: keyFrameMediaKey,
|
|
48101
49161
|
bbox: held.bbox,
|
|
48102
49162
|
frameWidth: held.frameWidth,
|
|
48103
49163
|
frameHeight: held.frameHeight,
|
|
48104
49164
|
padding: held.cropPadding,
|
|
48105
|
-
timestamp: held.timestamp
|
|
49165
|
+
timestamp: held.timestamp,
|
|
49166
|
+
...subjectPath !== void 0 ? { subjectPath } : {}
|
|
48106
49167
|
});
|
|
48107
49168
|
if (derived) {
|
|
48108
49169
|
cropData = derived.jpeg;
|
|
@@ -48113,10 +49174,18 @@ var PlateRecognizer = class {
|
|
|
48113
49174
|
},
|
|
48114
49175
|
meta: {
|
|
48115
49176
|
plateId,
|
|
48116
|
-
skewMs: derived.skewMs
|
|
49177
|
+
skewMs: derived.skewMs,
|
|
49178
|
+
motion: derived.motion,
|
|
49179
|
+
subjectIou: derived.subjectIou ?? null
|
|
48117
49180
|
}
|
|
48118
49181
|
});
|
|
48119
|
-
}
|
|
49182
|
+
} else this.deps.logger.info("plate crop not recovered — keyFrame derive refused or missed", {
|
|
49183
|
+
tags: {
|
|
49184
|
+
deviceId,
|
|
49185
|
+
trackId
|
|
49186
|
+
},
|
|
49187
|
+
meta: { plateId }
|
|
49188
|
+
});
|
|
48120
49189
|
} catch (err) {
|
|
48121
49190
|
this.deps.logger.debug("PlateRecognizer keyFrame crop derive failed", {
|
|
48122
49191
|
tags: { deviceId },
|
|
@@ -48717,6 +49786,122 @@ async function reconcileGallerySamples(deps, options) {
|
|
|
48717
49786
|
stoppedByCeiling
|
|
48718
49787
|
};
|
|
48719
49788
|
}
|
|
49789
|
+
//#endregion
|
|
49790
|
+
//#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
|
|
49791
|
+
/** The scope id this leg reports and persists its cursor under. */
|
|
49792
|
+
var EMBEDDING_ORPHAN_SCOPE = "vector-index:object-clip";
|
|
49793
|
+
/**
|
|
49794
|
+
* Walk the index once, resuming at `cursor`.
|
|
49795
|
+
*
|
|
49796
|
+
* Never throws: a probe failure ends the pass with what it has and says which
|
|
49797
|
+
* bound stopped it, so a short pass can never be read as a clean one.
|
|
49798
|
+
*/
|
|
49799
|
+
async function scanEmbeddingOrphans(deps, limits, cursor) {
|
|
49800
|
+
const tally = {
|
|
49801
|
+
examined: 0,
|
|
49802
|
+
orphans: 0,
|
|
49803
|
+
reclaimed: 0,
|
|
49804
|
+
kept: 0,
|
|
49805
|
+
withinGrace: 0,
|
|
49806
|
+
unresolvable: 0,
|
|
49807
|
+
notReclaimed: 0
|
|
49808
|
+
};
|
|
49809
|
+
const deviceIds = /* @__PURE__ */ new Set();
|
|
49810
|
+
const graceFloor = deps.now() - limits.graceMs;
|
|
49811
|
+
let offset = Math.max(0, cursor);
|
|
49812
|
+
let stoppedBy = "exhausted";
|
|
49813
|
+
for (;;) {
|
|
49814
|
+
if (tally.examined >= limits.maxRows) {
|
|
49815
|
+
stoppedBy = "row-ceiling";
|
|
49816
|
+
break;
|
|
49817
|
+
}
|
|
49818
|
+
let page;
|
|
49819
|
+
try {
|
|
49820
|
+
page = await deps.scanPage(offset, limits.pageSize);
|
|
49821
|
+
} catch (err) {
|
|
49822
|
+
deps.logger.warn("embedding orphan scan: index page failed — pass abandoned", { meta: {
|
|
49823
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49824
|
+
offset,
|
|
49825
|
+
error: String(err)
|
|
49826
|
+
} });
|
|
49827
|
+
stoppedBy = "page-stalled";
|
|
49828
|
+
break;
|
|
49829
|
+
}
|
|
49830
|
+
const named = page.rows.filter((r) => r.trackId !== null).map((r) => r.trackId ?? "");
|
|
49831
|
+
let live;
|
|
49832
|
+
try {
|
|
49833
|
+
live = named.length > 0 ? await deps.liveTrackIds(named) : /* @__PURE__ */ new Set();
|
|
49834
|
+
} catch (err) {
|
|
49835
|
+
deps.logger.warn("embedding orphan scan: liveness probe failed — pass abandoned", { meta: {
|
|
49836
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49837
|
+
offset,
|
|
49838
|
+
error: String(err)
|
|
49839
|
+
} });
|
|
49840
|
+
stoppedBy = "probe-failed";
|
|
49841
|
+
break;
|
|
49842
|
+
}
|
|
49843
|
+
const doomed = [];
|
|
49844
|
+
let ceilingHit = false;
|
|
49845
|
+
for (const row of page.rows) {
|
|
49846
|
+
tally.examined += 1;
|
|
49847
|
+
if (row.trackId === null) {
|
|
49848
|
+
tally.unresolvable += 1;
|
|
49849
|
+
continue;
|
|
49850
|
+
}
|
|
49851
|
+
if (live.has(row.trackId)) {
|
|
49852
|
+
tally.kept += 1;
|
|
49853
|
+
continue;
|
|
49854
|
+
}
|
|
49855
|
+
if (row.timestamp === null || row.timestamp > graceFloor) {
|
|
49856
|
+
tally.withinGrace += 1;
|
|
49857
|
+
continue;
|
|
49858
|
+
}
|
|
49859
|
+
tally.orphans += 1;
|
|
49860
|
+
if (row.deviceId !== null) deviceIds.add(row.deviceId);
|
|
49861
|
+
if (limits.mode !== "reclaim") continue;
|
|
49862
|
+
if (tally.reclaimed + doomed.length >= limits.maxReclaim) {
|
|
49863
|
+
ceilingHit = true;
|
|
49864
|
+
break;
|
|
49865
|
+
}
|
|
49866
|
+
doomed.push(row.id);
|
|
49867
|
+
}
|
|
49868
|
+
let reclaimedInPage = 0;
|
|
49869
|
+
if (doomed.length > 0) {
|
|
49870
|
+
const outcome = await deps.reclaim(doomed);
|
|
49871
|
+
reclaimedInPage = outcome.deleted.length;
|
|
49872
|
+
tally.reclaimed += outcome.deleted.length;
|
|
49873
|
+
tally.notReclaimed += outcome.stillPresent.length;
|
|
49874
|
+
}
|
|
49875
|
+
if (deps.breathe) await deps.breathe();
|
|
49876
|
+
if (ceilingHit) {
|
|
49877
|
+
stoppedBy = "reclaim-ceiling";
|
|
49878
|
+
offset += page.rows.length - reclaimedInPage;
|
|
49879
|
+
break;
|
|
49880
|
+
}
|
|
49881
|
+
offset += page.rows.length - reclaimedInPage;
|
|
49882
|
+
if (page.nextCursor === null) {
|
|
49883
|
+
offset = 0;
|
|
49884
|
+
break;
|
|
49885
|
+
}
|
|
49886
|
+
if (page.rows.length === 0) {
|
|
49887
|
+
stoppedBy = "page-stalled";
|
|
49888
|
+
break;
|
|
49889
|
+
}
|
|
49890
|
+
}
|
|
49891
|
+
return {
|
|
49892
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49893
|
+
examined: tally.examined,
|
|
49894
|
+
orphans: tally.orphans,
|
|
49895
|
+
reclaimed: tally.reclaimed,
|
|
49896
|
+
kept: tally.kept,
|
|
49897
|
+
withinGrace: tally.withinGrace,
|
|
49898
|
+
unresolvable: tally.unresolvable,
|
|
49899
|
+
notReclaimed: tally.notReclaimed,
|
|
49900
|
+
deviceIds: [...deviceIds],
|
|
49901
|
+
stoppedBy,
|
|
49902
|
+
nextCursor: Math.max(0, offset)
|
|
49903
|
+
};
|
|
49904
|
+
}
|
|
48720
49905
|
/**
|
|
48721
49906
|
* Where the completion marker lives.
|
|
48722
49907
|
*
|
|
@@ -50236,6 +51421,37 @@ var EventStore = class {
|
|
|
50236
51421
|
return found;
|
|
50237
51422
|
}
|
|
50238
51423
|
/**
|
|
51424
|
+
* The SUBSET of `eventIds` that are MOTION events specifically.
|
|
51425
|
+
*
|
|
51426
|
+
* {@link existingEventIds} answers "is this event alive", which is the
|
|
51427
|
+
* orphan audit's question. The media reclaim asks a narrower one: *is this
|
|
51428
|
+
* event-owned snapshot a MOTION-VISIT still* — because the selection shape
|
|
51429
|
+
* (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
|
|
51430
|
+
* Nothing structurally prevents another writer landing an event-owned
|
|
51431
|
+
* snapshot on an object or audio row, and the reclaim must not be the place
|
|
51432
|
+
* that discovers it. A per-page indexed `whereIn` on one table is cheap
|
|
51433
|
+
* where that mistake would not be.
|
|
51434
|
+
*
|
|
51435
|
+
* THROWS on a query failure, same contract and same reason.
|
|
51436
|
+
*/
|
|
51437
|
+
async existingMotionEventIds(eventIds) {
|
|
51438
|
+
const found = /* @__PURE__ */ new Set();
|
|
51439
|
+
for (let i = 0; i < eventIds.length; i += 500) {
|
|
51440
|
+
const chunk = eventIds.slice(i, i + 500);
|
|
51441
|
+
if (chunk.length === 0) continue;
|
|
51442
|
+
const rows = await this.store.query.query({
|
|
51443
|
+
collection: MOTION_EVENTS_COLLECTION,
|
|
51444
|
+
columns: ["id"],
|
|
51445
|
+
filter: {
|
|
51446
|
+
whereIn: { id: [...chunk] },
|
|
51447
|
+
limit: chunk.length
|
|
51448
|
+
}
|
|
51449
|
+
});
|
|
51450
|
+
for (const row of rows) found.add(row.id);
|
|
51451
|
+
}
|
|
51452
|
+
return found;
|
|
51453
|
+
}
|
|
51454
|
+
/**
|
|
50239
51455
|
* Delete all events for a specific device with `timestamp < cutoffMs`
|
|
50240
51456
|
* (exclusive upper bound) across all three kinds, returning the per-kind
|
|
50241
51457
|
* deleted counts and the flattened list of deleted ids (so the caller can
|
|
@@ -50508,11 +51724,21 @@ var SAMPLE_COLUMNS$1 = [
|
|
|
50508
51724
|
type: "TEXT",
|
|
50509
51725
|
notNull: true
|
|
50510
51726
|
},
|
|
51727
|
+
(
|
|
51728
|
+
/**
|
|
51729
|
+
* LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
|
|
51730
|
+
* `identity-arcface` vector index now, keyed by this row's id.
|
|
51731
|
+
*
|
|
51732
|
+
* Still declared and still `notNull`: rows written before the conversion carry
|
|
51733
|
+
* their array, and `loadGallery` falls back to it for exactly as long as one
|
|
51734
|
+
* of them has not been converted. A CONVERTED row holds `[]`, which is both
|
|
51735
|
+
* the idempotence marker and the only way to see the conversion is done.
|
|
51736
|
+
*/
|
|
50511
51737
|
{
|
|
50512
51738
|
name: "embedding",
|
|
50513
51739
|
type: "JSON",
|
|
50514
51740
|
notNull: true
|
|
50515
|
-
},
|
|
51741
|
+
}),
|
|
50516
51742
|
{
|
|
50517
51743
|
name: "mediaKey",
|
|
50518
51744
|
type: "TEXT"
|
|
@@ -50559,9 +51785,11 @@ var SAMPLE_COLUMNS$1 = [
|
|
|
50559
51785
|
];
|
|
50560
51786
|
var IdentityStore = class {
|
|
50561
51787
|
store;
|
|
51788
|
+
vectors;
|
|
50562
51789
|
logger;
|
|
50563
51790
|
constructor(deps) {
|
|
50564
51791
|
this.store = deps.store;
|
|
51792
|
+
this.vectors = deps.vectors;
|
|
50565
51793
|
this.logger = deps.logger;
|
|
50566
51794
|
}
|
|
50567
51795
|
static async declare(store) {
|
|
@@ -50579,6 +51807,41 @@ var IdentityStore = class {
|
|
|
50579
51807
|
}]
|
|
50580
51808
|
});
|
|
50581
51809
|
}
|
|
51810
|
+
/**
|
|
51811
|
+
* How an UNCONVERTED `identity-samples` row becomes an index row.
|
|
51812
|
+
*
|
|
51813
|
+
* `modelId` is `notNull` on this collection, so unlike the faces buffer there
|
|
51814
|
+
* is nothing to infer — a row that somehow holds no model is REFUSED and
|
|
51815
|
+
* keeps its JSON. An enrolled vector stamped with a guessed model is a sample
|
|
51816
|
+
* the matcher would compare across feature spaces, which is the one mistake
|
|
51817
|
+
* this gallery cannot survive.
|
|
51818
|
+
*/
|
|
51819
|
+
conversionSpec() {
|
|
51820
|
+
return {
|
|
51821
|
+
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
51822
|
+
vectors: this.vectors,
|
|
51823
|
+
write: (id, embedding, data) => {
|
|
51824
|
+
const modelId = data["modelId"];
|
|
51825
|
+
if (typeof modelId !== "string" || modelId.length === 0) return null;
|
|
51826
|
+
const identityId = data["identityId"];
|
|
51827
|
+
const deviceId = data["deviceId"];
|
|
51828
|
+
const addedAt = data["addedAt"];
|
|
51829
|
+
const source = data["source"];
|
|
51830
|
+
return {
|
|
51831
|
+
id,
|
|
51832
|
+
embedding,
|
|
51833
|
+
className: IDENTITY_VECTOR_CLASS,
|
|
51834
|
+
modelId,
|
|
51835
|
+
timestamp: typeof addedAt === "number" ? addedAt : 0,
|
|
51836
|
+
...typeof deviceId === "number" ? { deviceId } : {},
|
|
51837
|
+
extra: {
|
|
51838
|
+
...typeof identityId === "string" ? { identityId } : {},
|
|
51839
|
+
...typeof source === "string" ? { source } : {}
|
|
51840
|
+
}
|
|
51841
|
+
};
|
|
51842
|
+
}
|
|
51843
|
+
};
|
|
51844
|
+
}
|
|
50582
51845
|
async createIdentity(input) {
|
|
50583
51846
|
const now = Date.now();
|
|
50584
51847
|
const identity = {
|
|
@@ -50613,11 +51876,13 @@ var IdentityStore = class {
|
|
|
50613
51876
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50614
51877
|
filter: { where: { identityId: id } }
|
|
50615
51878
|
});
|
|
51879
|
+
const droppedSamples = [];
|
|
50616
51880
|
for (const s of samples) try {
|
|
50617
51881
|
await this.store.delete.mutate({
|
|
50618
51882
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50619
51883
|
key: s.id
|
|
50620
51884
|
});
|
|
51885
|
+
droppedSamples.push(s.id);
|
|
50621
51886
|
} catch (err) {
|
|
50622
51887
|
this.logger.warn("deleteIdentity sample delete failed", { meta: {
|
|
50623
51888
|
identityId: id,
|
|
@@ -50625,6 +51890,7 @@ var IdentityStore = class {
|
|
|
50625
51890
|
error: String(err)
|
|
50626
51891
|
} });
|
|
50627
51892
|
}
|
|
51893
|
+
await this.vectors.drop(droppedSamples);
|
|
50628
51894
|
await this.store.delete.mutate({
|
|
50629
51895
|
collection: IDENTITIES_COLLECTION,
|
|
50630
51896
|
key: id
|
|
@@ -50650,7 +51916,6 @@ var IdentityStore = class {
|
|
|
50650
51916
|
const sample = {
|
|
50651
51917
|
id: randomUUID(),
|
|
50652
51918
|
identityId: input.identityId,
|
|
50653
|
-
embedding: input.embedding,
|
|
50654
51919
|
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
|
|
50655
51920
|
...input.sourceMediaKey !== void 0 ? { sourceMediaKey: input.sourceMediaKey } : {},
|
|
50656
51921
|
source: input.source ?? "detected",
|
|
@@ -50659,12 +51924,27 @@ var IdentityStore = class {
|
|
|
50659
51924
|
dim: input.embedding.length,
|
|
50660
51925
|
addedAt: Date.now()
|
|
50661
51926
|
};
|
|
51927
|
+
if (!await this.vectors.put({
|
|
51928
|
+
id: sample.id,
|
|
51929
|
+
embedding: input.embedding,
|
|
51930
|
+
className: "identity",
|
|
51931
|
+
modelId: input.modelId,
|
|
51932
|
+
timestamp: sample.addedAt,
|
|
51933
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
51934
|
+
extra: {
|
|
51935
|
+
identityId: input.identityId,
|
|
51936
|
+
source: sample.source
|
|
51937
|
+
}
|
|
51938
|
+
})) throw new Error(`identity sample was not enrolled: its ${input.embedding.length}-dim vector could not be written to the gallery index`);
|
|
50662
51939
|
const { id, ...rest } = sample;
|
|
50663
51940
|
await this.store.insert.mutate({
|
|
50664
51941
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50665
51942
|
record: {
|
|
50666
51943
|
id,
|
|
50667
|
-
data:
|
|
51944
|
+
data: {
|
|
51945
|
+
...rest,
|
|
51946
|
+
embedding: []
|
|
51947
|
+
}
|
|
50668
51948
|
}
|
|
50669
51949
|
});
|
|
50670
51950
|
const identity = (await this.listIdentities()).find((i) => i.id === input.identityId);
|
|
@@ -50713,6 +51993,7 @@ var IdentityStore = class {
|
|
|
50713
51993
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50714
51994
|
key: sampleId
|
|
50715
51995
|
});
|
|
51996
|
+
await this.vectors.drop([sampleId]);
|
|
50716
51997
|
const identity = (await this.listIdentities()).find((i) => i.id === identityId);
|
|
50717
51998
|
if (identity) {
|
|
50718
51999
|
const patch = {
|
|
@@ -50740,16 +52021,46 @@ var IdentityStore = class {
|
|
|
50740
52021
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50741
52022
|
filter: {}
|
|
50742
52023
|
});
|
|
50743
|
-
const
|
|
52024
|
+
const wanted = [];
|
|
50744
52025
|
for (const r of rows) {
|
|
50745
52026
|
const d = r.data;
|
|
50746
|
-
if (
|
|
50747
|
-
gallery.push({
|
|
50748
|
-
identityId: d.identityId,
|
|
50749
|
-
embedding: d.embedding,
|
|
50750
|
-
modelId: d.modelId
|
|
50751
|
-
});
|
|
52027
|
+
if (typeof d.modelId === "string" && typeof d.identityId === "string") wanted.push(r.id);
|
|
50752
52028
|
}
|
|
52029
|
+
const vectors = await this.vectors.load(wanted);
|
|
52030
|
+
const gallery = [];
|
|
52031
|
+
let fromLegacyColumn = 0;
|
|
52032
|
+
let missing = 0;
|
|
52033
|
+
for (const r of rows) {
|
|
52034
|
+
const d = r.data;
|
|
52035
|
+
if (typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
|
|
52036
|
+
const indexed = vectors.get(r.id);
|
|
52037
|
+
if (indexed !== void 0) {
|
|
52038
|
+
gallery.push({
|
|
52039
|
+
identityId: d.identityId,
|
|
52040
|
+
embedding: indexed,
|
|
52041
|
+
modelId: d.modelId
|
|
52042
|
+
});
|
|
52043
|
+
continue;
|
|
52044
|
+
}
|
|
52045
|
+
const legacy = d.embedding;
|
|
52046
|
+
if (Array.isArray(legacy) && legacy.length > 0) {
|
|
52047
|
+
fromLegacyColumn += 1;
|
|
52048
|
+
gallery.push({
|
|
52049
|
+
identityId: d.identityId,
|
|
52050
|
+
embedding: legacy,
|
|
52051
|
+
modelId: d.modelId
|
|
52052
|
+
});
|
|
52053
|
+
continue;
|
|
52054
|
+
}
|
|
52055
|
+
missing += 1;
|
|
52056
|
+
}
|
|
52057
|
+
if (fromLegacyColumn > 0 || missing > 0) this.logger.warn("identity gallery did not come entirely from the vector index", { meta: {
|
|
52058
|
+
index: this.vectors.name,
|
|
52059
|
+
enrolled: rows.length,
|
|
52060
|
+
loaded: gallery.length,
|
|
52061
|
+
fromLegacyColumn,
|
|
52062
|
+
missing
|
|
52063
|
+
} });
|
|
50753
52064
|
return gallery;
|
|
50754
52065
|
}
|
|
50755
52066
|
/**
|
|
@@ -50785,12 +52096,23 @@ var IdentityStore = class {
|
|
|
50785
52096
|
* holding 0).
|
|
50786
52097
|
*/
|
|
50787
52098
|
async restampSample(input) {
|
|
52099
|
+
if (!await this.vectors.put({
|
|
52100
|
+
id: input.sampleId,
|
|
52101
|
+
embedding: input.embedding,
|
|
52102
|
+
className: "identity",
|
|
52103
|
+
modelId: input.modelId,
|
|
52104
|
+
timestamp: Date.now(),
|
|
52105
|
+
extra: { identityId: input.identityId }
|
|
52106
|
+
})) return {
|
|
52107
|
+
landed: false,
|
|
52108
|
+
reason: "the re-embedded vector did not reach the gallery index"
|
|
52109
|
+
};
|
|
50788
52110
|
try {
|
|
50789
52111
|
await this.store.update.mutate({
|
|
50790
52112
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50791
52113
|
id: input.sampleId,
|
|
50792
52114
|
data: {
|
|
50793
|
-
embedding:
|
|
52115
|
+
embedding: [],
|
|
50794
52116
|
modelId: input.modelId,
|
|
50795
52117
|
dim: input.dim
|
|
50796
52118
|
}
|
|
@@ -53078,6 +54400,53 @@ var TrackStore = class {
|
|
|
53078
54400
|
return found;
|
|
53079
54401
|
}
|
|
53080
54402
|
/**
|
|
54403
|
+
* The SUBSET of `trackIds` that are PINNED — `retrainStatus: 'staging'` or
|
|
54404
|
+
* `favourited: true`.
|
|
54405
|
+
*
|
|
54406
|
+
* A pin is the operator saying "keep this one". It is enforced against the
|
|
54407
|
+
* age sweep (`listIdsBefore` excludes both), and it must be enforced against
|
|
54408
|
+
* a bulk reclaim for exactly the same reason: a starred track whose
|
|
54409
|
+
* filmstrip is gone is a pin that did not hold.
|
|
54410
|
+
*
|
|
54411
|
+
* **The ACTIVE (in-RAM) tracks count as pinned here.** A running track has
|
|
54412
|
+
* no persisted row yet, so the table cannot answer for it; treating an
|
|
54413
|
+
* unanswerable id as unpinned would delete the media of the camera's current
|
|
54414
|
+
* subject. Unknown means protected — the inverse of {@link existingTrackIds},
|
|
54415
|
+
* where unknown means alive, and the same safe direction.
|
|
54416
|
+
*
|
|
54417
|
+
* THROWS on a query failure. A probe that quietly under-reports here reads as
|
|
54418
|
+
* "nothing is pinned".
|
|
54419
|
+
*/
|
|
54420
|
+
async pinnedTrackIds(trackIds) {
|
|
54421
|
+
const PAGE = 500;
|
|
54422
|
+
const found = /* @__PURE__ */ new Set();
|
|
54423
|
+
for (const id of trackIds) if (this.active.has(id)) found.add(id);
|
|
54424
|
+
for (let i = 0; i < trackIds.length; i += PAGE) {
|
|
54425
|
+
const chunk = trackIds.slice(i, i + PAGE);
|
|
54426
|
+
if (chunk.length === 0) continue;
|
|
54427
|
+
const [staging, favourited] = await Promise.all([this.store.query.query({
|
|
54428
|
+
collection: TRACKS_COLLECTION,
|
|
54429
|
+
columns: ["id"],
|
|
54430
|
+
filter: {
|
|
54431
|
+
whereIn: { id: [...chunk] },
|
|
54432
|
+
where: { retrainStatus: "staging" },
|
|
54433
|
+
limit: chunk.length
|
|
54434
|
+
}
|
|
54435
|
+
}), this.store.query.query({
|
|
54436
|
+
collection: TRACKS_COLLECTION,
|
|
54437
|
+
columns: ["id"],
|
|
54438
|
+
filter: {
|
|
54439
|
+
whereIn: { id: [...chunk] },
|
|
54440
|
+
where: { favourited: true },
|
|
54441
|
+
limit: chunk.length
|
|
54442
|
+
}
|
|
54443
|
+
})]);
|
|
54444
|
+
for (const row of staging) found.add(row.id);
|
|
54445
|
+
for (const row of favourited) found.add(row.id);
|
|
54446
|
+
}
|
|
54447
|
+
return found;
|
|
54448
|
+
}
|
|
54449
|
+
/**
|
|
53081
54450
|
* The distinct set of deviceIds that currently have persisted tracks. Feeds
|
|
53082
54451
|
* the per-device retention sweep (design §6) so a device that stopped
|
|
53083
54452
|
* producing frames still has its aged track debris pruned. Paged scan +
|
|
@@ -54480,13 +55849,45 @@ async function runOrphanAudit(input) {
|
|
|
54480
55849
|
* sees the audit in a flame graph needs to be able to make it smaller without a
|
|
54481
55850
|
* release.
|
|
54482
55851
|
*
|
|
54483
|
-
* `mode` defaults to `'
|
|
54484
|
-
* `
|
|
55852
|
+
* `mode` defaults to `'reclaim'` as of 2026-08-30 (D304). It defaulted to
|
|
55853
|
+
* `'report'` from the day the engine shipped, and that default was right for
|
|
55854
|
+
* exactly as long as its premise held — see {@link OrphanAuditModeSchema}.
|
|
54485
55855
|
*/
|
|
54486
55856
|
var ORPHAN_AUDIT_MODE_SETTING = "orphanAuditMode";
|
|
54487
55857
|
var ORPHAN_AUDIT_INTERVAL_SETTING = "orphanAuditIntervalHours";
|
|
54488
55858
|
var ORPHAN_AUDIT_PAGE_SIZE_SETTING = "orphanAuditPageSize";
|
|
54489
|
-
|
|
55859
|
+
/**
|
|
55860
|
+
* `'reclaim'` is the default. Here is the argument, because reversing a
|
|
55861
|
+
* destructive default needs one on the record.
|
|
55862
|
+
*
|
|
55863
|
+
* The original default was `'report'`, and `orphan-audit.ts` states its reason
|
|
55864
|
+
* precisely: *"this engine's correctness rests entirely on an ownership model
|
|
55865
|
+
* that is being rewritten in the same change"*. Report mode existed to make
|
|
55866
|
+
* that model observable BEFORE it was load-bearing.
|
|
55867
|
+
*
|
|
55868
|
+
* That premise is spent. The model has run in production for weeks and has been
|
|
55869
|
+
* read: the `faces` scope reported 99 orphans among its oldest 300 rows and all
|
|
55870
|
+
* 99 were `assigned`, i.e. the exemption doing exactly what it claims; the
|
|
55871
|
+
* vector index reported 956 orphans per pass, of which the D58 re-derivability
|
|
55872
|
+
* check (a vector whose track is gone can neither be re-embedded nor returned)
|
|
55873
|
+
* accounts for all of them. What report mode had left to prove, it proved.
|
|
55874
|
+
*
|
|
55875
|
+
* What it did instead was nothing, forever. `reclaimed: 0` was logged every six
|
|
55876
|
+
* hours against a standing backlog, and a backstop that never acts is not a
|
|
55877
|
+
* backstop — it is a leftover that describes the right design, which this repo
|
|
55878
|
+
* has decided reads as verification and is worse than an absence.
|
|
55879
|
+
*
|
|
55880
|
+
* The blast radius is unchanged and bounded five ways (exempt owner kinds, the
|
|
55881
|
+
* grace window, probe-failure abandons the scope, an unresolvable row is kept,
|
|
55882
|
+
* and a per-scope reclaim ceiling that shows up in the report as `stoppedBy`).
|
|
55883
|
+
* An operator who wants observation-only sets `orphanAuditMode: 'report'` and
|
|
55884
|
+
* gets exactly the old behaviour; the knob did not move, only its default.
|
|
55885
|
+
*
|
|
55886
|
+
* NOTE what this does NOT change: an OPERATOR-triggered pass
|
|
55887
|
+
* (`retention.orphanAudit`) is still dry-run unless it names `mode` — it never
|
|
55888
|
+
* inherits this setting. Reclaiming on demand is still a word you have to type.
|
|
55889
|
+
*/
|
|
55890
|
+
var OrphanAuditModeSchema = _enum(["report", "reclaim"]).default("reclaim");
|
|
54490
55891
|
/** Hours between passes. Hours, not minutes — this walks whole collections. */
|
|
54491
55892
|
var OrphanAuditIntervalHoursSchema = number().min(1).max(168).default(6);
|
|
54492
55893
|
var OrphanAuditPageSizeSchema = number().int().min(50).max(5e3).default(500);
|
|
@@ -54772,8 +56173,12 @@ var BindingCache = class {
|
|
|
54772
56173
|
*
|
|
54773
56174
|
* `confirm` (S4) is the confirmation-gate re-detect crop: it GATES track birth
|
|
54774
56175
|
* (the frame path awaits it), so it ranks WITH `firstFrame` at 0 — it must
|
|
54775
|
-
* jump every queued lower-priority capture
|
|
54776
|
-
*
|
|
56176
|
+
* jump every queued lower-priority capture. It DOES set a `staleAfterMs`, and
|
|
56177
|
+
* it must: priority 0 means it is inserted at the head, so an entry here that
|
|
56178
|
+
* cannot expire makes the whole lane unrecoverable — every later arrival is
|
|
56179
|
+
* inserted at the tail and shed on admission. Its deadline is the gate's own
|
|
56180
|
+
* `timeoutMs`, past which its caller has already discarded the answer; see the
|
|
56181
|
+
* `fetchCrop` note in `pipeline-analytics/index.ts`. `face`/`plate` (S4) are
|
|
54777
56182
|
* enrichment gallery crops — same rank as `embed`: nice-to-have media that
|
|
54778
56183
|
* must never displace the track-media ladder above.
|
|
54779
56184
|
*
|
|
@@ -54818,7 +56223,8 @@ var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
|
|
|
54818
56223
|
var DEFAULT_LOG_WINDOW_MS = 6e4;
|
|
54819
56224
|
/** One `capture queue full` line per device per this interval. The condition
|
|
54820
56225
|
* holds for as long as the lane is saturated; one line per shed request would
|
|
54821
|
-
* be thousands.
|
|
56226
|
+
* be thousands. What the line REPORTS is the whole interval's shed count, not
|
|
56227
|
+
* the one call that happened to break the throttle — see {@link OverflowWindow}. */
|
|
54822
56228
|
var OVERFLOW_WARN_THROTTLE_MS = 6e4;
|
|
54823
56229
|
var CaptureScheduler = class {
|
|
54824
56230
|
logger;
|
|
@@ -54832,8 +56238,8 @@ var CaptureScheduler = class {
|
|
|
54832
56238
|
/** holdKey → refcount of queued/running requests holding it. */
|
|
54833
56239
|
heldKeys = /* @__PURE__ */ new Map();
|
|
54834
56240
|
windows = /* @__PURE__ */ new Map();
|
|
54835
|
-
/** Per-device throttle for the `capture queue full` warn. */
|
|
54836
|
-
|
|
56241
|
+
/** Per-device throttle + shed accumulator for the `capture queue full` warn. */
|
|
56242
|
+
overflowWindows = /* @__PURE__ */ new Map();
|
|
54837
56243
|
onDropped;
|
|
54838
56244
|
onStarted;
|
|
54839
56245
|
constructor(options) {
|
|
@@ -54918,6 +56324,7 @@ var CaptureScheduler = class {
|
|
|
54918
56324
|
this.coalesced.clear();
|
|
54919
56325
|
this.heldKeys.clear();
|
|
54920
56326
|
this.windows.clear();
|
|
56327
|
+
this.overflowWindows.clear();
|
|
54921
56328
|
}
|
|
54922
56329
|
/** Snapshot of the CURRENT window's counters (tests/ops introspection). */
|
|
54923
56330
|
counters(deviceId) {
|
|
@@ -55031,8 +56438,18 @@ var CaptureScheduler = class {
|
|
|
55031
56438
|
if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
|
|
55032
56439
|
});
|
|
55033
56440
|
}
|
|
55034
|
-
/**
|
|
55035
|
-
*
|
|
56441
|
+
/**
|
|
56442
|
+
* Drop every queued entry already past its `staleAfterMs`.
|
|
56443
|
+
*
|
|
56444
|
+
* An entry without one is never expired here — and that is a real hazard, not
|
|
56445
|
+
* a convenience: combined with {@link insertByPriority} + {@link shedToCap},
|
|
56446
|
+
* an unexpirable entry near the HEAD makes a saturated lane unrecoverable,
|
|
56447
|
+
* because every later arrival lands at the tail and is shed on admission.
|
|
56448
|
+
* `confirm` was the case that bit (see the `fetchCrop` note in
|
|
56449
|
+
* `pipeline-analytics/index.ts`); `keyFrame` is the one kind that still omits
|
|
56450
|
+
* a deadline, and it may, because nothing abandons it — a late keyFrame can
|
|
56451
|
+
* still be served from the worker's scene-tile store and lands for real.
|
|
56452
|
+
*/
|
|
55036
56453
|
dropExpired(deviceId, lane) {
|
|
55037
56454
|
const nowMs = this.now();
|
|
55038
56455
|
for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
|
|
@@ -55045,9 +56462,14 @@ var CaptureScheduler = class {
|
|
|
55045
56462
|
entry.drop();
|
|
55046
56463
|
}
|
|
55047
56464
|
}
|
|
55048
|
-
/**
|
|
55049
|
-
*
|
|
55050
|
-
*
|
|
56465
|
+
/**
|
|
56466
|
+
* Shed from the tail (lowest priority, newest within it) until the lane is at
|
|
56467
|
+
* its cap. Loud: a capture that is dropped rather than merely late is a
|
|
56468
|
+
* missing thumbnail or first frame, and silence would read as "never asked".
|
|
56469
|
+
*
|
|
56470
|
+
* The warn is throttled but its `shed` count is NOT the throttled call's — it
|
|
56471
|
+
* is every entry shed since the last line. See {@link OverflowWindow}.
|
|
56472
|
+
*/
|
|
55051
56473
|
shedToCap(deviceId, lane) {
|
|
55052
56474
|
let shed = 0;
|
|
55053
56475
|
while (lane.queue.length > this.maxQueued) {
|
|
@@ -55060,19 +56482,41 @@ var CaptureScheduler = class {
|
|
|
55060
56482
|
}
|
|
55061
56483
|
if (shed === 0) return;
|
|
55062
56484
|
const nowMs = this.now();
|
|
55063
|
-
|
|
55064
|
-
|
|
56485
|
+
const window = this.overflowWindow(deviceId, nowMs);
|
|
56486
|
+
window.shed += shed;
|
|
56487
|
+
if (nowMs - window.lastWarnAt < OVERFLOW_WARN_THROTTLE_MS) return;
|
|
56488
|
+
const reported = window.shed;
|
|
56489
|
+
const sinceMs = window.sinceMs;
|
|
56490
|
+
window.shed = 0;
|
|
56491
|
+
window.sinceMs = nowMs;
|
|
56492
|
+
window.lastWarnAt = nowMs;
|
|
55065
56493
|
this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
|
|
55066
56494
|
tags: { deviceId },
|
|
55067
56495
|
meta: {
|
|
55068
|
-
shed,
|
|
56496
|
+
shed: reported,
|
|
56497
|
+
windowSec: Math.round((nowMs - sinceMs) / 1e3),
|
|
55069
56498
|
cap: this.maxQueued,
|
|
55070
56499
|
queueDepth: lane.queue.length,
|
|
56500
|
+
unexpirable: lane.queue.reduce((total, entry) => total + (entry.staleAfterMs === void 0 ? 1 : 0), 0),
|
|
55071
56501
|
inFlight: lane.inFlight,
|
|
55072
56502
|
budget: this.budget
|
|
55073
56503
|
}
|
|
55074
56504
|
});
|
|
55075
56505
|
}
|
|
56506
|
+
/** The device's shed accumulator, created on first shed. A device that has
|
|
56507
|
+
* never overflowed carries no entry — and its first shed reports
|
|
56508
|
+
* immediately, because the condition is new. */
|
|
56509
|
+
overflowWindow(deviceId, nowMs) {
|
|
56510
|
+
const existing = this.overflowWindows.get(deviceId);
|
|
56511
|
+
if (existing !== void 0) return existing;
|
|
56512
|
+
const created = {
|
|
56513
|
+
sinceMs: nowMs,
|
|
56514
|
+
shed: 0,
|
|
56515
|
+
lastWarnAt: Number.NEGATIVE_INFINITY
|
|
56516
|
+
};
|
|
56517
|
+
this.overflowWindows.set(deviceId, created);
|
|
56518
|
+
return created;
|
|
56519
|
+
}
|
|
55076
56520
|
insertByPriority(lane, entry) {
|
|
55077
56521
|
let index = lane.queue.length;
|
|
55078
56522
|
while (index > 0) {
|
|
@@ -57362,6 +58806,36 @@ var EventMediaDispatcher = class {
|
|
|
57362
58806
|
}
|
|
57363
58807
|
}
|
|
57364
58808
|
};
|
|
58809
|
+
//#endregion
|
|
58810
|
+
//#region src/pipeline-analytics/services/motion-marker-policy.ts
|
|
58811
|
+
/**
|
|
58812
|
+
* Pure. No clock of its own, no store read, no await — it is called on the
|
|
58813
|
+
* motion event path at up to the provider's keepalive rate.
|
|
58814
|
+
*
|
|
58815
|
+
* A non-positive `quietMs` would make every edge a marker (`now - x >= 0` is
|
|
58816
|
+
* true for any mirror), which is the flood this module removes; it is clamped
|
|
58817
|
+
* by the schema (`motionMarkerQuietMs.min`), and the ordering below puts the
|
|
58818
|
+
* detection check first so the reason names the most useful fact.
|
|
58819
|
+
*/
|
|
58820
|
+
function decideMotionStill(input) {
|
|
58821
|
+
if (input.debugMediaEnabled) return {
|
|
58822
|
+
capture: true,
|
|
58823
|
+
reason: "debug"
|
|
58824
|
+
};
|
|
58825
|
+
const { now, quietMs } = input;
|
|
58826
|
+
if (input.lastDetectionAtMs !== void 0 && now - input.lastDetectionAtMs < quietMs) return {
|
|
58827
|
+
capture: false,
|
|
58828
|
+
reason: "detection-recent"
|
|
58829
|
+
};
|
|
58830
|
+
if (input.lastMarkerAtMs !== void 0 && now - input.lastMarkerAtMs < quietMs) return {
|
|
58831
|
+
capture: false,
|
|
58832
|
+
reason: "marker-recent"
|
|
58833
|
+
};
|
|
58834
|
+
return {
|
|
58835
|
+
capture: true,
|
|
58836
|
+
reason: "quiet-marker"
|
|
58837
|
+
};
|
|
58838
|
+
}
|
|
57365
58839
|
var SnapshotTimeoutError$1 = class extends Error {
|
|
57366
58840
|
constructor(ms) {
|
|
57367
58841
|
super(`snapshot cap timed out after ${ms}ms`);
|
|
@@ -57383,24 +58857,65 @@ function withTimeout$1(promise, ms) {
|
|
|
57383
58857
|
var MotionEventSnapshotCapturer = class {
|
|
57384
58858
|
deps;
|
|
57385
58859
|
snapshotTimeoutMs;
|
|
58860
|
+
/** Last ms a marker was written, per device. */
|
|
58861
|
+
lastMarkerAt = /* @__PURE__ */ new Map();
|
|
58862
|
+
/** Edges dropped since that device's last marker — reported on its line. */
|
|
58863
|
+
suppressed = /* @__PURE__ */ new Map();
|
|
57386
58864
|
constructor(deps) {
|
|
57387
58865
|
this.deps = deps;
|
|
57388
58866
|
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
57389
58867
|
}
|
|
57390
58868
|
/**
|
|
57391
|
-
*
|
|
57392
|
-
* throws — the durable record is the
|
|
57393
|
-
* the caller.
|
|
58869
|
+
* Judge the edge, and only if it survives, fetch a still and persist it as
|
|
58870
|
+
* the motion event's snapshot. Never throws — the durable record is the
|
|
58871
|
+
* motion row, already inserted by the caller.
|
|
58872
|
+
*
|
|
58873
|
+
* The two clocks the decision reads live HERE, per device: `lastMarkerAt`
|
|
58874
|
+
* (so a drought yields one marker per window, not one per provider
|
|
58875
|
+
* keepalive) and `suppressed` (so the drop is reported ON the next marker's
|
|
58876
|
+
* line rather than 92 400 times a day).
|
|
57394
58877
|
*/
|
|
57395
58878
|
async capture(input) {
|
|
58879
|
+
const decision = decideMotionStill({
|
|
58880
|
+
debugMediaEnabled: input.policy.debugMediaEnabled,
|
|
58881
|
+
now: input.timestamp,
|
|
58882
|
+
lastDetectionAtMs: input.policy.lastDetectionAtMs,
|
|
58883
|
+
lastMarkerAtMs: this.lastMarkerAt.get(input.deviceId),
|
|
58884
|
+
quietMs: input.policy.quietMs
|
|
58885
|
+
});
|
|
58886
|
+
if (!decision.capture) {
|
|
58887
|
+
this.suppressed.set(input.deviceId, (this.suppressed.get(input.deviceId) ?? 0) + 1);
|
|
58888
|
+
return {
|
|
58889
|
+
captured: false,
|
|
58890
|
+
reason: decision.reason
|
|
58891
|
+
};
|
|
58892
|
+
}
|
|
58893
|
+
this.lastMarkerAt.set(input.deviceId, input.timestamp);
|
|
58894
|
+
const suppressed = this.suppressed.get(input.deviceId) ?? 0;
|
|
58895
|
+
this.suppressed.delete(input.deviceId);
|
|
58896
|
+
if (decision.reason === "quiet-marker") this.deps.logger.info("motion marker still: detection drought", {
|
|
58897
|
+
tags: { deviceId: input.deviceId },
|
|
58898
|
+
meta: {
|
|
58899
|
+
eventId: input.eventId,
|
|
58900
|
+
quietMs: input.policy.quietMs,
|
|
58901
|
+
suppressedSinceLastMarker: suppressed,
|
|
58902
|
+
lastDetectionAtMs: input.policy.lastDetectionAtMs ?? null
|
|
58903
|
+
}
|
|
58904
|
+
});
|
|
57396
58905
|
let snap;
|
|
57397
58906
|
try {
|
|
57398
58907
|
snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
57399
58908
|
} catch (err) {
|
|
57400
58909
|
this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
57401
|
-
return
|
|
58910
|
+
return {
|
|
58911
|
+
captured: false,
|
|
58912
|
+
reason: decision.reason
|
|
58913
|
+
};
|
|
57402
58914
|
}
|
|
57403
|
-
if (snap === null) return
|
|
58915
|
+
if (snap === null) return {
|
|
58916
|
+
captured: false,
|
|
58917
|
+
reason: decision.reason
|
|
58918
|
+
};
|
|
57404
58919
|
const raw = Buffer.from(snap.base64, "base64");
|
|
57405
58920
|
let data = raw;
|
|
57406
58921
|
try {
|
|
@@ -57419,7 +58934,21 @@ var MotionEventSnapshotCapturer = class {
|
|
|
57419
58934
|
});
|
|
57420
58935
|
} catch (err) {
|
|
57421
58936
|
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
58937
|
+
return {
|
|
58938
|
+
captured: false,
|
|
58939
|
+
reason: decision.reason
|
|
58940
|
+
};
|
|
57422
58941
|
}
|
|
58942
|
+
return {
|
|
58943
|
+
captured: true,
|
|
58944
|
+
reason: decision.reason
|
|
58945
|
+
};
|
|
58946
|
+
}
|
|
58947
|
+
/** Drop a device's clocks when it unbinds — same lifetime as every other
|
|
58948
|
+
* per-device mirror on the addon. */
|
|
58949
|
+
forgetDevice(deviceId) {
|
|
58950
|
+
this.lastMarkerAt.delete(deviceId);
|
|
58951
|
+
this.suppressed.delete(deviceId);
|
|
57423
58952
|
}
|
|
57424
58953
|
};
|
|
57425
58954
|
/**
|
|
@@ -57486,6 +59015,78 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
57486
59015
|
}
|
|
57487
59016
|
return landed;
|
|
57488
59017
|
}
|
|
59018
|
+
var SensorTransitionGate = class {
|
|
59019
|
+
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
59020
|
+
lastValues = /* @__PURE__ */ new Map();
|
|
59021
|
+
/** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
|
|
59022
|
+
drops = /* @__PURE__ */ new Map();
|
|
59023
|
+
windowStartedAt;
|
|
59024
|
+
startedAtMs;
|
|
59025
|
+
now;
|
|
59026
|
+
freshnessMs;
|
|
59027
|
+
summaryWindowMs;
|
|
59028
|
+
constructor(options) {
|
|
59029
|
+
this.startedAtMs = options.startedAtMs;
|
|
59030
|
+
this.now = options.now;
|
|
59031
|
+
this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
|
|
59032
|
+
this.summaryWindowMs = options.summaryWindowMs ?? 6e5;
|
|
59033
|
+
this.windowStartedAt = options.now();
|
|
59034
|
+
}
|
|
59035
|
+
/**
|
|
59036
|
+
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
59037
|
+
*
|
|
59038
|
+
* A cap with no boolean level passes through unconditionally. A cap that HAS
|
|
59039
|
+
* one passes only when its boolean moved — in either direction.
|
|
59040
|
+
*/
|
|
59041
|
+
observe(deviceId, capName, slice) {
|
|
59042
|
+
if (!isSourceCap(capName)) return { changed: true };
|
|
59043
|
+
const key = `${deviceId}:${capName}`;
|
|
59044
|
+
const verdict = evaluateSensorEdge({
|
|
59045
|
+
capName,
|
|
59046
|
+
slice,
|
|
59047
|
+
prior: this.lastValues.get(key),
|
|
59048
|
+
startedAtMs: this.startedAtMs,
|
|
59049
|
+
nowMs: this.now(),
|
|
59050
|
+
firstSightingFreshnessMs: this.freshnessMs
|
|
59051
|
+
});
|
|
59052
|
+
if (verdict.edge === "rising") {
|
|
59053
|
+
this.lastValues.set(key, true);
|
|
59054
|
+
return { changed: true };
|
|
59055
|
+
}
|
|
59056
|
+
if (verdict.value !== null) this.lastValues.set(key, verdict.value);
|
|
59057
|
+
if (verdict.reason === "falling-edge") return { changed: true };
|
|
59058
|
+
this.noteDrop(deviceId, capName, verdict.reason);
|
|
59059
|
+
return {
|
|
59060
|
+
changed: false,
|
|
59061
|
+
reason: verdict.reason
|
|
59062
|
+
};
|
|
59063
|
+
}
|
|
59064
|
+
/**
|
|
59065
|
+
* The window's drops, or `[]` while the window is still open. Draining opens
|
|
59066
|
+
* the next window.
|
|
59067
|
+
*
|
|
59068
|
+
* Sorted by `(deviceId, capName, reason)` so a line is comparable with the
|
|
59069
|
+
* previous window's without the reader re-sorting it.
|
|
59070
|
+
*/
|
|
59071
|
+
takeDropSummary(nowMs) {
|
|
59072
|
+
if (nowMs - this.windowStartedAt < this.summaryWindowMs) return [];
|
|
59073
|
+
this.windowStartedAt = nowMs;
|
|
59074
|
+
if (this.drops.size === 0) return [];
|
|
59075
|
+
const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName) || a.reason.localeCompare(b.reason));
|
|
59076
|
+
this.drops.clear();
|
|
59077
|
+
return out;
|
|
59078
|
+
}
|
|
59079
|
+
noteDrop(deviceId, capName, reason) {
|
|
59080
|
+
const key = `${deviceId}:${capName}:${reason}`;
|
|
59081
|
+
const prior = this.drops.get(key);
|
|
59082
|
+
this.drops.set(key, {
|
|
59083
|
+
deviceId,
|
|
59084
|
+
capName,
|
|
59085
|
+
reason,
|
|
59086
|
+
count: (prior?.count ?? 0) + 1
|
|
59087
|
+
});
|
|
59088
|
+
}
|
|
59089
|
+
};
|
|
57489
59090
|
//#endregion
|
|
57490
59091
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
57491
59092
|
/**
|
|
@@ -58669,10 +60270,23 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
58669
60270
|
fields: [
|
|
58670
60271
|
{
|
|
58671
60272
|
type: "boolean",
|
|
58672
|
-
key: "
|
|
58673
|
-
label: "
|
|
58674
|
-
description: "
|
|
58675
|
-
default:
|
|
60273
|
+
key: "debugMediaEnabled",
|
|
60274
|
+
label: "Debug media",
|
|
60275
|
+
description: "Off (the default) keeps first/last frame, key frames, thumbnails and face/plate crops, and reduces motion-only stills to one marker per detection drought. On also stores the per-track filmstrip and a still on EVERY motion transition — diagnostic volume, roughly 95 KiB per transition.",
|
|
60276
|
+
default: false
|
|
60277
|
+
},
|
|
60278
|
+
{
|
|
60279
|
+
type: "slider",
|
|
60280
|
+
key: "motionMarkerQuietMs",
|
|
60281
|
+
label: "Motion marker after",
|
|
60282
|
+
description: "With debug media off: a motion still is kept only when no detection has run on this camera for this long, and at most one per window. Fleet p90 of detection-free stretches is ~13 min.",
|
|
60283
|
+
min: 6e4,
|
|
60284
|
+
max: 216e5,
|
|
60285
|
+
step: 6e4,
|
|
60286
|
+
default: 9e5,
|
|
60287
|
+
showValue: true,
|
|
60288
|
+
unit: "min",
|
|
60289
|
+
displayScale: 6e4
|
|
58676
60290
|
},
|
|
58677
60291
|
{
|
|
58678
60292
|
type: "slider",
|
|
@@ -59181,7 +60795,7 @@ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
|
59181
60795
|
}
|
|
59182
60796
|
/**
|
|
59183
60797
|
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
59184
|
-
* frame. Pure: no side effects. The caller keeps the `
|
|
60798
|
+
* frame. Pure: no side effects. The caller keeps the `debugMediaEnabled` master
|
|
59185
60799
|
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
59186
60800
|
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
59187
60801
|
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
@@ -59347,6 +60961,129 @@ async function runAudioTrackClassMigration(deps, collection) {
|
|
|
59347
60961
|
deps.logger.info("audio track class migration", { meta: { ...stats } });
|
|
59348
60962
|
return stats;
|
|
59349
60963
|
}
|
|
60964
|
+
/** Rows one pass will touch. A ceiling, not a target. */
|
|
60965
|
+
var CONVERSION_MAX_ROWS = 2e4;
|
|
60966
|
+
/** Ids named in the "could not convert" line before it stops. */
|
|
60967
|
+
var REFUSED_IDS_LOGGED = 10;
|
|
60968
|
+
/** A stored `embedding` column, narrowed. */
|
|
60969
|
+
function floatsOf(raw) {
|
|
60970
|
+
if (!Array.isArray(raw)) return null;
|
|
60971
|
+
const out = [];
|
|
60972
|
+
for (const v of raw) {
|
|
60973
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
60974
|
+
out.push(v);
|
|
60975
|
+
}
|
|
60976
|
+
return out;
|
|
60977
|
+
}
|
|
60978
|
+
/**
|
|
60979
|
+
* Convert one collection. Never throws — a failed page ends the pass with what
|
|
60980
|
+
* it has, and the next boot picks the rest up.
|
|
60981
|
+
*/
|
|
60982
|
+
async function convertJsonEmbeddings(spec, deps) {
|
|
60983
|
+
let examined = 0;
|
|
60984
|
+
let alreadyConverted = 0;
|
|
60985
|
+
let converted = 0;
|
|
60986
|
+
let refused = 0;
|
|
60987
|
+
let unusable = 0;
|
|
60988
|
+
let offset = 0;
|
|
60989
|
+
let stoppedBy = "exhausted";
|
|
60990
|
+
const refusedIds = [];
|
|
60991
|
+
while (examined < CONVERSION_MAX_ROWS) {
|
|
60992
|
+
let rows;
|
|
60993
|
+
try {
|
|
60994
|
+
rows = await deps.store.query.query({
|
|
60995
|
+
collection: spec.collection,
|
|
60996
|
+
filter: {
|
|
60997
|
+
orderBy: {
|
|
60998
|
+
field: "id",
|
|
60999
|
+
direction: "asc"
|
|
61000
|
+
},
|
|
61001
|
+
limit: 200,
|
|
61002
|
+
offset
|
|
61003
|
+
}
|
|
61004
|
+
});
|
|
61005
|
+
} catch (err) {
|
|
61006
|
+
deps.logger.warn("embedding conversion: page failed — pass abandoned", { meta: {
|
|
61007
|
+
collection: spec.collection,
|
|
61008
|
+
offset,
|
|
61009
|
+
error: String(err)
|
|
61010
|
+
} });
|
|
61011
|
+
stoppedBy = "page-failed";
|
|
61012
|
+
break;
|
|
61013
|
+
}
|
|
61014
|
+
if (rows.length === 0) break;
|
|
61015
|
+
for (const row of rows) {
|
|
61016
|
+
examined += 1;
|
|
61017
|
+
const data = row.data;
|
|
61018
|
+
const raw = data["embedding"];
|
|
61019
|
+
if (Array.isArray(raw) && raw.length === 0) {
|
|
61020
|
+
alreadyConverted += 1;
|
|
61021
|
+
continue;
|
|
61022
|
+
}
|
|
61023
|
+
const embedding = floatsOf(raw);
|
|
61024
|
+
if (embedding === null || embedding.length === 0) {
|
|
61025
|
+
unusable += 1;
|
|
61026
|
+
continue;
|
|
61027
|
+
}
|
|
61028
|
+
const write = spec.write(row.id, embedding, data);
|
|
61029
|
+
if (write === null || !await spec.vectors.put(write)) {
|
|
61030
|
+
refused += 1;
|
|
61031
|
+
if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
|
|
61032
|
+
continue;
|
|
61033
|
+
}
|
|
61034
|
+
if (((await spec.vectors.load([row.id])).get(row.id)?.length ?? 0) !== embedding.length) {
|
|
61035
|
+
refused += 1;
|
|
61036
|
+
if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
|
|
61037
|
+
continue;
|
|
61038
|
+
}
|
|
61039
|
+
try {
|
|
61040
|
+
await deps.store.update.mutate({
|
|
61041
|
+
collection: spec.collection,
|
|
61042
|
+
id: row.id,
|
|
61043
|
+
data: { embedding: [] }
|
|
61044
|
+
});
|
|
61045
|
+
converted += 1;
|
|
61046
|
+
} catch (err) {
|
|
61047
|
+
refused += 1;
|
|
61048
|
+
deps.logger.warn("embedding conversion: row kept its JSON after a successful index write", { meta: {
|
|
61049
|
+
collection: spec.collection,
|
|
61050
|
+
id: row.id,
|
|
61051
|
+
error: String(err)
|
|
61052
|
+
} });
|
|
61053
|
+
}
|
|
61054
|
+
}
|
|
61055
|
+
if (deps.breathe) await deps.breathe();
|
|
61056
|
+
offset += rows.length;
|
|
61057
|
+
if (rows.length < 200) break;
|
|
61058
|
+
}
|
|
61059
|
+
if (examined >= 2e4) stoppedBy = "row-ceiling";
|
|
61060
|
+
if (refused > 0) deps.logger.warn("embedding conversion: rows could not be moved into the index", { meta: {
|
|
61061
|
+
collection: spec.collection,
|
|
61062
|
+
index: spec.vectors.name,
|
|
61063
|
+
refused,
|
|
61064
|
+
ids: refusedIds,
|
|
61065
|
+
why: "the index refused the write or the row named no model — these rows KEEP their JSON vector and are retried on the next pass"
|
|
61066
|
+
} });
|
|
61067
|
+
deps.logger.info("embedding conversion", { meta: {
|
|
61068
|
+
collection: spec.collection,
|
|
61069
|
+
index: spec.vectors.name,
|
|
61070
|
+
examined,
|
|
61071
|
+
alreadyConverted,
|
|
61072
|
+
converted,
|
|
61073
|
+
refused,
|
|
61074
|
+
unusable,
|
|
61075
|
+
stoppedBy
|
|
61076
|
+
} });
|
|
61077
|
+
return {
|
|
61078
|
+
collection: spec.collection,
|
|
61079
|
+
examined,
|
|
61080
|
+
alreadyConverted,
|
|
61081
|
+
converted,
|
|
61082
|
+
refused,
|
|
61083
|
+
unusable,
|
|
61084
|
+
stoppedBy
|
|
61085
|
+
};
|
|
61086
|
+
}
|
|
59350
61087
|
//#endregion
|
|
59351
61088
|
//#region src/pipeline-analytics/store/object-embedding-store.ts
|
|
59352
61089
|
/**
|
|
@@ -59598,10 +61335,19 @@ var ObjectEmbeddingStore = class {
|
|
|
59598
61335
|
score: match.score
|
|
59599
61336
|
});
|
|
59600
61337
|
}
|
|
61338
|
+
if (result.effectiveTopK < input.limit) this.logger.warn("vector search ran with a smaller topK than requested", {
|
|
61339
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
61340
|
+
meta: {
|
|
61341
|
+
index: OBJECT_CLIP_INDEX,
|
|
61342
|
+
requested: input.limit,
|
|
61343
|
+
effectiveTopK: result.effectiveTopK
|
|
61344
|
+
}
|
|
61345
|
+
});
|
|
59601
61346
|
return {
|
|
59602
61347
|
hits,
|
|
59603
61348
|
scanned: result.scanned,
|
|
59604
|
-
truncated: result.truncated
|
|
61349
|
+
truncated: result.truncated,
|
|
61350
|
+
effectiveTopK: result.effectiveTopK
|
|
59605
61351
|
};
|
|
59606
61352
|
} catch (err) {
|
|
59607
61353
|
this.logger.warn("ObjectEmbeddingStore.searchByVector failed", {
|
|
@@ -59611,7 +61357,109 @@ var ObjectEmbeddingStore = class {
|
|
|
59611
61357
|
return {
|
|
59612
61358
|
hits: [],
|
|
59613
61359
|
scanned: 0,
|
|
59614
|
-
truncated: false
|
|
61360
|
+
truncated: false,
|
|
61361
|
+
effectiveTopK: 0
|
|
61362
|
+
};
|
|
61363
|
+
}
|
|
61364
|
+
}
|
|
61365
|
+
/**
|
|
61366
|
+
* ENUMERATE one page of the index, unranked.
|
|
61367
|
+
*
|
|
61368
|
+
* The orphan scan used to enumerate by asking for a KNN of 20,000 against a
|
|
61369
|
+
* ZERO probe vector. Both halves were wrong. sqlite-vec clamps `k` to 4,096,
|
|
61370
|
+
* so the scan saw 18.5% of the index and logged `examined: 4096` as though it
|
|
61371
|
+
* had walked it; and a zero vector has no direction, so whatever the 4,096
|
|
61372
|
+
* were, they were not a meaningful sample of anything. This asks the question
|
|
61373
|
+
* the reconcile actually has — *what is in here* — through `vectorStore.scan`,
|
|
61374
|
+
* which has no `k` ceiling because it computes no distance.
|
|
61375
|
+
*
|
|
61376
|
+
* An empty page is NOT the end of the index; `nextCursor === null` is.
|
|
61377
|
+
*/
|
|
61378
|
+
async scanRefs(cursor, limit) {
|
|
61379
|
+
const page = await this.vectorStore.scan.query({
|
|
61380
|
+
index: OBJECT_CLIP_INDEX,
|
|
61381
|
+
cursor,
|
|
61382
|
+
limit
|
|
61383
|
+
});
|
|
61384
|
+
const rows = [];
|
|
61385
|
+
for (const item of page.items) {
|
|
61386
|
+
const trackId = item.metadata["trackId"];
|
|
61387
|
+
const deviceId = item.metadata["deviceId"];
|
|
61388
|
+
const timestamp = item.metadata["timestamp"];
|
|
61389
|
+
rows.push({
|
|
61390
|
+
id: item.id,
|
|
61391
|
+
trackId: typeof trackId === "string" && trackId.length > 0 ? trackId : item.id.length > 0 ? item.id : null,
|
|
61392
|
+
deviceId: typeof deviceId === "number" ? deviceId : null,
|
|
61393
|
+
timestamp: typeof timestamp === "number" ? timestamp : null
|
|
61394
|
+
});
|
|
61395
|
+
}
|
|
61396
|
+
return {
|
|
61397
|
+
rows,
|
|
61398
|
+
nextCursor: page.nextCursor
|
|
61399
|
+
};
|
|
61400
|
+
}
|
|
61401
|
+
/**
|
|
61402
|
+
* Delete named vectors and CONFIRM they are gone.
|
|
61403
|
+
*
|
|
61404
|
+
* `deleteByIds` reports a row count the `vec0` backend cannot always produce,
|
|
61405
|
+
* so "the call returned" has never been the same statement as "the row left"
|
|
61406
|
+
* — and this store has already shipped one counter that could not tell those
|
|
61407
|
+
* apart (`rebuilt: 704` against an index that moved by 0). The read-back is
|
|
61408
|
+
* `getByIds`, which returns metadata and no vectors, so confirming a page of
|
|
61409
|
+
* deletions costs one indexed lookup and not one byte of float.
|
|
61410
|
+
*
|
|
61411
|
+
* Returns the ids that are VERIFIABLY gone. Anything still present is
|
|
61412
|
+
* returned as `stillPresent` for the caller to log — a delete that silently
|
|
61413
|
+
* did nothing is how an orphan becomes permanent.
|
|
61414
|
+
*/
|
|
61415
|
+
async deleteVerified(ids) {
|
|
61416
|
+
if (ids.length === 0) return {
|
|
61417
|
+
deleted: [],
|
|
61418
|
+
stillPresent: []
|
|
61419
|
+
};
|
|
61420
|
+
try {
|
|
61421
|
+
await this.vectorStore.deleteByIds.mutate({
|
|
61422
|
+
index: OBJECT_CLIP_INDEX,
|
|
61423
|
+
ids: [...ids]
|
|
61424
|
+
});
|
|
61425
|
+
} catch (err) {
|
|
61426
|
+
this.declaredDim = null;
|
|
61427
|
+
this.logger.warn("object embedding delete failed — vectors kept", { meta: {
|
|
61428
|
+
index: OBJECT_CLIP_INDEX,
|
|
61429
|
+
ids: ids.length,
|
|
61430
|
+
error: String(err)
|
|
61431
|
+
} });
|
|
61432
|
+
return {
|
|
61433
|
+
deleted: [],
|
|
61434
|
+
stillPresent: [...ids]
|
|
61435
|
+
};
|
|
61436
|
+
}
|
|
61437
|
+
try {
|
|
61438
|
+
const readBack = await this.vectorStore.getByIds.query({
|
|
61439
|
+
index: OBJECT_CLIP_INDEX,
|
|
61440
|
+
ids: [...ids]
|
|
61441
|
+
});
|
|
61442
|
+
const survivors = new Set(readBack.items.map((i) => i.id));
|
|
61443
|
+
const deleted = ids.filter((id) => !survivors.has(id));
|
|
61444
|
+
const stillPresent = ids.filter((id) => survivors.has(id));
|
|
61445
|
+
if (stillPresent.length > 0) this.logger.warn("object embedding delete did not land — rows survived the delete", { meta: {
|
|
61446
|
+
index: OBJECT_CLIP_INDEX,
|
|
61447
|
+
asked: ids.length,
|
|
61448
|
+
survived: stillPresent.length
|
|
61449
|
+
} });
|
|
61450
|
+
return {
|
|
61451
|
+
deleted,
|
|
61452
|
+
stillPresent
|
|
61453
|
+
};
|
|
61454
|
+
} catch (err) {
|
|
61455
|
+
this.logger.warn("object embedding delete could not be verified", { meta: {
|
|
61456
|
+
index: OBJECT_CLIP_INDEX,
|
|
61457
|
+
ids: ids.length,
|
|
61458
|
+
error: String(err)
|
|
61459
|
+
} });
|
|
61460
|
+
return {
|
|
61461
|
+
deleted: [],
|
|
61462
|
+
stillPresent: [...ids]
|
|
59615
61463
|
};
|
|
59616
61464
|
}
|
|
59617
61465
|
}
|
|
@@ -59658,13 +61506,15 @@ var ObjectEmbeddingStore = class {
|
|
|
59658
61506
|
deleted: 0,
|
|
59659
61507
|
refused: false
|
|
59660
61508
|
};
|
|
59661
|
-
await this.
|
|
61509
|
+
const { deleted, stillPresent } = await this.deleteVerified(orphans);
|
|
59662
61510
|
this.logger.info("object embedding orphans pruned", { meta: {
|
|
59663
61511
|
orphans: orphans.length,
|
|
61512
|
+
deleted: deleted.length,
|
|
61513
|
+
kept: stillPresent.length,
|
|
59664
61514
|
candidates: input.candidates.length
|
|
59665
61515
|
} });
|
|
59666
61516
|
return {
|
|
59667
|
-
deleted:
|
|
61517
|
+
deleted: deleted.length,
|
|
59668
61518
|
refused: false
|
|
59669
61519
|
};
|
|
59670
61520
|
}
|
|
@@ -59701,25 +61551,15 @@ var ObjectEmbeddingStore = class {
|
|
|
59701
61551
|
rows: 0,
|
|
59702
61552
|
bytes: 0
|
|
59703
61553
|
}];
|
|
59704
|
-
|
|
59705
|
-
|
|
59706
|
-
|
|
59707
|
-
|
|
59708
|
-
|
|
59709
|
-
}
|
|
59710
|
-
this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
|
|
59711
|
-
count: trackIds.length,
|
|
59712
|
-
error: String(err)
|
|
59713
|
-
} });
|
|
59714
|
-
return [{
|
|
59715
|
-
family: "embeddings",
|
|
59716
|
-
rows: 0,
|
|
59717
|
-
bytes: 0
|
|
59718
|
-
}];
|
|
59719
|
-
}
|
|
61554
|
+
const { deleted, stillPresent } = await this.deleteVerified(trackIds);
|
|
61555
|
+
if (stillPresent.length > 0) this.logger.warn("cascade left CLIP vectors behind — they are now unreachable by ownership", { meta: {
|
|
61556
|
+
asked: trackIds.length,
|
|
61557
|
+
kept: stillPresent.length,
|
|
61558
|
+
why: "the track rows are deleted immediately after this leaf, so these vectors can only be reclaimed by the orphan audit"
|
|
61559
|
+
} });
|
|
59720
61560
|
return [{
|
|
59721
61561
|
family: "embeddings",
|
|
59722
|
-
rows:
|
|
61562
|
+
rows: deleted.length,
|
|
59723
61563
|
bytes: 0
|
|
59724
61564
|
}];
|
|
59725
61565
|
}
|
|
@@ -60665,15 +62505,19 @@ function buildStationarySnapshot(input) {
|
|
|
60665
62505
|
*/
|
|
60666
62506
|
var ENCODER_INFO_TTL_MS = 6e4;
|
|
60667
62507
|
/**
|
|
60668
|
-
* Rows the
|
|
62508
|
+
* Rows of the vector index one orphan pass may examine.
|
|
62509
|
+
*
|
|
62510
|
+
* Bounded so a retention tick cannot turn into a full-index scan; the rest is
|
|
62511
|
+
* left for the next tick, resumed from a cursor. Orphans are not urgent, they
|
|
62512
|
+
* are dead weight the search has to skip.
|
|
60669
62513
|
*
|
|
60670
|
-
*
|
|
60671
|
-
*
|
|
60672
|
-
*
|
|
62514
|
+
* **This number is now real.** It used to be handed to a KNN as `topK`, where
|
|
62515
|
+
* sqlite-vec silently clamped it to 4,096 — so the audit saw 18.5% of a 22,128-
|
|
62516
|
+
* row index and reported `examined: 4096` as if it had walked the whole thing.
|
|
62517
|
+
* The pass enumerates through `vectorStore.scan` instead, which has no `k`
|
|
62518
|
+
* ceiling because it computes no distance. See `embedding-orphan-scan.ts`.
|
|
60673
62519
|
*/
|
|
60674
62520
|
var EMBEDDING_ORPHAN_SCAN_LIMIT = 2e4;
|
|
60675
|
-
/** Dimension of the zero probe vector used to enumerate the index. */
|
|
60676
|
-
var EMBEDDING_ORPHAN_PROBE_DIM = 512;
|
|
60677
62521
|
/**
|
|
60678
62522
|
* Tracks fetched per page while rebuilding.
|
|
60679
62523
|
*
|
|
@@ -60741,6 +62585,7 @@ var customActions = {
|
|
|
60741
62585
|
...embeddingActions,
|
|
60742
62586
|
...debugActions,
|
|
60743
62587
|
...orphanAuditActions,
|
|
62588
|
+
...mediaReclaimActions,
|
|
60744
62589
|
...viewerSettingsActions,
|
|
60745
62590
|
...photoEnrollActions,
|
|
60746
62591
|
...faceRescoreActions,
|
|
@@ -60781,11 +62626,38 @@ var FACE_REEMBED_AUTO_CHECK_MS = 6e4;
|
|
|
60781
62626
|
/** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
|
|
60782
62627
|
* scheduled detail call's frameHandle lease is already gone. */
|
|
60783
62628
|
var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
60784
|
-
/** Max |keyFrame −
|
|
60785
|
-
*
|
|
60786
|
-
*
|
|
60787
|
-
*
|
|
62629
|
+
/** Max |keyFrame − measured| skew (ms) accepted for a DURABLE keyFrame-derived
|
|
62630
|
+
* crop when the track's position trail carries NO motion evidence about the
|
|
62631
|
+
* two instants. With evidence the motion guard decides instead — time was
|
|
62632
|
+
* never the quantity that separated a right crop from a wrong one. */
|
|
60788
62633
|
var KEYFRAME_CROP_MAX_SKEW_MS = 1500;
|
|
62634
|
+
/**
|
|
62635
|
+
* Subject-box IoU across the two instants at or above which a keyFrame-derived
|
|
62636
|
+
* crop is accepted whatever the skew.
|
|
62637
|
+
*
|
|
62638
|
+
* Chosen from the 2026-08-30 debug-marked tracks, where each crop's verdict
|
|
62639
|
+
* came from LOOKING at the stored image (`docs/design/
|
|
62640
|
+
* 2026-08-30-debug-marked-tracks-media-triage.md`):
|
|
62641
|
+
*
|
|
62642
|
+
* `e9c024bb` windshield, wrong skew 1179 ms IoU 0.063
|
|
62643
|
+
* `4126f977` gravel, wrong skew 531 ms IoU 0.318
|
|
62644
|
+
* `7613c0fa` plate framed, RIGHT skew 2579 ms IoU 1.000
|
|
62645
|
+
*
|
|
62646
|
+
* Note the ordering: the only correct crop of the three has the LARGEST skew.
|
|
62647
|
+
* The healthy population sits far above the wrong pair too — subject IoU across
|
|
62648
|
+
* a 1500 ms lag is 0.83–1.00 on the eight debug tracks whose tiles frame their
|
|
62649
|
+
* subject. 0.5 is the midpoint of the empirical gap (0.318 → 1.000) and stays
|
|
62650
|
+
* well under that 0.83 floor. A refusal is not a lost tile: the close path
|
|
62651
|
+
* falls through to the retained detection-raster crop, which is right-moment
|
|
62652
|
+
* pixels by construction, so a false refusal costs resolution, not the image.
|
|
62653
|
+
*/
|
|
62654
|
+
var KEYFRAME_CROP_MIN_SUBJECT_IOU = .5;
|
|
62655
|
+
/** How far a position sample may sit from an instant and still be evidence
|
|
62656
|
+
* about it. The trail is one sample per emitted frame and is downsampled only
|
|
62657
|
+
* on long tracks (≈280–385 ms spacing at 60–95 s of life), so a full second
|
|
62658
|
+
* covers a healthy trail many times over; past it the honest answer is "no
|
|
62659
|
+
* evidence" and the temporal bound takes over. */
|
|
62660
|
+
var KEYFRAME_CROP_MAX_SAMPLE_GAP_MS = 1e3;
|
|
60789
62661
|
/** How long the active CLIP model id (from the embedding-encoder) is cached
|
|
60790
62662
|
* before re-reading. */
|
|
60791
62663
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
@@ -60797,7 +62669,9 @@ var CONFIRMATION_DETECTOR_CACHE_TTL_MS = 6e4;
|
|
|
60797
62669
|
* around the subject improves the confirmation detector's recall. */
|
|
60798
62670
|
var CONFIRMATION_CROP_PADDING = .15;
|
|
60799
62671
|
/** Cap the confirmation crop width so a 4K native surface never floods the
|
|
60800
|
-
* transport just to confirm one small box.
|
|
62672
|
+
* transport just to confirm one small box. EXPORTED so a test can recognise a
|
|
62673
|
+
* confirmation round trip among the runner's requests by the real constant
|
|
62674
|
+
* rather than by a copy of the number that would silently stop matching. */
|
|
60801
62675
|
var CONFIRMATION_CROP_MAX_WIDTH = 320;
|
|
60802
62676
|
/** Staleness bound for QUEUED enrichment captures (face/plate gallery crops,
|
|
60803
62677
|
* object-embedding best crops, embedding full-frame fetches — S4). These are
|
|
@@ -60980,6 +62854,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
60980
62854
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
60981
62855
|
* so the DeviceStateChanged handler stays cheap. */
|
|
60982
62856
|
linkedCamerasCache = null;
|
|
62857
|
+
/**
|
|
62858
|
+
* Whether a mapped sensor cap's slice carried a real TRANSITION.
|
|
62859
|
+
*
|
|
62860
|
+
* `DeviceStateChanged` is emitted on a slice diff, and a contact slice
|
|
62861
|
+
* carries `lastChangedAt` beside `entryOpen` — so an availability flap or a
|
|
62862
|
+
* provider-side device rebuild re-emits with the boolean unmoved. Every one
|
|
62863
|
+
* of those became a `SensorEvent` row and a synthetic track until this gate
|
|
62864
|
+
* existed. Built in the field initializer: `startedAtMs` is the moment this
|
|
62865
|
+
* process began watching, which is the floor for a first sighting that is
|
|
62866
|
+
* already active. See `services/sensor-transition-gate.ts`.
|
|
62867
|
+
*/
|
|
62868
|
+
sensorTransitions = new SensorTransitionGate({
|
|
62869
|
+
startedAtMs: Date.now(),
|
|
62870
|
+
now: () => Date.now()
|
|
62871
|
+
});
|
|
60983
62872
|
/** The ONE projector of non-spatial events into synthetic snapshot-carrying
|
|
60984
62873
|
* tracks — linked sensor/control state changes AND audio markers. */
|
|
60985
62874
|
syntheticTracks = null;
|
|
@@ -61661,6 +63550,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61661
63550
|
decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
|
|
61662
63551
|
cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
|
|
61663
63552
|
maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
|
|
63553
|
+
minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
|
|
63554
|
+
maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
|
|
61664
63555
|
logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
|
|
61665
63556
|
});
|
|
61666
63557
|
this._keyFrameThumbnailDerive = async (input) => {
|
|
@@ -61672,7 +63563,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61672
63563
|
return {
|
|
61673
63564
|
thumbnail: derived.jpeg,
|
|
61674
63565
|
thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
|
|
61675
|
-
skewMs: derived.skewMs
|
|
63566
|
+
skewMs: derived.skewMs,
|
|
63567
|
+
motion: derived.motion,
|
|
63568
|
+
...derived.subjectIou !== void 0 ? { subjectIou: derived.subjectIou } : {}
|
|
61676
63569
|
};
|
|
61677
63570
|
};
|
|
61678
63571
|
}
|
|
@@ -61759,6 +63652,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61759
63652
|
* page rather than a full rescan. In RAM: losing them costs a rescan, and
|
|
61760
63653
|
* persisting them would be a durable record of a purely advisory position. */
|
|
61761
63654
|
orphanAuditCursors = /* @__PURE__ */ new Map();
|
|
63655
|
+
/** Resume points for the debug-media reclaim, keyed `<scope>:<deviceId>`.
|
|
63656
|
+
* In RAM like the audit's: the pass is resumable from zero at no cost
|
|
63657
|
+
* beyond re-walking a head that is already empty. */
|
|
63658
|
+
mediaReclaimCursors = /* @__PURE__ */ new Map();
|
|
63659
|
+
/** Single-flight latch — two concurrent passes would double the disk
|
|
63660
|
+
* pressure the pacing exists to avoid. */
|
|
63661
|
+
mediaReclaimRunning = false;
|
|
63662
|
+
/**
|
|
63663
|
+
* The vector-index leg's resume cursor, kept in its OWN field.
|
|
63664
|
+
*
|
|
63665
|
+
* Not a key in {@link orphanAuditCursors}: `runOrphanAudit` returns a map
|
|
63666
|
+
* built from ITS scopes and the caller assigns it wholesale, so a key added
|
|
63667
|
+
* afterwards is dropped on the next run — the walk would silently restart at
|
|
63668
|
+
* row 0 every six hours and never reach the tail of the index, which is the
|
|
63669
|
+
* same failure the `k` clamp already caused once.
|
|
63670
|
+
*/
|
|
63671
|
+
orphanEmbeddingCursor = 0;
|
|
61762
63672
|
/** Re-entrancy guard — two whole-database scans must never overlap on the
|
|
61763
63673
|
* thread that also serves frames. */
|
|
61764
63674
|
orphanAuditRunning = false;
|
|
@@ -61845,6 +63755,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61845
63755
|
designatedNode: designated
|
|
61846
63756
|
} });
|
|
61847
63757
|
}
|
|
63758
|
+
if (this.isPostProcessingNode) this.convertLegacyEmbeddingColumns(api);
|
|
61848
63759
|
const transport = createNativeFrameTransport({
|
|
61849
63760
|
api,
|
|
61850
63761
|
ownNodeId,
|
|
@@ -61896,6 +63807,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61896
63807
|
...embeddingActions,
|
|
61897
63808
|
...debugActions,
|
|
61898
63809
|
...orphanAuditActions,
|
|
63810
|
+
...mediaReclaimActions,
|
|
61899
63811
|
...viewerSettingsActions,
|
|
61900
63812
|
...photoEnrollActions,
|
|
61901
63813
|
...faceRescoreActions,
|
|
@@ -62091,6 +64003,37 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62091
64003
|
});
|
|
62092
64004
|
this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
|
|
62093
64005
|
}
|
|
64006
|
+
/**
|
|
64007
|
+
* Move `faces.embedding` and `identity-samples.embedding` out of JSON text
|
|
64008
|
+
* and into their vector indexes — once, in place, idempotently.
|
|
64009
|
+
*
|
|
64010
|
+
* Not awaited by the boot path and not a schema migration: see
|
|
64011
|
+
* `embedding-json-conversion.ts` for why "converted" is a property of a row
|
|
64012
|
+
* rather than a version number. Every failure mode here is safe — a row whose
|
|
64013
|
+
* index write did not land keeps its array, and both readers fall back to it.
|
|
64014
|
+
*/
|
|
64015
|
+
async convertLegacyEmbeddingColumns(api) {
|
|
64016
|
+
const faceStore = this.faceStore;
|
|
64017
|
+
const identityStore = this.identityStore;
|
|
64018
|
+
if (!faceStore || !identityStore) {
|
|
64019
|
+
this.ctx.logger.warn("embedding conversion skipped — the gallery stores are not up", { meta: {
|
|
64020
|
+
faceStore: faceStore !== null,
|
|
64021
|
+
identityStore: identityStore !== null
|
|
64022
|
+
} });
|
|
64023
|
+
return;
|
|
64024
|
+
}
|
|
64025
|
+
const deps = {
|
|
64026
|
+
store: api.settingsStore,
|
|
64027
|
+
logger: this.ctx.logger.child("EmbeddingConversion"),
|
|
64028
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
64029
|
+
};
|
|
64030
|
+
try {
|
|
64031
|
+
await convertJsonEmbeddings(faceStore.conversionSpec(), deps);
|
|
64032
|
+
await convertJsonEmbeddings(identityStore.conversionSpec(), deps);
|
|
64033
|
+
} catch (err) {
|
|
64034
|
+
this.ctx.logger.warn("embedding conversion pass failed", { meta: { error: String(err) } });
|
|
64035
|
+
}
|
|
64036
|
+
}
|
|
62094
64037
|
async declareCollections(api) {
|
|
62095
64038
|
this.settingsStore = api.settingsStore;
|
|
62096
64039
|
await Promise.all([
|
|
@@ -62423,6 +64366,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62423
64366
|
this.motionEventSnapshots = new MotionEventSnapshotCapturer({
|
|
62424
64367
|
snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
|
|
62425
64368
|
media: mediaStore,
|
|
64369
|
+
logger: logger.child("MotionMarkerStill"),
|
|
62426
64370
|
onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
|
|
62427
64371
|
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
62428
64372
|
meta: {
|
|
@@ -62440,13 +64384,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62440
64384
|
error: errMsg(err)
|
|
62441
64385
|
} })
|
|
62442
64386
|
});
|
|
64387
|
+
const galleryVectorStore = api.vectorStore;
|
|
64388
|
+
if (galleryVectorStore === void 0) throw new Error("pipeline-analytics: the vector-store capability is unavailable, so the face and identity galleries have no home for their vectors — refusing to start recognition without one");
|
|
62443
64389
|
const identityStore = new IdentityStore({
|
|
62444
64390
|
store: api.settingsStore,
|
|
64391
|
+
vectors: new EmbeddingVectorIndex({
|
|
64392
|
+
index: IDENTITY_ARCFACE_INDEX,
|
|
64393
|
+
client: galleryVectorStore,
|
|
64394
|
+
logger: logger.child("IdentityVectors")
|
|
64395
|
+
}),
|
|
62445
64396
|
logger: logger.child("IdentityStore")
|
|
62446
64397
|
});
|
|
62447
64398
|
this.identityStore = identityStore;
|
|
62448
64399
|
const faceStore = new FaceStore({
|
|
62449
64400
|
store: api.settingsStore,
|
|
64401
|
+
vectors: new EmbeddingVectorIndex({
|
|
64402
|
+
index: FACE_ARCFACE_INDEX,
|
|
64403
|
+
client: galleryVectorStore,
|
|
64404
|
+
logger: logger.child("FaceVectors")
|
|
64405
|
+
}),
|
|
62450
64406
|
logger: logger.child("FaceStore")
|
|
62451
64407
|
});
|
|
62452
64408
|
this.faceStore = faceStore;
|
|
@@ -62568,6 +64524,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62568
64524
|
decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
|
|
62569
64525
|
cropRegionToJpeg: async (bytes, w, h, norm) => (await extractCrop(bytes, w, h, norm)).crop,
|
|
62570
64526
|
maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
|
|
64527
|
+
minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
|
|
64528
|
+
maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
|
|
62571
64529
|
logger: logger.child("KeyFrameCrop")
|
|
62572
64530
|
});
|
|
62573
64531
|
this.plateRecognizer = new PlateRecognizer({
|
|
@@ -63395,10 +65353,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
63395
65353
|
const limit = input.candidateLimit ?? 25;
|
|
63396
65354
|
const asked = input.deviceIds;
|
|
63397
65355
|
const known = new Set([...registry.deviceIds(), ...evidence?.deviceIdsWithCandidates() ?? []]);
|
|
63398
|
-
return { devices: (asked === void 0 || asked.length === 0 ? [...known].
|
|
65356
|
+
return { devices: (asked === void 0 || asked.length === 0 ? [...known].toSorted((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
|
|
63399
65357
|
const observedAt = registry.observedAt(deviceId);
|
|
63400
65358
|
const sessionOpenedAt = registry.sessionOpenedAt(deviceId);
|
|
63401
|
-
const ordered = [...evidence?.candidatesFor(deviceId) ?? []].
|
|
65359
|
+
const ordered = [...evidence?.candidatesFor(deviceId) ?? []].toSorted((a, b) => b.agreed - a.agreed);
|
|
63402
65360
|
return {
|
|
63403
65361
|
deviceId,
|
|
63404
65362
|
...sessionOpenedAt !== void 0 ? { sessionOpenedAt } : {},
|
|
@@ -63687,6 +65645,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
63687
65645
|
this.forgetDeviceProcessors(deviceId);
|
|
63688
65646
|
this.levelStateByDevice.delete(deviceId);
|
|
63689
65647
|
this.lastTrackActivityMs.delete(deviceId);
|
|
65648
|
+
this.motionEventSnapshots?.forgetDevice(deviceId);
|
|
63690
65649
|
this.audioEpisodeByDevice.delete(deviceId);
|
|
63691
65650
|
for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
|
|
63692
65651
|
this.audioEpisodeTracks.delete(episodeId);
|
|
@@ -65099,12 +67058,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65099
67058
|
deviceId,
|
|
65100
67059
|
trackId: candidate.trackId,
|
|
65101
67060
|
kind: "confirm",
|
|
67061
|
+
staleAfterMs: config.timeoutMs,
|
|
65102
67062
|
exec: () => captureCrop(frameHandle, {
|
|
65103
67063
|
x: candidate.bbox.x,
|
|
65104
67064
|
y: candidate.bbox.y,
|
|
65105
67065
|
w: candidate.bbox.w,
|
|
65106
67066
|
h: candidate.bbox.h
|
|
65107
|
-
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING,
|
|
67067
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
|
|
65108
67068
|
}),
|
|
65109
67069
|
fetchFallbackCrop: (candidate) => {
|
|
65110
67070
|
const displayCrop = this.captureDisplayCropFn;
|
|
@@ -65114,7 +67074,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65114
67074
|
y: candidate.bbox.y,
|
|
65115
67075
|
w: candidate.bbox.w,
|
|
65116
67076
|
h: candidate.bbox.h
|
|
65117
|
-
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId,
|
|
67077
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
|
|
65118
67078
|
},
|
|
65119
67079
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
65120
67080
|
minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
|
|
@@ -65477,12 +67437,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65477
67437
|
/**
|
|
65478
67438
|
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
65479
67439
|
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
65480
|
-
* • `snapshot` (append)
|
|
65481
|
-
* the
|
|
67440
|
+
* • `snapshot` (append) fires on the snapshotIntervalMs cadence, gated by
|
|
67441
|
+
* `debugMediaEnabled`; the rolling `lastFrame` fires on its own cadence
|
|
67442
|
+
* and is NOT gated (it is in the keep-set);
|
|
65482
67443
|
* • `thumbnail` (best) fires when confidence beats the held best per
|
|
65483
67444
|
* `BEST_FRAME_POLICY` (hysteresis margin, rate-limited to one per
|
|
65484
67445
|
* `minGapMs` — see `pipeline/best-selection-policies.ts`), and is
|
|
65485
|
-
*
|
|
67446
|
+
* never gated (a single best still-frame is always useful).
|
|
65486
67447
|
* The per-track best-confidence map is updated here as a side effect.
|
|
65487
67448
|
*/
|
|
65488
67449
|
/**
|
|
@@ -65894,9 +67855,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65894
67855
|
});
|
|
65895
67856
|
continue;
|
|
65896
67857
|
}
|
|
65897
|
-
const
|
|
65898
|
-
|
|
65899
|
-
lastSnapshotAt: lastSnap,
|
|
67858
|
+
const dueSnapshot = evaluatePeriodicSnapshot({
|
|
67859
|
+
lastSnapshotAt: this.trackStore.lastSnapshotAt(t.trackId),
|
|
65900
67860
|
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
65901
67861
|
currentBbox: t.bbox,
|
|
65902
67862
|
now: timestamp,
|
|
@@ -65921,7 +67881,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65921
67881
|
const isNewBest = this.residents.observeBestFrame(deviceId, t.trackId, t.confidence, timestamp, edgeClear, centerScore, containment, sizeScore, squareFraction, false);
|
|
65922
67882
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
65923
67883
|
const plan = planPeriodicMedia({
|
|
65924
|
-
|
|
67884
|
+
debugMediaEnabled: media.debugMediaEnabled,
|
|
65925
67885
|
dueSnapshot,
|
|
65926
67886
|
isNewBest,
|
|
65927
67887
|
thumbnailLanded: this.residents.isThumbnailLanded(t.trackId),
|
|
@@ -66350,11 +68310,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
66350
68310
|
timestamp: ev.timestamp
|
|
66351
68311
|
}
|
|
66352
68312
|
});
|
|
66353
|
-
if (transitionedOn) await this.
|
|
66354
|
-
deviceId,
|
|
66355
|
-
eventId: ev.id,
|
|
66356
|
-
timestamp: ev.timestamp
|
|
66357
|
-
});
|
|
68313
|
+
if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
|
|
66358
68314
|
}
|
|
66359
68315
|
/**
|
|
66360
68316
|
* Persist firmware-driven (onboard) motion events. Mirrors
|
|
@@ -66429,10 +68385,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
66429
68385
|
timestamp: ev.timestamp
|
|
66430
68386
|
}
|
|
66431
68387
|
});
|
|
66432
|
-
if (transitionedOn) await this.
|
|
68388
|
+
if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
|
|
68389
|
+
}
|
|
68390
|
+
/**
|
|
68391
|
+
* The gate in front of the motion still. Both motion handlers reach it, so
|
|
68392
|
+
* there is exactly one place that decides.
|
|
68393
|
+
*
|
|
68394
|
+
* With debug media OFF (the default) the still is the operator's fallback
|
|
68395
|
+
* marker: produced only during a DETECTION DROUGHT, once per
|
|
68396
|
+
* `motionMarkerQuietMs` window. See `services/motion-marker-policy.ts` for
|
|
68397
|
+
* why the raw edge is not a scene fact — it is the provider's ~1 Hz
|
|
68398
|
+
* keepalive beating against the analyzer's per-frame sample through one
|
|
68399
|
+
* shared edge map.
|
|
68400
|
+
*
|
|
68401
|
+
* A dropped write is REPORTED, but as a per-window summary and never per
|
|
68402
|
+
* event: the suppressed count since the last marker rides on the marker's
|
|
68403
|
+
* own line. At 92 400 suppressions/day a line each would be the flood the
|
|
68404
|
+
* logging rules exist to prevent, and a silent branch reads as "never
|
|
68405
|
+
* happened".
|
|
68406
|
+
*/
|
|
68407
|
+
async captureMotionMarkerStill(deviceId, eventId, timestamp) {
|
|
68408
|
+
const capturer = this.motionEventSnapshots;
|
|
68409
|
+
if (!capturer) return;
|
|
68410
|
+
const media = await this.resolveDeviceMediaSettings(deviceId);
|
|
68411
|
+
await capturer.capture({
|
|
66433
68412
|
deviceId,
|
|
66434
|
-
eventId
|
|
66435
|
-
timestamp
|
|
68413
|
+
eventId,
|
|
68414
|
+
timestamp,
|
|
68415
|
+
policy: {
|
|
68416
|
+
debugMediaEnabled: media.debugMediaEnabled,
|
|
68417
|
+
quietMs: media.motionMarkerQuietMs,
|
|
68418
|
+
lastDetectionAtMs: this.lastTrackActivityMs.get(deviceId)
|
|
68419
|
+
}
|
|
66436
68420
|
});
|
|
66437
68421
|
}
|
|
66438
68422
|
async sweepExpiredTracks() {
|
|
@@ -67171,7 +69155,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67171
69155
|
};
|
|
67172
69156
|
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
67173
69157
|
else dropKeyFrame();
|
|
67174
|
-
this.
|
|
69158
|
+
const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
|
|
69159
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
|
|
67175
69160
|
this.residents.close(track.trackId, "stationary");
|
|
67176
69161
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
67177
69162
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
@@ -67509,11 +69494,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67509
69494
|
* press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
|
|
67510
69495
|
* event is a missing history row and a missing notification — never wrong
|
|
67511
69496
|
* durable state.
|
|
69497
|
+
*
|
|
69498
|
+
* The FIRST question is whether the sensor changed at all. This event is
|
|
69499
|
+
* emitted on a slice diff, and a level cap's slice carries its transition
|
|
69500
|
+
* timestamp beside its boolean — so an availability flap or a device rebuild
|
|
69501
|
+
* re-emits with the boolean unmoved. Both the row and the marker are gated on
|
|
69502
|
+
* the same verdict, in that one place: two derivations of "did this happen"
|
|
69503
|
+
* is one too many, and it is the mistake this handler already made once with
|
|
69504
|
+
* "which cameras" (`resolveSensorEventCameras`).
|
|
67512
69505
|
*/
|
|
67513
69506
|
async handleSensorStateChanged(data, timestamp) {
|
|
67514
69507
|
const store = this.sensorEventStore;
|
|
67515
69508
|
const cache = this.linkedCamerasCache;
|
|
67516
69509
|
if (store === null || cache === null) return;
|
|
69510
|
+
if (!this.sensorSliceCarriedATransition(data)) return;
|
|
67517
69511
|
const persisted = [];
|
|
67518
69512
|
try {
|
|
67519
69513
|
await ingestSensorStateChange({
|
|
@@ -67577,6 +69571,55 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67577
69571
|
for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
67578
69572
|
}
|
|
67579
69573
|
/**
|
|
69574
|
+
* True when this slice carried a real transition of its sensor.
|
|
69575
|
+
*
|
|
69576
|
+
* A cap with no boolean level (`doorbell`, `button`, `lock-control`,
|
|
69577
|
+
* `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
|
|
69578
|
+
* the event, and gating it would re-ship the silence of 2026-08-08. A cap
|
|
69579
|
+
* that HAS one passes only when the boolean moved, in either direction.
|
|
69580
|
+
*
|
|
69581
|
+
* A slice that is not an object at all is refused rather than defaulted to
|
|
69582
|
+
* `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
|
|
69583
|
+
* object, which looks like a sensor reporting nonsense when what happened is
|
|
69584
|
+
* that there was nothing to read. Same discipline as the recorder's
|
|
69585
|
+
* `parseSensorStateChanged`.
|
|
69586
|
+
*
|
|
69587
|
+
* The drops are SUMMARISED, never logged one by one: this path carries every
|
|
69588
|
+
* camera's native `motion` slice at motion rate.
|
|
69589
|
+
*/
|
|
69590
|
+
sensorSliceCarriedATransition(data) {
|
|
69591
|
+
const slice = data.slice;
|
|
69592
|
+
if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
|
|
69593
|
+
this.ctx.logger.debug("sensor state change carried no slice — dropped", {
|
|
69594
|
+
tags: { deviceId: data.deviceId },
|
|
69595
|
+
meta: { capName: data.capName }
|
|
69596
|
+
});
|
|
69597
|
+
return false;
|
|
69598
|
+
}
|
|
69599
|
+
const verdict = this.sensorTransitions.observe(data.deviceId, data.capName, { ...slice });
|
|
69600
|
+
this.flushSensorTransitionDrops();
|
|
69601
|
+
return verdict.changed;
|
|
69602
|
+
}
|
|
69603
|
+
/**
|
|
69604
|
+
* One `info` line per (device, cap, reason) per window, or none.
|
|
69605
|
+
*
|
|
69606
|
+
* A branch that drops work says so — but this one drops ~0.6 events/s
|
|
69607
|
+
* fleet-wide in its steady state (every camera's native `motion`), so what it
|
|
69608
|
+
* says is a COUNT. The line an operator greps when a real contact produced no
|
|
69609
|
+
* marker is here, with the numeric `deviceId` of the SENSOR, and it says
|
|
69610
|
+
* which verdict swallowed it.
|
|
69611
|
+
*/
|
|
69612
|
+
flushSensorTransitionDrops() {
|
|
69613
|
+
for (const drop of this.sensorTransitions.takeDropSummary(Date.now())) this.ctx.logger.info("sensor state changes dropped — no transition", {
|
|
69614
|
+
tags: { deviceId: drop.deviceId },
|
|
69615
|
+
meta: {
|
|
69616
|
+
capName: drop.capName,
|
|
69617
|
+
reason: drop.reason,
|
|
69618
|
+
count: drop.count
|
|
69619
|
+
}
|
|
69620
|
+
});
|
|
69621
|
+
}
|
|
69622
|
+
/**
|
|
67580
69623
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|
|
67581
69624
|
*
|
|
67582
69625
|
* BOTH branches are `debug`, deliberately. After the self-camera fix neither
|
|
@@ -67755,25 +69798,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67755
69798
|
return runner.runStatelessStep.mutate(input, nodePin(nodeId));
|
|
67756
69799
|
}
|
|
67757
69800
|
/**
|
|
67758
|
-
* Every track id the track store still holds, across the given devices.
|
|
67759
|
-
*
|
|
67760
|
-
* Returned as a Set for the orphan sweep, which REFUSES an empty one — a
|
|
67761
|
-
* store that failed to answer must not be read as "no tracks exist".
|
|
67762
|
-
*/
|
|
67763
|
-
async collectLiveTrackIds(deviceIds) {
|
|
67764
|
-
const live = /* @__PURE__ */ new Set();
|
|
67765
|
-
const trackStore = this.trackStore;
|
|
67766
|
-
if (!trackStore) return live;
|
|
67767
|
-
for (const deviceId of deviceIds) {
|
|
67768
|
-
const page = await trackStore.queryRecentIds({
|
|
67769
|
-
deviceIds: [deviceId],
|
|
67770
|
-
limit: EMBEDDING_ORPHAN_SCAN_LIMIT
|
|
67771
|
-
});
|
|
67772
|
-
for (const trackId of page.trackIds) live.add(trackId);
|
|
67773
|
-
}
|
|
67774
|
-
return live;
|
|
67775
|
-
}
|
|
67776
|
-
/**
|
|
67777
69801
|
* The periodic ORPHAN AUDIT — ownership-keyed, wide interval, report-first.
|
|
67778
69802
|
*
|
|
67779
69803
|
* This is NOT a fifth age sweep and must never be mistaken for one. It takes
|
|
@@ -67874,7 +69898,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67874
69898
|
}
|
|
67875
69899
|
});
|
|
67876
69900
|
}
|
|
67877
|
-
await this.auditEmbeddingOrphans(limits);
|
|
69901
|
+
await this.auditEmbeddingOrphans(limits, overrides?.restart === true);
|
|
67878
69902
|
if (report.totalOrphans > 0) this.eventsOpsLog?.append({
|
|
67879
69903
|
op: "orphan-audit",
|
|
67880
69904
|
reason: "maintenance",
|
|
@@ -67904,43 +69928,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
67904
69928
|
* that failed to answer must never be read as "no tracks exist", which would
|
|
67905
69929
|
* wipe the whole index. In report mode nothing is deleted at all.
|
|
67906
69930
|
*/
|
|
67907
|
-
async auditEmbeddingOrphans(limits) {
|
|
69931
|
+
async auditEmbeddingOrphans(limits, restart) {
|
|
67908
69932
|
const embeddingStore = this.objectEmbeddingStore;
|
|
67909
|
-
|
|
67910
|
-
|
|
67911
|
-
|
|
67912
|
-
|
|
67913
|
-
|
|
67914
|
-
|
|
67915
|
-
limit: EMBEDDING_ORPHAN_SCAN_LIMIT
|
|
67916
|
-
})).hits.map((h) => h.row.trackId);
|
|
67917
|
-
const orphans = candidates.filter((id) => !live.has(id));
|
|
67918
|
-
if (limits.mode !== "reclaim") {
|
|
67919
|
-
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
67920
|
-
mode: limits.mode,
|
|
67921
|
-
collection: "vector-index:object-clip",
|
|
67922
|
-
examined: candidates.length,
|
|
67923
|
-
orphans: live.size === 0 ? 0 : orphans.length,
|
|
67924
|
-
reclaimed: 0,
|
|
67925
|
-
refused: live.size === 0
|
|
67926
|
-
} });
|
|
67927
|
-
return;
|
|
67928
|
-
}
|
|
67929
|
-
const result = await embeddingStore.pruneOrphans({
|
|
67930
|
-
liveTrackIds: live,
|
|
67931
|
-
candidates
|
|
67932
|
-
});
|
|
67933
|
-
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
67934
|
-
mode: limits.mode,
|
|
67935
|
-
collection: "vector-index:object-clip",
|
|
67936
|
-
examined: candidates.length,
|
|
67937
|
-
orphans: result.deleted,
|
|
67938
|
-
reclaimed: result.deleted,
|
|
67939
|
-
refused: result.refused
|
|
69933
|
+
const trackStore = this.trackStore;
|
|
69934
|
+
if (!embeddingStore || !trackStore) {
|
|
69935
|
+
this.ctx.logger.warn("analytics orphan audit: vector-index leg skipped — stores not up", { meta: {
|
|
69936
|
+
collection: EMBEDDING_ORPHAN_SCOPE,
|
|
69937
|
+
embeddingStore: embeddingStore !== null,
|
|
69938
|
+
trackStore: trackStore !== null
|
|
67940
69939
|
} });
|
|
67941
|
-
|
|
67942
|
-
this.ctx.logger.debug("analytics orphan audit (vector index) failed", { meta: { error: String(err) } });
|
|
69940
|
+
return;
|
|
67943
69941
|
}
|
|
69942
|
+
const report = await scanEmbeddingOrphans({
|
|
69943
|
+
scanPage: (cursor, limit) => embeddingStore.scanRefs(cursor, limit),
|
|
69944
|
+
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
69945
|
+
reclaim: (ids) => embeddingStore.deleteVerified(ids),
|
|
69946
|
+
now: () => Date.now(),
|
|
69947
|
+
logger: this.ctx.logger,
|
|
69948
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
69949
|
+
}, {
|
|
69950
|
+
mode: limits.mode,
|
|
69951
|
+
graceMs: limits.graceMs,
|
|
69952
|
+
pageSize: limits.pageSize,
|
|
69953
|
+
maxRows: Math.max(limits.maxRowsPerScope, EMBEDDING_ORPHAN_SCAN_LIMIT),
|
|
69954
|
+
maxReclaim: limits.maxReclaimPerScope
|
|
69955
|
+
}, restart ? 0 : this.orphanEmbeddingCursor);
|
|
69956
|
+
this.orphanEmbeddingCursor = report.nextCursor;
|
|
69957
|
+
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
69958
|
+
mode: limits.mode,
|
|
69959
|
+
collection: report.scope,
|
|
69960
|
+
examined: report.examined,
|
|
69961
|
+
orphans: report.orphans,
|
|
69962
|
+
reclaimed: report.reclaimed,
|
|
69963
|
+
kept: report.kept,
|
|
69964
|
+
withinGrace: report.withinGrace,
|
|
69965
|
+
unresolvable: report.unresolvable,
|
|
69966
|
+
notReclaimed: report.notReclaimed,
|
|
69967
|
+
stoppedBy: report.stoppedBy,
|
|
69968
|
+
nextCursor: report.nextCursor
|
|
69969
|
+
} });
|
|
69970
|
+
for (const deviceId of report.deviceIds) this.ctx.logger.info("analytics orphan audit (device)", {
|
|
69971
|
+
tags: { deviceId },
|
|
69972
|
+
meta: {
|
|
69973
|
+
deviceId,
|
|
69974
|
+
collection: report.scope,
|
|
69975
|
+
mode: limits.mode
|
|
69976
|
+
}
|
|
69977
|
+
});
|
|
67944
69978
|
}
|
|
67945
69979
|
/**
|
|
67946
69980
|
* Operator-tunable audit limits. `mode` and `pageSize` are SETTINGS because
|
|
@@ -68418,11 +70452,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68418
70452
|
}, Buffer.from(file.base64, "base64"));
|
|
68419
70453
|
if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
|
|
68420
70454
|
if (outcome.kind === "failed") return refuse("failed", outcome.reason);
|
|
68421
|
-
await faceStore.
|
|
70455
|
+
if (!await faceStore.restampEmbedding({
|
|
70456
|
+
faceId: face.id,
|
|
70457
|
+
deviceId: face.deviceId,
|
|
70458
|
+
trackId: face.trackId,
|
|
70459
|
+
timestamp: face.timestamp,
|
|
68422
70460
|
embedding: outcome.embedding,
|
|
68423
|
-
|
|
68424
|
-
});
|
|
68425
|
-
if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
70461
|
+
modelId: clusterModelId
|
|
70462
|
+
})) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
68426
70463
|
this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
|
|
68427
70464
|
tags: { deviceId: face.deviceId },
|
|
68428
70465
|
meta: {
|
|
@@ -68483,7 +70520,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68483
70520
|
const identityStore = this.identityStore;
|
|
68484
70521
|
if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
|
|
68485
70522
|
const faceId = `face-${input.trackId}`;
|
|
68486
|
-
const face = await faceStore.
|
|
70523
|
+
const face = await faceStore.getWithEmbedding(faceId);
|
|
68487
70524
|
if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
|
|
68488
70525
|
if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
|
|
68489
70526
|
const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
|
|
@@ -68695,9 +70732,95 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68695
70732
|
"embedding.wipe": async () => this.wipeObjectEmbeddings(),
|
|
68696
70733
|
"embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
|
|
68697
70734
|
"embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
|
|
68698
|
-
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
|
|
70735
|
+
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
|
|
70736
|
+
"retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
|
|
68699
70737
|
};
|
|
68700
70738
|
}
|
|
70739
|
+
/**
|
|
70740
|
+
* One operator-driven debug-media reclaim pass — the entry point behind
|
|
70741
|
+
* `retention.mediaReclaim`.
|
|
70742
|
+
*
|
|
70743
|
+
* DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
|
|
70744
|
+
* `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
|
|
70745
|
+
* from the per-device cursors this method keeps, and a run that is cut short
|
|
70746
|
+
* has still only forgotten rows whose blobs it proved gone.
|
|
70747
|
+
*
|
|
70748
|
+
* Single-flight: a second call while a pass is in flight returns
|
|
70749
|
+
* `ran: false` rather than doubling the disk pressure the pacing exists to
|
|
70750
|
+
* avoid.
|
|
70751
|
+
*/
|
|
70752
|
+
async runMediaReclaimNow(rawInput) {
|
|
70753
|
+
const input = MediaReclaimActionInputSchema.parse(rawInput);
|
|
70754
|
+
const mediaStore = this.mediaStore;
|
|
70755
|
+
const eventStore = this.eventStore;
|
|
70756
|
+
const trackStore = this.trackStore;
|
|
70757
|
+
const empty = {
|
|
70758
|
+
ran: false,
|
|
70759
|
+
mode: input.mode,
|
|
70760
|
+
devices: [],
|
|
70761
|
+
totalExamined: 0,
|
|
70762
|
+
totalEligible: 0,
|
|
70763
|
+
totalReclaimed: 0,
|
|
70764
|
+
totalBytesReclaimed: 0,
|
|
70765
|
+
totalRefused: 0,
|
|
70766
|
+
durationMs: 0,
|
|
70767
|
+
complete: false
|
|
70768
|
+
};
|
|
70769
|
+
if (!mediaStore || !eventStore || !trackStore) return empty;
|
|
70770
|
+
if (this.mediaReclaimRunning) return empty;
|
|
70771
|
+
this.mediaReclaimRunning = true;
|
|
70772
|
+
try {
|
|
70773
|
+
const limits = {
|
|
70774
|
+
mode: input.mode,
|
|
70775
|
+
pageSize: input.pageSize ?? DEFAULT_MEDIA_RECLAIM_LIMITS.pageSize,
|
|
70776
|
+
maxRowsPerDevice: input.maxRowsPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxRowsPerDevice,
|
|
70777
|
+
maxReclaimPerDevice: input.maxReclaimPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxReclaimPerDevice,
|
|
70778
|
+
maxBytesPerRun: input.maxBytesPerRun ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxBytesPerRun,
|
|
70779
|
+
budgetMs: input.budgetMinutes !== void 0 ? input.budgetMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.budgetMs,
|
|
70780
|
+
graceMs: input.graceMinutes !== void 0 ? input.graceMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.graceMs,
|
|
70781
|
+
throttleBytesPerSec: input.throttleBytesPerSec ?? DEFAULT_MEDIA_RECLAIM_LIMITS.throttleBytesPerSec
|
|
70782
|
+
};
|
|
70783
|
+
const scopes = input.scopes ?? MEDIA_RECLAIM_SCOPES;
|
|
70784
|
+
const { report, cursors } = await runMediaReclaim({
|
|
70785
|
+
deviceIds: input.deviceIds ?? await this.retentionDeviceIds(),
|
|
70786
|
+
scopes,
|
|
70787
|
+
limits,
|
|
70788
|
+
cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
|
|
70789
|
+
deps: {
|
|
70790
|
+
page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
|
|
70791
|
+
deviceId,
|
|
70792
|
+
ownerKind: scope === "motion-still" ? "event" : "track",
|
|
70793
|
+
kind: "snapshot",
|
|
70794
|
+
afterMs,
|
|
70795
|
+
limit
|
|
70796
|
+
}),
|
|
70797
|
+
motionEventIds: (ids) => eventStore.existingMotionEventIds(ids),
|
|
70798
|
+
pinnedTrackIds: (ids) => trackStore.pinnedTrackIds(ids),
|
|
70799
|
+
reclaim: (key) => mediaStore.deleteConfirmed(key),
|
|
70800
|
+
now: () => Date.now(),
|
|
70801
|
+
logger: this.ctx.logger,
|
|
70802
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve)),
|
|
70803
|
+
pace: (ms) => ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms))
|
|
70804
|
+
}
|
|
70805
|
+
});
|
|
70806
|
+
this.mediaReclaimCursors = cursors;
|
|
70807
|
+
if (limits.mode === "reclaim" && report.totalReclaimed > 0) await this.eventsOpsLog?.append({
|
|
70808
|
+
op: "prune",
|
|
70809
|
+
reason: "operator",
|
|
70810
|
+
deviceId: null,
|
|
70811
|
+
itemsAffected: report.totalReclaimed,
|
|
70812
|
+
bytesReclaimed: report.totalBytesReclaimed,
|
|
70813
|
+
detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
|
|
70814
|
+
});
|
|
70815
|
+
return {
|
|
70816
|
+
ran: true,
|
|
70817
|
+
...report,
|
|
70818
|
+
devices: [...report.devices]
|
|
70819
|
+
};
|
|
70820
|
+
} finally {
|
|
70821
|
+
this.mediaReclaimRunning = false;
|
|
70822
|
+
}
|
|
70823
|
+
}
|
|
68701
70824
|
/** Progress of the detached rebuild — the only way to observe a long pass. */
|
|
68702
70825
|
async getObjectEmbeddingRebuildStatus() {
|
|
68703
70826
|
const state = this.rebuildState;
|
|
@@ -68749,7 +70872,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68749
70872
|
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {}
|
|
68750
70873
|
});
|
|
68751
70874
|
const ranked = search.hits;
|
|
68752
|
-
if (ranked.length === 0)
|
|
70875
|
+
if (ranked.length === 0) {
|
|
70876
|
+
if (input.classFilter !== void 0) this.ctx.logger.warn("searchObjectEvents: classFilter matched nothing", {
|
|
70877
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
70878
|
+
meta: {
|
|
70879
|
+
classFilter: input.classFilter,
|
|
70880
|
+
why: "the index's className column carries the CLIP step's output class, not the track's class — only that value can match"
|
|
70881
|
+
}
|
|
70882
|
+
});
|
|
70883
|
+
return [];
|
|
70884
|
+
}
|
|
68753
70885
|
const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
|
|
68754
70886
|
const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
68755
70887
|
for (const { row } of ranked) {
|
|
@@ -68815,15 +70947,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68815
70947
|
const mediaStore = this.mediaStore;
|
|
68816
70948
|
const trackStore = this.trackStore;
|
|
68817
70949
|
if (!eventStore || !mediaStore || !trackStore) return null;
|
|
70950
|
+
const stores = {
|
|
70951
|
+
eventStore,
|
|
70952
|
+
mediaStore,
|
|
70953
|
+
faceStore: this.faceStore,
|
|
70954
|
+
plateStore: this.plateStore,
|
|
70955
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
70956
|
+
groupStore: this.groupStore
|
|
70957
|
+
};
|
|
70958
|
+
const absent = absentCascadeLegs(stores);
|
|
70959
|
+
if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
|
|
70960
|
+
absent,
|
|
70961
|
+
why: "an optional store was not bound at boot; the track root is still deleted, so these families can only be reclaimed by the orphan audit"
|
|
70962
|
+
} });
|
|
68818
70963
|
return {
|
|
68819
|
-
registry: buildAnalyticsCascadeRegistry(
|
|
68820
|
-
eventStore,
|
|
68821
|
-
mediaStore,
|
|
68822
|
-
faceStore: this.faceStore,
|
|
68823
|
-
plateStore: this.plateStore,
|
|
68824
|
-
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
68825
|
-
groupStore: this.groupStore
|
|
68826
|
-
}),
|
|
70964
|
+
registry: buildAnalyticsCascadeRegistry(stores),
|
|
68827
70965
|
trackStore
|
|
68828
70966
|
};
|
|
68829
70967
|
}
|
|
@@ -68978,6 +71116,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
68978
71116
|
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
68979
71117
|
return this.mediaRelocate.countUnstamped();
|
|
68980
71118
|
}
|
|
71119
|
+
/**
|
|
71120
|
+
* How many rows a pass would still act on against `toLocationId`.
|
|
71121
|
+
*
|
|
71122
|
+
* The media lane's denominator AND its residue, from the one predicate the
|
|
71123
|
+
* pass itself uses. `null` means the count could not be taken — never `0`,
|
|
71124
|
+
* which would read as "there is nothing left to move".
|
|
71125
|
+
*/
|
|
71126
|
+
async countRelocatableMedia(input) {
|
|
71127
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
71128
|
+
return this.mediaRelocate.countSelectable(input);
|
|
71129
|
+
}
|
|
68981
71130
|
/** Every relocate job this addon knows about, newest first (in RAM: the
|
|
68982
71131
|
* move is resumable, so a lost list costs nothing but the display). */
|
|
68983
71132
|
async listRelocateMediaJobs() {
|
|
@@ -69386,6 +71535,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
69386
71535
|
this.sensitivitySettingsCache.delete(input.deviceId);
|
|
69387
71536
|
this.trackingSettingsCache.delete(input.deviceId);
|
|
69388
71537
|
this.confirmationGateSettingsCache.delete(input.deviceId);
|
|
71538
|
+
this.mediaSettingsCache.delete(input.deviceId);
|
|
69389
71539
|
this.forgetDeviceProcessors(input.deviceId);
|
|
69390
71540
|
return { success: true };
|
|
69391
71541
|
}
|
|
@@ -69419,4 +71569,4 @@ function withMean(row) {
|
|
|
69419
71569
|
};
|
|
69420
71570
|
}
|
|
69421
71571
|
//#endregion
|
|
69422
|
-
export { DETECTION_PIPELINE_SECTION_IDS, customActions, PipelineAnalyticsAddon as default, ncActions, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
71572
|
+
export { CONFIRMATION_CROP_MAX_WIDTH, DETECTION_PIPELINE_SECTION_IDS, customActions, PipelineAnalyticsAddon as default, ncActions, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|