@camstack/addon-post-analysis 0.1.18 → 0.1.20
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/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/enrichment-engine/index.js +1 -1
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/{index-DafwGlkQ.js → index-B0RhVv1c.js} +3940 -807
- package/dist/index-B0RhVv1c.js.map +1 -0
- package/dist/{index-CIJfmsWX.mjs → index-ot5PeFg_.mjs} +3943 -810
- package/dist/index-ot5PeFg_.mjs.map +1 -0
- package/dist/pipeline-analytics/@mf-types.zip +0 -0
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-BD3oMNGB.mjs +29 -0
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BgOHCakr.mjs +18 -0
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D-USVuHq.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D1qPKjvR.mjs} +3 -1
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-qQCPW8pT.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-B5X50Xa4.mjs} +1 -1
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-Bv9bYz9E.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B10b5k5J.mjs} +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -3
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B3kCe2qM.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DWB3apaJ.mjs} +6 -6
- package/dist/pipeline-analytics/{client-DHmQcIWy.mjs → client-C6xdgLZU.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-CuWzic_f.mjs → hostInit-3cyL9eyG.mjs} +12 -12
- package/dist/pipeline-analytics/{index-BA65ZJOW.mjs → index-BCTHeI2m.mjs} +254 -268
- package/dist/pipeline-analytics/{index-Crs1D0Uu.mjs → index-BuWLz0GG.mjs} +1 -1
- package/dist/pipeline-analytics/{index-gpelkpEE.mjs → index-CIwq-tQL.mjs} +1 -1
- package/dist/pipeline-analytics/{index-CHnXxMRA.mjs → index-CWBMDbou.mjs} +1 -1
- package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
- package/dist/pipeline-analytics/{index-DicaGC31.mjs → index-D883Q5B8.mjs} +1 -1
- package/dist/pipeline-analytics/index-DtOI1aTU.mjs +18504 -0
- package/dist/pipeline-analytics/index.js +605 -42
- package/dist/pipeline-analytics/index.js.map +1 -1
- package/dist/pipeline-analytics/index.mjs +604 -42
- package/dist/pipeline-analytics/index.mjs.map +1 -1
- package/dist/pipeline-analytics/{jsx-runtime-Wcfyyyt4.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/pipeline-analytics/{schemas-ChN4Ih0h.mjs → schemas-B7L0qZtq.mjs} +530 -515
- package/package.json +12 -27
- package/dist/ffmpeg-config-DRONlBsj.mjs +0 -56
- package/dist/ffmpeg-config-DRONlBsj.mjs.map +0 -1
- package/dist/ffmpeg-config-uANz3sV5.js +0 -73
- package/dist/ffmpeg-config-uANz3sV5.js.map +0 -1
- package/dist/index-CIJfmsWX.mjs.map +0 -1
- package/dist/index-DafwGlkQ.js.map +0 -1
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +0 -19
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BcWYbuKp.mjs +0 -18
- package/dist/pipeline-analytics/index-CUXiTSWS.mjs +0 -13883
- package/dist/pipeline-analytics/index-gbflFMEY.mjs +0 -36403
- package/dist/playlist-generator-EhPaB7Hn.js +0 -48
- package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
- package/dist/playlist-generator-VTkgn53O.mjs +0 -48
- package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
- package/dist/recording/index.js +0 -257
- package/dist/recording/index.js.map +0 -1
- package/dist/recording/index.mjs +0 -235
- package/dist/recording/index.mjs.map +0 -1
- package/dist/recording-coordinator-BKsM_JGg.js +0 -1052
- package/dist/recording-coordinator-BKsM_JGg.js.map +0 -1
- package/dist/recording-coordinator-Bw3N1gYu.mjs +0 -1012
- package/dist/recording-coordinator-Bw3N1gYu.mjs.map +0 -1
- package/dist/recording-db-gOgaoQh0.js +0 -348
- package/dist/recording-db-gOgaoQh0.js.map +0 -1
- package/dist/recording-db-lIkSMTLq.mjs +0 -348
- package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
- package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
- package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
- package/dist/recording-service-facade-Do1PKlAL.js +0 -123
- package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
- package/dist/storage-estimator-CRpoQc9j.js +0 -72
- package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
- package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
- package/dist/storage-estimator-DzD8gWJH.mjs.map +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const index = require("../index-B0RhVv1c.js");
|
|
3
4
|
const node_crypto = require("node:crypto");
|
|
5
|
+
const sharp = require("sharp");
|
|
4
6
|
function pointInPolygon(point, polygon) {
|
|
5
7
|
if (polygon.length < 3) return false;
|
|
6
8
|
let inside = false;
|
|
@@ -891,7 +893,7 @@ class MediaStore {
|
|
|
891
893
|
const key = buildKey(params);
|
|
892
894
|
const path = buildPath(params);
|
|
893
895
|
try {
|
|
894
|
-
await this.storage.write({ location: "
|
|
896
|
+
await this.storage.write({ location: "eventMedia", relativePath: path, data: params.data });
|
|
895
897
|
await this.store.insert.mutate({
|
|
896
898
|
collection: MEDIA_COLLECTION,
|
|
897
899
|
record: {
|
|
@@ -926,7 +928,7 @@ class MediaStore {
|
|
|
926
928
|
const sizeBytes = Number(data["sizeBytes"]);
|
|
927
929
|
const kind = String(data["kind"]);
|
|
928
930
|
try {
|
|
929
|
-
const buf = await this.storage.read({ location: "
|
|
931
|
+
const buf = await this.storage.read({ location: "eventMedia", relativePath: path });
|
|
930
932
|
files.push({
|
|
931
933
|
key: row.id,
|
|
932
934
|
kind,
|
|
@@ -942,6 +944,32 @@ class MediaStore {
|
|
|
942
944
|
}
|
|
943
945
|
return files;
|
|
944
946
|
}
|
|
947
|
+
/** Delete ALL media (rows + blobs) owned by the given events. Called when the
|
|
948
|
+
* events are evicted at retention so a thumbnail never outlives its event.
|
|
949
|
+
* Returns number of blobs removed. Best-effort per row. */
|
|
950
|
+
async deleteForEvents(eventIds) {
|
|
951
|
+
let removed = 0;
|
|
952
|
+
for (const eventId of eventIds) {
|
|
953
|
+
const rows = await this.store.query.query({
|
|
954
|
+
collection: MEDIA_COLLECTION,
|
|
955
|
+
filter: { where: { ownerKind: "event", ownerId: eventId } }
|
|
956
|
+
});
|
|
957
|
+
for (const row of rows) {
|
|
958
|
+
const path = String(row.data["path"] ?? "");
|
|
959
|
+
try {
|
|
960
|
+
if (path) await this.storage.delete({ location: "eventMedia", relativePath: path });
|
|
961
|
+
} catch {
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
await this.store.delete.mutate({ collection: MEDIA_COLLECTION, key: row.id });
|
|
965
|
+
removed++;
|
|
966
|
+
} catch (err) {
|
|
967
|
+
this.logger.debug("media delete-for-event failed", { meta: { key: row.id, error: String(err) } });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return removed;
|
|
972
|
+
}
|
|
945
973
|
/** Retention sweep: delete any media row + blob older than cutoff.
|
|
946
974
|
* Returns number of entries removed. */
|
|
947
975
|
async evictBefore(cutoffMs) {
|
|
@@ -953,7 +981,7 @@ class MediaStore {
|
|
|
953
981
|
for (const row of rows) {
|
|
954
982
|
const path = String(row.data["path"] ?? "");
|
|
955
983
|
try {
|
|
956
|
-
if (path) await this.storage.delete({ location: "
|
|
984
|
+
if (path) await this.storage.delete({ location: "eventMedia", relativePath: path });
|
|
957
985
|
} catch {
|
|
958
986
|
}
|
|
959
987
|
try {
|
|
@@ -971,6 +999,7 @@ class MediaStore {
|
|
|
971
999
|
const MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
|
|
972
1000
|
const OBJECT_EVENTS_COLLECTION = "pipeline-analytics:object-events";
|
|
973
1001
|
const AUDIO_EVENTS_COLLECTION = "pipeline-analytics:audio-events";
|
|
1002
|
+
const PRUNE_PAGE_SIZE = 500;
|
|
974
1003
|
const COMMON_BASE_COLUMNS = [
|
|
975
1004
|
{ name: "id", type: "TEXT", primaryKey: true, notNull: true },
|
|
976
1005
|
{ name: "deviceId", type: "INTEGER", notNull: true },
|
|
@@ -1081,6 +1110,9 @@ class EventStore {
|
|
|
1081
1110
|
collection: MOTION_EVENTS_COLLECTION,
|
|
1082
1111
|
filter: this.buildFilter(q)
|
|
1083
1112
|
});
|
|
1113
|
+
if (q.projection === "slim") {
|
|
1114
|
+
return rows.map((r) => slimMotion(r.id, stripNulls(r.data)));
|
|
1115
|
+
}
|
|
1084
1116
|
return rows.map((r) => {
|
|
1085
1117
|
const ev = { id: r.id, kind: "motion", ...stripNulls(r.data) };
|
|
1086
1118
|
return ev;
|
|
@@ -1096,6 +1128,9 @@ class EventStore {
|
|
|
1096
1128
|
collection: OBJECT_EVENTS_COLLECTION,
|
|
1097
1129
|
filter
|
|
1098
1130
|
});
|
|
1131
|
+
if (q.projection === "slim") {
|
|
1132
|
+
return rows.map((r) => slimObject(r.id, stripNulls(r.data)));
|
|
1133
|
+
}
|
|
1099
1134
|
return rows.map((r) => {
|
|
1100
1135
|
const ev = { id: r.id, kind: "object", ...stripNulls(r.data) };
|
|
1101
1136
|
return ev;
|
|
@@ -1106,35 +1141,193 @@ class EventStore {
|
|
|
1106
1141
|
collection: AUDIO_EVENTS_COLLECTION,
|
|
1107
1142
|
filter: this.buildFilter(q)
|
|
1108
1143
|
});
|
|
1144
|
+
if (q.projection === "slim") {
|
|
1145
|
+
return rows.map((r) => slimAudio(r.id, stripNulls(r.data)));
|
|
1146
|
+
}
|
|
1109
1147
|
return rows.map((r) => {
|
|
1110
1148
|
const ev = { id: r.id, kind: "audio", ...stripNulls(r.data) };
|
|
1111
1149
|
return ev;
|
|
1112
1150
|
});
|
|
1113
1151
|
}
|
|
1152
|
+
// ── Density (histogram aggregation) ─────────────────────────────
|
|
1153
|
+
/**
|
|
1154
|
+
* Return per-kind event counts in equal-width time buckets.
|
|
1155
|
+
*
|
|
1156
|
+
* Each bucket maps to a `bucketStart = since + i * bucketMs`.
|
|
1157
|
+
* Empty buckets (motion + object + audio all zero) are omitted so the
|
|
1158
|
+
* UI only processes non-trivial data points.
|
|
1159
|
+
*
|
|
1160
|
+
* All three collections are queried in parallel via `histogram` to
|
|
1161
|
+
* minimise latency.
|
|
1162
|
+
*/
|
|
1163
|
+
async densityByKind(q) {
|
|
1164
|
+
const run = async (collection) => {
|
|
1165
|
+
const rows = await this.store.histogram.query({
|
|
1166
|
+
collection,
|
|
1167
|
+
field: "timestamp",
|
|
1168
|
+
bucketSize: q.bucketMs,
|
|
1169
|
+
origin: q.since,
|
|
1170
|
+
filter: {
|
|
1171
|
+
where: { deviceId: q.deviceId },
|
|
1172
|
+
whereBetween: { timestamp: [q.since, q.until] }
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
return new Map(rows.map((r) => [r.bucket, r.count]));
|
|
1176
|
+
};
|
|
1177
|
+
const [motionMap, objectMap, audioMap] = await Promise.all([
|
|
1178
|
+
run(MOTION_EVENTS_COLLECTION),
|
|
1179
|
+
run(OBJECT_EVENTS_COLLECTION),
|
|
1180
|
+
run(AUDIO_EVENTS_COLLECTION)
|
|
1181
|
+
]);
|
|
1182
|
+
const buckets = Math.max(0, Math.ceil((q.until - q.since) / q.bucketMs));
|
|
1183
|
+
const out = [];
|
|
1184
|
+
for (let i = 0; i < buckets; i++) {
|
|
1185
|
+
const m = motionMap.get(i) ?? 0;
|
|
1186
|
+
const o = objectMap.get(i) ?? 0;
|
|
1187
|
+
const a = audioMap.get(i) ?? 0;
|
|
1188
|
+
if (m + o + a === 0) continue;
|
|
1189
|
+
out.push({ bucketStart: q.since + i * q.bucketMs, motion: m, object: o, audio: a });
|
|
1190
|
+
}
|
|
1191
|
+
return out;
|
|
1192
|
+
}
|
|
1114
1193
|
// ── Retention ────────────────────────────────────────────────────
|
|
1115
1194
|
// (helper below)
|
|
1195
|
+
/**
|
|
1196
|
+
* Delete events older than the per-kind cutoffs and RETURN the deleted ids so
|
|
1197
|
+
* the caller can delete each event's media in lockstep (a thumbnail must never
|
|
1198
|
+
* outlive — nor predecease — its event). Batched (≤500/kind/sweep).
|
|
1199
|
+
*/
|
|
1116
1200
|
async evictBefore(params) {
|
|
1117
|
-
const deletedCounts = { motion: 0, object: 0, audio: 0 };
|
|
1118
1201
|
const batch = async (collection, cutoffMs) => {
|
|
1119
1202
|
const rows = await this.store.query.query({
|
|
1120
1203
|
collection,
|
|
1121
1204
|
filter: { whereBetween: { timestamp: [0, cutoffMs] }, limit: 500 }
|
|
1122
1205
|
});
|
|
1123
|
-
|
|
1206
|
+
const ids = [];
|
|
1124
1207
|
for (const row of rows) {
|
|
1208
|
+
const id = row.id;
|
|
1209
|
+
if (typeof id !== "string") continue;
|
|
1125
1210
|
try {
|
|
1126
|
-
await this.store.delete.mutate({ collection, key:
|
|
1127
|
-
|
|
1211
|
+
await this.store.delete.mutate({ collection, key: id });
|
|
1212
|
+
ids.push(id);
|
|
1128
1213
|
} catch {
|
|
1129
1214
|
}
|
|
1130
1215
|
}
|
|
1131
|
-
return
|
|
1216
|
+
return ids;
|
|
1217
|
+
};
|
|
1218
|
+
return {
|
|
1219
|
+
motion: await batch(MOTION_EVENTS_COLLECTION, params.motionCutoffMs),
|
|
1220
|
+
object: await batch(OBJECT_EVENTS_COLLECTION, params.objectCutoffMs),
|
|
1221
|
+
audio: await batch(AUDIO_EVENTS_COLLECTION, params.audioCutoffMs)
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Delete all events for a specific device with `timestamp < cutoffMs`
|
|
1226
|
+
* (exclusive upper bound) across all three kinds, returning the per-kind
|
|
1227
|
+
* deleted counts and the flattened list of deleted ids (so the caller can
|
|
1228
|
+
* delete thumbnails in lockstep via `MediaStore.deleteForEvents`).
|
|
1229
|
+
*
|
|
1230
|
+
* Filter shape: `where: { deviceId }` + `whereBetween: { timestamp: [0, cutoffMs - 1] }`
|
|
1231
|
+
* mirrors the inclusive `[lo, hi]` range the settings-store backend uses,
|
|
1232
|
+
* making `cutoffMs - 1` the last timestamp that qualifies (i.e. strictly < cutoffMs).
|
|
1233
|
+
*
|
|
1234
|
+
* The underlying settings-store `query` cap applies a default row limit
|
|
1235
|
+
* (~500). This method loops per kind — repeatedly querying up to
|
|
1236
|
+
* `PRUNE_PAGE_SIZE` rows and deleting them — until a page returns 0 rows,
|
|
1237
|
+
* so ALL matching rows are deleted regardless of total count.
|
|
1238
|
+
*/
|
|
1239
|
+
async pruneBefore(input) {
|
|
1240
|
+
const allForDevice = async (collection) => {
|
|
1241
|
+
const ids = [];
|
|
1242
|
+
for (; ; ) {
|
|
1243
|
+
const rows = await this.store.query.query({
|
|
1244
|
+
collection,
|
|
1245
|
+
filter: {
|
|
1246
|
+
where: { deviceId: input.deviceId },
|
|
1247
|
+
// inclusive [0, cutoffMs - 1] → selects timestamps strictly < cutoffMs
|
|
1248
|
+
whereBetween: { timestamp: [0, input.cutoffMs - 1] },
|
|
1249
|
+
limit: PRUNE_PAGE_SIZE
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
if (rows.length === 0) break;
|
|
1253
|
+
let deletedInPage = 0;
|
|
1254
|
+
for (const row of rows) {
|
|
1255
|
+
const id = row.id;
|
|
1256
|
+
if (typeof id !== "string") continue;
|
|
1257
|
+
try {
|
|
1258
|
+
await this.store.delete.mutate({ collection, key: id });
|
|
1259
|
+
ids.push(id);
|
|
1260
|
+
deletedInPage++;
|
|
1261
|
+
} catch {
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (deletedInPage === 0) break;
|
|
1265
|
+
}
|
|
1266
|
+
return ids;
|
|
1267
|
+
};
|
|
1268
|
+
const [motionIds, objectIds, audioIds] = await Promise.all([
|
|
1269
|
+
allForDevice(MOTION_EVENTS_COLLECTION),
|
|
1270
|
+
allForDevice(OBJECT_EVENTS_COLLECTION),
|
|
1271
|
+
allForDevice(AUDIO_EVENTS_COLLECTION)
|
|
1272
|
+
]);
|
|
1273
|
+
return {
|
|
1274
|
+
counts: {
|
|
1275
|
+
motion: motionIds.length,
|
|
1276
|
+
object: objectIds.length,
|
|
1277
|
+
audio: audioIds.length
|
|
1278
|
+
},
|
|
1279
|
+
ids: [...motionIds, ...objectIds, ...audioIds]
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function slimMotion(id, data) {
|
|
1284
|
+
return {
|
|
1285
|
+
id,
|
|
1286
|
+
kind: "motion",
|
|
1287
|
+
deviceId: data["deviceId"],
|
|
1288
|
+
timestamp: data["timestamp"],
|
|
1289
|
+
regionCount: data["regionCount"]
|
|
1290
|
+
// regions / frameWidth / frameHeight intentionally omitted (slim)
|
|
1291
|
+
// mediaUrl left unset — B5 will populate
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function slimObject(id, data) {
|
|
1295
|
+
const base = {
|
|
1296
|
+
id,
|
|
1297
|
+
kind: "object",
|
|
1298
|
+
deviceId: data["deviceId"],
|
|
1299
|
+
timestamp: data["timestamp"],
|
|
1300
|
+
className: data["className"]
|
|
1301
|
+
// trackId / confidence / bbox / zones / state intentionally omitted (slim)
|
|
1302
|
+
// mediaUrl left unset — B5 will populate
|
|
1303
|
+
};
|
|
1304
|
+
if (typeof data["label"] === "string") {
|
|
1305
|
+
return { ...base, label: data["label"] };
|
|
1306
|
+
}
|
|
1307
|
+
return base;
|
|
1308
|
+
}
|
|
1309
|
+
function slimAudio(id, data) {
|
|
1310
|
+
const base = {
|
|
1311
|
+
id,
|
|
1312
|
+
kind: "audio",
|
|
1313
|
+
deviceId: data["deviceId"],
|
|
1314
|
+
timestamp: data["timestamp"],
|
|
1315
|
+
rms: data["rms"],
|
|
1316
|
+
dbfs: data["dbfs"]
|
|
1317
|
+
// mediaUrl left unset — B5 will populate
|
|
1318
|
+
};
|
|
1319
|
+
if (data["classification"] !== null && data["classification"] !== void 0) {
|
|
1320
|
+
const c = data["classification"];
|
|
1321
|
+
return {
|
|
1322
|
+
...base,
|
|
1323
|
+
classification: {
|
|
1324
|
+
className: c.className,
|
|
1325
|
+
score: c.score,
|
|
1326
|
+
...typeof c.originalClass === "string" ? { originalClass: c.originalClass } : {}
|
|
1327
|
+
}
|
|
1132
1328
|
};
|
|
1133
|
-
deletedCounts.motion = await batch(MOTION_EVENTS_COLLECTION, params.motionCutoffMs);
|
|
1134
|
-
deletedCounts.object = await batch(OBJECT_EVENTS_COLLECTION, params.objectCutoffMs);
|
|
1135
|
-
deletedCounts.audio = await batch(AUDIO_EVENTS_COLLECTION, params.audioCutoffMs);
|
|
1136
|
-
return deletedCounts;
|
|
1137
1329
|
}
|
|
1330
|
+
return base;
|
|
1138
1331
|
}
|
|
1139
1332
|
function stripNulls(data) {
|
|
1140
1333
|
const out = {};
|
|
@@ -1143,6 +1336,41 @@ function stripNulls(data) {
|
|
|
1143
1336
|
}
|
|
1144
1337
|
return out;
|
|
1145
1338
|
}
|
|
1339
|
+
const THUMB_MAX_WIDTH = 360;
|
|
1340
|
+
const THUMB_QUALITY = 70;
|
|
1341
|
+
class EventThumbnailer {
|
|
1342
|
+
constructor(deps) {
|
|
1343
|
+
this.deps = deps;
|
|
1344
|
+
}
|
|
1345
|
+
/** Thumbnail one event. */
|
|
1346
|
+
async capture(deviceId, target) {
|
|
1347
|
+
await this.captureMany(deviceId, [target]);
|
|
1348
|
+
}
|
|
1349
|
+
/** Thumbnail several events that share a moment (e.g. multiple object events
|
|
1350
|
+
* from one inference frame) with a single snapshot fetch + encode. */
|
|
1351
|
+
async captureMany(deviceId, targets) {
|
|
1352
|
+
if (targets.length === 0) return;
|
|
1353
|
+
try {
|
|
1354
|
+
const snap = await this.deps.getSnapshot(deviceId);
|
|
1355
|
+
if (!snap?.base64) return;
|
|
1356
|
+
const jpeg = await sharp(Buffer.from(snap.base64, "base64")).rotate().resize({ width: THUMB_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
1357
|
+
for (const t of targets) {
|
|
1358
|
+
await this.deps.mediaStore.put({
|
|
1359
|
+
deviceId,
|
|
1360
|
+
ownerKind: "event",
|
|
1361
|
+
ownerId: t.eventId,
|
|
1362
|
+
kind: "thumbnail",
|
|
1363
|
+
timestamp: t.timestamp,
|
|
1364
|
+
data: jpeg
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
this.deps.logger.warn("event thumbnail capture failed", {
|
|
1369
|
+
meta: { deviceId, count: targets.length, error: err instanceof Error ? err.message : String(err) }
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1146
1374
|
class SliceThrottler {
|
|
1147
1375
|
opts;
|
|
1148
1376
|
lastWrittenAt = /* @__PURE__ */ new Map();
|
|
@@ -1710,15 +1938,128 @@ function computeAudioSnapshot(ts, s, windowSec, scoreThreshold) {
|
|
|
1710
1938
|
byClass
|
|
1711
1939
|
};
|
|
1712
1940
|
}
|
|
1941
|
+
const AudioDetectionSettingsSchema = index.object({
|
|
1942
|
+
classificationMinScore: index.number().min(0).max(1).default(0.6),
|
|
1943
|
+
levelDeviationDb: index.number().positive().default(10),
|
|
1944
|
+
levelWindowSec: index.number().positive().default(10),
|
|
1945
|
+
levelMaxWaitSec: index.number().positive().default(20)
|
|
1946
|
+
});
|
|
1947
|
+
const AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
|
|
1948
|
+
function resolveAudioDetectionSettings(raw) {
|
|
1949
|
+
const pick = (key) => {
|
|
1950
|
+
const field = AudioDetectionSettingsSchema.shape[key];
|
|
1951
|
+
const parsed = field.safeParse(raw[key]);
|
|
1952
|
+
return parsed.success ? parsed.data : AUDIO_DETECTION_DEFAULTS[key];
|
|
1953
|
+
};
|
|
1954
|
+
return {
|
|
1955
|
+
classificationMinScore: pick("classificationMinScore"),
|
|
1956
|
+
levelDeviationDb: pick("levelDeviationDb"),
|
|
1957
|
+
levelWindowSec: pick("levelWindowSec"),
|
|
1958
|
+
levelMaxWaitSec: pick("levelMaxWaitSec")
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function emptyLevelState() {
|
|
1962
|
+
return { samples: [], lastEmitMs: null, lastPeakMs: null };
|
|
1963
|
+
}
|
|
1964
|
+
function observeLevel(state, sample, cfg) {
|
|
1965
|
+
const cutoff = sample.timestampMs - cfg.windowMs;
|
|
1966
|
+
const kept = state.samples.filter((s) => s.timestampMs >= cutoff);
|
|
1967
|
+
const windowSamples = [...kept, sample];
|
|
1968
|
+
const mean = windowSamples.reduce((acc, s) => acc + s.dbfs, 0) / windowSamples.length;
|
|
1969
|
+
const audible = sample.dbfs > cfg.silenceFloorDbfs;
|
|
1970
|
+
if (!audible) {
|
|
1971
|
+
return {
|
|
1972
|
+
emit: false,
|
|
1973
|
+
reason: null,
|
|
1974
|
+
mean,
|
|
1975
|
+
nextState: { samples: windowSamples, lastEmitMs: state.lastEmitMs, lastPeakMs: state.lastPeakMs }
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
const priorMean = kept.length > 0 ? kept.reduce((acc, s) => acc + s.dbfs, 0) / kept.length : mean;
|
|
1979
|
+
const isPeak = sample.dbfs >= priorMean + cfg.deviationDb;
|
|
1980
|
+
const sincePeak = state.lastPeakMs === null ? Number.POSITIVE_INFINITY : sample.timestampMs - state.lastPeakMs;
|
|
1981
|
+
const peakCooldownOk = sincePeak >= cfg.maxWaitMs;
|
|
1982
|
+
const sinceLastEmit = state.lastEmitMs === null ? Number.POSITIVE_INFINITY : sample.timestampMs - state.lastEmitMs;
|
|
1983
|
+
const heartbeatCooldownOk = sinceLastEmit >= cfg.maxWaitMs;
|
|
1984
|
+
let reason = null;
|
|
1985
|
+
if (isPeak && peakCooldownOk) reason = "peak";
|
|
1986
|
+
else if (!isPeak && heartbeatCooldownOk) reason = "heartbeat";
|
|
1987
|
+
const emit = reason !== null;
|
|
1988
|
+
const nextLastEmitMs = emit ? sample.timestampMs : state.lastEmitMs;
|
|
1989
|
+
const nextLastPeakMs = reason === "peak" ? sample.timestampMs : state.lastPeakMs;
|
|
1990
|
+
return {
|
|
1991
|
+
emit,
|
|
1992
|
+
reason,
|
|
1993
|
+
mean,
|
|
1994
|
+
nextState: { samples: windowSamples, lastEmitMs: nextLastEmitMs, lastPeakMs: nextLastPeakMs }
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function classifyAudioFrame(top, cfg) {
|
|
1998
|
+
if (top && top.className !== "silence" && top.score >= cfg.classificationMinScore) {
|
|
1999
|
+
return { kind: "classification", className: top.className, score: top.score };
|
|
2000
|
+
}
|
|
2001
|
+
return { kind: "level" };
|
|
2002
|
+
}
|
|
2003
|
+
const CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
2004
|
+
function createEventMediaHandler(deps) {
|
|
2005
|
+
return async (req, res) => {
|
|
2006
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2007
|
+
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const rawPath = (req.url ?? "/").split("?")[0] ?? "/";
|
|
2011
|
+
const eventId = rawPath.replace(/^\/+/, "");
|
|
2012
|
+
if (!eventId || eventId.includes("/")) {
|
|
2013
|
+
res.writeHead(404).end();
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
let media = null;
|
|
2017
|
+
try {
|
|
2018
|
+
media = await deps.getMedia(eventId);
|
|
2019
|
+
} catch {
|
|
2020
|
+
const body = "Internal server error";
|
|
2021
|
+
res.writeHead(500, { "content-type": "text/plain", "content-length": String(Buffer.byteLength(body)) });
|
|
2022
|
+
if (req.method !== "HEAD") res.end(body);
|
|
2023
|
+
else res.end();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (media === null) {
|
|
2027
|
+
res.writeHead(404).end();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const etag = '"' + media.key + '"';
|
|
2031
|
+
if (req.headers["if-none-match"] === etag) {
|
|
2032
|
+
res.writeHead(304, { etag, "cache-control": CACHE_CONTROL }).end();
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
res.writeHead(200, {
|
|
2036
|
+
"content-type": "image/jpeg",
|
|
2037
|
+
"cache-control": CACHE_CONTROL,
|
|
2038
|
+
etag,
|
|
2039
|
+
"content-length": String(media.bytes.byteLength)
|
|
2040
|
+
});
|
|
2041
|
+
if (req.method === "HEAD") {
|
|
2042
|
+
res.end();
|
|
2043
|
+
} else {
|
|
2044
|
+
res.end(Buffer.from(media.bytes));
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
1713
2048
|
const TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
2049
|
+
const SETTINGS_CACHE_TTL_MS = 5e3;
|
|
2050
|
+
const SILENCE_FLOOR_DBFS = -55;
|
|
1714
2051
|
const RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
1715
2052
|
const AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
1716
2053
|
const MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
2054
|
+
function toAnalyticsDeviceSections(sections) {
|
|
2055
|
+
return sections.map((s) => ({ ...s, tab: s.tab ?? "Analytics", location: "top-tab" }));
|
|
2056
|
+
}
|
|
1717
2057
|
class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
1718
2058
|
processors = /* @__PURE__ */ new Map();
|
|
1719
2059
|
trackStore = null;
|
|
1720
2060
|
mediaStore = null;
|
|
1721
2061
|
eventStore = null;
|
|
2062
|
+
thumbnailer = null;
|
|
1722
2063
|
bindingCache = null;
|
|
1723
2064
|
zoneAnalytics = null;
|
|
1724
2065
|
audioMetrics = null;
|
|
@@ -1741,6 +2082,12 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1741
2082
|
unsubDeviceUnreg = null;
|
|
1742
2083
|
ttlSweepTimer = null;
|
|
1743
2084
|
retentionSweepTimer = null;
|
|
2085
|
+
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
2086
|
+
eventMediaDataPlane = null;
|
|
2087
|
+
/** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
|
|
2088
|
+
* Set once the data-plane is registered; null until then (e.g. no
|
|
2089
|
+
* dataPlane facility in the current environment). */
|
|
2090
|
+
eventMediaBaseUrl = null;
|
|
1744
2091
|
// Current tracked-state snapshot per active track. Used so
|
|
1745
2092
|
// TrackStarted/TrackEnded fire exactly once per lifecycle transition.
|
|
1746
2093
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
@@ -1751,6 +2098,12 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1751
2098
|
// Same throttle for motion: one row per heartbeat while detected stays
|
|
1752
2099
|
// true; immediate insert on off→on transition. Off→off skipped.
|
|
1753
2100
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
2101
|
+
// Per-device rolling-window loudness detector state. Updated on every audio
|
|
2102
|
+
// frame that falls to the level path (non-confident / silence classification).
|
|
2103
|
+
levelStateByDevice = /* @__PURE__ */ new Map();
|
|
2104
|
+
// Per-device settings cache: avoids a store read every ~30 Hz audio frame.
|
|
2105
|
+
// Each entry expires after SETTINGS_CACHE_TTL_MS.
|
|
2106
|
+
settingsCacheByDevice = /* @__PURE__ */ new Map();
|
|
1754
2107
|
shuttingDown = false;
|
|
1755
2108
|
constructor() {
|
|
1756
2109
|
super({});
|
|
@@ -1771,6 +2124,17 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1771
2124
|
logger: logger.child("MediaStore")
|
|
1772
2125
|
});
|
|
1773
2126
|
this.eventStore = new EventStore({ store: api.settingsStore, logger: logger.child("EventStore") });
|
|
2127
|
+
this.thumbnailer = new EventThumbnailer({
|
|
2128
|
+
getSnapshot: async (deviceId) => {
|
|
2129
|
+
try {
|
|
2130
|
+
return await api.snapshot.getSnapshot.query({ deviceId });
|
|
2131
|
+
} catch {
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
2134
|
+
},
|
|
2135
|
+
mediaStore: this.mediaStore,
|
|
2136
|
+
logger: logger.child("EventThumbnailer")
|
|
2137
|
+
});
|
|
1774
2138
|
this.bindingCache = new BindingCache({ api, logger: logger.child("BindingCache") });
|
|
1775
2139
|
this.zoneAnalytics = new ZoneAnalyticsProvider({
|
|
1776
2140
|
logger: logger.child("ZoneAnalytics"),
|
|
@@ -1791,6 +2155,25 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1791
2155
|
return { enabled, thresholdDb };
|
|
1792
2156
|
}
|
|
1793
2157
|
});
|
|
2158
|
+
try {
|
|
2159
|
+
const handler = createEventMediaHandler({
|
|
2160
|
+
getMedia: async (id) => {
|
|
2161
|
+
try {
|
|
2162
|
+
return await this.readEventThumbnail(id);
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
this.ctx.logger.warn("readEventThumbnail failed", { meta: { eventId: id, error: index.errMsg(err) } });
|
|
2165
|
+
throw err;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({ prefix: "event-media", access: "admin", handler }) ?? null;
|
|
2170
|
+
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
2171
|
+
this.ctx.logger.info("event-media data-plane served", {
|
|
2172
|
+
meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" }
|
|
2173
|
+
});
|
|
2174
|
+
} catch (err) {
|
|
2175
|
+
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: index.errMsg(err) } });
|
|
2176
|
+
}
|
|
1794
2177
|
this.unsubInference = this.ctx.eventBus.subscribe(
|
|
1795
2178
|
{ category: index.EventCategory.PipelineInferenceResult },
|
|
1796
2179
|
(ev) => {
|
|
@@ -1829,6 +2212,8 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1829
2212
|
this.trackStore?.clearDevice(data.deviceId);
|
|
1830
2213
|
this.processors.delete(data.deviceId);
|
|
1831
2214
|
this.lastActiveTrackIds.delete(data.deviceId);
|
|
2215
|
+
this.levelStateByDevice.delete(data.deviceId);
|
|
2216
|
+
this.settingsCacheByDevice.delete(data.deviceId);
|
|
1832
2217
|
}
|
|
1833
2218
|
}
|
|
1834
2219
|
);
|
|
@@ -1839,6 +2224,8 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
1839
2224
|
this.trackStore?.clearDevice(deviceId);
|
|
1840
2225
|
this.processors.delete(deviceId);
|
|
1841
2226
|
this.lastActiveTrackIds.delete(deviceId);
|
|
2227
|
+
this.levelStateByDevice.delete(deviceId);
|
|
2228
|
+
this.settingsCacheByDevice.delete(deviceId);
|
|
1842
2229
|
this.bindingCache?.invalidate(deviceId);
|
|
1843
2230
|
this.zoneAnalytics?.forgetDevice(deviceId);
|
|
1844
2231
|
this.audioMetrics?.forgetDevice(deviceId);
|
|
@@ -2042,8 +2429,13 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2042
2429
|
this.audioMetrics?.destroy();
|
|
2043
2430
|
this.processors.clear();
|
|
2044
2431
|
this.lastActiveTrackIds.clear();
|
|
2432
|
+
this.levelStateByDevice.clear();
|
|
2433
|
+
this.settingsCacheByDevice.clear();
|
|
2045
2434
|
this.trackStore?.clearAll();
|
|
2046
2435
|
this.bindingCache?.clearAll();
|
|
2436
|
+
await this.eventMediaDataPlane?.dispose();
|
|
2437
|
+
this.eventMediaDataPlane = null;
|
|
2438
|
+
this.eventMediaBaseUrl = null;
|
|
2047
2439
|
}
|
|
2048
2440
|
// ── Subscriber handlers ──────────────────────────────────────────────
|
|
2049
2441
|
async handleInferenceResult(data) {
|
|
@@ -2107,6 +2499,12 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2107
2499
|
}
|
|
2108
2500
|
this.lastActiveTrackIds.set(deviceId, currentTrackIds);
|
|
2109
2501
|
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
2502
|
+
if (result.objectEvents.length > 0) {
|
|
2503
|
+
void this.thumbnailer?.captureMany(
|
|
2504
|
+
deviceId,
|
|
2505
|
+
result.objectEvents.map((e) => ({ eventId: e.id, timestamp: e.timestamp }))
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2110
2508
|
for (const e of result.objectEvents) {
|
|
2111
2509
|
this.ctx.eventBus.emit({
|
|
2112
2510
|
id: `pa-${e.id}`,
|
|
@@ -2130,6 +2528,19 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2130
2528
|
}
|
|
2131
2529
|
});
|
|
2132
2530
|
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Read per-device audio-detection settings with a TTL cache so the store
|
|
2533
|
+
* is not hit on every ~30 Hz audio frame.
|
|
2534
|
+
*/
|
|
2535
|
+
async resolveDeviceAudioSettings(deviceId) {
|
|
2536
|
+
const now = Date.now();
|
|
2537
|
+
const cached = this.settingsCacheByDevice.get(deviceId);
|
|
2538
|
+
if (cached && now < cached.expiresAt) return cached.settings;
|
|
2539
|
+
const raw = await this.ctx?.settings?.readDeviceStore(deviceId) ?? {};
|
|
2540
|
+
const settings = resolveAudioDetectionSettings(raw);
|
|
2541
|
+
this.settingsCacheByDevice.set(deviceId, { settings, expiresAt: now + SETTINGS_CACHE_TTL_MS });
|
|
2542
|
+
return settings;
|
|
2543
|
+
}
|
|
2133
2544
|
async handleAudioResult(data) {
|
|
2134
2545
|
if (this.shuttingDown) return;
|
|
2135
2546
|
const { deviceId, frame } = data;
|
|
@@ -2149,33 +2560,67 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2149
2560
|
}
|
|
2150
2561
|
} : {}
|
|
2151
2562
|
});
|
|
2152
|
-
const
|
|
2153
|
-
const
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2563
|
+
const settings = await this.resolveDeviceAudioSettings(deviceId);
|
|
2564
|
+
const topForRouter = topClassification ? { className: topClassification.macroClass, score: topClassification.score } : void 0;
|
|
2565
|
+
const route = classifyAudioFrame(topForRouter, { classificationMinScore: settings.classificationMinScore });
|
|
2566
|
+
if (route.kind === "classification") {
|
|
2567
|
+
if (!topClassification) {
|
|
2568
|
+
this.ctx.logger.warn("classifyAudioFrame returned classification but topClassification is null", { meta: { deviceId } });
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
const last = this.lastAudioInsertByDevice.get(deviceId);
|
|
2572
|
+
const sameClass = last?.className === route.className;
|
|
2573
|
+
const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
|
|
2574
|
+
if (sameClass && !heartbeatDue) return;
|
|
2575
|
+
const ev2 = {
|
|
2576
|
+
id: node_crypto.randomUUID(),
|
|
2577
|
+
deviceId,
|
|
2578
|
+
timestamp,
|
|
2579
|
+
kind: "audio",
|
|
2580
|
+
rms: level?.rms ?? 0,
|
|
2581
|
+
dbfs: level?.dbfs ?? 0,
|
|
2582
|
+
classification: {
|
|
2583
|
+
className: topClassification.macroClass,
|
|
2584
|
+
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
2585
|
+
score: topClassification.score
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
this.lastAudioInsertByDevice.set(deviceId, { className: route.className, atMs: timestamp });
|
|
2589
|
+
await this.eventStore.insertAudio(ev2);
|
|
2590
|
+
void this.thumbnailer?.capture(ev2.deviceId, { eventId: ev2.id, timestamp: ev2.timestamp });
|
|
2591
|
+
this.ctx.eventBus.emit({
|
|
2592
|
+
id: `pa-${ev2.id}`,
|
|
2593
|
+
timestamp: new Date(ev2.timestamp),
|
|
2594
|
+
source: { type: "addon", id: "pipeline-analytics", addonId: "pipeline-analytics" },
|
|
2595
|
+
category: index.EventCategory.PipelineAnalyticsDetectionEvent,
|
|
2596
|
+
data: { deviceId, kind: "audio", eventId: ev2.id, timestamp: ev2.timestamp }
|
|
2597
|
+
});
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
const st = this.levelStateByDevice.get(deviceId) ?? emptyLevelState();
|
|
2601
|
+
const res = observeLevel(
|
|
2602
|
+
st,
|
|
2603
|
+
{ timestampMs: timestamp, dbfs: level?.dbfs ?? -Infinity },
|
|
2604
|
+
{
|
|
2605
|
+
windowMs: settings.levelWindowSec * 1e3,
|
|
2606
|
+
deviationDb: settings.levelDeviationDb,
|
|
2607
|
+
maxWaitMs: settings.levelMaxWaitSec * 1e3,
|
|
2608
|
+
silenceFloorDbfs: SILENCE_FLOOR_DBFS
|
|
2609
|
+
}
|
|
2610
|
+
);
|
|
2611
|
+
this.levelStateByDevice.set(deviceId, res.nextState);
|
|
2612
|
+
if (!res.emit) return;
|
|
2162
2613
|
const ev = {
|
|
2163
2614
|
id: node_crypto.randomUUID(),
|
|
2164
2615
|
deviceId,
|
|
2165
2616
|
timestamp,
|
|
2166
2617
|
kind: "audio",
|
|
2167
2618
|
rms: level?.rms ?? 0,
|
|
2168
|
-
dbfs: level?.dbfs ?? 0
|
|
2169
|
-
...topClassification ? {
|
|
2170
|
-
classification: {
|
|
2171
|
-
className: topClassification.macroClass,
|
|
2172
|
-
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
2173
|
-
score: topClassification.score
|
|
2174
|
-
}
|
|
2175
|
-
} : {}
|
|
2619
|
+
dbfs: level?.dbfs ?? 0
|
|
2176
2620
|
};
|
|
2177
|
-
this.lastAudioInsertByDevice.set(deviceId, { className, atMs: timestamp });
|
|
2621
|
+
this.lastAudioInsertByDevice.set(deviceId, { className: void 0, atMs: timestamp });
|
|
2178
2622
|
await this.eventStore.insertAudio(ev);
|
|
2623
|
+
void this.thumbnailer?.capture(ev.deviceId, { eventId: ev.id, timestamp: ev.timestamp });
|
|
2179
2624
|
this.ctx.eventBus.emit({
|
|
2180
2625
|
id: `pa-${ev.id}`,
|
|
2181
2626
|
timestamp: new Date(ev.timestamp),
|
|
@@ -2220,6 +2665,7 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2220
2665
|
};
|
|
2221
2666
|
this.lastMotionInsertByDevice.set(deviceId, { detected, atMs: timestamp });
|
|
2222
2667
|
await this.eventStore.insertMotion(ev);
|
|
2668
|
+
void this.thumbnailer?.capture(ev.deviceId, { eventId: ev.id, timestamp: ev.timestamp });
|
|
2223
2669
|
this.ctx.eventBus.emit({
|
|
2224
2670
|
id: `pa-${ev.id}`,
|
|
2225
2671
|
timestamp: new Date(ev.timestamp),
|
|
@@ -2271,6 +2717,7 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2271
2717
|
};
|
|
2272
2718
|
this.lastMotionInsertByDevice.set(deviceId, { detected, atMs: timestamp });
|
|
2273
2719
|
await this.eventStore.insertMotion(ev);
|
|
2720
|
+
void this.thumbnailer?.capture(ev.deviceId, { eventId: ev.id, timestamp: ev.timestamp });
|
|
2274
2721
|
this.ctx.eventBus.emit({
|
|
2275
2722
|
id: `pa-${ev.id}`,
|
|
2276
2723
|
timestamp: new Date(ev.timestamp),
|
|
@@ -2308,13 +2755,31 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2308
2755
|
if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
|
|
2309
2756
|
const now = Date.now();
|
|
2310
2757
|
const day = 24 * 60 * 60 * 1e3;
|
|
2758
|
+
const OBJECT_RETENTION_DAYS = 30;
|
|
2759
|
+
const motionCutoffMs = now - 14 * day;
|
|
2760
|
+
const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
|
|
2761
|
+
const audioCutoffMs = now - 7 * day;
|
|
2311
2762
|
try {
|
|
2312
|
-
await this.eventStore.evictBefore({
|
|
2313
|
-
motionCutoffMs
|
|
2314
|
-
objectCutoffMs
|
|
2315
|
-
audioCutoffMs
|
|
2763
|
+
const evicted = await this.eventStore.evictBefore({
|
|
2764
|
+
motionCutoffMs,
|
|
2765
|
+
objectCutoffMs,
|
|
2766
|
+
audioCutoffMs
|
|
2316
2767
|
});
|
|
2317
|
-
|
|
2768
|
+
const evictedIds = [...evicted.motion, ...evicted.object, ...evicted.audio];
|
|
2769
|
+
if (evictedIds.length > 0) {
|
|
2770
|
+
await this.mediaStore.deleteForEvents(evictedIds);
|
|
2771
|
+
this.ctx.logger.info("analytics event eviction (age sweep)", {
|
|
2772
|
+
meta: {
|
|
2773
|
+
motion: evicted.motion.length,
|
|
2774
|
+
object: evicted.object.length,
|
|
2775
|
+
audio: evicted.audio.length,
|
|
2776
|
+
motionCutoffMs,
|
|
2777
|
+
objectCutoffMs,
|
|
2778
|
+
audioCutoffMs
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
await this.mediaStore.evictBefore(now - (OBJECT_RETENTION_DAYS + 1) * day);
|
|
2318
2783
|
} catch (err) {
|
|
2319
2784
|
this.ctx.logger.debug("sweepRetention failed", { meta: { error: String(err) } });
|
|
2320
2785
|
}
|
|
@@ -2392,13 +2857,22 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2392
2857
|
this.lastActiveTrackIds.delete(input.deviceId);
|
|
2393
2858
|
}
|
|
2394
2859
|
async getMotionEvents(input) {
|
|
2395
|
-
|
|
2860
|
+
const events = await (this.eventStore?.queryMotion(input) ?? Promise.resolve([]));
|
|
2861
|
+
if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
|
|
2862
|
+
return this.withMediaUrl(events);
|
|
2863
|
+
}
|
|
2864
|
+
async getEventDensity(input) {
|
|
2865
|
+
return this.eventStore?.densityByKind(input) ?? [];
|
|
2396
2866
|
}
|
|
2397
2867
|
async getObjectEvents(input) {
|
|
2398
|
-
|
|
2868
|
+
const events = await (this.eventStore?.queryObject(input) ?? Promise.resolve([]));
|
|
2869
|
+
if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
|
|
2870
|
+
return this.withMediaUrl(events);
|
|
2399
2871
|
}
|
|
2400
2872
|
async getAudioEvents(input) {
|
|
2401
|
-
|
|
2873
|
+
const events = await (this.eventStore?.queryAudio(input) ?? Promise.resolve([]));
|
|
2874
|
+
if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
|
|
2875
|
+
return this.withMediaUrl(events);
|
|
2402
2876
|
}
|
|
2403
2877
|
async getEventMedia(input) {
|
|
2404
2878
|
return this.mediaStore?.listByOwner("event", input.eventId) ?? [];
|
|
@@ -2406,6 +2880,45 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2406
2880
|
async getTrackMedia(input) {
|
|
2407
2881
|
return this.mediaStore?.listByOwner("track", input.trackId) ?? [];
|
|
2408
2882
|
}
|
|
2883
|
+
async pruneEventsBefore(input) {
|
|
2884
|
+
if (!this.eventStore) return { motion: 0, object: 0, audio: 0 };
|
|
2885
|
+
const { counts, ids } = await this.eventStore.pruneBefore(input);
|
|
2886
|
+
if (ids.length > 0 && this.mediaStore) {
|
|
2887
|
+
await this.mediaStore.deleteForEvents([...ids]);
|
|
2888
|
+
}
|
|
2889
|
+
const { motion, object, audio } = counts;
|
|
2890
|
+
if (motion + object + audio > 0) {
|
|
2891
|
+
this.ctx.logger.info("analytics event eviction (floor)", {
|
|
2892
|
+
meta: { deviceId: input.deviceId, cutoffMs: input.cutoffMs, motion, object, audio }
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
return counts;
|
|
2896
|
+
}
|
|
2897
|
+
// ── Data-plane helpers ────────────────────────────────────────────────
|
|
2898
|
+
/**
|
|
2899
|
+
* Decode a stored event thumbnail into `EventMedia` for the data-plane
|
|
2900
|
+
* handler. Prefers the `thumbnail` kind; falls back to the first file
|
|
2901
|
+
* if no thumbnail is present. Returns `null` if the event has no media.
|
|
2902
|
+
*/
|
|
2903
|
+
async readEventThumbnail(eventId) {
|
|
2904
|
+
const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
|
|
2905
|
+
const thumbnail = files.find((f) => f.kind === "thumbnail") ?? files[0];
|
|
2906
|
+
if (!thumbnail) return null;
|
|
2907
|
+
const bytes = Buffer.from(thumbnail.base64, "base64");
|
|
2908
|
+
return { bytes, key: thumbnail.key };
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* Return a new array where each event gets a `mediaUrl` stamp pointing
|
|
2912
|
+
* to its thumbnail on the event-media data-plane.
|
|
2913
|
+
*
|
|
2914
|
+
* Only called when `this.eventMediaBaseUrl` is non-null (guard sits in
|
|
2915
|
+
* the callers), so the template literal is safe.
|
|
2916
|
+
*/
|
|
2917
|
+
withMediaUrl(events) {
|
|
2918
|
+
const base = this.eventMediaBaseUrl;
|
|
2919
|
+
if (base === null) return events;
|
|
2920
|
+
return events.map((e) => ({ ...e, mediaUrl: `${base}/${e.id}` }));
|
|
2921
|
+
}
|
|
2409
2922
|
// ── Global + per-device settings (P8) ─────────────────────────────
|
|
2410
2923
|
//
|
|
2411
2924
|
// Cascade: global defaults live on this addon's global settings
|
|
@@ -2551,6 +3064,54 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2551
3064
|
unit: "days"
|
|
2552
3065
|
}
|
|
2553
3066
|
]
|
|
3067
|
+
},
|
|
3068
|
+
{
|
|
3069
|
+
id: "audio-detection",
|
|
3070
|
+
title: "Audio detection",
|
|
3071
|
+
description: "Thresholds for audio-level and classification events. Per-device override.",
|
|
3072
|
+
columns: 2,
|
|
3073
|
+
fields: [
|
|
3074
|
+
{
|
|
3075
|
+
type: "number",
|
|
3076
|
+
key: "classificationMinScore",
|
|
3077
|
+
label: "Classification min score",
|
|
3078
|
+
description: "Minimum confidence (0–1) for a classification result to be accepted.",
|
|
3079
|
+
min: 0,
|
|
3080
|
+
max: 1,
|
|
3081
|
+
step: 0.05,
|
|
3082
|
+
default: AUDIO_DETECTION_DEFAULTS.classificationMinScore
|
|
3083
|
+
},
|
|
3084
|
+
{
|
|
3085
|
+
type: "number",
|
|
3086
|
+
key: "levelDeviationDb",
|
|
3087
|
+
label: "Level deviation",
|
|
3088
|
+
description: "dB above the rolling mean required to trigger a peak event.",
|
|
3089
|
+
min: 1,
|
|
3090
|
+
step: 1,
|
|
3091
|
+
default: AUDIO_DETECTION_DEFAULTS.levelDeviationDb,
|
|
3092
|
+
unit: "dB"
|
|
3093
|
+
},
|
|
3094
|
+
{
|
|
3095
|
+
type: "number",
|
|
3096
|
+
key: "levelWindowSec",
|
|
3097
|
+
label: "Level window",
|
|
3098
|
+
description: "Rolling baseline window length for peak detection.",
|
|
3099
|
+
min: 1,
|
|
3100
|
+
step: 1,
|
|
3101
|
+
default: AUDIO_DETECTION_DEFAULTS.levelWindowSec,
|
|
3102
|
+
unit: "s"
|
|
3103
|
+
},
|
|
3104
|
+
{
|
|
3105
|
+
type: "number",
|
|
3106
|
+
key: "levelMaxWaitSec",
|
|
3107
|
+
label: "Level max wait",
|
|
3108
|
+
description: "Maximum silence between events before a heartbeat sample is emitted.",
|
|
3109
|
+
min: 1,
|
|
3110
|
+
step: 1,
|
|
3111
|
+
default: AUDIO_DETECTION_DEFAULTS.levelMaxWaitSec,
|
|
3112
|
+
unit: "s"
|
|
3113
|
+
}
|
|
3114
|
+
]
|
|
2554
3115
|
}
|
|
2555
3116
|
]
|
|
2556
3117
|
});
|
|
@@ -2562,7 +3123,7 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2562
3123
|
const baseSections = schema ? index.hydrateSchema(
|
|
2563
3124
|
{
|
|
2564
3125
|
...schema,
|
|
2565
|
-
sections: schema.sections
|
|
3126
|
+
sections: toAnalyticsDeviceSections(schema.sections)
|
|
2566
3127
|
},
|
|
2567
3128
|
raw
|
|
2568
3129
|
).sections : [];
|
|
@@ -2590,6 +3151,7 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2590
3151
|
}
|
|
2591
3152
|
async applyDeviceSettingsPatch(input) {
|
|
2592
3153
|
await this.updateDeviceSettings(input.deviceId, input.patch);
|
|
3154
|
+
this.settingsCacheByDevice.delete(input.deviceId);
|
|
2593
3155
|
return { success: true };
|
|
2594
3156
|
}
|
|
2595
3157
|
/**
|
|
@@ -2610,5 +3172,6 @@ class PipelineAnalyticsAddon extends index.BaseAddon {
|
|
|
2610
3172
|
}
|
|
2611
3173
|
}
|
|
2612
3174
|
}
|
|
2613
|
-
|
|
3175
|
+
exports.default = PipelineAnalyticsAddon;
|
|
3176
|
+
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|
|
2614
3177
|
//# sourceMappingURL=index.js.map
|