@camstack/addon-post-analysis 1.2.49 → 1.2.50
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-DYOxxL_e.mjs → dist-BhadCxSQ.mjs} +473 -16
- package/dist/{dist-BaqsyWbd.js → dist-Bm5GpbGn.js} +484 -15
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Bjc_xs4R.mjs → node-Co0YpEFr.mjs} +1 -0
- package/dist/{node-CdY4IvEy.js → node-HEkeoPPw.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B-HELo_d.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B0B7ZGiM.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CiIJt_s8.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJ3AVT6B.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-wlxZFMRT.mjs → hostInit-BahNk1yZ.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1236 -516
- package/dist/pipeline-analytics/index.mjs +1235 -515
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as literal, A as faceGalleryCapability, B as videoclipsCapability, C as audioMetricsCapability, D as defineCustomActions, E as customAction, F as pipelineAnalyticsCapability, G as createEvent, H as errMsg, I as plateGalleryCapability, J as nodePin, K as hydrateSchema, L as readDeviceStateFrom, M as isScheduleActive, N as kebabToCamel, P as notificationRulesCapability, Q as boolean, R as subKindsOf, S as alarmPanelCapability, T as cosineSimilarity, U as BaseAddon, V as zoneAnalyticsCapability, W as DeviceType, X as _enum, Y as sleep, Z as array, _ as RetrainStatusSchema, a as MACRO_LABELS, at as EventCategory, b as TrackSourceSchema, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as number, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, it as unknown, k as encodeVectorBase64, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, nt as record, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as isDeviceScopedCap, r as EVENT_KIND_BY_CAP, rt as string, s as NC_TAXONOMY, t as BaseDevice, tt as object, u as NcRulePatchSchema, v as TimelapseRuleInputSchema, w as buildEventKindDescriptor, x as addonWidgetsSourceCapability, y as TimelapseRuleSchema, z as vectorDimFromBase64 } from "../dist-BhadCxSQ.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1583,66 +1583,6 @@ function evaluateRule(rule, subject, deviceState) {
|
|
|
1583
1583
|
matchedOn: presentConditionIds(c)
|
|
1584
1584
|
};
|
|
1585
1585
|
}
|
|
1586
|
-
var WEEKDAY_TO_DAY = {
|
|
1587
|
-
Sun: 0,
|
|
1588
|
-
Mon: 1,
|
|
1589
|
-
Tue: 2,
|
|
1590
|
-
Wed: 3,
|
|
1591
|
-
Thu: 4,
|
|
1592
|
-
Fri: 5,
|
|
1593
|
-
Sat: 6
|
|
1594
|
-
};
|
|
1595
|
-
/** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
|
|
1596
|
-
* An invalid/unknown IANA name falls back to the host timezone. */
|
|
1597
|
-
function localDayMinute(atMs, timezone) {
|
|
1598
|
-
const d = new Date(atMs);
|
|
1599
|
-
if (timezone !== void 0) try {
|
|
1600
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1601
|
-
timeZone: timezone,
|
|
1602
|
-
weekday: "short",
|
|
1603
|
-
hour: "numeric",
|
|
1604
|
-
minute: "numeric",
|
|
1605
|
-
hourCycle: "h23"
|
|
1606
|
-
}).formatToParts(d);
|
|
1607
|
-
let weekday;
|
|
1608
|
-
let hour;
|
|
1609
|
-
let minute;
|
|
1610
|
-
for (const p of parts) if (p.type === "weekday") weekday = p.value;
|
|
1611
|
-
else if (p.type === "hour") hour = Number(p.value);
|
|
1612
|
-
else if (p.type === "minute") minute = Number(p.value);
|
|
1613
|
-
const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
|
|
1614
|
-
if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
|
|
1615
|
-
day,
|
|
1616
|
-
minute: hour * 60 + minute
|
|
1617
|
-
};
|
|
1618
|
-
} catch {}
|
|
1619
|
-
return {
|
|
1620
|
-
day: d.getDay(),
|
|
1621
|
-
minute: d.getHours() * 60 + d.getMinutes()
|
|
1622
|
-
};
|
|
1623
|
-
}
|
|
1624
|
-
/**
|
|
1625
|
-
* Is the schedule active at `atMs`? No schedule = always active. Windows
|
|
1626
|
-
* are OR'd; a window with `startMinute > endMinute` crosses midnight (it
|
|
1627
|
-
* starts on a listed day and spills into the next). `invert` flips the
|
|
1628
|
-
* result (active OUTSIDE the windows).
|
|
1629
|
-
*/
|
|
1630
|
-
function isScheduleActive(schedule, atMs) {
|
|
1631
|
-
if (schedule === void 0) return true;
|
|
1632
|
-
const { day, minute } = localDayMinute(atMs, schedule.timezone);
|
|
1633
|
-
const prevDay = (day + 6) % 7;
|
|
1634
|
-
let inside = false;
|
|
1635
|
-
for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
|
|
1636
|
-
if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
|
|
1637
|
-
inside = true;
|
|
1638
|
-
break;
|
|
1639
|
-
}
|
|
1640
|
-
} else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
|
|
1641
|
-
inside = true;
|
|
1642
|
-
break;
|
|
1643
|
-
}
|
|
1644
|
-
return schedule.invert === true ? !inside : inside;
|
|
1645
|
-
}
|
|
1646
1586
|
/** Normalize a plate string: uppercase, alphanumeric only. */
|
|
1647
1587
|
function normalizePlate$1(value) {
|
|
1648
1588
|
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
@@ -13509,6 +13449,27 @@ var StoragePressureTracker = class {
|
|
|
13509
13449
|
};
|
|
13510
13450
|
//#endregion
|
|
13511
13451
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
13452
|
+
var OWNER_KINDS = [
|
|
13453
|
+
"event",
|
|
13454
|
+
"track",
|
|
13455
|
+
"face",
|
|
13456
|
+
"identity",
|
|
13457
|
+
"plate",
|
|
13458
|
+
"vehicle"
|
|
13459
|
+
];
|
|
13460
|
+
/**
|
|
13461
|
+
* Narrow an arbitrary string to an `OwnerKind`.
|
|
13462
|
+
*
|
|
13463
|
+
* Exists so a caller that builds owner scopes from data (the training-export
|
|
13464
|
+
* plan walks `track` / `face` / `plate` / `event` as strings) can reach the
|
|
13465
|
+
* store without a cast. The list is derived from `OWNER_KINDS`, so a new owner
|
|
13466
|
+
* kind is admitted here the moment it is declared there — a hand-written value
|
|
13467
|
+
* list would silently reject it, which is the failure `TrackSourceSchema` was
|
|
13468
|
+
* already bitten by on the track row.
|
|
13469
|
+
*/
|
|
13470
|
+
function isMediaOwnerKind(value) {
|
|
13471
|
+
return OWNER_KINDS.some((kind) => kind === value);
|
|
13472
|
+
}
|
|
13512
13473
|
var MEDIA_COLLECTION = "pipeline-analytics:media";
|
|
13513
13474
|
/** Owner-id prefix for a track's buffered FACE crop. Invariant established by
|
|
13514
13475
|
* the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
|
|
@@ -14732,6 +14693,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
14732
14693
|
bestEventId: bestEventId ?? t.trackId,
|
|
14733
14694
|
windowMs: t.lastSeen - t.firstSeen,
|
|
14734
14695
|
...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
|
|
14696
|
+
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
14735
14697
|
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
14736
14698
|
});
|
|
14737
14699
|
}
|
|
@@ -15967,8 +15929,35 @@ function classifyTrackAppearance(input) {
|
|
|
15967
15929
|
if (input.inPrevActive) return "continuing";
|
|
15968
15930
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
15969
15931
|
}
|
|
15970
|
-
|
|
15971
|
-
|
|
15932
|
+
/** Thrown when the device is already holding its full staging budget. Distinct
|
|
15933
|
+
* type so a surface can tell "you are out of room" from "that track is gone". */
|
|
15934
|
+
var StagingBudgetExceededError = class extends Error {
|
|
15935
|
+
deviceId;
|
|
15936
|
+
staging;
|
|
15937
|
+
constructor(deviceId, staging) {
|
|
15938
|
+
super(`setTrackFlags: device ${deviceId} already has ${staging} tracks in retrain staging (limit 500). Un-mark some tracks before marking more.`);
|
|
15939
|
+
this.deviceId = deviceId;
|
|
15940
|
+
this.staging = staging;
|
|
15941
|
+
this.name = "StagingBudgetExceededError";
|
|
15942
|
+
}
|
|
15943
|
+
};
|
|
15944
|
+
/**
|
|
15945
|
+
* Thrown when a `markForTrain` write — in EITHER direction — targets a track
|
|
15946
|
+
* that has already been trained.
|
|
15947
|
+
*
|
|
15948
|
+
* Its own error type, next to the budget's, because the two refusals need
|
|
15949
|
+
* different sentences on the surface: one says "make room", the other says "this
|
|
15950
|
+
* track is already in the dataset". Collapsing them into a generic failure is
|
|
15951
|
+
* how a toggle ends up flipping back with nothing to explain it.
|
|
15952
|
+
*/
|
|
15953
|
+
var TrackAlreadyTrainedError = class extends Error {
|
|
15954
|
+
trackId;
|
|
15955
|
+
constructor(trackId) {
|
|
15956
|
+
super(`setTrackFlags: track ${trackId} is already trained — its frames are copies in the retrain dataset. Re-staging it is an action of the retrain page, not this toggle.`);
|
|
15957
|
+
this.trackId = trackId;
|
|
15958
|
+
this.name = "TrackAlreadyTrainedError";
|
|
15959
|
+
}
|
|
15960
|
+
};
|
|
15972
15961
|
/**
|
|
15973
15962
|
* Apply a partial flag patch and report the RESOLVED state of both flags.
|
|
15974
15963
|
*
|
|
@@ -15981,19 +15970,63 @@ function classifyTrackAppearance(input) {
|
|
|
15981
15970
|
* An empty patch is a pure read, not an error: the three calling surfaces build
|
|
15982
15971
|
* the patch from their own toggle, and "nothing changed" is a legitimate outcome
|
|
15983
15972
|
* that must not surface as a failure.
|
|
15984
|
-
|
|
15985
|
-
|
|
15973
|
+
*
|
|
15974
|
+
* **`trained` is TERMINAL for this toggle, in both directions.** A trained
|
|
15975
|
+
* track's chosen frames are already copies inside the retrain dataset; staging
|
|
15976
|
+
* it again from a generic checkbox is how the same material gets annotated twice
|
|
15977
|
+
* under two different ground truths, and un-marking it would claim to undo a
|
|
15978
|
+
* training pass it cannot undo. The deliberate return to staging belongs to the
|
|
15979
|
+
* retrain page, which is also the only thing that produces `trained`. A `debug`
|
|
15980
|
+
* patch on a trained track is untouched by this — `debug` is not the lifecycle.
|
|
15981
|
+
*
|
|
15982
|
+
* **The budget is checked only when STAGING is being entered.** Un-marking is
|
|
15983
|
+
* always allowed and so is a `debug` patch — a limit that blocked the way OUT of
|
|
15984
|
+
* the limit would be a trap, and a device at its ceiling would become unable to
|
|
15985
|
+
* release the very storage the ceiling exists to protect. Re-marking a track
|
|
15986
|
+
* that is already staging is not a new hold either, so it does not consume
|
|
15987
|
+
* budget and cannot be refused by it.
|
|
15988
|
+
*/
|
|
15989
|
+
async function applyTrackFlags(deps, input) {
|
|
15990
|
+
const { store, logger } = deps;
|
|
15986
15991
|
const current = await store.readFlags(input.trackId);
|
|
15987
15992
|
if (current === null) throw new Error(`setTrackFlags: unknown track ${input.trackId}`);
|
|
15993
|
+
if (input.flags.markForTrain !== void 0 && current.retrainStatus === "trained") {
|
|
15994
|
+
logger.warn("setTrackFlags refused — track already trained", {
|
|
15995
|
+
tags: { deviceId: input.deviceId },
|
|
15996
|
+
meta: {
|
|
15997
|
+
deviceId: input.deviceId,
|
|
15998
|
+
trackId: input.trackId,
|
|
15999
|
+
requested: input.flags.markForTrain
|
|
16000
|
+
}
|
|
16001
|
+
});
|
|
16002
|
+
throw new TrackAlreadyTrainedError(input.trackId);
|
|
16003
|
+
}
|
|
16004
|
+
if (input.flags.markForTrain === true && current.retrainStatus !== "staging") {
|
|
16005
|
+
const staging = await store.countStaging({ deviceId: input.deviceId });
|
|
16006
|
+
if (staging >= 500) {
|
|
16007
|
+
logger.warn("setTrackFlags refused — device staging budget full", {
|
|
16008
|
+
tags: { deviceId: input.deviceId },
|
|
16009
|
+
meta: {
|
|
16010
|
+
deviceId: input.deviceId,
|
|
16011
|
+
trackId: input.trackId,
|
|
16012
|
+
staging,
|
|
16013
|
+
limit: 500
|
|
16014
|
+
}
|
|
16015
|
+
});
|
|
16016
|
+
throw new StagingBudgetExceededError(input.deviceId, staging);
|
|
16017
|
+
}
|
|
16018
|
+
}
|
|
15988
16019
|
const patch = {
|
|
15989
16020
|
...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
|
|
15990
16021
|
...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
|
|
15991
16022
|
};
|
|
15992
16023
|
if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
|
|
16024
|
+
const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
|
|
15993
16025
|
return {
|
|
15994
16026
|
trackId: input.trackId,
|
|
15995
|
-
markForTrain:
|
|
15996
|
-
debug: patch.debug ?? current.debug
|
|
16027
|
+
markForTrain: retrainStatus === "staging",
|
|
16028
|
+
debug: patch.debug ?? current.debug,
|
|
16029
|
+
retrainStatus
|
|
15997
16030
|
};
|
|
15998
16031
|
}
|
|
15999
16032
|
//#endregion
|
|
@@ -18884,8 +18917,10 @@ var TRACKS_COLUMNS = [
|
|
|
18884
18917
|
type: "JSON"
|
|
18885
18918
|
},
|
|
18886
18919
|
{
|
|
18887
|
-
name: "
|
|
18888
|
-
type: "
|
|
18920
|
+
name: "retrainStatus",
|
|
18921
|
+
type: "TEXT",
|
|
18922
|
+
notNull: true,
|
|
18923
|
+
defaultValue: "none"
|
|
18889
18924
|
},
|
|
18890
18925
|
{
|
|
18891
18926
|
name: "debug",
|
|
@@ -18962,7 +18997,10 @@ function cloneTrack(t) {
|
|
|
18962
18997
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
18963
18998
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
18964
18999
|
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {},
|
|
18965
|
-
...t.
|
|
19000
|
+
...t.retrainStatus !== void 0 ? {
|
|
19001
|
+
retrainStatus: t.retrainStatus,
|
|
19002
|
+
markForTrain: t.retrainStatus === "staging"
|
|
19003
|
+
} : {},
|
|
18966
19004
|
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
18967
19005
|
};
|
|
18968
19006
|
}
|
|
@@ -19244,24 +19282,30 @@ var TrackStore = class {
|
|
|
19244
19282
|
}
|
|
19245
19283
|
}
|
|
19246
19284
|
/**
|
|
19247
|
-
* Apply a PARTIAL patch of the per-track operator
|
|
19285
|
+
* Apply a PARTIAL patch of the per-track operator state (`markForTrain`,
|
|
19248
19286
|
* `debug`). Mirrors `setLabel`'s dual write — in-RAM active entry AND the
|
|
19249
|
-
* persisted row — so a
|
|
19287
|
+
* persisted row — so a mark set on a LIVE track is carried into
|
|
19250
19288
|
* `persistCompleted` at expiry instead of evaporating with the RAM state.
|
|
19251
19289
|
*
|
|
19290
|
+
* `markForTrain` is a LIFECYCLE move, not a boolean write: `true` is
|
|
19291
|
+
* `→ staging`, `false` is `→ none`. This method does not police the move —
|
|
19292
|
+
* the `trained` refusal lives in `applyTrackFlags`, the single cap-level write
|
|
19293
|
+
* surface, which is the only caller that has already read the current state.
|
|
19294
|
+
*
|
|
19252
19295
|
* An omitted key is left untouched; `false` is WRITTEN, not dropped, so an
|
|
19253
|
-
* explicitly un-
|
|
19296
|
+
* explicitly un-marked track stays distinguishable from one never touched.
|
|
19254
19297
|
* An empty patch is a no-op (an update with no columns is not a useful
|
|
19255
19298
|
* statement, and the store would reject or silently ignore it).
|
|
19256
19299
|
*/
|
|
19257
19300
|
async setFlags(trackId, patch) {
|
|
19258
19301
|
const data = {};
|
|
19259
|
-
|
|
19302
|
+
const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
|
|
19303
|
+
if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
|
|
19260
19304
|
if (patch.debug !== void 0) data["debug"] = patch.debug;
|
|
19261
19305
|
if (Object.keys(data).length === 0) return;
|
|
19262
19306
|
const active = this.active.get(trackId);
|
|
19263
19307
|
if (active) {
|
|
19264
|
-
if (
|
|
19308
|
+
if (nextStatus !== void 0) active.retrainStatus = nextStatus;
|
|
19265
19309
|
if (patch.debug !== void 0) active.debug = patch.debug;
|
|
19266
19310
|
}
|
|
19267
19311
|
try {
|
|
@@ -19278,20 +19322,25 @@ var TrackStore = class {
|
|
|
19278
19322
|
}
|
|
19279
19323
|
}
|
|
19280
19324
|
/**
|
|
19281
|
-
* Resolved
|
|
19282
|
-
* row.
|
|
19283
|
-
*
|
|
19325
|
+
* Resolved operator state for a track — active entry first, then the
|
|
19326
|
+
* persisted row. `null` when the track is unknown to both.
|
|
19327
|
+
*
|
|
19328
|
+
* Reports the LIFECYCLE STATUS, not the derived boolean, because the one
|
|
19329
|
+
* caller that matters decides on it: `applyTrackFlags` must tell `trained`
|
|
19330
|
+
* (refuse the write) from `none` (accept it), and both report
|
|
19331
|
+
* `markForTrain: false`. Absent collapses to `'none'` / `false` — the
|
|
19332
|
+
* "never touched" tri-state exists only in RAM and carries no decision.
|
|
19284
19333
|
*/
|
|
19285
19334
|
async readFlags(trackId) {
|
|
19286
19335
|
const active = this.active.get(trackId);
|
|
19287
19336
|
if (active) return {
|
|
19288
|
-
|
|
19337
|
+
retrainStatus: active.retrainStatus ?? "none",
|
|
19289
19338
|
debug: active.debug === true
|
|
19290
19339
|
};
|
|
19291
19340
|
const persisted = await this.getPersistedByTrackId(trackId);
|
|
19292
19341
|
if (!persisted) return null;
|
|
19293
19342
|
return {
|
|
19294
|
-
|
|
19343
|
+
retrainStatus: persisted.retrainStatus ?? "none",
|
|
19295
19344
|
debug: persisted.debug === true
|
|
19296
19345
|
};
|
|
19297
19346
|
}
|
|
@@ -19356,10 +19405,11 @@ var TrackStore = class {
|
|
|
19356
19405
|
* `offset` by the number of rows a page did NOT remove, which terminates
|
|
19357
19406
|
* because the offset only grows and the result set is finite.
|
|
19358
19407
|
*
|
|
19359
|
-
* This is the seam
|
|
19360
|
-
*
|
|
19361
|
-
*
|
|
19362
|
-
*
|
|
19408
|
+
* This is the seam the retention PIN lands on, and it lands here ONLY: after
|
|
19409
|
+
* D61 the track row is the single cascading root, so one exemption on this one
|
|
19410
|
+
* query holds the track's events, crops, keyframes and CLIP vector with it —
|
|
19411
|
+
* where D59 had to plan for four exemptions across four disagreeing sweeps.
|
|
19412
|
+
* The pin is `whereIn: { retrainStatus: ['none','trained'] }` below (D81).
|
|
19363
19413
|
*
|
|
19364
19414
|
* Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
|
|
19365
19415
|
* → strictly `< cutoffMs`. Best-effort — a query failure yields [].
|
|
@@ -19370,6 +19420,7 @@ var TrackStore = class {
|
|
|
19370
19420
|
collection: TRACKS_COLLECTION,
|
|
19371
19421
|
filter: {
|
|
19372
19422
|
where: { deviceId },
|
|
19423
|
+
whereIn: { retrainStatus: ["none", "trained"] },
|
|
19373
19424
|
whereBetween: { lastSeen: [0, cutoffMs - 1] },
|
|
19374
19425
|
orderBy: {
|
|
19375
19426
|
field: "lastSeen",
|
|
@@ -19393,6 +19444,87 @@ var TrackStore = class {
|
|
|
19393
19444
|
}
|
|
19394
19445
|
}
|
|
19395
19446
|
/**
|
|
19447
|
+
* The device's STAGING tracks, newest first — the training-export selection.
|
|
19448
|
+
*
|
|
19449
|
+
* `retrainStatus = 'staging'` is exactly the complement of the sweep's
|
|
19450
|
+
* `whereIn ['none','trained']` (all three safe against NULL because the column
|
|
19451
|
+
* is NOT NULL DEFAULT 'none') — the load-bearing part: this is the set
|
|
19452
|
+
* retention has promised to still be there. `trained` is deliberately NOT in
|
|
19453
|
+
* it: the sweep is free to delete a trained track mid-pass, and exporting one
|
|
19454
|
+
* is how a manifest ends up describing files the archive does not contain.
|
|
19455
|
+
*
|
|
19456
|
+
* `projection: 'slim'` because an export enumerates media; it has no use for
|
|
19457
|
+
* the per-frame trajectory JSON, which is the heaviest column on the row.
|
|
19458
|
+
*/
|
|
19459
|
+
async listStaging(deviceId, limit) {
|
|
19460
|
+
try {
|
|
19461
|
+
return (await this.store.query.query({
|
|
19462
|
+
collection: TRACKS_COLLECTION,
|
|
19463
|
+
filter: {
|
|
19464
|
+
where: {
|
|
19465
|
+
deviceId,
|
|
19466
|
+
retrainStatus: "staging"
|
|
19467
|
+
},
|
|
19468
|
+
orderBy: {
|
|
19469
|
+
field: "lastSeen",
|
|
19470
|
+
direction: "desc"
|
|
19471
|
+
},
|
|
19472
|
+
limit
|
|
19473
|
+
}
|
|
19474
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
|
|
19475
|
+
} catch (err) {
|
|
19476
|
+
this.logger.warn("TrackStore.listStaging failed", {
|
|
19477
|
+
tags: { deviceId },
|
|
19478
|
+
meta: {
|
|
19479
|
+
deviceId,
|
|
19480
|
+
limit,
|
|
19481
|
+
error: String(err)
|
|
19482
|
+
}
|
|
19483
|
+
});
|
|
19484
|
+
return [];
|
|
19485
|
+
}
|
|
19486
|
+
}
|
|
19487
|
+
/**
|
|
19488
|
+
* How many of this device's tracks are STAGING — optionally only those older
|
|
19489
|
+
* than a cutoff, i.e. exactly the rows a sweep just declined to touch.
|
|
19490
|
+
*
|
|
19491
|
+
* Two callers, one query. The sweep uses `olderThanMs` for its report; the
|
|
19492
|
+
* `setTrackFlags` body uses the unbounded form to enforce the per-device
|
|
19493
|
+
* staging budget, because staging is unbounded in TIME by construction and
|
|
19494
|
+
* something has to bound it in COUNT (D81, and D59 asked for exactly this).
|
|
19495
|
+
* `trained` tracks are NOT counted — they are evictable again, so they cost
|
|
19496
|
+
* the budget nothing.
|
|
19497
|
+
*
|
|
19498
|
+
* Best-effort — a failure reports 0 rather than disturbing the caller. The
|
|
19499
|
+
* budget check reads that as "there is room", which is the safe direction: a
|
|
19500
|
+
* failed count must not stop an operator marking a track.
|
|
19501
|
+
*/
|
|
19502
|
+
async countStaging(query) {
|
|
19503
|
+
const { deviceId, olderThanMs } = query;
|
|
19504
|
+
try {
|
|
19505
|
+
return await this.store.count.query({
|
|
19506
|
+
collection: TRACKS_COLLECTION,
|
|
19507
|
+
filter: {
|
|
19508
|
+
where: {
|
|
19509
|
+
deviceId,
|
|
19510
|
+
retrainStatus: "staging"
|
|
19511
|
+
},
|
|
19512
|
+
...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
|
|
19513
|
+
}
|
|
19514
|
+
});
|
|
19515
|
+
} catch (err) {
|
|
19516
|
+
this.logger.warn("TrackStore.countStaging failed", {
|
|
19517
|
+
tags: { deviceId },
|
|
19518
|
+
meta: {
|
|
19519
|
+
deviceId,
|
|
19520
|
+
olderThanMs,
|
|
19521
|
+
error: String(err)
|
|
19522
|
+
}
|
|
19523
|
+
});
|
|
19524
|
+
return 0;
|
|
19525
|
+
}
|
|
19526
|
+
}
|
|
19527
|
+
/**
|
|
19396
19528
|
* The SUBSET of `trackIds` that still have a persisted row — the orphan
|
|
19397
19529
|
* audit's liveness probe.
|
|
19398
19530
|
*
|
|
@@ -19732,7 +19864,7 @@ var TrackStore = class {
|
|
|
19732
19864
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
19733
19865
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
19734
19866
|
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
19735
|
-
...t.
|
|
19867
|
+
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
19736
19868
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
19737
19869
|
...envelope !== null && dims !== void 0 ? {
|
|
19738
19870
|
envMinX: envelope.minX,
|
|
@@ -19766,7 +19898,7 @@ var TrackStore = class {
|
|
|
19766
19898
|
const bestEventId = data["bestEventId"];
|
|
19767
19899
|
const importanceReason = data["importanceReason"];
|
|
19768
19900
|
const audioLabels = data["audioLabels"];
|
|
19769
|
-
const
|
|
19901
|
+
const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
|
|
19770
19902
|
const debug = data["debug"];
|
|
19771
19903
|
const envMinX = data["envMinX"];
|
|
19772
19904
|
const envMinY = data["envMinY"];
|
|
@@ -19798,7 +19930,10 @@ var TrackStore = class {
|
|
|
19798
19930
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
19799
19931
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
19800
19932
|
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
19801
|
-
...
|
|
19933
|
+
...retrainParsed.success ? {
|
|
19934
|
+
retrainStatus: retrainParsed.data,
|
|
19935
|
+
markForTrain: retrainParsed.data === "staging"
|
|
19936
|
+
} : {},
|
|
19802
19937
|
...typeof debug === "boolean" ? { debug } : {},
|
|
19803
19938
|
...envelope !== null ? { envelope } : {}
|
|
19804
19939
|
};
|
|
@@ -21677,120 +21812,415 @@ function wireSubscriptions(deps) {
|
|
|
21677
21812
|
}));
|
|
21678
21813
|
return unsubs;
|
|
21679
21814
|
}
|
|
21680
|
-
//#endregion
|
|
21681
|
-
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
21682
21815
|
/**
|
|
21683
|
-
*
|
|
21684
|
-
*
|
|
21685
|
-
*
|
|
21686
|
-
*
|
|
21687
|
-
*
|
|
21688
|
-
* (c) sensor kinds contributed by the camera's OWN bindings and by its
|
|
21689
|
-
* LINKED devices (device-manager `getLinkedDevices`), one descriptor per
|
|
21690
|
-
* bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
|
|
21691
|
-
* Binding-driven per device (`getBindings`) — never a global cap
|
|
21692
|
-
* enumeration (D12).
|
|
21693
|
-
*
|
|
21694
|
-
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
21695
|
-
* linked camera ids, plus the camera id set) with a TTL, so the
|
|
21696
|
-
* `DeviceStateChanged` handler stays cheap at bus rate.
|
|
21816
|
+
* A baseline whose last sample is older than `baselineMs × this` no longer
|
|
21817
|
+
* describes the camera — an on-motion camera detaches for hours and comes
|
|
21818
|
+
* back to a different scene. Decaying it forward would make the first frame
|
|
21819
|
+
* back either a guaranteed marker (mean still holds the old quiet) or a
|
|
21820
|
+
* guaranteed miss; discarding it is the honest option and costs one warm-up.
|
|
21697
21821
|
*/
|
|
21822
|
+
var STALE_BASELINE_FACTOR = 5;
|
|
21698
21823
|
/**
|
|
21699
|
-
*
|
|
21700
|
-
*
|
|
21701
|
-
*
|
|
21702
|
-
*
|
|
21703
|
-
* camera-side button. Those have no other route into the per-camera event
|
|
21704
|
-
* substrate, so the camera includes itself.
|
|
21705
|
-
*
|
|
21706
|
-
* `motion` is the exception, and today the ONLY one: camera motion already
|
|
21707
|
-
* has a dedicated camera-native path (`MotionAnalysis` /
|
|
21708
|
-
* `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
|
|
21709
|
-
* taxonomy lane). Every real camera provider registers a native `motion` cap
|
|
21710
|
-
* on the camera device, so self-inclusion would mint a SECOND, differently
|
|
21711
|
-
* named copy (`motion-sensor`) of every motion transition, at motion rate.
|
|
21712
|
-
*
|
|
21713
|
-
* Add a cap here only when the camera already emits that event through
|
|
21714
|
-
* another ingest path — never to reduce noise.
|
|
21824
|
+
* Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
|
|
21825
|
+
* has MAD 0 and would make every deviation infinite. 1 dB is below the
|
|
21826
|
+
* quantisation of any real level meter, so it only ever binds on a synthetic
|
|
21827
|
+
* or a genuinely dead-steady signal — where bar 2 is what should decide.
|
|
21715
21828
|
*/
|
|
21716
|
-
var
|
|
21717
|
-
|
|
21718
|
-
|
|
21719
|
-
|
|
21829
|
+
var MIN_MAD_DB = 1;
|
|
21830
|
+
function emptyAudioBaseline() {
|
|
21831
|
+
return {
|
|
21832
|
+
count: 0,
|
|
21833
|
+
mean: 0,
|
|
21834
|
+
mad: 0,
|
|
21835
|
+
firstMs: 0,
|
|
21836
|
+
lastMs: 0
|
|
21837
|
+
};
|
|
21838
|
+
}
|
|
21839
|
+
/** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
|
|
21840
|
+
function seed(sample) {
|
|
21841
|
+
return {
|
|
21842
|
+
count: 1,
|
|
21843
|
+
mean: sample.dbfs,
|
|
21844
|
+
mad: 0,
|
|
21845
|
+
firstMs: sample.timestampMs,
|
|
21846
|
+
lastMs: sample.timestampMs
|
|
21847
|
+
};
|
|
21720
21848
|
}
|
|
21721
21849
|
/**
|
|
21722
|
-
*
|
|
21723
|
-
* (a) motion built-in — always;
|
|
21724
|
-
* (b) each enabled detection macro + its taxonomy subs (reverse COCO);
|
|
21725
|
-
* (c) audio macro + its subs when the classifier is enabled;
|
|
21726
|
-
* (d) package kinds when a package zone rule is enabled;
|
|
21727
|
-
* (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
|
|
21728
|
-
* LINKED devices (binding-driven), deduped per (kind, source deviceId).
|
|
21729
|
-
* Every descriptor carries parentKind/level/iconId/labelKey via the single
|
|
21730
|
-
* taxonomy dictionary — no color/icon is declared here.
|
|
21731
|
-
*/
|
|
21732
|
-
/**
|
|
21733
|
-
* Run one lookup, reporting a failure instead of propagating it.
|
|
21850
|
+
* Fold `sample` into `state` and decide whether it is a marker candidate.
|
|
21734
21851
|
*
|
|
21735
|
-
*
|
|
21736
|
-
*
|
|
21737
|
-
*
|
|
21738
|
-
*
|
|
21739
|
-
* is an unhandled rejection.
|
|
21852
|
+
* The verdict is measured against the PRIOR baseline (this frame excluded) so
|
|
21853
|
+
* a lone spike is not diluted by itself — the same reasoning
|
|
21854
|
+
* `audio-level-detector` uses for its `priorMean`. The state returned always
|
|
21855
|
+
* includes the sample, whatever the verdict.
|
|
21740
21856
|
*/
|
|
21741
|
-
|
|
21742
|
-
|
|
21743
|
-
|
|
21744
|
-
|
|
21745
|
-
|
|
21746
|
-
|
|
21747
|
-
|
|
21748
|
-
}
|
|
21749
|
-
async function composeEventKinds(deps, deviceId) {
|
|
21750
|
-
const out = [];
|
|
21751
|
-
const cameraSource = {
|
|
21752
|
-
capName: "pipeline-analytics",
|
|
21753
|
-
deviceId
|
|
21857
|
+
function observeAudioAnomaly(state, sample, cfg) {
|
|
21858
|
+
const isFirst = state.count === 0;
|
|
21859
|
+
const gapMs = sample.timestampMs - state.lastMs;
|
|
21860
|
+
const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
|
|
21861
|
+
if (isFirst || isStale) return {
|
|
21862
|
+
verdict: { kind: "warmup" },
|
|
21863
|
+
nextState: seed(sample)
|
|
21754
21864
|
};
|
|
21755
|
-
const
|
|
21756
|
-
|
|
21757
|
-
|
|
21865
|
+
const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
|
|
21866
|
+
const priorMean = state.mean;
|
|
21867
|
+
const priorMad = state.mad;
|
|
21868
|
+
const nextState = {
|
|
21869
|
+
count: state.count + 1,
|
|
21870
|
+
mean: priorMean + alpha * (sample.dbfs - priorMean),
|
|
21871
|
+
mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
|
|
21872
|
+
firstMs: state.firstMs,
|
|
21873
|
+
lastMs: sample.timestampMs
|
|
21758
21874
|
};
|
|
21759
|
-
|
|
21760
|
-
|
|
21761
|
-
|
|
21762
|
-
|
|
21763
|
-
|
|
21764
|
-
|
|
21765
|
-
|
|
21766
|
-
|
|
21767
|
-
|
|
21768
|
-
|
|
21769
|
-
|
|
21770
|
-
|
|
21771
|
-
|
|
21772
|
-
|
|
21773
|
-
|
|
21774
|
-
|
|
21775
|
-
|
|
21776
|
-
|
|
21777
|
-
|
|
21778
|
-
|
|
21779
|
-
|
|
21780
|
-
|
|
21781
|
-
|
|
21782
|
-
|
|
21783
|
-
|
|
21784
|
-
|
|
21785
|
-
|
|
21786
|
-
|
|
21787
|
-
|
|
21788
|
-
|
|
21789
|
-
|
|
21790
|
-
|
|
21791
|
-
|
|
21792
|
-
|
|
21793
|
-
|
|
21875
|
+
if (sample.dbfs <= cfg.silenceFloorDbfs) return {
|
|
21876
|
+
verdict: { kind: "silent" },
|
|
21877
|
+
nextState
|
|
21878
|
+
};
|
|
21879
|
+
if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
|
|
21880
|
+
verdict: { kind: "warmup" },
|
|
21881
|
+
nextState
|
|
21882
|
+
};
|
|
21883
|
+
if (sample.dbfs < cfg.minDbfs) return {
|
|
21884
|
+
verdict: {
|
|
21885
|
+
kind: "not-loud",
|
|
21886
|
+
dbfs: sample.dbfs
|
|
21887
|
+
},
|
|
21888
|
+
nextState
|
|
21889
|
+
};
|
|
21890
|
+
const deviationDb = sample.dbfs - priorMean;
|
|
21891
|
+
const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
|
|
21892
|
+
const measured = {
|
|
21893
|
+
dbfs: sample.dbfs,
|
|
21894
|
+
mean: priorMean,
|
|
21895
|
+
deviationDb,
|
|
21896
|
+
deviations
|
|
21897
|
+
};
|
|
21898
|
+
return {
|
|
21899
|
+
verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
|
|
21900
|
+
kind: "anomalous",
|
|
21901
|
+
...measured
|
|
21902
|
+
} : {
|
|
21903
|
+
kind: "not-anomalous",
|
|
21904
|
+
...measured
|
|
21905
|
+
},
|
|
21906
|
+
nextState
|
|
21907
|
+
};
|
|
21908
|
+
}
|
|
21909
|
+
//#endregion
|
|
21910
|
+
//#region src/pipeline-analytics/services/audio-marker-projector.ts
|
|
21911
|
+
/** One info line per camera per minute, and only when something qualified. */
|
|
21912
|
+
var METRICS_WINDOW_MS = 6e4;
|
|
21913
|
+
var HOUR_MS = 60 * 6e4;
|
|
21914
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
21915
|
+
function emptyCounters(atMs) {
|
|
21916
|
+
return {
|
|
21917
|
+
windowStartMs: atMs,
|
|
21918
|
+
enabled: false,
|
|
21919
|
+
observed: 0,
|
|
21920
|
+
notLoud: 0,
|
|
21921
|
+
notAnomalous: 0,
|
|
21922
|
+
candidates: 0,
|
|
21923
|
+
suppressedMotion: 0,
|
|
21924
|
+
suppressedCooldown: 0,
|
|
21925
|
+
suppressedHourly: 0,
|
|
21926
|
+
suppressedDaily: 0,
|
|
21927
|
+
suppressedInFlight: 0,
|
|
21928
|
+
wouldFire: 0,
|
|
21929
|
+
materialized: 0,
|
|
21930
|
+
materializeFailed: 0
|
|
21931
|
+
};
|
|
21932
|
+
}
|
|
21933
|
+
var AudioMarkerProjector = class {
|
|
21934
|
+
deps;
|
|
21935
|
+
baselines = /* @__PURE__ */ new Map();
|
|
21936
|
+
rates = /* @__PURE__ */ new Map();
|
|
21937
|
+
counters = /* @__PURE__ */ new Map();
|
|
21938
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
21939
|
+
constructor(deps) {
|
|
21940
|
+
this.deps = deps;
|
|
21941
|
+
}
|
|
21942
|
+
/**
|
|
21943
|
+
* Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
|
|
21944
|
+
* the common path must not allocate a promise, so the decision is taken
|
|
21945
|
+
* inline and only an actual marker goes async (fire-and-forget, bounded by
|
|
21946
|
+
* the in-flight guard and the rate ceilings).
|
|
21947
|
+
*
|
|
21948
|
+
* Call this BEFORE the classification/level route split so the baseline
|
|
21949
|
+
* sees every frame. A baseline fed only by the level route is blind to
|
|
21950
|
+
* exactly the loud, confidently-classified frames a marker is for.
|
|
21951
|
+
*/
|
|
21952
|
+
observe(input, cfg) {
|
|
21953
|
+
const { deviceId, timestampMs } = input;
|
|
21954
|
+
const counters = this.countersFor(deviceId, timestampMs);
|
|
21955
|
+
counters.observed += 1;
|
|
21956
|
+
counters.enabled = cfg.enabled;
|
|
21957
|
+
const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
|
|
21958
|
+
timestampMs,
|
|
21959
|
+
dbfs: input.dbfs
|
|
21960
|
+
}, cfg);
|
|
21961
|
+
this.baselines.set(deviceId, res.nextState);
|
|
21962
|
+
this.maybeFlush(deviceId, timestampMs);
|
|
21963
|
+
switch (res.verdict.kind) {
|
|
21964
|
+
case "silent":
|
|
21965
|
+
case "warmup": return;
|
|
21966
|
+
case "not-loud":
|
|
21967
|
+
counters.notLoud += 1;
|
|
21968
|
+
return;
|
|
21969
|
+
case "not-anomalous":
|
|
21970
|
+
counters.notAnomalous += 1;
|
|
21971
|
+
return;
|
|
21972
|
+
case "anomalous": break;
|
|
21973
|
+
}
|
|
21974
|
+
counters.candidates += 1;
|
|
21975
|
+
if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
|
|
21976
|
+
counters.suppressedMotion += 1;
|
|
21977
|
+
return;
|
|
21978
|
+
}
|
|
21979
|
+
const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
|
|
21980
|
+
const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
|
|
21981
|
+
if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
|
|
21982
|
+
counters.suppressedCooldown += 1;
|
|
21983
|
+
return;
|
|
21984
|
+
}
|
|
21985
|
+
const recent = fired.filter((t) => timestampMs - t < DAY_MS);
|
|
21986
|
+
if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
|
|
21987
|
+
counters.suppressedHourly += 1;
|
|
21988
|
+
this.rates.set(deviceId, { firedAtMs: recent });
|
|
21989
|
+
return;
|
|
21990
|
+
}
|
|
21991
|
+
if (recent.length >= cfg.maxPerDay) {
|
|
21992
|
+
counters.suppressedDaily += 1;
|
|
21993
|
+
this.rates.set(deviceId, { firedAtMs: recent });
|
|
21994
|
+
return;
|
|
21995
|
+
}
|
|
21996
|
+
if (!cfg.enabled) {
|
|
21997
|
+
counters.wouldFire += 1;
|
|
21998
|
+
return;
|
|
21999
|
+
}
|
|
22000
|
+
if (this.inFlight.has(deviceId)) {
|
|
22001
|
+
counters.suppressedInFlight += 1;
|
|
22002
|
+
return;
|
|
22003
|
+
}
|
|
22004
|
+
this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
|
|
22005
|
+
counters.materialized += 1;
|
|
22006
|
+
this.inFlight.add(deviceId);
|
|
22007
|
+
this.materialize(input, counters);
|
|
22008
|
+
}
|
|
22009
|
+
/**
|
|
22010
|
+
* Emit and reset a camera's window if it has anything to say. Exposed so a
|
|
22011
|
+
* shutdown (or a test) can force the tally out rather than losing it.
|
|
22012
|
+
*/
|
|
22013
|
+
flush(nowMs) {
|
|
22014
|
+
for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
|
|
22015
|
+
}
|
|
22016
|
+
/** Drop all per-camera state for a device (unbind / device removal). */
|
|
22017
|
+
forgetDevice(deviceId) {
|
|
22018
|
+
this.baselines.delete(deviceId);
|
|
22019
|
+
this.rates.delete(deviceId);
|
|
22020
|
+
this.counters.delete(deviceId);
|
|
22021
|
+
this.inFlight.delete(deviceId);
|
|
22022
|
+
}
|
|
22023
|
+
async materialize(input, counters) {
|
|
22024
|
+
const { deviceId, timestampMs } = input;
|
|
22025
|
+
try {
|
|
22026
|
+
const audioLabels = buildAudioLabels(input);
|
|
22027
|
+
await this.deps.sink.materialize({
|
|
22028
|
+
cameraId: deviceId,
|
|
22029
|
+
sourceDeviceId: deviceId,
|
|
22030
|
+
source: "audio",
|
|
22031
|
+
kind: "audio",
|
|
22032
|
+
timestamp: timestampMs,
|
|
22033
|
+
...audioLabels !== void 0 ? { audioLabels } : {}
|
|
22034
|
+
});
|
|
22035
|
+
} catch (err) {
|
|
22036
|
+
counters.materialized -= 1;
|
|
22037
|
+
counters.materializeFailed += 1;
|
|
22038
|
+
this.deps.onError?.("materializeAudioMarker", err);
|
|
22039
|
+
this.deps.logger.warn("audio marker materialize failed", {
|
|
22040
|
+
tags: { deviceId },
|
|
22041
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
22042
|
+
});
|
|
22043
|
+
} finally {
|
|
22044
|
+
this.inFlight.delete(deviceId);
|
|
22045
|
+
}
|
|
22046
|
+
}
|
|
22047
|
+
countersFor(deviceId, atMs) {
|
|
22048
|
+
const existing = this.counters.get(deviceId);
|
|
22049
|
+
if (existing !== void 0) return existing;
|
|
22050
|
+
const fresh = emptyCounters(atMs);
|
|
22051
|
+
this.counters.set(deviceId, fresh);
|
|
22052
|
+
return fresh;
|
|
22053
|
+
}
|
|
22054
|
+
maybeFlush(deviceId, nowMs) {
|
|
22055
|
+
const c = this.counters.get(deviceId);
|
|
22056
|
+
if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22057
|
+
this.emitWindow(deviceId, nowMs, false);
|
|
22058
|
+
}
|
|
22059
|
+
/**
|
|
22060
|
+
* One line per camera per window — but ONLY when a frame reached the
|
|
22061
|
+
* candidate gate. A camera that heard nothing extraordinary has nothing to
|
|
22062
|
+
* report, and a line per camera per minute forever would bury the ones that
|
|
22063
|
+
* do. `force` still honours that: it flushes the window, it does not invent
|
|
22064
|
+
* a reason to speak.
|
|
22065
|
+
*/
|
|
22066
|
+
emitWindow(deviceId, nowMs, force) {
|
|
22067
|
+
const c = this.counters.get(deviceId);
|
|
22068
|
+
if (c === void 0) return;
|
|
22069
|
+
if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22070
|
+
if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
|
|
22071
|
+
tags: { deviceId },
|
|
22072
|
+
meta: {
|
|
22073
|
+
enabled: c.enabled,
|
|
22074
|
+
windowMs: nowMs - c.windowStartMs,
|
|
22075
|
+
observed: c.observed,
|
|
22076
|
+
notLoud: c.notLoud,
|
|
22077
|
+
notAnomalous: c.notAnomalous,
|
|
22078
|
+
candidates: c.candidates,
|
|
22079
|
+
suppressedMotion: c.suppressedMotion,
|
|
22080
|
+
suppressedCooldown: c.suppressedCooldown,
|
|
22081
|
+
suppressedHourly: c.suppressedHourly,
|
|
22082
|
+
suppressedDaily: c.suppressedDaily,
|
|
22083
|
+
suppressedInFlight: c.suppressedInFlight,
|
|
22084
|
+
wouldFire: c.wouldFire,
|
|
22085
|
+
materialized: c.materialized,
|
|
22086
|
+
materializeFailed: c.materializeFailed
|
|
22087
|
+
}
|
|
22088
|
+
});
|
|
22089
|
+
this.counters.set(deviceId, emptyCounters(nowMs));
|
|
22090
|
+
}
|
|
22091
|
+
};
|
|
22092
|
+
/**
|
|
22093
|
+
* The class heard on the anomalous frame, in the shape `Track.audioLabels`
|
|
22094
|
+
* already defines. Using that field is what lets a marker label itself
|
|
22095
|
+
* ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
|
|
22096
|
+
* the audio macro vocabulary collides with existing subs (`dog` is a COCO
|
|
22097
|
+
* animal, `siren` is a control), so a sub per audio class would put a barking
|
|
22098
|
+
* dog in the ANIMAL lane.
|
|
22099
|
+
*/
|
|
22100
|
+
function buildAudioLabels(input) {
|
|
22101
|
+
if (input.className === void 0) return void 0;
|
|
22102
|
+
return [{
|
|
22103
|
+
label: input.className,
|
|
22104
|
+
peakScore: input.score ?? 0,
|
|
22105
|
+
count: 1,
|
|
22106
|
+
firstAt: input.timestampMs,
|
|
22107
|
+
lastAt: input.timestampMs
|
|
22108
|
+
}];
|
|
22109
|
+
}
|
|
22110
|
+
//#endregion
|
|
22111
|
+
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
22112
|
+
/**
|
|
22113
|
+
* Extensible per-device event kinds (Part B).
|
|
22114
|
+
*
|
|
22115
|
+
* `composeEventKinds` builds the `listEventKinds` payload for a camera:
|
|
22116
|
+
* (a) built-ins — motion + audio, always present;
|
|
22117
|
+
* (b) detection classes actually OBSERVED on the device (track history);
|
|
22118
|
+
* (c) sensor kinds contributed by the camera's OWN bindings and by its
|
|
22119
|
+
* LINKED devices (device-manager `getLinkedDevices`), one descriptor per
|
|
22120
|
+
* bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
|
|
22121
|
+
* Binding-driven per device (`getBindings`) — never a global cap
|
|
22122
|
+
* enumeration (D12).
|
|
22123
|
+
*
|
|
22124
|
+
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
22125
|
+
* linked camera ids, plus the camera id set) with a TTL, so the
|
|
22126
|
+
* `DeviceStateChanged` handler stays cheap at bus rate.
|
|
22127
|
+
*/
|
|
22128
|
+
/**
|
|
22129
|
+
* Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
|
|
22130
|
+
*
|
|
22131
|
+
* A sensor cap bound on a camera makes the camera both the source and the
|
|
22132
|
+
* subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
|
|
22133
|
+
* camera-side button. Those have no other route into the per-camera event
|
|
22134
|
+
* substrate, so the camera includes itself.
|
|
22135
|
+
*
|
|
22136
|
+
* `motion` is the exception, and today the ONLY one: camera motion already
|
|
22137
|
+
* has a dedicated camera-native path (`MotionAnalysis` /
|
|
22138
|
+
* `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
|
|
22139
|
+
* taxonomy lane). Every real camera provider registers a native `motion` cap
|
|
22140
|
+
* on the camera device, so self-inclusion would mint a SECOND, differently
|
|
22141
|
+
* named copy (`motion-sensor`) of every motion transition, at motion rate.
|
|
22142
|
+
*
|
|
22143
|
+
* Add a cap here only when the camera already emits that event through
|
|
22144
|
+
* another ingest path — never to reduce noise.
|
|
22145
|
+
*/
|
|
22146
|
+
var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
|
|
22147
|
+
/** True when a camera may contribute `capName` for itself. */
|
|
22148
|
+
function selfCameraMayEmit(capName) {
|
|
22149
|
+
return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
|
|
22150
|
+
}
|
|
22151
|
+
/**
|
|
22152
|
+
* Full event-kind taxonomy a camera CAN emit (config-derived):
|
|
22153
|
+
* (a) motion built-in — always;
|
|
22154
|
+
* (b) each enabled detection macro + its taxonomy subs (reverse COCO);
|
|
22155
|
+
* (c) audio macro + its subs when the classifier is enabled;
|
|
22156
|
+
* (d) package kinds when a package zone rule is enabled;
|
|
22157
|
+
* (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
|
|
22158
|
+
* LINKED devices (binding-driven), deduped per (kind, source deviceId).
|
|
22159
|
+
* Every descriptor carries parentKind/level/iconId/labelKey via the single
|
|
22160
|
+
* taxonomy dictionary — no color/icon is declared here.
|
|
22161
|
+
*/
|
|
22162
|
+
/**
|
|
22163
|
+
* Run one lookup, reporting a failure instead of propagating it.
|
|
22164
|
+
*
|
|
22165
|
+
* `undefined` means "this one did not answer" — the caller degrades that part
|
|
22166
|
+
* of the taxonomy and keeps the rest. That is the pre-existing contract (each
|
|
22167
|
+
* section had its own try/catch); it is factored out here only because the
|
|
22168
|
+
* sections now run concurrently, and a rejected promise nobody has awaited yet
|
|
22169
|
+
* is an unhandled rejection.
|
|
22170
|
+
*/
|
|
22171
|
+
async function attempt(deps, scope, run) {
|
|
22172
|
+
try {
|
|
22173
|
+
return await run();
|
|
22174
|
+
} catch (err) {
|
|
22175
|
+
deps.onError?.(scope, err);
|
|
22176
|
+
return;
|
|
22177
|
+
}
|
|
22178
|
+
}
|
|
22179
|
+
async function composeEventKinds(deps, deviceId) {
|
|
22180
|
+
const out = [];
|
|
22181
|
+
const cameraSource = {
|
|
22182
|
+
capName: "pipeline-analytics",
|
|
22183
|
+
deviceId
|
|
22184
|
+
};
|
|
22185
|
+
const pushKind = (kind, source = cameraSource) => {
|
|
22186
|
+
const d = buildEventKindDescriptor(kind, source);
|
|
22187
|
+
if (d !== null) out.push(d);
|
|
22188
|
+
};
|
|
22189
|
+
const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
|
|
22190
|
+
const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
|
|
22191
|
+
const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
|
|
22192
|
+
const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
|
|
22193
|
+
const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
|
|
22194
|
+
pushKind("motion");
|
|
22195
|
+
for (const macro of await macrosP ?? []) {
|
|
22196
|
+
pushKind(macro);
|
|
22197
|
+
for (const sub of subKindsOf(macro)) pushKind(sub.kind);
|
|
22198
|
+
}
|
|
22199
|
+
if (await audioP === true) {
|
|
22200
|
+
pushKind("audio");
|
|
22201
|
+
for (const sub of subKindsOf("audio")) pushKind(sub.kind);
|
|
22202
|
+
}
|
|
22203
|
+
if (await packageP === true) {
|
|
22204
|
+
pushKind("package");
|
|
22205
|
+
for (const sub of subKindsOf("package")) pushKind(sub.kind);
|
|
22206
|
+
}
|
|
22207
|
+
const devices = (await linkedP)?.devices ?? [];
|
|
22208
|
+
const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
|
|
22209
|
+
const seen = /* @__PURE__ */ new Set();
|
|
22210
|
+
const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
|
|
22211
|
+
if (entries === void 0) return;
|
|
22212
|
+
for (const { capName } of entries) {
|
|
22213
|
+
if (isSelf && !selfCameraMayEmit(capName)) continue;
|
|
22214
|
+
const descriptor = EVENT_KIND_BY_CAP[capName];
|
|
22215
|
+
if (descriptor === void 0) continue;
|
|
22216
|
+
const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
|
|
22217
|
+
if (seen.has(dedupeKey)) continue;
|
|
22218
|
+
seen.add(dedupeKey);
|
|
22219
|
+
pushKind(descriptor.kind, {
|
|
22220
|
+
capName,
|
|
22221
|
+
deviceId: sourceDeviceId
|
|
22222
|
+
});
|
|
22223
|
+
}
|
|
21794
22224
|
};
|
|
21795
22225
|
pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
|
|
21796
22226
|
for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
|
|
@@ -22597,334 +23027,39 @@ var EventMediaDispatcher = class {
|
|
|
22597
23027
|
this.deps.logger.warn(`event media: ${child.kind} failed`, {
|
|
22598
23028
|
tags: { deviceId },
|
|
22599
23029
|
meta: {
|
|
22600
|
-
deviceId,
|
|
22601
|
-
eventId: ev.eventId,
|
|
22602
|
-
error: err instanceof Error ? err.message : String(err)
|
|
22603
|
-
}
|
|
22604
|
-
});
|
|
22605
|
-
}
|
|
22606
|
-
}
|
|
22607
|
-
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
22608
|
-
const boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, tf.trackId, tf.bbox, tf.label);
|
|
22609
|
-
if (!boxed) return false;
|
|
22610
|
-
try {
|
|
22611
|
-
await this.deps.mediaStore.put({
|
|
22612
|
-
deviceId,
|
|
22613
|
-
ownerKind: "track",
|
|
22614
|
-
ownerId: tf.trackId,
|
|
22615
|
-
kind: "firstFrame",
|
|
22616
|
-
timestamp: tf.timestamp,
|
|
22617
|
-
data: boxed
|
|
22618
|
-
});
|
|
22619
|
-
return true;
|
|
22620
|
-
} catch (err) {
|
|
22621
|
-
this.deps.logger.warn("event media: track frame failed", {
|
|
22622
|
-
tags: { deviceId },
|
|
22623
|
-
meta: {
|
|
22624
|
-
deviceId,
|
|
22625
|
-
trackId: tf.trackId,
|
|
22626
|
-
error: err instanceof Error ? err.message : String(err)
|
|
22627
|
-
}
|
|
22628
|
-
});
|
|
22629
|
-
return false;
|
|
22630
|
-
}
|
|
22631
|
-
}
|
|
22632
|
-
};
|
|
22633
|
-
/**
|
|
22634
|
-
* A baseline whose last sample is older than `baselineMs × this` no longer
|
|
22635
|
-
* describes the camera — an on-motion camera detaches for hours and comes
|
|
22636
|
-
* back to a different scene. Decaying it forward would make the first frame
|
|
22637
|
-
* back either a guaranteed marker (mean still holds the old quiet) or a
|
|
22638
|
-
* guaranteed miss; discarding it is the honest option and costs one warm-up.
|
|
22639
|
-
*/
|
|
22640
|
-
var STALE_BASELINE_FACTOR = 5;
|
|
22641
|
-
/**
|
|
22642
|
-
* Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
|
|
22643
|
-
* has MAD 0 and would make every deviation infinite. 1 dB is below the
|
|
22644
|
-
* quantisation of any real level meter, so it only ever binds on a synthetic
|
|
22645
|
-
* or a genuinely dead-steady signal — where bar 2 is what should decide.
|
|
22646
|
-
*/
|
|
22647
|
-
var MIN_MAD_DB = 1;
|
|
22648
|
-
function emptyAudioBaseline() {
|
|
22649
|
-
return {
|
|
22650
|
-
count: 0,
|
|
22651
|
-
mean: 0,
|
|
22652
|
-
mad: 0,
|
|
22653
|
-
firstMs: 0,
|
|
22654
|
-
lastMs: 0
|
|
22655
|
-
};
|
|
22656
|
-
}
|
|
22657
|
-
/** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
|
|
22658
|
-
function seed(sample) {
|
|
22659
|
-
return {
|
|
22660
|
-
count: 1,
|
|
22661
|
-
mean: sample.dbfs,
|
|
22662
|
-
mad: 0,
|
|
22663
|
-
firstMs: sample.timestampMs,
|
|
22664
|
-
lastMs: sample.timestampMs
|
|
22665
|
-
};
|
|
22666
|
-
}
|
|
22667
|
-
/**
|
|
22668
|
-
* Fold `sample` into `state` and decide whether it is a marker candidate.
|
|
22669
|
-
*
|
|
22670
|
-
* The verdict is measured against the PRIOR baseline (this frame excluded) so
|
|
22671
|
-
* a lone spike is not diluted by itself — the same reasoning
|
|
22672
|
-
* `audio-level-detector` uses for its `priorMean`. The state returned always
|
|
22673
|
-
* includes the sample, whatever the verdict.
|
|
22674
|
-
*/
|
|
22675
|
-
function observeAudioAnomaly(state, sample, cfg) {
|
|
22676
|
-
const isFirst = state.count === 0;
|
|
22677
|
-
const gapMs = sample.timestampMs - state.lastMs;
|
|
22678
|
-
const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
|
|
22679
|
-
if (isFirst || isStale) return {
|
|
22680
|
-
verdict: { kind: "warmup" },
|
|
22681
|
-
nextState: seed(sample)
|
|
22682
|
-
};
|
|
22683
|
-
const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
|
|
22684
|
-
const priorMean = state.mean;
|
|
22685
|
-
const priorMad = state.mad;
|
|
22686
|
-
const nextState = {
|
|
22687
|
-
count: state.count + 1,
|
|
22688
|
-
mean: priorMean + alpha * (sample.dbfs - priorMean),
|
|
22689
|
-
mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
|
|
22690
|
-
firstMs: state.firstMs,
|
|
22691
|
-
lastMs: sample.timestampMs
|
|
22692
|
-
};
|
|
22693
|
-
if (sample.dbfs <= cfg.silenceFloorDbfs) return {
|
|
22694
|
-
verdict: { kind: "silent" },
|
|
22695
|
-
nextState
|
|
22696
|
-
};
|
|
22697
|
-
if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
|
|
22698
|
-
verdict: { kind: "warmup" },
|
|
22699
|
-
nextState
|
|
22700
|
-
};
|
|
22701
|
-
if (sample.dbfs < cfg.minDbfs) return {
|
|
22702
|
-
verdict: {
|
|
22703
|
-
kind: "not-loud",
|
|
22704
|
-
dbfs: sample.dbfs
|
|
22705
|
-
},
|
|
22706
|
-
nextState
|
|
22707
|
-
};
|
|
22708
|
-
const deviationDb = sample.dbfs - priorMean;
|
|
22709
|
-
const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
|
|
22710
|
-
const measured = {
|
|
22711
|
-
dbfs: sample.dbfs,
|
|
22712
|
-
mean: priorMean,
|
|
22713
|
-
deviationDb,
|
|
22714
|
-
deviations
|
|
22715
|
-
};
|
|
22716
|
-
return {
|
|
22717
|
-
verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
|
|
22718
|
-
kind: "anomalous",
|
|
22719
|
-
...measured
|
|
22720
|
-
} : {
|
|
22721
|
-
kind: "not-anomalous",
|
|
22722
|
-
...measured
|
|
22723
|
-
},
|
|
22724
|
-
nextState
|
|
22725
|
-
};
|
|
22726
|
-
}
|
|
22727
|
-
//#endregion
|
|
22728
|
-
//#region src/pipeline-analytics/services/audio-marker-projector.ts
|
|
22729
|
-
/** One info line per camera per minute, and only when something qualified. */
|
|
22730
|
-
var METRICS_WINDOW_MS = 6e4;
|
|
22731
|
-
var HOUR_MS = 60 * 6e4;
|
|
22732
|
-
var DAY_MS = 24 * HOUR_MS;
|
|
22733
|
-
function emptyCounters(atMs) {
|
|
22734
|
-
return {
|
|
22735
|
-
windowStartMs: atMs,
|
|
22736
|
-
enabled: false,
|
|
22737
|
-
observed: 0,
|
|
22738
|
-
notLoud: 0,
|
|
22739
|
-
notAnomalous: 0,
|
|
22740
|
-
candidates: 0,
|
|
22741
|
-
suppressedMotion: 0,
|
|
22742
|
-
suppressedCooldown: 0,
|
|
22743
|
-
suppressedHourly: 0,
|
|
22744
|
-
suppressedDaily: 0,
|
|
22745
|
-
suppressedInFlight: 0,
|
|
22746
|
-
wouldFire: 0,
|
|
22747
|
-
materialized: 0,
|
|
22748
|
-
materializeFailed: 0
|
|
22749
|
-
};
|
|
22750
|
-
}
|
|
22751
|
-
var AudioMarkerProjector = class {
|
|
22752
|
-
deps;
|
|
22753
|
-
baselines = /* @__PURE__ */ new Map();
|
|
22754
|
-
rates = /* @__PURE__ */ new Map();
|
|
22755
|
-
counters = /* @__PURE__ */ new Map();
|
|
22756
|
-
inFlight = /* @__PURE__ */ new Set();
|
|
22757
|
-
constructor(deps) {
|
|
22758
|
-
this.deps = deps;
|
|
22759
|
-
}
|
|
22760
|
-
/**
|
|
22761
|
-
* Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
|
|
22762
|
-
* the common path must not allocate a promise, so the decision is taken
|
|
22763
|
-
* inline and only an actual marker goes async (fire-and-forget, bounded by
|
|
22764
|
-
* the in-flight guard and the rate ceilings).
|
|
22765
|
-
*
|
|
22766
|
-
* Call this BEFORE the classification/level route split so the baseline
|
|
22767
|
-
* sees every frame. A baseline fed only by the level route is blind to
|
|
22768
|
-
* exactly the loud, confidently-classified frames a marker is for.
|
|
22769
|
-
*/
|
|
22770
|
-
observe(input, cfg) {
|
|
22771
|
-
const { deviceId, timestampMs } = input;
|
|
22772
|
-
const counters = this.countersFor(deviceId, timestampMs);
|
|
22773
|
-
counters.observed += 1;
|
|
22774
|
-
counters.enabled = cfg.enabled;
|
|
22775
|
-
const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
|
|
22776
|
-
timestampMs,
|
|
22777
|
-
dbfs: input.dbfs
|
|
22778
|
-
}, cfg);
|
|
22779
|
-
this.baselines.set(deviceId, res.nextState);
|
|
22780
|
-
this.maybeFlush(deviceId, timestampMs);
|
|
22781
|
-
switch (res.verdict.kind) {
|
|
22782
|
-
case "silent":
|
|
22783
|
-
case "warmup": return;
|
|
22784
|
-
case "not-loud":
|
|
22785
|
-
counters.notLoud += 1;
|
|
22786
|
-
return;
|
|
22787
|
-
case "not-anomalous":
|
|
22788
|
-
counters.notAnomalous += 1;
|
|
22789
|
-
return;
|
|
22790
|
-
case "anomalous": break;
|
|
22791
|
-
}
|
|
22792
|
-
counters.candidates += 1;
|
|
22793
|
-
if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
|
|
22794
|
-
counters.suppressedMotion += 1;
|
|
22795
|
-
return;
|
|
22796
|
-
}
|
|
22797
|
-
const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
|
|
22798
|
-
const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
|
|
22799
|
-
if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
|
|
22800
|
-
counters.suppressedCooldown += 1;
|
|
22801
|
-
return;
|
|
22802
|
-
}
|
|
22803
|
-
const recent = fired.filter((t) => timestampMs - t < DAY_MS);
|
|
22804
|
-
if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
|
|
22805
|
-
counters.suppressedHourly += 1;
|
|
22806
|
-
this.rates.set(deviceId, { firedAtMs: recent });
|
|
22807
|
-
return;
|
|
22808
|
-
}
|
|
22809
|
-
if (recent.length >= cfg.maxPerDay) {
|
|
22810
|
-
counters.suppressedDaily += 1;
|
|
22811
|
-
this.rates.set(deviceId, { firedAtMs: recent });
|
|
22812
|
-
return;
|
|
22813
|
-
}
|
|
22814
|
-
if (!cfg.enabled) {
|
|
22815
|
-
counters.wouldFire += 1;
|
|
22816
|
-
return;
|
|
22817
|
-
}
|
|
22818
|
-
if (this.inFlight.has(deviceId)) {
|
|
22819
|
-
counters.suppressedInFlight += 1;
|
|
22820
|
-
return;
|
|
22821
|
-
}
|
|
22822
|
-
this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
|
|
22823
|
-
counters.materialized += 1;
|
|
22824
|
-
this.inFlight.add(deviceId);
|
|
22825
|
-
this.materialize(input, counters);
|
|
22826
|
-
}
|
|
22827
|
-
/**
|
|
22828
|
-
* Emit and reset a camera's window if it has anything to say. Exposed so a
|
|
22829
|
-
* shutdown (or a test) can force the tally out rather than losing it.
|
|
22830
|
-
*/
|
|
22831
|
-
flush(nowMs) {
|
|
22832
|
-
for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
|
|
22833
|
-
}
|
|
22834
|
-
/** Drop all per-camera state for a device (unbind / device removal). */
|
|
22835
|
-
forgetDevice(deviceId) {
|
|
22836
|
-
this.baselines.delete(deviceId);
|
|
22837
|
-
this.rates.delete(deviceId);
|
|
22838
|
-
this.counters.delete(deviceId);
|
|
22839
|
-
this.inFlight.delete(deviceId);
|
|
23030
|
+
deviceId,
|
|
23031
|
+
eventId: ev.eventId,
|
|
23032
|
+
error: err instanceof Error ? err.message : String(err)
|
|
23033
|
+
}
|
|
23034
|
+
});
|
|
23035
|
+
}
|
|
22840
23036
|
}
|
|
22841
|
-
async
|
|
22842
|
-
const
|
|
23037
|
+
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
23038
|
+
const boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, tf.trackId, tf.bbox, tf.label);
|
|
23039
|
+
if (!boxed) return false;
|
|
22843
23040
|
try {
|
|
22844
|
-
|
|
22845
|
-
|
|
22846
|
-
|
|
22847
|
-
|
|
22848
|
-
|
|
22849
|
-
|
|
22850
|
-
|
|
22851
|
-
...audioLabels !== void 0 ? { audioLabels } : {}
|
|
23041
|
+
await this.deps.mediaStore.put({
|
|
23042
|
+
deviceId,
|
|
23043
|
+
ownerKind: "track",
|
|
23044
|
+
ownerId: tf.trackId,
|
|
23045
|
+
kind: "firstFrame",
|
|
23046
|
+
timestamp: tf.timestamp,
|
|
23047
|
+
data: boxed
|
|
22852
23048
|
});
|
|
23049
|
+
return true;
|
|
22853
23050
|
} catch (err) {
|
|
22854
|
-
|
|
22855
|
-
counters.materializeFailed += 1;
|
|
22856
|
-
this.deps.onError?.("materializeAudioMarker", err);
|
|
22857
|
-
this.deps.logger.warn("audio marker materialize failed", {
|
|
23051
|
+
this.deps.logger.warn("event media: track frame failed", {
|
|
22858
23052
|
tags: { deviceId },
|
|
22859
|
-
meta: {
|
|
23053
|
+
meta: {
|
|
23054
|
+
deviceId,
|
|
23055
|
+
trackId: tf.trackId,
|
|
23056
|
+
error: err instanceof Error ? err.message : String(err)
|
|
23057
|
+
}
|
|
22860
23058
|
});
|
|
22861
|
-
|
|
22862
|
-
this.inFlight.delete(deviceId);
|
|
23059
|
+
return false;
|
|
22863
23060
|
}
|
|
22864
23061
|
}
|
|
22865
|
-
countersFor(deviceId, atMs) {
|
|
22866
|
-
const existing = this.counters.get(deviceId);
|
|
22867
|
-
if (existing !== void 0) return existing;
|
|
22868
|
-
const fresh = emptyCounters(atMs);
|
|
22869
|
-
this.counters.set(deviceId, fresh);
|
|
22870
|
-
return fresh;
|
|
22871
|
-
}
|
|
22872
|
-
maybeFlush(deviceId, nowMs) {
|
|
22873
|
-
const c = this.counters.get(deviceId);
|
|
22874
|
-
if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22875
|
-
this.emitWindow(deviceId, nowMs, false);
|
|
22876
|
-
}
|
|
22877
|
-
/**
|
|
22878
|
-
* One line per camera per window — but ONLY when a frame reached the
|
|
22879
|
-
* candidate gate. A camera that heard nothing extraordinary has nothing to
|
|
22880
|
-
* report, and a line per camera per minute forever would bury the ones that
|
|
22881
|
-
* do. `force` still honours that: it flushes the window, it does not invent
|
|
22882
|
-
* a reason to speak.
|
|
22883
|
-
*/
|
|
22884
|
-
emitWindow(deviceId, nowMs, force) {
|
|
22885
|
-
const c = this.counters.get(deviceId);
|
|
22886
|
-
if (c === void 0) return;
|
|
22887
|
-
if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22888
|
-
if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
|
|
22889
|
-
tags: { deviceId },
|
|
22890
|
-
meta: {
|
|
22891
|
-
enabled: c.enabled,
|
|
22892
|
-
windowMs: nowMs - c.windowStartMs,
|
|
22893
|
-
observed: c.observed,
|
|
22894
|
-
notLoud: c.notLoud,
|
|
22895
|
-
notAnomalous: c.notAnomalous,
|
|
22896
|
-
candidates: c.candidates,
|
|
22897
|
-
suppressedMotion: c.suppressedMotion,
|
|
22898
|
-
suppressedCooldown: c.suppressedCooldown,
|
|
22899
|
-
suppressedHourly: c.suppressedHourly,
|
|
22900
|
-
suppressedDaily: c.suppressedDaily,
|
|
22901
|
-
suppressedInFlight: c.suppressedInFlight,
|
|
22902
|
-
wouldFire: c.wouldFire,
|
|
22903
|
-
materialized: c.materialized,
|
|
22904
|
-
materializeFailed: c.materializeFailed
|
|
22905
|
-
}
|
|
22906
|
-
});
|
|
22907
|
-
this.counters.set(deviceId, emptyCounters(nowMs));
|
|
22908
|
-
}
|
|
22909
23062
|
};
|
|
22910
|
-
/**
|
|
22911
|
-
* The class heard on the anomalous frame, in the shape `Track.audioLabels`
|
|
22912
|
-
* already defines. Using that field is what lets a marker label itself
|
|
22913
|
-
* ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
|
|
22914
|
-
* the audio macro vocabulary collides with existing subs (`dog` is a COCO
|
|
22915
|
-
* animal, `siren` is a control), so a sub per audio class would put a barking
|
|
22916
|
-
* dog in the ANIMAL lane.
|
|
22917
|
-
*/
|
|
22918
|
-
function buildAudioLabels(input) {
|
|
22919
|
-
if (input.className === void 0) return void 0;
|
|
22920
|
-
return [{
|
|
22921
|
-
label: input.className,
|
|
22922
|
-
peakScore: input.score ?? 0,
|
|
22923
|
-
count: 1,
|
|
22924
|
-
firstAt: input.timestampMs,
|
|
22925
|
-
lastAt: input.timestampMs
|
|
22926
|
-
}];
|
|
22927
|
-
}
|
|
22928
23063
|
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
22929
23064
|
var FULL_FRAME_QUALITY = 80;
|
|
22930
23065
|
/**
|
|
@@ -24937,6 +25072,478 @@ async function sweepTrackRetention(deps) {
|
|
|
24937
25072
|
return totalTracks;
|
|
24938
25073
|
}
|
|
24939
25074
|
//#endregion
|
|
25075
|
+
//#region src/pipeline-analytics/training-export/dataset-plan.ts
|
|
25076
|
+
var FACE_OWNER_PREFIX = "face-";
|
|
25077
|
+
var PLATE_OWNER_PREFIX = "plate-";
|
|
25078
|
+
/**
|
|
25079
|
+
* Tracks one export pass will carry.
|
|
25080
|
+
*
|
|
25081
|
+
* It is the per-device pin budget (500) times the four cameras a homelab hub
|
|
25082
|
+
* plausibly runs, rounded — i.e. "every track that can be marked", not a
|
|
25083
|
+
* throttle the operator has to think about. The ceiling exists so a fleet-wide
|
|
25084
|
+
* export cannot become an unbounded walk if the pin budget is ever raised, and
|
|
25085
|
+
* `truncated` in the summary says when it bit rather than quietly shipping a
|
|
25086
|
+
* partial dataset as if it were the whole one.
|
|
25087
|
+
*/
|
|
25088
|
+
var TRAINING_EXPORT_MAX_TRACKS = 2e3;
|
|
25089
|
+
/**
|
|
25090
|
+
* Archive path for one media file.
|
|
25091
|
+
*
|
|
25092
|
+
* The scope segment is not decoration: a track and its best object event can
|
|
25093
|
+
* both own a `crop` at the same millisecond, and without the segment the second
|
|
25094
|
+
* one would overwrite the first inside the archive — a silent loss that only
|
|
25095
|
+
* shows up as a dataset that is smaller than the manifest says.
|
|
25096
|
+
*/
|
|
25097
|
+
function archivePathFor(params) {
|
|
25098
|
+
const leaf = params.singleInstance ? `${params.kind}.jpg` : `${params.kind}-${params.timestamp}.jpg`;
|
|
25099
|
+
const dir = params.scope === "" ? "" : `${params.scope}/`;
|
|
25100
|
+
return `tracks/${params.deviceId}/${params.trackId}/${dir}${leaf}`;
|
|
25101
|
+
}
|
|
25102
|
+
/**
|
|
25103
|
+
* A media key ends in `:<timestamp>` for the accumulating kinds and stops at the
|
|
25104
|
+
* kind for the single-instance ones. Reading it off the key rather than
|
|
25105
|
+
* re-deriving the kind set keeps this from drifting when a kind changes sides.
|
|
25106
|
+
*/
|
|
25107
|
+
function isSingleInstanceKey(key, kind) {
|
|
25108
|
+
return key.endsWith(`:${kind}`);
|
|
25109
|
+
}
|
|
25110
|
+
/**
|
|
25111
|
+
* Build the plan. Index reads only — the returned plan holds no image bytes and
|
|
25112
|
+
* is safe to send over tRPC.
|
|
25113
|
+
*/
|
|
25114
|
+
async function buildTrainingExportPlan(readers, input) {
|
|
25115
|
+
const tracks = [];
|
|
25116
|
+
const skippedPaths = [];
|
|
25117
|
+
let truncated = false;
|
|
25118
|
+
let remaining = input.maxTracks;
|
|
25119
|
+
for (const deviceId of input.deviceIds) {
|
|
25120
|
+
if (remaining <= 0) break;
|
|
25121
|
+
const marked = await readers.listMarkedTracks(deviceId, remaining + 1);
|
|
25122
|
+
if (marked.length > remaining) truncated = true;
|
|
25123
|
+
const take = marked.slice(0, remaining);
|
|
25124
|
+
remaining -= take.length;
|
|
25125
|
+
if (take.length === 0) continue;
|
|
25126
|
+
const eventLinks = await readers.listEventIdsForTracks(take.map((t) => t.trackId));
|
|
25127
|
+
const eventsByTrack = /* @__PURE__ */ new Map();
|
|
25128
|
+
for (const link of eventLinks) {
|
|
25129
|
+
const list = eventsByTrack.get(link.trackId);
|
|
25130
|
+
if (list) list.push(link.eventId);
|
|
25131
|
+
else eventsByTrack.set(link.trackId, [link.eventId]);
|
|
25132
|
+
}
|
|
25133
|
+
for (const track of take) {
|
|
25134
|
+
const files = [];
|
|
25135
|
+
const owners = [
|
|
25136
|
+
{
|
|
25137
|
+
ownerKind: "track",
|
|
25138
|
+
ownerId: track.trackId,
|
|
25139
|
+
scope: ""
|
|
25140
|
+
},
|
|
25141
|
+
{
|
|
25142
|
+
ownerKind: "face",
|
|
25143
|
+
ownerId: `${FACE_OWNER_PREFIX}${track.trackId}`,
|
|
25144
|
+
scope: "face"
|
|
25145
|
+
},
|
|
25146
|
+
{
|
|
25147
|
+
ownerKind: "plate",
|
|
25148
|
+
ownerId: `${PLATE_OWNER_PREFIX}${track.trackId}`,
|
|
25149
|
+
scope: "plate"
|
|
25150
|
+
},
|
|
25151
|
+
...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
|
|
25152
|
+
ownerKind: "event",
|
|
25153
|
+
ownerId: eventId,
|
|
25154
|
+
scope: `events/${eventId}`
|
|
25155
|
+
}))
|
|
25156
|
+
];
|
|
25157
|
+
for (const owner of owners) {
|
|
25158
|
+
const infos = await readers.listMediaInfo(owner.ownerKind, owner.ownerId);
|
|
25159
|
+
for (const info of infos) {
|
|
25160
|
+
const archivePath = archivePathFor({
|
|
25161
|
+
deviceId: track.deviceId,
|
|
25162
|
+
trackId: track.trackId,
|
|
25163
|
+
scope: owner.scope,
|
|
25164
|
+
kind: info.kind,
|
|
25165
|
+
timestamp: info.timestamp,
|
|
25166
|
+
singleInstance: isSingleInstanceKey(info.key, info.kind)
|
|
25167
|
+
});
|
|
25168
|
+
files.push({
|
|
25169
|
+
archivePath,
|
|
25170
|
+
mediaKey: info.key,
|
|
25171
|
+
kind: info.kind,
|
|
25172
|
+
sizeBytes: info.sizeBytes,
|
|
25173
|
+
timestamp: info.timestamp
|
|
25174
|
+
});
|
|
25175
|
+
}
|
|
25176
|
+
}
|
|
25177
|
+
tracks.push({
|
|
25178
|
+
trackId: track.trackId,
|
|
25179
|
+
deviceId: track.deviceId,
|
|
25180
|
+
className: track.className,
|
|
25181
|
+
...track.label !== void 0 ? { label: track.label } : {},
|
|
25182
|
+
firstSeen: track.firstSeen,
|
|
25183
|
+
lastSeen: track.lastSeen,
|
|
25184
|
+
classes: track.classes ?? [],
|
|
25185
|
+
zonesVisited: track.zonesVisited,
|
|
25186
|
+
...track.importance !== void 0 ? { importance: track.importance } : {},
|
|
25187
|
+
files
|
|
25188
|
+
});
|
|
25189
|
+
}
|
|
25190
|
+
}
|
|
25191
|
+
const perDevice = /* @__PURE__ */ new Map();
|
|
25192
|
+
let fileCount = 0;
|
|
25193
|
+
let byteCount = 0;
|
|
25194
|
+
for (const track of tracks) {
|
|
25195
|
+
const agg = perDevice.get(track.deviceId) ?? {
|
|
25196
|
+
tracks: 0,
|
|
25197
|
+
files: 0,
|
|
25198
|
+
bytes: 0
|
|
25199
|
+
};
|
|
25200
|
+
agg.tracks += 1;
|
|
25201
|
+
for (const file of track.files) {
|
|
25202
|
+
agg.files += 1;
|
|
25203
|
+
agg.bytes += file.sizeBytes;
|
|
25204
|
+
fileCount += 1;
|
|
25205
|
+
byteCount += file.sizeBytes;
|
|
25206
|
+
}
|
|
25207
|
+
perDevice.set(track.deviceId, agg);
|
|
25208
|
+
}
|
|
25209
|
+
return {
|
|
25210
|
+
generatedAt: readers.now(),
|
|
25211
|
+
tracks,
|
|
25212
|
+
totals: [...perDevice.entries()].map(([deviceId, agg]) => ({
|
|
25213
|
+
deviceId,
|
|
25214
|
+
...agg
|
|
25215
|
+
})),
|
|
25216
|
+
trackCount: tracks.length,
|
|
25217
|
+
fileCount,
|
|
25218
|
+
byteCount,
|
|
25219
|
+
truncated,
|
|
25220
|
+
skippedPaths
|
|
25221
|
+
};
|
|
25222
|
+
}
|
|
25223
|
+
//#endregion
|
|
25224
|
+
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
25225
|
+
/**
|
|
25226
|
+
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
25227
|
+
* entry, without ever holding the archive in memory.
|
|
25228
|
+
*
|
|
25229
|
+
* ## Why hand-rolled rather than the `tar` package
|
|
25230
|
+
*
|
|
25231
|
+
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
25232
|
+
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
25233
|
+
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
25234
|
+
* operator relocated to), under paths that encode nothing a training set wants —
|
|
25235
|
+
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
25236
|
+
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
25237
|
+
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
25238
|
+
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
25239
|
+
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
25240
|
+
*
|
|
25241
|
+
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
25242
|
+
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
25243
|
+
* door rather than against my reading of the format.
|
|
25244
|
+
*
|
|
25245
|
+
* ## What this deliberately does NOT do
|
|
25246
|
+
*
|
|
25247
|
+
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
25248
|
+
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
25249
|
+
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
25250
|
+
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
25251
|
+
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
25252
|
+
*/
|
|
25253
|
+
var BLOCK_SIZE = 512;
|
|
25254
|
+
var NAME_MAX = 100;
|
|
25255
|
+
var PREFIX_MAX = 155;
|
|
25256
|
+
/**
|
|
25257
|
+
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
25258
|
+
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
25259
|
+
*/
|
|
25260
|
+
function splitUstarName(name) {
|
|
25261
|
+
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
25262
|
+
prefix: "",
|
|
25263
|
+
name
|
|
25264
|
+
};
|
|
25265
|
+
for (let i = name.length - 1; i >= 0; i--) {
|
|
25266
|
+
if (name[i] !== "/") continue;
|
|
25267
|
+
const head = name.slice(0, i);
|
|
25268
|
+
const tail = name.slice(i + 1);
|
|
25269
|
+
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
25270
|
+
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
25271
|
+
prefix: head,
|
|
25272
|
+
name: tail
|
|
25273
|
+
};
|
|
25274
|
+
}
|
|
25275
|
+
return null;
|
|
25276
|
+
}
|
|
25277
|
+
/** True when a path cannot be represented as a ustar entry name. */
|
|
25278
|
+
function tarNameTooLong(name) {
|
|
25279
|
+
return splitUstarName(name) === null;
|
|
25280
|
+
}
|
|
25281
|
+
function writeString(block, value, offset, length) {
|
|
25282
|
+
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
25283
|
+
}
|
|
25284
|
+
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
25285
|
+
function writeOctal(block, value, offset, length) {
|
|
25286
|
+
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
25287
|
+
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
25288
|
+
}
|
|
25289
|
+
/**
|
|
25290
|
+
* The 512-byte header for one regular file.
|
|
25291
|
+
*
|
|
25292
|
+
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
25293
|
+
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
25294
|
+
* fine until something tries to read it.
|
|
25295
|
+
*/
|
|
25296
|
+
function tarHeader(entry) {
|
|
25297
|
+
const split = splitUstarName(entry.name);
|
|
25298
|
+
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
25299
|
+
const block = Buffer.alloc(BLOCK_SIZE);
|
|
25300
|
+
writeString(block, split.name, 0, NAME_MAX);
|
|
25301
|
+
writeOctal(block, 420, 100, 8);
|
|
25302
|
+
writeOctal(block, 0, 108, 8);
|
|
25303
|
+
writeOctal(block, 0, 116, 8);
|
|
25304
|
+
writeOctal(block, entry.body.length, 124, 12);
|
|
25305
|
+
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
25306
|
+
block.write(" ", 148, 8, "ascii");
|
|
25307
|
+
block.write("0", 156, 1, "ascii");
|
|
25308
|
+
block.write("ustar\0", 257, 6, "ascii");
|
|
25309
|
+
block.write("00", 263, 2, "ascii");
|
|
25310
|
+
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
25311
|
+
let sum = 0;
|
|
25312
|
+
for (const byte of block) sum += byte;
|
|
25313
|
+
writeOctal(block, sum, 148, 7);
|
|
25314
|
+
block.write("\0", 154, 1, "ascii");
|
|
25315
|
+
block.write(" ", 155, 1, "ascii");
|
|
25316
|
+
return block;
|
|
25317
|
+
}
|
|
25318
|
+
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
25319
|
+
function tarPadding(size) {
|
|
25320
|
+
const remainder = size % BLOCK_SIZE;
|
|
25321
|
+
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
25322
|
+
}
|
|
25323
|
+
/** Header + body + padding for one entry, ready to write. */
|
|
25324
|
+
function tarBlocks(entry) {
|
|
25325
|
+
return [
|
|
25326
|
+
tarHeader(entry),
|
|
25327
|
+
entry.body,
|
|
25328
|
+
tarPadding(entry.body.length)
|
|
25329
|
+
];
|
|
25330
|
+
}
|
|
25331
|
+
/** The two zero blocks that terminate an archive. */
|
|
25332
|
+
function tarTrailer() {
|
|
25333
|
+
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
25334
|
+
}
|
|
25335
|
+
//#endregion
|
|
25336
|
+
//#region src/pipeline-analytics/training-export/training-export-stream.ts
|
|
25337
|
+
var MANIFEST_ENTRY_NAME = "manifest.json";
|
|
25338
|
+
function manifestTrack(track) {
|
|
25339
|
+
return {
|
|
25340
|
+
trackId: track.trackId,
|
|
25341
|
+
deviceId: track.deviceId,
|
|
25342
|
+
className: track.className,
|
|
25343
|
+
...track.label !== void 0 ? { label: track.label } : {},
|
|
25344
|
+
firstSeen: track.firstSeen,
|
|
25345
|
+
lastSeen: track.lastSeen,
|
|
25346
|
+
classes: track.classes,
|
|
25347
|
+
zonesVisited: track.zonesVisited,
|
|
25348
|
+
...track.importance !== void 0 ? { importance: track.importance } : {},
|
|
25349
|
+
files: track.files.map((f) => ({
|
|
25350
|
+
path: f.archivePath,
|
|
25351
|
+
kind: f.kind,
|
|
25352
|
+
timestamp: f.timestamp,
|
|
25353
|
+
sizeBytes: f.sizeBytes
|
|
25354
|
+
}))
|
|
25355
|
+
};
|
|
25356
|
+
}
|
|
25357
|
+
/**
|
|
25358
|
+
* The manifest body.
|
|
25359
|
+
*
|
|
25360
|
+
* `mediaKey` is deliberately NOT in it. The archive is a dataset handed to a
|
|
25361
|
+
* training pipeline, and a store-internal key is meaningless outside the hub
|
|
25362
|
+
* that issued it — worse, it invites a consumer to believe it can fetch by that
|
|
25363
|
+
* key later, on media the retention pin does not guarantee is still there once
|
|
25364
|
+
* the mark is removed. The archive path is the identity inside the archive.
|
|
25365
|
+
*/
|
|
25366
|
+
function buildManifest(plan) {
|
|
25367
|
+
const body = {
|
|
25368
|
+
format: "camstack-training-export",
|
|
25369
|
+
version: 1,
|
|
25370
|
+
generatedAt: plan.generatedAt,
|
|
25371
|
+
selection: "tracks in retrain staging (retrainStatus: 'staging')",
|
|
25372
|
+
trackCount: plan.trackCount,
|
|
25373
|
+
fileCount: plan.fileCount,
|
|
25374
|
+
byteCount: plan.byteCount,
|
|
25375
|
+
truncated: plan.truncated,
|
|
25376
|
+
devices: plan.totals,
|
|
25377
|
+
tracks: plan.tracks.map(manifestTrack)
|
|
25378
|
+
};
|
|
25379
|
+
return Buffer.from(JSON.stringify(body, null, 2), "utf8");
|
|
25380
|
+
}
|
|
25381
|
+
/** Write the whole archive to the sink. */
|
|
25382
|
+
async function streamDataset(deps) {
|
|
25383
|
+
const { plan, sink, readBlob, logger, now, pace } = deps;
|
|
25384
|
+
const pacingMs = deps.pacingMs ?? 25;
|
|
25385
|
+
const mtimeMs = now();
|
|
25386
|
+
for (const block of tarBlocks({
|
|
25387
|
+
name: MANIFEST_ENTRY_NAME,
|
|
25388
|
+
body: buildManifest(plan),
|
|
25389
|
+
mtimeMs
|
|
25390
|
+
})) await sink.write(block);
|
|
25391
|
+
let filesWritten = 0;
|
|
25392
|
+
let bytesWritten = 0;
|
|
25393
|
+
let filesMissing = 0;
|
|
25394
|
+
let aborted = false;
|
|
25395
|
+
for (const track of plan.tracks) {
|
|
25396
|
+
for (const file of track.files) {
|
|
25397
|
+
if (deps.aborted?.() === true) {
|
|
25398
|
+
aborted = true;
|
|
25399
|
+
break;
|
|
25400
|
+
}
|
|
25401
|
+
if (tarNameTooLong(file.archivePath)) {
|
|
25402
|
+
filesMissing += 1;
|
|
25403
|
+
logger.warn("training export skipped a file — path too long for the archive", {
|
|
25404
|
+
tags: { deviceId: track.deviceId },
|
|
25405
|
+
meta: {
|
|
25406
|
+
deviceId: track.deviceId,
|
|
25407
|
+
trackId: track.trackId,
|
|
25408
|
+
path: file.archivePath
|
|
25409
|
+
}
|
|
25410
|
+
});
|
|
25411
|
+
continue;
|
|
25412
|
+
}
|
|
25413
|
+
const bytes = await readBlob(file.mediaKey);
|
|
25414
|
+
if (bytes === null) {
|
|
25415
|
+
filesMissing += 1;
|
|
25416
|
+
logger.warn("training export skipped a file — media unreadable", {
|
|
25417
|
+
tags: { deviceId: track.deviceId },
|
|
25418
|
+
meta: {
|
|
25419
|
+
deviceId: track.deviceId,
|
|
25420
|
+
trackId: track.trackId,
|
|
25421
|
+
mediaKey: file.mediaKey
|
|
25422
|
+
}
|
|
25423
|
+
});
|
|
25424
|
+
continue;
|
|
25425
|
+
}
|
|
25426
|
+
for (const block of tarBlocks({
|
|
25427
|
+
name: file.archivePath,
|
|
25428
|
+
body: bytes,
|
|
25429
|
+
mtimeMs
|
|
25430
|
+
})) await sink.write(block);
|
|
25431
|
+
filesWritten += 1;
|
|
25432
|
+
bytesWritten += bytes.length;
|
|
25433
|
+
if (pacingMs > 0) await pace(pacingMs);
|
|
25434
|
+
}
|
|
25435
|
+
if (aborted) break;
|
|
25436
|
+
}
|
|
25437
|
+
await sink.write(tarTrailer());
|
|
25438
|
+
await sink.end();
|
|
25439
|
+
logger.info("training export complete", { meta: {
|
|
25440
|
+
tracks: plan.trackCount,
|
|
25441
|
+
filesWritten,
|
|
25442
|
+
filesMissing,
|
|
25443
|
+
bytesWritten,
|
|
25444
|
+
truncated: plan.truncated,
|
|
25445
|
+
aborted
|
|
25446
|
+
} });
|
|
25447
|
+
return {
|
|
25448
|
+
filesWritten,
|
|
25449
|
+
bytesWritten,
|
|
25450
|
+
filesMissing,
|
|
25451
|
+
aborted
|
|
25452
|
+
};
|
|
25453
|
+
}
|
|
25454
|
+
//#endregion
|
|
25455
|
+
//#region src/pipeline-analytics/training-export/training-export-handler.ts
|
|
25456
|
+
var TRAINING_EXPORT_PREFIX = "training-export";
|
|
25457
|
+
/**
|
|
25458
|
+
* Parse `?deviceIds=615,617` into a device list. An absent or unparseable value
|
|
25459
|
+
* yields `[]`, which the caller reads as "every device with marked tracks" —
|
|
25460
|
+
* stated here rather than left to the reader because the empty array is doing
|
|
25461
|
+
* two jobs and only one of them is obvious.
|
|
25462
|
+
*/
|
|
25463
|
+
function parseDeviceIds(url) {
|
|
25464
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
25465
|
+
const raw = new URLSearchParams(query).get("deviceIds");
|
|
25466
|
+
if (raw === null || raw.trim() === "") return [];
|
|
25467
|
+
const out = [];
|
|
25468
|
+
for (const part of raw.split(",")) {
|
|
25469
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
25470
|
+
if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
|
|
25471
|
+
}
|
|
25472
|
+
return out;
|
|
25473
|
+
}
|
|
25474
|
+
/** Archive filename. Sanitised the same way the backup download is — a header
|
|
25475
|
+
* value carrying a CR or a quote is a response-splitting bug, not a filename. */
|
|
25476
|
+
function archiveFilename(nowMs) {
|
|
25477
|
+
return `camstack-training-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.tar`.replace(/[\r\n"]/g, "");
|
|
25478
|
+
}
|
|
25479
|
+
/** Adapt a `ServerResponse` to the sink, honouring backpressure. */
|
|
25480
|
+
function responseSink(res) {
|
|
25481
|
+
return {
|
|
25482
|
+
write: (chunk) => new Promise((resolve, reject) => {
|
|
25483
|
+
const ok = res.write(chunk, (err) => {
|
|
25484
|
+
if (err) reject(err);
|
|
25485
|
+
else if (ok) resolve();
|
|
25486
|
+
});
|
|
25487
|
+
if (!ok) res.once("drain", resolve);
|
|
25488
|
+
}),
|
|
25489
|
+
end: () => new Promise((resolve) => {
|
|
25490
|
+
res.end(resolve);
|
|
25491
|
+
})
|
|
25492
|
+
};
|
|
25493
|
+
}
|
|
25494
|
+
function createTrainingExportHandler(deps) {
|
|
25495
|
+
return async (req, res) => {
|
|
25496
|
+
const method = req.method ?? "GET";
|
|
25497
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
25498
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
25499
|
+
res.end();
|
|
25500
|
+
return;
|
|
25501
|
+
}
|
|
25502
|
+
const deviceIds = parseDeviceIds(req.url ?? "");
|
|
25503
|
+
let plan;
|
|
25504
|
+
try {
|
|
25505
|
+
plan = await deps.buildPlan(deviceIds);
|
|
25506
|
+
} catch (err) {
|
|
25507
|
+
deps.logger.warn("training export plan failed", { meta: { error: String(err) } });
|
|
25508
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
25509
|
+
res.end("training export plan failed");
|
|
25510
|
+
return;
|
|
25511
|
+
}
|
|
25512
|
+
res.writeHead(200, {
|
|
25513
|
+
"content-type": "application/x-tar",
|
|
25514
|
+
"content-disposition": `attachment; filename="${archiveFilename(deps.now())}"`,
|
|
25515
|
+
"cache-control": "no-store",
|
|
25516
|
+
"x-camstack-export-tracks": String(plan.trackCount),
|
|
25517
|
+
"x-camstack-export-files": String(plan.fileCount)
|
|
25518
|
+
});
|
|
25519
|
+
if (method === "HEAD") {
|
|
25520
|
+
res.end();
|
|
25521
|
+
return;
|
|
25522
|
+
}
|
|
25523
|
+
let clientGone = false;
|
|
25524
|
+
res.once("close", () => {
|
|
25525
|
+
clientGone = res.writableEnded === false;
|
|
25526
|
+
});
|
|
25527
|
+
try {
|
|
25528
|
+
await streamDataset({
|
|
25529
|
+
plan,
|
|
25530
|
+
sink: responseSink(res),
|
|
25531
|
+
readBlob: deps.readBlob,
|
|
25532
|
+
logger: deps.logger,
|
|
25533
|
+
now: deps.now,
|
|
25534
|
+
pace: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
25535
|
+
aborted: () => clientGone
|
|
25536
|
+
});
|
|
25537
|
+
} catch (err) {
|
|
25538
|
+
deps.logger.warn("training export stream failed mid-archive", { meta: {
|
|
25539
|
+
error: String(err),
|
|
25540
|
+
tracks: plan.trackCount
|
|
25541
|
+
} });
|
|
25542
|
+
res.destroy();
|
|
25543
|
+
}
|
|
25544
|
+
};
|
|
25545
|
+
}
|
|
25546
|
+
//#endregion
|
|
24940
25547
|
//#region src/pipeline-analytics/zone-analytics-provider.ts
|
|
24941
25548
|
var HISTORY_WINDOW_MS = 3600 * 1e3;
|
|
24942
25549
|
/** Cap name for the runtime-state mirror — matches the cap's declared
|
|
@@ -25569,6 +26176,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
25569
26176
|
retentionSweepTimer = null;
|
|
25570
26177
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
25571
26178
|
eventMediaDataPlane = null;
|
|
26179
|
+
/** Handle for the training-export data-plane listener (dispose on shutdown). */
|
|
26180
|
+
trainingExportDataPlane = null;
|
|
25572
26181
|
/** The NC artifact plane (signed public links for notification media) and its
|
|
25573
26182
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
25574
26183
|
ncArtifactPlane = null;
|
|
@@ -26202,7 +26811,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
26202
26811
|
let storage = this.ctx.kernel.storage;
|
|
26203
26812
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
26204
26813
|
if (mediaRoot) {
|
|
26205
|
-
const { FilesystemStorageProvider } = await import("../node-
|
|
26814
|
+
const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
|
|
26206
26815
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
26207
26816
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
26208
26817
|
}
|
|
@@ -26711,11 +27320,99 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
26711
27320
|
}) ?? null;
|
|
26712
27321
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
26713
27322
|
await this.serveNcArtifactPlane();
|
|
27323
|
+
await this.serveTrainingExportPlane();
|
|
26714
27324
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
26715
27325
|
} catch (err) {
|
|
26716
27326
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
26717
27327
|
}
|
|
26718
27328
|
}
|
|
27329
|
+
/**
|
|
27330
|
+
* The training-export data plane. Separate listener, `access: 'admin'` —
|
|
27331
|
+
* marking a track is a viewer-level curation act (D81), but walking off with
|
|
27332
|
+
* every stored frame behind the marked set is not.
|
|
27333
|
+
*/
|
|
27334
|
+
async serveTrainingExportPlane() {
|
|
27335
|
+
try {
|
|
27336
|
+
const handler = createTrainingExportHandler({
|
|
27337
|
+
buildPlan: (deviceIds) => this.buildTrainingExportPlan(deviceIds),
|
|
27338
|
+
readBlob: async (mediaKey) => {
|
|
27339
|
+
const file = await (this.mediaStore?.getByKey(mediaKey) ?? Promise.resolve(null));
|
|
27340
|
+
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
27341
|
+
},
|
|
27342
|
+
logger: this.ctx.logger,
|
|
27343
|
+
now: () => Date.now()
|
|
27344
|
+
});
|
|
27345
|
+
this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
27346
|
+
prefix: "training-export",
|
|
27347
|
+
access: "admin",
|
|
27348
|
+
handler
|
|
27349
|
+
}) ?? null;
|
|
27350
|
+
} catch (err) {
|
|
27351
|
+
this.ctx.logger.warn("training-export data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
27352
|
+
}
|
|
27353
|
+
}
|
|
27354
|
+
/**
|
|
27355
|
+
* Plan the training export — INDEX READS ONLY. No blob is touched here, which
|
|
27356
|
+
* is what lets `getTrainingExportSummary` answer "how big is this" for the
|
|
27357
|
+
* cost of a few KB per track instead of the gigabytes the answer is about.
|
|
27358
|
+
*
|
|
27359
|
+
* An empty `deviceIds` means every device that has tracks, resolved from the
|
|
27360
|
+
* track store rather than from the device manager: a camera that was removed
|
|
27361
|
+
* can still own marked tracks, and refusing to export them because the device
|
|
27362
|
+
* is gone would lose exactly the material an operator kept on purpose.
|
|
27363
|
+
*/
|
|
27364
|
+
async buildTrainingExportPlan(deviceIds) {
|
|
27365
|
+
const trackStore = this.trackStore;
|
|
27366
|
+
const mediaStore = this.mediaStore;
|
|
27367
|
+
const eventStore = this.eventStore;
|
|
27368
|
+
if (!trackStore || !mediaStore) throw new Error("training export: analytics stores unavailable");
|
|
27369
|
+
return buildTrainingExportPlan({
|
|
27370
|
+
listMarkedTracks: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
|
|
27371
|
+
listMediaInfo: async (ownerKind, ownerId) => {
|
|
27372
|
+
if (!isMediaOwnerKind(ownerKind)) return [];
|
|
27373
|
+
return mediaStore.listInfoByOwner(ownerKind, ownerId);
|
|
27374
|
+
},
|
|
27375
|
+
listEventIdsForTracks: async (trackIds) => {
|
|
27376
|
+
if (!eventStore || trackIds.length === 0) return [];
|
|
27377
|
+
return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
|
|
27378
|
+
eventId: e.id,
|
|
27379
|
+
trackId: e.trackId
|
|
27380
|
+
}]);
|
|
27381
|
+
},
|
|
27382
|
+
now: () => Date.now()
|
|
27383
|
+
}, {
|
|
27384
|
+
deviceIds: deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds(),
|
|
27385
|
+
maxTracks: TRAINING_EXPORT_MAX_TRACKS
|
|
27386
|
+
});
|
|
27387
|
+
}
|
|
27388
|
+
/**
|
|
27389
|
+
* Estimate the training export without producing it — the cheap question
|
|
27390
|
+
* (D56's discipline, applied to a pass that runs no inference at all).
|
|
27391
|
+
*/
|
|
27392
|
+
async getTrainingExportSummary(input) {
|
|
27393
|
+
const plan = await this.buildTrainingExportPlan(input.deviceIds ?? []);
|
|
27394
|
+
this.ctx.logger.info("training export summary", { meta: {
|
|
27395
|
+
tracks: plan.trackCount,
|
|
27396
|
+
files: plan.fileCount,
|
|
27397
|
+
bytes: plan.byteCount,
|
|
27398
|
+
truncated: plan.truncated
|
|
27399
|
+
} });
|
|
27400
|
+
return {
|
|
27401
|
+
generatedAt: plan.generatedAt,
|
|
27402
|
+
trackCount: plan.trackCount,
|
|
27403
|
+
fileCount: plan.fileCount,
|
|
27404
|
+
byteCount: plan.byteCount,
|
|
27405
|
+
truncated: plan.truncated,
|
|
27406
|
+
devices: plan.totals
|
|
27407
|
+
};
|
|
27408
|
+
}
|
|
27409
|
+
/** Where to stream the dataset from. The bytes never come through the cap. */
|
|
27410
|
+
async getTrainingExportUrl(input) {
|
|
27411
|
+
if (this.trainingExportDataPlane === null) throw new Error("training export: data plane unavailable on this node");
|
|
27412
|
+
const ids = input.deviceIds ?? [];
|
|
27413
|
+
const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
|
|
27414
|
+
return { url: `/addon/${this.ctx.id}/${TRAINING_EXPORT_PREFIX}${query}` };
|
|
27415
|
+
}
|
|
26719
27416
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
26720
27417
|
* enrichment-engine addon. It subscribes to DetectionResult, resolves the
|
|
26721
27418
|
* frame via the runner transport, crops each qualifying detection, and
|
|
@@ -26966,6 +27663,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
26966
27663
|
this.eventMediaDataPlane = null;
|
|
26967
27664
|
this.eventMediaBaseUrl = null;
|
|
26968
27665
|
this.eventMediaDispatcher = null;
|
|
27666
|
+
await this.trainingExportDataPlane?.dispose();
|
|
27667
|
+
this.trainingExportDataPlane = null;
|
|
26969
27668
|
}
|
|
26970
27669
|
async handleInferenceResult(data) {
|
|
26971
27670
|
if (this.shuttingDown) return;
|
|
@@ -30118,15 +30817,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30118
30817
|
}
|
|
30119
30818
|
/**
|
|
30120
30819
|
* Set the per-track operator flags (`markForTrain`, `debug`). Partial patch —
|
|
30121
|
-
* see `applyTrackFlags`.
|
|
30122
|
-
*
|
|
30820
|
+
* see `applyTrackFlags`. `markForTrain` moves the retrain LIFECYCLE
|
|
30821
|
+
* (`none ↔ staging`, D81), and `staging` is what PINS the track against the
|
|
30822
|
+
* retention sweep — which is why the body enforces a per-device staging budget
|
|
30823
|
+
* and refuses any move on an already-`trained` track. `debug` is attention
|
|
30824
|
+
* only. This is the write surface the three UI toggles share.
|
|
30123
30825
|
*/
|
|
30124
30826
|
async setTrackFlags(input) {
|
|
30125
30827
|
const trackStore = this.trackStore;
|
|
30126
30828
|
if (!trackStore) throw new Error("setTrackFlags: track store unavailable");
|
|
30127
30829
|
const out = await applyTrackFlags({
|
|
30128
|
-
|
|
30129
|
-
|
|
30830
|
+
store: {
|
|
30831
|
+
readFlags: (trackId) => trackStore.readFlags(trackId),
|
|
30832
|
+
setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
|
|
30833
|
+
countStaging: (query) => trackStore.countStaging(query)
|
|
30834
|
+
},
|
|
30835
|
+
logger: this.ctx.logger
|
|
30130
30836
|
}, input);
|
|
30131
30837
|
this.ctx.logger.info("track operator flags set", {
|
|
30132
30838
|
tags: { deviceId: input.deviceId },
|
|
@@ -30134,6 +30840,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30134
30840
|
deviceId: input.deviceId,
|
|
30135
30841
|
trackId: input.trackId,
|
|
30136
30842
|
markForTrain: out.markForTrain,
|
|
30843
|
+
retrainStatus: out.retrainStatus,
|
|
30137
30844
|
debug: out.debug
|
|
30138
30845
|
}
|
|
30139
30846
|
});
|
|
@@ -30257,6 +30964,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30257
30964
|
deviceId,
|
|
30258
30965
|
cutoffMs
|
|
30259
30966
|
});
|
|
30967
|
+
const staging = await this.trackStore?.countStaging({
|
|
30968
|
+
deviceId,
|
|
30969
|
+
olderThanMs: cutoffMs
|
|
30970
|
+
});
|
|
30971
|
+
if (staging !== void 0 && staging > 0) this.ctx.logger.info("track retention held staging tracks (retrainStatus)", {
|
|
30972
|
+
tags: { deviceId },
|
|
30973
|
+
meta: {
|
|
30974
|
+
deviceId,
|
|
30975
|
+
staging,
|
|
30976
|
+
cutoffMs,
|
|
30977
|
+
deleted: counts.tracks
|
|
30978
|
+
}
|
|
30979
|
+
});
|
|
30260
30980
|
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
30261
30981
|
op: "prune",
|
|
30262
30982
|
reason: "retention",
|