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