@camstack/addon-post-analysis 1.2.218 → 1.2.220
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-CbQHenLE.js → dist-Bw4aTXFP.js} +86 -29
- package/dist/{dist-BqkhuC85.mjs → dist-DWXR9KNe.mjs} +81 -24
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-JPlVJb-8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bfm6wOeY.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-CqdkxAhm.mjs → hostInit-CbC3Ttww.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +572 -160
- package/dist/pipeline-analytics/index.mjs +572 -160
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-Bw4aTXFP.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -8341,8 +8341,55 @@ function incomingFromSystemEvent(event, lookup) {
|
|
|
8341
8341
|
default: return null;
|
|
8342
8342
|
}
|
|
8343
8343
|
}
|
|
8344
|
+
/** The shells whose update has its own sentence. Anything else gets the plain
|
|
8345
|
+
* one: a body that names a shell nobody observed is worse than a vague body. */
|
|
8346
|
+
var WRAPPER_BODY_KINDS = new Set([
|
|
8347
|
+
"docker",
|
|
8348
|
+
"electron",
|
|
8349
|
+
"native"
|
|
8350
|
+
]);
|
|
8351
|
+
/**
|
|
8352
|
+
* The SHELL announcement (D437) — the container / app bundle a node runs
|
|
8353
|
+
* inside is behind the release contract.
|
|
8354
|
+
*
|
|
8355
|
+
* It is deliberately NOT folded into `server-update-available`: the versions
|
|
8356
|
+
* here are the shell's (a seed closure, an app bundle), not the running code's,
|
|
8357
|
+
* and on 2026-08-02 the running code was perfectly current on a hub whose image
|
|
8358
|
+
* was 13 days old from a repository nothing rebuilt. It also mints no button —
|
|
8359
|
+
* no process can replace the shell it is running inside — so the BODY is the
|
|
8360
|
+
* whole delivery, and it is chosen by `wrapperKind` so a docker operator is
|
|
8361
|
+
* handed the exact command instead of a button that cannot exist.
|
|
8362
|
+
*/
|
|
8363
|
+
function incomingFromWrapperUpdate(facts, data) {
|
|
8364
|
+
const packages = updatePackages(data);
|
|
8365
|
+
if (packages.length === 0) return null;
|
|
8366
|
+
const head = packages[0];
|
|
8367
|
+
const nodeId = head.nodeId ?? stringField(data, "nodeId");
|
|
8368
|
+
const wrapperKind = stringField(data, "wrapperKind");
|
|
8369
|
+
const bodySuffix = wrapperKind !== void 0 && WRAPPER_BODY_KINDS.has(wrapperKind) ? wrapperKind : null;
|
|
8370
|
+
return systemSubject({
|
|
8371
|
+
facts,
|
|
8372
|
+
kind: "wrapper-update-available",
|
|
8373
|
+
subject: `wrapper:${nodeId ?? "hub"}`,
|
|
8374
|
+
titleKey: "system.wrapper-update-available.title",
|
|
8375
|
+
bodyKey: bodySuffix === null ? "system.wrapper-update-available.body" : `system.wrapper-update-available.body.${bodySuffix}`,
|
|
8376
|
+
textParams: {
|
|
8377
|
+
currentVersion: head.currentVersion,
|
|
8378
|
+
latestVersion: head.latestVersion,
|
|
8379
|
+
...nodeId !== void 0 ? { nodeId } : {}
|
|
8380
|
+
},
|
|
8381
|
+
textCount: 1,
|
|
8382
|
+
packageName: head.packageName,
|
|
8383
|
+
packageNames: [head.packageName],
|
|
8384
|
+
...nodeId !== void 0 ? {
|
|
8385
|
+
nodeId,
|
|
8386
|
+
nodeIds: [nodeId]
|
|
8387
|
+
} : {}
|
|
8388
|
+
});
|
|
8389
|
+
}
|
|
8344
8390
|
function incomingFromUpdateAvailable(facts, data) {
|
|
8345
8391
|
const target = stringField(data, "target");
|
|
8392
|
+
if (target === "wrapper") return incomingFromWrapperUpdate(facts, data);
|
|
8346
8393
|
if (target !== "addon" && target !== "server") return null;
|
|
8347
8394
|
const packages = updatePackages(data);
|
|
8348
8395
|
if (packages.length === 0) return null;
|
|
@@ -9452,7 +9499,7 @@ var CAMERA_DEVICE_TYPE = "camera";
|
|
|
9452
9499
|
/** Kinds whose action is "go and apply an ADDON update". */
|
|
9453
9500
|
var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
|
|
9454
9501
|
/** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
|
|
9455
|
-
var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
|
|
9502
|
+
var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
|
|
9456
9503
|
/**
|
|
9457
9504
|
* Kinds whose action is "go and look at this device's stream".
|
|
9458
9505
|
*
|
|
@@ -10017,6 +10064,11 @@ var en_default = {
|
|
|
10017
10064
|
"system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
|
|
10018
10065
|
"system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
|
|
10019
10066
|
"system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
|
|
10067
|
+
"system.wrapper-update-available.title": "The container or app on {{nodeId}} is out of date",
|
|
10068
|
+
"system.wrapper-update-available.body": "The shell this node runs inside carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Updating the server keeps the code current but never replaces the shell.",
|
|
10069
|
+
"system.wrapper-update-available.body.docker": "This node's image carries seed {{currentVersion}}; the release contract delivers {{latestVersion}}. A container cannot replace its own image — recreate it from the contract image: docker compose pull && docker compose up -d",
|
|
10070
|
+
"system.wrapper-update-available.body.electron": "The desktop app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. The app downloads the new build on its own and installs it the next time it quits.",
|
|
10071
|
+
"system.wrapper-update-available.body.native": "The installed app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Reinstall it from the current release to converge.",
|
|
10020
10072
|
"system.addon-updated.title": {
|
|
10021
10073
|
"one": "{{count}} addon updated",
|
|
10022
10074
|
"other": "{{count}} addons updated"
|
|
@@ -10272,6 +10324,11 @@ var it_default = {
|
|
|
10272
10324
|
"system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
|
|
10273
10325
|
"system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
|
|
10274
10326
|
"system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
|
|
10327
|
+
"system.wrapper-update-available.title": "Il contenitore o l'app di {{nodeId}} è indietro",
|
|
10328
|
+
"system.wrapper-update-available.body": "Il guscio in cui gira questo nodo porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Aggiornare il server tiene il codice aggiornato ma non sostituisce mai il guscio.",
|
|
10329
|
+
"system.wrapper-update-available.body.docker": "L'immagine di questo nodo porta il seed {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Un container non può sostituire la propria immagine — ricrealo dall'immagine del contratto: docker compose pull && docker compose up -d",
|
|
10330
|
+
"system.wrapper-update-available.body.electron": "L'app desktop su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. L'app scarica da sola la nuova build e la installa alla prossima chiusura.",
|
|
10331
|
+
"system.wrapper-update-available.body.native": "L'app installata su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Reinstallala dal rilascio corrente per allinearla.",
|
|
10275
10332
|
"system.addon-updated.title": {
|
|
10276
10333
|
"one": "{{count}} addon aggiornato",
|
|
10277
10334
|
"other": "{{count}} addon aggiornati"
|
|
@@ -19945,6 +20002,14 @@ function checkTimelapsePacing(input) {
|
|
|
19945
20002
|
unservedWindows: []
|
|
19946
20003
|
};
|
|
19947
20004
|
}
|
|
20005
|
+
//#endregion
|
|
20006
|
+
//#region src/notification-center/timelapse/timelapse-renderer.ts
|
|
20007
|
+
/** `part / whole` to two decimals — the shape a log line is read in. An empty
|
|
20008
|
+
* whole is 0, never NaN: a ratio nobody can compare is not a measurement. */
|
|
20009
|
+
function ratio(part, whole) {
|
|
20010
|
+
if (whole <= 0) return 0;
|
|
20011
|
+
return Math.round(part / whole * 100) / 100;
|
|
20012
|
+
}
|
|
19948
20013
|
/** Bound on the post-render pacing check. The check is best-effort by charter:
|
|
19949
20014
|
* a byte read that hangs must delay delivery by at most this, never sink it. */
|
|
19950
20015
|
var PACING_CHECK_TIMEOUT_MS = 2e4;
|
|
@@ -19968,25 +20033,6 @@ function profileCost(profile) {
|
|
|
19968
20033
|
const i = PROFILE_BY_COST.indexOf(profile);
|
|
19969
20034
|
return i === -1 ? PROFILE_BY_COST.length : i;
|
|
19970
20035
|
}
|
|
19971
|
-
/**
|
|
19972
|
-
* The CHEAPEST profile that has footage over the window.
|
|
19973
|
-
*
|
|
19974
|
-
* It used to be the profile with the MOST coverage, ties broken by name — and
|
|
19975
|
-
* that is a rule which picks `high` almost every time, because the high band is
|
|
19976
|
-
* the one that records continuously. On 2026-09-03 it did: a test render of a
|
|
19977
|
-
* ten-hour window chose `profile=high` at 97% coverage, which on this hub means
|
|
19978
|
-
* 4K read from a USB spinning disk. Decoding it made the machine stop answering
|
|
19979
|
-
* SSH and its own web UI for half an hour, and it had to be power-cycled.
|
|
19980
|
-
*
|
|
19981
|
-
* A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
|
|
19982
|
-
* night, and the cost difference between the bands is one to two orders of
|
|
19983
|
-
* magnitude in both bytes read and frames decoded. So there is no trade to
|
|
19984
|
-
* weigh: take the cheapest band that has anything, every time.
|
|
19985
|
-
*
|
|
19986
|
-
* Coverage no longer decides WHICH — only whether a band qualifies at all. A
|
|
19987
|
-
* band with less footage is still the right source; the gaps show as a shorter
|
|
19988
|
-
* video, not as a reason to read twenty times the bytes.
|
|
19989
|
-
*/
|
|
19990
20036
|
function pickProfile(ranges, window) {
|
|
19991
20037
|
const byProfile = /* @__PURE__ */ new Map();
|
|
19992
20038
|
for (const r of ranges) {
|
|
@@ -20233,18 +20279,20 @@ var TimelapseRenderer = class {
|
|
|
20233
20279
|
const finished = await this.awaitReady(queued, tags);
|
|
20234
20280
|
const published = await this.publish(finished.id, request, tags);
|
|
20235
20281
|
const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
|
|
20282
|
+
const archive = await this.measureArchive(finished.id, request, chosen, tags);
|
|
20283
|
+
const deliveredRows = archive?.rows ?? availability.filter((r) => r.profile === chosen.profile);
|
|
20236
20284
|
await this.verifyPacing({
|
|
20237
20285
|
exportId: finished.id,
|
|
20238
20286
|
request,
|
|
20239
20287
|
dense,
|
|
20240
20288
|
subjects,
|
|
20241
|
-
availability:
|
|
20289
|
+
availability: deliveredRows,
|
|
20242
20290
|
tags
|
|
20243
20291
|
});
|
|
20244
20292
|
return {
|
|
20245
20293
|
exportId: finished.id,
|
|
20246
20294
|
profile: chosen.profile,
|
|
20247
|
-
coverage: chosen.coverage,
|
|
20295
|
+
coverage: archive?.coverage ?? chosen.coverage,
|
|
20248
20296
|
plan,
|
|
20249
20297
|
detections,
|
|
20250
20298
|
artifact: published.video,
|
|
@@ -20352,6 +20400,96 @@ var TimelapseRenderer = class {
|
|
|
20352
20400
|
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
|
|
20353
20401
|
}
|
|
20354
20402
|
/**
|
|
20403
|
+
* The window against the archive, measured AFTER the export and always said.
|
|
20404
|
+
*
|
|
20405
|
+
* Three figures, one line: the window's length, what the PLAN was cut from
|
|
20406
|
+
* (the availability read before the export) and what the ARCHIVE holds (the
|
|
20407
|
+
* same read after it — the export's verified walk has repaired the index the
|
|
20408
|
+
* calendar serves from by then). `plannedOfArchive` is the ratio D445 found
|
|
20409
|
+
* at 0.20 on camera 4374 and nothing reported; a healthy night reads 1.00
|
|
20410
|
+
* because both reads return the same rows.
|
|
20411
|
+
*
|
|
20412
|
+
* WHY THE THRESHOLD IS A SLACK AND NOT A RATIO. The two reads are the SAME
|
|
20413
|
+
* query against the SAME index, so on a healthy night they return the same
|
|
20414
|
+
* rows and the shortfall is exactly 0 — there is no distribution to pick a
|
|
20415
|
+
* percentile out of. It is non-zero only when something CHANGED the index
|
|
20416
|
+
* between them, and the export's verified walk is the only thing that does.
|
|
20417
|
+
* So the bar is not "how short is too short" (the robot's night was 0.20 and
|
|
20418
|
+
* a milder one would be just as wrong); it is only "is this a real change or
|
|
20419
|
+
* a boundary artefact". One artefact exists: the window is closed, but a
|
|
20420
|
+
* segment straddling its end can finalize while the export runs and extend
|
|
20421
|
+
* the last range by its own length. {@link COVERAGE_COMPLETE_SLACK_MS} (30 s)
|
|
20422
|
+
* is the slack the coverage figure already allows at exactly those edges,
|
|
20423
|
+
* and it is several segments wide. No second constant, and nothing between
|
|
20424
|
+
* "unchanged" and "the planner rendered from a short answer".
|
|
20425
|
+
*
|
|
20426
|
+
* Never throws. A re-read that fails is SAID, the plan's figure stands, and
|
|
20427
|
+
* every figure that needed the archive is `null` rather than a stand-in.
|
|
20428
|
+
*/
|
|
20429
|
+
async measureArchive(exportId, request, chosen, tags) {
|
|
20430
|
+
const { window } = request;
|
|
20431
|
+
const windowSec = Math.round((window.endMs - window.startMs) / 1e3);
|
|
20432
|
+
const plannedSec = Math.round(chosen.coverage.coveredMs / 1e3);
|
|
20433
|
+
let rows;
|
|
20434
|
+
try {
|
|
20435
|
+
rows = (await this.ports.getAvailability({
|
|
20436
|
+
deviceId: request.deviceId,
|
|
20437
|
+
fromMs: window.startMs,
|
|
20438
|
+
toMs: window.endMs,
|
|
20439
|
+
profile: chosen.profile
|
|
20440
|
+
})).ranges.filter((r) => r.profile === chosen.profile);
|
|
20441
|
+
} catch (err) {
|
|
20442
|
+
this.ports.logger.warn("timelapse coverage: archive re-read failed — the window is reported against the plan only", {
|
|
20443
|
+
tags,
|
|
20444
|
+
meta: {
|
|
20445
|
+
exportId,
|
|
20446
|
+
ruleId: request.ruleId,
|
|
20447
|
+
profile: chosen.profile,
|
|
20448
|
+
windowSec,
|
|
20449
|
+
plannedSec,
|
|
20450
|
+
plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
|
|
20451
|
+
archiveSec: null,
|
|
20452
|
+
plannedOfArchive: null,
|
|
20453
|
+
archiveOfWindow: null,
|
|
20454
|
+
error: String(err)
|
|
20455
|
+
}
|
|
20456
|
+
});
|
|
20457
|
+
return null;
|
|
20458
|
+
}
|
|
20459
|
+
const coverage = footageCoverage(rows, window.startMs, window.endMs);
|
|
20460
|
+
const shortfallMs = coverage.coveredMs - chosen.coverage.coveredMs;
|
|
20461
|
+
const meta = {
|
|
20462
|
+
exportId,
|
|
20463
|
+
ruleId: request.ruleId,
|
|
20464
|
+
profile: chosen.profile,
|
|
20465
|
+
windowSec,
|
|
20466
|
+
plannedSec,
|
|
20467
|
+
archiveSec: Math.round(coverage.coveredMs / 1e3),
|
|
20468
|
+
/** What the operator ASKED for against what the file HOLDS — the ratio
|
|
20469
|
+
* the robot's night made someone file a report over: ten hours in,
|
|
20470
|
+
* 6.4 s of video out. Low is not by itself a defect (a lazy source is
|
|
20471
|
+
* legitimately idle most of a night), which is why it never gates. */
|
|
20472
|
+
plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
|
|
20473
|
+
archiveOfWindow: ratio(coverage.coveredMs, window.endMs - window.startMs),
|
|
20474
|
+
/** The one that IS a defect: what the planner saw against what the
|
|
20475
|
+
* export's verified walk found. 0.20 on camera 4374, 1.00 when healthy. */
|
|
20476
|
+
plannedOfArchive: ratio(chosen.coverage.coveredMs, coverage.coveredMs),
|
|
20477
|
+
shortfallSec: Math.round(shortfallMs / 1e3)
|
|
20478
|
+
};
|
|
20479
|
+
if (shortfallMs > 3e4) this.ports.logger.warn("timelapse coverage: the plan was cut from less footage than the archive holds", {
|
|
20480
|
+
tags,
|
|
20481
|
+
meta
|
|
20482
|
+
});
|
|
20483
|
+
else this.ports.logger.info("timelapse coverage: the window against the archive", {
|
|
20484
|
+
tags,
|
|
20485
|
+
meta
|
|
20486
|
+
});
|
|
20487
|
+
return {
|
|
20488
|
+
rows,
|
|
20489
|
+
coverage
|
|
20490
|
+
};
|
|
20491
|
+
}
|
|
20492
|
+
/**
|
|
20355
20493
|
* Run the pacing check against the finished file, and SAY what it found.
|
|
20356
20494
|
*
|
|
20357
20495
|
* Never throws and never blocks past its bound: the check is telemetry about
|
|
@@ -25824,6 +25962,7 @@ function systemEventSubject(kind, scope) {
|
|
|
25824
25962
|
if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
|
|
25825
25963
|
if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
|
|
25826
25964
|
if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
|
|
25965
|
+
if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
|
|
25827
25966
|
if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
|
|
25828
25967
|
if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
|
|
25829
25968
|
if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
|
|
@@ -28373,6 +28512,34 @@ var MEDIA_INDEXES = [
|
|
|
28373
28512
|
"deviceId",
|
|
28374
28513
|
"sizeBytes"
|
|
28375
28514
|
]
|
|
28515
|
+
}),
|
|
28516
|
+
(
|
|
28517
|
+
/**
|
|
28518
|
+
* The per-LOCATION footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE locationId
|
|
28519
|
+
* = ?`, and the legacy `locationId IS NULL` half of the same question. See
|
|
28520
|
+
* {@link MediaStore.footprintByLocation} (D388) and D447.
|
|
28521
|
+
*
|
|
28522
|
+
* **Why not `idx_media_location`.** It leads with `locationId`, so it finds
|
|
28523
|
+
* the range — and then, exactly as the `idx_media_device_size` note above
|
|
28524
|
+
* describes for `deviceId`, fetches the table row behind every entry to read
|
|
28525
|
+
* `sizeBytes`. Every row of the store is on SOME location, so the range is
|
|
28526
|
+
* the table: measured 2026-09-10/11 on `hub/sqlite-settings`, 4 128–6 962 ms
|
|
28527
|
+
* per statement, every five minutes (the occupancy refresh), all night —
|
|
28528
|
+
* `plan=["SEARCH … USING INDEX idx_media_location (locationId=?)"]` on every
|
|
28529
|
+
* WARN. That thread is synchronous, so every configuration read in the
|
|
28530
|
+
* cluster queued behind it: the orchestrator's `configMs=6640` at 23:26:11
|
|
28531
|
+
* is this statement's 6 002 ms at 23:26:10.
|
|
28532
|
+
*
|
|
28533
|
+
* With `sizeBytes` in the index the planner never touches the table
|
|
28534
|
+
* (`USING COVERING INDEX idx_media_location_size`), and the seal gate keeps
|
|
28535
|
+
* its own narrower index. Same shape, same one-off caveat as its two
|
|
28536
|
+
* siblings: ~30 bytes a row, one more b-tree per insert, and the first
|
|
28537
|
+
* `declareCollection` after this ships builds it over every existing row —
|
|
28538
|
+
* do not deploy it beside a running drain or seal.
|
|
28539
|
+
*/
|
|
28540
|
+
{
|
|
28541
|
+
name: "idx_media_location_size",
|
|
28542
|
+
columns: ["locationId", "sizeBytes"]
|
|
28376
28543
|
})
|
|
28377
28544
|
];
|
|
28378
28545
|
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
@@ -33426,7 +33593,12 @@ var FaceGalleryProvider = class {
|
|
|
33426
33593
|
identityId: input.identityId
|
|
33427
33594
|
});
|
|
33428
33595
|
assigned += 1;
|
|
33429
|
-
} catch {
|
|
33596
|
+
} catch (err) {
|
|
33597
|
+
this.logger.error("assignFaces refused a face", { meta: {
|
|
33598
|
+
faceId,
|
|
33599
|
+
identityId: input.identityId,
|
|
33600
|
+
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
33601
|
+
} });
|
|
33430
33602
|
failed.push(faceId);
|
|
33431
33603
|
}
|
|
33432
33604
|
return {
|
|
@@ -34810,6 +34982,21 @@ var SpawnBesideCoastingSampler = class {
|
|
|
34810
34982
|
this.counts.clear();
|
|
34811
34983
|
}
|
|
34812
34984
|
};
|
|
34985
|
+
/** Per-cause counter + sampling decision. One instance per tracker, so the
|
|
34986
|
+
* counts are per camera and per detection source. */
|
|
34987
|
+
var TrackLossSampler = class {
|
|
34988
|
+
counts = /* @__PURE__ */ new Map();
|
|
34989
|
+
/** The running count when this loss should be emitted, else `undefined`. */
|
|
34990
|
+
next(cause) {
|
|
34991
|
+
const count = (this.counts.get(cause) ?? 0) + 1;
|
|
34992
|
+
this.counts.set(cause, count);
|
|
34993
|
+
if (count <= 10) return count;
|
|
34994
|
+
return count % 50 === 0 ? count : void 0;
|
|
34995
|
+
}
|
|
34996
|
+
reset() {
|
|
34997
|
+
this.counts.clear();
|
|
34998
|
+
}
|
|
34999
|
+
};
|
|
34813
35000
|
/** Per-reason counter + sampling decision. One instance per tracker, so the
|
|
34814
35001
|
* counts are per camera and per detection source. */
|
|
34815
35002
|
var SpawnRefusalSampler = class {
|
|
@@ -34918,6 +35105,16 @@ var SortTracker = class SortTracker {
|
|
|
34918
35105
|
refusalSink = null;
|
|
34919
35106
|
/** Per-reason counters behind the sampled spawn-beside-coasting diagnostics. */
|
|
34920
35107
|
spawnBesideSampler = new SpawnBesideCoastingSampler();
|
|
35108
|
+
/**
|
|
35109
|
+
* Deliveries to {@link update} since this tracker was constructed. One per
|
|
35110
|
+
* frame the pipeline actually hands over — which is exactly what "the camera
|
|
35111
|
+
* was looking" means, and is NOT derivable from wall clock: a quiet camera
|
|
35112
|
+
* and a busy one span the same seconds (D438).
|
|
35113
|
+
*/
|
|
35114
|
+
frameSeq = 0;
|
|
35115
|
+
/** Sink for track deaths — see {@link setTrackLossObserver}. */
|
|
35116
|
+
trackLossObserver = null;
|
|
35117
|
+
trackLossSampler = new TrackLossSampler();
|
|
34921
35118
|
/** Sink for fragmentation diagnostics — see {@link setSpawnBesideCoastingObserver}. */
|
|
34922
35119
|
spawnBesideObserver = null;
|
|
34923
35120
|
constructor(config = {}) {
|
|
@@ -34950,6 +35147,13 @@ var SortTracker = class SortTracker {
|
|
|
34950
35147
|
* Same contract as the other two sinks — the tracker has no `deviceId`, the
|
|
34951
35148
|
* caller stamps it, and nothing is computed when no observer is wired.
|
|
34952
35149
|
*/
|
|
35150
|
+
/**
|
|
35151
|
+
* Wire the sink for {@link TrackLossRecord}. Nothing is computed when no
|
|
35152
|
+
* observer is attached.
|
|
35153
|
+
*/
|
|
35154
|
+
setTrackLossObserver(observer) {
|
|
35155
|
+
this.trackLossObserver = observer;
|
|
35156
|
+
}
|
|
34953
35157
|
setSpawnBesideCoastingObserver(observer) {
|
|
34954
35158
|
this.spawnBesideObserver = observer;
|
|
34955
35159
|
}
|
|
@@ -34964,13 +35168,13 @@ var SortTracker = class SortTracker {
|
|
|
34964
35168
|
reportSpawnBesideCoasting(spawnedId, det, coasting, detections, frameArea, timestamp) {
|
|
34965
35169
|
const observer = this.spawnBesideObserver;
|
|
34966
35170
|
if (!observer) return;
|
|
34967
|
-
const
|
|
35171
|
+
const family = SortTracker.spawnBesideFamily(det.class);
|
|
34968
35172
|
const dc = bboxCentroid(det.bbox);
|
|
34969
35173
|
let best;
|
|
34970
35174
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
34971
35175
|
let bestReach = 0;
|
|
34972
35176
|
for (const track of coasting) {
|
|
34973
|
-
if (
|
|
35177
|
+
if (SortTracker.spawnBesideFamily(track.class) !== family) continue;
|
|
34974
35178
|
const tc = bboxCentroid(track.bbox);
|
|
34975
35179
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
34976
35180
|
const reach = this.reacquireReach(track, timestamp, true);
|
|
@@ -34985,6 +35189,7 @@ var SortTracker = class SortTracker {
|
|
|
34985
35189
|
const declinedBy = this.explainDecline(best, det, detections, frameArea, timestamp, bestDist);
|
|
34986
35190
|
const spawnCount = this.spawnBesideSampler.next(declinedBy);
|
|
34987
35191
|
if (spawnCount === void 0) return;
|
|
35192
|
+
const crowd = declinedBy === "crowd-guard" ? this.crowdAround(best, det, detections) : void 0;
|
|
34988
35193
|
observer({
|
|
34989
35194
|
spawnedTrackId: spawnedId,
|
|
34990
35195
|
spawnedClass: det.class,
|
|
@@ -35001,6 +35206,8 @@ var SortTracker = class SortTracker {
|
|
|
35001
35206
|
gapMs: timestamp - best.lastSeen,
|
|
35002
35207
|
trackAgeMs: timestamp - best.firstSeen,
|
|
35003
35208
|
declinedBy,
|
|
35209
|
+
...crowd === void 0 ? {} : { competitorCount: crowd.count },
|
|
35210
|
+
...crowd?.nearestPx === void 0 ? {} : { nearestCompetitorPx: crowd.nearestPx },
|
|
35004
35211
|
spawnCount,
|
|
35005
35212
|
sampledEvery: 10
|
|
35006
35213
|
});
|
|
@@ -35015,8 +35222,7 @@ var SortTracker = class SortTracker {
|
|
|
35015
35222
|
* `already-claimed` means.
|
|
35016
35223
|
*/
|
|
35017
35224
|
explainDecline(track, det, detections, frameArea, timestamp, centroidDist) {
|
|
35018
|
-
if (
|
|
35019
|
-
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return "assoc-group";
|
|
35225
|
+
if (track.class !== det.class) return det.nativeRecovery !== void 0 ? "native-measured-class" : "cross-class";
|
|
35020
35226
|
if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "already-claimed";
|
|
35021
35227
|
if (centroidDist <= this.reacquireReach(track, timestamp, true)) {
|
|
35022
35228
|
if (!this.isPlausibleBirth(det, frameArea)) return "already-claimed";
|
|
@@ -35075,14 +35281,44 @@ var SortTracker = class SortTracker {
|
|
|
35075
35281
|
* come back — a graveyarded track may still resurrect, and a line about work
|
|
35076
35282
|
* that was not lost is worse than no line.
|
|
35077
35283
|
*/
|
|
35284
|
+
/**
|
|
35285
|
+
* Report one track that ended with nothing taking over (D438).
|
|
35286
|
+
*
|
|
35287
|
+
* Deliberately separate from {@link reportTrackLoss}, which speaks only for
|
|
35288
|
+
* spawns that never reached `minHits`. The population under investigation —
|
|
35289
|
+
* animal tracks dying mid-frame after six frames — had all EMITTED, so that
|
|
35290
|
+
* path never saw a single one of them.
|
|
35291
|
+
*
|
|
35292
|
+
* `timestamp` is the frame doing the sweeping, not the track's own clock: the
|
|
35293
|
+
* question is how much passed while nobody was looking.
|
|
35294
|
+
*/
|
|
35295
|
+
reportTrackDeath(track, cause, timestamp) {
|
|
35296
|
+
const observer = this.trackLossObserver;
|
|
35297
|
+
if (!observer) return;
|
|
35298
|
+
const lossCount = this.trackLossSampler.next(cause);
|
|
35299
|
+
if (lossCount === void 0) return;
|
|
35300
|
+
observer({
|
|
35301
|
+
trackId: track.id,
|
|
35302
|
+
trackClass: track.class,
|
|
35303
|
+
cause,
|
|
35304
|
+
framesSinceLastSeen: this.frameSeq - track.lastSeenFrameSeq,
|
|
35305
|
+
msSinceLastSeen: timestamp - track.lastSeen,
|
|
35306
|
+
lifeMs: track.lastSeen - track.firstSeen,
|
|
35307
|
+
positions: track.hits,
|
|
35308
|
+
emitted: track.emitted,
|
|
35309
|
+
lossCount,
|
|
35310
|
+
sampledEvery: 50
|
|
35311
|
+
});
|
|
35312
|
+
}
|
|
35078
35313
|
reportTrackLoss(track) {
|
|
35079
35314
|
if (track.emitted || track.lossReported) return;
|
|
35080
35315
|
track.lossReported = true;
|
|
35081
35316
|
this.reportRefusal("unconfirmed-spawn-lost", {
|
|
35082
|
-
class: track.
|
|
35317
|
+
class: track.frameClass,
|
|
35083
35318
|
originalClass: track.originalClass,
|
|
35084
35319
|
score: track.score,
|
|
35085
|
-
bbox: track.bbox
|
|
35320
|
+
bbox: track.bbox,
|
|
35321
|
+
...track.lastNativeRecovery !== void 0 ? { nativeRecovery: track.lastNativeRecovery } : {}
|
|
35086
35322
|
}, {
|
|
35087
35323
|
threshold: this.config.minHits,
|
|
35088
35324
|
measured: track.hits,
|
|
@@ -35137,43 +35373,73 @@ var SortTracker = class SortTracker {
|
|
|
35137
35373
|
static pairKey(a, b) {
|
|
35138
35374
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
35139
35375
|
}
|
|
35140
|
-
/**
|
|
35141
|
-
|
|
35142
|
-
|
|
35143
|
-
|
|
35144
|
-
|
|
35145
|
-
|
|
35376
|
+
/** The two macro-classes a coarse pass confuses on a non-upright person. */
|
|
35377
|
+
static isPersonAnimalPair(a, b) {
|
|
35378
|
+
return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
|
|
35379
|
+
}
|
|
35380
|
+
/**
|
|
35381
|
+
* The family a spawn-beside-coasting line is reported AGAINST — {person,
|
|
35382
|
+
* animal} as one, every other class its own. Reporting only, never a binding
|
|
35383
|
+
* rule: a cross-class pair is exactly what that line exists to name (the
|
|
35384
|
+
* D378 veto, the D439 salvage rule), so a pair the tracker refused must still
|
|
35385
|
+
* be reportable. Independent of `classGroupAssoc` for the same reason.
|
|
35386
|
+
*/
|
|
35387
|
+
static spawnBesideFamily(cls) {
|
|
35388
|
+
if (cls === PERSON_CLASS || cls === ANIMAL_CLASS) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
|
|
35146
35389
|
return cls;
|
|
35147
35390
|
}
|
|
35148
|
-
/**
|
|
35149
|
-
*
|
|
35150
|
-
*
|
|
35151
|
-
*
|
|
35391
|
+
/**
|
|
35392
|
+
* A CROSS-CLASS BIND IS A FLIP CLAIM, AND A FLIP IS WEAK (D439).
|
|
35393
|
+
*
|
|
35394
|
+
* `classGroupAssoc` exists for one failure of the COARSE full-frame pass: a
|
|
35395
|
+
* bending person re-labelled `animal` for a frame, over the person's own
|
|
35396
|
+
* box, at the 0.4-0.6 the coarse pass gives a shape it half recognises. That
|
|
35397
|
+
* claim has three parts, and this predicate is all three:
|
|
35398
|
+
*
|
|
35399
|
+
* - the box was measured by the coarse pass — never `nativeRecovery`. The
|
|
35400
|
+
* runner's second pass measures the OPPOSITE thing: a 640-800 px native
|
|
35401
|
+
* square around a motion region the full-frame boxes do NOT account for,
|
|
35402
|
+
* which is by construction not the subject the full pass already has.
|
|
35403
|
+
* 2026-09-07 07:51:33, an `animal` at `nativeRecoveryScore=0.833`
|
|
35404
|
+
* absorbed into a person: the D378 half of this rule;
|
|
35405
|
+
* - it is a {person, animal} pair;
|
|
35406
|
+
* - it is WEAK — below {@link TrackerConfig.animalOverPersonMaxScore}, the
|
|
35407
|
+
* same bar the spawn gate uses for the same claim. A `person` at 0.86 or
|
|
35408
|
+
* an `animal` at 0.92 is not a half-recognised shape, it is a subject,
|
|
35409
|
+
* and binding it to the other class's track hands one subject's id to
|
|
35410
|
+
* another (camera 615, 17:11:01: person 0.8616 → animal track
|
|
35411
|
+
* `a28c1557`, which was then born as a person).
|
|
35412
|
+
*
|
|
35413
|
+
* Where the caller may use it is the fourth part, and it is the caller's:
|
|
35414
|
+
* only the predicted-box passes ({@link greedyAssociate}), where "same place"
|
|
35415
|
+
* is measured against a prediction on the strict `iouThreshold`. The salvage
|
|
35416
|
+
* passes ({@link looseMatch}) never cross classes — their whole premise is
|
|
35417
|
+
* weak overlap, and a weak-overlap cross-class bind is a dog beside its
|
|
35418
|
+
* owner, every time.
|
|
35419
|
+
*/
|
|
35420
|
+
crossClassFlip(trackClass, det) {
|
|
35421
|
+
if (!this.config.classGroupAssoc) return false;
|
|
35422
|
+
if (det.nativeRecovery !== void 0) return false;
|
|
35423
|
+
if (!SortTracker.isPersonAnimalPair(trackClass, det.class)) return false;
|
|
35424
|
+
return det.score < this.config.animalOverPersonMaxScore;
|
|
35425
|
+
}
|
|
35426
|
+
/** Whether a detection may associate to a track on a PREDICTED-BOX pass:
|
|
35427
|
+
* its own class, or — with `classGroupAssoc` — a weak coarse flip of the
|
|
35428
|
+
* {person, animal} pair ({@link crossClassFlip}). `classGating` off means no
|
|
35429
|
+
* gate at all, matching the legacy contract, except that a native-measured
|
|
35430
|
+
* box still binds only its own class (D378). */
|
|
35152
35431
|
associable(trackClass, det) {
|
|
35153
|
-
if (
|
|
35432
|
+
if (trackClass === det.class) return true;
|
|
35433
|
+
if (det.nativeRecovery !== void 0) return false;
|
|
35154
35434
|
if (!this.config.classGating) return true;
|
|
35155
|
-
return this.
|
|
35435
|
+
return this.crossClassFlip(trackClass, det);
|
|
35156
35436
|
}
|
|
35157
35437
|
/**
|
|
35158
|
-
* A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378)
|
|
35159
|
-
*
|
|
35160
|
-
*
|
|
35161
|
-
*
|
|
35162
|
-
*
|
|
35163
|
-
* the person track is strictly better than minting a concurrent id.
|
|
35164
|
-
*
|
|
35165
|
-
* The runner's second pass makes the opposite measurement — the same detector
|
|
35166
|
-
* on a 640-800 px native square around a motion region the full-frame boxes
|
|
35167
|
-
* do NOT account for. Such a box is, by construction, not the subject the
|
|
35168
|
-
* full pass already has; absorbing it into a person track means the animal is
|
|
35169
|
-
* never seen at all, which on 2026-09-07 07:51:33 is exactly what happened to
|
|
35170
|
-
* an `animal` at `nativeRecoveryScore=0.833`.
|
|
35171
|
-
*
|
|
35172
|
-
* So the widening does not apply to a detection carrying `nativeRecovery`:
|
|
35173
|
-
* it associates to its OWN class or it spawns. Every other guard on that
|
|
35174
|
-
* spawn still runs — the per-class score floor, the area floor, the
|
|
35175
|
-
* `dedupSpawnIou` overlap, and `resolveDuplicates` afterwards, where a person
|
|
35176
|
-
* still beats a cross-class animal.
|
|
35438
|
+
* A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378) — the
|
|
35439
|
+
* occlusion gate's copy of the first clause of {@link crossClassFlip}. A
|
|
35440
|
+
* cross-class occluder is not a same-subject claim for a box the native
|
|
35441
|
+
* pass measured: a dog at its owner's knee is 100% contained in the person's
|
|
35442
|
+
* box and is not the person.
|
|
35177
35443
|
*/
|
|
35178
35444
|
sameGroupAsMeasured(trackClass, det) {
|
|
35179
35445
|
if (det.nativeRecovery === void 0) return true;
|
|
@@ -35346,9 +35612,16 @@ var SortTracker = class SortTracker {
|
|
|
35346
35612
|
};
|
|
35347
35613
|
}
|
|
35348
35614
|
/**
|
|
35349
|
-
* How many
|
|
35350
|
-
*
|
|
35351
|
-
*
|
|
35615
|
+
* How many SAME-CLASS subjects OTHER than `corpse` share the scene with it:
|
|
35616
|
+
* every track LIVE on this frame, plus any other resurrectable corpse whose
|
|
35617
|
+
* lifetime OVERLAPPED the candidate's.
|
|
35618
|
+
*
|
|
35619
|
+
* Exact class, not the {person, animal} family (D439): a competitor is a
|
|
35620
|
+
* subject the id could actually be handed to, and since a salvage pass binds
|
|
35621
|
+
* its own class only, a person cannot take a dog's id there. Counting the
|
|
35622
|
+
* owner as the dog's competitor split one dog's id 12 times on camera 615 in
|
|
35623
|
+
* 6.6 h — every bind the reach clause accepted, the guard then refused
|
|
35624
|
+
* because the person was on screen.
|
|
35352
35625
|
*
|
|
35353
35626
|
* The overlap clause on the graveyard half is what keeps this guard off the
|
|
35354
35627
|
* case the resurrection pass exists for. A single flickering subject leaves a
|
|
@@ -35362,30 +35635,30 @@ var SortTracker = class SortTracker {
|
|
|
35362
35635
|
* steals an id is very often one whose OWN track was born after the corpse
|
|
35363
35636
|
* died (camera 618 — the toddler's track post-dates the older boy's).
|
|
35364
35637
|
*/
|
|
35365
|
-
|
|
35366
|
-
const group = this.assocGroup(corpse.class);
|
|
35638
|
+
sameClassPeerCount(corpse, live) {
|
|
35367
35639
|
let peers = 0;
|
|
35368
|
-
for (const t of live) if (t !== corpse &&
|
|
35640
|
+
for (const t of live) if (t !== corpse && t.class === corpse.class) peers++;
|
|
35369
35641
|
for (const other of this.lostTracks) {
|
|
35370
35642
|
if (other === corpse) continue;
|
|
35371
35643
|
if (!other.resurrectable) continue;
|
|
35372
|
-
if (
|
|
35644
|
+
if (other.class !== corpse.class) continue;
|
|
35373
35645
|
if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
|
|
35374
35646
|
}
|
|
35375
35647
|
return peers;
|
|
35376
35648
|
}
|
|
35377
35649
|
/**
|
|
35378
|
-
* Loose
|
|
35379
|
-
*
|
|
35380
|
-
*
|
|
35381
|
-
*
|
|
35382
|
-
*
|
|
35383
|
-
*
|
|
35384
|
-
*
|
|
35650
|
+
* Loose gate used by the rescue and resurrection passes: accept when the
|
|
35651
|
+
* detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or its
|
|
35652
|
+
* centroid is within reach. EXACT-class, always, independent of
|
|
35653
|
+
* `classGating` and of `classGroupAssoc` (D439): a salvage pass binds on
|
|
35654
|
+
* weak overlap by definition, and a weak-overlap bind across {person,
|
|
35655
|
+
* animal} is not a crouch flip — a flip sits on the subject's own box — it
|
|
35656
|
+
* is a dog beside its owner, or the owner beside the dog. Until 2026-09-10
|
|
35657
|
+
* this gate crossed the group, and it is where a `person` at 0.86 took over
|
|
35658
|
+
* an animal's id (camera 615, 17:11:01).
|
|
35385
35659
|
*/
|
|
35386
35660
|
looseMatch(track, det, timestamp, timeScaled = true) {
|
|
35387
|
-
if (
|
|
35388
|
-
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
|
|
35661
|
+
if (track.class !== det.class) return null;
|
|
35389
35662
|
if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
|
|
35390
35663
|
const tc = bboxCentroid(track.bbox);
|
|
35391
35664
|
const dc = bboxCentroid(det.bbox);
|
|
@@ -35431,12 +35704,41 @@ var SortTracker = class SortTracker {
|
|
|
35431
35704
|
* bind time and at report time — the spawn-beside-coasting line has to be able
|
|
35432
35705
|
* to say which gate declined without re-running the greedy competition.
|
|
35433
35706
|
*/
|
|
35707
|
+
/**
|
|
35708
|
+
* WHO the crowd was, for the record only — this changes no decision.
|
|
35709
|
+
*
|
|
35710
|
+
* {@link isUnambiguousReach} refuses on PRESENCE, with no distance in the
|
|
35711
|
+
* question, so a person at the far corner of a room splits a track as surely
|
|
35712
|
+
* as one standing beside it. Counting them, and measuring the nearest, is
|
|
35713
|
+
* what makes narrowing that rule a decision rather than a guess (D436).
|
|
35714
|
+
*/
|
|
35715
|
+
crowdAround(track, det, detections) {
|
|
35716
|
+
const cx = det.bbox.x + det.bbox.w / 2;
|
|
35717
|
+
const cy = det.bbox.y + det.bbox.h / 2;
|
|
35718
|
+
const counted = [];
|
|
35719
|
+
let nearest;
|
|
35720
|
+
const consider = (bbox) => {
|
|
35721
|
+
counted.push(bbox);
|
|
35722
|
+
const d = Math.hypot(bbox.x + bbox.w / 2 - cx, bbox.y + bbox.h / 2 - cy);
|
|
35723
|
+
if (nearest === void 0 || d < nearest) nearest = d;
|
|
35724
|
+
};
|
|
35725
|
+
for (const t of this.tracks) if (t !== track && t.class === track.class) consider(t.bbox);
|
|
35726
|
+
for (const other of detections) {
|
|
35727
|
+
if (other === det) continue;
|
|
35728
|
+
if (other.class !== track.class) continue;
|
|
35729
|
+
if (counted.some((b) => iou$3(b, other.bbox) > 0)) continue;
|
|
35730
|
+
consider(other.bbox);
|
|
35731
|
+
}
|
|
35732
|
+
return {
|
|
35733
|
+
count: counted.length,
|
|
35734
|
+
nearestPx: nearest
|
|
35735
|
+
};
|
|
35736
|
+
}
|
|
35434
35737
|
isUnambiguousReach(track, det, detections) {
|
|
35435
|
-
if (this.
|
|
35436
|
-
const group = this.assocGroup(track.class);
|
|
35738
|
+
if (this.sameClassPeerCount(track, this.tracks) > 0) return false;
|
|
35437
35739
|
for (const other of detections) {
|
|
35438
35740
|
if (other === det) continue;
|
|
35439
|
-
if (
|
|
35741
|
+
if (other.class === track.class) return false;
|
|
35440
35742
|
}
|
|
35441
35743
|
return true;
|
|
35442
35744
|
}
|
|
@@ -35473,8 +35775,8 @@ var SortTracker = class SortTracker {
|
|
|
35473
35775
|
* weight; otherwise the latest-frame class (ambiguous vote or voting off).
|
|
35474
35776
|
*/
|
|
35475
35777
|
resolveReportedClass(t) {
|
|
35476
|
-
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.
|
|
35477
|
-
let bestClass = t.
|
|
35778
|
+
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.frameClass;
|
|
35779
|
+
let bestClass = t.frameClass;
|
|
35478
35780
|
let bestVote = -1;
|
|
35479
35781
|
let total = 0;
|
|
35480
35782
|
for (const [cls, v] of t.classVotes) {
|
|
@@ -35484,8 +35786,8 @@ var SortTracker = class SortTracker {
|
|
|
35484
35786
|
bestClass = cls;
|
|
35485
35787
|
}
|
|
35486
35788
|
}
|
|
35487
|
-
if (total <= 0) return t.
|
|
35488
|
-
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.
|
|
35789
|
+
if (total <= 0) return t.frameClass;
|
|
35790
|
+
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.frameClass;
|
|
35489
35791
|
}
|
|
35490
35792
|
/** Whether a detection clears its per-class spawn score floor. Only gates NEW
|
|
35491
35793
|
* spawns — an established track still matches below its floor. */
|
|
@@ -35501,16 +35803,67 @@ var SortTracker = class SortTracker {
|
|
|
35501
35803
|
return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
|
|
35502
35804
|
}
|
|
35503
35805
|
/**
|
|
35806
|
+
* The live tracks split into the two tiers that claim a box in a fixed
|
|
35807
|
+
* order (D442, see {@link update}): those already confirmed for emission,
|
|
35808
|
+
* and the provisional ones still under `minHits`. Decided on the state at
|
|
35809
|
+
* the start of the frame, as ByteTrack does. With `minHits: 1` the second
|
|
35810
|
+
* tier is always empty and every pass over it is a no-op.
|
|
35811
|
+
*/
|
|
35812
|
+
associationTiers() {
|
|
35813
|
+
const established = [];
|
|
35814
|
+
const provisional = [];
|
|
35815
|
+
for (const track of this.tracks) if (this.isConfirmedForEmit(track)) established.push(track);
|
|
35816
|
+
else provisional.push(track);
|
|
35817
|
+
return [established, provisional];
|
|
35818
|
+
}
|
|
35819
|
+
/**
|
|
35820
|
+
* The rescue pass. HIGH-score detections the greedy pass left unmatched get
|
|
35821
|
+
* a second, looser chance against the still-unmatched (coasting) tracks of
|
|
35822
|
+
* `tracks` — measured on the track's LAST-KNOWN bbox, never the drifted
|
|
35823
|
+
* prediction. Recovers half-box re-acquisition and small-drift flicker
|
|
35824
|
+
* without spawning a duplicate. Best (highest last-known IoU) pairs first;
|
|
35825
|
+
* LOW detections are reserved for the ByteTrack low stage.
|
|
35826
|
+
*/
|
|
35827
|
+
rescueAssociate(tracks, gates, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35828
|
+
const rescuePairs = [];
|
|
35829
|
+
for (const track of tracks) {
|
|
35830
|
+
if (matchedTracks.has(track)) continue;
|
|
35831
|
+
for (let di = 0; di < detections.length; di++) {
|
|
35832
|
+
if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
|
|
35833
|
+
const det = detections[di];
|
|
35834
|
+
const gate = this.looseMatch(track, det, timestamp);
|
|
35835
|
+
if (gate === null || !gates.has(gate)) continue;
|
|
35836
|
+
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
|
|
35837
|
+
rescuePairs.push({
|
|
35838
|
+
track,
|
|
35839
|
+
detIdx: di,
|
|
35840
|
+
score: iou$3(track.bbox, det.bbox),
|
|
35841
|
+
gate
|
|
35842
|
+
});
|
|
35843
|
+
}
|
|
35844
|
+
}
|
|
35845
|
+
rescuePairs.sort((a, b) => b.score - a.score);
|
|
35846
|
+
for (const pair of rescuePairs) {
|
|
35847
|
+
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
35848
|
+
const det = detections[pair.detIdx];
|
|
35849
|
+
matched.set(pair.track, det);
|
|
35850
|
+
matchedTracks.add(pair.track);
|
|
35851
|
+
used.add(pair.detIdx);
|
|
35852
|
+
this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
|
|
35853
|
+
}
|
|
35854
|
+
}
|
|
35855
|
+
/**
|
|
35504
35856
|
* One greedy IoU association pass over a candidate detection subset against the
|
|
35505
|
-
*
|
|
35857
|
+
* still-UNMATCHED tracks of `tracks` (one association tier — see
|
|
35858
|
+
* {@link associationTiers}), on each track's PREDICTED box. Highest-IoU pairs
|
|
35506
35859
|
* bind first; a track/detection binds at most once. Shared by the high-score
|
|
35507
35860
|
* first stage and the low-score second stage (different candidate sets + IoU
|
|
35508
35861
|
* gates) so the two-tier ByteTrack association stays a single implementation.
|
|
35509
35862
|
* Mutates `used` / `matchedTracks` / `matched` in place.
|
|
35510
35863
|
*/
|
|
35511
|
-
greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35864
|
+
greedyAssociate(tracks, detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35512
35865
|
const pairs = [];
|
|
35513
|
-
for (const track of
|
|
35866
|
+
for (const track of tracks) {
|
|
35514
35867
|
if (matchedTracks.has(track)) continue;
|
|
35515
35868
|
const pbox = this.predicted(track, timestamp);
|
|
35516
35869
|
const confirmed = this.isConfirmedForEmit(track);
|
|
@@ -35536,6 +35889,7 @@ var SortTracker = class SortTracker {
|
|
|
35536
35889
|
}
|
|
35537
35890
|
}
|
|
35538
35891
|
update(detections, timestamp, frameContext) {
|
|
35892
|
+
this.frameSeq++;
|
|
35539
35893
|
const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
|
|
35540
35894
|
const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
|
|
35541
35895
|
const resurrectedScene = /* @__PURE__ */ new Map();
|
|
@@ -35546,6 +35900,7 @@ var SortTracker = class SortTracker {
|
|
|
35546
35900
|
track.lostAt = timestamp;
|
|
35547
35901
|
track.resurrectable = false;
|
|
35548
35902
|
this.reportTrackLoss(track);
|
|
35903
|
+
this.reportTrackDeath(track, "lifetime-cap", timestamp);
|
|
35549
35904
|
this.lostTracks.push(track);
|
|
35550
35905
|
} else alive.push(track);
|
|
35551
35906
|
this.tracks = alive;
|
|
@@ -35558,13 +35913,17 @@ var SortTracker = class SortTracker {
|
|
|
35558
35913
|
track.lostAt = track.lastSeen;
|
|
35559
35914
|
track.resurrectable = false;
|
|
35560
35915
|
this.reportTrackLoss(track);
|
|
35916
|
+
this.reportTrackDeath(track, "stale-beyond-budgets", timestamp);
|
|
35561
35917
|
this.lostTracks.push(track);
|
|
35562
35918
|
} else alive.push(track);
|
|
35563
35919
|
this.tracks = alive;
|
|
35564
35920
|
}
|
|
35565
35921
|
const stillResurrectable = [];
|
|
35566
35922
|
for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
|
|
35567
|
-
else
|
|
35923
|
+
else {
|
|
35924
|
+
this.reportTrackLoss(t);
|
|
35925
|
+
this.reportTrackDeath(t, "graveyard-expired", timestamp);
|
|
35926
|
+
}
|
|
35568
35927
|
this.lostTracks = stillResurrectable;
|
|
35569
35928
|
const used = /* @__PURE__ */ new Set();
|
|
35570
35929
|
const matchedTracks = /* @__PURE__ */ new Set();
|
|
@@ -35574,36 +35933,21 @@ var SortTracker = class SortTracker {
|
|
|
35574
35933
|
for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
|
|
35575
35934
|
else highIdxs.push(di);
|
|
35576
35935
|
const lowSet = new Set(lowIdxs);
|
|
35577
|
-
|
|
35578
|
-
const
|
|
35579
|
-
|
|
35580
|
-
|
|
35581
|
-
|
|
35582
|
-
|
|
35583
|
-
|
|
35584
|
-
|
|
35585
|
-
|
|
35586
|
-
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
|
|
35587
|
-
rescuePairs.push({
|
|
35588
|
-
track,
|
|
35589
|
-
detIdx: di,
|
|
35590
|
-
score: iou$3(track.bbox, det.bbox),
|
|
35591
|
-
gate
|
|
35592
|
-
});
|
|
35593
|
-
}
|
|
35594
|
-
}
|
|
35595
|
-
rescuePairs.sort((a, b) => b.score - a.score);
|
|
35596
|
-
for (const pair of rescuePairs) {
|
|
35597
|
-
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
35598
|
-
const det = detections[pair.detIdx];
|
|
35599
|
-
matched.set(pair.track, det);
|
|
35600
|
-
matchedTracks.add(pair.track);
|
|
35601
|
-
used.add(pair.detIdx);
|
|
35602
|
-
this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
|
|
35603
|
-
}
|
|
35604
|
-
if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35936
|
+
const [established, provisional] = this.associationTiers();
|
|
35937
|
+
const OVERLAP = new Set(["iou"]);
|
|
35938
|
+
const PROXIMITY = new Set(["centroid", "growth"]);
|
|
35939
|
+
this.greedyAssociate(established, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35940
|
+
this.rescueAssociate(established, OVERLAP, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35941
|
+
this.greedyAssociate(provisional, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35942
|
+
this.rescueAssociate(established, PROXIMITY, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35943
|
+
this.rescueAssociate(provisional, new Set([...OVERLAP, ...PROXIMITY]), detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35944
|
+
if (this.config.byteTrackEnabled && lowIdxs.length > 0) for (const tier of [established, provisional]) this.greedyAssociate(tier, detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35605
35945
|
for (const [track, det] of matched) {
|
|
35606
|
-
if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
|
|
35946
|
+
if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
|
|
35947
|
+
against: track,
|
|
35948
|
+
measured: iou$3(this.predicted(track, timestamp), det.bbox),
|
|
35949
|
+
threshold: this.config.animalOverPersonMaxScore
|
|
35950
|
+
});
|
|
35607
35951
|
const prevCenter = bboxCentroid({
|
|
35608
35952
|
x: track.bbox.x,
|
|
35609
35953
|
y: track.bbox.y,
|
|
@@ -35619,12 +35963,15 @@ var SortTracker = class SortTracker {
|
|
|
35619
35963
|
const frames = track.age + 1;
|
|
35620
35964
|
const dtMs = timestamp - track.lastSeen;
|
|
35621
35965
|
track.bbox = det.bbox;
|
|
35622
|
-
track.class = det.class;
|
|
35966
|
+
if (!this.crossClassFlip(track.class, det)) track.class = det.class;
|
|
35967
|
+
track.frameClass = det.class;
|
|
35623
35968
|
track.originalClass = det.originalClass;
|
|
35624
35969
|
track.score = det.score;
|
|
35970
|
+
track.lastNativeRecovery = det.nativeRecovery;
|
|
35625
35971
|
track.age = 0;
|
|
35626
35972
|
track.hits++;
|
|
35627
35973
|
track.lastSeen = timestamp;
|
|
35974
|
+
track.lastSeenFrameSeq = this.frameSeq;
|
|
35628
35975
|
if (dtMs > 0) {
|
|
35629
35976
|
track.velocity = {
|
|
35630
35977
|
dx: (newCenter.x - prevCenter.x) / dtMs,
|
|
@@ -35673,7 +36020,7 @@ var SortTracker = class SortTracker {
|
|
|
35673
36020
|
if (gate === null) continue;
|
|
35674
36021
|
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
|
|
35675
36022
|
const score = iou$3(lost.bbox, det.bbox);
|
|
35676
|
-
const peers = this.
|
|
36023
|
+
const peers = this.sameClassPeerCount(lost, surviving);
|
|
35677
36024
|
if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
|
|
35678
36025
|
if (score > refusedScore) {
|
|
35679
36026
|
refused = lost;
|
|
@@ -35698,12 +36045,15 @@ var SortTracker = class SortTracker {
|
|
|
35698
36045
|
this.lostTracks = this.lostTracks.filter((t) => t !== best);
|
|
35699
36046
|
best.bbox = det.bbox;
|
|
35700
36047
|
best.class = det.class;
|
|
36048
|
+
best.frameClass = det.class;
|
|
35701
36049
|
best.originalClass = det.originalClass;
|
|
35702
36050
|
best.score = det.score;
|
|
36051
|
+
best.lastNativeRecovery = det.nativeRecovery;
|
|
35703
36052
|
best.age = 0;
|
|
35704
36053
|
best.hits++;
|
|
35705
36054
|
best.lost = false;
|
|
35706
36055
|
best.lastSeen = timestamp;
|
|
36056
|
+
best.lastSeenFrameSeq = this.frameSeq;
|
|
35707
36057
|
best.velocity = {
|
|
35708
36058
|
dx: 0,
|
|
35709
36059
|
dy: 0
|
|
@@ -35760,6 +36110,7 @@ var SortTracker = class SortTracker {
|
|
|
35760
36110
|
id: spawnedId,
|
|
35761
36111
|
bbox: det.bbox,
|
|
35762
36112
|
class: det.class,
|
|
36113
|
+
frameClass: det.class,
|
|
35763
36114
|
originalClass: det.originalClass,
|
|
35764
36115
|
score: det.score,
|
|
35765
36116
|
age: 0,
|
|
@@ -35767,6 +36118,7 @@ var SortTracker = class SortTracker {
|
|
|
35767
36118
|
path: [det.bbox],
|
|
35768
36119
|
firstSeen: timestamp,
|
|
35769
36120
|
lastSeen: timestamp,
|
|
36121
|
+
lastSeenFrameSeq: this.frameSeq,
|
|
35770
36122
|
velocity: {
|
|
35771
36123
|
dx: 0,
|
|
35772
36124
|
dy: 0
|
|
@@ -35777,7 +36129,8 @@ var SortTracker = class SortTracker {
|
|
|
35777
36129
|
resurrectable: true,
|
|
35778
36130
|
classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
|
|
35779
36131
|
emitted: false,
|
|
35780
|
-
lossReported: false
|
|
36132
|
+
lossReported: false,
|
|
36133
|
+
lastNativeRecovery: det.nativeRecovery
|
|
35781
36134
|
});
|
|
35782
36135
|
}
|
|
35783
36136
|
this.tracks = surviving;
|
|
@@ -36307,6 +36660,10 @@ var FrameProcessor = class {
|
|
|
36307
36660
|
/** Wire (or clear) the tracker's fragmentation sink — a new id minted beside a
|
|
36308
36661
|
* live coasting track that could have claimed it (D399). Same `deviceId`
|
|
36309
36662
|
* contract as the two sinks above. */
|
|
36663
|
+
/** Wire the sink for track deaths (D438) — pure passthrough to the tracker. */
|
|
36664
|
+
setTrackLossObserver(observer) {
|
|
36665
|
+
this.tracker.setTrackLossObserver(observer);
|
|
36666
|
+
}
|
|
36310
36667
|
setSpawnBesideCoastingObserver(observer) {
|
|
36311
36668
|
this.tracker.setSpawnBesideCoastingObserver(observer);
|
|
36312
36669
|
}
|
|
@@ -65035,6 +65392,44 @@ function buildAudioLabels(input) {
|
|
|
65035
65392
|
}];
|
|
65036
65393
|
}
|
|
65037
65394
|
//#endregion
|
|
65395
|
+
//#region src/pipeline-analytics/services/event-kinds-config.ts
|
|
65396
|
+
/** The honest answer for a pipeline that could not be read: nothing advertised. */
|
|
65397
|
+
var NO_EMITTABLE = {
|
|
65398
|
+
macroClasses: [],
|
|
65399
|
+
audioEnabled: false
|
|
65400
|
+
};
|
|
65401
|
+
/** Depth-first walk of the step tree; the detector is a root today, but the
|
|
65402
|
+
* question is "is it enabled anywhere", not "is it a root". */
|
|
65403
|
+
function* walkSteps(steps) {
|
|
65404
|
+
for (const step of steps) {
|
|
65405
|
+
yield step;
|
|
65406
|
+
if (step.children !== void 0) yield* walkSteps(step.children);
|
|
65407
|
+
}
|
|
65408
|
+
}
|
|
65409
|
+
/**
|
|
65410
|
+
* The macro classes an enabled `object-detection` step emits. Order follows
|
|
65411
|
+
* the authority, not the stored array, so two cameras narrowed to the same
|
|
65412
|
+
* set render the same lanes in the same order.
|
|
65413
|
+
*/
|
|
65414
|
+
function macroClassesOf(step) {
|
|
65415
|
+
const stored = step.settings?.["enabledMacroClasses"];
|
|
65416
|
+
if (!Array.isArray(stored) || stored.length === 0) return [...require_dist.FIRST_LEVEL_MACRO_CLASSES];
|
|
65417
|
+
const chosen = new Set(stored.filter((v) => typeof v === "string"));
|
|
65418
|
+
return require_dist.FIRST_LEVEL_MACRO_CLASSES.filter((macro) => chosen.has(macro));
|
|
65419
|
+
}
|
|
65420
|
+
function emittableFromPipelineConfig(config) {
|
|
65421
|
+
let macroClasses = [];
|
|
65422
|
+
for (const step of walkSteps(config.steps)) {
|
|
65423
|
+
if (step.addonId !== "object-detection" || step.enabled === false) continue;
|
|
65424
|
+
macroClasses = macroClassesOf(step);
|
|
65425
|
+
break;
|
|
65426
|
+
}
|
|
65427
|
+
return {
|
|
65428
|
+
macroClasses,
|
|
65429
|
+
audioEnabled: config.audio?.enabled === true
|
|
65430
|
+
};
|
|
65431
|
+
}
|
|
65432
|
+
//#endregion
|
|
65038
65433
|
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
65039
65434
|
/**
|
|
65040
65435
|
* Extensible per-device event kinds (Part B).
|
|
@@ -66696,10 +67091,12 @@ var TrackingSettingsSchema = require_dist.object({
|
|
|
66696
67091
|
minSpawnAreaFracAnimal: require_dist.number().min(0).max(1).default(0),
|
|
66697
67092
|
/** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
|
|
66698
67093
|
minSpawnAreaFracVehicle: require_dist.number().min(0).max(1).default(0),
|
|
66699
|
-
/**
|
|
66700
|
-
*
|
|
66701
|
-
*
|
|
66702
|
-
*
|
|
67094
|
+
/** Let a person that flips to `animal` for a frame re-match its existing
|
|
67095
|
+
* track (no concurrent animal id). A flip is the same subject in the same
|
|
67096
|
+
* place, weakly read (D439): the bind is allowed only on the predicted-box
|
|
67097
|
+
* passes, only for a coarse box below `animalOverPersonMaxScore`, never on
|
|
67098
|
+
* the rescue / resurrection passes. Reported label stays via the class
|
|
67099
|
+
* vote. Off = exact per-frame class gating. */
|
|
66703
67100
|
classGroupAssoc: require_dist.boolean().default(true)
|
|
66704
67101
|
});
|
|
66705
67102
|
var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
|
|
@@ -67289,7 +67686,7 @@ function buildDetectionSettingsSections() {
|
|
|
67289
67686
|
type: "boolean",
|
|
67290
67687
|
key: "classGroupAssoc",
|
|
67291
67688
|
label: "Person/animal group matching",
|
|
67292
|
-
description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
|
|
67689
|
+
description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. A flip is the same subject in the same place, weakly read: the cross-class match happens only on the strict predicted-box pass and only for a full-frame box below the \"animal over person max score\" bar — a confident animal beside a person, or a person beside an animal, is its own subject and keeps its own track. The reported class is still decided by the per-track class vote.",
|
|
67293
67690
|
default: TRACKING_DEFAULTS.classGroupAssoc
|
|
67294
67691
|
},
|
|
67295
67692
|
{
|
|
@@ -77760,6 +78157,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77760
78157
|
}
|
|
77761
78158
|
});
|
|
77762
78159
|
});
|
|
78160
|
+
p.setTrackLossObserver((record) => {
|
|
78161
|
+
this.ctx.logger.info("tracker: a track ended and nothing took over", {
|
|
78162
|
+
tags: { deviceId },
|
|
78163
|
+
meta: {
|
|
78164
|
+
source,
|
|
78165
|
+
...record
|
|
78166
|
+
}
|
|
78167
|
+
});
|
|
78168
|
+
});
|
|
77763
78169
|
p.setSpawnBesideCoastingObserver((record) => {
|
|
77764
78170
|
this.ctx.logger.info("tracker: new id minted beside a live coasting track", {
|
|
77765
78171
|
tags: { deviceId },
|
|
@@ -77931,28 +78337,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77931
78337
|
};
|
|
77932
78338
|
}
|
|
77933
78339
|
/**
|
|
77934
|
-
* Every event kind the device can produce: built-ins (motion + audio),
|
|
77935
|
-
* detection classes
|
|
77936
|
-
*
|
|
77937
|
-
*
|
|
78340
|
+
* Every event kind the device can produce: built-ins (motion + audio), the
|
|
78341
|
+
* detection classes its CONFIGURED pipeline can emit, and sensor kinds from
|
|
78342
|
+
* LINKED devices (device-manager `getLinkedDevices`, binding-driven per
|
|
78343
|
+
* linked device). Degrades to the built-ins on error.
|
|
78344
|
+
*
|
|
78345
|
+
* The detection and audio facts are read from `resolvePipeline` — the
|
|
78346
|
+
* configuration — and never from `getCameraStatus`, which is RUNTIME: an
|
|
78347
|
+
* on-motion camera holds no session between motion events
|
|
78348
|
+
* (`reasons.detection: 'armed:on-motion'`, `detection: null`) and was being
|
|
78349
|
+
* advertised as detecting nothing while its tracks said person / vehicle /
|
|
78350
|
+
* animal ([D449](../../../../docs/decisions/adr-0449-the-timeline-taxonomy-is-the-configured-one-raw-forward-has-no-switch-and-a-one-row-write-waits-on-the-disk.md)).
|
|
77938
78351
|
*/
|
|
77939
78352
|
async listEventKinds(input) {
|
|
77940
78353
|
const api = this.ctx.api;
|
|
77941
|
-
const
|
|
77942
|
-
this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
|
|
77943
|
-
tags: { deviceId: input.deviceId },
|
|
77944
|
-
meta: { error: require_dist.errMsg(err) }
|
|
77945
|
-
});
|
|
77946
|
-
return null;
|
|
77947
|
-
});
|
|
77948
|
-
const detectionOn = status?.detection != null;
|
|
77949
|
-
const audioOn = status?.audio?.enabled === true;
|
|
77950
|
-
const detectionMacros = require_dist.MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
|
|
78354
|
+
const emittable = await this.emittableFromConfig(input.deviceId, "listEventKinds");
|
|
77951
78355
|
return composeEventKinds({
|
|
77952
78356
|
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
77953
78357
|
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
77954
|
-
enabledMacroClasses: async () =>
|
|
77955
|
-
audioEnabled: async () =>
|
|
78358
|
+
enabledMacroClasses: async () => emittable.macroClasses,
|
|
78359
|
+
audioEnabled: async () => emittable.audioEnabled,
|
|
77956
78360
|
packageZonesEnabled: async (deviceId) => {
|
|
77957
78361
|
return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
|
|
77958
78362
|
},
|
|
@@ -77966,6 +78370,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77966
78370
|
}, input.deviceId);
|
|
77967
78371
|
}
|
|
77968
78372
|
/**
|
|
78373
|
+
* What one camera's configured pipeline can emit (`resolvePipeline`), or
|
|
78374
|
+
* the honest fallback when the orchestrator could not answer: nothing
|
|
78375
|
+
* advertised, and a WARN saying so. The cap's answer is a plain list, so it
|
|
78376
|
+
* has no room for "unknown" — that residual of D315 is named in D449; the
|
|
78377
|
+
* steady state (an armed camera with no session) is no longer in it.
|
|
78378
|
+
*/
|
|
78379
|
+
async emittableFromConfig(deviceId, scope) {
|
|
78380
|
+
try {
|
|
78381
|
+
return emittableFromPipelineConfig(await this.ctx.api.pipelineOrchestrator.resolvePipeline.query({ deviceId }));
|
|
78382
|
+
} catch (err) {
|
|
78383
|
+
this.ctx.logger.warn(`${scope}: resolvePipeline failed — no ML kinds advertised`, {
|
|
78384
|
+
tags: { deviceId },
|
|
78385
|
+
meta: { error: require_dist.errMsg(err) }
|
|
78386
|
+
});
|
|
78387
|
+
return NO_EMITTABLE;
|
|
78388
|
+
}
|
|
78389
|
+
}
|
|
78390
|
+
/**
|
|
77969
78391
|
* `listEventKinds` for many cameras, in one call.
|
|
77970
78392
|
*
|
|
77971
78393
|
* The saving is not the round-trips the CALLER avoids — it is the fan-out
|
|
@@ -77977,11 +78399,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77977
78399
|
* `getLinkedDevices` alone took 4110 ms — each one runs a full-fleet
|
|
77978
78400
|
* `listAll` inside device-manager and thirty of them QUEUE.
|
|
77979
78401
|
*
|
|
77980
|
-
* Now
|
|
77981
|
-
* `
|
|
77982
|
-
* `
|
|
77983
|
-
*
|
|
77984
|
-
* maps instead of RPC.
|
|
78402
|
+
* Now two calls for the whole topology, whatever N is —
|
|
78403
|
+
* `prefetchDeviceTopology`'s `getLinkedDevicesBatch` + `getBindingsBatch` —
|
|
78404
|
+
* plus one `resolvePipeline` per camera, all in flight at once (D449; there
|
|
78405
|
+
* is no batch form of it). `composeEventKinds` is unchanged — it is handed
|
|
78406
|
+
* the same per-device client interfaces, backed by maps instead of RPC.
|
|
77985
78407
|
*
|
|
77986
78408
|
* A camera whose composition fails yields an EMPTY list rather than dropping
|
|
77987
78409
|
* out of the response: a caller that asked for twelve and got eleven cannot
|
|
@@ -77990,15 +78412,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77990
78412
|
async listEventKindsBatch(input) {
|
|
77991
78413
|
const api = this.ctx.api;
|
|
77992
78414
|
const deviceIds = [...new Set(input.deviceIds)];
|
|
77993
|
-
const
|
|
77994
|
-
this.ctx.logger.warn("listEventKindsBatch: getCameraStatuses failed", { meta: {
|
|
77995
|
-
deviceCount: deviceIds.length,
|
|
77996
|
-
error: require_dist.errMsg(err)
|
|
77997
|
-
} });
|
|
77998
|
-
return [];
|
|
77999
|
-
});
|
|
78000
|
-
const statusByDevice = new Map(statuses.map((st) => [st.deviceId, st]));
|
|
78001
|
-
const detectionMacros = require_dist.MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
|
|
78415
|
+
const emittableByDevice = new Map(await Promise.all(deviceIds.map(async (deviceId) => [deviceId, await this.emittableFromConfig(deviceId, "listEventKindsBatch")])));
|
|
78002
78416
|
const topology = await prefetchDeviceTopology({
|
|
78003
78417
|
getLinkedDevicesBatch: (i) => api.deviceManager.getLinkedDevicesBatch.query(i),
|
|
78004
78418
|
getBindingsBatch: (i) => api.deviceManager.getBindingsBatch.query(i)
|
|
@@ -78010,14 +78424,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78010
78424
|
return null;
|
|
78011
78425
|
});
|
|
78012
78426
|
return composeEventKindsForDevices(deviceIds, async (deviceId) => {
|
|
78013
|
-
const
|
|
78014
|
-
const detectionOn = status?.detection != null;
|
|
78015
|
-
const audioOn = status?.audio?.enabled === true;
|
|
78427
|
+
const emittable = emittableByDevice.get(deviceId) ?? NO_EMITTABLE;
|
|
78016
78428
|
return composeEventKinds({
|
|
78017
78429
|
linkedDevices: topology?.linkedDevices ?? { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
78018
78430
|
bindings: topology?.bindings ?? { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
78019
|
-
enabledMacroClasses: async () =>
|
|
78020
|
-
audioEnabled: async () =>
|
|
78431
|
+
enabledMacroClasses: async () => emittable.macroClasses,
|
|
78432
|
+
audioEnabled: async () => emittable.audioEnabled,
|
|
78021
78433
|
packageZonesEnabled: async (id) => {
|
|
78022
78434
|
return (await this.resolveDevicePackageRules(id)).some((r) => r.enabled !== false);
|
|
78023
78435
|
},
|