@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.
Files changed (67) 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-BFbwYH1P.js → index-B0RhVv1c.js} +3514 -750
  6. package/dist/index-B0RhVv1c.js.map +1 -0
  7. package/dist/{index-BrTlzsrE.mjs → index-ot5PeFg_.mjs} +3517 -753
  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-BZTB2scQ.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D1qPKjvR.mjs} +2 -1
  14. 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
  15. 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
  16. package/dist/pipeline-analytics/_stub.js +2 -3
  17. 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
  18. package/dist/pipeline-analytics/{client-BlxIUpgf.mjs → client-C6xdgLZU.mjs} +2 -2
  19. package/dist/pipeline-analytics/{hostInit-qBB1Thhi.mjs → hostInit-3cyL9eyG.mjs} +12 -12
  20. package/dist/pipeline-analytics/{index-Dw6Q30NI.mjs → index-BCTHeI2m.mjs} +253 -267
  21. package/dist/pipeline-analytics/{index-DlhiA9R0.mjs → index-BuWLz0GG.mjs} +1 -1
  22. package/dist/pipeline-analytics/{index-DtdgkNgf.mjs → index-CIwq-tQL.mjs} +1 -1
  23. package/dist/pipeline-analytics/{index-BoL0rgZt.mjs → index-CWBMDbou.mjs} +1 -1
  24. package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
  25. package/dist/pipeline-analytics/{index-CR1aiZDH.mjs → index-D883Q5B8.mjs} +1 -1
  26. package/dist/pipeline-analytics/{index-Dy2V7VOm.mjs → index-DtOI1aTU.mjs} +10112 -5987
  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-Dlbl3gpr.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
  32. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  33. package/dist/pipeline-analytics/{schemas-ClCuS4qa.mjs → schemas-B7L0qZtq.mjs} +411 -406
  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-BFbwYH1P.js.map +0 -1
  40. package/dist/index-BrTlzsrE.mjs.map +0 -1
  41. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.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-7HAAnpQu.mjs +0 -18
  43. package/dist/pipeline-analytics/index-i47purqY.mjs +0 -37880
  44. package/dist/playlist-generator-EhPaB7Hn.js +0 -48
  45. package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
  46. package/dist/playlist-generator-VTkgn53O.mjs +0 -48
  47. package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
  48. package/dist/recording/index.js +0 -257
  49. package/dist/recording/index.js.map +0 -1
  50. package/dist/recording/index.mjs +0 -235
  51. package/dist/recording/index.mjs.map +0 -1
  52. package/dist/recording-coordinator-BoGr5moz.js +0 -1052
  53. package/dist/recording-coordinator-BoGr5moz.js.map +0 -1
  54. package/dist/recording-coordinator-CsYH9LqF.mjs +0 -1012
  55. package/dist/recording-coordinator-CsYH9LqF.mjs.map +0 -1
  56. package/dist/recording-db-gOgaoQh0.js +0 -348
  57. package/dist/recording-db-gOgaoQh0.js.map +0 -1
  58. package/dist/recording-db-lIkSMTLq.mjs +0 -348
  59. package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
  60. package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
  61. package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
  62. package/dist/recording-service-facade-Do1PKlAL.js +0 -123
  63. package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
  64. package/dist/storage-estimator-CRpoQc9j.js +0 -72
  65. package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
  66. package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
  67. 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-BrTlzsrE.mjs";
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: "data", relativePath: path, data: params.data });
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: "data", relativePath: path });
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: "data", relativePath: path });
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
- let count = 0;
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: row.id });
1126
- count++;
1209
+ await this.store.delete.mutate({ collection, key: id });
1210
+ ids.push(id);
1127
1211
  } catch {
1128
1212
  }
1129
1213
  }
1130
- return count;
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 className = topClassification?.macroClass;
2152
- const scoreFloor = 0.4;
2153
- const hasMeaningfulClassification = className !== void 0 && (topClassification?.score ?? 0) >= scoreFloor;
2154
- const dbfsFloor = -55;
2155
- const aboveSilence = (level?.dbfs ?? -Infinity) > dbfsFloor;
2156
- if (!hasMeaningfulClassification && !aboveSilence) return;
2157
- const last = this.lastAudioInsertByDevice.get(deviceId);
2158
- const sameClass = last?.className === className;
2159
- const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
2160
- if (sameClass && !heartbeatDue) return;
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: now - 14 * day,
2313
- objectCutoffMs: now - 30 * day,
2314
- audioCutoffMs: now - 7 * day
2761
+ const evicted = await this.eventStore.evictBefore({
2762
+ motionCutoffMs,
2763
+ objectCutoffMs,
2764
+ audioCutoffMs
2315
2765
  });
2316
- await this.mediaStore.evictBefore(now - 14 * day);
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
- return this.eventStore?.queryMotion(input) ?? [];
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
- return this.eventStore?.queryObject(input) ?? [];
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
- return this.eventStore?.queryAudio(input) ?? [];
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.map((s) => ({ ...s, tab: s.tab ?? "Analytics" }))
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