@camstack/addon-post-analysis 1.2.148 → 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
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-BTviHGUC.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -25812,6 +25812,112 @@ var MediaStore = class {
|
|
|
25812
25812
|
}
|
|
25813
25813
|
}
|
|
25814
25814
|
/**
|
|
25815
|
+
* Delete one row and its blob, and CONFIRM the blob is gone before the row
|
|
25816
|
+
* is forgotten (D148, read for this store).
|
|
25817
|
+
*
|
|
25818
|
+
* Every other delete on this class is best-effort: `deleteByKeyUnchecked`
|
|
25819
|
+
* swallows the unlink error and drops the row anyway, and the underlying
|
|
25820
|
+
* `fs.rm(…, { force: true })` cannot distinguish "removed it" from "there
|
|
25821
|
+
* was nothing there". That is tolerable for a cascade whose whole subtree is
|
|
25822
|
+
* going, and it is NOT tolerable for a bulk reclaim: a row deleted while its
|
|
25823
|
+
* blob survives is 95 KiB that nothing will ever name again — the orphan
|
|
25824
|
+
* class this codebase has spent the week sweeping.
|
|
25825
|
+
*
|
|
25826
|
+
* So the order is: unlink, re-probe, and only then delete the row. A probe
|
|
25827
|
+
* that cannot answer (unreachable volume) THROWS, exactly like
|
|
25828
|
+
* {@link referencedBlobState} — the caller must refuse, not assume absence.
|
|
25829
|
+
*
|
|
25830
|
+
* A row with no `path` is a row whose blob was never written; there is
|
|
25831
|
+
* nothing to confirm and the row is removable.
|
|
25832
|
+
*/
|
|
25833
|
+
async deleteConfirmed(key) {
|
|
25834
|
+
return this.gated(async () => {
|
|
25835
|
+
const row = await this.store.get.query({
|
|
25836
|
+
collection: MEDIA_COLLECTION,
|
|
25837
|
+
key
|
|
25838
|
+
});
|
|
25839
|
+
if (!row) return {
|
|
25840
|
+
rows: 0,
|
|
25841
|
+
bytes: 0
|
|
25842
|
+
};
|
|
25843
|
+
const data = row;
|
|
25844
|
+
const relativePath = String(data["path"] ?? "");
|
|
25845
|
+
const bytes = Number(data["sizeBytes"] ?? 0);
|
|
25846
|
+
if (relativePath.length > 0) {
|
|
25847
|
+
const location = mediaRowLocation(data);
|
|
25848
|
+
await this.storage.delete({
|
|
25849
|
+
location,
|
|
25850
|
+
relativePath
|
|
25851
|
+
});
|
|
25852
|
+
if (this.storage.exists) {
|
|
25853
|
+
if (await this.storage.exists({
|
|
25854
|
+
location,
|
|
25855
|
+
relativePath
|
|
25856
|
+
})) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
|
|
25857
|
+
} else {
|
|
25858
|
+
let readable = false;
|
|
25859
|
+
try {
|
|
25860
|
+
await this.storage.read({
|
|
25861
|
+
location,
|
|
25862
|
+
relativePath
|
|
25863
|
+
});
|
|
25864
|
+
readable = true;
|
|
25865
|
+
} catch {}
|
|
25866
|
+
if (readable) throw new Error(`media blob survived its delete: ${key} (${relativePath})`);
|
|
25867
|
+
}
|
|
25868
|
+
}
|
|
25869
|
+
await this.store.delete.mutate({
|
|
25870
|
+
collection: MEDIA_COLLECTION,
|
|
25871
|
+
key
|
|
25872
|
+
});
|
|
25873
|
+
return {
|
|
25874
|
+
rows: 1,
|
|
25875
|
+
bytes: Number.isFinite(bytes) ? bytes : 0
|
|
25876
|
+
};
|
|
25877
|
+
});
|
|
25878
|
+
}
|
|
25879
|
+
/**
|
|
25880
|
+
* A page of media rows of ONE kind for ONE device, timestamp-ascending from
|
|
25881
|
+
* `afterMs`. The reclaim's cursor.
|
|
25882
|
+
*
|
|
25883
|
+
* `(deviceId, timestamp)` is `idx_media_device_ts`, so this is an index
|
|
25884
|
+
* range scan — unlike a fleet-wide `kind = 'snapshot'` predicate, which has
|
|
25885
|
+
* no index at all (`MEDIA_INDEXES`) and would table-scan 1.26 M rows on
|
|
25886
|
+
* hub-main's single synchronous SQLite thread.
|
|
25887
|
+
*/
|
|
25888
|
+
async pageByKindForDevice(input) {
|
|
25889
|
+
return (await this.store.query.query({
|
|
25890
|
+
collection: MEDIA_COLLECTION,
|
|
25891
|
+
columns: [
|
|
25892
|
+
"id",
|
|
25893
|
+
"ownerId",
|
|
25894
|
+
"timestamp",
|
|
25895
|
+
"sizeBytes"
|
|
25896
|
+
],
|
|
25897
|
+
filter: {
|
|
25898
|
+
where: {
|
|
25899
|
+
deviceId: input.deviceId,
|
|
25900
|
+
ownerKind: input.ownerKind,
|
|
25901
|
+
kind: input.kind
|
|
25902
|
+
},
|
|
25903
|
+
whereBetween: { timestamp: [input.afterMs, Number.MAX_SAFE_INTEGER] },
|
|
25904
|
+
orderBy: {
|
|
25905
|
+
field: "timestamp",
|
|
25906
|
+
direction: "asc"
|
|
25907
|
+
},
|
|
25908
|
+
limit: input.limit
|
|
25909
|
+
}
|
|
25910
|
+
})).map((row) => {
|
|
25911
|
+
const data = row.data;
|
|
25912
|
+
return {
|
|
25913
|
+
key: row.id,
|
|
25914
|
+
ownerId: String(data["ownerId"] ?? ""),
|
|
25915
|
+
timestamp: Number(data["timestamp"] ?? 0),
|
|
25916
|
+
sizeBytes: Number(data["sizeBytes"] ?? 0)
|
|
25917
|
+
};
|
|
25918
|
+
});
|
|
25919
|
+
}
|
|
25920
|
+
/**
|
|
25815
25921
|
* `deviceId` is the AUTHORIZATION filter: every media row is stamped with
|
|
25816
25922
|
* its device at write time, so scoping the query by it makes a media read
|
|
25817
25923
|
* for a device the caller cannot name return nothing — the DB itself
|
|
@@ -28255,6 +28361,212 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
28255
28361
|
};
|
|
28256
28362
|
}
|
|
28257
28363
|
//#endregion
|
|
28364
|
+
//#region src/pipeline-analytics/store/embedding-vector-index.ts
|
|
28365
|
+
/**
|
|
28366
|
+
* The vector indexes this addon owns, and the one client that talks to them.
|
|
28367
|
+
*
|
|
28368
|
+
* ## The three indexes, and what a row in each one IS
|
|
28369
|
+
*
|
|
28370
|
+
* | index | dim | encoder | one row is | ranked against |
|
|
28371
|
+
* | --- | --- | --- | --- | --- |
|
|
28372
|
+
* | `object-clip` | 512 | MobileCLIP image tower (`mobileclip-*`) | a TRACK's best-confidence object crop | a CLIP **text** vector — semantic search over footage |
|
|
28373
|
+
* | `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 |
|
|
28374
|
+
* | `identity-arcface` | 512 | the SAME ArcFace model | one ENROLLED face of a person | every live face detection, at recognition time |
|
|
28375
|
+
*
|
|
28376
|
+
* `plates`, `vehicles`, `vehicle-samples` and `identities` hold no vector of
|
|
28377
|
+
* any kind — a plate is a STRING and a score, a vehicle and an identity are a
|
|
28378
|
+
* name. They are named here because `vector-store.cap.ts` once claimed six
|
|
28379
|
+
* stores were waiting to migrate, and five of them being absent from this table
|
|
28380
|
+
* is the answer to "where are the other ones".
|
|
28381
|
+
*
|
|
28382
|
+
* ## Why `face-arcface` and `identity-arcface` are two indexes
|
|
28383
|
+
*
|
|
28384
|
+
* They hold the same 512 dims from the same model, and they are still separate.
|
|
28385
|
+
* An index is a set of rows that get ranked against each other and that live and
|
|
28386
|
+
* die together, and these are neither:
|
|
28387
|
+
*
|
|
28388
|
+
* - a `face-arcface` row is TRACK-OWNED. It cascades away with its track and is
|
|
28389
|
+
* capped per camera; it is a CANDIDATE.
|
|
28390
|
+
* - an `identity-arcface` row is retention-EXEMPT forever. It is the ANSWER
|
|
28391
|
+
* every candidate is measured against.
|
|
28392
|
+
*
|
|
28393
|
+
* Merged, every gallery load and every reconcile would carry a discriminating
|
|
28394
|
+
* filter, and the two ways that filter can fail are "a face is ranked against
|
|
28395
|
+
* itself" and "an enrolled person's only sample is reclaimed as a track orphan".
|
|
28396
|
+
* Sharing a dimension is not a reason to share an index.
|
|
28397
|
+
*/
|
|
28398
|
+
/** The buffered per-track ArcFace face vector (`pipeline-analytics:faces`). */
|
|
28399
|
+
var FACE_ARCFACE_INDEX = "face-arcface";
|
|
28400
|
+
/** The ENROLLED gallery (`pipeline-analytics:identity-samples`). */
|
|
28401
|
+
var IDENTITY_ARCFACE_INDEX = "identity-arcface";
|
|
28402
|
+
/**
|
|
28403
|
+
* The `className` each index stamps on its rows.
|
|
28404
|
+
*
|
|
28405
|
+
* A constant per index, exactly as `object-clip` stamps `embedding`. It is not
|
|
28406
|
+
* a subject class and must never be filtered on as one: it says which PRODUCER
|
|
28407
|
+
* wrote the row, which is what keeps a face vector out of a CLIP ranking.
|
|
28408
|
+
*/
|
|
28409
|
+
var FACE_VECTOR_CLASS = "face";
|
|
28410
|
+
var IDENTITY_VECTOR_CLASS = "identity";
|
|
28411
|
+
/**
|
|
28412
|
+
* One index, plus the lazy declaration every caller would otherwise re-derive.
|
|
28413
|
+
*
|
|
28414
|
+
* The dimension is not known at construction — it comes from whatever encoder
|
|
28415
|
+
* is active — so the index is declared on the first vector, exactly as
|
|
28416
|
+
* `ObjectEmbeddingStore` does it, and a failed declaration DISABLES writes for
|
|
28417
|
+
* that dim rather than letting an upsert fail against an index the provider has
|
|
28418
|
+
* never seen. A failed write clears the cache so the next one re-declares:
|
|
28419
|
+
* a provider swap under a running hub once left that cache warm and every write
|
|
28420
|
+
* throwing for hours.
|
|
28421
|
+
*/
|
|
28422
|
+
var EmbeddingVectorIndex = class {
|
|
28423
|
+
index;
|
|
28424
|
+
client;
|
|
28425
|
+
logger;
|
|
28426
|
+
declaredDim = null;
|
|
28427
|
+
constructor(deps) {
|
|
28428
|
+
this.index = deps.index;
|
|
28429
|
+
this.client = deps.client;
|
|
28430
|
+
this.logger = deps.logger;
|
|
28431
|
+
}
|
|
28432
|
+
/** The index name, for a log line that has to name which store dropped work. */
|
|
28433
|
+
get name() {
|
|
28434
|
+
return this.index;
|
|
28435
|
+
}
|
|
28436
|
+
async ensureIndex(dim) {
|
|
28437
|
+
if (this.declaredDim === dim) return true;
|
|
28438
|
+
try {
|
|
28439
|
+
await this.client.declareIndex.mutate({
|
|
28440
|
+
index: this.index,
|
|
28441
|
+
dim,
|
|
28442
|
+
metric: "cosine"
|
|
28443
|
+
});
|
|
28444
|
+
this.declaredDim = dim;
|
|
28445
|
+
return true;
|
|
28446
|
+
} catch (err) {
|
|
28447
|
+
this.logger.warn("vector index declaration failed — writes disabled for this dim", { meta: {
|
|
28448
|
+
index: this.index,
|
|
28449
|
+
dim,
|
|
28450
|
+
error: String(err)
|
|
28451
|
+
} });
|
|
28452
|
+
return false;
|
|
28453
|
+
}
|
|
28454
|
+
}
|
|
28455
|
+
/**
|
|
28456
|
+
* Write one vector. Returns whether it LANDED, never whether the call
|
|
28457
|
+
* returned — the caller uses this to decide it may stop keeping its own copy,
|
|
28458
|
+
* and "the mutate resolved" is not the same statement (D58).
|
|
28459
|
+
*/
|
|
28460
|
+
async put(input) {
|
|
28461
|
+
if (input.embedding.length === 0) {
|
|
28462
|
+
this.logger.warn("refused to index an empty vector", { meta: {
|
|
28463
|
+
index: this.index,
|
|
28464
|
+
id: input.id
|
|
28465
|
+
} });
|
|
28466
|
+
return false;
|
|
28467
|
+
}
|
|
28468
|
+
if (!await this.ensureIndex(input.embedding.length)) return false;
|
|
28469
|
+
const metadata = {
|
|
28470
|
+
...input.extra,
|
|
28471
|
+
className: input.className,
|
|
28472
|
+
modelId: input.modelId,
|
|
28473
|
+
timestamp: input.timestamp,
|
|
28474
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
|
|
28475
|
+
};
|
|
28476
|
+
try {
|
|
28477
|
+
const result = await this.client.upsert.mutate({
|
|
28478
|
+
index: this.index,
|
|
28479
|
+
items: [{
|
|
28480
|
+
id: input.id,
|
|
28481
|
+
vector: require_dist.encodeVectorBase64(input.embedding),
|
|
28482
|
+
metadata
|
|
28483
|
+
}]
|
|
28484
|
+
});
|
|
28485
|
+
if (result.upserted === 0) {
|
|
28486
|
+
this.logger.warn("vector upsert rejected", {
|
|
28487
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
28488
|
+
meta: {
|
|
28489
|
+
index: this.index,
|
|
28490
|
+
id: input.id,
|
|
28491
|
+
rejected: result.rejected
|
|
28492
|
+
}
|
|
28493
|
+
});
|
|
28494
|
+
return false;
|
|
28495
|
+
}
|
|
28496
|
+
return true;
|
|
28497
|
+
} catch (err) {
|
|
28498
|
+
this.declaredDim = null;
|
|
28499
|
+
this.logger.warn("vector upsert failed", {
|
|
28500
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
28501
|
+
meta: {
|
|
28502
|
+
index: this.index,
|
|
28503
|
+
id: input.id,
|
|
28504
|
+
error: String(err)
|
|
28505
|
+
}
|
|
28506
|
+
});
|
|
28507
|
+
return false;
|
|
28508
|
+
}
|
|
28509
|
+
}
|
|
28510
|
+
/**
|
|
28511
|
+
* Read named vectors back as float arrays.
|
|
28512
|
+
*
|
|
28513
|
+
* An id with no row is simply ABSENT from the map — never a zero vector. A
|
|
28514
|
+
* zero vector scores 0 against everything and would quietly un-enrol a person
|
|
28515
|
+
* instead of failing, which is the reason `fetchByIds` drops an unreadable
|
|
28516
|
+
* row rather than padding it.
|
|
28517
|
+
*
|
|
28518
|
+
* On a read failure the map is EMPTY and the failure is logged. An empty
|
|
28519
|
+
* gallery is a visible outage; a partial one silently forgets people.
|
|
28520
|
+
*/
|
|
28521
|
+
async load(ids) {
|
|
28522
|
+
const out = /* @__PURE__ */ new Map();
|
|
28523
|
+
if (ids.length === 0) return out;
|
|
28524
|
+
try {
|
|
28525
|
+
const result = await this.client.fetchByIds.query({
|
|
28526
|
+
index: this.index,
|
|
28527
|
+
ids: [...ids]
|
|
28528
|
+
});
|
|
28529
|
+
for (const item of result.items) {
|
|
28530
|
+
const buf = Buffer.from(item.vector, "base64");
|
|
28531
|
+
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) continue;
|
|
28532
|
+
const view = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
|
|
28533
|
+
out.set(item.id, Array.from(view));
|
|
28534
|
+
}
|
|
28535
|
+
} catch (err) {
|
|
28536
|
+
this.logger.warn("vector fetch failed — no vectors returned for this call", { meta: {
|
|
28537
|
+
index: this.index,
|
|
28538
|
+
ids: ids.length,
|
|
28539
|
+
error: String(err)
|
|
28540
|
+
} });
|
|
28541
|
+
return /* @__PURE__ */ new Map();
|
|
28542
|
+
}
|
|
28543
|
+
return out;
|
|
28544
|
+
}
|
|
28545
|
+
/**
|
|
28546
|
+
* Drop named vectors.
|
|
28547
|
+
*
|
|
28548
|
+
* Best-effort and LOUD: these rows have exactly one owner and it is being
|
|
28549
|
+
* deleted, so a delete that quietly did nothing leaves a vector nothing can
|
|
28550
|
+
* ever reach by ownership again.
|
|
28551
|
+
*/
|
|
28552
|
+
async drop(ids) {
|
|
28553
|
+
if (ids.length === 0) return;
|
|
28554
|
+
try {
|
|
28555
|
+
await this.client.deleteByIds.mutate({
|
|
28556
|
+
index: this.index,
|
|
28557
|
+
ids: [...ids]
|
|
28558
|
+
});
|
|
28559
|
+
} catch (err) {
|
|
28560
|
+
this.declaredDim = null;
|
|
28561
|
+
this.logger.warn("vector delete failed — rows left behind", { meta: {
|
|
28562
|
+
index: this.index,
|
|
28563
|
+
ids: ids.length,
|
|
28564
|
+
error: String(err)
|
|
28565
|
+
} });
|
|
28566
|
+
}
|
|
28567
|
+
}
|
|
28568
|
+
};
|
|
28569
|
+
//#endregion
|
|
28258
28570
|
//#region src/pipeline-analytics/store/face-sort.ts
|
|
28259
28571
|
var DEFAULT_FACE_SORT = {
|
|
28260
28572
|
by: "timestamp",
|
|
@@ -28320,6 +28632,15 @@ function rankAndCap(faces, order, limit) {
|
|
|
28320
28632
|
//#endregion
|
|
28321
28633
|
//#region src/pipeline-analytics/store/face-store.ts
|
|
28322
28634
|
/**
|
|
28635
|
+
* The model a row written before 2026-08-20 was produced by.
|
|
28636
|
+
*
|
|
28637
|
+
* Those rows predate `embeddingModelId`, and they were all this. The vector
|
|
28638
|
+
* index stamps a `modelId` on every row it holds, so a converted legacy row has
|
|
28639
|
+
* to be stamped with something true rather than left blank — a blank modelId in
|
|
28640
|
+
* the gallery is a vector the matcher cannot decide is comparable.
|
|
28641
|
+
*/
|
|
28642
|
+
var LEGACY_FACE_MODEL_ID = "arcface-r100";
|
|
28643
|
+
/**
|
|
28323
28644
|
* @durable class=ledger owner=pipeline-analytics
|
|
28324
28645
|
* write="one UPSERT per track that produced a face detail — the id is
|
|
28325
28646
|
* 'face-<trackId>', so a track has at most one row and the recognizer
|
|
@@ -28401,11 +28722,23 @@ var FACE_COLUMNS = [
|
|
|
28401
28722
|
type: "INTEGER",
|
|
28402
28723
|
notNull: true
|
|
28403
28724
|
},
|
|
28725
|
+
(
|
|
28726
|
+
/**
|
|
28727
|
+
* LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
|
|
28728
|
+
* `face-arcface` vector index now, keyed by this row's id.
|
|
28729
|
+
*
|
|
28730
|
+
* The column stays declared and stays `notNull` because rows written before
|
|
28731
|
+
* the conversion still carry their array and the conversion has to be able to
|
|
28732
|
+
* read them. A CONVERTED row holds `[]` — two bytes, and the marker that says
|
|
28733
|
+
* "the vector is in the index", which is what makes the conversion idempotent
|
|
28734
|
+
* and observable. Nothing reads this column except the conversion and the
|
|
28735
|
+
* fallback in {@link FaceStore.getWithEmbedding}.
|
|
28736
|
+
*/
|
|
28404
28737
|
{
|
|
28405
28738
|
name: "embedding",
|
|
28406
28739
|
type: "JSON",
|
|
28407
28740
|
notNull: true
|
|
28408
|
-
},
|
|
28741
|
+
}),
|
|
28409
28742
|
{
|
|
28410
28743
|
name: "embeddingModelId",
|
|
28411
28744
|
type: "TEXT"
|
|
@@ -28489,8 +28822,9 @@ function buildTimestampWindow(input) {
|
|
|
28489
28822
|
*/
|
|
28490
28823
|
function rowToFace(id, raw) {
|
|
28491
28824
|
const data = raw;
|
|
28825
|
+
const { embedding: _legacyEmbedding, ...rest } = data;
|
|
28492
28826
|
return {
|
|
28493
|
-
...
|
|
28827
|
+
...rest,
|
|
28494
28828
|
id,
|
|
28495
28829
|
assigned: Boolean(data.assigned),
|
|
28496
28830
|
mediaKey: data.mediaKey ?? void 0,
|
|
@@ -28506,9 +28840,11 @@ function rowToFace(id, raw) {
|
|
|
28506
28840
|
}
|
|
28507
28841
|
var FaceStore = class {
|
|
28508
28842
|
store;
|
|
28843
|
+
vectors;
|
|
28509
28844
|
logger;
|
|
28510
28845
|
constructor(deps) {
|
|
28511
28846
|
this.store = deps.store;
|
|
28847
|
+
this.vectors = deps.vectors;
|
|
28512
28848
|
this.logger = deps.logger;
|
|
28513
28849
|
}
|
|
28514
28850
|
static async declare(store) {
|
|
@@ -28523,12 +28859,31 @@ var FaceStore = class {
|
|
|
28523
28859
|
* id per track → one entry per track). `set` replaces an existing row.
|
|
28524
28860
|
*/
|
|
28525
28861
|
async insert(face) {
|
|
28526
|
-
const { id, ...rest } = face;
|
|
28862
|
+
const { id, embedding, ...rest } = face;
|
|
28863
|
+
if (!await this.vectors.put({
|
|
28864
|
+
id,
|
|
28865
|
+
embedding,
|
|
28866
|
+
className: "face",
|
|
28867
|
+
modelId: face.embeddingModelId ?? "arcface-r100",
|
|
28868
|
+
timestamp: face.timestamp,
|
|
28869
|
+
deviceId: face.deviceId,
|
|
28870
|
+
extra: { trackId: face.trackId }
|
|
28871
|
+
})) this.logger.warn("face row written WITHOUT a searchable vector", {
|
|
28872
|
+
tags: { deviceId: face.deviceId },
|
|
28873
|
+
meta: {
|
|
28874
|
+
faceId: id,
|
|
28875
|
+
index: this.vectors.name,
|
|
28876
|
+
trackId: face.trackId
|
|
28877
|
+
}
|
|
28878
|
+
});
|
|
28527
28879
|
try {
|
|
28528
28880
|
await this.store.set.mutate({
|
|
28529
28881
|
collection: FACES_COLLECTION,
|
|
28530
28882
|
key: id,
|
|
28531
|
-
value:
|
|
28883
|
+
value: {
|
|
28884
|
+
...rest,
|
|
28885
|
+
embedding: []
|
|
28886
|
+
}
|
|
28532
28887
|
});
|
|
28533
28888
|
} catch (err) {
|
|
28534
28889
|
this.logger.warn("FaceStore.insert failed", {
|
|
@@ -28541,6 +28896,111 @@ var FaceStore = class {
|
|
|
28541
28896
|
}
|
|
28542
28897
|
}
|
|
28543
28898
|
/**
|
|
28899
|
+
* How an UNCONVERTED `faces` row becomes an index row.
|
|
28900
|
+
*
|
|
28901
|
+
* The only judgement here is the modelId: a row written before 2026-08-20
|
|
28902
|
+
* carries no `embeddingModelId`, and those rows were all `arcface-r100`, so
|
|
28903
|
+
* that is what they are stamped with — a KNOWN historical fact, not a
|
|
28904
|
+
* fallback guess. A row that names some other model keeps its own.
|
|
28905
|
+
*/
|
|
28906
|
+
conversionSpec() {
|
|
28907
|
+
return {
|
|
28908
|
+
collection: FACES_COLLECTION,
|
|
28909
|
+
vectors: this.vectors,
|
|
28910
|
+
write: (id, embedding, data) => {
|
|
28911
|
+
const deviceId = data["deviceId"];
|
|
28912
|
+
const timestamp = data["timestamp"];
|
|
28913
|
+
const trackId = data["trackId"];
|
|
28914
|
+
const modelId = data["embeddingModelId"];
|
|
28915
|
+
return {
|
|
28916
|
+
id,
|
|
28917
|
+
embedding,
|
|
28918
|
+
className: FACE_VECTOR_CLASS,
|
|
28919
|
+
modelId: typeof modelId === "string" && modelId.length > 0 ? modelId : LEGACY_FACE_MODEL_ID,
|
|
28920
|
+
timestamp: typeof timestamp === "number" ? timestamp : 0,
|
|
28921
|
+
...typeof deviceId === "number" ? { deviceId } : {},
|
|
28922
|
+
...typeof trackId === "string" ? { extra: { trackId } } : {}
|
|
28923
|
+
};
|
|
28924
|
+
}
|
|
28925
|
+
};
|
|
28926
|
+
}
|
|
28927
|
+
/**
|
|
28928
|
+
* One face WITH its ArcFace vector — the enrolment and re-assignment read.
|
|
28929
|
+
*
|
|
28930
|
+
* Falls back to the row's legacy JSON column when the index has no vector for
|
|
28931
|
+
* this id, which is exactly the window in which a row written before the
|
|
28932
|
+
* conversion has not been converted yet. The fallback is LOGGED: it is
|
|
28933
|
+
* correct, it is temporary, and a fallback nobody can count is a fallback that
|
|
28934
|
+
* becomes permanent.
|
|
28935
|
+
*/
|
|
28936
|
+
async getWithEmbedding(faceId) {
|
|
28937
|
+
const raw = await this.store.get.query({
|
|
28938
|
+
collection: FACES_COLLECTION,
|
|
28939
|
+
key: faceId
|
|
28940
|
+
});
|
|
28941
|
+
if (!raw) return null;
|
|
28942
|
+
const face = rowToFace(faceId, raw);
|
|
28943
|
+
const fromIndex = (await this.vectors.load([faceId])).get(faceId);
|
|
28944
|
+
if (fromIndex !== void 0) return {
|
|
28945
|
+
...face,
|
|
28946
|
+
embedding: fromIndex
|
|
28947
|
+
};
|
|
28948
|
+
const legacy = raw.embedding;
|
|
28949
|
+
if (Array.isArray(legacy) && legacy.length > 0 && legacy.every((n) => typeof n === "number")) {
|
|
28950
|
+
this.logger.info("face vector served from the legacy JSON column", {
|
|
28951
|
+
tags: { deviceId: face.deviceId },
|
|
28952
|
+
meta: {
|
|
28953
|
+
faceId,
|
|
28954
|
+
why: "this row has not been converted into the vector index yet"
|
|
28955
|
+
}
|
|
28956
|
+
});
|
|
28957
|
+
return {
|
|
28958
|
+
...face,
|
|
28959
|
+
embedding: legacy
|
|
28960
|
+
};
|
|
28961
|
+
}
|
|
28962
|
+
this.logger.warn("face row has no vector in either home", {
|
|
28963
|
+
tags: { deviceId: face.deviceId },
|
|
28964
|
+
meta: {
|
|
28965
|
+
faceId,
|
|
28966
|
+
index: this.vectors.name
|
|
28967
|
+
}
|
|
28968
|
+
});
|
|
28969
|
+
return null;
|
|
28970
|
+
}
|
|
28971
|
+
/**
|
|
28972
|
+
* The ArcFace vectors of named rows, for the CLUSTERING pass.
|
|
28973
|
+
*
|
|
28974
|
+
* Ids with no vector are ABSENT from the map, never present with an empty
|
|
28975
|
+
* array — the clustering loop skips a zero-length vector, so an empty array
|
|
28976
|
+
* would turn "the index did not answer" into "these faces are not clusterable"
|
|
28977
|
+
* and produce a confident zero.
|
|
28978
|
+
*/
|
|
28979
|
+
async loadEmbeddings(faceIds) {
|
|
28980
|
+
const indexed = await this.vectors.load(faceIds);
|
|
28981
|
+
const missing = faceIds.filter((id) => !indexed.has(id));
|
|
28982
|
+
if (missing.length === 0) return indexed;
|
|
28983
|
+
const out = new Map(indexed);
|
|
28984
|
+
let fromLegacyColumn = 0;
|
|
28985
|
+
for (const id of missing) {
|
|
28986
|
+
const legacy = (await this.store.get.query({
|
|
28987
|
+
collection: FACES_COLLECTION,
|
|
28988
|
+
key: id
|
|
28989
|
+
}))?.embedding;
|
|
28990
|
+
if (!Array.isArray(legacy) || legacy.length === 0 || !legacy.every((n) => typeof n === "number")) continue;
|
|
28991
|
+
out.set(id, legacy);
|
|
28992
|
+
fromLegacyColumn += 1;
|
|
28993
|
+
}
|
|
28994
|
+
this.logger.info("face vectors served partly from the legacy JSON column", { meta: {
|
|
28995
|
+
index: this.vectors.name,
|
|
28996
|
+
asked: faceIds.length,
|
|
28997
|
+
fromIndex: indexed.size,
|
|
28998
|
+
fromLegacyColumn,
|
|
28999
|
+
unavailable: missing.length - fromLegacyColumn
|
|
29000
|
+
} });
|
|
29001
|
+
return out;
|
|
29002
|
+
}
|
|
29003
|
+
/**
|
|
28544
29004
|
* Recent UNASSIGNED faces (the enrollment buffer), newest first.
|
|
28545
29005
|
*
|
|
28546
29006
|
* NOTE: `assigned` is normalized via `Boolean()` on read so the production
|
|
@@ -28660,6 +29120,7 @@ var FaceStore = class {
|
|
|
28660
29120
|
error: String(err)
|
|
28661
29121
|
} });
|
|
28662
29122
|
}
|
|
29123
|
+
await this.vectors.drop([...deleted]);
|
|
28663
29124
|
this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
|
|
28664
29125
|
return [...deleted];
|
|
28665
29126
|
}
|
|
@@ -28690,6 +29151,45 @@ var FaceStore = class {
|
|
|
28690
29151
|
});
|
|
28691
29152
|
}
|
|
28692
29153
|
}
|
|
29154
|
+
/**
|
|
29155
|
+
* Replace a buffered face's vector AND its model stamp, then confirm BOTH.
|
|
29156
|
+
*
|
|
29157
|
+
* The vector and the stamp live in two homes — the index and the row — and
|
|
29158
|
+
* they must move together or not at all: a row stamped with the cluster model
|
|
29159
|
+
* beside a vector from the previous one is a face the matcher believes is
|
|
29160
|
+
* comparable and is not, which is worse than a face it refuses to compare.
|
|
29161
|
+
*
|
|
29162
|
+
* So: vector first (refuse if it does not land), row second, then a read-back
|
|
29163
|
+
* of the stamp. Returns whether the whole move landed — never whether the
|
|
29164
|
+
* calls returned (D58).
|
|
29165
|
+
*/
|
|
29166
|
+
async restampEmbedding(input) {
|
|
29167
|
+
if (!await this.vectors.put({
|
|
29168
|
+
id: input.faceId,
|
|
29169
|
+
embedding: input.embedding,
|
|
29170
|
+
className: "face",
|
|
29171
|
+
modelId: input.modelId,
|
|
29172
|
+
timestamp: input.timestamp,
|
|
29173
|
+
deviceId: input.deviceId,
|
|
29174
|
+
extra: { trackId: input.trackId }
|
|
29175
|
+
})) {
|
|
29176
|
+
this.logger.warn("face re-embed refused: the vector did not reach the index", {
|
|
29177
|
+
tags: { deviceId: input.deviceId },
|
|
29178
|
+
meta: {
|
|
29179
|
+
faceId: input.faceId,
|
|
29180
|
+
index: this.vectors.name
|
|
29181
|
+
}
|
|
29182
|
+
});
|
|
29183
|
+
return false;
|
|
29184
|
+
}
|
|
29185
|
+
await this.update(input.faceId, { embeddingModelId: input.modelId });
|
|
29186
|
+
await this.store.update.mutate({
|
|
29187
|
+
collection: FACES_COLLECTION,
|
|
29188
|
+
id: input.faceId,
|
|
29189
|
+
data: { embedding: [] }
|
|
29190
|
+
});
|
|
29191
|
+
return (await this.get(input.faceId))?.embeddingModelId === input.modelId;
|
|
29192
|
+
}
|
|
28693
29193
|
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
28694
29194
|
async markAssigned(faceId, identityId, sampleId) {
|
|
28695
29195
|
const data = {
|
|
@@ -28775,11 +29275,13 @@ var FaceStore = class {
|
|
|
28775
29275
|
collection: FACES_COLLECTION,
|
|
28776
29276
|
filter: { where: { trackId } }
|
|
28777
29277
|
})).filter((r) => !r.data.assigned);
|
|
29278
|
+
const dropped = [];
|
|
28778
29279
|
for (const row of eligible) try {
|
|
28779
29280
|
await this.store.delete.mutate({
|
|
28780
29281
|
collection: FACES_COLLECTION,
|
|
28781
29282
|
key: row.id
|
|
28782
29283
|
});
|
|
29284
|
+
dropped.push(row.id);
|
|
28783
29285
|
removed++;
|
|
28784
29286
|
} catch (err) {
|
|
28785
29287
|
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
@@ -28787,6 +29289,7 @@ var FaceStore = class {
|
|
|
28787
29289
|
error: String(err)
|
|
28788
29290
|
} });
|
|
28789
29291
|
}
|
|
29292
|
+
await this.vectors.drop(dropped);
|
|
28790
29293
|
} catch (err) {
|
|
28791
29294
|
this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
|
|
28792
29295
|
trackId,
|
|
@@ -28812,7 +29315,9 @@ var FaceStore = class {
|
|
|
28812
29315
|
faceId,
|
|
28813
29316
|
error: String(err)
|
|
28814
29317
|
} });
|
|
29318
|
+
return;
|
|
28815
29319
|
}
|
|
29320
|
+
await this.vectors.drop([faceId]);
|
|
28816
29321
|
}
|
|
28817
29322
|
/**
|
|
28818
29323
|
* Retrieve a single face by id. Returns null if not found.
|
|
@@ -29231,7 +29736,7 @@ var FaceGalleryProvider = class {
|
|
|
29231
29736
|
await this.faceStore.clearAssignment(faceId);
|
|
29232
29737
|
await this.faceStore.update(faceId, { mediaKey: null });
|
|
29233
29738
|
}
|
|
29234
|
-
const currentFace = await this.faceStore.
|
|
29739
|
+
const currentFace = await this.faceStore.getWithEmbedding(faceId);
|
|
29235
29740
|
if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
|
|
29236
29741
|
const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
|
|
29237
29742
|
const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
|
|
@@ -29416,10 +29921,21 @@ var FaceGalleryProvider = class {
|
|
|
29416
29921
|
threshold,
|
|
29417
29922
|
minClusterSize
|
|
29418
29923
|
} });
|
|
29419
|
-
const
|
|
29420
|
-
|
|
29421
|
-
|
|
29422
|
-
|
|
29924
|
+
const vectors = await this.faceStore.loadEmbeddings(faces.map((f) => f.id));
|
|
29925
|
+
const items = [];
|
|
29926
|
+
for (const face of faces) {
|
|
29927
|
+
const embedding = vectors.get(face.id);
|
|
29928
|
+
if (embedding === void 0 || embedding.length === 0) continue;
|
|
29929
|
+
items.push({
|
|
29930
|
+
id: face.id,
|
|
29931
|
+
embedding
|
|
29932
|
+
});
|
|
29933
|
+
}
|
|
29934
|
+
if (items.length < faces.length) this.logger.warn("suggestFaceClusters skipped faces with no vector in the index", { meta: {
|
|
29935
|
+
scanned: faces.length,
|
|
29936
|
+
clusterable: items.length
|
|
29937
|
+
} });
|
|
29938
|
+
const clusters = clusterByEmbedding(items, threshold).filter((c) => c.size >= minClusterSize);
|
|
29423
29939
|
return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
|
|
29424
29940
|
}
|
|
29425
29941
|
};
|
|
@@ -32181,7 +32697,19 @@ async function runPreviewUpgrade(deps, input) {
|
|
|
32181
32697
|
});
|
|
32182
32698
|
return "no-bbox";
|
|
32183
32699
|
}
|
|
32700
|
+
if (pending.bestSeen.timestamp === void 0) {
|
|
32701
|
+
deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
|
|
32702
|
+
tags: { deviceId },
|
|
32703
|
+
meta: {
|
|
32704
|
+
trackId,
|
|
32705
|
+
keyFrameKey: input.keyFrameKey,
|
|
32706
|
+
heldProvenance: held
|
|
32707
|
+
}
|
|
32708
|
+
});
|
|
32709
|
+
return "no-bbox";
|
|
32710
|
+
}
|
|
32184
32711
|
const derived = await deps.derive({
|
|
32712
|
+
deviceId,
|
|
32185
32713
|
mediaKey: input.keyFrameKey,
|
|
32186
32714
|
bbox: {
|
|
32187
32715
|
x: pending.bestSeen.x,
|
|
@@ -32191,7 +32719,7 @@ async function runPreviewUpgrade(deps, input) {
|
|
|
32191
32719
|
},
|
|
32192
32720
|
frameWidth: pending.bestSeen.frameWidth,
|
|
32193
32721
|
frameHeight: pending.bestSeen.frameHeight,
|
|
32194
|
-
timestamp:
|
|
32722
|
+
timestamp: pending.bestSeen.timestamp
|
|
32195
32723
|
});
|
|
32196
32724
|
if (derived === null) {
|
|
32197
32725
|
deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
|
|
@@ -32555,6 +33083,28 @@ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
|
32555
33083
|
* `onInitialize`, not because their families are optional — a missing one means
|
|
32556
33084
|
* the addon is not fully up, and the sweep does not run in that state.
|
|
32557
33085
|
*/
|
|
33086
|
+
/**
|
|
33087
|
+
* The optional legs that are NOT in the registry this call would build.
|
|
33088
|
+
*
|
|
33089
|
+
* `buildAnalyticsCascadeRegistry` pushes each optional store only `if` it is
|
|
33090
|
+
* there, and that `if` is silent. A hub whose `vector-store` capability was
|
|
33091
|
+
* unavailable at boot builds an embedding-less cascade and then prunes tracks
|
|
33092
|
+
* with it — every pass, for the whole life of the process — while the only
|
|
33093
|
+
* evidence is one warn at startup that scrolled away hours earlier. Every CLIP
|
|
33094
|
+
* vector for every track deleted in that window becomes an orphan that nothing
|
|
33095
|
+
* can reach by ownership again, because its owner is gone.
|
|
33096
|
+
*
|
|
33097
|
+
* So the omission is named and returned, and the caller LOGS it. A branch that
|
|
33098
|
+
* drops work says so; silence reads as "there was nothing to drop".
|
|
33099
|
+
*/
|
|
33100
|
+
function absentCascadeLegs(stores) {
|
|
33101
|
+
const absent = [];
|
|
33102
|
+
if (!stores.faceStore) absent.push("faces");
|
|
33103
|
+
if (!stores.plateStore) absent.push("plates");
|
|
33104
|
+
if (!stores.objectEmbeddingStore) absent.push("embeddings");
|
|
33105
|
+
if (!stores.groupStore) absent.push("groups");
|
|
33106
|
+
return absent;
|
|
33107
|
+
}
|
|
32558
33108
|
function buildAnalyticsCascadeRegistry(stores) {
|
|
32559
33109
|
const registry = [stores.eventStore, stores.mediaStore];
|
|
32560
33110
|
if (stores.faceStore) registry.push(stores.faceStore);
|
|
@@ -32831,7 +33381,8 @@ var TrackCloser = class {
|
|
|
32831
33381
|
w: bestSeen.w,
|
|
32832
33382
|
h: bestSeen.h,
|
|
32833
33383
|
frameWidth: bestSeen.frameWidth,
|
|
32834
|
-
frameHeight: bestSeen.frameHeight
|
|
33384
|
+
frameHeight: bestSeen.frameHeight,
|
|
33385
|
+
timestamp: bestSeen.timestamp
|
|
32835
33386
|
} } : {},
|
|
32836
33387
|
recordedAtMs: this.now()
|
|
32837
33388
|
});
|
|
@@ -32847,7 +33398,7 @@ var TrackCloser = class {
|
|
|
32847
33398
|
});
|
|
32848
33399
|
const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
|
|
32849
33400
|
const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
|
|
32850
|
-
this.fireRecognizerEnds(t.deviceId, t.trackId);
|
|
33401
|
+
this.fireRecognizerEnds(t.deviceId, t.trackId, t.positions);
|
|
32851
33402
|
const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
|
|
32852
33403
|
const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
|
|
32853
33404
|
this.deps.residents.close(t.trackId, "end");
|
|
@@ -32906,14 +33457,14 @@ var TrackCloser = class {
|
|
|
32906
33457
|
/** Start face/plate `onTrackEnd` and wire the Design-B DEFERRED
|
|
32907
33458
|
* keyFrame-key drop (drop only once the face promise settles — or
|
|
32908
33459
|
* immediately when no face recognizer is active). */
|
|
32909
|
-
fireRecognizerEnds(deviceId, trackId) {
|
|
33460
|
+
fireRecognizerEnds(deviceId, trackId, subjectPath) {
|
|
32910
33461
|
const faceEnd = this.deps.faceRecognizer()?.onTrackEnd(deviceId, trackId);
|
|
32911
33462
|
const dropKeyFrameKey = () => {
|
|
32912
33463
|
this.deps.residents.dropKeyFrameKey(trackId);
|
|
32913
33464
|
};
|
|
32914
33465
|
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
32915
33466
|
else dropKeyFrameKey();
|
|
32916
|
-
this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId);
|
|
33467
|
+
this.deps.plateRecognizer()?.onTrackEnd(deviceId, trackId, subjectPath);
|
|
32917
33468
|
}
|
|
32918
33469
|
/** Last-resort preview: commit the retained detection-raster subject crop
|
|
32919
33470
|
* as the track's `thumbnail` (honest sub-native, never upscaled). */
|
|
@@ -32983,6 +33534,7 @@ var TrackCloser = class {
|
|
|
32983
33534
|
}
|
|
32984
33535
|
try {
|
|
32985
33536
|
const derived = await derive({
|
|
33537
|
+
deviceId: t.deviceId,
|
|
32986
33538
|
mediaKey: keyFrame.key,
|
|
32987
33539
|
bbox: {
|
|
32988
33540
|
x: bestSeen.x,
|
|
@@ -32992,7 +33544,8 @@ var TrackCloser = class {
|
|
|
32992
33544
|
},
|
|
32993
33545
|
frameWidth: bestSeen.frameWidth,
|
|
32994
33546
|
frameHeight: bestSeen.frameHeight,
|
|
32995
|
-
timestamp:
|
|
33547
|
+
timestamp: bestSeen.timestamp,
|
|
33548
|
+
subjectPath: t.positions
|
|
32996
33549
|
});
|
|
32997
33550
|
if (!derived) {
|
|
32998
33551
|
if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
|
|
@@ -33033,6 +33586,8 @@ var TrackCloser = class {
|
|
|
33033
33586
|
trackId: t.trackId,
|
|
33034
33587
|
keyFrameKey: keyFrame.key,
|
|
33035
33588
|
skewMs: derived.skewMs,
|
|
33589
|
+
motion: derived.motion ?? null,
|
|
33590
|
+
subjectIou: derived.subjectIou ?? null,
|
|
33036
33591
|
replaced: held?.provenance ?? null
|
|
33037
33592
|
}
|
|
33038
33593
|
});
|
|
@@ -34336,6 +34891,304 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
|
|
|
34336
34891
|
};
|
|
34337
34892
|
}
|
|
34338
34893
|
//#endregion
|
|
34894
|
+
//#region src/pipeline-analytics/retention/media-reclaim-actions.ts
|
|
34895
|
+
/**
|
|
34896
|
+
* The operator button for the debug-media reclaim.
|
|
34897
|
+
*
|
|
34898
|
+
* The mechanism ships armed with nothing: **arming it is the operator's
|
|
34899
|
+
* call**. `mode` omitted means `report` — it counts, logs one line per camera
|
|
34900
|
+
* and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
|
|
34901
|
+
* that has to be typed.
|
|
34902
|
+
*
|
|
34903
|
+
* Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
|
|
34904
|
+
* earned it: dry-run default, explicit pacing, explicit blast radius, and a
|
|
34905
|
+
* report you can read instead of a log line you have to find.
|
|
34906
|
+
*/
|
|
34907
|
+
/**
|
|
34908
|
+
* `report` counts; `reclaim` deletes.
|
|
34909
|
+
*
|
|
34910
|
+
* `.default('report')` is load-bearing: the settings form sends `{}` for a
|
|
34911
|
+
* button with no fields, and the safe reading of an unstated intent is the one
|
|
34912
|
+
* that destroys nothing.
|
|
34913
|
+
*/
|
|
34914
|
+
var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
|
|
34915
|
+
var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
|
|
34916
|
+
var MediaReclaimActionInputSchema = require_dist.object({
|
|
34917
|
+
mode: MediaReclaimActionModeSchema,
|
|
34918
|
+
/** Which populations to walk. Omitted = both. */
|
|
34919
|
+
scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
|
|
34920
|
+
/** Restrict to these cameras. Omitted = every camera in retention scope. */
|
|
34921
|
+
deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
|
|
34922
|
+
/** Start every device from the top instead of resuming its cursor. */
|
|
34923
|
+
restart: require_dist.boolean().optional(),
|
|
34924
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
34925
|
+
pageSize: require_dist.number().int().min(50).max(5e3).optional(),
|
|
34926
|
+
/** Rows EXAMINED per camera in this pass — the pacing bound. */
|
|
34927
|
+
maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
|
|
34928
|
+
/** Rows DELETED per camera in this pass — the blast-radius bound. */
|
|
34929
|
+
maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
|
|
34930
|
+
/** Bytes released across the whole run — the disk-contention bound. */
|
|
34931
|
+
maxBytesPerRun: require_dist.number().int().min(1).optional(),
|
|
34932
|
+
/** Wall-clock ceiling for the run. */
|
|
34933
|
+
budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
|
|
34934
|
+
/**
|
|
34935
|
+
* Bytes per second the pass may release. The recordings volume sustains
|
|
34936
|
+
* ~4 MB/s and the archive needs it more than this does; the default is half
|
|
34937
|
+
* of that.
|
|
34938
|
+
*/
|
|
34939
|
+
throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
|
|
34940
|
+
/**
|
|
34941
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
34942
|
+
* exclusively save rows, never select them.
|
|
34943
|
+
*/
|
|
34944
|
+
graceMinutes: require_dist.number().int().min(1).max(10080).optional()
|
|
34945
|
+
});
|
|
34946
|
+
var MediaReclaimDeviceReportSchema = require_dist.object({
|
|
34947
|
+
deviceId: require_dist.number(),
|
|
34948
|
+
scope: MediaReclaimScopeSchema,
|
|
34949
|
+
examined: require_dist.number(),
|
|
34950
|
+
eligible: require_dist.number(),
|
|
34951
|
+
reclaimed: require_dist.number(),
|
|
34952
|
+
bytesReclaimed: require_dist.number(),
|
|
34953
|
+
/** Rows kept because the owning track is pinned (`staging`/`favourited`). */
|
|
34954
|
+
held: require_dist.number(),
|
|
34955
|
+
/** Rows kept because the owner could not be shown to be a motion event. */
|
|
34956
|
+
unresolved: require_dist.number(),
|
|
34957
|
+
withinGrace: require_dist.number(),
|
|
34958
|
+
/** Deletes that REFUSED — the blob survived or the volume could not answer.
|
|
34959
|
+
* The row was kept and the cursor did not pass it. */
|
|
34960
|
+
refused: require_dist.number(),
|
|
34961
|
+
stoppedBy: require_dist.string(),
|
|
34962
|
+
/** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
|
|
34963
|
+
nextCursor: require_dist.number()
|
|
34964
|
+
});
|
|
34965
|
+
var MediaReclaimActionResultSchema = require_dist.object({
|
|
34966
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
34967
|
+
ran: require_dist.boolean(),
|
|
34968
|
+
mode: require_dist._enum(["report", "reclaim"]),
|
|
34969
|
+
devices: require_dist.array(MediaReclaimDeviceReportSchema),
|
|
34970
|
+
totalExamined: require_dist.number(),
|
|
34971
|
+
totalEligible: require_dist.number(),
|
|
34972
|
+
totalReclaimed: require_dist.number(),
|
|
34973
|
+
totalBytesReclaimed: require_dist.number(),
|
|
34974
|
+
totalRefused: require_dist.number(),
|
|
34975
|
+
durationMs: require_dist.number(),
|
|
34976
|
+
/** Every device+scope reported `exhausted` — there is nothing left to do. */
|
|
34977
|
+
complete: require_dist.boolean()
|
|
34978
|
+
});
|
|
34979
|
+
var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
|
|
34980
|
+
//#endregion
|
|
34981
|
+
//#region src/pipeline-analytics/retention/media-reclaim.ts
|
|
34982
|
+
var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
|
|
34983
|
+
var DEFAULT_MEDIA_RECLAIM_LIMITS = {
|
|
34984
|
+
mode: "report",
|
|
34985
|
+
pageSize: 500,
|
|
34986
|
+
maxRowsPerDevice: 5e4,
|
|
34987
|
+
maxReclaimPerDevice: 5e4,
|
|
34988
|
+
maxBytesPerRun: 20 * 1024 * 1024 * 1024,
|
|
34989
|
+
budgetMs: 30 * 6e4,
|
|
34990
|
+
graceMs: 60 * 6e4,
|
|
34991
|
+
throttleBytesPerSec: 2 * 1024 * 1024
|
|
34992
|
+
};
|
|
34993
|
+
function emptyTally() {
|
|
34994
|
+
return {
|
|
34995
|
+
examined: 0,
|
|
34996
|
+
eligible: 0,
|
|
34997
|
+
reclaimed: 0,
|
|
34998
|
+
bytesReclaimed: 0,
|
|
34999
|
+
held: 0,
|
|
35000
|
+
unresolved: 0,
|
|
35001
|
+
withinGrace: 0,
|
|
35002
|
+
refused: 0
|
|
35003
|
+
};
|
|
35004
|
+
}
|
|
35005
|
+
/**
|
|
35006
|
+
* Walk ONE device within ONE scope. Never throws: a probe failure ends the
|
|
35007
|
+
* device with what it has and a cursor that has not moved past anything
|
|
35008
|
+
* unfinished.
|
|
35009
|
+
*/
|
|
35010
|
+
async function reclaimDeviceScope(input) {
|
|
35011
|
+
const { deviceId, scope, deps, limits } = input;
|
|
35012
|
+
const tally = emptyTally();
|
|
35013
|
+
const graceFloor = deps.now() - limits.graceMs;
|
|
35014
|
+
let cursor = Math.max(0, input.cursor);
|
|
35015
|
+
let byteBudget = input.byteBudget;
|
|
35016
|
+
let stoppedBy = "exhausted";
|
|
35017
|
+
for (;;) {
|
|
35018
|
+
if (deps.now() >= input.deadlineMs) {
|
|
35019
|
+
stoppedBy = "budget";
|
|
35020
|
+
break;
|
|
35021
|
+
}
|
|
35022
|
+
if (tally.examined >= limits.maxRowsPerDevice) {
|
|
35023
|
+
stoppedBy = "row-ceiling";
|
|
35024
|
+
break;
|
|
35025
|
+
}
|
|
35026
|
+
const rows = await deps.page({
|
|
35027
|
+
deviceId,
|
|
35028
|
+
scope,
|
|
35029
|
+
afterMs: cursor,
|
|
35030
|
+
limit: limits.pageSize
|
|
35031
|
+
});
|
|
35032
|
+
if (rows.length === 0) break;
|
|
35033
|
+
const ownerIds = [...new Set(rows.map((r) => r.ownerId).filter((id) => id.length > 0))];
|
|
35034
|
+
let motionOwners = /* @__PURE__ */ new Set();
|
|
35035
|
+
let pinnedOwners = /* @__PURE__ */ new Set();
|
|
35036
|
+
try {
|
|
35037
|
+
if (scope === "motion-still") motionOwners = ownerIds.length > 0 ? await deps.motionEventIds(ownerIds) : /* @__PURE__ */ new Set();
|
|
35038
|
+
else pinnedOwners = ownerIds.length > 0 ? await deps.pinnedTrackIds(ownerIds) : /* @__PURE__ */ new Set();
|
|
35039
|
+
} catch (err) {
|
|
35040
|
+
deps.logger.warn("media reclaim: ownership probe failed — device abandoned for this run", {
|
|
35041
|
+
tags: { deviceId },
|
|
35042
|
+
meta: {
|
|
35043
|
+
scope,
|
|
35044
|
+
cursor,
|
|
35045
|
+
error: String(err)
|
|
35046
|
+
}
|
|
35047
|
+
});
|
|
35048
|
+
stoppedBy = "probe-failed";
|
|
35049
|
+
break;
|
|
35050
|
+
}
|
|
35051
|
+
let pageBytes = 0;
|
|
35052
|
+
let halted = null;
|
|
35053
|
+
for (const row of rows) {
|
|
35054
|
+
tally.examined += 1;
|
|
35055
|
+
const advanceTo = row.timestamp;
|
|
35056
|
+
if (row.timestamp > graceFloor) {
|
|
35057
|
+
tally.withinGrace += 1;
|
|
35058
|
+
cursor = advanceTo;
|
|
35059
|
+
continue;
|
|
35060
|
+
}
|
|
35061
|
+
if (scope === "motion-still" && !motionOwners.has(row.ownerId)) {
|
|
35062
|
+
tally.unresolved += 1;
|
|
35063
|
+
cursor = advanceTo;
|
|
35064
|
+
continue;
|
|
35065
|
+
}
|
|
35066
|
+
if (scope === "track-filmstrip" && pinnedOwners.has(row.ownerId)) {
|
|
35067
|
+
tally.held += 1;
|
|
35068
|
+
cursor = advanceTo;
|
|
35069
|
+
continue;
|
|
35070
|
+
}
|
|
35071
|
+
tally.eligible += 1;
|
|
35072
|
+
if (limits.mode === "report") {
|
|
35073
|
+
cursor = advanceTo;
|
|
35074
|
+
continue;
|
|
35075
|
+
}
|
|
35076
|
+
if (tally.reclaimed >= limits.maxReclaimPerDevice) {
|
|
35077
|
+
halted = "reclaim-ceiling";
|
|
35078
|
+
break;
|
|
35079
|
+
}
|
|
35080
|
+
if (byteBudget <= 0) {
|
|
35081
|
+
halted = "byte-ceiling";
|
|
35082
|
+
break;
|
|
35083
|
+
}
|
|
35084
|
+
try {
|
|
35085
|
+
const gone = await deps.reclaim(row.key);
|
|
35086
|
+
tally.reclaimed += gone.rows;
|
|
35087
|
+
tally.bytesReclaimed += gone.bytes;
|
|
35088
|
+
byteBudget -= gone.bytes;
|
|
35089
|
+
pageBytes += gone.bytes;
|
|
35090
|
+
cursor = advanceTo;
|
|
35091
|
+
} catch (err) {
|
|
35092
|
+
tally.refused += 1;
|
|
35093
|
+
deps.logger.warn("media reclaim: delete refused — row kept", {
|
|
35094
|
+
tags: { deviceId },
|
|
35095
|
+
meta: {
|
|
35096
|
+
scope,
|
|
35097
|
+
key: row.key,
|
|
35098
|
+
error: String(err)
|
|
35099
|
+
}
|
|
35100
|
+
});
|
|
35101
|
+
halted = "delete-refused";
|
|
35102
|
+
break;
|
|
35103
|
+
}
|
|
35104
|
+
}
|
|
35105
|
+
if (halted !== null) {
|
|
35106
|
+
stoppedBy = halted;
|
|
35107
|
+
break;
|
|
35108
|
+
}
|
|
35109
|
+
if (limits.throttleBytesPerSec > 0 && pageBytes > 0 && deps.pace) await deps.pace(pageBytes / limits.throttleBytesPerSec * 1e3);
|
|
35110
|
+
if (deps.breathe) await deps.breathe();
|
|
35111
|
+
if (rows.length < limits.pageSize) break;
|
|
35112
|
+
}
|
|
35113
|
+
return {
|
|
35114
|
+
deviceId,
|
|
35115
|
+
scope,
|
|
35116
|
+
examined: tally.examined,
|
|
35117
|
+
eligible: tally.eligible,
|
|
35118
|
+
reclaimed: tally.reclaimed,
|
|
35119
|
+
bytesReclaimed: tally.bytesReclaimed,
|
|
35120
|
+
held: tally.held,
|
|
35121
|
+
unresolved: tally.unresolved,
|
|
35122
|
+
withinGrace: tally.withinGrace,
|
|
35123
|
+
refused: tally.refused,
|
|
35124
|
+
stoppedBy,
|
|
35125
|
+
nextCursor: cursor
|
|
35126
|
+
};
|
|
35127
|
+
}
|
|
35128
|
+
/** Cursor key for one `(device, scope)` pair. */
|
|
35129
|
+
function mediaReclaimCursorKey(deviceId, scope) {
|
|
35130
|
+
return `${scope}:${deviceId}`;
|
|
35131
|
+
}
|
|
35132
|
+
/**
|
|
35133
|
+
* Walk every `(device, scope)` pair under one shared byte budget and one
|
|
35134
|
+
* shared deadline, and log ONE summary line per device — never a line per row.
|
|
35135
|
+
*/
|
|
35136
|
+
async function runMediaReclaim(input) {
|
|
35137
|
+
const startedAt = input.deps.now();
|
|
35138
|
+
const deadlineMs = startedAt + input.limits.budgetMs;
|
|
35139
|
+
const nextCursors = new Map(input.cursors);
|
|
35140
|
+
const devices = [];
|
|
35141
|
+
let byteBudget = input.limits.maxBytesPerRun;
|
|
35142
|
+
for (const scope of input.scopes) for (const deviceId of input.deviceIds) {
|
|
35143
|
+
if (input.deps.now() >= deadlineMs) break;
|
|
35144
|
+
const key = mediaReclaimCursorKey(deviceId, scope);
|
|
35145
|
+
const report = await reclaimDeviceScope({
|
|
35146
|
+
deviceId,
|
|
35147
|
+
scope,
|
|
35148
|
+
cursor: input.cursors.get(key) ?? 0,
|
|
35149
|
+
deps: input.deps,
|
|
35150
|
+
limits: input.limits,
|
|
35151
|
+
deadlineMs,
|
|
35152
|
+
byteBudget
|
|
35153
|
+
});
|
|
35154
|
+
byteBudget -= report.bytesReclaimed;
|
|
35155
|
+
nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
|
|
35156
|
+
devices.push(report);
|
|
35157
|
+
if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
|
|
35158
|
+
tags: { deviceId },
|
|
35159
|
+
meta: {
|
|
35160
|
+
scope,
|
|
35161
|
+
mode: input.limits.mode,
|
|
35162
|
+
examined: report.examined,
|
|
35163
|
+
eligible: report.eligible,
|
|
35164
|
+
reclaimed: report.reclaimed,
|
|
35165
|
+
bytesReclaimed: report.bytesReclaimed,
|
|
35166
|
+
held: report.held,
|
|
35167
|
+
unresolved: report.unresolved,
|
|
35168
|
+
withinGrace: report.withinGrace,
|
|
35169
|
+
refused: report.refused,
|
|
35170
|
+
stoppedBy: report.stoppedBy,
|
|
35171
|
+
nextCursor: report.nextCursor
|
|
35172
|
+
}
|
|
35173
|
+
});
|
|
35174
|
+
}
|
|
35175
|
+
const total = (pick) => devices.reduce((acc, r) => acc + pick(r), 0);
|
|
35176
|
+
return {
|
|
35177
|
+
report: {
|
|
35178
|
+
mode: input.limits.mode,
|
|
35179
|
+
devices,
|
|
35180
|
+
totalExamined: total((r) => r.examined),
|
|
35181
|
+
totalEligible: total((r) => r.eligible),
|
|
35182
|
+
totalReclaimed: total((r) => r.reclaimed),
|
|
35183
|
+
totalBytesReclaimed: total((r) => r.bytesReclaimed),
|
|
35184
|
+
totalRefused: total((r) => r.refused),
|
|
35185
|
+
durationMs: input.deps.now() - startedAt,
|
|
35186
|
+
complete: devices.every((r) => r.stoppedBy === "exhausted")
|
|
35187
|
+
},
|
|
35188
|
+
cursors: nextCursors
|
|
35189
|
+
};
|
|
35190
|
+
}
|
|
35191
|
+
//#endregion
|
|
34339
35192
|
//#region src/pipeline-analytics/retention/orphan-audit-actions.ts
|
|
34340
35193
|
/**
|
|
34341
35194
|
* The operator button for the orphan audit.
|
|
@@ -43542,7 +44395,7 @@ function snapshot(j) {
|
|
|
43542
44395
|
entities: [...entitiesFor(j.mode)],
|
|
43543
44396
|
filesMoved: j.filesMoved,
|
|
43544
44397
|
bytesMoved: j.bytesMoved,
|
|
43545
|
-
filesTotal:
|
|
44398
|
+
filesTotal: j.filesTotal,
|
|
43546
44399
|
startedAt: j.startedAt,
|
|
43547
44400
|
finishedAt: j.finishedAt,
|
|
43548
44401
|
error: j.error
|
|
@@ -43564,6 +44417,40 @@ function rowIsGallery(data) {
|
|
|
43564
44417
|
const ownerKind = String(data["ownerKind"] ?? "");
|
|
43565
44418
|
return isMediaOwnerKind(ownerKind) && isRetentionExemptOwnerKind(ownerKind);
|
|
43566
44419
|
}
|
|
44420
|
+
/**
|
|
44421
|
+
* Which collections a pass walks.
|
|
44422
|
+
*
|
|
44423
|
+
* ONE derivation, shared by the pass and by its pre-count. Two lists would let
|
|
44424
|
+
* an M counted over both collections face an N moved across one — the shape
|
|
44425
|
+
* D295 calls "a guard nobody believes".
|
|
44426
|
+
*/
|
|
44427
|
+
function collectionsFor(mode) {
|
|
44428
|
+
if (mode === "gallery") return [{
|
|
44429
|
+
name: MEDIA_COLLECTION,
|
|
44430
|
+
order: "timestamp"
|
|
44431
|
+
}];
|
|
44432
|
+
return [{
|
|
44433
|
+
name: MEDIA_COLLECTION,
|
|
44434
|
+
order: "timestamp"
|
|
44435
|
+
}, {
|
|
44436
|
+
name: RETRAIN_FRAMES_COLLECTION,
|
|
44437
|
+
order: "copiedAt"
|
|
44438
|
+
}];
|
|
44439
|
+
}
|
|
44440
|
+
/**
|
|
44441
|
+
* Does this pass act on this row at all?
|
|
44442
|
+
*
|
|
44443
|
+
* A free function of `(mode, target)` rather than a method on the job, so the
|
|
44444
|
+
* pre-count that produces `filesTotal` and the walk that produces `filesMoved`
|
|
44445
|
+
* ask the SAME question. A separately-written predicate is how a denominator
|
|
44446
|
+
* ends up counting a different population than the numerator.
|
|
44447
|
+
*/
|
|
44448
|
+
function selectsRow(mode, toLocationId, data, isRetrain) {
|
|
44449
|
+
if (mode === "seal") return rowIsUnstamped(data);
|
|
44450
|
+
if (mediaRowLocation(data) === toLocationId) return false;
|
|
44451
|
+
if (mode === "gallery") return !isRetrain && rowIsGallery(data);
|
|
44452
|
+
return isRetrain || !rowIsGallery(data);
|
|
44453
|
+
}
|
|
43567
44454
|
var MediaRelocateEngine = class {
|
|
43568
44455
|
deps;
|
|
43569
44456
|
jobs = /* @__PURE__ */ new Map();
|
|
@@ -43612,6 +44499,7 @@ var MediaRelocateEngine = class {
|
|
|
43612
44499
|
deviceId: null,
|
|
43613
44500
|
filesMoved: 0,
|
|
43614
44501
|
bytesMoved: 0,
|
|
44502
|
+
filesTotal: null,
|
|
43615
44503
|
startedAt: this.deps.now(),
|
|
43616
44504
|
finishedAt: null,
|
|
43617
44505
|
error: null,
|
|
@@ -43660,12 +44548,39 @@ var MediaRelocateEngine = class {
|
|
|
43660
44548
|
if (rows.length < PAGE_SIZE) return;
|
|
43661
44549
|
}
|
|
43662
44550
|
}
|
|
43663
|
-
/**
|
|
43664
|
-
|
|
43665
|
-
|
|
43666
|
-
|
|
43667
|
-
|
|
43668
|
-
|
|
44551
|
+
/**
|
|
44552
|
+
* How many rows a pass would still act on against `toLocationId`.
|
|
44553
|
+
*
|
|
44554
|
+
* The media lane's **M**, and — read on its own — its residue: "how much of
|
|
44555
|
+
* `eventMedia` is still not on the current default". Both come from here so
|
|
44556
|
+
* neither can drift from what the pass actually moves.
|
|
44557
|
+
*
|
|
44558
|
+
* `null` is "the count could not be taken", never `0`. A zero here reads as
|
|
44559
|
+
* "there is nothing left to move", which is precisely the claim a failed read
|
|
44560
|
+
* must not be allowed to make (D295).
|
|
44561
|
+
*/
|
|
44562
|
+
async countSelectable(input) {
|
|
44563
|
+
return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
|
|
44564
|
+
}
|
|
44565
|
+
async countSelected(mode, toLocationId, stop) {
|
|
44566
|
+
try {
|
|
44567
|
+
let rows = 0;
|
|
44568
|
+
for (const collection of collectionsFor(mode)) {
|
|
44569
|
+
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
44570
|
+
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
44571
|
+
if (stop()) return null;
|
|
44572
|
+
if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
|
|
44573
|
+
}
|
|
44574
|
+
}
|
|
44575
|
+
return { rows };
|
|
44576
|
+
} catch (err) {
|
|
44577
|
+
this.deps.logger.warn("media relocate: row count failed — this pass cannot say how much of the collection it saw", { meta: {
|
|
44578
|
+
mode,
|
|
44579
|
+
toLocationId,
|
|
44580
|
+
error: String(err)
|
|
44581
|
+
} });
|
|
44582
|
+
return null;
|
|
44583
|
+
}
|
|
43669
44584
|
}
|
|
43670
44585
|
async run(job, throttleMbps) {
|
|
43671
44586
|
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -43679,23 +44594,14 @@ var MediaRelocateEngine = class {
|
|
|
43679
44594
|
* rows in silence reads as "there were none", which is how a migration
|
|
43680
44595
|
* gets believed. */
|
|
43681
44596
|
let pathlessRows = 0;
|
|
43682
|
-
|
|
43683
|
-
|
|
43684
|
-
order: "timestamp"
|
|
43685
|
-
}] : [{
|
|
43686
|
-
name: MEDIA_COLLECTION,
|
|
43687
|
-
order: "timestamp"
|
|
43688
|
-
}, {
|
|
43689
|
-
name: RETRAIN_FRAMES_COLLECTION,
|
|
43690
|
-
order: "copiedAt"
|
|
43691
|
-
}];
|
|
43692
|
-
for (const collection of collections) {
|
|
44597
|
+
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
44598
|
+
for (const collection of collectionsFor(job.mode)) {
|
|
43693
44599
|
if (job.cancelRequested) break;
|
|
43694
44600
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
43695
44601
|
for await (const row of this.pageRows(collection.name, collection.order)) {
|
|
43696
44602
|
if (job.cancelRequested) break;
|
|
43697
44603
|
const data = row.data;
|
|
43698
|
-
if (!
|
|
44604
|
+
if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
|
|
43699
44605
|
if (job.mode === "seal") {
|
|
43700
44606
|
try {
|
|
43701
44607
|
await this.stamp(collection.name, row.id, data, job.toLocationId);
|
|
@@ -43808,10 +44714,38 @@ var MediaSettingsSchema = require_dist.object({
|
|
|
43808
44714
|
* 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
|
|
43809
44715
|
* max 2 (200%). */
|
|
43810
44716
|
cropPadding: require_dist.number().min(0).max(2).default(.15),
|
|
43811
|
-
/**
|
|
43812
|
-
*
|
|
43813
|
-
*
|
|
43814
|
-
|
|
44717
|
+
/**
|
|
44718
|
+
* Per-camera DEBUG media. Default **false**, and it absorbs the retired
|
|
44719
|
+
* `saveThumbnails` rather than sitting beside it (D62: a second switch that
|
|
44720
|
+
* disagrees with the first is worse than no switch).
|
|
44721
|
+
*
|
|
44722
|
+
* What it gates, and only this:
|
|
44723
|
+
* - the periodic per-track `snapshot` filmstrip (`periodic-media-plan.ts`);
|
|
44724
|
+
* - the per-EDGE motion still — the debug surplus that
|
|
44725
|
+
* {@link decideMotionStill} otherwise reduces to one marker per
|
|
44726
|
+
* {@link MediaSettings.motionMarkerQuietMs} drought window.
|
|
44727
|
+
*
|
|
44728
|
+
* What it never gates, at any value: `firstFrame`, the rolling `lastFrame`,
|
|
44729
|
+
* `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
|
|
44730
|
+
* buffers, gallery media, the CLIP search `crop` — and the quiet-period
|
|
44731
|
+
* motion marker itself, which is the operator's fallback and must survive
|
|
44732
|
+
* the default (a blank timeline on a quiet camera reads as broken).
|
|
44733
|
+
*
|
|
44734
|
+
* The rolling `lastFrame` deliberately moved OUT from under the old
|
|
44735
|
+
* `saveThumbnails`: it is in the keep-set, and a debug switch may not take
|
|
44736
|
+
* it away.
|
|
44737
|
+
*/
|
|
44738
|
+
debugMediaEnabled: require_dist.boolean().default(false),
|
|
44739
|
+
/**
|
|
44740
|
+
* The detection-drought window N for the motion marker: with debug media
|
|
44741
|
+
* off, a motion off→on edge produces a still only when NO detection has run
|
|
44742
|
+
* on this camera for this long AND no marker was produced in the last such
|
|
44743
|
+
* window. Chosen from the observed fleet distribution of detection-free
|
|
44744
|
+
* stretches (median 42 s, p75 201 s, p90 770 s) — 900 s sits just past p90,
|
|
44745
|
+
* so nine stretches in ten produce nothing. See
|
|
44746
|
+
* `services/motion-marker-policy.ts`.
|
|
44747
|
+
*/
|
|
44748
|
+
motionMarkerQuietMs: require_dist.number().int().min(6e4).max(360 * 6e4).default(9e5),
|
|
43815
44749
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
43816
44750
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
43817
44751
|
* has elapsed since its previous one. */
|
|
@@ -43840,7 +44774,8 @@ function resolveMediaSettings(raw) {
|
|
|
43840
44774
|
};
|
|
43841
44775
|
return {
|
|
43842
44776
|
cropPadding: pick("cropPadding"),
|
|
43843
|
-
|
|
44777
|
+
debugMediaEnabled: pick("debugMediaEnabled"),
|
|
44778
|
+
motionMarkerQuietMs: pick("motionMarkerQuietMs"),
|
|
43844
44779
|
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
43845
44780
|
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
43846
44781
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
@@ -44084,9 +45019,9 @@ function resolvePackageDropSettings(raw) {
|
|
|
44084
45019
|
* can never duplicate one.
|
|
44085
45020
|
*/
|
|
44086
45021
|
function planPeriodicMedia(input) {
|
|
44087
|
-
const appendSnapshot = input.dueSnapshot;
|
|
45022
|
+
const appendSnapshot = input.debugMediaEnabled && input.dueSnapshot;
|
|
44088
45023
|
const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
|
|
44089
|
-
const lastFrameElapsed =
|
|
45024
|
+
const lastFrameElapsed = Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
|
|
44090
45025
|
const firstFrameLanded = input.firstFrameLanded ?? true;
|
|
44091
45026
|
const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
|
|
44092
45027
|
const thumbnailLanded = input.thumbnailLanded ?? true;
|
|
@@ -44757,6 +45692,90 @@ function padBbox(bbox, padding) {
|
|
|
44757
45692
|
};
|
|
44758
45693
|
}
|
|
44759
45694
|
//#endregion
|
|
45695
|
+
//#region src/pipeline-analytics/pipeline/subject-agreement.ts
|
|
45696
|
+
/**
|
|
45697
|
+
* `subjectAgreement` — the ONE predicate for "a rectangle measured on frame A
|
|
45698
|
+
* is still honest against the pixels of frame B".
|
|
45699
|
+
*
|
|
45700
|
+
* Two durable display crops cut a rectangle out of a frame that is NOT the
|
|
45701
|
+
* frame the rectangle was measured on: the close-time thumbnail derive (the
|
|
45702
|
+
* best-seen subject box, cut from the persisted keyFrame) and the plate
|
|
45703
|
+
* gallery's last-resort crop (the plate rect at the best OCR read, cut from the
|
|
45704
|
+
* same keyFrame). Both were guarded on TIME alone, and time is the wrong
|
|
45705
|
+
* quantity: 1.5 s is nothing for a parked car and hundreds of native pixels for
|
|
45706
|
+
* a car crossing the frame. Confirmed on the operator's debug-marked tracks
|
|
45707
|
+
* (2026-08-30 triage): `e9c024bb` passed the time bound at 531–1179 ms and
|
|
45708
|
+
* stored a windshield and a patch of gravel, while `7613c0fa` — the same path,
|
|
45709
|
+
* 2579 ms of skew, a stationary vehicle — stored a perfectly framed plate.
|
|
45710
|
+
*
|
|
45711
|
+
* The quantity that separates those cases is MOTION, and the track's own
|
|
45712
|
+
* `positions` trail already records it. This asks the trail one question:
|
|
45713
|
+
*
|
|
45714
|
+
* between the instant the rectangle was measured and the instant the pixels
|
|
45715
|
+
* were captured, did the subject stay where it was?
|
|
45716
|
+
*
|
|
45717
|
+
* IoU over the two subject boxes answers it, and because both boxes come from
|
|
45718
|
+
* the SAME trail they are in the same detection-frame pixel space by
|
|
45719
|
+
* construction — there is no cross-space normalization to get wrong.
|
|
45720
|
+
*
|
|
45721
|
+
* D52's one-derivation discipline applied to the guard: this is the only place
|
|
45722
|
+
* the question is answered, and {@link createKeyFrameCrop} is its only caller,
|
|
45723
|
+
* so both defects share one contract instead of two drifting copies.
|
|
45724
|
+
*
|
|
45725
|
+
* `unknown` is a real verdict, not a failure: a track with no trail, or a trail
|
|
45726
|
+
* downsampled away from one of the two instants, carries NO motion evidence.
|
|
45727
|
+
* The caller then falls back to the temporal bound it always had — an absent
|
|
45728
|
+
* read changes nothing (D49).
|
|
45729
|
+
*/
|
|
45730
|
+
function nearestSample(path, atMs, maxSampleGapMs) {
|
|
45731
|
+
let best;
|
|
45732
|
+
let bestGap = Number.POSITIVE_INFINITY;
|
|
45733
|
+
for (const sample of path) {
|
|
45734
|
+
const gap = Math.abs(sample.timestamp - atMs);
|
|
45735
|
+
if (gap < bestGap) {
|
|
45736
|
+
bestGap = gap;
|
|
45737
|
+
best = sample;
|
|
45738
|
+
}
|
|
45739
|
+
}
|
|
45740
|
+
if (best === void 0 || bestGap > maxSampleGapMs) return void 0;
|
|
45741
|
+
return {
|
|
45742
|
+
bbox: best.bbox,
|
|
45743
|
+
gapMs: bestGap
|
|
45744
|
+
};
|
|
45745
|
+
}
|
|
45746
|
+
/**
|
|
45747
|
+
* Answer "did the subject stay put between the two instants?" from the track's
|
|
45748
|
+
* own position trail. Pure — no IO, no clock, no logging: the caller owns the
|
|
45749
|
+
* consequence and the line it writes.
|
|
45750
|
+
*/
|
|
45751
|
+
function subjectAgreement(query) {
|
|
45752
|
+
if (query.path.length === 0) return {
|
|
45753
|
+
kind: "unknown",
|
|
45754
|
+
reason: "no-path"
|
|
45755
|
+
};
|
|
45756
|
+
const atMeasure = nearestSample(query.path, query.measuredAtMs, query.maxSampleGapMs);
|
|
45757
|
+
if (atMeasure === void 0) return {
|
|
45758
|
+
kind: "unknown",
|
|
45759
|
+
reason: "no-sample-near-measure"
|
|
45760
|
+
};
|
|
45761
|
+
const atCut = nearestSample(query.path, query.cutFromMs, query.maxSampleGapMs);
|
|
45762
|
+
if (atCut === void 0) return {
|
|
45763
|
+
kind: "unknown",
|
|
45764
|
+
reason: "no-sample-near-cut"
|
|
45765
|
+
};
|
|
45766
|
+
const overlap = iou$1(atMeasure.bbox, atCut.bbox);
|
|
45767
|
+
const sampleGapMs = Math.max(atMeasure.gapMs, atCut.gapMs);
|
|
45768
|
+
return overlap >= query.minIou ? {
|
|
45769
|
+
kind: "agrees",
|
|
45770
|
+
iou: overlap,
|
|
45771
|
+
sampleGapMs
|
|
45772
|
+
} : {
|
|
45773
|
+
kind: "moved",
|
|
45774
|
+
iou: overlap,
|
|
45775
|
+
sampleGapMs
|
|
45776
|
+
};
|
|
45777
|
+
}
|
|
45778
|
+
//#endregion
|
|
44760
45779
|
//#region src/pipeline-analytics/pipeline/keyframe-crop.ts
|
|
44761
45780
|
function createKeyFrameCrop(deps) {
|
|
44762
45781
|
return async (input) => {
|
|
@@ -44764,12 +45783,36 @@ function createKeyFrameCrop(deps) {
|
|
|
44764
45783
|
const media = await deps.getMedia(input.mediaKey);
|
|
44765
45784
|
if (!media) return null;
|
|
44766
45785
|
const skewMs = Math.abs(media.timestamp - input.timestamp);
|
|
44767
|
-
|
|
44768
|
-
|
|
44769
|
-
|
|
44770
|
-
|
|
44771
|
-
|
|
44772
|
-
|
|
45786
|
+
const verdict = subjectAgreement({
|
|
45787
|
+
path: input.subjectPath ?? [],
|
|
45788
|
+
measuredAtMs: input.timestamp,
|
|
45789
|
+
cutFromMs: media.timestamp,
|
|
45790
|
+
minIou: deps.minSubjectIou,
|
|
45791
|
+
maxSampleGapMs: deps.maxSubjectSampleGapMs
|
|
45792
|
+
});
|
|
45793
|
+
if (verdict.kind === "moved") {
|
|
45794
|
+
deps.logger.info("keyframe crop refused — subject moved between the two instants", {
|
|
45795
|
+
tags: { deviceId: input.deviceId },
|
|
45796
|
+
meta: {
|
|
45797
|
+
mediaKey: input.mediaKey,
|
|
45798
|
+
skewMs,
|
|
45799
|
+
subjectIou: verdict.iou,
|
|
45800
|
+
minSubjectIou: deps.minSubjectIou,
|
|
45801
|
+
sampleGapMs: verdict.sampleGapMs
|
|
45802
|
+
}
|
|
45803
|
+
});
|
|
45804
|
+
return null;
|
|
45805
|
+
}
|
|
45806
|
+
if (verdict.kind === "unknown" && skewMs > deps.maxSkewMs) {
|
|
45807
|
+
deps.logger.info("keyframe crop refused — temporal skew too large, no motion evidence", {
|
|
45808
|
+
tags: { deviceId: input.deviceId },
|
|
45809
|
+
meta: {
|
|
45810
|
+
mediaKey: input.mediaKey,
|
|
45811
|
+
skewMs,
|
|
45812
|
+
maxSkewMs: deps.maxSkewMs,
|
|
45813
|
+
unknownReason: verdict.reason
|
|
45814
|
+
}
|
|
45815
|
+
});
|
|
44773
45816
|
return null;
|
|
44774
45817
|
}
|
|
44775
45818
|
try {
|
|
@@ -44781,15 +45824,25 @@ function createKeyFrameCrop(deps) {
|
|
|
44781
45824
|
w: input.bbox.w / input.frameWidth,
|
|
44782
45825
|
h: input.bbox.h / input.frameHeight
|
|
44783
45826
|
}, input.padding);
|
|
44784
|
-
|
|
44785
|
-
|
|
44786
|
-
|
|
45827
|
+
const jpeg = await deps.cropRegionToJpeg(Buffer.from(rgb.bytes), rgb.width, rgb.height, norm);
|
|
45828
|
+
return verdict.kind === "agrees" ? {
|
|
45829
|
+
jpeg,
|
|
45830
|
+
skewMs,
|
|
45831
|
+
motion: "agrees",
|
|
45832
|
+
subjectIou: verdict.iou
|
|
45833
|
+
} : {
|
|
45834
|
+
jpeg,
|
|
45835
|
+
skewMs,
|
|
45836
|
+
motion: "unknown"
|
|
44787
45837
|
};
|
|
44788
45838
|
} catch (err) {
|
|
44789
|
-
deps.logger.debug("keyframe crop derive failed", {
|
|
44790
|
-
|
|
44791
|
-
|
|
44792
|
-
|
|
45839
|
+
deps.logger.debug("keyframe crop derive failed", {
|
|
45840
|
+
tags: { deviceId: input.deviceId },
|
|
45841
|
+
meta: {
|
|
45842
|
+
mediaKey: input.mediaKey,
|
|
45843
|
+
error: err instanceof Error ? err.message : String(err)
|
|
45844
|
+
}
|
|
45845
|
+
});
|
|
44793
45846
|
return null;
|
|
44794
45847
|
}
|
|
44795
45848
|
};
|
|
@@ -47632,6 +48685,52 @@ async function applyTrackFlags(deps, input) {
|
|
|
47632
48685
|
};
|
|
47633
48686
|
}
|
|
47634
48687
|
//#endregion
|
|
48688
|
+
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
48689
|
+
/**
|
|
48690
|
+
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
48691
|
+
*
|
|
48692
|
+
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
48693
|
+
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
48694
|
+
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
48695
|
+
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
48696
|
+
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
48697
|
+
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
48698
|
+
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
48699
|
+
* "Prefer unassigned over wrong vehicle."
|
|
48700
|
+
*/
|
|
48701
|
+
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
48702
|
+
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
48703
|
+
maxDistance: 1,
|
|
48704
|
+
lowScoreFloor: .5,
|
|
48705
|
+
lowScoreMaxDistance: 0
|
|
48706
|
+
};
|
|
48707
|
+
function matchPlateText(probe, gallery, opts) {
|
|
48708
|
+
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
48709
|
+
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
48710
|
+
for (const s of gallery) {
|
|
48711
|
+
const dist = plateDistance(probe.text, s.text);
|
|
48712
|
+
const prev = bestByVehicle.get(s.vehicleId);
|
|
48713
|
+
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
48714
|
+
}
|
|
48715
|
+
if (bestByVehicle.size === 0) return null;
|
|
48716
|
+
let bestId = null;
|
|
48717
|
+
let bestDist = Infinity;
|
|
48718
|
+
let tie = false;
|
|
48719
|
+
for (const [vehicleId, dist] of bestByVehicle) {
|
|
48720
|
+
if (dist > allowed) continue;
|
|
48721
|
+
if (dist < bestDist) {
|
|
48722
|
+
bestDist = dist;
|
|
48723
|
+
bestId = vehicleId;
|
|
48724
|
+
tie = false;
|
|
48725
|
+
} else if (dist === bestDist) tie = true;
|
|
48726
|
+
}
|
|
48727
|
+
if (bestId === null || tie) return null;
|
|
48728
|
+
return {
|
|
48729
|
+
vehicleId: bestId,
|
|
48730
|
+
distance: bestDist
|
|
48731
|
+
};
|
|
48732
|
+
}
|
|
48733
|
+
//#endregion
|
|
47635
48734
|
//#region src/pipeline-analytics/pipeline/plate-vote.ts
|
|
47636
48735
|
/**
|
|
47637
48736
|
* Per-character VOTE over a track's plate reads.
|
|
@@ -47773,52 +48872,6 @@ function votePlateText(reads) {
|
|
|
47773
48872
|
return out;
|
|
47774
48873
|
}
|
|
47775
48874
|
//#endregion
|
|
47776
|
-
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
47777
|
-
/**
|
|
47778
|
-
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
47779
|
-
*
|
|
47780
|
-
* Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
|
|
47781
|
-
* distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
|
|
47782
|
-
* There is no model-version gate (text has no embedding drift) and no ambiguity
|
|
47783
|
-
* `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
|
|
47784
|
-
* conservatively to NO match. The read's `score` weights how far it may match:
|
|
47785
|
-
* a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
|
|
47786
|
-
* a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
|
|
47787
|
-
* "Prefer unassigned over wrong vehicle."
|
|
47788
|
-
*/
|
|
47789
|
-
/** Calibration placeholders — re-tune against real OCR score distributions. */
|
|
47790
|
-
var DEFAULT_PLATE_MATCH_OPTS = {
|
|
47791
|
-
maxDistance: 1,
|
|
47792
|
-
lowScoreFloor: .5,
|
|
47793
|
-
lowScoreMaxDistance: 0
|
|
47794
|
-
};
|
|
47795
|
-
function matchPlateText(probe, gallery, opts) {
|
|
47796
|
-
const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
|
|
47797
|
-
const bestByVehicle = /* @__PURE__ */ new Map();
|
|
47798
|
-
for (const s of gallery) {
|
|
47799
|
-
const dist = plateDistance(probe.text, s.text);
|
|
47800
|
-
const prev = bestByVehicle.get(s.vehicleId);
|
|
47801
|
-
if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
|
|
47802
|
-
}
|
|
47803
|
-
if (bestByVehicle.size === 0) return null;
|
|
47804
|
-
let bestId = null;
|
|
47805
|
-
let bestDist = Infinity;
|
|
47806
|
-
let tie = false;
|
|
47807
|
-
for (const [vehicleId, dist] of bestByVehicle) {
|
|
47808
|
-
if (dist > allowed) continue;
|
|
47809
|
-
if (dist < bestDist) {
|
|
47810
|
-
bestDist = dist;
|
|
47811
|
-
bestId = vehicleId;
|
|
47812
|
-
tie = false;
|
|
47813
|
-
} else if (dist === bestDist) tie = true;
|
|
47814
|
-
}
|
|
47815
|
-
if (bestId === null || tie) return null;
|
|
47816
|
-
return {
|
|
47817
|
-
vehicleId: bestId,
|
|
47818
|
-
distance: bestDist
|
|
47819
|
-
};
|
|
47820
|
-
}
|
|
47821
|
-
//#endregion
|
|
47822
48875
|
//#region src/pipeline-analytics/runtime/failure-report.ts
|
|
47823
48876
|
/**
|
|
47824
48877
|
* What `pipeline-analytics` reports through `failure-contribution` — the four
|
|
@@ -48150,9 +49203,16 @@ var PlateRecognizer = class {
|
|
|
48150
49203
|
bestLabelMatchConfidence(trackId) {
|
|
48151
49204
|
return this.bestPlate.get(trackId)?.score;
|
|
48152
49205
|
}
|
|
48153
|
-
/**
|
|
48154
|
-
*
|
|
48155
|
-
|
|
49206
|
+
/**
|
|
49207
|
+
* Persist the held best plate for a finished track as one PlateStore row
|
|
49208
|
+
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state.
|
|
49209
|
+
*
|
|
49210
|
+
* @param subjectPath the closing track's position trail, supplied by the
|
|
49211
|
+
* closer. It is what tells a parked car (whose plate rect is still honest
|
|
49212
|
+
* seconds later) from a crossing one (whose rect points at the road it has
|
|
49213
|
+
* already left) — the distinction the pure time bound could not make.
|
|
49214
|
+
*/
|
|
49215
|
+
async onTrackEnd(deviceId, trackId, subjectPath) {
|
|
48156
49216
|
const held = this.bestPlate.get(trackId);
|
|
48157
49217
|
this.bestPlate.delete(trackId);
|
|
48158
49218
|
if (held === void 0) return;
|
|
@@ -48161,12 +49221,14 @@ var PlateRecognizer = class {
|
|
|
48161
49221
|
let cropData = held.crop;
|
|
48162
49222
|
if (cropData === void 0 && keyFrameMediaKey !== void 0 && this.deps.deriveCropFromKeyFrame) try {
|
|
48163
49223
|
const derived = await this.deps.deriveCropFromKeyFrame({
|
|
49224
|
+
deviceId,
|
|
48164
49225
|
mediaKey: keyFrameMediaKey,
|
|
48165
49226
|
bbox: held.bbox,
|
|
48166
49227
|
frameWidth: held.frameWidth,
|
|
48167
49228
|
frameHeight: held.frameHeight,
|
|
48168
49229
|
padding: held.cropPadding,
|
|
48169
|
-
timestamp: held.timestamp
|
|
49230
|
+
timestamp: held.timestamp,
|
|
49231
|
+
...subjectPath !== void 0 ? { subjectPath } : {}
|
|
48170
49232
|
});
|
|
48171
49233
|
if (derived) {
|
|
48172
49234
|
cropData = derived.jpeg;
|
|
@@ -48177,10 +49239,18 @@ var PlateRecognizer = class {
|
|
|
48177
49239
|
},
|
|
48178
49240
|
meta: {
|
|
48179
49241
|
plateId,
|
|
48180
|
-
skewMs: derived.skewMs
|
|
49242
|
+
skewMs: derived.skewMs,
|
|
49243
|
+
motion: derived.motion,
|
|
49244
|
+
subjectIou: derived.subjectIou ?? null
|
|
48181
49245
|
}
|
|
48182
49246
|
});
|
|
48183
|
-
}
|
|
49247
|
+
} else this.deps.logger.info("plate crop not recovered — keyFrame derive refused or missed", {
|
|
49248
|
+
tags: {
|
|
49249
|
+
deviceId,
|
|
49250
|
+
trackId
|
|
49251
|
+
},
|
|
49252
|
+
meta: { plateId }
|
|
49253
|
+
});
|
|
48184
49254
|
} catch (err) {
|
|
48185
49255
|
this.deps.logger.debug("PlateRecognizer keyFrame crop derive failed", {
|
|
48186
49256
|
tags: { deviceId },
|
|
@@ -48781,6 +49851,122 @@ async function reconcileGallerySamples(deps, options) {
|
|
|
48781
49851
|
stoppedByCeiling
|
|
48782
49852
|
};
|
|
48783
49853
|
}
|
|
49854
|
+
//#endregion
|
|
49855
|
+
//#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
|
|
49856
|
+
/** The scope id this leg reports and persists its cursor under. */
|
|
49857
|
+
var EMBEDDING_ORPHAN_SCOPE = "vector-index:object-clip";
|
|
49858
|
+
/**
|
|
49859
|
+
* Walk the index once, resuming at `cursor`.
|
|
49860
|
+
*
|
|
49861
|
+
* Never throws: a probe failure ends the pass with what it has and says which
|
|
49862
|
+
* bound stopped it, so a short pass can never be read as a clean one.
|
|
49863
|
+
*/
|
|
49864
|
+
async function scanEmbeddingOrphans(deps, limits, cursor) {
|
|
49865
|
+
const tally = {
|
|
49866
|
+
examined: 0,
|
|
49867
|
+
orphans: 0,
|
|
49868
|
+
reclaimed: 0,
|
|
49869
|
+
kept: 0,
|
|
49870
|
+
withinGrace: 0,
|
|
49871
|
+
unresolvable: 0,
|
|
49872
|
+
notReclaimed: 0
|
|
49873
|
+
};
|
|
49874
|
+
const deviceIds = /* @__PURE__ */ new Set();
|
|
49875
|
+
const graceFloor = deps.now() - limits.graceMs;
|
|
49876
|
+
let offset = Math.max(0, cursor);
|
|
49877
|
+
let stoppedBy = "exhausted";
|
|
49878
|
+
for (;;) {
|
|
49879
|
+
if (tally.examined >= limits.maxRows) {
|
|
49880
|
+
stoppedBy = "row-ceiling";
|
|
49881
|
+
break;
|
|
49882
|
+
}
|
|
49883
|
+
let page;
|
|
49884
|
+
try {
|
|
49885
|
+
page = await deps.scanPage(offset, limits.pageSize);
|
|
49886
|
+
} catch (err) {
|
|
49887
|
+
deps.logger.warn("embedding orphan scan: index page failed — pass abandoned", { meta: {
|
|
49888
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49889
|
+
offset,
|
|
49890
|
+
error: String(err)
|
|
49891
|
+
} });
|
|
49892
|
+
stoppedBy = "page-stalled";
|
|
49893
|
+
break;
|
|
49894
|
+
}
|
|
49895
|
+
const named = page.rows.filter((r) => r.trackId !== null).map((r) => r.trackId ?? "");
|
|
49896
|
+
let live;
|
|
49897
|
+
try {
|
|
49898
|
+
live = named.length > 0 ? await deps.liveTrackIds(named) : /* @__PURE__ */ new Set();
|
|
49899
|
+
} catch (err) {
|
|
49900
|
+
deps.logger.warn("embedding orphan scan: liveness probe failed — pass abandoned", { meta: {
|
|
49901
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49902
|
+
offset,
|
|
49903
|
+
error: String(err)
|
|
49904
|
+
} });
|
|
49905
|
+
stoppedBy = "probe-failed";
|
|
49906
|
+
break;
|
|
49907
|
+
}
|
|
49908
|
+
const doomed = [];
|
|
49909
|
+
let ceilingHit = false;
|
|
49910
|
+
for (const row of page.rows) {
|
|
49911
|
+
tally.examined += 1;
|
|
49912
|
+
if (row.trackId === null) {
|
|
49913
|
+
tally.unresolvable += 1;
|
|
49914
|
+
continue;
|
|
49915
|
+
}
|
|
49916
|
+
if (live.has(row.trackId)) {
|
|
49917
|
+
tally.kept += 1;
|
|
49918
|
+
continue;
|
|
49919
|
+
}
|
|
49920
|
+
if (row.timestamp === null || row.timestamp > graceFloor) {
|
|
49921
|
+
tally.withinGrace += 1;
|
|
49922
|
+
continue;
|
|
49923
|
+
}
|
|
49924
|
+
tally.orphans += 1;
|
|
49925
|
+
if (row.deviceId !== null) deviceIds.add(row.deviceId);
|
|
49926
|
+
if (limits.mode !== "reclaim") continue;
|
|
49927
|
+
if (tally.reclaimed + doomed.length >= limits.maxReclaim) {
|
|
49928
|
+
ceilingHit = true;
|
|
49929
|
+
break;
|
|
49930
|
+
}
|
|
49931
|
+
doomed.push(row.id);
|
|
49932
|
+
}
|
|
49933
|
+
let reclaimedInPage = 0;
|
|
49934
|
+
if (doomed.length > 0) {
|
|
49935
|
+
const outcome = await deps.reclaim(doomed);
|
|
49936
|
+
reclaimedInPage = outcome.deleted.length;
|
|
49937
|
+
tally.reclaimed += outcome.deleted.length;
|
|
49938
|
+
tally.notReclaimed += outcome.stillPresent.length;
|
|
49939
|
+
}
|
|
49940
|
+
if (deps.breathe) await deps.breathe();
|
|
49941
|
+
if (ceilingHit) {
|
|
49942
|
+
stoppedBy = "reclaim-ceiling";
|
|
49943
|
+
offset += page.rows.length - reclaimedInPage;
|
|
49944
|
+
break;
|
|
49945
|
+
}
|
|
49946
|
+
offset += page.rows.length - reclaimedInPage;
|
|
49947
|
+
if (page.nextCursor === null) {
|
|
49948
|
+
offset = 0;
|
|
49949
|
+
break;
|
|
49950
|
+
}
|
|
49951
|
+
if (page.rows.length === 0) {
|
|
49952
|
+
stoppedBy = "page-stalled";
|
|
49953
|
+
break;
|
|
49954
|
+
}
|
|
49955
|
+
}
|
|
49956
|
+
return {
|
|
49957
|
+
scope: EMBEDDING_ORPHAN_SCOPE,
|
|
49958
|
+
examined: tally.examined,
|
|
49959
|
+
orphans: tally.orphans,
|
|
49960
|
+
reclaimed: tally.reclaimed,
|
|
49961
|
+
kept: tally.kept,
|
|
49962
|
+
withinGrace: tally.withinGrace,
|
|
49963
|
+
unresolvable: tally.unresolvable,
|
|
49964
|
+
notReclaimed: tally.notReclaimed,
|
|
49965
|
+
deviceIds: [...deviceIds],
|
|
49966
|
+
stoppedBy,
|
|
49967
|
+
nextCursor: Math.max(0, offset)
|
|
49968
|
+
};
|
|
49969
|
+
}
|
|
48784
49970
|
/**
|
|
48785
49971
|
* Where the completion marker lives.
|
|
48786
49972
|
*
|
|
@@ -50300,6 +51486,37 @@ var EventStore = class {
|
|
|
50300
51486
|
return found;
|
|
50301
51487
|
}
|
|
50302
51488
|
/**
|
|
51489
|
+
* The SUBSET of `eventIds` that are MOTION events specifically.
|
|
51490
|
+
*
|
|
51491
|
+
* {@link existingEventIds} answers "is this event alive", which is the
|
|
51492
|
+
* orphan audit's question. The media reclaim asks a narrower one: *is this
|
|
51493
|
+
* event-owned snapshot a MOTION-VISIT still* — because the selection shape
|
|
51494
|
+
* (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
|
|
51495
|
+
* Nothing structurally prevents another writer landing an event-owned
|
|
51496
|
+
* snapshot on an object or audio row, and the reclaim must not be the place
|
|
51497
|
+
* that discovers it. A per-page indexed `whereIn` on one table is cheap
|
|
51498
|
+
* where that mistake would not be.
|
|
51499
|
+
*
|
|
51500
|
+
* THROWS on a query failure, same contract and same reason.
|
|
51501
|
+
*/
|
|
51502
|
+
async existingMotionEventIds(eventIds) {
|
|
51503
|
+
const found = /* @__PURE__ */ new Set();
|
|
51504
|
+
for (let i = 0; i < eventIds.length; i += 500) {
|
|
51505
|
+
const chunk = eventIds.slice(i, i + 500);
|
|
51506
|
+
if (chunk.length === 0) continue;
|
|
51507
|
+
const rows = await this.store.query.query({
|
|
51508
|
+
collection: MOTION_EVENTS_COLLECTION,
|
|
51509
|
+
columns: ["id"],
|
|
51510
|
+
filter: {
|
|
51511
|
+
whereIn: { id: [...chunk] },
|
|
51512
|
+
limit: chunk.length
|
|
51513
|
+
}
|
|
51514
|
+
});
|
|
51515
|
+
for (const row of rows) found.add(row.id);
|
|
51516
|
+
}
|
|
51517
|
+
return found;
|
|
51518
|
+
}
|
|
51519
|
+
/**
|
|
50303
51520
|
* Delete all events for a specific device with `timestamp < cutoffMs`
|
|
50304
51521
|
* (exclusive upper bound) across all three kinds, returning the per-kind
|
|
50305
51522
|
* deleted counts and the flattened list of deleted ids (so the caller can
|
|
@@ -50572,11 +51789,21 @@ var SAMPLE_COLUMNS$1 = [
|
|
|
50572
51789
|
type: "TEXT",
|
|
50573
51790
|
notNull: true
|
|
50574
51791
|
},
|
|
51792
|
+
(
|
|
51793
|
+
/**
|
|
51794
|
+
* LEGACY, drained on 2026-08-30 (D304). The ArcFace vector lives in the
|
|
51795
|
+
* `identity-arcface` vector index now, keyed by this row's id.
|
|
51796
|
+
*
|
|
51797
|
+
* Still declared and still `notNull`: rows written before the conversion carry
|
|
51798
|
+
* their array, and `loadGallery` falls back to it for exactly as long as one
|
|
51799
|
+
* of them has not been converted. A CONVERTED row holds `[]`, which is both
|
|
51800
|
+
* the idempotence marker and the only way to see the conversion is done.
|
|
51801
|
+
*/
|
|
50575
51802
|
{
|
|
50576
51803
|
name: "embedding",
|
|
50577
51804
|
type: "JSON",
|
|
50578
51805
|
notNull: true
|
|
50579
|
-
},
|
|
51806
|
+
}),
|
|
50580
51807
|
{
|
|
50581
51808
|
name: "mediaKey",
|
|
50582
51809
|
type: "TEXT"
|
|
@@ -50623,9 +51850,11 @@ var SAMPLE_COLUMNS$1 = [
|
|
|
50623
51850
|
];
|
|
50624
51851
|
var IdentityStore = class {
|
|
50625
51852
|
store;
|
|
51853
|
+
vectors;
|
|
50626
51854
|
logger;
|
|
50627
51855
|
constructor(deps) {
|
|
50628
51856
|
this.store = deps.store;
|
|
51857
|
+
this.vectors = deps.vectors;
|
|
50629
51858
|
this.logger = deps.logger;
|
|
50630
51859
|
}
|
|
50631
51860
|
static async declare(store) {
|
|
@@ -50643,6 +51872,41 @@ var IdentityStore = class {
|
|
|
50643
51872
|
}]
|
|
50644
51873
|
});
|
|
50645
51874
|
}
|
|
51875
|
+
/**
|
|
51876
|
+
* How an UNCONVERTED `identity-samples` row becomes an index row.
|
|
51877
|
+
*
|
|
51878
|
+
* `modelId` is `notNull` on this collection, so unlike the faces buffer there
|
|
51879
|
+
* is nothing to infer — a row that somehow holds no model is REFUSED and
|
|
51880
|
+
* keeps its JSON. An enrolled vector stamped with a guessed model is a sample
|
|
51881
|
+
* the matcher would compare across feature spaces, which is the one mistake
|
|
51882
|
+
* this gallery cannot survive.
|
|
51883
|
+
*/
|
|
51884
|
+
conversionSpec() {
|
|
51885
|
+
return {
|
|
51886
|
+
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
51887
|
+
vectors: this.vectors,
|
|
51888
|
+
write: (id, embedding, data) => {
|
|
51889
|
+
const modelId = data["modelId"];
|
|
51890
|
+
if (typeof modelId !== "string" || modelId.length === 0) return null;
|
|
51891
|
+
const identityId = data["identityId"];
|
|
51892
|
+
const deviceId = data["deviceId"];
|
|
51893
|
+
const addedAt = data["addedAt"];
|
|
51894
|
+
const source = data["source"];
|
|
51895
|
+
return {
|
|
51896
|
+
id,
|
|
51897
|
+
embedding,
|
|
51898
|
+
className: IDENTITY_VECTOR_CLASS,
|
|
51899
|
+
modelId,
|
|
51900
|
+
timestamp: typeof addedAt === "number" ? addedAt : 0,
|
|
51901
|
+
...typeof deviceId === "number" ? { deviceId } : {},
|
|
51902
|
+
extra: {
|
|
51903
|
+
...typeof identityId === "string" ? { identityId } : {},
|
|
51904
|
+
...typeof source === "string" ? { source } : {}
|
|
51905
|
+
}
|
|
51906
|
+
};
|
|
51907
|
+
}
|
|
51908
|
+
};
|
|
51909
|
+
}
|
|
50646
51910
|
async createIdentity(input) {
|
|
50647
51911
|
const now = Date.now();
|
|
50648
51912
|
const identity = {
|
|
@@ -50677,11 +51941,13 @@ var IdentityStore = class {
|
|
|
50677
51941
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50678
51942
|
filter: { where: { identityId: id } }
|
|
50679
51943
|
});
|
|
51944
|
+
const droppedSamples = [];
|
|
50680
51945
|
for (const s of samples) try {
|
|
50681
51946
|
await this.store.delete.mutate({
|
|
50682
51947
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50683
51948
|
key: s.id
|
|
50684
51949
|
});
|
|
51950
|
+
droppedSamples.push(s.id);
|
|
50685
51951
|
} catch (err) {
|
|
50686
51952
|
this.logger.warn("deleteIdentity sample delete failed", { meta: {
|
|
50687
51953
|
identityId: id,
|
|
@@ -50689,6 +51955,7 @@ var IdentityStore = class {
|
|
|
50689
51955
|
error: String(err)
|
|
50690
51956
|
} });
|
|
50691
51957
|
}
|
|
51958
|
+
await this.vectors.drop(droppedSamples);
|
|
50692
51959
|
await this.store.delete.mutate({
|
|
50693
51960
|
collection: IDENTITIES_COLLECTION,
|
|
50694
51961
|
key: id
|
|
@@ -50714,7 +51981,6 @@ var IdentityStore = class {
|
|
|
50714
51981
|
const sample = {
|
|
50715
51982
|
id: (0, node_crypto.randomUUID)(),
|
|
50716
51983
|
identityId: input.identityId,
|
|
50717
|
-
embedding: input.embedding,
|
|
50718
51984
|
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
|
|
50719
51985
|
...input.sourceMediaKey !== void 0 ? { sourceMediaKey: input.sourceMediaKey } : {},
|
|
50720
51986
|
source: input.source ?? "detected",
|
|
@@ -50723,12 +51989,27 @@ var IdentityStore = class {
|
|
|
50723
51989
|
dim: input.embedding.length,
|
|
50724
51990
|
addedAt: Date.now()
|
|
50725
51991
|
};
|
|
51992
|
+
if (!await this.vectors.put({
|
|
51993
|
+
id: sample.id,
|
|
51994
|
+
embedding: input.embedding,
|
|
51995
|
+
className: "identity",
|
|
51996
|
+
modelId: input.modelId,
|
|
51997
|
+
timestamp: sample.addedAt,
|
|
51998
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
|
|
51999
|
+
extra: {
|
|
52000
|
+
identityId: input.identityId,
|
|
52001
|
+
source: sample.source
|
|
52002
|
+
}
|
|
52003
|
+
})) throw new Error(`identity sample was not enrolled: its ${input.embedding.length}-dim vector could not be written to the gallery index`);
|
|
50726
52004
|
const { id, ...rest } = sample;
|
|
50727
52005
|
await this.store.insert.mutate({
|
|
50728
52006
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50729
52007
|
record: {
|
|
50730
52008
|
id,
|
|
50731
|
-
data:
|
|
52009
|
+
data: {
|
|
52010
|
+
...rest,
|
|
52011
|
+
embedding: []
|
|
52012
|
+
}
|
|
50732
52013
|
}
|
|
50733
52014
|
});
|
|
50734
52015
|
const identity = (await this.listIdentities()).find((i) => i.id === input.identityId);
|
|
@@ -50777,6 +52058,7 @@ var IdentityStore = class {
|
|
|
50777
52058
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50778
52059
|
key: sampleId
|
|
50779
52060
|
});
|
|
52061
|
+
await this.vectors.drop([sampleId]);
|
|
50780
52062
|
const identity = (await this.listIdentities()).find((i) => i.id === identityId);
|
|
50781
52063
|
if (identity) {
|
|
50782
52064
|
const patch = {
|
|
@@ -50804,16 +52086,46 @@ var IdentityStore = class {
|
|
|
50804
52086
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50805
52087
|
filter: {}
|
|
50806
52088
|
});
|
|
50807
|
-
const
|
|
52089
|
+
const wanted = [];
|
|
50808
52090
|
for (const r of rows) {
|
|
50809
52091
|
const d = r.data;
|
|
50810
|
-
if (
|
|
50811
|
-
gallery.push({
|
|
50812
|
-
identityId: d.identityId,
|
|
50813
|
-
embedding: d.embedding,
|
|
50814
|
-
modelId: d.modelId
|
|
50815
|
-
});
|
|
52092
|
+
if (typeof d.modelId === "string" && typeof d.identityId === "string") wanted.push(r.id);
|
|
50816
52093
|
}
|
|
52094
|
+
const vectors = await this.vectors.load(wanted);
|
|
52095
|
+
const gallery = [];
|
|
52096
|
+
let fromLegacyColumn = 0;
|
|
52097
|
+
let missing = 0;
|
|
52098
|
+
for (const r of rows) {
|
|
52099
|
+
const d = r.data;
|
|
52100
|
+
if (typeof d.modelId !== "string" || typeof d.identityId !== "string") continue;
|
|
52101
|
+
const indexed = vectors.get(r.id);
|
|
52102
|
+
if (indexed !== void 0) {
|
|
52103
|
+
gallery.push({
|
|
52104
|
+
identityId: d.identityId,
|
|
52105
|
+
embedding: indexed,
|
|
52106
|
+
modelId: d.modelId
|
|
52107
|
+
});
|
|
52108
|
+
continue;
|
|
52109
|
+
}
|
|
52110
|
+
const legacy = d.embedding;
|
|
52111
|
+
if (Array.isArray(legacy) && legacy.length > 0) {
|
|
52112
|
+
fromLegacyColumn += 1;
|
|
52113
|
+
gallery.push({
|
|
52114
|
+
identityId: d.identityId,
|
|
52115
|
+
embedding: legacy,
|
|
52116
|
+
modelId: d.modelId
|
|
52117
|
+
});
|
|
52118
|
+
continue;
|
|
52119
|
+
}
|
|
52120
|
+
missing += 1;
|
|
52121
|
+
}
|
|
52122
|
+
if (fromLegacyColumn > 0 || missing > 0) this.logger.warn("identity gallery did not come entirely from the vector index", { meta: {
|
|
52123
|
+
index: this.vectors.name,
|
|
52124
|
+
enrolled: rows.length,
|
|
52125
|
+
loaded: gallery.length,
|
|
52126
|
+
fromLegacyColumn,
|
|
52127
|
+
missing
|
|
52128
|
+
} });
|
|
50817
52129
|
return gallery;
|
|
50818
52130
|
}
|
|
50819
52131
|
/**
|
|
@@ -50849,12 +52161,23 @@ var IdentityStore = class {
|
|
|
50849
52161
|
* holding 0).
|
|
50850
52162
|
*/
|
|
50851
52163
|
async restampSample(input) {
|
|
52164
|
+
if (!await this.vectors.put({
|
|
52165
|
+
id: input.sampleId,
|
|
52166
|
+
embedding: input.embedding,
|
|
52167
|
+
className: "identity",
|
|
52168
|
+
modelId: input.modelId,
|
|
52169
|
+
timestamp: Date.now(),
|
|
52170
|
+
extra: { identityId: input.identityId }
|
|
52171
|
+
})) return {
|
|
52172
|
+
landed: false,
|
|
52173
|
+
reason: "the re-embedded vector did not reach the gallery index"
|
|
52174
|
+
};
|
|
50852
52175
|
try {
|
|
50853
52176
|
await this.store.update.mutate({
|
|
50854
52177
|
collection: IDENTITY_SAMPLES_COLLECTION,
|
|
50855
52178
|
id: input.sampleId,
|
|
50856
52179
|
data: {
|
|
50857
|
-
embedding:
|
|
52180
|
+
embedding: [],
|
|
50858
52181
|
modelId: input.modelId,
|
|
50859
52182
|
dim: input.dim
|
|
50860
52183
|
}
|
|
@@ -53142,6 +54465,53 @@ var TrackStore = class {
|
|
|
53142
54465
|
return found;
|
|
53143
54466
|
}
|
|
53144
54467
|
/**
|
|
54468
|
+
* The SUBSET of `trackIds` that are PINNED — `retrainStatus: 'staging'` or
|
|
54469
|
+
* `favourited: true`.
|
|
54470
|
+
*
|
|
54471
|
+
* A pin is the operator saying "keep this one". It is enforced against the
|
|
54472
|
+
* age sweep (`listIdsBefore` excludes both), and it must be enforced against
|
|
54473
|
+
* a bulk reclaim for exactly the same reason: a starred track whose
|
|
54474
|
+
* filmstrip is gone is a pin that did not hold.
|
|
54475
|
+
*
|
|
54476
|
+
* **The ACTIVE (in-RAM) tracks count as pinned here.** A running track has
|
|
54477
|
+
* no persisted row yet, so the table cannot answer for it; treating an
|
|
54478
|
+
* unanswerable id as unpinned would delete the media of the camera's current
|
|
54479
|
+
* subject. Unknown means protected — the inverse of {@link existingTrackIds},
|
|
54480
|
+
* where unknown means alive, and the same safe direction.
|
|
54481
|
+
*
|
|
54482
|
+
* THROWS on a query failure. A probe that quietly under-reports here reads as
|
|
54483
|
+
* "nothing is pinned".
|
|
54484
|
+
*/
|
|
54485
|
+
async pinnedTrackIds(trackIds) {
|
|
54486
|
+
const PAGE = 500;
|
|
54487
|
+
const found = /* @__PURE__ */ new Set();
|
|
54488
|
+
for (const id of trackIds) if (this.active.has(id)) found.add(id);
|
|
54489
|
+
for (let i = 0; i < trackIds.length; i += PAGE) {
|
|
54490
|
+
const chunk = trackIds.slice(i, i + PAGE);
|
|
54491
|
+
if (chunk.length === 0) continue;
|
|
54492
|
+
const [staging, favourited] = await Promise.all([this.store.query.query({
|
|
54493
|
+
collection: TRACKS_COLLECTION,
|
|
54494
|
+
columns: ["id"],
|
|
54495
|
+
filter: {
|
|
54496
|
+
whereIn: { id: [...chunk] },
|
|
54497
|
+
where: { retrainStatus: "staging" },
|
|
54498
|
+
limit: chunk.length
|
|
54499
|
+
}
|
|
54500
|
+
}), this.store.query.query({
|
|
54501
|
+
collection: TRACKS_COLLECTION,
|
|
54502
|
+
columns: ["id"],
|
|
54503
|
+
filter: {
|
|
54504
|
+
whereIn: { id: [...chunk] },
|
|
54505
|
+
where: { favourited: true },
|
|
54506
|
+
limit: chunk.length
|
|
54507
|
+
}
|
|
54508
|
+
})]);
|
|
54509
|
+
for (const row of staging) found.add(row.id);
|
|
54510
|
+
for (const row of favourited) found.add(row.id);
|
|
54511
|
+
}
|
|
54512
|
+
return found;
|
|
54513
|
+
}
|
|
54514
|
+
/**
|
|
53145
54515
|
* The distinct set of deviceIds that currently have persisted tracks. Feeds
|
|
53146
54516
|
* the per-device retention sweep (design §6) so a device that stopped
|
|
53147
54517
|
* producing frames still has its aged track debris pruned. Paged scan +
|
|
@@ -54544,13 +55914,45 @@ async function runOrphanAudit(input) {
|
|
|
54544
55914
|
* sees the audit in a flame graph needs to be able to make it smaller without a
|
|
54545
55915
|
* release.
|
|
54546
55916
|
*
|
|
54547
|
-
* `mode` defaults to `'
|
|
54548
|
-
* `
|
|
55917
|
+
* `mode` defaults to `'reclaim'` as of 2026-08-30 (D304). It defaulted to
|
|
55918
|
+
* `'report'` from the day the engine shipped, and that default was right for
|
|
55919
|
+
* exactly as long as its premise held — see {@link OrphanAuditModeSchema}.
|
|
54549
55920
|
*/
|
|
54550
55921
|
var ORPHAN_AUDIT_MODE_SETTING = "orphanAuditMode";
|
|
54551
55922
|
var ORPHAN_AUDIT_INTERVAL_SETTING = "orphanAuditIntervalHours";
|
|
54552
55923
|
var ORPHAN_AUDIT_PAGE_SIZE_SETTING = "orphanAuditPageSize";
|
|
54553
|
-
|
|
55924
|
+
/**
|
|
55925
|
+
* `'reclaim'` is the default. Here is the argument, because reversing a
|
|
55926
|
+
* destructive default needs one on the record.
|
|
55927
|
+
*
|
|
55928
|
+
* The original default was `'report'`, and `orphan-audit.ts` states its reason
|
|
55929
|
+
* precisely: *"this engine's correctness rests entirely on an ownership model
|
|
55930
|
+
* that is being rewritten in the same change"*. Report mode existed to make
|
|
55931
|
+
* that model observable BEFORE it was load-bearing.
|
|
55932
|
+
*
|
|
55933
|
+
* That premise is spent. The model has run in production for weeks and has been
|
|
55934
|
+
* read: the `faces` scope reported 99 orphans among its oldest 300 rows and all
|
|
55935
|
+
* 99 were `assigned`, i.e. the exemption doing exactly what it claims; the
|
|
55936
|
+
* vector index reported 956 orphans per pass, of which the D58 re-derivability
|
|
55937
|
+
* check (a vector whose track is gone can neither be re-embedded nor returned)
|
|
55938
|
+
* accounts for all of them. What report mode had left to prove, it proved.
|
|
55939
|
+
*
|
|
55940
|
+
* What it did instead was nothing, forever. `reclaimed: 0` was logged every six
|
|
55941
|
+
* hours against a standing backlog, and a backstop that never acts is not a
|
|
55942
|
+
* backstop — it is a leftover that describes the right design, which this repo
|
|
55943
|
+
* has decided reads as verification and is worse than an absence.
|
|
55944
|
+
*
|
|
55945
|
+
* The blast radius is unchanged and bounded five ways (exempt owner kinds, the
|
|
55946
|
+
* grace window, probe-failure abandons the scope, an unresolvable row is kept,
|
|
55947
|
+
* and a per-scope reclaim ceiling that shows up in the report as `stoppedBy`).
|
|
55948
|
+
* An operator who wants observation-only sets `orphanAuditMode: 'report'` and
|
|
55949
|
+
* gets exactly the old behaviour; the knob did not move, only its default.
|
|
55950
|
+
*
|
|
55951
|
+
* NOTE what this does NOT change: an OPERATOR-triggered pass
|
|
55952
|
+
* (`retention.orphanAudit`) is still dry-run unless it names `mode` — it never
|
|
55953
|
+
* inherits this setting. Reclaiming on demand is still a word you have to type.
|
|
55954
|
+
*/
|
|
55955
|
+
var OrphanAuditModeSchema = require_dist._enum(["report", "reclaim"]).default("reclaim");
|
|
54554
55956
|
/** Hours between passes. Hours, not minutes — this walks whole collections. */
|
|
54555
55957
|
var OrphanAuditIntervalHoursSchema = require_dist.number().min(1).max(168).default(6);
|
|
54556
55958
|
var OrphanAuditPageSizeSchema = require_dist.number().int().min(50).max(5e3).default(500);
|
|
@@ -54836,8 +56238,12 @@ var BindingCache = class {
|
|
|
54836
56238
|
*
|
|
54837
56239
|
* `confirm` (S4) is the confirmation-gate re-detect crop: it GATES track birth
|
|
54838
56240
|
* (the frame path awaits it), so it ranks WITH `firstFrame` at 0 — it must
|
|
54839
|
-
* jump every queued lower-priority capture
|
|
54840
|
-
*
|
|
56241
|
+
* jump every queued lower-priority capture. It DOES set a `staleAfterMs`, and
|
|
56242
|
+
* it must: priority 0 means it is inserted at the head, so an entry here that
|
|
56243
|
+
* cannot expire makes the whole lane unrecoverable — every later arrival is
|
|
56244
|
+
* inserted at the tail and shed on admission. Its deadline is the gate's own
|
|
56245
|
+
* `timeoutMs`, past which its caller has already discarded the answer; see the
|
|
56246
|
+
* `fetchCrop` note in `pipeline-analytics/index.ts`. `face`/`plate` (S4) are
|
|
54841
56247
|
* enrichment gallery crops — same rank as `embed`: nice-to-have media that
|
|
54842
56248
|
* must never displace the track-media ladder above.
|
|
54843
56249
|
*
|
|
@@ -54882,7 +56288,8 @@ var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
|
|
|
54882
56288
|
var DEFAULT_LOG_WINDOW_MS = 6e4;
|
|
54883
56289
|
/** One `capture queue full` line per device per this interval. The condition
|
|
54884
56290
|
* holds for as long as the lane is saturated; one line per shed request would
|
|
54885
|
-
* be thousands.
|
|
56291
|
+
* be thousands. What the line REPORTS is the whole interval's shed count, not
|
|
56292
|
+
* the one call that happened to break the throttle — see {@link OverflowWindow}. */
|
|
54886
56293
|
var OVERFLOW_WARN_THROTTLE_MS = 6e4;
|
|
54887
56294
|
var CaptureScheduler = class {
|
|
54888
56295
|
logger;
|
|
@@ -54896,8 +56303,8 @@ var CaptureScheduler = class {
|
|
|
54896
56303
|
/** holdKey → refcount of queued/running requests holding it. */
|
|
54897
56304
|
heldKeys = /* @__PURE__ */ new Map();
|
|
54898
56305
|
windows = /* @__PURE__ */ new Map();
|
|
54899
|
-
/** Per-device throttle for the `capture queue full` warn. */
|
|
54900
|
-
|
|
56306
|
+
/** Per-device throttle + shed accumulator for the `capture queue full` warn. */
|
|
56307
|
+
overflowWindows = /* @__PURE__ */ new Map();
|
|
54901
56308
|
onDropped;
|
|
54902
56309
|
onStarted;
|
|
54903
56310
|
constructor(options) {
|
|
@@ -54982,6 +56389,7 @@ var CaptureScheduler = class {
|
|
|
54982
56389
|
this.coalesced.clear();
|
|
54983
56390
|
this.heldKeys.clear();
|
|
54984
56391
|
this.windows.clear();
|
|
56392
|
+
this.overflowWindows.clear();
|
|
54985
56393
|
}
|
|
54986
56394
|
/** Snapshot of the CURRENT window's counters (tests/ops introspection). */
|
|
54987
56395
|
counters(deviceId) {
|
|
@@ -55095,8 +56503,18 @@ var CaptureScheduler = class {
|
|
|
55095
56503
|
if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
|
|
55096
56504
|
});
|
|
55097
56505
|
}
|
|
55098
|
-
/**
|
|
55099
|
-
*
|
|
56506
|
+
/**
|
|
56507
|
+
* Drop every queued entry already past its `staleAfterMs`.
|
|
56508
|
+
*
|
|
56509
|
+
* An entry without one is never expired here — and that is a real hazard, not
|
|
56510
|
+
* a convenience: combined with {@link insertByPriority} + {@link shedToCap},
|
|
56511
|
+
* an unexpirable entry near the HEAD makes a saturated lane unrecoverable,
|
|
56512
|
+
* because every later arrival lands at the tail and is shed on admission.
|
|
56513
|
+
* `confirm` was the case that bit (see the `fetchCrop` note in
|
|
56514
|
+
* `pipeline-analytics/index.ts`); `keyFrame` is the one kind that still omits
|
|
56515
|
+
* a deadline, and it may, because nothing abandons it — a late keyFrame can
|
|
56516
|
+
* still be served from the worker's scene-tile store and lands for real.
|
|
56517
|
+
*/
|
|
55100
56518
|
dropExpired(deviceId, lane) {
|
|
55101
56519
|
const nowMs = this.now();
|
|
55102
56520
|
for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
|
|
@@ -55109,9 +56527,14 @@ var CaptureScheduler = class {
|
|
|
55109
56527
|
entry.drop();
|
|
55110
56528
|
}
|
|
55111
56529
|
}
|
|
55112
|
-
/**
|
|
55113
|
-
*
|
|
55114
|
-
*
|
|
56530
|
+
/**
|
|
56531
|
+
* Shed from the tail (lowest priority, newest within it) until the lane is at
|
|
56532
|
+
* its cap. Loud: a capture that is dropped rather than merely late is a
|
|
56533
|
+
* missing thumbnail or first frame, and silence would read as "never asked".
|
|
56534
|
+
*
|
|
56535
|
+
* The warn is throttled but its `shed` count is NOT the throttled call's — it
|
|
56536
|
+
* is every entry shed since the last line. See {@link OverflowWindow}.
|
|
56537
|
+
*/
|
|
55115
56538
|
shedToCap(deviceId, lane) {
|
|
55116
56539
|
let shed = 0;
|
|
55117
56540
|
while (lane.queue.length > this.maxQueued) {
|
|
@@ -55124,19 +56547,41 @@ var CaptureScheduler = class {
|
|
|
55124
56547
|
}
|
|
55125
56548
|
if (shed === 0) return;
|
|
55126
56549
|
const nowMs = this.now();
|
|
55127
|
-
|
|
55128
|
-
|
|
56550
|
+
const window = this.overflowWindow(deviceId, nowMs);
|
|
56551
|
+
window.shed += shed;
|
|
56552
|
+
if (nowMs - window.lastWarnAt < OVERFLOW_WARN_THROTTLE_MS) return;
|
|
56553
|
+
const reported = window.shed;
|
|
56554
|
+
const sinceMs = window.sinceMs;
|
|
56555
|
+
window.shed = 0;
|
|
56556
|
+
window.sinceMs = nowMs;
|
|
56557
|
+
window.lastWarnAt = nowMs;
|
|
55129
56558
|
this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
|
|
55130
56559
|
tags: { deviceId },
|
|
55131
56560
|
meta: {
|
|
55132
|
-
shed,
|
|
56561
|
+
shed: reported,
|
|
56562
|
+
windowSec: Math.round((nowMs - sinceMs) / 1e3),
|
|
55133
56563
|
cap: this.maxQueued,
|
|
55134
56564
|
queueDepth: lane.queue.length,
|
|
56565
|
+
unexpirable: lane.queue.reduce((total, entry) => total + (entry.staleAfterMs === void 0 ? 1 : 0), 0),
|
|
55135
56566
|
inFlight: lane.inFlight,
|
|
55136
56567
|
budget: this.budget
|
|
55137
56568
|
}
|
|
55138
56569
|
});
|
|
55139
56570
|
}
|
|
56571
|
+
/** The device's shed accumulator, created on first shed. A device that has
|
|
56572
|
+
* never overflowed carries no entry — and its first shed reports
|
|
56573
|
+
* immediately, because the condition is new. */
|
|
56574
|
+
overflowWindow(deviceId, nowMs) {
|
|
56575
|
+
const existing = this.overflowWindows.get(deviceId);
|
|
56576
|
+
if (existing !== void 0) return existing;
|
|
56577
|
+
const created = {
|
|
56578
|
+
sinceMs: nowMs,
|
|
56579
|
+
shed: 0,
|
|
56580
|
+
lastWarnAt: Number.NEGATIVE_INFINITY
|
|
56581
|
+
};
|
|
56582
|
+
this.overflowWindows.set(deviceId, created);
|
|
56583
|
+
return created;
|
|
56584
|
+
}
|
|
55140
56585
|
insertByPriority(lane, entry) {
|
|
55141
56586
|
let index = lane.queue.length;
|
|
55142
56587
|
while (index > 0) {
|
|
@@ -57426,6 +58871,36 @@ var EventMediaDispatcher = class {
|
|
|
57426
58871
|
}
|
|
57427
58872
|
}
|
|
57428
58873
|
};
|
|
58874
|
+
//#endregion
|
|
58875
|
+
//#region src/pipeline-analytics/services/motion-marker-policy.ts
|
|
58876
|
+
/**
|
|
58877
|
+
* Pure. No clock of its own, no store read, no await — it is called on the
|
|
58878
|
+
* motion event path at up to the provider's keepalive rate.
|
|
58879
|
+
*
|
|
58880
|
+
* A non-positive `quietMs` would make every edge a marker (`now - x >= 0` is
|
|
58881
|
+
* true for any mirror), which is the flood this module removes; it is clamped
|
|
58882
|
+
* by the schema (`motionMarkerQuietMs.min`), and the ordering below puts the
|
|
58883
|
+
* detection check first so the reason names the most useful fact.
|
|
58884
|
+
*/
|
|
58885
|
+
function decideMotionStill(input) {
|
|
58886
|
+
if (input.debugMediaEnabled) return {
|
|
58887
|
+
capture: true,
|
|
58888
|
+
reason: "debug"
|
|
58889
|
+
};
|
|
58890
|
+
const { now, quietMs } = input;
|
|
58891
|
+
if (input.lastDetectionAtMs !== void 0 && now - input.lastDetectionAtMs < quietMs) return {
|
|
58892
|
+
capture: false,
|
|
58893
|
+
reason: "detection-recent"
|
|
58894
|
+
};
|
|
58895
|
+
if (input.lastMarkerAtMs !== void 0 && now - input.lastMarkerAtMs < quietMs) return {
|
|
58896
|
+
capture: false,
|
|
58897
|
+
reason: "marker-recent"
|
|
58898
|
+
};
|
|
58899
|
+
return {
|
|
58900
|
+
capture: true,
|
|
58901
|
+
reason: "quiet-marker"
|
|
58902
|
+
};
|
|
58903
|
+
}
|
|
57429
58904
|
var SnapshotTimeoutError$1 = class extends Error {
|
|
57430
58905
|
constructor(ms) {
|
|
57431
58906
|
super(`snapshot cap timed out after ${ms}ms`);
|
|
@@ -57447,24 +58922,65 @@ function withTimeout$1(promise, ms) {
|
|
|
57447
58922
|
var MotionEventSnapshotCapturer = class {
|
|
57448
58923
|
deps;
|
|
57449
58924
|
snapshotTimeoutMs;
|
|
58925
|
+
/** Last ms a marker was written, per device. */
|
|
58926
|
+
lastMarkerAt = /* @__PURE__ */ new Map();
|
|
58927
|
+
/** Edges dropped since that device's last marker — reported on its line. */
|
|
58928
|
+
suppressed = /* @__PURE__ */ new Map();
|
|
57450
58929
|
constructor(deps) {
|
|
57451
58930
|
this.deps = deps;
|
|
57452
58931
|
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
57453
58932
|
}
|
|
57454
58933
|
/**
|
|
57455
|
-
*
|
|
57456
|
-
* throws — the durable record is the
|
|
57457
|
-
* the caller.
|
|
58934
|
+
* Judge the edge, and only if it survives, fetch a still and persist it as
|
|
58935
|
+
* the motion event's snapshot. Never throws — the durable record is the
|
|
58936
|
+
* motion row, already inserted by the caller.
|
|
58937
|
+
*
|
|
58938
|
+
* The two clocks the decision reads live HERE, per device: `lastMarkerAt`
|
|
58939
|
+
* (so a drought yields one marker per window, not one per provider
|
|
58940
|
+
* keepalive) and `suppressed` (so the drop is reported ON the next marker's
|
|
58941
|
+
* line rather than 92 400 times a day).
|
|
57458
58942
|
*/
|
|
57459
58943
|
async capture(input) {
|
|
58944
|
+
const decision = decideMotionStill({
|
|
58945
|
+
debugMediaEnabled: input.policy.debugMediaEnabled,
|
|
58946
|
+
now: input.timestamp,
|
|
58947
|
+
lastDetectionAtMs: input.policy.lastDetectionAtMs,
|
|
58948
|
+
lastMarkerAtMs: this.lastMarkerAt.get(input.deviceId),
|
|
58949
|
+
quietMs: input.policy.quietMs
|
|
58950
|
+
});
|
|
58951
|
+
if (!decision.capture) {
|
|
58952
|
+
this.suppressed.set(input.deviceId, (this.suppressed.get(input.deviceId) ?? 0) + 1);
|
|
58953
|
+
return {
|
|
58954
|
+
captured: false,
|
|
58955
|
+
reason: decision.reason
|
|
58956
|
+
};
|
|
58957
|
+
}
|
|
58958
|
+
this.lastMarkerAt.set(input.deviceId, input.timestamp);
|
|
58959
|
+
const suppressed = this.suppressed.get(input.deviceId) ?? 0;
|
|
58960
|
+
this.suppressed.delete(input.deviceId);
|
|
58961
|
+
if (decision.reason === "quiet-marker") this.deps.logger.info("motion marker still: detection drought", {
|
|
58962
|
+
tags: { deviceId: input.deviceId },
|
|
58963
|
+
meta: {
|
|
58964
|
+
eventId: input.eventId,
|
|
58965
|
+
quietMs: input.policy.quietMs,
|
|
58966
|
+
suppressedSinceLastMarker: suppressed,
|
|
58967
|
+
lastDetectionAtMs: input.policy.lastDetectionAtMs ?? null
|
|
58968
|
+
}
|
|
58969
|
+
});
|
|
57460
58970
|
let snap;
|
|
57461
58971
|
try {
|
|
57462
58972
|
snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
57463
58973
|
} catch (err) {
|
|
57464
58974
|
this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
57465
|
-
return
|
|
58975
|
+
return {
|
|
58976
|
+
captured: false,
|
|
58977
|
+
reason: decision.reason
|
|
58978
|
+
};
|
|
57466
58979
|
}
|
|
57467
|
-
if (snap === null) return
|
|
58980
|
+
if (snap === null) return {
|
|
58981
|
+
captured: false,
|
|
58982
|
+
reason: decision.reason
|
|
58983
|
+
};
|
|
57468
58984
|
const raw = Buffer.from(snap.base64, "base64");
|
|
57469
58985
|
let data = raw;
|
|
57470
58986
|
try {
|
|
@@ -57483,7 +58999,21 @@ var MotionEventSnapshotCapturer = class {
|
|
|
57483
58999
|
});
|
|
57484
59000
|
} catch (err) {
|
|
57485
59001
|
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
59002
|
+
return {
|
|
59003
|
+
captured: false,
|
|
59004
|
+
reason: decision.reason
|
|
59005
|
+
};
|
|
57486
59006
|
}
|
|
59007
|
+
return {
|
|
59008
|
+
captured: true,
|
|
59009
|
+
reason: decision.reason
|
|
59010
|
+
};
|
|
59011
|
+
}
|
|
59012
|
+
/** Drop a device's clocks when it unbinds — same lifetime as every other
|
|
59013
|
+
* per-device mirror on the addon. */
|
|
59014
|
+
forgetDevice(deviceId) {
|
|
59015
|
+
this.lastMarkerAt.delete(deviceId);
|
|
59016
|
+
this.suppressed.delete(deviceId);
|
|
57487
59017
|
}
|
|
57488
59018
|
};
|
|
57489
59019
|
/**
|
|
@@ -57550,6 +59080,78 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
57550
59080
|
}
|
|
57551
59081
|
return landed;
|
|
57552
59082
|
}
|
|
59083
|
+
var SensorTransitionGate = class {
|
|
59084
|
+
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
59085
|
+
lastValues = /* @__PURE__ */ new Map();
|
|
59086
|
+
/** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
|
|
59087
|
+
drops = /* @__PURE__ */ new Map();
|
|
59088
|
+
windowStartedAt;
|
|
59089
|
+
startedAtMs;
|
|
59090
|
+
now;
|
|
59091
|
+
freshnessMs;
|
|
59092
|
+
summaryWindowMs;
|
|
59093
|
+
constructor(options) {
|
|
59094
|
+
this.startedAtMs = options.startedAtMs;
|
|
59095
|
+
this.now = options.now;
|
|
59096
|
+
this.freshnessMs = options.firstSightingFreshnessMs ?? 3e4;
|
|
59097
|
+
this.summaryWindowMs = options.summaryWindowMs ?? 6e5;
|
|
59098
|
+
this.windowStartedAt = options.now();
|
|
59099
|
+
}
|
|
59100
|
+
/**
|
|
59101
|
+
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
59102
|
+
*
|
|
59103
|
+
* A cap with no boolean level passes through unconditionally. A cap that HAS
|
|
59104
|
+
* one passes only when its boolean moved — in either direction.
|
|
59105
|
+
*/
|
|
59106
|
+
observe(deviceId, capName, slice) {
|
|
59107
|
+
if (!require_dist.isSourceCap(capName)) return { changed: true };
|
|
59108
|
+
const key = `${deviceId}:${capName}`;
|
|
59109
|
+
const verdict = require_dist.evaluateSensorEdge({
|
|
59110
|
+
capName,
|
|
59111
|
+
slice,
|
|
59112
|
+
prior: this.lastValues.get(key),
|
|
59113
|
+
startedAtMs: this.startedAtMs,
|
|
59114
|
+
nowMs: this.now(),
|
|
59115
|
+
firstSightingFreshnessMs: this.freshnessMs
|
|
59116
|
+
});
|
|
59117
|
+
if (verdict.edge === "rising") {
|
|
59118
|
+
this.lastValues.set(key, true);
|
|
59119
|
+
return { changed: true };
|
|
59120
|
+
}
|
|
59121
|
+
if (verdict.value !== null) this.lastValues.set(key, verdict.value);
|
|
59122
|
+
if (verdict.reason === "falling-edge") return { changed: true };
|
|
59123
|
+
this.noteDrop(deviceId, capName, verdict.reason);
|
|
59124
|
+
return {
|
|
59125
|
+
changed: false,
|
|
59126
|
+
reason: verdict.reason
|
|
59127
|
+
};
|
|
59128
|
+
}
|
|
59129
|
+
/**
|
|
59130
|
+
* The window's drops, or `[]` while the window is still open. Draining opens
|
|
59131
|
+
* the next window.
|
|
59132
|
+
*
|
|
59133
|
+
* Sorted by `(deviceId, capName, reason)` so a line is comparable with the
|
|
59134
|
+
* previous window's without the reader re-sorting it.
|
|
59135
|
+
*/
|
|
59136
|
+
takeDropSummary(nowMs) {
|
|
59137
|
+
if (nowMs - this.windowStartedAt < this.summaryWindowMs) return [];
|
|
59138
|
+
this.windowStartedAt = nowMs;
|
|
59139
|
+
if (this.drops.size === 0) return [];
|
|
59140
|
+
const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName) || a.reason.localeCompare(b.reason));
|
|
59141
|
+
this.drops.clear();
|
|
59142
|
+
return out;
|
|
59143
|
+
}
|
|
59144
|
+
noteDrop(deviceId, capName, reason) {
|
|
59145
|
+
const key = `${deviceId}:${capName}:${reason}`;
|
|
59146
|
+
const prior = this.drops.get(key);
|
|
59147
|
+
this.drops.set(key, {
|
|
59148
|
+
deviceId,
|
|
59149
|
+
capName,
|
|
59150
|
+
reason,
|
|
59151
|
+
count: (prior?.count ?? 0) + 1
|
|
59152
|
+
});
|
|
59153
|
+
}
|
|
59154
|
+
};
|
|
57553
59155
|
//#endregion
|
|
57554
59156
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
57555
59157
|
/**
|
|
@@ -58733,10 +60335,23 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
58733
60335
|
fields: [
|
|
58734
60336
|
{
|
|
58735
60337
|
type: "boolean",
|
|
58736
|
-
key: "
|
|
58737
|
-
label: "
|
|
58738
|
-
description: "
|
|
58739
|
-
default:
|
|
60338
|
+
key: "debugMediaEnabled",
|
|
60339
|
+
label: "Debug media",
|
|
60340
|
+
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.",
|
|
60341
|
+
default: false
|
|
60342
|
+
},
|
|
60343
|
+
{
|
|
60344
|
+
type: "slider",
|
|
60345
|
+
key: "motionMarkerQuietMs",
|
|
60346
|
+
label: "Motion marker after",
|
|
60347
|
+
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.",
|
|
60348
|
+
min: 6e4,
|
|
60349
|
+
max: 216e5,
|
|
60350
|
+
step: 6e4,
|
|
60351
|
+
default: 9e5,
|
|
60352
|
+
showValue: true,
|
|
60353
|
+
unit: "min",
|
|
60354
|
+
displayScale: 6e4
|
|
58740
60355
|
},
|
|
58741
60356
|
{
|
|
58742
60357
|
type: "slider",
|
|
@@ -59245,7 +60860,7 @@ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
|
59245
60860
|
}
|
|
59246
60861
|
/**
|
|
59247
60862
|
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
59248
|
-
* frame. Pure: no side effects. The caller keeps the `
|
|
60863
|
+
* frame. Pure: no side effects. The caller keeps the `debugMediaEnabled` master
|
|
59249
60864
|
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
59250
60865
|
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
59251
60866
|
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
@@ -59411,6 +61026,129 @@ async function runAudioTrackClassMigration(deps, collection) {
|
|
|
59411
61026
|
deps.logger.info("audio track class migration", { meta: { ...stats } });
|
|
59412
61027
|
return stats;
|
|
59413
61028
|
}
|
|
61029
|
+
/** Rows one pass will touch. A ceiling, not a target. */
|
|
61030
|
+
var CONVERSION_MAX_ROWS = 2e4;
|
|
61031
|
+
/** Ids named in the "could not convert" line before it stops. */
|
|
61032
|
+
var REFUSED_IDS_LOGGED = 10;
|
|
61033
|
+
/** A stored `embedding` column, narrowed. */
|
|
61034
|
+
function floatsOf(raw) {
|
|
61035
|
+
if (!Array.isArray(raw)) return null;
|
|
61036
|
+
const out = [];
|
|
61037
|
+
for (const v of raw) {
|
|
61038
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
61039
|
+
out.push(v);
|
|
61040
|
+
}
|
|
61041
|
+
return out;
|
|
61042
|
+
}
|
|
61043
|
+
/**
|
|
61044
|
+
* Convert one collection. Never throws — a failed page ends the pass with what
|
|
61045
|
+
* it has, and the next boot picks the rest up.
|
|
61046
|
+
*/
|
|
61047
|
+
async function convertJsonEmbeddings(spec, deps) {
|
|
61048
|
+
let examined = 0;
|
|
61049
|
+
let alreadyConverted = 0;
|
|
61050
|
+
let converted = 0;
|
|
61051
|
+
let refused = 0;
|
|
61052
|
+
let unusable = 0;
|
|
61053
|
+
let offset = 0;
|
|
61054
|
+
let stoppedBy = "exhausted";
|
|
61055
|
+
const refusedIds = [];
|
|
61056
|
+
while (examined < CONVERSION_MAX_ROWS) {
|
|
61057
|
+
let rows;
|
|
61058
|
+
try {
|
|
61059
|
+
rows = await deps.store.query.query({
|
|
61060
|
+
collection: spec.collection,
|
|
61061
|
+
filter: {
|
|
61062
|
+
orderBy: {
|
|
61063
|
+
field: "id",
|
|
61064
|
+
direction: "asc"
|
|
61065
|
+
},
|
|
61066
|
+
limit: 200,
|
|
61067
|
+
offset
|
|
61068
|
+
}
|
|
61069
|
+
});
|
|
61070
|
+
} catch (err) {
|
|
61071
|
+
deps.logger.warn("embedding conversion: page failed — pass abandoned", { meta: {
|
|
61072
|
+
collection: spec.collection,
|
|
61073
|
+
offset,
|
|
61074
|
+
error: String(err)
|
|
61075
|
+
} });
|
|
61076
|
+
stoppedBy = "page-failed";
|
|
61077
|
+
break;
|
|
61078
|
+
}
|
|
61079
|
+
if (rows.length === 0) break;
|
|
61080
|
+
for (const row of rows) {
|
|
61081
|
+
examined += 1;
|
|
61082
|
+
const data = row.data;
|
|
61083
|
+
const raw = data["embedding"];
|
|
61084
|
+
if (Array.isArray(raw) && raw.length === 0) {
|
|
61085
|
+
alreadyConverted += 1;
|
|
61086
|
+
continue;
|
|
61087
|
+
}
|
|
61088
|
+
const embedding = floatsOf(raw);
|
|
61089
|
+
if (embedding === null || embedding.length === 0) {
|
|
61090
|
+
unusable += 1;
|
|
61091
|
+
continue;
|
|
61092
|
+
}
|
|
61093
|
+
const write = spec.write(row.id, embedding, data);
|
|
61094
|
+
if (write === null || !await spec.vectors.put(write)) {
|
|
61095
|
+
refused += 1;
|
|
61096
|
+
if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
|
|
61097
|
+
continue;
|
|
61098
|
+
}
|
|
61099
|
+
if (((await spec.vectors.load([row.id])).get(row.id)?.length ?? 0) !== embedding.length) {
|
|
61100
|
+
refused += 1;
|
|
61101
|
+
if (refusedIds.length < REFUSED_IDS_LOGGED) refusedIds.push(row.id);
|
|
61102
|
+
continue;
|
|
61103
|
+
}
|
|
61104
|
+
try {
|
|
61105
|
+
await deps.store.update.mutate({
|
|
61106
|
+
collection: spec.collection,
|
|
61107
|
+
id: row.id,
|
|
61108
|
+
data: { embedding: [] }
|
|
61109
|
+
});
|
|
61110
|
+
converted += 1;
|
|
61111
|
+
} catch (err) {
|
|
61112
|
+
refused += 1;
|
|
61113
|
+
deps.logger.warn("embedding conversion: row kept its JSON after a successful index write", { meta: {
|
|
61114
|
+
collection: spec.collection,
|
|
61115
|
+
id: row.id,
|
|
61116
|
+
error: String(err)
|
|
61117
|
+
} });
|
|
61118
|
+
}
|
|
61119
|
+
}
|
|
61120
|
+
if (deps.breathe) await deps.breathe();
|
|
61121
|
+
offset += rows.length;
|
|
61122
|
+
if (rows.length < 200) break;
|
|
61123
|
+
}
|
|
61124
|
+
if (examined >= 2e4) stoppedBy = "row-ceiling";
|
|
61125
|
+
if (refused > 0) deps.logger.warn("embedding conversion: rows could not be moved into the index", { meta: {
|
|
61126
|
+
collection: spec.collection,
|
|
61127
|
+
index: spec.vectors.name,
|
|
61128
|
+
refused,
|
|
61129
|
+
ids: refusedIds,
|
|
61130
|
+
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"
|
|
61131
|
+
} });
|
|
61132
|
+
deps.logger.info("embedding conversion", { meta: {
|
|
61133
|
+
collection: spec.collection,
|
|
61134
|
+
index: spec.vectors.name,
|
|
61135
|
+
examined,
|
|
61136
|
+
alreadyConverted,
|
|
61137
|
+
converted,
|
|
61138
|
+
refused,
|
|
61139
|
+
unusable,
|
|
61140
|
+
stoppedBy
|
|
61141
|
+
} });
|
|
61142
|
+
return {
|
|
61143
|
+
collection: spec.collection,
|
|
61144
|
+
examined,
|
|
61145
|
+
alreadyConverted,
|
|
61146
|
+
converted,
|
|
61147
|
+
refused,
|
|
61148
|
+
unusable,
|
|
61149
|
+
stoppedBy
|
|
61150
|
+
};
|
|
61151
|
+
}
|
|
59414
61152
|
//#endregion
|
|
59415
61153
|
//#region src/pipeline-analytics/store/object-embedding-store.ts
|
|
59416
61154
|
/**
|
|
@@ -59662,10 +61400,19 @@ var ObjectEmbeddingStore = class {
|
|
|
59662
61400
|
score: match.score
|
|
59663
61401
|
});
|
|
59664
61402
|
}
|
|
61403
|
+
if (result.effectiveTopK < input.limit) this.logger.warn("vector search ran with a smaller topK than requested", {
|
|
61404
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
61405
|
+
meta: {
|
|
61406
|
+
index: OBJECT_CLIP_INDEX,
|
|
61407
|
+
requested: input.limit,
|
|
61408
|
+
effectiveTopK: result.effectiveTopK
|
|
61409
|
+
}
|
|
61410
|
+
});
|
|
59665
61411
|
return {
|
|
59666
61412
|
hits,
|
|
59667
61413
|
scanned: result.scanned,
|
|
59668
|
-
truncated: result.truncated
|
|
61414
|
+
truncated: result.truncated,
|
|
61415
|
+
effectiveTopK: result.effectiveTopK
|
|
59669
61416
|
};
|
|
59670
61417
|
} catch (err) {
|
|
59671
61418
|
this.logger.warn("ObjectEmbeddingStore.searchByVector failed", {
|
|
@@ -59675,7 +61422,109 @@ var ObjectEmbeddingStore = class {
|
|
|
59675
61422
|
return {
|
|
59676
61423
|
hits: [],
|
|
59677
61424
|
scanned: 0,
|
|
59678
|
-
truncated: false
|
|
61425
|
+
truncated: false,
|
|
61426
|
+
effectiveTopK: 0
|
|
61427
|
+
};
|
|
61428
|
+
}
|
|
61429
|
+
}
|
|
61430
|
+
/**
|
|
61431
|
+
* ENUMERATE one page of the index, unranked.
|
|
61432
|
+
*
|
|
61433
|
+
* The orphan scan used to enumerate by asking for a KNN of 20,000 against a
|
|
61434
|
+
* ZERO probe vector. Both halves were wrong. sqlite-vec clamps `k` to 4,096,
|
|
61435
|
+
* so the scan saw 18.5% of the index and logged `examined: 4096` as though it
|
|
61436
|
+
* had walked it; and a zero vector has no direction, so whatever the 4,096
|
|
61437
|
+
* were, they were not a meaningful sample of anything. This asks the question
|
|
61438
|
+
* the reconcile actually has — *what is in here* — through `vectorStore.scan`,
|
|
61439
|
+
* which has no `k` ceiling because it computes no distance.
|
|
61440
|
+
*
|
|
61441
|
+
* An empty page is NOT the end of the index; `nextCursor === null` is.
|
|
61442
|
+
*/
|
|
61443
|
+
async scanRefs(cursor, limit) {
|
|
61444
|
+
const page = await this.vectorStore.scan.query({
|
|
61445
|
+
index: OBJECT_CLIP_INDEX,
|
|
61446
|
+
cursor,
|
|
61447
|
+
limit
|
|
61448
|
+
});
|
|
61449
|
+
const rows = [];
|
|
61450
|
+
for (const item of page.items) {
|
|
61451
|
+
const trackId = item.metadata["trackId"];
|
|
61452
|
+
const deviceId = item.metadata["deviceId"];
|
|
61453
|
+
const timestamp = item.metadata["timestamp"];
|
|
61454
|
+
rows.push({
|
|
61455
|
+
id: item.id,
|
|
61456
|
+
trackId: typeof trackId === "string" && trackId.length > 0 ? trackId : item.id.length > 0 ? item.id : null,
|
|
61457
|
+
deviceId: typeof deviceId === "number" ? deviceId : null,
|
|
61458
|
+
timestamp: typeof timestamp === "number" ? timestamp : null
|
|
61459
|
+
});
|
|
61460
|
+
}
|
|
61461
|
+
return {
|
|
61462
|
+
rows,
|
|
61463
|
+
nextCursor: page.nextCursor
|
|
61464
|
+
};
|
|
61465
|
+
}
|
|
61466
|
+
/**
|
|
61467
|
+
* Delete named vectors and CONFIRM they are gone.
|
|
61468
|
+
*
|
|
61469
|
+
* `deleteByIds` reports a row count the `vec0` backend cannot always produce,
|
|
61470
|
+
* so "the call returned" has never been the same statement as "the row left"
|
|
61471
|
+
* — and this store has already shipped one counter that could not tell those
|
|
61472
|
+
* apart (`rebuilt: 704` against an index that moved by 0). The read-back is
|
|
61473
|
+
* `getByIds`, which returns metadata and no vectors, so confirming a page of
|
|
61474
|
+
* deletions costs one indexed lookup and not one byte of float.
|
|
61475
|
+
*
|
|
61476
|
+
* Returns the ids that are VERIFIABLY gone. Anything still present is
|
|
61477
|
+
* returned as `stillPresent` for the caller to log — a delete that silently
|
|
61478
|
+
* did nothing is how an orphan becomes permanent.
|
|
61479
|
+
*/
|
|
61480
|
+
async deleteVerified(ids) {
|
|
61481
|
+
if (ids.length === 0) return {
|
|
61482
|
+
deleted: [],
|
|
61483
|
+
stillPresent: []
|
|
61484
|
+
};
|
|
61485
|
+
try {
|
|
61486
|
+
await this.vectorStore.deleteByIds.mutate({
|
|
61487
|
+
index: OBJECT_CLIP_INDEX,
|
|
61488
|
+
ids: [...ids]
|
|
61489
|
+
});
|
|
61490
|
+
} catch (err) {
|
|
61491
|
+
this.declaredDim = null;
|
|
61492
|
+
this.logger.warn("object embedding delete failed — vectors kept", { meta: {
|
|
61493
|
+
index: OBJECT_CLIP_INDEX,
|
|
61494
|
+
ids: ids.length,
|
|
61495
|
+
error: String(err)
|
|
61496
|
+
} });
|
|
61497
|
+
return {
|
|
61498
|
+
deleted: [],
|
|
61499
|
+
stillPresent: [...ids]
|
|
61500
|
+
};
|
|
61501
|
+
}
|
|
61502
|
+
try {
|
|
61503
|
+
const readBack = await this.vectorStore.getByIds.query({
|
|
61504
|
+
index: OBJECT_CLIP_INDEX,
|
|
61505
|
+
ids: [...ids]
|
|
61506
|
+
});
|
|
61507
|
+
const survivors = new Set(readBack.items.map((i) => i.id));
|
|
61508
|
+
const deleted = ids.filter((id) => !survivors.has(id));
|
|
61509
|
+
const stillPresent = ids.filter((id) => survivors.has(id));
|
|
61510
|
+
if (stillPresent.length > 0) this.logger.warn("object embedding delete did not land — rows survived the delete", { meta: {
|
|
61511
|
+
index: OBJECT_CLIP_INDEX,
|
|
61512
|
+
asked: ids.length,
|
|
61513
|
+
survived: stillPresent.length
|
|
61514
|
+
} });
|
|
61515
|
+
return {
|
|
61516
|
+
deleted,
|
|
61517
|
+
stillPresent
|
|
61518
|
+
};
|
|
61519
|
+
} catch (err) {
|
|
61520
|
+
this.logger.warn("object embedding delete could not be verified", { meta: {
|
|
61521
|
+
index: OBJECT_CLIP_INDEX,
|
|
61522
|
+
ids: ids.length,
|
|
61523
|
+
error: String(err)
|
|
61524
|
+
} });
|
|
61525
|
+
return {
|
|
61526
|
+
deleted: [],
|
|
61527
|
+
stillPresent: [...ids]
|
|
59679
61528
|
};
|
|
59680
61529
|
}
|
|
59681
61530
|
}
|
|
@@ -59722,13 +61571,15 @@ var ObjectEmbeddingStore = class {
|
|
|
59722
61571
|
deleted: 0,
|
|
59723
61572
|
refused: false
|
|
59724
61573
|
};
|
|
59725
|
-
await this.
|
|
61574
|
+
const { deleted, stillPresent } = await this.deleteVerified(orphans);
|
|
59726
61575
|
this.logger.info("object embedding orphans pruned", { meta: {
|
|
59727
61576
|
orphans: orphans.length,
|
|
61577
|
+
deleted: deleted.length,
|
|
61578
|
+
kept: stillPresent.length,
|
|
59728
61579
|
candidates: input.candidates.length
|
|
59729
61580
|
} });
|
|
59730
61581
|
return {
|
|
59731
|
-
deleted:
|
|
61582
|
+
deleted: deleted.length,
|
|
59732
61583
|
refused: false
|
|
59733
61584
|
};
|
|
59734
61585
|
}
|
|
@@ -59765,25 +61616,15 @@ var ObjectEmbeddingStore = class {
|
|
|
59765
61616
|
rows: 0,
|
|
59766
61617
|
bytes: 0
|
|
59767
61618
|
}];
|
|
59768
|
-
|
|
59769
|
-
|
|
59770
|
-
|
|
59771
|
-
|
|
59772
|
-
|
|
59773
|
-
}
|
|
59774
|
-
this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
|
|
59775
|
-
count: trackIds.length,
|
|
59776
|
-
error: String(err)
|
|
59777
|
-
} });
|
|
59778
|
-
return [{
|
|
59779
|
-
family: "embeddings",
|
|
59780
|
-
rows: 0,
|
|
59781
|
-
bytes: 0
|
|
59782
|
-
}];
|
|
59783
|
-
}
|
|
61619
|
+
const { deleted, stillPresent } = await this.deleteVerified(trackIds);
|
|
61620
|
+
if (stillPresent.length > 0) this.logger.warn("cascade left CLIP vectors behind — they are now unreachable by ownership", { meta: {
|
|
61621
|
+
asked: trackIds.length,
|
|
61622
|
+
kept: stillPresent.length,
|
|
61623
|
+
why: "the track rows are deleted immediately after this leaf, so these vectors can only be reclaimed by the orphan audit"
|
|
61624
|
+
} });
|
|
59784
61625
|
return [{
|
|
59785
61626
|
family: "embeddings",
|
|
59786
|
-
rows:
|
|
61627
|
+
rows: deleted.length,
|
|
59787
61628
|
bytes: 0
|
|
59788
61629
|
}];
|
|
59789
61630
|
}
|
|
@@ -60729,15 +62570,19 @@ function buildStationarySnapshot(input) {
|
|
|
60729
62570
|
*/
|
|
60730
62571
|
var ENCODER_INFO_TTL_MS = 6e4;
|
|
60731
62572
|
/**
|
|
60732
|
-
* Rows the
|
|
62573
|
+
* Rows of the vector index one orphan pass may examine.
|
|
62574
|
+
*
|
|
62575
|
+
* Bounded so a retention tick cannot turn into a full-index scan; the rest is
|
|
62576
|
+
* left for the next tick, resumed from a cursor. Orphans are not urgent, they
|
|
62577
|
+
* are dead weight the search has to skip.
|
|
60733
62578
|
*
|
|
60734
|
-
*
|
|
60735
|
-
*
|
|
60736
|
-
*
|
|
62579
|
+
* **This number is now real.** It used to be handed to a KNN as `topK`, where
|
|
62580
|
+
* sqlite-vec silently clamped it to 4,096 — so the audit saw 18.5% of a 22,128-
|
|
62581
|
+
* row index and reported `examined: 4096` as if it had walked the whole thing.
|
|
62582
|
+
* The pass enumerates through `vectorStore.scan` instead, which has no `k`
|
|
62583
|
+
* ceiling because it computes no distance. See `embedding-orphan-scan.ts`.
|
|
60737
62584
|
*/
|
|
60738
62585
|
var EMBEDDING_ORPHAN_SCAN_LIMIT = 2e4;
|
|
60739
|
-
/** Dimension of the zero probe vector used to enumerate the index. */
|
|
60740
|
-
var EMBEDDING_ORPHAN_PROBE_DIM = 512;
|
|
60741
62586
|
/**
|
|
60742
62587
|
* Tracks fetched per page while rebuilding.
|
|
60743
62588
|
*
|
|
@@ -60805,6 +62650,7 @@ var customActions = {
|
|
|
60805
62650
|
...embeddingActions,
|
|
60806
62651
|
...debugActions,
|
|
60807
62652
|
...orphanAuditActions,
|
|
62653
|
+
...mediaReclaimActions,
|
|
60808
62654
|
...viewerSettingsActions,
|
|
60809
62655
|
...photoEnrollActions,
|
|
60810
62656
|
...faceRescoreActions,
|
|
@@ -60845,11 +62691,38 @@ var FACE_REEMBED_AUTO_CHECK_MS = 6e4;
|
|
|
60845
62691
|
/** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
|
|
60846
62692
|
* scheduled detail call's frameHandle lease is already gone. */
|
|
60847
62693
|
var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
60848
|
-
/** Max |keyFrame −
|
|
60849
|
-
*
|
|
60850
|
-
*
|
|
60851
|
-
*
|
|
62694
|
+
/** Max |keyFrame − measured| skew (ms) accepted for a DURABLE keyFrame-derived
|
|
62695
|
+
* crop when the track's position trail carries NO motion evidence about the
|
|
62696
|
+
* two instants. With evidence the motion guard decides instead — time was
|
|
62697
|
+
* never the quantity that separated a right crop from a wrong one. */
|
|
60852
62698
|
var KEYFRAME_CROP_MAX_SKEW_MS = 1500;
|
|
62699
|
+
/**
|
|
62700
|
+
* Subject-box IoU across the two instants at or above which a keyFrame-derived
|
|
62701
|
+
* crop is accepted whatever the skew.
|
|
62702
|
+
*
|
|
62703
|
+
* Chosen from the 2026-08-30 debug-marked tracks, where each crop's verdict
|
|
62704
|
+
* came from LOOKING at the stored image (`docs/design/
|
|
62705
|
+
* 2026-08-30-debug-marked-tracks-media-triage.md`):
|
|
62706
|
+
*
|
|
62707
|
+
* `e9c024bb` windshield, wrong skew 1179 ms IoU 0.063
|
|
62708
|
+
* `4126f977` gravel, wrong skew 531 ms IoU 0.318
|
|
62709
|
+
* `7613c0fa` plate framed, RIGHT skew 2579 ms IoU 1.000
|
|
62710
|
+
*
|
|
62711
|
+
* Note the ordering: the only correct crop of the three has the LARGEST skew.
|
|
62712
|
+
* The healthy population sits far above the wrong pair too — subject IoU across
|
|
62713
|
+
* a 1500 ms lag is 0.83–1.00 on the eight debug tracks whose tiles frame their
|
|
62714
|
+
* subject. 0.5 is the midpoint of the empirical gap (0.318 → 1.000) and stays
|
|
62715
|
+
* well under that 0.83 floor. A refusal is not a lost tile: the close path
|
|
62716
|
+
* falls through to the retained detection-raster crop, which is right-moment
|
|
62717
|
+
* pixels by construction, so a false refusal costs resolution, not the image.
|
|
62718
|
+
*/
|
|
62719
|
+
var KEYFRAME_CROP_MIN_SUBJECT_IOU = .5;
|
|
62720
|
+
/** How far a position sample may sit from an instant and still be evidence
|
|
62721
|
+
* about it. The trail is one sample per emitted frame and is downsampled only
|
|
62722
|
+
* on long tracks (≈280–385 ms spacing at 60–95 s of life), so a full second
|
|
62723
|
+
* covers a healthy trail many times over; past it the honest answer is "no
|
|
62724
|
+
* evidence" and the temporal bound takes over. */
|
|
62725
|
+
var KEYFRAME_CROP_MAX_SAMPLE_GAP_MS = 1e3;
|
|
60853
62726
|
/** How long the active CLIP model id (from the embedding-encoder) is cached
|
|
60854
62727
|
* before re-reading. */
|
|
60855
62728
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
@@ -60861,7 +62734,9 @@ var CONFIRMATION_DETECTOR_CACHE_TTL_MS = 6e4;
|
|
|
60861
62734
|
* around the subject improves the confirmation detector's recall. */
|
|
60862
62735
|
var CONFIRMATION_CROP_PADDING = .15;
|
|
60863
62736
|
/** Cap the confirmation crop width so a 4K native surface never floods the
|
|
60864
|
-
* transport just to confirm one small box.
|
|
62737
|
+
* transport just to confirm one small box. EXPORTED so a test can recognise a
|
|
62738
|
+
* confirmation round trip among the runner's requests by the real constant
|
|
62739
|
+
* rather than by a copy of the number that would silently stop matching. */
|
|
60865
62740
|
var CONFIRMATION_CROP_MAX_WIDTH = 320;
|
|
60866
62741
|
/** Staleness bound for QUEUED enrichment captures (face/plate gallery crops,
|
|
60867
62742
|
* object-embedding best crops, embedding full-frame fetches — S4). These are
|
|
@@ -61044,6 +62919,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61044
62919
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
61045
62920
|
* so the DeviceStateChanged handler stays cheap. */
|
|
61046
62921
|
linkedCamerasCache = null;
|
|
62922
|
+
/**
|
|
62923
|
+
* Whether a mapped sensor cap's slice carried a real TRANSITION.
|
|
62924
|
+
*
|
|
62925
|
+
* `DeviceStateChanged` is emitted on a slice diff, and a contact slice
|
|
62926
|
+
* carries `lastChangedAt` beside `entryOpen` — so an availability flap or a
|
|
62927
|
+
* provider-side device rebuild re-emits with the boolean unmoved. Every one
|
|
62928
|
+
* of those became a `SensorEvent` row and a synthetic track until this gate
|
|
62929
|
+
* existed. Built in the field initializer: `startedAtMs` is the moment this
|
|
62930
|
+
* process began watching, which is the floor for a first sighting that is
|
|
62931
|
+
* already active. See `services/sensor-transition-gate.ts`.
|
|
62932
|
+
*/
|
|
62933
|
+
sensorTransitions = new SensorTransitionGate({
|
|
62934
|
+
startedAtMs: Date.now(),
|
|
62935
|
+
now: () => Date.now()
|
|
62936
|
+
});
|
|
61047
62937
|
/** The ONE projector of non-spatial events into synthetic snapshot-carrying
|
|
61048
62938
|
* tracks — linked sensor/control state changes AND audio markers. */
|
|
61049
62939
|
syntheticTracks = null;
|
|
@@ -61725,6 +63615,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61725
63615
|
decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
|
|
61726
63616
|
cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
|
|
61727
63617
|
maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
|
|
63618
|
+
minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
|
|
63619
|
+
maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
|
|
61728
63620
|
logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
|
|
61729
63621
|
});
|
|
61730
63622
|
this._keyFrameThumbnailDerive = async (input) => {
|
|
@@ -61736,7 +63628,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61736
63628
|
return {
|
|
61737
63629
|
thumbnail: derived.jpeg,
|
|
61738
63630
|
thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
|
|
61739
|
-
skewMs: derived.skewMs
|
|
63631
|
+
skewMs: derived.skewMs,
|
|
63632
|
+
motion: derived.motion,
|
|
63633
|
+
...derived.subjectIou !== void 0 ? { subjectIou: derived.subjectIou } : {}
|
|
61740
63634
|
};
|
|
61741
63635
|
};
|
|
61742
63636
|
}
|
|
@@ -61823,6 +63717,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61823
63717
|
* page rather than a full rescan. In RAM: losing them costs a rescan, and
|
|
61824
63718
|
* persisting them would be a durable record of a purely advisory position. */
|
|
61825
63719
|
orphanAuditCursors = /* @__PURE__ */ new Map();
|
|
63720
|
+
/** Resume points for the debug-media reclaim, keyed `<scope>:<deviceId>`.
|
|
63721
|
+
* In RAM like the audit's: the pass is resumable from zero at no cost
|
|
63722
|
+
* beyond re-walking a head that is already empty. */
|
|
63723
|
+
mediaReclaimCursors = /* @__PURE__ */ new Map();
|
|
63724
|
+
/** Single-flight latch — two concurrent passes would double the disk
|
|
63725
|
+
* pressure the pacing exists to avoid. */
|
|
63726
|
+
mediaReclaimRunning = false;
|
|
63727
|
+
/**
|
|
63728
|
+
* The vector-index leg's resume cursor, kept in its OWN field.
|
|
63729
|
+
*
|
|
63730
|
+
* Not a key in {@link orphanAuditCursors}: `runOrphanAudit` returns a map
|
|
63731
|
+
* built from ITS scopes and the caller assigns it wholesale, so a key added
|
|
63732
|
+
* afterwards is dropped on the next run — the walk would silently restart at
|
|
63733
|
+
* row 0 every six hours and never reach the tail of the index, which is the
|
|
63734
|
+
* same failure the `k` clamp already caused once.
|
|
63735
|
+
*/
|
|
63736
|
+
orphanEmbeddingCursor = 0;
|
|
61826
63737
|
/** Re-entrancy guard — two whole-database scans must never overlap on the
|
|
61827
63738
|
* thread that also serves frames. */
|
|
61828
63739
|
orphanAuditRunning = false;
|
|
@@ -61909,6 +63820,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61909
63820
|
designatedNode: designated
|
|
61910
63821
|
} });
|
|
61911
63822
|
}
|
|
63823
|
+
if (this.isPostProcessingNode) this.convertLegacyEmbeddingColumns(api);
|
|
61912
63824
|
const transport = createNativeFrameTransport({
|
|
61913
63825
|
api,
|
|
61914
63826
|
ownNodeId,
|
|
@@ -61960,6 +63872,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61960
63872
|
...embeddingActions,
|
|
61961
63873
|
...debugActions,
|
|
61962
63874
|
...orphanAuditActions,
|
|
63875
|
+
...mediaReclaimActions,
|
|
61963
63876
|
...viewerSettingsActions,
|
|
61964
63877
|
...photoEnrollActions,
|
|
61965
63878
|
...faceRescoreActions,
|
|
@@ -62155,6 +64068,37 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62155
64068
|
});
|
|
62156
64069
|
this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
|
|
62157
64070
|
}
|
|
64071
|
+
/**
|
|
64072
|
+
* Move `faces.embedding` and `identity-samples.embedding` out of JSON text
|
|
64073
|
+
* and into their vector indexes — once, in place, idempotently.
|
|
64074
|
+
*
|
|
64075
|
+
* Not awaited by the boot path and not a schema migration: see
|
|
64076
|
+
* `embedding-json-conversion.ts` for why "converted" is a property of a row
|
|
64077
|
+
* rather than a version number. Every failure mode here is safe — a row whose
|
|
64078
|
+
* index write did not land keeps its array, and both readers fall back to it.
|
|
64079
|
+
*/
|
|
64080
|
+
async convertLegacyEmbeddingColumns(api) {
|
|
64081
|
+
const faceStore = this.faceStore;
|
|
64082
|
+
const identityStore = this.identityStore;
|
|
64083
|
+
if (!faceStore || !identityStore) {
|
|
64084
|
+
this.ctx.logger.warn("embedding conversion skipped — the gallery stores are not up", { meta: {
|
|
64085
|
+
faceStore: faceStore !== null,
|
|
64086
|
+
identityStore: identityStore !== null
|
|
64087
|
+
} });
|
|
64088
|
+
return;
|
|
64089
|
+
}
|
|
64090
|
+
const deps = {
|
|
64091
|
+
store: api.settingsStore,
|
|
64092
|
+
logger: this.ctx.logger.child("EmbeddingConversion"),
|
|
64093
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
64094
|
+
};
|
|
64095
|
+
try {
|
|
64096
|
+
await convertJsonEmbeddings(faceStore.conversionSpec(), deps);
|
|
64097
|
+
await convertJsonEmbeddings(identityStore.conversionSpec(), deps);
|
|
64098
|
+
} catch (err) {
|
|
64099
|
+
this.ctx.logger.warn("embedding conversion pass failed", { meta: { error: String(err) } });
|
|
64100
|
+
}
|
|
64101
|
+
}
|
|
62158
64102
|
async declareCollections(api) {
|
|
62159
64103
|
this.settingsStore = api.settingsStore;
|
|
62160
64104
|
await Promise.all([
|
|
@@ -62487,6 +64431,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62487
64431
|
this.motionEventSnapshots = new MotionEventSnapshotCapturer({
|
|
62488
64432
|
snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
|
|
62489
64433
|
media: mediaStore,
|
|
64434
|
+
logger: logger.child("MotionMarkerStill"),
|
|
62490
64435
|
onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
|
|
62491
64436
|
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
62492
64437
|
meta: {
|
|
@@ -62504,13 +64449,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62504
64449
|
error: require_dist.errMsg(err)
|
|
62505
64450
|
} })
|
|
62506
64451
|
});
|
|
64452
|
+
const galleryVectorStore = api.vectorStore;
|
|
64453
|
+
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");
|
|
62507
64454
|
const identityStore = new IdentityStore({
|
|
62508
64455
|
store: api.settingsStore,
|
|
64456
|
+
vectors: new EmbeddingVectorIndex({
|
|
64457
|
+
index: IDENTITY_ARCFACE_INDEX,
|
|
64458
|
+
client: galleryVectorStore,
|
|
64459
|
+
logger: logger.child("IdentityVectors")
|
|
64460
|
+
}),
|
|
62509
64461
|
logger: logger.child("IdentityStore")
|
|
62510
64462
|
});
|
|
62511
64463
|
this.identityStore = identityStore;
|
|
62512
64464
|
const faceStore = new FaceStore({
|
|
62513
64465
|
store: api.settingsStore,
|
|
64466
|
+
vectors: new EmbeddingVectorIndex({
|
|
64467
|
+
index: FACE_ARCFACE_INDEX,
|
|
64468
|
+
client: galleryVectorStore,
|
|
64469
|
+
logger: logger.child("FaceVectors")
|
|
64470
|
+
}),
|
|
62514
64471
|
logger: logger.child("FaceStore")
|
|
62515
64472
|
});
|
|
62516
64473
|
this.faceStore = faceStore;
|
|
@@ -62632,6 +64589,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62632
64589
|
decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
|
|
62633
64590
|
cropRegionToJpeg: async (bytes, w, h, norm) => (await extractCrop(bytes, w, h, norm)).crop,
|
|
62634
64591
|
maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
|
|
64592
|
+
minSubjectIou: KEYFRAME_CROP_MIN_SUBJECT_IOU,
|
|
64593
|
+
maxSubjectSampleGapMs: KEYFRAME_CROP_MAX_SAMPLE_GAP_MS,
|
|
62635
64594
|
logger: logger.child("KeyFrameCrop")
|
|
62636
64595
|
});
|
|
62637
64596
|
this.plateRecognizer = new PlateRecognizer({
|
|
@@ -63459,10 +65418,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
63459
65418
|
const limit = input.candidateLimit ?? 25;
|
|
63460
65419
|
const asked = input.deviceIds;
|
|
63461
65420
|
const known = new Set([...registry.deviceIds(), ...evidence?.deviceIdsWithCandidates() ?? []]);
|
|
63462
|
-
return { devices: (asked === void 0 || asked.length === 0 ? [...known].
|
|
65421
|
+
return { devices: (asked === void 0 || asked.length === 0 ? [...known].toSorted((a, b) => a - b) : asked.filter((id) => known.has(id))).map((deviceId) => {
|
|
63463
65422
|
const observedAt = registry.observedAt(deviceId);
|
|
63464
65423
|
const sessionOpenedAt = registry.sessionOpenedAt(deviceId);
|
|
63465
|
-
const ordered = [...evidence?.candidatesFor(deviceId) ?? []].
|
|
65424
|
+
const ordered = [...evidence?.candidatesFor(deviceId) ?? []].toSorted((a, b) => b.agreed - a.agreed);
|
|
63466
65425
|
return {
|
|
63467
65426
|
deviceId,
|
|
63468
65427
|
...sessionOpenedAt !== void 0 ? { sessionOpenedAt } : {},
|
|
@@ -63751,6 +65710,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
63751
65710
|
this.forgetDeviceProcessors(deviceId);
|
|
63752
65711
|
this.levelStateByDevice.delete(deviceId);
|
|
63753
65712
|
this.lastTrackActivityMs.delete(deviceId);
|
|
65713
|
+
this.motionEventSnapshots?.forgetDevice(deviceId);
|
|
63754
65714
|
this.audioEpisodeByDevice.delete(deviceId);
|
|
63755
65715
|
for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
|
|
63756
65716
|
this.audioEpisodeTracks.delete(episodeId);
|
|
@@ -65163,12 +67123,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65163
67123
|
deviceId,
|
|
65164
67124
|
trackId: candidate.trackId,
|
|
65165
67125
|
kind: "confirm",
|
|
67126
|
+
staleAfterMs: config.timeoutMs,
|
|
65166
67127
|
exec: () => captureCrop(frameHandle, {
|
|
65167
67128
|
x: candidate.bbox.x,
|
|
65168
67129
|
y: candidate.bbox.y,
|
|
65169
67130
|
w: candidate.bbox.w,
|
|
65170
67131
|
h: candidate.bbox.h
|
|
65171
|
-
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING,
|
|
67132
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, 320, deviceId)
|
|
65172
67133
|
}),
|
|
65173
67134
|
fetchFallbackCrop: (candidate) => {
|
|
65174
67135
|
const displayCrop = this.captureDisplayCropFn;
|
|
@@ -65178,7 +67139,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65178
67139
|
y: candidate.bbox.y,
|
|
65179
67140
|
w: candidate.bbox.w,
|
|
65180
67141
|
h: candidate.bbox.h
|
|
65181
|
-
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId,
|
|
67142
|
+
}, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
|
|
65182
67143
|
},
|
|
65183
67144
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
65184
67145
|
minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
|
|
@@ -65541,12 +67502,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65541
67502
|
/**
|
|
65542
67503
|
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
65543
67504
|
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
65544
|
-
* • `snapshot` (append)
|
|
65545
|
-
* the
|
|
67505
|
+
* • `snapshot` (append) fires on the snapshotIntervalMs cadence, gated by
|
|
67506
|
+
* `debugMediaEnabled`; the rolling `lastFrame` fires on its own cadence
|
|
67507
|
+
* and is NOT gated (it is in the keep-set);
|
|
65546
67508
|
* • `thumbnail` (best) fires when confidence beats the held best per
|
|
65547
67509
|
* `BEST_FRAME_POLICY` (hysteresis margin, rate-limited to one per
|
|
65548
67510
|
* `minGapMs` — see `pipeline/best-selection-policies.ts`), and is
|
|
65549
|
-
*
|
|
67511
|
+
* never gated (a single best still-frame is always useful).
|
|
65550
67512
|
* The per-track best-confidence map is updated here as a side effect.
|
|
65551
67513
|
*/
|
|
65552
67514
|
/**
|
|
@@ -65958,9 +67920,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65958
67920
|
});
|
|
65959
67921
|
continue;
|
|
65960
67922
|
}
|
|
65961
|
-
const
|
|
65962
|
-
|
|
65963
|
-
lastSnapshotAt: lastSnap,
|
|
67923
|
+
const dueSnapshot = evaluatePeriodicSnapshot({
|
|
67924
|
+
lastSnapshotAt: this.trackStore.lastSnapshotAt(t.trackId),
|
|
65964
67925
|
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
65965
67926
|
currentBbox: t.bbox,
|
|
65966
67927
|
now: timestamp,
|
|
@@ -65985,7 +67946,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65985
67946
|
const isNewBest = this.residents.observeBestFrame(deviceId, t.trackId, t.confidence, timestamp, edgeClear, centerScore, containment, sizeScore, squareFraction, false);
|
|
65986
67947
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
65987
67948
|
const plan = planPeriodicMedia({
|
|
65988
|
-
|
|
67949
|
+
debugMediaEnabled: media.debugMediaEnabled,
|
|
65989
67950
|
dueSnapshot,
|
|
65990
67951
|
isNewBest,
|
|
65991
67952
|
thumbnailLanded: this.residents.isThumbnailLanded(t.trackId),
|
|
@@ -66414,11 +68375,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
66414
68375
|
timestamp: ev.timestamp
|
|
66415
68376
|
}
|
|
66416
68377
|
});
|
|
66417
|
-
if (transitionedOn) await this.
|
|
66418
|
-
deviceId,
|
|
66419
|
-
eventId: ev.id,
|
|
66420
|
-
timestamp: ev.timestamp
|
|
66421
|
-
});
|
|
68378
|
+
if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
|
|
66422
68379
|
}
|
|
66423
68380
|
/**
|
|
66424
68381
|
* Persist firmware-driven (onboard) motion events. Mirrors
|
|
@@ -66493,10 +68450,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
66493
68450
|
timestamp: ev.timestamp
|
|
66494
68451
|
}
|
|
66495
68452
|
});
|
|
66496
|
-
if (transitionedOn) await this.
|
|
68453
|
+
if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
|
|
68454
|
+
}
|
|
68455
|
+
/**
|
|
68456
|
+
* The gate in front of the motion still. Both motion handlers reach it, so
|
|
68457
|
+
* there is exactly one place that decides.
|
|
68458
|
+
*
|
|
68459
|
+
* With debug media OFF (the default) the still is the operator's fallback
|
|
68460
|
+
* marker: produced only during a DETECTION DROUGHT, once per
|
|
68461
|
+
* `motionMarkerQuietMs` window. See `services/motion-marker-policy.ts` for
|
|
68462
|
+
* why the raw edge is not a scene fact — it is the provider's ~1 Hz
|
|
68463
|
+
* keepalive beating against the analyzer's per-frame sample through one
|
|
68464
|
+
* shared edge map.
|
|
68465
|
+
*
|
|
68466
|
+
* A dropped write is REPORTED, but as a per-window summary and never per
|
|
68467
|
+
* event: the suppressed count since the last marker rides on the marker's
|
|
68468
|
+
* own line. At 92 400 suppressions/day a line each would be the flood the
|
|
68469
|
+
* logging rules exist to prevent, and a silent branch reads as "never
|
|
68470
|
+
* happened".
|
|
68471
|
+
*/
|
|
68472
|
+
async captureMotionMarkerStill(deviceId, eventId, timestamp) {
|
|
68473
|
+
const capturer = this.motionEventSnapshots;
|
|
68474
|
+
if (!capturer) return;
|
|
68475
|
+
const media = await this.resolveDeviceMediaSettings(deviceId);
|
|
68476
|
+
await capturer.capture({
|
|
66497
68477
|
deviceId,
|
|
66498
|
-
eventId
|
|
66499
|
-
timestamp
|
|
68478
|
+
eventId,
|
|
68479
|
+
timestamp,
|
|
68480
|
+
policy: {
|
|
68481
|
+
debugMediaEnabled: media.debugMediaEnabled,
|
|
68482
|
+
quietMs: media.motionMarkerQuietMs,
|
|
68483
|
+
lastDetectionAtMs: this.lastTrackActivityMs.get(deviceId)
|
|
68484
|
+
}
|
|
66500
68485
|
});
|
|
66501
68486
|
}
|
|
66502
68487
|
async sweepExpiredTracks() {
|
|
@@ -67235,7 +69220,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67235
69220
|
};
|
|
67236
69221
|
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
67237
69222
|
else dropKeyFrame();
|
|
67238
|
-
this.
|
|
69223
|
+
const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
|
|
69224
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
|
|
67239
69225
|
this.residents.close(track.trackId, "stationary");
|
|
67240
69226
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
67241
69227
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
@@ -67573,11 +69559,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67573
69559
|
* press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
|
|
67574
69560
|
* event is a missing history row and a missing notification — never wrong
|
|
67575
69561
|
* durable state.
|
|
69562
|
+
*
|
|
69563
|
+
* The FIRST question is whether the sensor changed at all. This event is
|
|
69564
|
+
* emitted on a slice diff, and a level cap's slice carries its transition
|
|
69565
|
+
* timestamp beside its boolean — so an availability flap or a device rebuild
|
|
69566
|
+
* re-emits with the boolean unmoved. Both the row and the marker are gated on
|
|
69567
|
+
* the same verdict, in that one place: two derivations of "did this happen"
|
|
69568
|
+
* is one too many, and it is the mistake this handler already made once with
|
|
69569
|
+
* "which cameras" (`resolveSensorEventCameras`).
|
|
67576
69570
|
*/
|
|
67577
69571
|
async handleSensorStateChanged(data, timestamp) {
|
|
67578
69572
|
const store = this.sensorEventStore;
|
|
67579
69573
|
const cache = this.linkedCamerasCache;
|
|
67580
69574
|
if (store === null || cache === null) return;
|
|
69575
|
+
if (!this.sensorSliceCarriedATransition(data)) return;
|
|
67581
69576
|
const persisted = [];
|
|
67582
69577
|
try {
|
|
67583
69578
|
await ingestSensorStateChange({
|
|
@@ -67641,6 +69636,55 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67641
69636
|
for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
67642
69637
|
}
|
|
67643
69638
|
/**
|
|
69639
|
+
* True when this slice carried a real transition of its sensor.
|
|
69640
|
+
*
|
|
69641
|
+
* A cap with no boolean level (`doorbell`, `button`, `lock-control`,
|
|
69642
|
+
* `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
|
|
69643
|
+
* the event, and gating it would re-ship the silence of 2026-08-08. A cap
|
|
69644
|
+
* that HAS one passes only when the boolean moved, in either direction.
|
|
69645
|
+
*
|
|
69646
|
+
* A slice that is not an object at all is refused rather than defaulted to
|
|
69647
|
+
* `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
|
|
69648
|
+
* object, which looks like a sensor reporting nonsense when what happened is
|
|
69649
|
+
* that there was nothing to read. Same discipline as the recorder's
|
|
69650
|
+
* `parseSensorStateChanged`.
|
|
69651
|
+
*
|
|
69652
|
+
* The drops are SUMMARISED, never logged one by one: this path carries every
|
|
69653
|
+
* camera's native `motion` slice at motion rate.
|
|
69654
|
+
*/
|
|
69655
|
+
sensorSliceCarriedATransition(data) {
|
|
69656
|
+
const slice = data.slice;
|
|
69657
|
+
if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
|
|
69658
|
+
this.ctx.logger.debug("sensor state change carried no slice — dropped", {
|
|
69659
|
+
tags: { deviceId: data.deviceId },
|
|
69660
|
+
meta: { capName: data.capName }
|
|
69661
|
+
});
|
|
69662
|
+
return false;
|
|
69663
|
+
}
|
|
69664
|
+
const verdict = this.sensorTransitions.observe(data.deviceId, data.capName, { ...slice });
|
|
69665
|
+
this.flushSensorTransitionDrops();
|
|
69666
|
+
return verdict.changed;
|
|
69667
|
+
}
|
|
69668
|
+
/**
|
|
69669
|
+
* One `info` line per (device, cap, reason) per window, or none.
|
|
69670
|
+
*
|
|
69671
|
+
* A branch that drops work says so — but this one drops ~0.6 events/s
|
|
69672
|
+
* fleet-wide in its steady state (every camera's native `motion`), so what it
|
|
69673
|
+
* says is a COUNT. The line an operator greps when a real contact produced no
|
|
69674
|
+
* marker is here, with the numeric `deviceId` of the SENSOR, and it says
|
|
69675
|
+
* which verdict swallowed it.
|
|
69676
|
+
*/
|
|
69677
|
+
flushSensorTransitionDrops() {
|
|
69678
|
+
for (const drop of this.sensorTransitions.takeDropSummary(Date.now())) this.ctx.logger.info("sensor state changes dropped — no transition", {
|
|
69679
|
+
tags: { deviceId: drop.deviceId },
|
|
69680
|
+
meta: {
|
|
69681
|
+
capName: drop.capName,
|
|
69682
|
+
reason: drop.reason,
|
|
69683
|
+
count: drop.count
|
|
69684
|
+
}
|
|
69685
|
+
});
|
|
69686
|
+
}
|
|
69687
|
+
/**
|
|
67644
69688
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|
|
67645
69689
|
*
|
|
67646
69690
|
* BOTH branches are `debug`, deliberately. After the self-camera fix neither
|
|
@@ -67819,25 +69863,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67819
69863
|
return runner.runStatelessStep.mutate(input, require_dist.nodePin(nodeId));
|
|
67820
69864
|
}
|
|
67821
69865
|
/**
|
|
67822
|
-
* Every track id the track store still holds, across the given devices.
|
|
67823
|
-
*
|
|
67824
|
-
* Returned as a Set for the orphan sweep, which REFUSES an empty one — a
|
|
67825
|
-
* store that failed to answer must not be read as "no tracks exist".
|
|
67826
|
-
*/
|
|
67827
|
-
async collectLiveTrackIds(deviceIds) {
|
|
67828
|
-
const live = /* @__PURE__ */ new Set();
|
|
67829
|
-
const trackStore = this.trackStore;
|
|
67830
|
-
if (!trackStore) return live;
|
|
67831
|
-
for (const deviceId of deviceIds) {
|
|
67832
|
-
const page = await trackStore.queryRecentIds({
|
|
67833
|
-
deviceIds: [deviceId],
|
|
67834
|
-
limit: EMBEDDING_ORPHAN_SCAN_LIMIT
|
|
67835
|
-
});
|
|
67836
|
-
for (const trackId of page.trackIds) live.add(trackId);
|
|
67837
|
-
}
|
|
67838
|
-
return live;
|
|
67839
|
-
}
|
|
67840
|
-
/**
|
|
67841
69866
|
* The periodic ORPHAN AUDIT — ownership-keyed, wide interval, report-first.
|
|
67842
69867
|
*
|
|
67843
69868
|
* This is NOT a fifth age sweep and must never be mistaken for one. It takes
|
|
@@ -67938,7 +69963,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67938
69963
|
}
|
|
67939
69964
|
});
|
|
67940
69965
|
}
|
|
67941
|
-
await this.auditEmbeddingOrphans(limits);
|
|
69966
|
+
await this.auditEmbeddingOrphans(limits, overrides?.restart === true);
|
|
67942
69967
|
if (report.totalOrphans > 0) this.eventsOpsLog?.append({
|
|
67943
69968
|
op: "orphan-audit",
|
|
67944
69969
|
reason: "maintenance",
|
|
@@ -67968,43 +69993,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
67968
69993
|
* that failed to answer must never be read as "no tracks exist", which would
|
|
67969
69994
|
* wipe the whole index. In report mode nothing is deleted at all.
|
|
67970
69995
|
*/
|
|
67971
|
-
async auditEmbeddingOrphans(limits) {
|
|
69996
|
+
async auditEmbeddingOrphans(limits, restart) {
|
|
67972
69997
|
const embeddingStore = this.objectEmbeddingStore;
|
|
67973
|
-
|
|
67974
|
-
|
|
67975
|
-
|
|
67976
|
-
|
|
67977
|
-
|
|
67978
|
-
|
|
67979
|
-
limit: EMBEDDING_ORPHAN_SCAN_LIMIT
|
|
67980
|
-
})).hits.map((h) => h.row.trackId);
|
|
67981
|
-
const orphans = candidates.filter((id) => !live.has(id));
|
|
67982
|
-
if (limits.mode !== "reclaim") {
|
|
67983
|
-
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
67984
|
-
mode: limits.mode,
|
|
67985
|
-
collection: "vector-index:object-clip",
|
|
67986
|
-
examined: candidates.length,
|
|
67987
|
-
orphans: live.size === 0 ? 0 : orphans.length,
|
|
67988
|
-
reclaimed: 0,
|
|
67989
|
-
refused: live.size === 0
|
|
67990
|
-
} });
|
|
67991
|
-
return;
|
|
67992
|
-
}
|
|
67993
|
-
const result = await embeddingStore.pruneOrphans({
|
|
67994
|
-
liveTrackIds: live,
|
|
67995
|
-
candidates
|
|
67996
|
-
});
|
|
67997
|
-
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
67998
|
-
mode: limits.mode,
|
|
67999
|
-
collection: "vector-index:object-clip",
|
|
68000
|
-
examined: candidates.length,
|
|
68001
|
-
orphans: result.deleted,
|
|
68002
|
-
reclaimed: result.deleted,
|
|
68003
|
-
refused: result.refused
|
|
69998
|
+
const trackStore = this.trackStore;
|
|
69999
|
+
if (!embeddingStore || !trackStore) {
|
|
70000
|
+
this.ctx.logger.warn("analytics orphan audit: vector-index leg skipped — stores not up", { meta: {
|
|
70001
|
+
collection: EMBEDDING_ORPHAN_SCOPE,
|
|
70002
|
+
embeddingStore: embeddingStore !== null,
|
|
70003
|
+
trackStore: trackStore !== null
|
|
68004
70004
|
} });
|
|
68005
|
-
|
|
68006
|
-
this.ctx.logger.debug("analytics orphan audit (vector index) failed", { meta: { error: String(err) } });
|
|
70005
|
+
return;
|
|
68007
70006
|
}
|
|
70007
|
+
const report = await scanEmbeddingOrphans({
|
|
70008
|
+
scanPage: (cursor, limit) => embeddingStore.scanRefs(cursor, limit),
|
|
70009
|
+
liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
|
|
70010
|
+
reclaim: (ids) => embeddingStore.deleteVerified(ids),
|
|
70011
|
+
now: () => Date.now(),
|
|
70012
|
+
logger: this.ctx.logger,
|
|
70013
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
70014
|
+
}, {
|
|
70015
|
+
mode: limits.mode,
|
|
70016
|
+
graceMs: limits.graceMs,
|
|
70017
|
+
pageSize: limits.pageSize,
|
|
70018
|
+
maxRows: Math.max(limits.maxRowsPerScope, EMBEDDING_ORPHAN_SCAN_LIMIT),
|
|
70019
|
+
maxReclaim: limits.maxReclaimPerScope
|
|
70020
|
+
}, restart ? 0 : this.orphanEmbeddingCursor);
|
|
70021
|
+
this.orphanEmbeddingCursor = report.nextCursor;
|
|
70022
|
+
this.ctx.logger.info("analytics orphan audit", { meta: {
|
|
70023
|
+
mode: limits.mode,
|
|
70024
|
+
collection: report.scope,
|
|
70025
|
+
examined: report.examined,
|
|
70026
|
+
orphans: report.orphans,
|
|
70027
|
+
reclaimed: report.reclaimed,
|
|
70028
|
+
kept: report.kept,
|
|
70029
|
+
withinGrace: report.withinGrace,
|
|
70030
|
+
unresolvable: report.unresolvable,
|
|
70031
|
+
notReclaimed: report.notReclaimed,
|
|
70032
|
+
stoppedBy: report.stoppedBy,
|
|
70033
|
+
nextCursor: report.nextCursor
|
|
70034
|
+
} });
|
|
70035
|
+
for (const deviceId of report.deviceIds) this.ctx.logger.info("analytics orphan audit (device)", {
|
|
70036
|
+
tags: { deviceId },
|
|
70037
|
+
meta: {
|
|
70038
|
+
deviceId,
|
|
70039
|
+
collection: report.scope,
|
|
70040
|
+
mode: limits.mode
|
|
70041
|
+
}
|
|
70042
|
+
});
|
|
68008
70043
|
}
|
|
68009
70044
|
/**
|
|
68010
70045
|
* Operator-tunable audit limits. `mode` and `pageSize` are SETTINGS because
|
|
@@ -68482,11 +70517,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
68482
70517
|
}, Buffer.from(file.base64, "base64"));
|
|
68483
70518
|
if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
|
|
68484
70519
|
if (outcome.kind === "failed") return refuse("failed", outcome.reason);
|
|
68485
|
-
await faceStore.
|
|
70520
|
+
if (!await faceStore.restampEmbedding({
|
|
70521
|
+
faceId: face.id,
|
|
70522
|
+
deviceId: face.deviceId,
|
|
70523
|
+
trackId: face.trackId,
|
|
70524
|
+
timestamp: face.timestamp,
|
|
68486
70525
|
embedding: outcome.embedding,
|
|
68487
|
-
|
|
68488
|
-
});
|
|
68489
|
-
if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
70526
|
+
modelId: clusterModelId
|
|
70527
|
+
})) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
68490
70528
|
this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
|
|
68491
70529
|
tags: { deviceId: face.deviceId },
|
|
68492
70530
|
meta: {
|
|
@@ -68547,7 +70585,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
68547
70585
|
const identityStore = this.identityStore;
|
|
68548
70586
|
if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
|
|
68549
70587
|
const faceId = `face-${input.trackId}`;
|
|
68550
|
-
const face = await faceStore.
|
|
70588
|
+
const face = await faceStore.getWithEmbedding(faceId);
|
|
68551
70589
|
if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
|
|
68552
70590
|
if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
|
|
68553
70591
|
const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
|
|
@@ -68759,9 +70797,95 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
68759
70797
|
"embedding.wipe": async () => this.wipeObjectEmbeddings(),
|
|
68760
70798
|
"embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
|
|
68761
70799
|
"embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
|
|
68762
|
-
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
|
|
70800
|
+
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
|
|
70801
|
+
"retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
|
|
68763
70802
|
};
|
|
68764
70803
|
}
|
|
70804
|
+
/**
|
|
70805
|
+
* One operator-driven debug-media reclaim pass — the entry point behind
|
|
70806
|
+
* `retention.mediaReclaim`.
|
|
70807
|
+
*
|
|
70808
|
+
* DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
|
|
70809
|
+
* `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
|
|
70810
|
+
* from the per-device cursors this method keeps, and a run that is cut short
|
|
70811
|
+
* has still only forgotten rows whose blobs it proved gone.
|
|
70812
|
+
*
|
|
70813
|
+
* Single-flight: a second call while a pass is in flight returns
|
|
70814
|
+
* `ran: false` rather than doubling the disk pressure the pacing exists to
|
|
70815
|
+
* avoid.
|
|
70816
|
+
*/
|
|
70817
|
+
async runMediaReclaimNow(rawInput) {
|
|
70818
|
+
const input = MediaReclaimActionInputSchema.parse(rawInput);
|
|
70819
|
+
const mediaStore = this.mediaStore;
|
|
70820
|
+
const eventStore = this.eventStore;
|
|
70821
|
+
const trackStore = this.trackStore;
|
|
70822
|
+
const empty = {
|
|
70823
|
+
ran: false,
|
|
70824
|
+
mode: input.mode,
|
|
70825
|
+
devices: [],
|
|
70826
|
+
totalExamined: 0,
|
|
70827
|
+
totalEligible: 0,
|
|
70828
|
+
totalReclaimed: 0,
|
|
70829
|
+
totalBytesReclaimed: 0,
|
|
70830
|
+
totalRefused: 0,
|
|
70831
|
+
durationMs: 0,
|
|
70832
|
+
complete: false
|
|
70833
|
+
};
|
|
70834
|
+
if (!mediaStore || !eventStore || !trackStore) return empty;
|
|
70835
|
+
if (this.mediaReclaimRunning) return empty;
|
|
70836
|
+
this.mediaReclaimRunning = true;
|
|
70837
|
+
try {
|
|
70838
|
+
const limits = {
|
|
70839
|
+
mode: input.mode,
|
|
70840
|
+
pageSize: input.pageSize ?? DEFAULT_MEDIA_RECLAIM_LIMITS.pageSize,
|
|
70841
|
+
maxRowsPerDevice: input.maxRowsPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxRowsPerDevice,
|
|
70842
|
+
maxReclaimPerDevice: input.maxReclaimPerDevice ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxReclaimPerDevice,
|
|
70843
|
+
maxBytesPerRun: input.maxBytesPerRun ?? DEFAULT_MEDIA_RECLAIM_LIMITS.maxBytesPerRun,
|
|
70844
|
+
budgetMs: input.budgetMinutes !== void 0 ? input.budgetMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.budgetMs,
|
|
70845
|
+
graceMs: input.graceMinutes !== void 0 ? input.graceMinutes * 6e4 : DEFAULT_MEDIA_RECLAIM_LIMITS.graceMs,
|
|
70846
|
+
throttleBytesPerSec: input.throttleBytesPerSec ?? DEFAULT_MEDIA_RECLAIM_LIMITS.throttleBytesPerSec
|
|
70847
|
+
};
|
|
70848
|
+
const scopes = input.scopes ?? MEDIA_RECLAIM_SCOPES;
|
|
70849
|
+
const { report, cursors } = await runMediaReclaim({
|
|
70850
|
+
deviceIds: input.deviceIds ?? await this.retentionDeviceIds(),
|
|
70851
|
+
scopes,
|
|
70852
|
+
limits,
|
|
70853
|
+
cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
|
|
70854
|
+
deps: {
|
|
70855
|
+
page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
|
|
70856
|
+
deviceId,
|
|
70857
|
+
ownerKind: scope === "motion-still" ? "event" : "track",
|
|
70858
|
+
kind: "snapshot",
|
|
70859
|
+
afterMs,
|
|
70860
|
+
limit
|
|
70861
|
+
}),
|
|
70862
|
+
motionEventIds: (ids) => eventStore.existingMotionEventIds(ids),
|
|
70863
|
+
pinnedTrackIds: (ids) => trackStore.pinnedTrackIds(ids),
|
|
70864
|
+
reclaim: (key) => mediaStore.deleteConfirmed(key),
|
|
70865
|
+
now: () => Date.now(),
|
|
70866
|
+
logger: this.ctx.logger,
|
|
70867
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve)),
|
|
70868
|
+
pace: (ms) => ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms))
|
|
70869
|
+
}
|
|
70870
|
+
});
|
|
70871
|
+
this.mediaReclaimCursors = cursors;
|
|
70872
|
+
if (limits.mode === "reclaim" && report.totalReclaimed > 0) await this.eventsOpsLog?.append({
|
|
70873
|
+
op: "prune",
|
|
70874
|
+
reason: "operator",
|
|
70875
|
+
deviceId: null,
|
|
70876
|
+
itemsAffected: report.totalReclaimed,
|
|
70877
|
+
bytesReclaimed: report.totalBytesReclaimed,
|
|
70878
|
+
detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
|
|
70879
|
+
});
|
|
70880
|
+
return {
|
|
70881
|
+
ran: true,
|
|
70882
|
+
...report,
|
|
70883
|
+
devices: [...report.devices]
|
|
70884
|
+
};
|
|
70885
|
+
} finally {
|
|
70886
|
+
this.mediaReclaimRunning = false;
|
|
70887
|
+
}
|
|
70888
|
+
}
|
|
68765
70889
|
/** Progress of the detached rebuild — the only way to observe a long pass. */
|
|
68766
70890
|
async getObjectEmbeddingRebuildStatus() {
|
|
68767
70891
|
const state = this.rebuildState;
|
|
@@ -68813,7 +70937,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
68813
70937
|
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {}
|
|
68814
70938
|
});
|
|
68815
70939
|
const ranked = search.hits;
|
|
68816
|
-
if (ranked.length === 0)
|
|
70940
|
+
if (ranked.length === 0) {
|
|
70941
|
+
if (input.classFilter !== void 0) this.ctx.logger.warn("searchObjectEvents: classFilter matched nothing", {
|
|
70942
|
+
...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
|
|
70943
|
+
meta: {
|
|
70944
|
+
classFilter: input.classFilter,
|
|
70945
|
+
why: "the index's className column carries the CLIP step's output class, not the track's class — only that value can match"
|
|
70946
|
+
}
|
|
70947
|
+
});
|
|
70948
|
+
return [];
|
|
70949
|
+
}
|
|
68817
70950
|
const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
|
|
68818
70951
|
const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
68819
70952
|
for (const { row } of ranked) {
|
|
@@ -68879,15 +71012,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
68879
71012
|
const mediaStore = this.mediaStore;
|
|
68880
71013
|
const trackStore = this.trackStore;
|
|
68881
71014
|
if (!eventStore || !mediaStore || !trackStore) return null;
|
|
71015
|
+
const stores = {
|
|
71016
|
+
eventStore,
|
|
71017
|
+
mediaStore,
|
|
71018
|
+
faceStore: this.faceStore,
|
|
71019
|
+
plateStore: this.plateStore,
|
|
71020
|
+
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
71021
|
+
groupStore: this.groupStore
|
|
71022
|
+
};
|
|
71023
|
+
const absent = absentCascadeLegs(stores);
|
|
71024
|
+
if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
|
|
71025
|
+
absent,
|
|
71026
|
+
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"
|
|
71027
|
+
} });
|
|
68882
71028
|
return {
|
|
68883
|
-
registry: buildAnalyticsCascadeRegistry(
|
|
68884
|
-
eventStore,
|
|
68885
|
-
mediaStore,
|
|
68886
|
-
faceStore: this.faceStore,
|
|
68887
|
-
plateStore: this.plateStore,
|
|
68888
|
-
objectEmbeddingStore: this.objectEmbeddingStore,
|
|
68889
|
-
groupStore: this.groupStore
|
|
68890
|
-
}),
|
|
71029
|
+
registry: buildAnalyticsCascadeRegistry(stores),
|
|
68891
71030
|
trackStore
|
|
68892
71031
|
};
|
|
68893
71032
|
}
|
|
@@ -69042,6 +71181,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
69042
71181
|
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
69043
71182
|
return this.mediaRelocate.countUnstamped();
|
|
69044
71183
|
}
|
|
71184
|
+
/**
|
|
71185
|
+
* How many rows a pass would still act on against `toLocationId`.
|
|
71186
|
+
*
|
|
71187
|
+
* The media lane's denominator AND its residue, from the one predicate the
|
|
71188
|
+
* pass itself uses. `null` means the count could not be taken — never `0`,
|
|
71189
|
+
* which would read as "there is nothing left to move".
|
|
71190
|
+
*/
|
|
71191
|
+
async countRelocatableMedia(input) {
|
|
71192
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
71193
|
+
return this.mediaRelocate.countSelectable(input);
|
|
71194
|
+
}
|
|
69045
71195
|
/** Every relocate job this addon knows about, newest first (in RAM: the
|
|
69046
71196
|
* move is resumable, so a lost list costs nothing but the display). */
|
|
69047
71197
|
async listRelocateMediaJobs() {
|
|
@@ -69450,6 +71600,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
69450
71600
|
this.sensitivitySettingsCache.delete(input.deviceId);
|
|
69451
71601
|
this.trackingSettingsCache.delete(input.deviceId);
|
|
69452
71602
|
this.confirmationGateSettingsCache.delete(input.deviceId);
|
|
71603
|
+
this.mediaSettingsCache.delete(input.deviceId);
|
|
69453
71604
|
this.forgetDeviceProcessors(input.deviceId);
|
|
69454
71605
|
return { success: true };
|
|
69455
71606
|
}
|
|
@@ -69483,6 +71634,7 @@ function withMean(row) {
|
|
|
69483
71634
|
};
|
|
69484
71635
|
}
|
|
69485
71636
|
//#endregion
|
|
71637
|
+
exports.CONFIRMATION_CROP_MAX_WIDTH = CONFIRMATION_CROP_MAX_WIDTH;
|
|
69486
71638
|
exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
|
|
69487
71639
|
exports.customActions = customActions;
|
|
69488
71640
|
exports.default = PipelineAnalyticsAddon;
|