@camstack/addon-post-analysis 1.2.151 → 1.2.153
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-B5K63Zmt.mjs → dist-DDIu0Q1W.mjs} +381 -194
- package/dist/{dist-DpQno5NK.js → dist-DeMgxNFC.js} +386 -193
- 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-bK-ELdFD.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cil5ur25.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-CG8y8a6j.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-CgUMPlDe.mjs → hostInit-I37PqSoI.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1055 -527
- package/dist/pipeline-analytics/index.mjs +1054 -526
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D8CtX8cU.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DDIu0Q1W.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -5406,12 +5406,6 @@ function buildAlarmButtons(input) {
|
|
|
5406
5406
|
}];
|
|
5407
5407
|
}
|
|
5408
5408
|
//#endregion
|
|
5409
|
-
//#region src/notification-center/cap-caller.ts
|
|
5410
|
-
function requireCapCaller(method, caller) {
|
|
5411
|
-
if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
|
|
5412
|
-
return caller;
|
|
5413
|
-
}
|
|
5414
|
-
//#endregion
|
|
5415
5409
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
5416
5410
|
/**
|
|
5417
5411
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -5654,6 +5648,12 @@ var AudioWatcher = class {
|
|
|
5654
5648
|
}
|
|
5655
5649
|
};
|
|
5656
5650
|
//#endregion
|
|
5651
|
+
//#region src/notification-center/cap-caller.ts
|
|
5652
|
+
function requireCapCaller(method, caller) {
|
|
5653
|
+
if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
|
|
5654
|
+
return caller;
|
|
5655
|
+
}
|
|
5656
|
+
//#endregion
|
|
5657
5657
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5658
5658
|
/**
|
|
5659
5659
|
* What a PIPELINE VALUE is allowed to look like once it is inside a prompt.
|
|
@@ -6288,25 +6288,46 @@ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
|
|
|
6288
6288
|
/** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
|
|
6289
6289
|
var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
|
|
6290
6290
|
/**
|
|
6291
|
-
* Kinds whose action is "go and look at
|
|
6291
|
+
* Kinds whose action is "go and look at this device's stream".
|
|
6292
|
+
*
|
|
6293
|
+
* BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
|
|
6294
|
+
* because "did it really come back, and what did it do while it was gone" is
|
|
6295
|
+
* an action and the page that answers it is the one the down-notification
|
|
6296
|
+
* already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
|
|
6297
|
+
* Videocamera sala feste è online" with `tapAction.value: null` — the same
|
|
6298
|
+
* camera's `device-offline` an hour earlier opened the Stream Broker tab, and
|
|
6299
|
+
* the recovery opened nothing, purely because `device-online` had no entry
|
|
6300
|
+
* here.
|
|
6292
6301
|
*
|
|
6293
|
-
* The legacy `camera
|
|
6294
|
-
* enum: outbox and history rows enqueued before the rename carry
|
|
6302
|
+
* The legacy `camera-*` pair is here for the same reason both are still in the
|
|
6303
|
+
* enum: outbox and history rows enqueued before the rename carry them, and an
|
|
6295
6304
|
* old row is exactly the one whose action has to keep working.
|
|
6305
|
+
*
|
|
6306
|
+
* Kinds NOT here are kinds whose DOWN direction has no action either
|
|
6307
|
+
* (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
|
|
6308
|
+
* recovery does not earn a destination its failure was never given.
|
|
6296
6309
|
*/
|
|
6297
|
-
var
|
|
6310
|
+
var DEVICE_STREAM_KINDS = new Set([
|
|
6298
6311
|
"device-offline",
|
|
6312
|
+
"device-online",
|
|
6299
6313
|
"stream-offline",
|
|
6300
|
-
"
|
|
6314
|
+
"stream-online",
|
|
6315
|
+
"camera-offline",
|
|
6316
|
+
"camera-online"
|
|
6301
6317
|
]);
|
|
6302
6318
|
/**
|
|
6303
6319
|
* Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
|
|
6304
6320
|
*
|
|
6305
|
-
* A stream belongs to a camera and nothing else. The legacy `camera
|
|
6306
|
-
*
|
|
6307
|
-
*
|
|
6321
|
+
* A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
|
|
6322
|
+
* too — that name is precisely the claim it made, and it is why the kind was
|
|
6323
|
+
* split into `device-*` + a `deviceTypes` condition.
|
|
6308
6324
|
*/
|
|
6309
|
-
var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
|
|
6325
|
+
var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
|
|
6326
|
+
"stream-offline",
|
|
6327
|
+
"stream-online",
|
|
6328
|
+
"camera-offline",
|
|
6329
|
+
"camera-online"
|
|
6330
|
+
]);
|
|
6310
6331
|
function withParam(path, key, value) {
|
|
6311
6332
|
if (value === void 0 || value.length === 0) return path;
|
|
6312
6333
|
return `${path}?${key}=${encodeURIComponent(value)}`;
|
|
@@ -6321,7 +6342,7 @@ function withParam(path, key, value) {
|
|
|
6321
6342
|
function systemEventTapPath(subject) {
|
|
6322
6343
|
if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
|
|
6323
6344
|
if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
|
|
6324
|
-
if (
|
|
6345
|
+
if (DEVICE_STREAM_KINDS.has(subject.kind)) {
|
|
6325
6346
|
const deviceId = subject.deviceId;
|
|
6326
6347
|
if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
|
|
6327
6348
|
const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
|
|
@@ -10723,16 +10744,26 @@ function incomingFromUpdateAvailable(facts, data) {
|
|
|
10723
10744
|
...nodeIds.length > 0 ? { nodeIds } : {}
|
|
10724
10745
|
});
|
|
10725
10746
|
}
|
|
10726
|
-
/**
|
|
10747
|
+
/**
|
|
10748
|
+
* Version-bearing `addon.updated` only — a settings save carries no versions
|
|
10749
|
+
* at all (`BaseAddon.updateGlobalSettings`).
|
|
10750
|
+
*
|
|
10751
|
+
* `toVersion` IS the discriminator, and `fromVersion` is not: the emitter
|
|
10752
|
+
* omits the predecessor when it cannot read what was installed (a first
|
|
10753
|
+
* install, an unreadable tree). Requiring both dropped those updates on the
|
|
10754
|
+
* floor entirely — the operator got no notification for an update that
|
|
10755
|
+
* happened — which is the price that was being paid to keep `'0.0.0'`
|
|
10756
|
+
* out. Report the half that is known; say nothing about the half that is not.
|
|
10757
|
+
*/
|
|
10727
10758
|
function parseAddonUpdateComplete(data) {
|
|
10728
10759
|
const packageName = stringField(data, "packageName");
|
|
10729
10760
|
const fromVersion = stringField(data, "fromVersion");
|
|
10730
10761
|
const toVersion = stringField(data, "toVersion");
|
|
10731
|
-
if (packageName === void 0 ||
|
|
10762
|
+
if (packageName === void 0 || toVersion === void 0) return null;
|
|
10732
10763
|
const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
|
|
10733
10764
|
return {
|
|
10734
10765
|
packageName,
|
|
10735
|
-
fromVersion,
|
|
10766
|
+
...fromVersion !== void 0 ? { fromVersion } : {},
|
|
10736
10767
|
toVersion,
|
|
10737
10768
|
...nodeId !== void 0 ? { nodeId } : {}
|
|
10738
10769
|
};
|
|
@@ -10742,7 +10773,10 @@ function incomingFromAddonUpdates(facts, updates) {
|
|
|
10742
10773
|
const packageNames = unique(updates.map((update) => update.packageName));
|
|
10743
10774
|
const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
|
|
10744
10775
|
const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
|
|
10745
|
-
const list = updates.map((update) =>
|
|
10776
|
+
const list = updates.map((update) => {
|
|
10777
|
+
const transition = update.fromVersion !== void 0 ? `${update.fromVersion} → ${update.toVersion}` : `→ ${update.toVersion}`;
|
|
10778
|
+
return update.nodeId !== void 0 ? `${update.packageName} ${transition} (${update.nodeId})` : `${update.packageName} ${transition}`;
|
|
10779
|
+
}).join("\n");
|
|
10746
10780
|
const head = updates[0];
|
|
10747
10781
|
return systemSubject({
|
|
10748
10782
|
facts,
|
|
@@ -23564,17 +23598,17 @@ function planRuleTest(input) {
|
|
|
23564
23598
|
deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
|
|
23565
23599
|
nodeId: systemScope.nodeId ?? "hub",
|
|
23566
23600
|
packageName: systemScope.packageName ?? "@camstack/server",
|
|
23567
|
-
currentVersion: "
|
|
23568
|
-
latestVersion: "
|
|
23601
|
+
currentVersion: "1.0.0",
|
|
23602
|
+
latestVersion: "1.0.1",
|
|
23569
23603
|
profile: "low",
|
|
23570
23604
|
seconds: "30",
|
|
23571
23605
|
devices: "cuda:0",
|
|
23572
23606
|
minutes: "15",
|
|
23573
23607
|
addons: TEST_CRASHED_RUNNER,
|
|
23574
|
-
list: "@camstack/addon-pipeline 1.0.
|
|
23608
|
+
list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
|
|
23575
23609
|
nodes: "hub",
|
|
23576
|
-
fromVersion: "
|
|
23577
|
-
toVersion: "
|
|
23610
|
+
fromVersion: "1.0.0",
|
|
23611
|
+
toVersion: "1.0.1",
|
|
23578
23612
|
title: "Export",
|
|
23579
23613
|
count: "1"
|
|
23580
23614
|
},
|
|
@@ -25638,6 +25672,67 @@ var MEDIA_INDEXES = [
|
|
|
25638
25672
|
{
|
|
25639
25673
|
name: "idx_media_location",
|
|
25640
25674
|
columns: ["locationId"]
|
|
25675
|
+
}),
|
|
25676
|
+
(
|
|
25677
|
+
/**
|
|
25678
|
+
* The events-management footprint — `GROUP BY deviceId` and
|
|
25679
|
+
* `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
|
|
25680
|
+
*
|
|
25681
|
+
* **Why not `idx_media_device_ts`.** That index already leads with
|
|
25682
|
+
* `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
|
|
25683
|
+
* so the sum has to fetch the table row behind every index entry it visits.
|
|
25684
|
+
* Over the whole collection that is one scattered read of the entire ~1.25 M
|
|
25685
|
+
* row table (`path` alone is ~80 characters per row) on the volume the
|
|
25686
|
+
* recorder is writing to. With `sizeBytes` in the index the planner never
|
|
25687
|
+
* touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
|
|
25688
|
+
*
|
|
25689
|
+
* It has to be a NEW index rather than a third column on the existing one.
|
|
25690
|
+
* `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
|
|
25691
|
+
* NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
|
|
25692
|
+
* every hub that already has it — the declaration would read as done and the
|
|
25693
|
+
* query would still hit the table.
|
|
25694
|
+
*
|
|
25695
|
+
* **What it costs.** ~19 bytes of index per row (two integers plus the
|
|
25696
|
+
* rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
|
|
25697
|
+
* insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
|
|
25698
|
+
* is noise beside the JPEG write the same call performs. The cost that is not
|
|
25699
|
+
* noise is the one-off: the first `declareCollection` after this ships builds
|
|
25700
|
+
* the index over every existing row. Do not deploy it beside a running drain
|
|
25701
|
+
* or seal — the same caveat `idx_media_location` carries, for the same
|
|
25702
|
+
* reason.
|
|
25703
|
+
*/
|
|
25704
|
+
{
|
|
25705
|
+
name: "idx_media_device_size",
|
|
25706
|
+
columns: ["deviceId", "sizeBytes"]
|
|
25707
|
+
}),
|
|
25708
|
+
(
|
|
25709
|
+
/**
|
|
25710
|
+
* The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
|
|
25711
|
+
* the same scoped to one camera. See {@link MediaStore.footprintByKind}.
|
|
25712
|
+
*
|
|
25713
|
+
* `kind` leads no existing index, so without this each of the thirteen
|
|
25714
|
+
* per-kind questions is a full table scan and the breakdown costs thirteen of
|
|
25715
|
+
* them. With it, each kind is a contiguous covering range and the thirteen
|
|
25716
|
+
* together scan the index once — the cost of the whole breakdown is one pass
|
|
25717
|
+
* over ~36 MB of index instead of thirteen passes over the table.
|
|
25718
|
+
*
|
|
25719
|
+
* `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
|
|
25720
|
+
* (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
|
|
25721
|
+
* more than `(kind, sizeBytes)` would cost. Without it that query finds its
|
|
25722
|
+
* range from the index and then reads the table for every row in it.
|
|
25723
|
+
*
|
|
25724
|
+
* ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
|
|
25725
|
+
* on every insert. Same one-off caveat as the two indexes above: the first
|
|
25726
|
+
* declare builds it over every existing row, so do not ship it beside a
|
|
25727
|
+
* running drain or seal.
|
|
25728
|
+
*/
|
|
25729
|
+
{
|
|
25730
|
+
name: "idx_media_kind_device_size",
|
|
25731
|
+
columns: [
|
|
25732
|
+
"kind",
|
|
25733
|
+
"deviceId",
|
|
25734
|
+
"sizeBytes"
|
|
25735
|
+
]
|
|
25641
25736
|
})
|
|
25642
25737
|
];
|
|
25643
25738
|
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
@@ -25724,7 +25819,7 @@ var MediaStore = class {
|
|
|
25724
25819
|
});
|
|
25725
25820
|
}
|
|
25726
25821
|
/** Persist a media blob and register its metadata row. Returns the
|
|
25727
|
-
* collection key (
|
|
25822
|
+
* collection key (MediaFileRef.key) that callers store as reference. */
|
|
25728
25823
|
async put(params) {
|
|
25729
25824
|
if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
|
|
25730
25825
|
return this.putUnchecked(params);
|
|
@@ -26046,8 +26141,20 @@ var MediaStore = class {
|
|
|
26046
26141
|
* its device at write time, so scoping the query by it makes a media read
|
|
26047
26142
|
* for a device the caller cannot name return nothing — the DB itself
|
|
26048
26143
|
* enforces the boundary, no post-hoc ownership check to forget.
|
|
26144
|
+
*
|
|
26145
|
+
* `kinds` is the WORK filter, and it belongs here rather than on the answer.
|
|
26146
|
+
* Every row this returns has had its blob read off disk and base64-encoded,
|
|
26147
|
+
* so a caller that filters afterwards has already paid for every kind it is
|
|
26148
|
+
* about to discard: measured on the live hub, one track's six kinds are
|
|
26149
|
+
* 3 597 219 bytes and `thumbnailSmall` is 31 155 of them, and asking for the
|
|
26150
|
+
* small one alone cost exactly as much as asking for all six. `kind` is a
|
|
26151
|
+
* real column (see {@link MEDIA_COLUMNS}), so the narrowing happens in the
|
|
26152
|
+
* query and the blobs of the other kinds are never opened.
|
|
26153
|
+
*
|
|
26154
|
+
* Absent (or empty) means every kind — the back-compat default, and the
|
|
26155
|
+
* honest answer for a caller that has not decided what it needs.
|
|
26049
26156
|
*/
|
|
26050
|
-
async listByOwner(ownerKind, ownerId, deviceId) {
|
|
26157
|
+
async listByOwner(ownerKind, ownerId, deviceId, kinds) {
|
|
26051
26158
|
const rows = await this.store.query.query({
|
|
26052
26159
|
collection: MEDIA_COLLECTION,
|
|
26053
26160
|
filter: {
|
|
@@ -26059,6 +26166,7 @@ var MediaStore = class {
|
|
|
26059
26166
|
ownerId,
|
|
26060
26167
|
deviceId
|
|
26061
26168
|
},
|
|
26169
|
+
...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
|
|
26062
26170
|
orderBy: {
|
|
26063
26171
|
field: "timestamp",
|
|
26064
26172
|
direction: "asc"
|
|
@@ -26279,47 +26387,136 @@ var MediaStore = class {
|
|
|
26279
26387
|
};
|
|
26280
26388
|
}
|
|
26281
26389
|
/**
|
|
26282
|
-
* On-demand footprint aggregation for the events-management UI:
|
|
26283
|
-
* `sizeBytes`
|
|
26284
|
-
*
|
|
26285
|
-
*
|
|
26286
|
-
*
|
|
26390
|
+
* On-demand footprint aggregation for the events-management UI: rows and
|
|
26391
|
+
* summed `sizeBytes` per camera, across the whole media collection.
|
|
26392
|
+
*
|
|
26393
|
+
* ## Why this is a histogram plus one aggregate per camera
|
|
26394
|
+
*
|
|
26395
|
+
* It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
|
|
26396
|
+
* no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
|
|
26397
|
+
* stepping over `n` entries, so the walk is quadratic in the table: at the
|
|
26398
|
+
* live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
|
|
26399
|
+
* with every row serialised across an RPC boundary and rebuilt in V8 to be
|
|
26400
|
+
* added to a number. It timed out at 60 s on every call it ever made, and it
|
|
26401
|
+
* is the only thing that can size the surplus-media deletion it gates.
|
|
26402
|
+
*
|
|
26403
|
+
* The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
|
|
26404
|
+
*
|
|
26405
|
+
* - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
|
|
26406
|
+
* — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
|
|
26407
|
+
* scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
|
|
26408
|
+
* `bucket` IS the deviceId: the column is INTEGER and the bucket
|
|
26409
|
+
* expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
|
|
26410
|
+
* - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
|
|
26411
|
+
* `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
|
|
26412
|
+
*
|
|
26413
|
+
* Both figures for a camera come from the SAME statement deliberately. Taking
|
|
26414
|
+
* `rows` from the histogram and `bytes` from the aggregate would let a write
|
|
26415
|
+
* landing between the two produce a footprint whose row count and byte count
|
|
26416
|
+
* describe different populations — a disagreement an operator sizing a
|
|
26417
|
+
* deletion has no way to detect.
|
|
26418
|
+
*
|
|
26419
|
+
* A read that fails THROWS. It must never degrade to a zero: "this camera
|
|
26420
|
+
* holds nothing" is precisely the claim a failed measurement must not be
|
|
26421
|
+
* allowed to make to something about to delete (D295, D312).
|
|
26287
26422
|
*/
|
|
26288
26423
|
async footprintByDevice() {
|
|
26289
|
-
const
|
|
26424
|
+
const buckets = await this.store.histogram.query({
|
|
26425
|
+
collection: MEDIA_COLLECTION,
|
|
26426
|
+
field: "deviceId",
|
|
26427
|
+
bucketSize: 1,
|
|
26428
|
+
origin: 0
|
|
26429
|
+
});
|
|
26290
26430
|
const out = /* @__PURE__ */ new Map();
|
|
26291
|
-
|
|
26292
|
-
|
|
26293
|
-
const
|
|
26431
|
+
for (const bucket of buckets) {
|
|
26432
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
26433
|
+
const deviceId = bucket.bucket;
|
|
26434
|
+
const agg = await this.store.aggregate.query({
|
|
26294
26435
|
collection: MEDIA_COLLECTION,
|
|
26295
|
-
|
|
26296
|
-
|
|
26297
|
-
|
|
26298
|
-
|
|
26299
|
-
|
|
26300
|
-
|
|
26301
|
-
|
|
26302
|
-
|
|
26436
|
+
fields: [{
|
|
26437
|
+
as: "bytes",
|
|
26438
|
+
field: "sizeBytes",
|
|
26439
|
+
op: "sum"
|
|
26440
|
+
}],
|
|
26441
|
+
filter: { where: { deviceId } }
|
|
26442
|
+
});
|
|
26443
|
+
if (agg.count === 0) continue;
|
|
26444
|
+
const bytes = agg.values["bytes"];
|
|
26445
|
+
out.set(deviceId, {
|
|
26446
|
+
bytes: bytes ?? 0,
|
|
26447
|
+
rows: agg.count
|
|
26303
26448
|
});
|
|
26304
|
-
if (rows.length === 0) break;
|
|
26305
|
-
for (const row of rows) {
|
|
26306
|
-
const data = row.data;
|
|
26307
|
-
const deviceId = Number(data["deviceId"]);
|
|
26308
|
-
const sizeBytes = Number(data["sizeBytes"]);
|
|
26309
|
-
if (!Number.isFinite(deviceId)) continue;
|
|
26310
|
-
const acc = out.get(deviceId) ?? {
|
|
26311
|
-
bytes: 0,
|
|
26312
|
-
rows: 0
|
|
26313
|
-
};
|
|
26314
|
-
acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
|
|
26315
|
-
acc.rows += 1;
|
|
26316
|
-
out.set(deviceId, acc);
|
|
26317
|
-
}
|
|
26318
|
-
if (rows.length < PAGE) break;
|
|
26319
|
-
offset += PAGE;
|
|
26320
26449
|
}
|
|
26321
26450
|
return out;
|
|
26322
26451
|
}
|
|
26452
|
+
/**
|
|
26453
|
+
* The media footprint broken down by KIND — fleet-wide, or for one camera.
|
|
26454
|
+
*
|
|
26455
|
+
* `footprintByDevice` answers "how much is where"; this answers "how much is
|
|
26456
|
+
* WHAT", which is the axis a deletion decision turns on. The deletable set
|
|
26457
|
+
* (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
|
|
26458
|
+
* the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
|
|
26459
|
+
* `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
|
|
26460
|
+
* gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
|
|
26461
|
+
* else. A byte total cannot size a deletion; this can.
|
|
26462
|
+
*
|
|
26463
|
+
* One `aggregate` per kind, each an indexed covering range on
|
|
26464
|
+
* `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
|
|
26465
|
+
*
|
|
26466
|
+
* That last statement is the point of the design and not a spare round trip.
|
|
26467
|
+
* `MediaFileKindEnum.options` is the closed set the writers use, but the
|
|
26468
|
+
* TABLE is not closed: a row written by a retired path, or by a version that
|
|
26469
|
+
* knew a kind this one does not, matches no per-kind query. Adding the kinds
|
|
26470
|
+
* up to produce the total would make such a row invisible and the denominator
|
|
26471
|
+
* quietly too small — the failure mode that matters most here, because the
|
|
26472
|
+
* caller is about to delete against it. So the total is measured
|
|
26473
|
+
* independently and the difference is REPORTED as `unaccounted*` rather than
|
|
26474
|
+
* being absorbed.
|
|
26475
|
+
*/
|
|
26476
|
+
async footprintByKind(deviceId) {
|
|
26477
|
+
const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
|
|
26478
|
+
const fields = [{
|
|
26479
|
+
as: "bytes",
|
|
26480
|
+
field: "sizeBytes",
|
|
26481
|
+
op: "sum"
|
|
26482
|
+
}];
|
|
26483
|
+
const totals = await this.store.aggregate.query({
|
|
26484
|
+
collection: MEDIA_COLLECTION,
|
|
26485
|
+
fields: [...fields],
|
|
26486
|
+
...scope === void 0 ? {} : { filter: scope }
|
|
26487
|
+
});
|
|
26488
|
+
const kinds = [];
|
|
26489
|
+
let countedRows = 0;
|
|
26490
|
+
let countedBytes = 0;
|
|
26491
|
+
for (const kind of MediaFileKindEnum.options) {
|
|
26492
|
+
const agg = await this.store.aggregate.query({
|
|
26493
|
+
collection: MEDIA_COLLECTION,
|
|
26494
|
+
fields: [...fields],
|
|
26495
|
+
filter: { where: {
|
|
26496
|
+
kind,
|
|
26497
|
+
...deviceId === void 0 ? {} : { deviceId }
|
|
26498
|
+
} }
|
|
26499
|
+
});
|
|
26500
|
+
if (agg.count === 0) continue;
|
|
26501
|
+
const bytes = agg.values["bytes"] ?? 0;
|
|
26502
|
+
countedRows += agg.count;
|
|
26503
|
+
countedBytes += bytes;
|
|
26504
|
+
kinds.push({
|
|
26505
|
+
kind,
|
|
26506
|
+
rows: agg.count,
|
|
26507
|
+
bytes
|
|
26508
|
+
});
|
|
26509
|
+
}
|
|
26510
|
+
kinds.sort((a, b) => b.bytes - a.bytes);
|
|
26511
|
+
const totalBytes = totals.values["bytes"] ?? 0;
|
|
26512
|
+
return {
|
|
26513
|
+
totalRows: totals.count,
|
|
26514
|
+
totalBytes,
|
|
26515
|
+
kinds,
|
|
26516
|
+
unaccountedRows: totals.count - countedRows,
|
|
26517
|
+
unaccountedBytes: totalBytes - countedBytes
|
|
26518
|
+
};
|
|
26519
|
+
}
|
|
26323
26520
|
/** Move a single media entry from one owner to another.
|
|
26324
26521
|
* The new copy is written before the old one is removed.
|
|
26325
26522
|
* The old-entry deletion is best-effort: on failure a warning is logged
|
|
@@ -27974,6 +28171,63 @@ var embeddingActions = defineCustomActions({
|
|
|
27974
28171
|
"embedding.reembedFacesStatus": customAction(NoInputSchema$1, ReembedFacesStatusSchema)
|
|
27975
28172
|
});
|
|
27976
28173
|
//#endregion
|
|
28174
|
+
//#region src/pipeline-analytics/media-file-url.ts
|
|
28175
|
+
/**
|
|
28176
|
+
* The plane path for one stored media key.
|
|
28177
|
+
*
|
|
28178
|
+
* `prefix` is `/addon/<addonId>/event-media` — a pure function of the addon
|
|
28179
|
+
* id, so it exists whether or not `dataPlane.serve` succeeded. A row that
|
|
28180
|
+
* named no address at all would be the silent drop this repo keeps paying for:
|
|
28181
|
+
* a client cannot tell "no bytes" from "no plane", and renders neither.
|
|
28182
|
+
*/
|
|
28183
|
+
function mediaFileUrl(prefix, key) {
|
|
28184
|
+
return `${prefix}/${encodeURIComponent(key)}`;
|
|
28185
|
+
}
|
|
28186
|
+
/**
|
|
28187
|
+
* A stored row as the wire sees it: the ref, the address, and — transitionally
|
|
28188
|
+
* — the bytes.
|
|
28189
|
+
*
|
|
28190
|
+
* `base64` is copied through ONLY because the deployed viewer's track-detail
|
|
28191
|
+
* hero still requires the field to parse a row at all. See the `MediaFile`
|
|
28192
|
+
* docblock in `pipeline-analytics.cap.ts`; deleting the field is a one-line
|
|
28193
|
+
* change here and one in the schema.
|
|
28194
|
+
*
|
|
28195
|
+
* `provenance` is deliberately NOT spread: it is store-internal, and the
|
|
28196
|
+
* previous `{ ...row, kind }` shape published it by accident.
|
|
28197
|
+
*/
|
|
28198
|
+
function toWireMediaFile(row, prefix) {
|
|
28199
|
+
return {
|
|
28200
|
+
key: row.key,
|
|
28201
|
+
kind: row.kind,
|
|
28202
|
+
sizeBytes: row.sizeBytes,
|
|
28203
|
+
timestamp: row.timestamp,
|
|
28204
|
+
url: mediaFileUrl(prefix, row.key),
|
|
28205
|
+
base64: row.base64
|
|
28206
|
+
};
|
|
28207
|
+
}
|
|
28208
|
+
/** The same row without its bytes — the manifest shape. Carries the SAME
|
|
28209
|
+
* `url` as the byte read, so a client never builds the plane path itself. */
|
|
28210
|
+
function toWireMediaFileInfo(row, prefix) {
|
|
28211
|
+
return {
|
|
28212
|
+
key: row.key,
|
|
28213
|
+
kind: row.kind,
|
|
28214
|
+
sizeBytes: row.sizeBytes,
|
|
28215
|
+
timestamp: row.timestamp,
|
|
28216
|
+
url: mediaFileUrl(prefix, row.key)
|
|
28217
|
+
};
|
|
28218
|
+
}
|
|
28219
|
+
//#endregion
|
|
28220
|
+
//#region src/pipeline-analytics/gallery-media-row.ts
|
|
28221
|
+
function toGalleryMediaRow(row, prefix) {
|
|
28222
|
+
return {
|
|
28223
|
+
key: row.key,
|
|
28224
|
+
kind: row.kind,
|
|
28225
|
+
url: mediaFileUrl(prefix, row.key),
|
|
28226
|
+
sizeBytes: row.sizeBytes,
|
|
28227
|
+
timestamp: row.timestamp
|
|
28228
|
+
};
|
|
28229
|
+
}
|
|
28230
|
+
//#endregion
|
|
27977
28231
|
//#region src/pipeline-analytics/include-crops-default.ts
|
|
27978
28232
|
function resolveIncludeCrops(input) {
|
|
27979
28233
|
return input?.includeCrops === true;
|
|
@@ -29633,6 +29887,7 @@ var FaceGalleryProvider = class {
|
|
|
29633
29887
|
logger;
|
|
29634
29888
|
refreshGallery;
|
|
29635
29889
|
eventMediaBaseUrl;
|
|
29890
|
+
eventMediaPathPrefix;
|
|
29636
29891
|
emitFaceGalleryChanged;
|
|
29637
29892
|
constructor(deps) {
|
|
29638
29893
|
this.identityStore = deps.identityStore;
|
|
@@ -29643,6 +29898,7 @@ var FaceGalleryProvider = class {
|
|
|
29643
29898
|
this.logger = deps.logger;
|
|
29644
29899
|
this.refreshGallery = deps.refreshGallery;
|
|
29645
29900
|
this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
|
|
29901
|
+
this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
|
|
29646
29902
|
this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
|
|
29647
29903
|
}
|
|
29648
29904
|
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
@@ -29830,13 +30086,7 @@ var FaceGalleryProvider = class {
|
|
|
29830
30086
|
* the enrolled identity copies — those live under identity owner).
|
|
29831
30087
|
*/
|
|
29832
30088
|
async getFaceMedia(input) {
|
|
29833
|
-
return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => (
|
|
29834
|
-
key: m.key,
|
|
29835
|
-
kind: m.kind,
|
|
29836
|
-
base64: m.base64,
|
|
29837
|
-
sizeBytes: m.sizeBytes,
|
|
29838
|
-
timestamp: m.timestamp
|
|
29839
|
-
}));
|
|
30089
|
+
return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
|
|
29840
30090
|
}
|
|
29841
30091
|
/**
|
|
29842
30092
|
* Assign a buffered face to an identity:
|
|
@@ -32547,50 +32797,136 @@ var FrameProcessor = class {
|
|
|
32547
32797
|
};
|
|
32548
32798
|
}
|
|
32549
32799
|
};
|
|
32550
|
-
|
|
32551
|
-
|
|
32800
|
+
/** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
|
|
32801
|
+
* evicts the oldest record. */
|
|
32802
|
+
var PendingPreviewUpgrades = class {
|
|
32803
|
+
entries = /* @__PURE__ */ new Map();
|
|
32804
|
+
ttlMs;
|
|
32805
|
+
maxEntries;
|
|
32806
|
+
now;
|
|
32807
|
+
constructor(options = {}) {
|
|
32808
|
+
this.ttlMs = options.ttlMs ?? 9e5;
|
|
32809
|
+
this.maxEntries = Math.max(1, options.maxEntries ?? 512);
|
|
32810
|
+
this.now = options.now ?? (() => Date.now());
|
|
32811
|
+
}
|
|
32812
|
+
/** Record (or replace) a closed track's pending upgrade. Prunes expired
|
|
32813
|
+
* entries first, then evicts the oldest while over the cap. */
|
|
32814
|
+
record(entry) {
|
|
32815
|
+
this.prune();
|
|
32816
|
+
this.entries.delete(entry.trackId);
|
|
32817
|
+
this.entries.set(entry.trackId, entry);
|
|
32818
|
+
while (this.entries.size > this.maxEntries) {
|
|
32819
|
+
const oldest = this.entries.keys().next();
|
|
32820
|
+
if (oldest.done === true) break;
|
|
32821
|
+
this.entries.delete(oldest.value);
|
|
32822
|
+
}
|
|
32823
|
+
}
|
|
32824
|
+
/** Claim a track's pending upgrade — removes it, so a second landing (or a
|
|
32825
|
+
* concurrent racer) finds nothing and the tile is rewritten at most once. */
|
|
32826
|
+
take(trackId) {
|
|
32827
|
+
this.prune();
|
|
32828
|
+
const entry = this.entries.get(trackId);
|
|
32829
|
+
if (entry === void 0) return void 0;
|
|
32830
|
+
this.entries.delete(trackId);
|
|
32831
|
+
return entry;
|
|
32832
|
+
}
|
|
32833
|
+
get size() {
|
|
32834
|
+
return this.entries.size;
|
|
32835
|
+
}
|
|
32836
|
+
prune() {
|
|
32837
|
+
const cutoff = this.now() - this.ttlMs;
|
|
32838
|
+
for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
|
|
32839
|
+
}
|
|
32840
|
+
};
|
|
32552
32841
|
/**
|
|
32553
|
-
*
|
|
32554
|
-
*
|
|
32555
|
-
*
|
|
32556
|
-
* No `TrackStore`, no bus, no media — a track here is a summary built
|
|
32557
|
-
* ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
|
|
32842
|
+
* Redeem a closed track's pending preview off a keyFrame that landed late.
|
|
32843
|
+
* Every non-`upgraded` outcome that had a pending entry logs itself — a
|
|
32844
|
+
* branch that leaves the stand-in in place must say so.
|
|
32558
32845
|
*/
|
|
32559
|
-
function
|
|
32560
|
-
const
|
|
32561
|
-
|
|
32562
|
-
if (
|
|
32563
|
-
|
|
32564
|
-
|
|
32565
|
-
|
|
32566
|
-
|
|
32567
|
-
|
|
32568
|
-
frame
|
|
32846
|
+
async function runPreviewUpgrade(deps, input) {
|
|
32847
|
+
const { deviceId, trackId } = input;
|
|
32848
|
+
const pending = deps.takePending(trackId);
|
|
32849
|
+
if (pending === void 0) return "no-pending";
|
|
32850
|
+
const held = await deps.heldThumbnailProvenance(trackId);
|
|
32851
|
+
if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
|
|
32852
|
+
deps.logger.info("late keyFrame: preview already native — no upgrade", {
|
|
32853
|
+
tags: { deviceId },
|
|
32854
|
+
meta: { trackId }
|
|
32569
32855
|
});
|
|
32570
|
-
|
|
32571
|
-
|
|
32572
|
-
|
|
32573
|
-
|
|
32574
|
-
|
|
32575
|
-
|
|
32576
|
-
|
|
32577
|
-
|
|
32578
|
-
framesMatched: matched
|
|
32579
|
-
});
|
|
32580
|
-
else {
|
|
32581
|
-
existing.className = td.className;
|
|
32582
|
-
existing.lastSeenMs = timestamp;
|
|
32583
|
-
existing.framesMatched += matched;
|
|
32856
|
+
return "held-native";
|
|
32857
|
+
}
|
|
32858
|
+
if (pending.bestSeen === void 0) {
|
|
32859
|
+
deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
|
|
32860
|
+
tags: { deviceId },
|
|
32861
|
+
meta: {
|
|
32862
|
+
trackId,
|
|
32863
|
+
heldProvenance: held
|
|
32584
32864
|
}
|
|
32585
|
-
}
|
|
32865
|
+
});
|
|
32866
|
+
return "no-bbox";
|
|
32586
32867
|
}
|
|
32587
|
-
|
|
32588
|
-
|
|
32589
|
-
|
|
32590
|
-
|
|
32591
|
-
|
|
32592
|
-
|
|
32593
|
-
|
|
32868
|
+
if (pending.bestSeen.timestamp === void 0) {
|
|
32869
|
+
deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
|
|
32870
|
+
tags: { deviceId },
|
|
32871
|
+
meta: {
|
|
32872
|
+
trackId,
|
|
32873
|
+
keyFrameKey: input.keyFrameKey,
|
|
32874
|
+
heldProvenance: held
|
|
32875
|
+
}
|
|
32876
|
+
});
|
|
32877
|
+
return "no-bbox";
|
|
32878
|
+
}
|
|
32879
|
+
const derived = await deps.derive({
|
|
32880
|
+
deviceId,
|
|
32881
|
+
mediaKey: input.keyFrameKey,
|
|
32882
|
+
bbox: {
|
|
32883
|
+
x: pending.bestSeen.x,
|
|
32884
|
+
y: pending.bestSeen.y,
|
|
32885
|
+
w: pending.bestSeen.w,
|
|
32886
|
+
h: pending.bestSeen.h
|
|
32887
|
+
},
|
|
32888
|
+
frameWidth: pending.bestSeen.frameWidth,
|
|
32889
|
+
frameHeight: pending.bestSeen.frameHeight,
|
|
32890
|
+
timestamp: pending.bestSeen.timestamp
|
|
32891
|
+
});
|
|
32892
|
+
if (derived === null) {
|
|
32893
|
+
deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
|
|
32894
|
+
tags: { deviceId },
|
|
32895
|
+
meta: {
|
|
32896
|
+
trackId,
|
|
32897
|
+
keyFrameKey: input.keyFrameKey,
|
|
32898
|
+
heldProvenance: held
|
|
32899
|
+
}
|
|
32900
|
+
});
|
|
32901
|
+
return "derive-miss";
|
|
32902
|
+
}
|
|
32903
|
+
await deps.put({
|
|
32904
|
+
deviceId,
|
|
32905
|
+
trackId,
|
|
32906
|
+
kind: "thumbnail",
|
|
32907
|
+
timestamp: input.timestamp,
|
|
32908
|
+
data: derived.thumbnail,
|
|
32909
|
+
provenance: "native"
|
|
32910
|
+
});
|
|
32911
|
+
await deps.put({
|
|
32912
|
+
deviceId,
|
|
32913
|
+
trackId,
|
|
32914
|
+
kind: "thumbnailSmall",
|
|
32915
|
+
timestamp: input.timestamp,
|
|
32916
|
+
data: derived.thumbnailSmall,
|
|
32917
|
+
provenance: "native"
|
|
32918
|
+
});
|
|
32919
|
+
await deps.clearPreviewMiss(deviceId, trackId);
|
|
32920
|
+
deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
|
|
32921
|
+
tags: { deviceId },
|
|
32922
|
+
meta: {
|
|
32923
|
+
trackId,
|
|
32924
|
+
keyFrameKey: input.keyFrameKey,
|
|
32925
|
+
replaced: held ?? "none",
|
|
32926
|
+
skewMs: derived.skewMs
|
|
32927
|
+
}
|
|
32928
|
+
});
|
|
32929
|
+
return "upgraded";
|
|
32594
32930
|
}
|
|
32595
32931
|
function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
|
|
32596
32932
|
if (frameWidth <= 0 || frameHeight <= 0) return true;
|
|
@@ -32750,137 +33086,6 @@ function promoteSemanticBestFrame(deps, input) {
|
|
|
32750
33086
|
captured: true
|
|
32751
33087
|
};
|
|
32752
33088
|
}
|
|
32753
|
-
/** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
|
|
32754
|
-
* evicts the oldest record. */
|
|
32755
|
-
var PendingPreviewUpgrades = class {
|
|
32756
|
-
entries = /* @__PURE__ */ new Map();
|
|
32757
|
-
ttlMs;
|
|
32758
|
-
maxEntries;
|
|
32759
|
-
now;
|
|
32760
|
-
constructor(options = {}) {
|
|
32761
|
-
this.ttlMs = options.ttlMs ?? 9e5;
|
|
32762
|
-
this.maxEntries = Math.max(1, options.maxEntries ?? 512);
|
|
32763
|
-
this.now = options.now ?? (() => Date.now());
|
|
32764
|
-
}
|
|
32765
|
-
/** Record (or replace) a closed track's pending upgrade. Prunes expired
|
|
32766
|
-
* entries first, then evicts the oldest while over the cap. */
|
|
32767
|
-
record(entry) {
|
|
32768
|
-
this.prune();
|
|
32769
|
-
this.entries.delete(entry.trackId);
|
|
32770
|
-
this.entries.set(entry.trackId, entry);
|
|
32771
|
-
while (this.entries.size > this.maxEntries) {
|
|
32772
|
-
const oldest = this.entries.keys().next();
|
|
32773
|
-
if (oldest.done === true) break;
|
|
32774
|
-
this.entries.delete(oldest.value);
|
|
32775
|
-
}
|
|
32776
|
-
}
|
|
32777
|
-
/** Claim a track's pending upgrade — removes it, so a second landing (or a
|
|
32778
|
-
* concurrent racer) finds nothing and the tile is rewritten at most once. */
|
|
32779
|
-
take(trackId) {
|
|
32780
|
-
this.prune();
|
|
32781
|
-
const entry = this.entries.get(trackId);
|
|
32782
|
-
if (entry === void 0) return void 0;
|
|
32783
|
-
this.entries.delete(trackId);
|
|
32784
|
-
return entry;
|
|
32785
|
-
}
|
|
32786
|
-
get size() {
|
|
32787
|
-
return this.entries.size;
|
|
32788
|
-
}
|
|
32789
|
-
prune() {
|
|
32790
|
-
const cutoff = this.now() - this.ttlMs;
|
|
32791
|
-
for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
|
|
32792
|
-
}
|
|
32793
|
-
};
|
|
32794
|
-
/**
|
|
32795
|
-
* Redeem a closed track's pending preview off a keyFrame that landed late.
|
|
32796
|
-
* Every non-`upgraded` outcome that had a pending entry logs itself — a
|
|
32797
|
-
* branch that leaves the stand-in in place must say so.
|
|
32798
|
-
*/
|
|
32799
|
-
async function runPreviewUpgrade(deps, input) {
|
|
32800
|
-
const { deviceId, trackId } = input;
|
|
32801
|
-
const pending = deps.takePending(trackId);
|
|
32802
|
-
if (pending === void 0) return "no-pending";
|
|
32803
|
-
const held = await deps.heldThumbnailProvenance(trackId);
|
|
32804
|
-
if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
|
|
32805
|
-
deps.logger.info("late keyFrame: preview already native — no upgrade", {
|
|
32806
|
-
tags: { deviceId },
|
|
32807
|
-
meta: { trackId }
|
|
32808
|
-
});
|
|
32809
|
-
return "held-native";
|
|
32810
|
-
}
|
|
32811
|
-
if (pending.bestSeen === void 0) {
|
|
32812
|
-
deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
|
|
32813
|
-
tags: { deviceId },
|
|
32814
|
-
meta: {
|
|
32815
|
-
trackId,
|
|
32816
|
-
heldProvenance: held
|
|
32817
|
-
}
|
|
32818
|
-
});
|
|
32819
|
-
return "no-bbox";
|
|
32820
|
-
}
|
|
32821
|
-
if (pending.bestSeen.timestamp === void 0) {
|
|
32822
|
-
deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
|
|
32823
|
-
tags: { deviceId },
|
|
32824
|
-
meta: {
|
|
32825
|
-
trackId,
|
|
32826
|
-
keyFrameKey: input.keyFrameKey,
|
|
32827
|
-
heldProvenance: held
|
|
32828
|
-
}
|
|
32829
|
-
});
|
|
32830
|
-
return "no-bbox";
|
|
32831
|
-
}
|
|
32832
|
-
const derived = await deps.derive({
|
|
32833
|
-
deviceId,
|
|
32834
|
-
mediaKey: input.keyFrameKey,
|
|
32835
|
-
bbox: {
|
|
32836
|
-
x: pending.bestSeen.x,
|
|
32837
|
-
y: pending.bestSeen.y,
|
|
32838
|
-
w: pending.bestSeen.w,
|
|
32839
|
-
h: pending.bestSeen.h
|
|
32840
|
-
},
|
|
32841
|
-
frameWidth: pending.bestSeen.frameWidth,
|
|
32842
|
-
frameHeight: pending.bestSeen.frameHeight,
|
|
32843
|
-
timestamp: pending.bestSeen.timestamp
|
|
32844
|
-
});
|
|
32845
|
-
if (derived === null) {
|
|
32846
|
-
deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
|
|
32847
|
-
tags: { deviceId },
|
|
32848
|
-
meta: {
|
|
32849
|
-
trackId,
|
|
32850
|
-
keyFrameKey: input.keyFrameKey,
|
|
32851
|
-
heldProvenance: held
|
|
32852
|
-
}
|
|
32853
|
-
});
|
|
32854
|
-
return "derive-miss";
|
|
32855
|
-
}
|
|
32856
|
-
await deps.put({
|
|
32857
|
-
deviceId,
|
|
32858
|
-
trackId,
|
|
32859
|
-
kind: "thumbnail",
|
|
32860
|
-
timestamp: input.timestamp,
|
|
32861
|
-
data: derived.thumbnail,
|
|
32862
|
-
provenance: "native"
|
|
32863
|
-
});
|
|
32864
|
-
await deps.put({
|
|
32865
|
-
deviceId,
|
|
32866
|
-
trackId,
|
|
32867
|
-
kind: "thumbnailSmall",
|
|
32868
|
-
timestamp: input.timestamp,
|
|
32869
|
-
data: derived.thumbnailSmall,
|
|
32870
|
-
provenance: "native"
|
|
32871
|
-
});
|
|
32872
|
-
await deps.clearPreviewMiss(deviceId, trackId);
|
|
32873
|
-
deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
|
|
32874
|
-
tags: { deviceId },
|
|
32875
|
-
meta: {
|
|
32876
|
-
trackId,
|
|
32877
|
-
keyFrameKey: input.keyFrameKey,
|
|
32878
|
-
replaced: held ?? "none",
|
|
32879
|
-
skewMs: derived.skewMs
|
|
32880
|
-
}
|
|
32881
|
-
});
|
|
32882
|
-
return "upgraded";
|
|
32883
|
-
}
|
|
32884
33089
|
//#endregion
|
|
32885
33090
|
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
32886
33091
|
/**
|
|
@@ -34619,6 +34824,7 @@ var PlateGalleryProvider = class {
|
|
|
34619
34824
|
refreshGallery;
|
|
34620
34825
|
emitPlateGalleryChanged;
|
|
34621
34826
|
eventMediaBaseUrl;
|
|
34827
|
+
eventMediaPathPrefix;
|
|
34622
34828
|
constructor(deps) {
|
|
34623
34829
|
this.plateStore = deps.plateStore;
|
|
34624
34830
|
this.vehicleStore = deps.vehicleStore;
|
|
@@ -34629,6 +34835,7 @@ var PlateGalleryProvider = class {
|
|
|
34629
34835
|
this.refreshGallery = deps.refreshGallery;
|
|
34630
34836
|
this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
|
|
34631
34837
|
this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
|
|
34838
|
+
this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
|
|
34632
34839
|
}
|
|
34633
34840
|
/** Best-effort emit — never allowed to fail the mutation it follows. */
|
|
34634
34841
|
safeEmitPlateGalleryChanged(payload) {
|
|
@@ -34699,13 +34906,7 @@ var PlateGalleryProvider = class {
|
|
|
34699
34906
|
return this.toPlateInfo(plate, nameMap, { includeCrops: false });
|
|
34700
34907
|
}
|
|
34701
34908
|
async getPlateMedia(input) {
|
|
34702
|
-
return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => (
|
|
34703
|
-
key: m.key,
|
|
34704
|
-
kind: m.kind,
|
|
34705
|
-
base64: m.base64,
|
|
34706
|
-
sizeBytes: m.sizeBytes,
|
|
34707
|
-
timestamp: m.timestamp
|
|
34708
|
-
}));
|
|
34909
|
+
return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
|
|
34709
34910
|
}
|
|
34710
34911
|
async searchPlates(input) {
|
|
34711
34912
|
const maxDistance = input.maxDistance ?? 1;
|
|
@@ -34948,6 +35149,51 @@ var PlateGalleryProvider = class {
|
|
|
34948
35149
|
}
|
|
34949
35150
|
};
|
|
34950
35151
|
//#endregion
|
|
35152
|
+
//#region src/pipeline-analytics/replay/frame-processor-pass.ts
|
|
35153
|
+
/**
|
|
35154
|
+
* Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
|
|
35155
|
+
* return the tracks it produced.
|
|
35156
|
+
*
|
|
35157
|
+
* No `TrackStore`, no bus, no media — a track here is a summary built
|
|
35158
|
+
* ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
|
|
35159
|
+
*/
|
|
35160
|
+
function runFrameProcessorPass(input) {
|
|
35161
|
+
const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
|
|
35162
|
+
if (input.zones !== void 0) processor.setZones(input.zones);
|
|
35163
|
+
if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
|
|
35164
|
+
if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
|
|
35165
|
+
const accum = /* @__PURE__ */ new Map();
|
|
35166
|
+
for (const { timestamp, frame } of input.frames) {
|
|
35167
|
+
const result = processor.process({
|
|
35168
|
+
timestamp,
|
|
35169
|
+
frame
|
|
35170
|
+
});
|
|
35171
|
+
for (const td of result.tracked) {
|
|
35172
|
+
const existing = accum.get(td.trackId);
|
|
35173
|
+
const matched = td.matchedThisFrame ? 1 : 0;
|
|
35174
|
+
if (existing === void 0) accum.set(td.trackId, {
|
|
35175
|
+
className: td.className,
|
|
35176
|
+
firstSeenMs: timestamp,
|
|
35177
|
+
lastSeenMs: timestamp,
|
|
35178
|
+
bbox: { ...td.bbox },
|
|
35179
|
+
framesMatched: matched
|
|
35180
|
+
});
|
|
35181
|
+
else {
|
|
35182
|
+
existing.className = td.className;
|
|
35183
|
+
existing.lastSeenMs = timestamp;
|
|
35184
|
+
existing.framesMatched += matched;
|
|
35185
|
+
}
|
|
35186
|
+
}
|
|
35187
|
+
}
|
|
35188
|
+
return { tracks: [...accum.values()].map((t) => ({
|
|
35189
|
+
className: t.className,
|
|
35190
|
+
firstSeenMs: t.firstSeenMs,
|
|
35191
|
+
lastSeenMs: t.lastSeenMs,
|
|
35192
|
+
bbox: t.bbox,
|
|
35193
|
+
framesMatched: t.framesMatched
|
|
35194
|
+
})) };
|
|
35195
|
+
}
|
|
35196
|
+
//#endregion
|
|
34951
35197
|
//#region src/pipeline-analytics/retention/disk-reconcile.ts
|
|
34952
35198
|
/**
|
|
34953
35199
|
* Disk-wins reconcile for pipeline analytics.
|
|
@@ -35012,92 +35258,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
|
|
|
35012
35258
|
};
|
|
35013
35259
|
}
|
|
35014
35260
|
//#endregion
|
|
35015
|
-
//#region src/pipeline-analytics/retention/media-reclaim-actions.ts
|
|
35016
|
-
/**
|
|
35017
|
-
* The operator button for the debug-media reclaim.
|
|
35018
|
-
*
|
|
35019
|
-
* The mechanism ships armed with nothing: **arming it is the operator's
|
|
35020
|
-
* call**. `mode` omitted means `report` — it counts, logs one line per camera
|
|
35021
|
-
* and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
|
|
35022
|
-
* that has to be typed.
|
|
35023
|
-
*
|
|
35024
|
-
* Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
|
|
35025
|
-
* earned it: dry-run default, explicit pacing, explicit blast radius, and a
|
|
35026
|
-
* report you can read instead of a log line you have to find.
|
|
35027
|
-
*/
|
|
35028
|
-
/**
|
|
35029
|
-
* `report` counts; `reclaim` deletes.
|
|
35030
|
-
*
|
|
35031
|
-
* `.default('report')` is load-bearing: the settings form sends `{}` for a
|
|
35032
|
-
* button with no fields, and the safe reading of an unstated intent is the one
|
|
35033
|
-
* that destroys nothing.
|
|
35034
|
-
*/
|
|
35035
|
-
var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
|
|
35036
|
-
var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
|
|
35037
|
-
var MediaReclaimActionInputSchema = object({
|
|
35038
|
-
mode: MediaReclaimActionModeSchema,
|
|
35039
|
-
/** Which populations to walk. Omitted = both. */
|
|
35040
|
-
scopes: array(MediaReclaimScopeSchema).min(1).optional(),
|
|
35041
|
-
/** Restrict to these cameras. Omitted = every camera in retention scope. */
|
|
35042
|
-
deviceIds: array(number().int()).min(1).optional(),
|
|
35043
|
-
/** Start every device from the top instead of resuming its cursor. */
|
|
35044
|
-
restart: boolean().optional(),
|
|
35045
|
-
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
35046
|
-
pageSize: number().int().min(50).max(5e3).optional(),
|
|
35047
|
-
/** Rows EXAMINED per camera in this pass — the pacing bound. */
|
|
35048
|
-
maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
|
|
35049
|
-
/** Rows DELETED per camera in this pass — the blast-radius bound. */
|
|
35050
|
-
maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
|
|
35051
|
-
/** Bytes released across the whole run — the disk-contention bound. */
|
|
35052
|
-
maxBytesPerRun: number().int().min(1).optional(),
|
|
35053
|
-
/** Wall-clock ceiling for the run. */
|
|
35054
|
-
budgetMinutes: number().int().min(1).max(720).optional(),
|
|
35055
|
-
/**
|
|
35056
|
-
* Bytes per second the pass may release. The recordings volume sustains
|
|
35057
|
-
* ~4 MB/s and the archive needs it more than this does; the default is half
|
|
35058
|
-
* of that.
|
|
35059
|
-
*/
|
|
35060
|
-
throttleBytesPerSec: number().int().min(64 * 1024).optional(),
|
|
35061
|
-
/**
|
|
35062
|
-
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
35063
|
-
* exclusively save rows, never select them.
|
|
35064
|
-
*/
|
|
35065
|
-
graceMinutes: number().int().min(1).max(10080).optional()
|
|
35066
|
-
});
|
|
35067
|
-
var MediaReclaimDeviceReportSchema = object({
|
|
35068
|
-
deviceId: number(),
|
|
35069
|
-
scope: MediaReclaimScopeSchema,
|
|
35070
|
-
examined: number(),
|
|
35071
|
-
eligible: number(),
|
|
35072
|
-
reclaimed: number(),
|
|
35073
|
-
bytesReclaimed: number(),
|
|
35074
|
-
/** Rows kept because the owning track is pinned (`staging`/`favourited`). */
|
|
35075
|
-
held: number(),
|
|
35076
|
-
/** Rows kept because the owner could not be shown to be a motion event. */
|
|
35077
|
-
unresolved: number(),
|
|
35078
|
-
withinGrace: number(),
|
|
35079
|
-
/** Deletes that REFUSED — the blob survived or the volume could not answer.
|
|
35080
|
-
* The row was kept and the cursor did not pass it. */
|
|
35081
|
-
refused: number(),
|
|
35082
|
-
stoppedBy: string(),
|
|
35083
|
-
/** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
|
|
35084
|
-
nextCursor: number()
|
|
35085
|
-
});
|
|
35086
|
-
var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
|
|
35087
|
-
/** `false` when a pass was already in flight — nothing was done. */
|
|
35088
|
-
ran: boolean(),
|
|
35089
|
-
mode: _enum(["report", "reclaim"]),
|
|
35090
|
-
devices: array(MediaReclaimDeviceReportSchema),
|
|
35091
|
-
totalExamined: number(),
|
|
35092
|
-
totalEligible: number(),
|
|
35093
|
-
totalReclaimed: number(),
|
|
35094
|
-
totalBytesReclaimed: number(),
|
|
35095
|
-
totalRefused: number(),
|
|
35096
|
-
durationMs: number(),
|
|
35097
|
-
/** Every device+scope reported `exhausted` — there is nothing left to do. */
|
|
35098
|
-
complete: boolean()
|
|
35099
|
-
}), { auth: "admin" }) });
|
|
35100
|
-
//#endregion
|
|
35101
35261
|
//#region src/pipeline-analytics/retention/media-reclaim.ts
|
|
35102
35262
|
var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
|
|
35103
35263
|
var DEFAULT_MEDIA_RECLAIM_LIMITS = {
|
|
@@ -35308,6 +35468,92 @@ async function runMediaReclaim(input) {
|
|
|
35308
35468
|
cursors: nextCursors
|
|
35309
35469
|
};
|
|
35310
35470
|
}
|
|
35471
|
+
//#endregion
|
|
35472
|
+
//#region src/pipeline-analytics/retention/media-reclaim-actions.ts
|
|
35473
|
+
/**
|
|
35474
|
+
* The operator button for the debug-media reclaim.
|
|
35475
|
+
*
|
|
35476
|
+
* The mechanism ships armed with nothing: **arming it is the operator's
|
|
35477
|
+
* call**. `mode` omitted means `report` — it counts, logs one line per camera
|
|
35478
|
+
* and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
|
|
35479
|
+
* that has to be typed.
|
|
35480
|
+
*
|
|
35481
|
+
* Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
|
|
35482
|
+
* earned it: dry-run default, explicit pacing, explicit blast radius, and a
|
|
35483
|
+
* report you can read instead of a log line you have to find.
|
|
35484
|
+
*/
|
|
35485
|
+
/**
|
|
35486
|
+
* `report` counts; `reclaim` deletes.
|
|
35487
|
+
*
|
|
35488
|
+
* `.default('report')` is load-bearing: the settings form sends `{}` for a
|
|
35489
|
+
* button with no fields, and the safe reading of an unstated intent is the one
|
|
35490
|
+
* that destroys nothing.
|
|
35491
|
+
*/
|
|
35492
|
+
var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
|
|
35493
|
+
var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
|
|
35494
|
+
var MediaReclaimActionInputSchema = object({
|
|
35495
|
+
mode: MediaReclaimActionModeSchema,
|
|
35496
|
+
/** Which populations to walk. Omitted = both. */
|
|
35497
|
+
scopes: array(MediaReclaimScopeSchema).min(1).optional(),
|
|
35498
|
+
/** Restrict to these cameras. Omitted = every camera in retention scope. */
|
|
35499
|
+
deviceIds: array(number().int()).min(1).optional(),
|
|
35500
|
+
/** Start every device from the top instead of resuming its cursor. */
|
|
35501
|
+
restart: boolean().optional(),
|
|
35502
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
35503
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
35504
|
+
/** Rows EXAMINED per camera in this pass — the pacing bound. */
|
|
35505
|
+
maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
|
|
35506
|
+
/** Rows DELETED per camera in this pass — the blast-radius bound. */
|
|
35507
|
+
maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
|
|
35508
|
+
/** Bytes released across the whole run — the disk-contention bound. */
|
|
35509
|
+
maxBytesPerRun: number().int().min(1).optional(),
|
|
35510
|
+
/** Wall-clock ceiling for the run. */
|
|
35511
|
+
budgetMinutes: number().int().min(1).max(720).optional(),
|
|
35512
|
+
/**
|
|
35513
|
+
* Bytes per second the pass may release. The recordings volume sustains
|
|
35514
|
+
* ~4 MB/s and the archive needs it more than this does; the default is half
|
|
35515
|
+
* of that.
|
|
35516
|
+
*/
|
|
35517
|
+
throttleBytesPerSec: number().int().min(64 * 1024).optional(),
|
|
35518
|
+
/**
|
|
35519
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
35520
|
+
* exclusively save rows, never select them.
|
|
35521
|
+
*/
|
|
35522
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
35523
|
+
});
|
|
35524
|
+
var MediaReclaimDeviceReportSchema = object({
|
|
35525
|
+
deviceId: number(),
|
|
35526
|
+
scope: MediaReclaimScopeSchema,
|
|
35527
|
+
examined: number(),
|
|
35528
|
+
eligible: number(),
|
|
35529
|
+
reclaimed: number(),
|
|
35530
|
+
bytesReclaimed: number(),
|
|
35531
|
+
/** Rows kept because the owning track is pinned (`staging`/`favourited`). */
|
|
35532
|
+
held: number(),
|
|
35533
|
+
/** Rows kept because the owner could not be shown to be a motion event. */
|
|
35534
|
+
unresolved: number(),
|
|
35535
|
+
withinGrace: number(),
|
|
35536
|
+
/** Deletes that REFUSED — the blob survived or the volume could not answer.
|
|
35537
|
+
* The row was kept and the cursor did not pass it. */
|
|
35538
|
+
refused: number(),
|
|
35539
|
+
stoppedBy: string(),
|
|
35540
|
+
/** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
|
|
35541
|
+
nextCursor: number()
|
|
35542
|
+
});
|
|
35543
|
+
var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
|
|
35544
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
35545
|
+
ran: boolean(),
|
|
35546
|
+
mode: _enum(["report", "reclaim"]),
|
|
35547
|
+
devices: array(MediaReclaimDeviceReportSchema),
|
|
35548
|
+
totalExamined: number(),
|
|
35549
|
+
totalEligible: number(),
|
|
35550
|
+
totalReclaimed: number(),
|
|
35551
|
+
totalBytesReclaimed: number(),
|
|
35552
|
+
totalRefused: number(),
|
|
35553
|
+
durationMs: number(),
|
|
35554
|
+
/** Every device+scope reported `exhausted` — there is nothing left to do. */
|
|
35555
|
+
complete: boolean()
|
|
35556
|
+
}), { auth: "admin" }) });
|
|
35311
35557
|
var OrphanAuditActionInputSchema = object({
|
|
35312
35558
|
mode: _enum(["report", "reclaim"]).default("report"),
|
|
35313
35559
|
/**
|
|
@@ -36627,6 +36873,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
|
|
|
36627
36873
|
return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
|
|
36628
36874
|
}
|
|
36629
36875
|
/**
|
|
36876
|
+
* The STORED kinds worth reading from one scope for a `kinds` request, or
|
|
36877
|
+
* `null` when the scope cannot contribute to it at all.
|
|
36878
|
+
*
|
|
36879
|
+
* This is the same vocabulary translation {@link collectTrackMedia} performs on
|
|
36880
|
+
* the way out, moved to the way IN so it can narrow the query instead of the
|
|
36881
|
+
* answer. A caller naming `faceCrop` must ask the face owner for its stored
|
|
36882
|
+
* `crop` — asking it for `faceCrop` matches nothing, and asking it for
|
|
36883
|
+
* everything opens blobs the answer will discard.
|
|
36884
|
+
*
|
|
36885
|
+
* `undefined` means "every kind": the honest translation of an unfiltered
|
|
36886
|
+
* request, and the only safe answer for the open-ended track scope.
|
|
36887
|
+
*
|
|
36888
|
+
* `null` (skip) is only ever returned for a DETAIL owner whose one publishable
|
|
36889
|
+
* kind was not asked for. The track scope is never skipped — its kinds are
|
|
36890
|
+
* open-ended, and dropping it on a guess is how a listing starts lying.
|
|
36891
|
+
*/
|
|
36892
|
+
function storedKindsForScope(scope, kinds) {
|
|
36893
|
+
if (kinds === void 0 || kinds.length === 0) return void 0;
|
|
36894
|
+
if (scope.publishCropAs === null) return kinds;
|
|
36895
|
+
return kinds.includes(scope.publishCropAs) ? [STORED_DETAIL_CROP_KIND] : null;
|
|
36896
|
+
}
|
|
36897
|
+
/**
|
|
36630
36898
|
* Fold every scope's rows into ONE track media listing: detail crops
|
|
36631
36899
|
* relabelled, everything filtered to `kinds`, ordered oldest first.
|
|
36632
36900
|
*
|
|
@@ -36763,8 +37031,10 @@ var AnalyticsQueryFacade = class {
|
|
|
36763
37031
|
return this.withMediaUrl(events);
|
|
36764
37032
|
}
|
|
36765
37033
|
async getEventMedia(input) {
|
|
36766
|
-
const
|
|
36767
|
-
|
|
37034
|
+
const store = this.deps.mediaStore();
|
|
37035
|
+
if (store === null) return [];
|
|
37036
|
+
const prefix = this.deps.eventMediaPathPrefix();
|
|
37037
|
+
return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
|
|
36768
37038
|
}
|
|
36769
37039
|
/**
|
|
36770
37040
|
* A track's media WITH the bytes, restricted to `kinds` when given.
|
|
@@ -36777,14 +37047,36 @@ var AnalyticsQueryFacade = class {
|
|
|
36777
37047
|
async getTrackMedia(input) {
|
|
36778
37048
|
const store = this.deps.mediaStore();
|
|
36779
37049
|
if (store === null) return [];
|
|
37050
|
+
const prefix = this.deps.eventMediaPathPrefix();
|
|
36780
37051
|
const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
|
|
36781
|
-
return collectTrackMedia(await Promise.all(scopes.map(async (scope) =>
|
|
36782
|
-
scope,
|
|
36783
|
-
|
|
36784
|
-
|
|
37052
|
+
return collectTrackMedia(await Promise.all(scopes.map(async (scope) => {
|
|
37053
|
+
const stored = storedKindsForScope(scope, input.kinds);
|
|
37054
|
+
if (stored === null) return {
|
|
37055
|
+
scope,
|
|
37056
|
+
rows: []
|
|
37057
|
+
};
|
|
37058
|
+
return {
|
|
37059
|
+
scope,
|
|
37060
|
+
rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId, stored)
|
|
37061
|
+
};
|
|
37062
|
+
})), (row, kind) => ({
|
|
36785
37063
|
...row,
|
|
36786
37064
|
kind
|
|
36787
|
-
}), input.kinds);
|
|
37065
|
+
}), input.kinds).map((row) => toWireMediaFile(row, prefix));
|
|
37066
|
+
}
|
|
37067
|
+
/**
|
|
37068
|
+
* An event's media WITHOUT the bytes.
|
|
37069
|
+
*
|
|
37070
|
+
* Index rows only — it never opens a blob. The track detail modal unions
|
|
37071
|
+
* this with {@link listTrackMedia} to build its filmstrip and then fetches
|
|
37072
|
+
* each tile from the `event-media` plane by key, so the bytes this used to
|
|
37073
|
+
* carry were read, base64'd, shipped and discarded.
|
|
37074
|
+
*/
|
|
37075
|
+
async listEventMedia(input) {
|
|
37076
|
+
const store = this.deps.mediaStore();
|
|
37077
|
+
if (store === null) return [];
|
|
37078
|
+
const prefix = this.deps.eventMediaPathPrefix();
|
|
37079
|
+
return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
|
|
36788
37080
|
}
|
|
36789
37081
|
/**
|
|
36790
37082
|
* A track's media WITHOUT the bytes.
|
|
@@ -36802,13 +37094,14 @@ var AnalyticsQueryFacade = class {
|
|
|
36802
37094
|
async listTrackMedia(input) {
|
|
36803
37095
|
const store = this.deps.mediaStore();
|
|
36804
37096
|
if (store === null) return [];
|
|
37097
|
+
const prefix = this.deps.eventMediaPathPrefix();
|
|
36805
37098
|
return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
|
|
36806
37099
|
scope,
|
|
36807
37100
|
rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
|
|
36808
37101
|
}))), (row, kind) => ({
|
|
36809
37102
|
...row,
|
|
36810
37103
|
kind
|
|
36811
|
-
}), void 0);
|
|
37104
|
+
}), void 0).map((row) => toWireMediaFileInfo(row, prefix));
|
|
36812
37105
|
}
|
|
36813
37106
|
async pruneEventsBefore(input) {
|
|
36814
37107
|
const eventStore = this.deps.eventStore();
|
|
@@ -36835,10 +37128,34 @@ var AnalyticsQueryFacade = class {
|
|
|
36835
37128
|
}
|
|
36836
37129
|
/**
|
|
36837
37130
|
* Durable event-store footprint for the events-management UI: event ROWS
|
|
36838
|
-
* (motion + object + audio)
|
|
36839
|
-
*
|
|
36840
|
-
*
|
|
36841
|
-
*
|
|
37131
|
+
* (motion + object + audio) per camera + total, and event-owned media BYTES
|
|
37132
|
+
* on disk per camera + total. Cameras with neither rows nor bytes are
|
|
37133
|
+
* omitted; the rest come back ordered by bytes, descending, which is the
|
|
37134
|
+
* order the operator's table renders.
|
|
37135
|
+
*
|
|
37136
|
+
* ## The device set is a union of THREE sources
|
|
37137
|
+
*
|
|
37138
|
+
* It used to be `media ∪ tracks`, and a camera holding events but no media
|
|
37139
|
+
* row and no persisted track was therefore invisible — not listed, and its
|
|
37140
|
+
* events missing from `totalRows`. That combination is not exotic: media
|
|
37141
|
+
* retention, a media deletion and `pruneTracksBefore` all run on clocks of
|
|
37142
|
+
* their own, and every one of them can outlive the event rows. The number
|
|
37143
|
+
* this method exists to produce is the denominator for a deletion, so an
|
|
37144
|
+
* undercount is the one failure mode that matters, and it was silent.
|
|
37145
|
+
*
|
|
37146
|
+
* Events now contribute their own device set, so a camera is present if ANY
|
|
37147
|
+
* of the three sources has ever heard of it.
|
|
37148
|
+
*
|
|
37149
|
+
* ## The cost is the answer, not the table
|
|
37150
|
+
*
|
|
37151
|
+
* Three grouped reads for the event rows (one per event collection, never
|
|
37152
|
+
* three per camera), one grouped read plus one aggregate per camera for the
|
|
37153
|
+
* media bytes, one for the track device set. The retired shape paid ~1 250
|
|
37154
|
+
* round trips for the media half alone and timed out at 60 s on every call.
|
|
37155
|
+
* See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
|
|
37156
|
+
*
|
|
37157
|
+
* A store that cannot answer THROWS rather than returning zeroes — see the
|
|
37158
|
+
* same note on both.
|
|
36842
37159
|
*/
|
|
36843
37160
|
async getEventStoreFootprint() {
|
|
36844
37161
|
const eventStore = this.deps.eventStore();
|
|
@@ -36848,15 +37165,16 @@ var AnalyticsQueryFacade = class {
|
|
|
36848
37165
|
totalBytes: 0,
|
|
36849
37166
|
devices: []
|
|
36850
37167
|
};
|
|
36851
|
-
const mediaByDevice = await mediaStore.footprintByDevice();
|
|
37168
|
+
const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
|
|
36852
37169
|
const deviceIds = new Set(mediaByDevice.keys());
|
|
37170
|
+
for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
|
|
36853
37171
|
const trackStore = this.deps.trackStore();
|
|
36854
37172
|
if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
|
|
36855
37173
|
const devices = [];
|
|
36856
37174
|
let totalRows = 0;
|
|
36857
37175
|
let totalBytes = 0;
|
|
36858
37176
|
for (const deviceId of deviceIds) {
|
|
36859
|
-
const rows =
|
|
37177
|
+
const rows = eventRowsByDevice.get(deviceId) ?? 0;
|
|
36860
37178
|
const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
|
|
36861
37179
|
if (rows === 0 && bytes === 0) continue;
|
|
36862
37180
|
totalRows += rows;
|
|
@@ -36875,6 +37193,26 @@ var AnalyticsQueryFacade = class {
|
|
|
36875
37193
|
};
|
|
36876
37194
|
}
|
|
36877
37195
|
/**
|
|
37196
|
+
* The media footprint by KIND — the axis that says what is safe to delete.
|
|
37197
|
+
* See {@link MediaStore.footprintByKind}.
|
|
37198
|
+
*
|
|
37199
|
+
* An absent media store is an empty breakdown, not a refusal: it is the same
|
|
37200
|
+
* "the addon has not finished booting" branch every other method here takes,
|
|
37201
|
+
* and the zeroes are accompanied by an empty `kinds` list, which no reader
|
|
37202
|
+
* can mistake for a measured "there is nothing".
|
|
37203
|
+
*/
|
|
37204
|
+
async getEventMediaFootprintByKind(input) {
|
|
37205
|
+
const mediaStore = this.deps.mediaStore();
|
|
37206
|
+
if (!mediaStore) return {
|
|
37207
|
+
totalRows: 0,
|
|
37208
|
+
totalBytes: 0,
|
|
37209
|
+
kinds: [],
|
|
37210
|
+
unaccountedRows: 0,
|
|
37211
|
+
unaccountedBytes: 0
|
|
37212
|
+
};
|
|
37213
|
+
return mediaStore.footprintByKind(input.deviceId);
|
|
37214
|
+
}
|
|
37215
|
+
/**
|
|
36878
37216
|
* Cluster-wide prune of events with `timestamp < olderThanMs` across every
|
|
36879
37217
|
* camera (via the device-agnostic `evictBefore`), deleting each pruned event's
|
|
36880
37218
|
* media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
|
|
@@ -39253,6 +39591,31 @@ function createEventMediaHandler(deps) {
|
|
|
39253
39591
|
}
|
|
39254
39592
|
//#endregion
|
|
39255
39593
|
//#region src/pipeline-analytics/event-thumbnail-resolver.ts
|
|
39594
|
+
/**
|
|
39595
|
+
* Rank `candidates` with `pick`, then open ONE blob — the winner's.
|
|
39596
|
+
*
|
|
39597
|
+
* The ladders decide on `kind` alone, so the bytes are dead weight until the
|
|
39598
|
+
* choice is made. Handing them pre-read rows meant a tile that serves the
|
|
39599
|
+
* 31 KB `thumbnailSmall` first read the whole 3 597 219-byte set, including a
|
|
39600
|
+
* 2.8 MB `keyFrame` no list view has ever rendered.
|
|
39601
|
+
*
|
|
39602
|
+
* A winner whose blob is GONE is dropped and the ladder re-run on the rest.
|
|
39603
|
+
* That fall-through is not an extra: `listByOwner` used to provide it for free
|
|
39604
|
+
* by skipping unreadable rows before the ladder ever saw them, and without it
|
|
39605
|
+
* an index row that outlived its file would 404 a tile that used to render.
|
|
39606
|
+
* Bounded by the candidate count — each attempt removes one row.
|
|
39607
|
+
*/
|
|
39608
|
+
async function readWinningBlob(candidates, pick, readBlob) {
|
|
39609
|
+
let remaining = candidates;
|
|
39610
|
+
while (remaining.length > 0) {
|
|
39611
|
+
const winner = pick(remaining);
|
|
39612
|
+
if (winner === void 0) return null;
|
|
39613
|
+
const bytes = await readBlob(winner.key);
|
|
39614
|
+
if (bytes !== null) return bytes;
|
|
39615
|
+
remaining = remaining.filter((row) => row.key !== winner.key);
|
|
39616
|
+
}
|
|
39617
|
+
return null;
|
|
39618
|
+
}
|
|
39256
39619
|
/** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
|
|
39257
39620
|
* `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
|
|
39258
39621
|
* for new tracks (served only for old stored rows). Undefined when the event
|
|
@@ -39303,15 +39666,24 @@ async function collectCropForcedMedia(source, id) {
|
|
|
39303
39666
|
};
|
|
39304
39667
|
}
|
|
39305
39668
|
/**
|
|
39306
|
-
*
|
|
39307
|
-
* has any, else the owning track's
|
|
39308
|
-
*
|
|
39669
|
+
* Which rows the default path should serve from, and how to rank them: the
|
|
39670
|
+
* event's OWN media when it has any, else the owning track's.
|
|
39671
|
+
*
|
|
39672
|
+
* It yields the candidate SET rather than a winner so the caller can open one
|
|
39673
|
+
* blob and, if that blob has gone, re-rank the rest ({@link readWinningBlob}).
|
|
39674
|
+
* Returning a single row would force the caller to read it blind and 404 a tile
|
|
39675
|
+
* whose index row outlived its file.
|
|
39309
39676
|
*/
|
|
39310
|
-
async function
|
|
39311
|
-
|
|
39312
|
-
|
|
39677
|
+
async function collectDefaultEventMedia(deps) {
|
|
39678
|
+
if (deps.eventFiles.length > 0) return {
|
|
39679
|
+
candidates: deps.eventFiles,
|
|
39680
|
+
pick: pickEventOwnedMedia
|
|
39681
|
+
};
|
|
39313
39682
|
const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
|
|
39314
|
-
return
|
|
39683
|
+
return {
|
|
39684
|
+
candidates: await deps.listTrackMedia(trackId),
|
|
39685
|
+
pick: pickTrackFallbackMedia
|
|
39686
|
+
};
|
|
39315
39687
|
}
|
|
39316
39688
|
//#endregion
|
|
39317
39689
|
//#region src/pipeline-analytics/face-settings.ts
|
|
@@ -44508,6 +44880,15 @@ function snapshot(j) {
|
|
|
44508
44880
|
*/
|
|
44509
44881
|
var LOCATION_ID_FIELD = "locationId";
|
|
44510
44882
|
/**
|
|
44883
|
+
* Unstamped rows claimed per bulk seal statement.
|
|
44884
|
+
*
|
|
44885
|
+
* Bounds two things at once: the number of bound parameters in the `IN` list,
|
|
44886
|
+
* and how long one `UPDATE` holds the write lock on the database every process
|
|
44887
|
+
* in the cluster reads configuration through. Deliberately well under SQLite's
|
|
44888
|
+
* variable ceiling, and small enough that a cancel lands within one statement.
|
|
44889
|
+
*/
|
|
44890
|
+
var SEAL_BATCH_SIZE = 500;
|
|
44891
|
+
/**
|
|
44511
44892
|
* True when the row names NO location — the population `seal` exists for.
|
|
44512
44893
|
*
|
|
44513
44894
|
* Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
|
|
@@ -44739,6 +45120,17 @@ var MediaRelocateEngine = class {
|
|
|
44739
45120
|
}
|
|
44740
45121
|
async countSelected(mode, toLocationId, stop) {
|
|
44741
45122
|
try {
|
|
45123
|
+
if (mode === "seal") {
|
|
45124
|
+
let sealable = 0;
|
|
45125
|
+
for (const collection of collectionsFor(mode)) {
|
|
45126
|
+
if (stop()) return null;
|
|
45127
|
+
sealable += await this.deps.store.count.query({
|
|
45128
|
+
collection: collection.name,
|
|
45129
|
+
filter: { where: { [LOCATION_ID_FIELD]: null } }
|
|
45130
|
+
});
|
|
45131
|
+
}
|
|
45132
|
+
return { rows: sealable };
|
|
45133
|
+
}
|
|
44742
45134
|
let rows = 0;
|
|
44743
45135
|
for (const collection of collectionsFor(mode)) {
|
|
44744
45136
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
@@ -44770,6 +45162,14 @@ var MediaRelocateEngine = class {
|
|
|
44770
45162
|
* gets believed. */
|
|
44771
45163
|
let pathlessRows = 0;
|
|
44772
45164
|
job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
|
|
45165
|
+
if (job.mode === "seal") {
|
|
45166
|
+
for (const collection of collectionsFor(job.mode)) {
|
|
45167
|
+
if (job.cancelRequested) break;
|
|
45168
|
+
await this.sealCollection(collection.name, job);
|
|
45169
|
+
}
|
|
45170
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
45171
|
+
return;
|
|
45172
|
+
}
|
|
44773
45173
|
for (const collection of collectionsFor(job.mode)) {
|
|
44774
45174
|
if (job.cancelRequested) break;
|
|
44775
45175
|
const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
|
|
@@ -44777,20 +45177,6 @@ var MediaRelocateEngine = class {
|
|
|
44777
45177
|
if (job.cancelRequested) break;
|
|
44778
45178
|
const data = row.data;
|
|
44779
45179
|
if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
|
|
44780
|
-
if (job.mode === "seal") {
|
|
44781
|
-
try {
|
|
44782
|
-
await this.stamp(collection.name, row.id, data, job.toLocationId);
|
|
44783
|
-
job.filesMoved++;
|
|
44784
|
-
} catch (err) {
|
|
44785
|
-
failedRows++;
|
|
44786
|
-
this.deps.logger.debug("event-media seal row failed", { meta: {
|
|
44787
|
-
collection: collection.name,
|
|
44788
|
-
key: row.id,
|
|
44789
|
-
error: String(err)
|
|
44790
|
-
} });
|
|
44791
|
-
}
|
|
44792
|
-
continue;
|
|
44793
|
-
}
|
|
44794
45180
|
const from = mediaRowLocation(data);
|
|
44795
45181
|
const relativePath = String(data["path"] ?? "");
|
|
44796
45182
|
if (relativePath.length === 0) {
|
|
@@ -44853,6 +45239,73 @@ var MediaRelocateEngine = class {
|
|
|
44853
45239
|
this.deps.onFinished?.(snapshot(job));
|
|
44854
45240
|
}
|
|
44855
45241
|
}
|
|
45242
|
+
/**
|
|
45243
|
+
* Stamp every unstamped row of one collection with the job's target, in
|
|
45244
|
+
* bounded bulk statements.
|
|
45245
|
+
*
|
|
45246
|
+
* ## What this replaced
|
|
45247
|
+
*
|
|
45248
|
+
* A per-row loop: page the whole collection through {@link pageRows}, then
|
|
45249
|
+
* one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
|
|
45250
|
+
* column to change one field, one RPC each, plus a second full walk before it
|
|
45251
|
+
* to produce the denominator. Measured on the live hub against the 464 902
|
|
45252
|
+
* unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
|
|
45253
|
+
* drain**, i.e. over two and a half days for a metadata stamp that reads no
|
|
45254
|
+
* file and moves no byte. It was cancelled. This is the write-side twin of
|
|
45255
|
+
* the read-side walk {@link countUnstamped} retired, and the fourth instance
|
|
45256
|
+
* of the same pattern found in one day — work looped over the door instead of
|
|
45257
|
+
* pushed into the statement.
|
|
45258
|
+
*
|
|
45259
|
+
* ## The shape
|
|
45260
|
+
*
|
|
45261
|
+
* Probe the indexed `IS NULL` range for a batch of ids, then ONE
|
|
45262
|
+
* `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
|
|
45263
|
+
* rows instead of one write per row.
|
|
45264
|
+
*
|
|
45265
|
+
* The probe carries no cursor and needs none: the predicate CONSUMES itself.
|
|
45266
|
+
* A stamped row leaves the range, so the next probe returns the next batch,
|
|
45267
|
+
* and a run that is interrupted anywhere simply resumes on what is left. That
|
|
45268
|
+
* is also why the seal is idempotent (a second run probes an empty range and
|
|
45269
|
+
* stamps nothing) and why it cannot repoint a row that already names a
|
|
45270
|
+
* different location — `IS NULL` never selects one.
|
|
45271
|
+
*
|
|
45272
|
+
* A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
|
|
45273
|
+
* statement instead of ~1 860, and is not used: half a million row rewrites,
|
|
45274
|
+
* each maintaining four indexes, is an unbounded statement holding the write
|
|
45275
|
+
* lock on a database the whole cluster reads configuration through, under an
|
|
45276
|
+
* RPC deadline that must not be raised. Batching bounds the statement, keeps
|
|
45277
|
+
* `cancel` responsive between batches, and keeps `filesMoved` honest.
|
|
45278
|
+
*
|
|
45279
|
+
* ## The termination guard
|
|
45280
|
+
*
|
|
45281
|
+
* If the probe finds rows and the update reports **0**, this THROWS. That is
|
|
45282
|
+
* not defensive noise: it is precisely what a backend compiling
|
|
45283
|
+
* `locationId = NULL` does — matches nothing, reports success (D312) — and
|
|
45284
|
+
* against it a self-consuming loop never terminates and a non-looping one
|
|
45285
|
+
* reports a completed seal having stamped nothing. Zero unstamped rows is the
|
|
45286
|
+
* single value the migration gate reads as "safe to cut over".
|
|
45287
|
+
*/
|
|
45288
|
+
async sealCollection(collection, job) {
|
|
45289
|
+
for (;;) {
|
|
45290
|
+
if (job.cancelRequested) return;
|
|
45291
|
+
const batch = await this.deps.store.query.query({
|
|
45292
|
+
collection,
|
|
45293
|
+
filter: {
|
|
45294
|
+
where: { [LOCATION_ID_FIELD]: null },
|
|
45295
|
+
limit: SEAL_BATCH_SIZE
|
|
45296
|
+
},
|
|
45297
|
+
columns: [LOCATION_ID_FIELD]
|
|
45298
|
+
});
|
|
45299
|
+
if (batch.length === 0) return;
|
|
45300
|
+
const { updated } = await this.deps.store.updateWhere.mutate({
|
|
45301
|
+
collection,
|
|
45302
|
+
filter: { whereIn: { id: batch.map((row) => row.id) } },
|
|
45303
|
+
data: { [LOCATION_ID_FIELD]: job.toLocationId }
|
|
45304
|
+
});
|
|
45305
|
+
if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
|
|
45306
|
+
job.filesMoved += updated;
|
|
45307
|
+
}
|
|
45308
|
+
}
|
|
44856
45309
|
async stamp(collection, key, data, locationId) {
|
|
44857
45310
|
await this.deps.store.set.mutate({
|
|
44858
45311
|
collection,
|
|
@@ -47418,6 +47871,70 @@ var PackageDropDetector = class {
|
|
|
47418
47871
|
}
|
|
47419
47872
|
};
|
|
47420
47873
|
//#endregion
|
|
47874
|
+
//#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
|
|
47875
|
+
/**
|
|
47876
|
+
* How much of the PERSON box must sit inside a vehicle box to be counted.
|
|
47877
|
+
*
|
|
47878
|
+
* 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
|
|
47879
|
+
* routinely pokes a little outside the vehicle box, and requiring total
|
|
47880
|
+
* containment would count almost none of them. 0.8 and not 0.5: half a person
|
|
47881
|
+
* box overlapping a car is what a pedestrian walking in front of one looks
|
|
47882
|
+
* like, and counting those would make the number mean "somebody was near a
|
|
47883
|
+
* car", which is not a defect.
|
|
47884
|
+
*/
|
|
47885
|
+
var CONTAINMENT_THRESHOLD = .8;
|
|
47886
|
+
/**
|
|
47887
|
+
* The classes that count as a vehicle for this test.
|
|
47888
|
+
*
|
|
47889
|
+
* A local list rather than a shared taxonomy on purpose: it exists to describe
|
|
47890
|
+
* ONE measurement, and a shared enum would make a change here silently change
|
|
47891
|
+
* detection behaviour somewhere else.
|
|
47892
|
+
*/
|
|
47893
|
+
var VEHICLE_CLASSES$1 = [
|
|
47894
|
+
"car",
|
|
47895
|
+
"truck",
|
|
47896
|
+
"bus",
|
|
47897
|
+
"motorcycle",
|
|
47898
|
+
"vehicle"
|
|
47899
|
+
];
|
|
47900
|
+
/** Area of the intersection of two boxes. `0` when they do not overlap. */
|
|
47901
|
+
function intersectionArea(a, b) {
|
|
47902
|
+
const left = Math.max(a.x, b.x);
|
|
47903
|
+
const right = Math.min(a.x + a.w, b.x + b.w);
|
|
47904
|
+
const top = Math.max(a.y, b.y);
|
|
47905
|
+
const bottom = Math.min(a.y + a.h, b.y + b.h);
|
|
47906
|
+
if (right <= left || bottom <= top) return 0;
|
|
47907
|
+
return (right - left) * (bottom - top);
|
|
47908
|
+
}
|
|
47909
|
+
/**
|
|
47910
|
+
* Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
|
|
47911
|
+
*
|
|
47912
|
+
* `0` for a degenerate person box: a zero-area box is inside everything and
|
|
47913
|
+
* inside nothing, and answering `1` would count every one of them.
|
|
47914
|
+
*/
|
|
47915
|
+
function containedFraction(person, vehicle) {
|
|
47916
|
+
const area = person.w * person.h;
|
|
47917
|
+
if (area <= 0) return 0;
|
|
47918
|
+
return intersectionArea(person, vehicle) / area;
|
|
47919
|
+
}
|
|
47920
|
+
/**
|
|
47921
|
+
* Is this person box contained in any vehicle box in the same frame?
|
|
47922
|
+
*
|
|
47923
|
+
* Returns the fraction for the BEST-containing vehicle when it clears
|
|
47924
|
+
* {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
|
|
47925
|
+
* a counter that only ever says "yes" cannot later be re-thresholded against
|
|
47926
|
+
* what was actually measured.
|
|
47927
|
+
*/
|
|
47928
|
+
function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
|
|
47929
|
+
let best = 0;
|
|
47930
|
+
for (const neighbour of neighbours) {
|
|
47931
|
+
if (!VEHICLE_CLASSES$1.includes(neighbour.className)) continue;
|
|
47932
|
+
const fraction = containedFraction(person, neighbour.bbox);
|
|
47933
|
+
if (fraction > best) best = fraction;
|
|
47934
|
+
}
|
|
47935
|
+
return best >= threshold ? best : null;
|
|
47936
|
+
}
|
|
47937
|
+
//#endregion
|
|
47421
47938
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
|
|
47422
47939
|
/**
|
|
47423
47940
|
* stationary-evidence — how a parked object EARNS a registry entry.
|
|
@@ -49949,83 +50466,6 @@ function buildRecordedStillPorts(input) {
|
|
|
49949
50466
|
})
|
|
49950
50467
|
};
|
|
49951
50468
|
}
|
|
49952
|
-
function errorMessage(err) {
|
|
49953
|
-
return err instanceof Error ? err.message : String(err);
|
|
49954
|
-
}
|
|
49955
|
-
async function reconcileGallerySamples(deps, options) {
|
|
49956
|
-
let checked = 0;
|
|
49957
|
-
let reclaimed = 0;
|
|
49958
|
-
let keyless = 0;
|
|
49959
|
-
let skippedGrace = 0;
|
|
49960
|
-
let stoppedByCeiling = false;
|
|
49961
|
-
const identities = await deps.listIdentities();
|
|
49962
|
-
for (const identity of identities) {
|
|
49963
|
-
const samples = await deps.listSamples(identity.id);
|
|
49964
|
-
for (const sample of samples) {
|
|
49965
|
-
if (sample.mediaKey === void 0) {
|
|
49966
|
-
keyless += 1;
|
|
49967
|
-
continue;
|
|
49968
|
-
}
|
|
49969
|
-
if (options.now - sample.addedAt < options.graceMs) {
|
|
49970
|
-
skippedGrace += 1;
|
|
49971
|
-
continue;
|
|
49972
|
-
}
|
|
49973
|
-
if (reclaimed >= options.maxReclaimPerRun) {
|
|
49974
|
-
stoppedByCeiling = true;
|
|
49975
|
-
deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
|
|
49976
|
-
return {
|
|
49977
|
-
checked,
|
|
49978
|
-
reclaimed,
|
|
49979
|
-
keyless,
|
|
49980
|
-
skippedGrace,
|
|
49981
|
-
stoppedByCeiling
|
|
49982
|
-
};
|
|
49983
|
-
}
|
|
49984
|
-
let state;
|
|
49985
|
-
try {
|
|
49986
|
-
state = await deps.blobState(sample.mediaKey);
|
|
49987
|
-
} catch (err) {
|
|
49988
|
-
const reason = errorMessage(err);
|
|
49989
|
-
deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
|
|
49990
|
-
sampleId: sample.id,
|
|
49991
|
-
identity: identity.name,
|
|
49992
|
-
error: reason
|
|
49993
|
-
} });
|
|
49994
|
-
return {
|
|
49995
|
-
checked,
|
|
49996
|
-
reclaimed,
|
|
49997
|
-
keyless,
|
|
49998
|
-
skippedGrace,
|
|
49999
|
-
stoppedByCeiling,
|
|
50000
|
-
aborted: reason
|
|
50001
|
-
};
|
|
50002
|
-
}
|
|
50003
|
-
checked += 1;
|
|
50004
|
-
if (state === "exists") continue;
|
|
50005
|
-
await deps.removeSample(identity.id, sample.id);
|
|
50006
|
-
reclaimed += 1;
|
|
50007
|
-
deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
|
|
50008
|
-
sampleId: sample.id,
|
|
50009
|
-
identity: identity.name,
|
|
50010
|
-
mediaKey: sample.mediaKey,
|
|
50011
|
-
state
|
|
50012
|
-
} });
|
|
50013
|
-
}
|
|
50014
|
-
}
|
|
50015
|
-
if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
|
|
50016
|
-
checked,
|
|
50017
|
-
reclaimed,
|
|
50018
|
-
keyless,
|
|
50019
|
-
skippedGrace
|
|
50020
|
-
} });
|
|
50021
|
-
return {
|
|
50022
|
-
checked,
|
|
50023
|
-
reclaimed,
|
|
50024
|
-
keyless,
|
|
50025
|
-
skippedGrace,
|
|
50026
|
-
stoppedByCeiling
|
|
50027
|
-
};
|
|
50028
|
-
}
|
|
50029
50469
|
//#endregion
|
|
50030
50470
|
//#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
|
|
50031
50471
|
/** The scope id this leg reports and persists its cursor under. */
|
|
@@ -50142,6 +50582,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
|
|
|
50142
50582
|
nextCursor: Math.max(0, offset)
|
|
50143
50583
|
};
|
|
50144
50584
|
}
|
|
50585
|
+
function errorMessage(err) {
|
|
50586
|
+
return err instanceof Error ? err.message : String(err);
|
|
50587
|
+
}
|
|
50588
|
+
async function reconcileGallerySamples(deps, options) {
|
|
50589
|
+
let checked = 0;
|
|
50590
|
+
let reclaimed = 0;
|
|
50591
|
+
let keyless = 0;
|
|
50592
|
+
let skippedGrace = 0;
|
|
50593
|
+
let stoppedByCeiling = false;
|
|
50594
|
+
const identities = await deps.listIdentities();
|
|
50595
|
+
for (const identity of identities) {
|
|
50596
|
+
const samples = await deps.listSamples(identity.id);
|
|
50597
|
+
for (const sample of samples) {
|
|
50598
|
+
if (sample.mediaKey === void 0) {
|
|
50599
|
+
keyless += 1;
|
|
50600
|
+
continue;
|
|
50601
|
+
}
|
|
50602
|
+
if (options.now - sample.addedAt < options.graceMs) {
|
|
50603
|
+
skippedGrace += 1;
|
|
50604
|
+
continue;
|
|
50605
|
+
}
|
|
50606
|
+
if (reclaimed >= options.maxReclaimPerRun) {
|
|
50607
|
+
stoppedByCeiling = true;
|
|
50608
|
+
deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
|
|
50609
|
+
return {
|
|
50610
|
+
checked,
|
|
50611
|
+
reclaimed,
|
|
50612
|
+
keyless,
|
|
50613
|
+
skippedGrace,
|
|
50614
|
+
stoppedByCeiling
|
|
50615
|
+
};
|
|
50616
|
+
}
|
|
50617
|
+
let state;
|
|
50618
|
+
try {
|
|
50619
|
+
state = await deps.blobState(sample.mediaKey);
|
|
50620
|
+
} catch (err) {
|
|
50621
|
+
const reason = errorMessage(err);
|
|
50622
|
+
deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
|
|
50623
|
+
sampleId: sample.id,
|
|
50624
|
+
identity: identity.name,
|
|
50625
|
+
error: reason
|
|
50626
|
+
} });
|
|
50627
|
+
return {
|
|
50628
|
+
checked,
|
|
50629
|
+
reclaimed,
|
|
50630
|
+
keyless,
|
|
50631
|
+
skippedGrace,
|
|
50632
|
+
stoppedByCeiling,
|
|
50633
|
+
aborted: reason
|
|
50634
|
+
};
|
|
50635
|
+
}
|
|
50636
|
+
checked += 1;
|
|
50637
|
+
if (state === "exists") continue;
|
|
50638
|
+
await deps.removeSample(identity.id, sample.id);
|
|
50639
|
+
reclaimed += 1;
|
|
50640
|
+
deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
|
|
50641
|
+
sampleId: sample.id,
|
|
50642
|
+
identity: identity.name,
|
|
50643
|
+
mediaKey: sample.mediaKey,
|
|
50644
|
+
state
|
|
50645
|
+
} });
|
|
50646
|
+
}
|
|
50647
|
+
}
|
|
50648
|
+
if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
|
|
50649
|
+
checked,
|
|
50650
|
+
reclaimed,
|
|
50651
|
+
keyless,
|
|
50652
|
+
skippedGrace
|
|
50653
|
+
} });
|
|
50654
|
+
return {
|
|
50655
|
+
checked,
|
|
50656
|
+
reclaimed,
|
|
50657
|
+
keyless,
|
|
50658
|
+
skippedGrace,
|
|
50659
|
+
stoppedByCeiling
|
|
50660
|
+
};
|
|
50661
|
+
}
|
|
50145
50662
|
/**
|
|
50146
50663
|
* Where the completion marker lives.
|
|
50147
50664
|
*
|
|
@@ -51520,6 +52037,43 @@ var EventStore = class {
|
|
|
51520
52037
|
return motion + object + audio;
|
|
51521
52038
|
}
|
|
51522
52039
|
/**
|
|
52040
|
+
* Persisted event rows (motion + object + audio) for EVERY device that has
|
|
52041
|
+
* any — three round trips for the whole fleet.
|
|
52042
|
+
*
|
|
52043
|
+
* {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
|
|
52044
|
+
* right for one camera and wrong in a loop: the events-management footprint
|
|
52045
|
+
* called it once per device, so the cost of "how many events does each camera
|
|
52046
|
+
* hold" grew with the camera count for a result that is a few dozen integers.
|
|
52047
|
+
*
|
|
52048
|
+
* A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
|
|
52049
|
+
* every event collection already carries `idx_<kind>_device_ts`, which leads
|
|
52050
|
+
* with `deviceId` — so the planner serves each of these from a covering index
|
|
52051
|
+
* with no table access at all. `bucket` IS the deviceId (the column is
|
|
52052
|
+
* INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
|
|
52053
|
+
*
|
|
52054
|
+
* Not best-effort: a failure propagates. The one caller is sizing a deletion,
|
|
52055
|
+
* and a camera silently reported as holding zero events is the answer that
|
|
52056
|
+
* must never come from a read that did not happen.
|
|
52057
|
+
*/
|
|
52058
|
+
async countByDevice() {
|
|
52059
|
+
const perCollection = await Promise.all([
|
|
52060
|
+
MOTION_EVENTS_COLLECTION,
|
|
52061
|
+
OBJECT_EVENTS_COLLECTION,
|
|
52062
|
+
AUDIO_EVENTS_COLLECTION
|
|
52063
|
+
].map((collection) => this.store.histogram.query({
|
|
52064
|
+
collection,
|
|
52065
|
+
field: "deviceId",
|
|
52066
|
+
bucketSize: 1,
|
|
52067
|
+
origin: 0
|
|
52068
|
+
})));
|
|
52069
|
+
const out = /* @__PURE__ */ new Map();
|
|
52070
|
+
for (const buckets of perCollection) for (const bucket of buckets) {
|
|
52071
|
+
if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
|
|
52072
|
+
out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
|
|
52073
|
+
}
|
|
52074
|
+
return out;
|
|
52075
|
+
}
|
|
52076
|
+
/**
|
|
51523
52077
|
* Return per-kind event counts in equal-width time buckets.
|
|
51524
52078
|
*
|
|
51525
52079
|
* Each bucket maps to a `bucketStart = since + i * bucketMs`.
|
|
@@ -53204,14 +53758,14 @@ function isAfterCursor(row, cursor) {
|
|
|
53204
53758
|
if (row.lastSeen < cursor.lastSeen) return true;
|
|
53205
53759
|
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
53206
53760
|
}
|
|
53207
|
-
var VEHICLE_CLASSES
|
|
53761
|
+
var VEHICLE_CLASSES = new Set([
|
|
53208
53762
|
"vehicle",
|
|
53209
53763
|
"car",
|
|
53210
53764
|
"truck",
|
|
53211
53765
|
"bus"
|
|
53212
53766
|
]);
|
|
53213
53767
|
function motionlessMaxPxForClass(className) {
|
|
53214
|
-
return VEHICLE_CLASSES
|
|
53768
|
+
return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
|
|
53215
53769
|
}
|
|
53216
53770
|
function netDisplacementPx(positions) {
|
|
53217
53771
|
const first = positions[0];
|
|
@@ -56971,70 +57525,6 @@ function hasWrappedDetection(entries) {
|
|
|
56971
57525
|
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
56972
57526
|
}
|
|
56973
57527
|
//#endregion
|
|
56974
|
-
//#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
|
|
56975
|
-
/**
|
|
56976
|
-
* How much of the PERSON box must sit inside a vehicle box to be counted.
|
|
56977
|
-
*
|
|
56978
|
-
* 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
|
|
56979
|
-
* routinely pokes a little outside the vehicle box, and requiring total
|
|
56980
|
-
* containment would count almost none of them. 0.8 and not 0.5: half a person
|
|
56981
|
-
* box overlapping a car is what a pedestrian walking in front of one looks
|
|
56982
|
-
* like, and counting those would make the number mean "somebody was near a
|
|
56983
|
-
* car", which is not a defect.
|
|
56984
|
-
*/
|
|
56985
|
-
var CONTAINMENT_THRESHOLD = .8;
|
|
56986
|
-
/**
|
|
56987
|
-
* The classes that count as a vehicle for this test.
|
|
56988
|
-
*
|
|
56989
|
-
* A local list rather than a shared taxonomy on purpose: it exists to describe
|
|
56990
|
-
* ONE measurement, and a shared enum would make a change here silently change
|
|
56991
|
-
* detection behaviour somewhere else.
|
|
56992
|
-
*/
|
|
56993
|
-
var VEHICLE_CLASSES = [
|
|
56994
|
-
"car",
|
|
56995
|
-
"truck",
|
|
56996
|
-
"bus",
|
|
56997
|
-
"motorcycle",
|
|
56998
|
-
"vehicle"
|
|
56999
|
-
];
|
|
57000
|
-
/** Area of the intersection of two boxes. `0` when they do not overlap. */
|
|
57001
|
-
function intersectionArea(a, b) {
|
|
57002
|
-
const left = Math.max(a.x, b.x);
|
|
57003
|
-
const right = Math.min(a.x + a.w, b.x + b.w);
|
|
57004
|
-
const top = Math.max(a.y, b.y);
|
|
57005
|
-
const bottom = Math.min(a.y + a.h, b.y + b.h);
|
|
57006
|
-
if (right <= left || bottom <= top) return 0;
|
|
57007
|
-
return (right - left) * (bottom - top);
|
|
57008
|
-
}
|
|
57009
|
-
/**
|
|
57010
|
-
* Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
|
|
57011
|
-
*
|
|
57012
|
-
* `0` for a degenerate person box: a zero-area box is inside everything and
|
|
57013
|
-
* inside nothing, and answering `1` would count every one of them.
|
|
57014
|
-
*/
|
|
57015
|
-
function containedFraction(person, vehicle) {
|
|
57016
|
-
const area = person.w * person.h;
|
|
57017
|
-
if (area <= 0) return 0;
|
|
57018
|
-
return intersectionArea(person, vehicle) / area;
|
|
57019
|
-
}
|
|
57020
|
-
/**
|
|
57021
|
-
* Is this person box contained in any vehicle box in the same frame?
|
|
57022
|
-
*
|
|
57023
|
-
* Returns the fraction for the BEST-containing vehicle when it clears
|
|
57024
|
-
* {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
|
|
57025
|
-
* a counter that only ever says "yes" cannot later be re-thresholded against
|
|
57026
|
-
* what was actually measured.
|
|
57027
|
-
*/
|
|
57028
|
-
function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
|
|
57029
|
-
let best = 0;
|
|
57030
|
-
for (const neighbour of neighbours) {
|
|
57031
|
-
if (!VEHICLE_CLASSES.includes(neighbour.className)) continue;
|
|
57032
|
-
const fraction = containedFraction(person, neighbour.bbox);
|
|
57033
|
-
if (fraction > best) best = fraction;
|
|
57034
|
-
}
|
|
57035
|
-
return best >= threshold ? best : null;
|
|
57036
|
-
}
|
|
57037
|
-
//#endregion
|
|
57038
57528
|
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
57039
57529
|
function createCaptureCrop(deps) {
|
|
57040
57530
|
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
|
|
@@ -63476,6 +63966,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
63476
63966
|
* Set once the data-plane is registered; null until then (e.g. no
|
|
63477
63967
|
* dataPlane facility in the current environment). */
|
|
63478
63968
|
eventMediaBaseUrl = null;
|
|
63969
|
+
/**
|
|
63970
|
+
* The SAME path, derived rather than observed — a pure function of the addon
|
|
63971
|
+
* id, so it exists before `serve()` is attempted and survives its failure.
|
|
63972
|
+
*
|
|
63973
|
+
* `eventMediaBaseUrl` above answers "is the plane up?", which is the right
|
|
63974
|
+
* question for the OPTIONAL `mediaUrl` stamp on a slim event projection.
|
|
63975
|
+
* It is the wrong question for a media row's `url`, which is that row's only
|
|
63976
|
+
* address: a row that dropped its address on a boot race would be
|
|
63977
|
+
* indistinguishable from a row with no bytes, and this repo has already paid
|
|
63978
|
+
* for one read that quietly returned less than it was asked for.
|
|
63979
|
+
*/
|
|
63980
|
+
eventMediaPathPrefix() {
|
|
63981
|
+
return `/addon/${this.ctx.id}/event-media`;
|
|
63982
|
+
}
|
|
63479
63983
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
63480
63984
|
/** See `pipeline/suppressed-births.ts` — rejected births must not be
|
|
63481
63985
|
* re-upserted, and must be forgotten when the tracker drops the id. */
|
|
@@ -63726,6 +64230,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
63726
64230
|
sensorEventStore: () => this.sensorEventStore,
|
|
63727
64231
|
opsLog: () => this.eventsOpsLog,
|
|
63728
64232
|
eventMediaBaseUrl: () => this.eventMediaBaseUrl,
|
|
64233
|
+
eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
|
|
63729
64234
|
logger: () => this.ctx.logger,
|
|
63730
64235
|
zoneCatalogue: () => this.zoneCatalogue
|
|
63731
64236
|
});
|
|
@@ -66268,6 +66773,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
66268
66773
|
logger: this.ctx.logger,
|
|
66269
66774
|
refreshGallery: () => this.faceRecognizer?.refreshGallery(),
|
|
66270
66775
|
eventMediaBaseUrl: () => this.eventMediaBaseUrl,
|
|
66776
|
+
eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
|
|
66271
66777
|
emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
|
|
66272
66778
|
});
|
|
66273
66779
|
this.faceGalleryProvider = faceGallery;
|
|
@@ -66282,6 +66788,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
66282
66788
|
this.plateRecognizer?.refreshGallery();
|
|
66283
66789
|
},
|
|
66284
66790
|
eventMediaBaseUrl: () => this.eventMediaBaseUrl,
|
|
66791
|
+
eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
|
|
66285
66792
|
emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
|
|
66286
66793
|
});
|
|
66287
66794
|
return [
|
|
@@ -69967,6 +70474,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
69967
70474
|
async listTrackMedia(input) {
|
|
69968
70475
|
return this.queryFacade.listTrackMedia(input);
|
|
69969
70476
|
}
|
|
70477
|
+
/** What media an EVENT has, without any of it — the same manifest, for the
|
|
70478
|
+
* half of the detail modal's filmstrip that is event-owned. */
|
|
70479
|
+
async listEventMedia(input) {
|
|
70480
|
+
return this.queryFacade.listEventMedia(input);
|
|
70481
|
+
}
|
|
69970
70482
|
/**
|
|
69971
70483
|
* Search object events by text using CLIP cosine similarity.
|
|
69972
70484
|
*
|
|
@@ -71285,6 +71797,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
71285
71797
|
async getEventStoreFootprint() {
|
|
71286
71798
|
return this.queryFacade.getEventStoreFootprint();
|
|
71287
71799
|
}
|
|
71800
|
+
async getEventMediaFootprintByKind(input) {
|
|
71801
|
+
return this.queryFacade.getEventMediaFootprintByKind(input);
|
|
71802
|
+
}
|
|
71288
71803
|
async pruneEvents(input) {
|
|
71289
71804
|
return this.queryFacade.pruneEvents(input);
|
|
71290
71805
|
}
|
|
@@ -71687,31 +72202,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
71687
72202
|
return media;
|
|
71688
72203
|
}
|
|
71689
72204
|
}
|
|
72205
|
+
/**
|
|
72206
|
+
* Resolve an event/track id to ONE tile's bytes.
|
|
72207
|
+
*
|
|
72208
|
+
* Every ladder below ranks rows by `kind` alone, so this walks the INDEX
|
|
72209
|
+
* (`listInfoByOwner`, no blob opened) and opens only the winner. It used to
|
|
72210
|
+
* walk `listByOwner`, which reads and base64-encodes every blob an owner
|
|
72211
|
+
* holds before the first comparison is made: measured on the live hub
|
|
72212
|
+
* 2026-08-30, a track's six kinds are 3 597 219 bytes and the tile serves
|
|
72213
|
+
* `thumbnailSmall` — 31 155 of them. An Events page of forty tiles read
|
|
72214
|
+
* ~144 MB and allocated ~190 MB of base64 to deliver ~1.2 MB, all of it on
|
|
72215
|
+
* the single-threaded post-analysis runner.
|
|
72216
|
+
*/
|
|
71690
72217
|
async readEventThumbnail(id, preferKind) {
|
|
71691
|
-
const
|
|
72218
|
+
const listInfo = (ownerKind, ownerId) => this.mediaStore?.listInfoByOwner(ownerKind, ownerId) ?? Promise.resolve([]);
|
|
72219
|
+
const getTrackIdForEvent = (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null);
|
|
72220
|
+
const readBlob = async (key) => {
|
|
72221
|
+
const file = await (this.mediaStore?.getByKey(key) ?? Promise.resolve(null));
|
|
72222
|
+
return file === null ? null : {
|
|
72223
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
72224
|
+
key: file.key
|
|
72225
|
+
};
|
|
72226
|
+
};
|
|
72227
|
+
const eventFiles = await listInfo("event", id);
|
|
71692
72228
|
if (preferKind !== void 0 && preferKind.length > 0) {
|
|
71693
72229
|
const layers = await collectCropForcedMedia({
|
|
71694
|
-
listByOwner:
|
|
71695
|
-
getTrackIdForEvent
|
|
72230
|
+
listByOwner: listInfo,
|
|
72231
|
+
getTrackIdForEvent
|
|
71696
72232
|
}, id);
|
|
71697
|
-
const
|
|
71698
|
-
|
|
71699
|
-
return {
|
|
71700
|
-
bytes: Buffer.from(chosen.base64, "base64"),
|
|
71701
|
-
key: chosen.key
|
|
71702
|
-
};
|
|
72233
|
+
const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
|
|
72234
|
+
return await readWinningBlob([...layers.bodyFiles, ...layers.detailFiles], (rows) => resolveCropForcedMedia(rows.filter((r) => bodyKeys.has(r.key)), (files) => pickCleanMedia(files, preferKind), rows.filter((r) => !bodyKeys.has(r.key))), readBlob);
|
|
71703
72235
|
}
|
|
71704
|
-
const
|
|
72236
|
+
const { candidates, pick } = await collectDefaultEventMedia({
|
|
71705
72237
|
eventId: id,
|
|
71706
72238
|
eventFiles,
|
|
71707
|
-
listTrackMedia: (trackId) =>
|
|
71708
|
-
getTrackIdForEvent
|
|
72239
|
+
listTrackMedia: (trackId) => listInfo("track", trackId),
|
|
72240
|
+
getTrackIdForEvent
|
|
71709
72241
|
});
|
|
71710
|
-
|
|
71711
|
-
return {
|
|
71712
|
-
bytes: Buffer.from(chosen.base64, "base64"),
|
|
71713
|
-
key: chosen.key
|
|
71714
|
-
};
|
|
72242
|
+
return await readWinningBlob(candidates, pick, readBlob);
|
|
71715
72243
|
}
|
|
71716
72244
|
globalSettingsSchema(cap) {
|
|
71717
72245
|
return this.schema(buildGlobalSettingsSchema(cap));
|