@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.
Files changed (68) hide show
  1. package/dist/embedding-encoder/index.js +1 -1
  2. package/dist/embedding-encoder/index.mjs +1 -1
  3. package/dist/enrichment-engine/index.js +1 -1
  4. package/dist/enrichment-engine/index.mjs +1 -1
  5. package/dist/{index-DafwGlkQ.js → index-B0RhVv1c.js} +3940 -807
  6. package/dist/index-B0RhVv1c.js.map +1 -0
  7. package/dist/{index-CIJfmsWX.mjs → index-ot5PeFg_.mjs} +3943 -810
  8. package/dist/index-ot5PeFg_.mjs.map +1 -0
  9. package/dist/pipeline-analytics/@mf-types.zip +0 -0
  10. 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
  11. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-BD3oMNGB.mjs +29 -0
  12. 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
  13. 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
  14. 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
  15. 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
  16. package/dist/pipeline-analytics/_stub.js +2 -3
  17. 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
  18. package/dist/pipeline-analytics/{client-DHmQcIWy.mjs → client-C6xdgLZU.mjs} +2 -2
  19. package/dist/pipeline-analytics/{hostInit-CuWzic_f.mjs → hostInit-3cyL9eyG.mjs} +12 -12
  20. package/dist/pipeline-analytics/{index-BA65ZJOW.mjs → index-BCTHeI2m.mjs} +254 -268
  21. package/dist/pipeline-analytics/{index-Crs1D0Uu.mjs → index-BuWLz0GG.mjs} +1 -1
  22. package/dist/pipeline-analytics/{index-gpelkpEE.mjs → index-CIwq-tQL.mjs} +1 -1
  23. package/dist/pipeline-analytics/{index-CHnXxMRA.mjs → index-CWBMDbou.mjs} +1 -1
  24. package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
  25. package/dist/pipeline-analytics/{index-DicaGC31.mjs → index-D883Q5B8.mjs} +1 -1
  26. package/dist/pipeline-analytics/index-DtOI1aTU.mjs +18504 -0
  27. package/dist/pipeline-analytics/index.js +605 -42
  28. package/dist/pipeline-analytics/index.js.map +1 -1
  29. package/dist/pipeline-analytics/index.mjs +604 -42
  30. package/dist/pipeline-analytics/index.mjs.map +1 -1
  31. package/dist/pipeline-analytics/{jsx-runtime-Wcfyyyt4.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
  32. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  33. package/dist/pipeline-analytics/{schemas-ChN4Ih0h.mjs → schemas-B7L0qZtq.mjs} +530 -515
  34. package/package.json +12 -27
  35. package/dist/ffmpeg-config-DRONlBsj.mjs +0 -56
  36. package/dist/ffmpeg-config-DRONlBsj.mjs.map +0 -1
  37. package/dist/ffmpeg-config-uANz3sV5.js +0 -73
  38. package/dist/ffmpeg-config-uANz3sV5.js.map +0 -1
  39. package/dist/index-CIJfmsWX.mjs.map +0 -1
  40. package/dist/index-DafwGlkQ.js.map +0 -1
  41. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +0 -19
  42. 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
  43. package/dist/pipeline-analytics/index-CUXiTSWS.mjs +0 -13883
  44. package/dist/pipeline-analytics/index-gbflFMEY.mjs +0 -36403
  45. package/dist/playlist-generator-EhPaB7Hn.js +0 -48
  46. package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
  47. package/dist/playlist-generator-VTkgn53O.mjs +0 -48
  48. package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
  49. package/dist/recording/index.js +0 -257
  50. package/dist/recording/index.js.map +0 -1
  51. package/dist/recording/index.mjs +0 -235
  52. package/dist/recording/index.mjs.map +0 -1
  53. package/dist/recording-coordinator-BKsM_JGg.js +0 -1052
  54. package/dist/recording-coordinator-BKsM_JGg.js.map +0 -1
  55. package/dist/recording-coordinator-Bw3N1gYu.mjs +0 -1012
  56. package/dist/recording-coordinator-Bw3N1gYu.mjs.map +0 -1
  57. package/dist/recording-db-gOgaoQh0.js +0 -348
  58. package/dist/recording-db-gOgaoQh0.js.map +0 -1
  59. package/dist/recording-db-lIkSMTLq.mjs +0 -348
  60. package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
  61. package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
  62. package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
  63. package/dist/recording-service-facade-Do1PKlAL.js +0 -123
  64. package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
  65. package/dist/storage-estimator-CRpoQc9j.js +0 -72
  66. package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
  67. package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
  68. package/dist/storage-estimator-DzD8gWJH.mjs.map +0 -1
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
- const index = require("../index-DafwGlkQ.js");
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: "data", relativePath: path, data: params.data });
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: "data", relativePath: path });
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: "data", relativePath: path });
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
- let count = 0;
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: row.id });
1127
- count++;
1211
+ await this.store.delete.mutate({ collection, key: id });
1212
+ ids.push(id);
1128
1213
  } catch {
1129
1214
  }
1130
1215
  }
1131
- return count;
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 className = topClassification?.macroClass;
2153
- const scoreFloor = 0.4;
2154
- const hasMeaningfulClassification = className !== void 0 && (topClassification?.score ?? 0) >= scoreFloor;
2155
- const dbfsFloor = -55;
2156
- const aboveSilence = (level?.dbfs ?? -Infinity) > dbfsFloor;
2157
- if (!hasMeaningfulClassification && !aboveSilence) return;
2158
- const last = this.lastAudioInsertByDevice.get(deviceId);
2159
- const sameClass = last?.className === className;
2160
- const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
2161
- if (sameClass && !heartbeatDue) return;
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: now - 14 * day,
2314
- objectCutoffMs: now - 30 * day,
2315
- audioCutoffMs: now - 7 * day
2763
+ const evicted = await this.eventStore.evictBefore({
2764
+ motionCutoffMs,
2765
+ objectCutoffMs,
2766
+ audioCutoffMs
2316
2767
  });
2317
- await this.mediaStore.evictBefore(now - 14 * day);
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
- return this.eventStore?.queryMotion(input) ?? [];
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
- return this.eventStore?.queryObject(input) ?? [];
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
- return this.eventStore?.queryAudio(input) ?? [];
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.map((s) => ({ ...s, tab: s.tab ?? "Analytics" }))
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
- module.exports = PipelineAnalyticsAddon;
3175
+ exports.default = PipelineAnalyticsAddon;
3176
+ exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
2614
3177
  //# sourceMappingURL=index.js.map