@camstack/addon-post-analysis 1.2.84 → 1.2.86

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.
@@ -1,4 +1,4 @@
1
- import { At as string, B as addonWidgetsSourceCapability, C as NcSnoozeSchema, Ct as boolean, D as OpsLogEntrySchema, Dt as object, E as NcTaxonomySchema, Et as number, F as TIMELAPSE_DENSE_FLOOR_SEC, G as buildEventKindDescriptor, H as assertTimelapseCadences, I as TimelapseRuleInputSchema, J as defineCustomActions, K as cosineSimilarity$1, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as EventCategory, N as SCENE_DIVERGED, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as partialRecord, P as SceneMonitorSchema, Q as faceGalleryCapability, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as array, T as NcSystemEventKindSchema, Tt as literal, U as audioMetricsCapability, V as alarmPanelCapability, W as audioModeOf, Y as deriveRecordingMode, Z as encodeVectorBase64, _ as NcRuleInputSchema, _t as hydrateSchema, at as plateGalleryCapability, b as NcRuleTargetSchema, bt as sleep, c as EVENT_PAD_MS, ct as sceneMonitorCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as videoclipsCapability, et as isDetectionMacroClass, f as NC_CONDITION_CATALOG, ft as zoneAnalyticsCapability, g as NcConditionDescriptorSchema, gt as createEvent, h as NC_TAXONOMY, ht as DeviceType, i as DETECTION_MACRO_CLASSES, it as pipelineAnalyticsCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as unknown, k as RetrainStatusSchema, kt as record, l as LabelAttributionSchema, lt as subKindsOf, mt as BaseAddon, n as DEFAULT_EVENT_COLOR, nt as kebabToCamel, o as DeclaredDevices, ot as readDeviceStateFrom, p as NC_DEFAULT_SNOOZE_MINUTES, pt as errMsg, q as customAction, rt as notificationRulesCapability, s as EVENT_KIND_BY_CAP, st as readTimelapseGeneratedAt, t as BaseDevice, tt as isScheduleActive, u as MACRO_LABELS, ut as vectorDimFromBase64, v as NcRulePatchSchema, vt as isDeviceScopedCap, w as NcSnoozeSuppressedSchema, wt as discriminatedUnion, x as NcScheduleSchema, xt as _enum, y as NcRuleSchema, yt as nodePin, z as TrackSourceSchema } from "../dist-CJWHkPz8.mjs";
1
+ import { $ as faceGalleryCapability, At as record, B as VISIT_MERGE_GAP_MS, C as NcSnoozeSchema, Ct as array, D as OpsLogEntrySchema, Dt as number, E as NcTaxonomySchema, Et as literal, F as TIMELAPSE_DENSE_FLOOR_SEC, G as audioModeOf, H as alarmPanelCapability, I as TimelapseRuleInputSchema, J as customAction, K as buildEventKindDescriptor, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as unknown, N as SCENE_DIVERGED, Nt as EventCategory, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as object, P as SceneMonitorSchema, Q as encodeVectorBase64, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as _enum, T as NcSystemEventKindSchema, Tt as discriminatedUnion, U as assertTimelapseCadences, V as addonWidgetsSourceCapability, W as audioMetricsCapability, X as deriveRecordingMode, Y as defineCustomActions, _ as NcRuleInputSchema, _t as createEvent, at as pipelineAnalyticsCapability, b as NcRuleTargetSchema, bt as nodePin, c as EVENT_PAD_MS, ct as readTimelapseGeneratedAt, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as vectorDimFromBase64, f as NC_CONDITION_CATALOG, ft as videoclipsCapability, g as NcConditionDescriptorSchema, gt as DeviceType, h as NC_TAXONOMY, ht as BaseAddon, i as DETECTION_MACRO_CLASSES, it as notificationRulesCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as string, k as RetrainStatusSchema, kt as partialRecord, l as LabelAttributionSchema, lt as sceneMonitorCapability, mt as errMsg, n as DEFAULT_EVENT_COLOR, nt as isScheduleActive, o as DeclaredDevices, ot as plateGalleryCapability, p as NC_DEFAULT_SNOOZE_MINUTES, pt as zoneAnalyticsCapability, q as cosineSimilarity$1, rt as kebabToCamel, s as EVENT_KIND_BY_CAP, st as readDeviceStateFrom, t as BaseDevice, tt as isDetectionMacroClass, u as MACRO_LABELS, ut as subKindsOf, v as NcRulePatchSchema, vt as hydrateSchema, w as NcSnoozeSuppressedSchema, wt as boolean, x as NcScheduleSchema, xt as sleep, y as NcRuleSchema, yt as isDeviceScopedCap, z as TrackSourceSchema } from "../dist-CzdBsvoR.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1580,11 +1580,31 @@ var NC_ALARM_DECLARATION = {
1580
1580
  * retention="pruned by NcArtifactStore's shelf bounds (14 d / 2000 rows / 2 GiB) — the index row falls with its bytes, on the producer node only (see prune(null) gate)"
1581
1581
  */
1582
1582
  var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1583
- var NcArtifactKindSchema = _enum([
1583
+ /**
1584
+ * What an artefact IS. The schema lives in the ADDON, not in `packages/types`,
1585
+ * for the same reason the summary rule's does: it ships on `camstack deploy`
1586
+ * and never waits for a framework train.
1587
+ *
1588
+ * TWO groups:
1589
+ * - **Gallery tiles** — what the operator browses (timelapse, gif, mp4, mosaic).
1590
+ * - **Posters** — companion stills (`timelapse-preview`, `event-still`). They
1591
+ * exist so a video/gif tile can name its thumbnail via `posterArtifactId`.
1592
+ * They are not a filter chip and they are omitted from an unfiltered list.
1593
+ */
1594
+ var NC_GALLERY_ARTIFACT_KINDS = [
1584
1595
  "timelapse",
1585
- "timelapse-preview",
1596
+ "event-gif",
1597
+ "event-clip",
1586
1598
  "summary-mosaic"
1587
- ]);
1599
+ ];
1600
+ var NC_POSTER_ARTIFACT_KINDS = ["timelapse-preview", "event-still"];
1601
+ var NcArtifactKindSchema = _enum([...NC_GALLERY_ARTIFACT_KINDS, ...NC_POSTER_ARTIFACT_KINDS]);
1602
+ /** Kind a notification attachment is filed under in the gallery. */
1603
+ function artifactKindForMediaType(mediaType) {
1604
+ if (mediaType === "gif") return "event-gif";
1605
+ if (mediaType === "video") return "event-clip";
1606
+ return "event-still";
1607
+ }
1588
1608
  /**
1589
1609
  * How the viewer should DRAW it. Derived from the mime once, here, so no client
1590
1610
  * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
@@ -1943,7 +1963,7 @@ var NcArtifactIndex = class {
1943
1963
  }
1944
1964
  buildFilter(query, viewer) {
1945
1965
  const whereIn = { ...this.visibility(viewer).whereIn };
1946
- if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1966
+ whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
1947
1967
  if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1948
1968
  const whereBetween = {
1949
1969
  capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
@@ -5688,7 +5708,7 @@ var NcAudioStillShelf = class {
5688
5708
  }
5689
5709
  async fetch(captureId, deviceId, atMs) {
5690
5710
  try {
5691
- const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5711
+ const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5692
5712
  if (shot === null || shot.base64.length === 0) {
5693
5713
  this.reportMiss(deviceId, "the camera returned no snapshot");
5694
5714
  return;
@@ -5754,7 +5774,7 @@ function audioStillMedia(data, timestamp) {
5754
5774
  ];
5755
5775
  }
5756
5776
  /** Reject if `promise` does not settle within `ms`. */
5757
- function withTimeout$2(promise, ms) {
5777
+ function withTimeout$3(promise, ms) {
5758
5778
  return new Promise((resolve, reject) => {
5759
5779
  const timer = setTimeout(() => {
5760
5780
  reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
@@ -7763,10 +7783,24 @@ function matchesSystemEvent(condition, subject) {
7763
7783
  if (!condition.kinds.includes(subject.kind)) return false;
7764
7784
  if (condition.deviceIds !== void 0 && (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId))) return false;
7765
7785
  if (condition.deviceTypes !== void 0 && (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType))) return false;
7766
- if (condition.nodeIds !== void 0 && (subject.nodeId === void 0 || !condition.nodeIds.includes(subject.nodeId))) return false;
7767
- if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
7786
+ if (condition.nodeIds !== void 0) {
7787
+ if (!subjectNodes(subject).some((id) => condition.nodeIds.includes(id))) return false;
7788
+ }
7789
+ if (condition.packageNames !== void 0) {
7790
+ if (!subjectPackages(subject).some((name) => condition.packageNames.includes(name))) return false;
7791
+ }
7768
7792
  return true;
7769
7793
  }
7794
+ function subjectNodes(subject) {
7795
+ const ids = [...subject.nodeIds ?? []];
7796
+ if (subject.nodeId !== void 0 && !ids.includes(subject.nodeId)) ids.push(subject.nodeId);
7797
+ return ids;
7798
+ }
7799
+ function subjectPackages(subject) {
7800
+ const names = [...subject.packageNames ?? []];
7801
+ if (subject.packageName !== void 0 && !names.includes(subject.packageName)) names.push(subject.packageName);
7802
+ return names;
7803
+ }
7770
7804
  function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7771
7805
  if (subject.kind === "system-event") {
7772
7806
  if (rule.delivery !== "system-event") return fail("delivery");
@@ -8223,12 +8257,30 @@ var en_default = {
8223
8257
  "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
8224
8258
  "other": "Camera {{deviceName}} has been analysed for {{minutes}} minutes with no detection at all"
8225
8259
  },
8226
- "system.addon-update-available.title": "Addon update available",
8227
- "system.addon-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
8260
+ "system.addon-update-available.title": {
8261
+ "one": "{{count}} addon update available",
8262
+ "other": "{{count}} addon updates available"
8263
+ },
8264
+ "system.addon-update-available.body": "{{list}}",
8228
8265
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8229
- "system.server-update-available.title": "Server update available",
8266
+ "system.server-update-available.title": {
8267
+ "one": "Server update available on {{count}} node",
8268
+ "other": "Server update available on {{count}} nodes"
8269
+ },
8230
8270
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
8231
8271
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8272
+ "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
8273
+ "system.addon-updated.title": {
8274
+ "one": "{{count}} addon updated",
8275
+ "other": "{{count}} addons updated"
8276
+ },
8277
+ "system.addon-updated.body": "{{list}}",
8278
+ "system.server-updated.title": "Server updated",
8279
+ "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8280
+ "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8281
+ "system.export-completed.title": "Export ready",
8282
+ "system.export-completed.body": "The footage export is ready",
8283
+ "system.export-completed.body.titled": "{{title}} is ready",
8232
8284
  "system.camera-online.title": "Device online",
8233
8285
  "system.camera-online.body": "Device {{deviceName}} is online",
8234
8286
  "system.camera-offline.title": "Device offline",
@@ -8417,12 +8469,30 @@ var it_default = {
8417
8469
  "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
8418
8470
  "other": "La camera {{deviceName}} è analizzata da {{minutes}} minuti senza alcun rilevamento"
8419
8471
  },
8420
- "system.addon-update-available.title": "Aggiornamento addon disponibile",
8421
- "system.addon-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
8472
+ "system.addon-update-available.title": {
8473
+ "one": "{{count}} aggiornamento addon disponibile",
8474
+ "other": "{{count}} aggiornamenti addon disponibili"
8475
+ },
8476
+ "system.addon-update-available.body": "{{list}}",
8422
8477
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8423
- "system.server-update-available.title": "Aggiornamento server disponibile",
8478
+ "system.server-update-available.title": {
8479
+ "one": "Aggiornamento server disponibile su {{count}} nodo",
8480
+ "other": "Aggiornamento server disponibile su {{count}} nodi"
8481
+ },
8424
8482
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
8425
8483
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8484
+ "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
8485
+ "system.addon-updated.title": {
8486
+ "one": "{{count}} addon aggiornato",
8487
+ "other": "{{count}} addon aggiornati"
8488
+ },
8489
+ "system.addon-updated.body": "{{list}}",
8490
+ "system.server-updated.title": "Server aggiornato",
8491
+ "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8492
+ "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8493
+ "system.export-completed.title": "Export pronto",
8494
+ "system.export-completed.body": "L'export del filmato è pronto",
8495
+ "system.export-completed.body.titled": "{{title}} è pronto",
8426
8496
  "system.camera-online.title": "Dispositivo online",
8427
8497
  "system.camera-online.body": "Il dispositivo {{deviceName}} è online",
8428
8498
  "system.camera-offline.title": "Dispositivo offline",
@@ -9149,7 +9219,7 @@ var NcDispatcher = class {
9149
9219
  vars: { body: rendered },
9150
9220
  frozen: rendered
9151
9221
  }) : rendered;
9152
- const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9222
+ const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9153
9223
  const params = pickParams(entry.payload.params);
9154
9224
  const actions = await this.mintActions(entry, target);
9155
9225
  return {
@@ -9210,11 +9280,39 @@ var NcDispatcher = class {
9210
9280
  }
9211
9281
  });
9212
9282
  }
9213
- async withArtifactUrls(attachments) {
9283
+ async withArtifactUrls(entry, attachments) {
9284
+ const index = this.deps.indexArtifact;
9214
9285
  const publish = this.deps.publishArtifact;
9215
- if (publish === void 0) return [...attachments];
9286
+ if (index === void 0 && publish === void 0) return [...attachments];
9216
9287
  const out = [];
9288
+ let posterId = null;
9289
+ const deviceId = entry.payload.subject.deviceId;
9290
+ const common = {
9291
+ deviceId: deviceId > 0 ? deviceId : null,
9292
+ deviceIds: deviceId > 0 ? [deviceId] : [],
9293
+ capturedAt: entry.payload.subject.timestamp,
9294
+ ruleId: entry.ruleId,
9295
+ ruleName: entry.payload.ruleName
9296
+ };
9217
9297
  for (const att of attachments) {
9298
+ if (index !== void 0) {
9299
+ const kind = artifactKindForMediaType(att.mediaType);
9300
+ const meta = {
9301
+ ...common,
9302
+ kind,
9303
+ ...posterId !== null && kind !== "event-still" ? { posterArtifactId: posterId } : {}
9304
+ };
9305
+ const pub = await index(att.bytes, att.mime, meta).catch(() => ({
9306
+ url: null,
9307
+ artifactId: null
9308
+ }));
9309
+ if (kind === "event-still" && pub.artifactId !== null) posterId = pub.artifactId;
9310
+ out.push(pub.url === null ? { ...att } : {
9311
+ ...att,
9312
+ url: pub.url
9313
+ });
9314
+ continue;
9315
+ }
9218
9316
  const url = await publish(att.bytes, att.mime).catch(() => null);
9219
9317
  out.push(url === null ? { ...att } : {
9220
9318
  ...att,
@@ -9828,6 +9926,16 @@ function stringField(data, key) {
9828
9926
  const value = data[key];
9829
9927
  return typeof value === "string" && value.length > 0 ? value : void 0;
9830
9928
  }
9929
+ function stringList(data, key) {
9930
+ const raw = data[key];
9931
+ if (!Array.isArray(raw)) return [];
9932
+ const ids = [];
9933
+ for (const item of raw) {
9934
+ if (typeof item === "string" && item.length > 0 && !ids.includes(item)) ids.push(item);
9935
+ if (ids.length >= 20) break;
9936
+ }
9937
+ return ids;
9938
+ }
9831
9939
  function numberField(data, key) {
9832
9940
  const value = data[key];
9833
9941
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -9971,6 +10079,8 @@ function systemSubject(input) {
9971
10079
  ...input.deviceType !== void 0 ? { deviceType: input.deviceType } : {},
9972
10080
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
9973
10081
  ...input.packageName !== void 0 ? { packageName: input.packageName } : {},
10082
+ ...input.nodeIds !== void 0 ? { nodeIds: input.nodeIds } : {},
10083
+ ...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
9974
10084
  ...input.textCount !== void 0 ? { textCount: input.textCount } : {},
9975
10085
  ...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
9976
10086
  data
@@ -10147,34 +10257,189 @@ function incomingFromSystemEvent(event, lookup) {
10147
10257
  ...identity !== void 0 ? { deviceType: identity.type } : {}
10148
10258
  });
10149
10259
  }
10150
- case "update.available": {
10151
- const target = stringField(data, "target");
10152
- const packageName = stringField(data, "packageName");
10153
- const currentVersion = stringField(data, "currentVersion");
10154
- const latestVersion = stringField(data, "latestVersion");
10155
- if (target !== "addon" && target !== "server" || packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) return null;
10156
- const addon = target === "addon";
10157
- const nodeId = stringField(data, "nodeId");
10158
- const nodeScope = nodeId ?? "hub";
10159
- const kind = addon ? "addon-update-available" : "server-update-available";
10160
- return systemSubject({
10161
- facts,
10162
- kind,
10163
- subject: addon ? `package:${nodeScope}:${packageName}` : `package:${packageName}`,
10164
- titleKey: `system.${kind}.title`,
10165
- bodyKey: nodeId !== void 0 ? `system.${kind}.body.onNode` : `system.${kind}.body`,
10166
- textParams: {
10167
- packageName,
10168
- currentVersion,
10169
- latestVersion,
10170
- ...nodeId !== void 0 ? { nodeId } : {}
10171
- },
10260
+ case "update.available": return incomingFromUpdateAvailable(facts, data);
10261
+ case "addon.updated": {
10262
+ const update = parseAddonUpdateComplete(data);
10263
+ return update === null ? null : incomingFromAddonUpdates(facts, [update]);
10264
+ }
10265
+ case "system.restart-completed": return incomingFromServerUpdated(facts, data);
10266
+ case "recording.export.completed": return incomingFromExportCompleted(facts, data);
10267
+ default: return null;
10268
+ }
10269
+ }
10270
+ function incomingFromUpdateAvailable(facts, data) {
10271
+ const target = stringField(data, "target");
10272
+ if (target !== "addon" && target !== "server") return null;
10273
+ const packages = updatePackages(data);
10274
+ if (packages.length === 0) return null;
10275
+ const addon = target === "addon";
10276
+ const kind = addon ? "addon-update-available" : "server-update-available";
10277
+ const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10278
+ const packageNames = unique(packages.map((pkg) => pkg.packageName));
10279
+ const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10280
+ const list = packages.map(formatAvailableLine).join("\n");
10281
+ const nodes = nodeIds.join(", ");
10282
+ const head = packages[0];
10283
+ const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10284
+ return systemSubject({
10285
+ facts,
10286
+ kind,
10287
+ subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10288
+ titleKey: `system.${kind}.title`,
10289
+ bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10290
+ textParams: {
10291
+ list,
10292
+ packageName: head.packageName,
10293
+ currentVersion: head.currentVersion,
10294
+ latestVersion: head.latestVersion,
10295
+ nodes,
10296
+ count: String(addon ? packages.length : nodeIds.length > 0 ? nodeIds.length : 1),
10297
+ ...oneNode !== void 0 ? { nodeId: oneNode } : {}
10298
+ },
10299
+ textCount: addon ? packages.length : nodeIds.length > 0 ? nodeIds.length : 1,
10300
+ packageName: head.packageName,
10301
+ packageNames,
10302
+ ...oneNode !== void 0 ? { nodeId: oneNode } : {},
10303
+ ...nodeIds.length > 0 ? { nodeIds } : {}
10304
+ });
10305
+ }
10306
+ /** Version-bearing `addon.updated` only — settings saves carry neither. */
10307
+ function parseAddonUpdateComplete(data) {
10308
+ const packageName = stringField(data, "packageName");
10309
+ const fromVersion = stringField(data, "fromVersion");
10310
+ const toVersion = stringField(data, "toVersion");
10311
+ if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10312
+ const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10313
+ return {
10314
+ packageName,
10315
+ fromVersion,
10316
+ toVersion,
10317
+ ...nodeId !== void 0 ? { nodeId } : {}
10318
+ };
10319
+ }
10320
+ function incomingFromAddonUpdates(facts, updates) {
10321
+ if (updates.length === 0) return null;
10322
+ const packageNames = unique(updates.map((update) => update.packageName));
10323
+ const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10324
+ const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10325
+ const list = updates.map((update) => update.nodeId !== void 0 ? `${update.packageName} ${update.fromVersion} → ${update.toVersion} (${update.nodeId})` : `${update.packageName} ${update.fromVersion} → ${update.toVersion}`).join("\n");
10326
+ const head = updates[0];
10327
+ return systemSubject({
10328
+ facts,
10329
+ kind: "addon-updated",
10330
+ subject: `packages:addon-updated:${signature}`,
10331
+ titleKey: "system.addon-updated.title",
10332
+ bodyKey: "system.addon-updated.body",
10333
+ textParams: {
10334
+ list,
10335
+ count: String(packageNames.length)
10336
+ },
10337
+ textCount: packageNames.length,
10338
+ packageName: head.packageName,
10339
+ packageNames,
10340
+ ...head.nodeId !== void 0 ? { nodeId: head.nodeId } : {},
10341
+ ...nodeIds.length > 0 ? { nodeIds } : {}
10342
+ });
10343
+ }
10344
+ function incomingFromServerUpdated(facts, data) {
10345
+ const kind = stringField(data, "kind");
10346
+ const requestedBy = stringField(data, "requestedBy");
10347
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10348
+ const packageName = stringField(data, "packageName") ?? "@camstack/server";
10349
+ const fromVersion = stringField(data, "fromVersion");
10350
+ const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10351
+ const nodeId = stringField(data, "nodeId") ?? "hub";
10352
+ const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10353
+ return systemSubject({
10354
+ facts,
10355
+ kind: "server-updated",
10356
+ subject: `server-updated:${nodeId}:${toVersion ?? "unknown"}`,
10357
+ titleKey: "system.server-updated.title",
10358
+ bodyKey,
10359
+ textParams: {
10360
+ packageName,
10361
+ nodeId,
10362
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10363
+ ...toVersion !== void 0 ? { toVersion } : {}
10364
+ },
10365
+ packageName,
10366
+ nodeId,
10367
+ nodeIds: [nodeId]
10368
+ });
10369
+ }
10370
+ function incomingFromExportCompleted(facts, data) {
10371
+ const exportId = stringField(data, "exportId");
10372
+ if (exportId === void 0) return null;
10373
+ const deviceId = numberField(data, "deviceId");
10374
+ const title = stringField(data, "title");
10375
+ const bytes = numberField(data, "fileBytes");
10376
+ const notifyTargetIds = stringList(data, "notifyTargetIds");
10377
+ const incoming = systemSubject({
10378
+ facts,
10379
+ kind: "export-completed",
10380
+ subject: `export:${exportId}`,
10381
+ titleKey: "system.export-completed.title",
10382
+ bodyKey: title !== void 0 ? "system.export-completed.body.titled" : "system.export-completed.body",
10383
+ textParams: {
10384
+ exportId,
10385
+ ...title !== void 0 ? { title } : {},
10386
+ ...bytes !== void 0 ? { bytes: String(bytes) } : {},
10387
+ ...deviceId !== void 0 ? { deviceId: String(deviceId) } : {}
10388
+ },
10389
+ ...deviceId !== void 0 ? { deviceId } : {}
10390
+ });
10391
+ if (notifyTargetIds.length === 0) return incoming;
10392
+ return {
10393
+ ...incoming,
10394
+ controls: {
10395
+ directTargetIds: notifyTargetIds,
10396
+ bypassMute: true
10397
+ }
10398
+ };
10399
+ }
10400
+ function updatePackages(data) {
10401
+ const raw = data["packages"];
10402
+ if (Array.isArray(raw) && raw.length > 0) {
10403
+ const rows = [];
10404
+ for (const item of raw) {
10405
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
10406
+ const row = item;
10407
+ const packageName = stringField(row, "packageName");
10408
+ const currentVersion = stringField(row, "currentVersion");
10409
+ const latestVersion = stringField(row, "latestVersion");
10410
+ if (packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) continue;
10411
+ const nodeId = stringField(row, "nodeId");
10412
+ rows.push({
10172
10413
  packageName,
10414
+ currentVersion,
10415
+ latestVersion,
10173
10416
  ...nodeId !== void 0 ? { nodeId } : {}
10174
10417
  });
10175
10418
  }
10176
- default: return null;
10419
+ if (rows.length > 0) return rows;
10177
10420
  }
10421
+ const packageName = stringField(data, "packageName");
10422
+ const currentVersion = stringField(data, "currentVersion");
10423
+ const latestVersion = stringField(data, "latestVersion");
10424
+ if (packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) return [];
10425
+ const nodeId = stringField(data, "nodeId");
10426
+ return [{
10427
+ packageName,
10428
+ currentVersion,
10429
+ latestVersion,
10430
+ ...nodeId !== void 0 ? { nodeId } : {}
10431
+ }];
10432
+ }
10433
+ function formatAvailableLine(pkg) {
10434
+ const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10435
+ return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10436
+ }
10437
+ function versionFromRequestedBy(requestedBy) {
10438
+ if (requestedBy === void 0) return void 0;
10439
+ return /@([^@\s]+)$/.exec(requestedBy)?.[1];
10440
+ }
10441
+ function unique(values) {
10442
+ return [...new Set(values)];
10178
10443
  }
10179
10444
  /**
10180
10445
  * The COMBINED row's two keys, or null when this transition is not one.
@@ -10890,6 +11155,67 @@ function recordToRow$2(subject, data) {
10890
11155
  updatedAt
10891
11156
  };
10892
11157
  }
11158
+ function isDeviceLivenessKind(kind) {
11159
+ return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
11160
+ }
11161
+ function livenessStateOfKind(kind) {
11162
+ return kind.endsWith("-offline") ? "offline" : "online";
11163
+ }
11164
+ var DeviceLivenessHoldoff = class {
11165
+ flush;
11166
+ pending = /* @__PURE__ */ new Map();
11167
+ lastNotified = /* @__PURE__ */ new Map();
11168
+ timers = /* @__PURE__ */ new Map();
11169
+ delayMs;
11170
+ schedule;
11171
+ cancel;
11172
+ constructor(flush, options = {}) {
11173
+ this.flush = flush;
11174
+ this.delayMs = options.delayMs ?? 3e5;
11175
+ this.schedule = options.schedule ?? setTimeout;
11176
+ this.cancel = options.cancel ?? clearTimeout;
11177
+ }
11178
+ /** The seeded (or last delivered) state — a bounce back to this is silence. */
11179
+ remember(subject, state) {
11180
+ this.lastNotified.set(subject, state);
11181
+ }
11182
+ /** Directory-seeded rows never pass through {@link remember}; first flip does. */
11183
+ rememberIfAbsent(subject, state) {
11184
+ if (!this.lastNotified.has(subject)) this.lastNotified.set(subject, state);
11185
+ }
11186
+ /** A real ledger flip. Starts / resets the trailing timer. */
11187
+ arm(subject, incoming) {
11188
+ this.pending.set(subject, incoming);
11189
+ const existing = this.timers.get(subject);
11190
+ if (existing !== void 0) this.cancel(existing);
11191
+ if (this.delayMs <= 0) {
11192
+ this.timers.delete(subject);
11193
+ this.fire(subject);
11194
+ return;
11195
+ }
11196
+ const id = this.schedule(() => {
11197
+ this.timers.delete(subject);
11198
+ this.fire(subject);
11199
+ }, this.delayMs);
11200
+ this.timers.set(subject, id);
11201
+ }
11202
+ dispose() {
11203
+ for (const id of this.timers.values()) this.cancel(id);
11204
+ this.timers.clear();
11205
+ this.pending.clear();
11206
+ }
11207
+ fire(subject) {
11208
+ const incoming = this.pending.get(subject);
11209
+ this.pending.delete(subject);
11210
+ if (incoming === void 0) return;
11211
+ const kind = incoming.subject.systemEvent?.kind;
11212
+ if (kind === void 0) return;
11213
+ const state = livenessStateOfKind(kind);
11214
+ if (this.lastNotified.get(subject) === state) return;
11215
+ this.lastNotified.set(subject, state);
11216
+ this.flush(incoming);
11217
+ }
11218
+ };
10893
11219
  //#endregion
10894
11220
  //#region src/notification-center/occupancy-watcher.ts
10895
11221
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
@@ -17772,6 +18098,36 @@ var TimelapseStore = class {
17772
18098
  return rule;
17773
18099
  }
17774
18100
  };
18101
+ var AddonUpdateCoalescer = class {
18102
+ flush;
18103
+ pending = /* @__PURE__ */ new Map();
18104
+ timer = null;
18105
+ delayMs;
18106
+ schedule;
18107
+ cancel;
18108
+ constructor(flush, options = {}) {
18109
+ this.flush = flush;
18110
+ this.delayMs = options.delayMs ?? 3e4;
18111
+ this.schedule = options.schedule ?? setTimeout;
18112
+ this.cancel = options.cancel ?? clearTimeout;
18113
+ }
18114
+ observe(update) {
18115
+ this.pending.set(update.packageName, update);
18116
+ if (this.timer !== null) this.cancel(this.timer);
18117
+ this.timer = this.schedule(() => {
18118
+ this.timer = null;
18119
+ const batch = [...this.pending.values()];
18120
+ this.pending.clear();
18121
+ if (batch.length > 0) this.flush(batch);
18122
+ }, this.delayMs);
18123
+ }
18124
+ dispose() {
18125
+ if (this.timer === null) return;
18126
+ this.cancel(this.timer);
18127
+ this.timer = null;
18128
+ this.pending.clear();
18129
+ }
18130
+ };
17775
18131
  //#endregion
17776
18132
  //#region src/notification-center/zone-owner-cache.ts
17777
18133
  var NcZoneOwnershipCache = class {
@@ -17881,6 +18237,15 @@ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
17881
18237
  */
17882
18238
  var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
17883
18239
  /**
18240
+ * Synthetic rule id for an export's on-screen picker.
18241
+ *
18242
+ * The operator named these targets on the export screen, not in a rule. The
18243
+ * row still lives in the outbox so history, retry and language rendering are
18244
+ * the same path as every other notification. A matching `export-completed`
18245
+ * rule that already reached a target is skipped so one export is one push.
18246
+ */
18247
+ var NC_EXPORT_NOTIFY_RULE_ID = "nc-export-notify";
18248
+ /**
17884
18249
  * Which alarm buttons a notification of this kind should carry, or `null` for
17885
18250
  * every notification that is not about the panel.
17886
18251
  *
@@ -18328,6 +18693,10 @@ var NotificationCenter = class NotificationCenter {
18328
18693
  * seeds, only a real flip notifies.
18329
18694
  */
18330
18695
  liveness;
18696
+ /** Batches `addon.updated` (version-bearing) into one notification per 30 s. */
18697
+ addonUpdates;
18698
+ /** Trailing 5 min hold-off for camera liveness flaps (plugin restart). */
18699
+ livenessHoldoff;
18331
18700
  /**
18332
18701
  * Serialized evaluation chain. The persist hooks are fire-and-forget for
18333
18702
  * the frame path, but two concurrent evaluations of the same device
@@ -18630,6 +18999,16 @@ var NotificationCenter = class NotificationCenter {
18630
18999
  store: deps.store,
18631
19000
  logger: this.logger.child("liveness")
18632
19001
  });
19002
+ this.addonUpdates = new AddonUpdateCoalescer((updates) => {
19003
+ const incoming = incomingFromAddonUpdates({
19004
+ eventId: `addon-updated:${updates.map((u) => u.packageName).join(",")}`,
19005
+ timestamp: this.now(),
19006
+ category: "addon.updated",
19007
+ data: { packages: updates }
19008
+ }, updates);
19009
+ if (incoming !== null) this.consumeEvent(incoming);
19010
+ });
19011
+ this.livenessHoldoff = new DeviceLivenessHoldoff((event) => this.consumeEvent(event), { delayMs: deps.livenessHoldoffMs ?? (process.env.VITEST ? 0 : 3e5) });
18633
19012
  this.actionRunner = deps.actuate !== void 0 && deps.isCapDeviceScoped !== void 0 ? new NcRuleActionRunner({
18634
19013
  logger: this.logger.child("actions"),
18635
19014
  actuate: (input) => this.actuateWithAttribution(input),
@@ -18888,6 +19267,8 @@ var NotificationCenter = class NotificationCenter {
18888
19267
  this.timelapseScheduler?.stop();
18889
19268
  this.summaryProducer?.stop();
18890
19269
  this.audioStills?.clear();
19270
+ this.addonUpdates.dispose();
19271
+ this.livenessHoldoff.dispose();
18891
19272
  this.evaluationActive = false;
18892
19273
  }
18893
19274
  /**
@@ -19362,6 +19743,11 @@ var NotificationCenter = class NotificationCenter {
19362
19743
  */
19363
19744
  onSystemEvent(event) {
19364
19745
  if (!this.evaluationActive) return;
19746
+ if (event.category === "addon.updated") {
19747
+ const update = parseAddonUpdateComplete(event.data);
19748
+ if (update !== null) this.addonUpdates.observe(update);
19749
+ return;
19750
+ }
19365
19751
  const incoming = incomingFromSystemEvent(event, this.deviceDirectory.lookup());
19366
19752
  if (incoming === null) {
19367
19753
  const dropped = discardedSystemEvent(event);
@@ -19377,11 +19763,22 @@ var NotificationCenter = class NotificationCenter {
19377
19763
  const system = incoming.subject.systemEvent;
19378
19764
  const observation = system !== void 0 ? ledgeredLiveness(system) : null;
19379
19765
  if (observation !== null) {
19766
+ const previous = this.liveness.stateOf(observation.subject);
19380
19767
  const verdict = this.liveness.observe(observation, this.now());
19768
+ if (verdict === "seeded") {
19769
+ this.livenessHoldoff.remember(observation.subject, observation.state);
19770
+ this.reportLivenessDrop(incoming, verdict);
19771
+ return;
19772
+ }
19381
19773
  if (verdict !== "flip") {
19382
19774
  this.reportLivenessDrop(incoming, verdict);
19383
19775
  return;
19384
19776
  }
19777
+ if (previous !== void 0) this.livenessHoldoff.rememberIfAbsent(observation.subject, previous);
19778
+ if (isDeviceLivenessKind(observation.kind)) {
19779
+ this.livenessHoldoff.arm(observation.subject, incoming);
19780
+ return;
19781
+ }
19385
19782
  } else if (system !== void 0 && (system.kind.startsWith("device-") || system.kind.startsWith("stream-"))) {
19386
19783
  if (this.lastSystemLiveness.get(system.subject) === system.kind) return;
19387
19784
  this.lastSystemLiveness.set(system.subject, system.kind);
@@ -19874,6 +20271,55 @@ var NotificationCenter = class NotificationCenter {
19874
20271
  });
19875
20272
  }
19876
20273
  /**
20274
+ * Deliver to targets the producer named (`controls.directTargetIds`).
20275
+ *
20276
+ * Skips ids a matching rule already reached so one export is one push.
20277
+ * Absent/empty is a no-op — every other producer omits the control.
20278
+ */
20279
+ async enqueuePickerTargets(event, reached, outcomes) {
20280
+ const ids = event.controls?.directTargetIds;
20281
+ if (ids === void 0 || ids.length === 0) return;
20282
+ const targets = [...new Set(ids)].filter((id) => !reached.has(id));
20283
+ if (targets.length === 0) return;
20284
+ const subject = event.subject;
20285
+ const entries = targets.map((targetId) => ({
20286
+ ruleId: NC_EXPORT_NOTIFY_RULE_ID,
20287
+ targetId,
20288
+ deviceId: subject.deviceId,
20289
+ recordKind: "system-event",
20290
+ recordId: subject.recordId,
20291
+ payload: {
20292
+ ruleName: "export",
20293
+ delivery: "system-event",
20294
+ priority: 2,
20295
+ media: "none",
20296
+ subject: {
20297
+ deviceId: subject.deviceId,
20298
+ className: "export",
20299
+ zones: [],
20300
+ timestamp: subject.timestamp,
20301
+ ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
20302
+ }
20303
+ }
20304
+ }));
20305
+ const inserted = await this.outbox.enqueue(entries);
20306
+ outcomes.push({
20307
+ ruleId: NC_EXPORT_NOTIFY_RULE_ID,
20308
+ ruleName: "export",
20309
+ matched: true,
20310
+ cooldown: "n/a",
20311
+ enqueued: inserted,
20312
+ targetIds: entries.map((entry) => entry.targetId)
20313
+ });
20314
+ this.logger.info("export picker notification enqueued", {
20315
+ tags: { deviceId: subject.deviceId },
20316
+ meta: {
20317
+ targets: targets.length,
20318
+ inserted
20319
+ }
20320
+ });
20321
+ }
20322
+ /**
19877
20323
  * The COMBINED notification's second half: withdraw what it replaced.
19878
20324
  *
19879
20325
  * Only for the targets the alarm actually reached, and only while their rows
@@ -19998,6 +20444,8 @@ var NotificationCenter = class NotificationCenter {
19998
20444
  }
19999
20445
  });
20000
20446
  if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
20447
+ await this.enqueuePickerTargets(event, /* @__PURE__ */ new Set(), outcomes);
20448
+ if (outcomes.some((outcome) => outcome.enqueued > 0)) return report({ candidates: 0 });
20001
20449
  return report({ droppedBy: "no-candidate-rule" });
20002
20450
  }
20003
20451
  const now = this.now();
@@ -20194,6 +20642,8 @@ var NotificationCenter = class NotificationCenter {
20194
20642
  }
20195
20643
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
20196
20644
  if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
20645
+ const reached = new Set(outcomes.flatMap((outcome) => outcome.targetIds));
20646
+ await this.enqueuePickerTargets(event, reached, outcomes);
20197
20647
  return report({ candidates: candidates.length });
20198
20648
  }
20199
20649
  /**
@@ -21413,6 +21863,9 @@ function systemEventSubject(kind, scope) {
21413
21863
  if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
21414
21864
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
21415
21865
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
21866
+ if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
21867
+ if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
21868
+ if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
21416
21869
  return `alarm:${scope.deviceId ?? 0}`;
21417
21870
  }
21418
21871
  /**
@@ -21524,7 +21977,13 @@ function planRuleTest(input) {
21524
21977
  profile: "low",
21525
21978
  seconds: "30",
21526
21979
  devices: "cuda:0",
21527
- minutes: "15"
21980
+ minutes: "15",
21981
+ list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
21982
+ nodes: "hub",
21983
+ fromVersion: "0.0.0",
21984
+ toVersion: "0.0.1",
21985
+ title: "Export",
21986
+ count: "1"
21528
21987
  },
21529
21988
  ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
21530
21989
  ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
@@ -28356,7 +28815,123 @@ var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAc
28356
28815
  durationMs: number()
28357
28816
  }), { auth: "admin" }) });
28358
28817
  //#endregion
28818
+ //#region src/pipeline-analytics/videoclip-visits.ts
28819
+ /**
28820
+ * Videoclip visits — the grain a clip should have on an events-mode camera.
28821
+ *
28822
+ * Disk writes one fMP4 per ffmpeg roll (~8–12 s). The recorder calendar merges
28823
+ * only gaps ≤ 5 s, so a single pantry walk shows up as several availability
28824
+ * ranges with ~8 s GOP holes between them. Analytics then mints one 15 s clip
28825
+ * per motion heartbeat. A *visit* is the keep-window those fragments belong
28826
+ * to: cluster availability with {@link VISIT_MERGE_GAP_MS}, annotate with
28827
+ * overlapping events. No second copy of the media; the files stay the source
28828
+ * of truth.
28829
+ */
28830
+ /** Synthetic event-id slot in `analytics:<start>:<end>:<id>` for a visit clip. */
28831
+ var VISIT_EVENT_ID = "visit";
28832
+ function clusterFootageVisits(ranges, gapMs = VISIT_MERGE_GAP_MS) {
28833
+ if (ranges.length === 0) return [];
28834
+ const sorted = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
28835
+ const visits = [];
28836
+ let pieces = [sorted[0]];
28837
+ let endMs = sorted[0].endMs;
28838
+ for (let i = 1; i < sorted.length; i++) {
28839
+ const next = sorted[i];
28840
+ if (next.startMs <= endMs + gapMs) {
28841
+ pieces.push(next);
28842
+ endMs = Math.max(endMs, next.endMs);
28843
+ continue;
28844
+ }
28845
+ visits.push(finishVisit(pieces, endMs));
28846
+ pieces = [next];
28847
+ endMs = next.endMs;
28848
+ }
28849
+ visits.push(finishVisit(pieces, endMs));
28850
+ return visits;
28851
+ }
28852
+ function finishVisit(pieces, endMs) {
28853
+ const holes = [];
28854
+ for (let i = 1; i < pieces.length; i++) {
28855
+ const prevEnd = pieces[i - 1].endMs;
28856
+ const nextStart = pieces[i].startMs;
28857
+ if (nextStart > prevEnd) holes.push({
28858
+ startMs: prevEnd,
28859
+ endMs: nextStart
28860
+ });
28861
+ }
28862
+ return {
28863
+ startMs: pieces[0].startMs,
28864
+ endMs,
28865
+ ranges: pieces,
28866
+ holes
28867
+ };
28868
+ }
28869
+ var KIND_ORDER = [
28870
+ "object",
28871
+ "motion",
28872
+ "audio"
28873
+ ];
28874
+ function eventOverlapsVisit(event, visit, pad) {
28875
+ if (event.timestamp >= visit.startMs && event.timestamp <= visit.endMs) return true;
28876
+ const windowStart = event.timestamp - pad.preMs;
28877
+ const windowEnd = event.timestamp + pad.postMs;
28878
+ return windowStart < visit.endMs && windowEnd > visit.startMs;
28879
+ }
28880
+ function dominantKind(attached) {
28881
+ for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
28882
+ return "motion";
28883
+ }
28884
+ function visitsToClips(visits, events, pad) {
28885
+ const tagged = [
28886
+ ...events.object.map((event) => ({
28887
+ kind: "object",
28888
+ event
28889
+ })),
28890
+ ...events.motion.map((event) => ({
28891
+ kind: "motion",
28892
+ event
28893
+ })),
28894
+ ...events.audio.map((event) => ({
28895
+ kind: "audio",
28896
+ event
28897
+ }))
28898
+ ];
28899
+ return visits.map((visit) => {
28900
+ const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
28901
+ const kind = dominantKind(attached);
28902
+ const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
28903
+ return {
28904
+ id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
28905
+ source: "analytics",
28906
+ kind,
28907
+ timeRange: {
28908
+ startMs: visit.startMs,
28909
+ endMs: visit.endMs
28910
+ },
28911
+ thumbnail: withThumb?.event.mediaUrl,
28912
+ eventIds: attached.map((a) => a.event.id),
28913
+ holes: [...visit.holes]
28914
+ };
28915
+ }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
28916
+ }
28917
+ //#endregion
28359
28918
  //#region src/pipeline-analytics/videoclips-provider.ts
28919
+ /**
28920
+ * Default `videoclips` provider — composes recorded footage visits with the
28921
+ * analytics event log. A clip is a time-WINDOW over existing footage, never a
28922
+ * separate file: `getClipPlayback` resolves to
28923
+ * `recording.getPlaybackManifest(startMs, endMs)`.
28924
+ *
28925
+ * When availability is present the grain is a *visit* (events-mode keep-window
28926
+ * clustered at `VISIT_MERGE_GAP_MS`), not one clip per motion heartbeat. The
28927
+ * clip id still encodes the window (`analytics:<startMs>:<endMs>:visit`) so
28928
+ * playback is self-contained. Without availability (or when that read fails)
28929
+ * the provider falls back to per-event padded windows.
28930
+ *
28931
+ * Pure helpers (`eventsToClips` / `clusterFootageVisits` / `clipIdFor` /
28932
+ * `parseClipId`) are unit-tested in isolation; the provider factory wires
28933
+ * injected fetch/resolve deps so the compose logic is testable without I/O.
28934
+ */
28360
28935
  var SOURCE = "analytics";
28361
28936
  function clipIdFor(eventId, startMs, endMs) {
28362
28937
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
@@ -28399,36 +28974,29 @@ function eventsToClips(events, pad) {
28399
28974
  ...events.audio.map((e) => make(e, "audio"))
28400
28975
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
28401
28976
  }
28402
- /** Keep only clips whose window overlaps at least one recorded range — a clip
28403
- * is a window over footage, so a window with nothing behind it is unplayable
28404
- * and must not be listed (a camera that never recorded showed 15 dead audio
28405
- * "clips" in the viewer ribbon, every tap a 500). Pure. */
28406
- function filterClipsByFootage(clips, ranges) {
28407
- return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
28408
- }
28409
28977
  function createVideoclipsProvider(deps) {
28410
28978
  return {
28411
28979
  listClips: async ({ deviceId, since, until, limit }) => {
28412
- const clips = eventsToClips(await deps.fetchEvents({
28980
+ const events = await deps.fetchEvents({
28413
28981
  deviceId,
28414
28982
  since,
28415
28983
  until,
28416
- limit
28417
- }), deps.defaultPad);
28418
- let filtered = clips;
28984
+ ...deps.fetchAvailability ? {} : { limit }
28985
+ });
28986
+ let clips = eventsToClips(events, deps.defaultPad);
28419
28987
  if (deps.fetchAvailability) try {
28420
- filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
28988
+ clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
28421
28989
  deviceId,
28422
28990
  since,
28423
28991
  until
28424
- }));
28992
+ }), VISIT_MERGE_GAP_MS), events, deps.defaultPad);
28425
28993
  } catch (err) {
28426
28994
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
28427
28995
  deviceId,
28428
28996
  error: err instanceof Error ? err.message : String(err)
28429
28997
  });
28430
28998
  }
28431
- return limit != null ? filtered.slice(0, limit) : filtered;
28999
+ return limit != null ? clips.slice(0, limit) : clips;
28432
29000
  },
28433
29001
  getClipPlayback: async ({ deviceId, clipId }) => {
28434
29002
  const parsed = parseClipId(clipId);
@@ -29858,7 +30426,7 @@ var undecided = (candidate, reason) => ({
29858
30426
  reason,
29859
30427
  className: candidate.className
29860
30428
  });
29861
- function withTimeout$1(promise, timeoutMs) {
30429
+ function withTimeout$2(promise, timeoutMs) {
29862
30430
  return new Promise((resolve, reject) => {
29863
30431
  const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("confirmation-timeout")), timeoutMs);
29864
30432
  promise.then((value) => {
@@ -29898,7 +30466,7 @@ async function confirmOne(candidate, config, deps, exhausted) {
29898
30466
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
29899
30467
  if (config.minCropPx > 0 && cropPx < config.minCropPx) return undecided(candidate, "below-min-crop");
29900
30468
  try {
29901
- return await withTimeout$1(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
30469
+ return await withTimeout$2(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
29902
30470
  } catch {
29903
30471
  return undecided(candidate, "timeout");
29904
30472
  }
@@ -34145,7 +34713,8 @@ function isMediaOwnerKind(value) {
34145
34713
  * write="one row per blob written through put / putReplacing — a track's
34146
34714
  * keyFrame, thumbnail, firstFrame and lastFrame (UPSERTED on a deterministic
34147
34715
  * id, so one per track+kind however many captures race), its periodic
34148
- * snapshots, each event's crop and full frames, and the buffered face/plate
34716
+ * snapshots, each event's crop and full frames, a motion visit's still
34717
+ * (`kind: snapshot` on ownerKind event), and the buffered face/plate
34149
34718
  * crops. A row is also REOWNED here (not rewritten) when the operator enrols a
34150
34719
  * face or a plate, which is what moves it out of retention's reach."
34151
34720
  * retention="no age sweep of its own — MediaStore.evictBefore was deleted (D59)
@@ -35557,7 +36126,8 @@ async function rankKeyEvents(candidates, options, peakLookup) {
35557
36126
  windowMs: t.lastSeen - t.firstSeen,
35558
36127
  ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
35559
36128
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
35560
- ...t.debug !== void 0 ? { debug: t.debug } : {}
36129
+ ...t.debug !== void 0 ? { debug: t.debug } : {},
36130
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {}
35561
36131
  });
35562
36132
  }
35563
36133
  scored.sort((a, b) => b.importance - a.importance);
@@ -37200,7 +37770,8 @@ async function applyTrackFlags(deps, input) {
37200
37770
  }
37201
37771
  const patch = {
37202
37772
  ...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
37203
- ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
37773
+ ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {},
37774
+ ...input.flags.favourited !== void 0 ? { favourited: input.flags.favourited } : {}
37204
37775
  };
37205
37776
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
37206
37777
  const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
@@ -37208,6 +37779,7 @@ async function applyTrackFlags(deps, input) {
37208
37779
  trackId: input.trackId,
37209
37780
  markForTrain: retrainStatus === "staging",
37210
37781
  debug: patch.debug ?? current.debug,
37782
+ favourited: patch.favourited ?? current.favourited,
37211
37783
  retrainStatus
37212
37784
  };
37213
37785
  }
@@ -38448,11 +39020,14 @@ function tieredLabelColumnData(patch) {
38448
39020
  * (MOTION_EVENT_HEARTBEAT_MS) while it stays on. The analyzer path and the
38449
39021
  * onboard-firmware path share that throttle, so a camera cannot double-count.
38450
39022
  * Measured at ~7,200 rows/day/camera."
38451
- * retention="a TERMINAL age-keyed root — no trackId, no media, nothing references
38452
- * it, so the track cascade can never reach it. Deleted by
39023
+ * retention="a TERMINAL age-keyed root — no trackId. A motion row may own a
39024
+ * still (`ownerKind: 'event'`, `kind: 'snapshot'`) captured on the off→on
39025
+ * visit so events-mode recordings have a videoclip thumbnail. Deleted by
38453
39026
  * EventStore.evictTracklessBefore on the DEVICE's one retention window
38454
- * (follow-recordings horizon, or trackRetentionDays, default 7). 0 days = keep
38455
- * forever, and this table is the reason that setting is dangerous."
39027
+ * (follow-recordings horizon, or trackRetentionDays, default 7), which
39028
+ * folds in MediaStore.deleteForEvents so the still leaves with the row.
39029
+ * 0 days = keep forever, and this table is the reason that setting is
39030
+ * dangerous."
38456
39031
  */
38457
39032
  var MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
38458
39033
  /**
@@ -39181,9 +39756,10 @@ var EventStore = class {
39181
39756
  * Object events now leave exactly one way: with their track, through
39182
39757
  * {@link deleteByTracks} (D61).
39183
39758
  *
39184
- * Motion and audio carry no `trackId`, own no media and are referenced by
39185
- * nothing — they are terminal retention ROOTS, so an age window on them
39186
- * cannot contradict anybody's (`retention/retention-model.ts`).
39759
+ * Motion and audio carry no `trackId` and are referenced by nothing — they
39760
+ * are terminal retention ROOTS, so an age window on them cannot contradict
39761
+ * anybody's (`retention/retention-model.ts`). A motion visit still is
39762
+ * event-owned media of that row and is deleted here in lockstep.
39187
39763
  *
39188
39764
  * **ONE cutoff for both kinds** (D62). They used to take a window each
39189
39765
  * (`retentionMotionDays` / `retentionAudioDays`), which is two clocks over
@@ -39200,9 +39776,17 @@ var EventStore = class {
39200
39776
  ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
39201
39777
  whereBetween: { timestamp: [0, params.cutoffMs] }
39202
39778
  });
39779
+ const motion = await batch(MOTION_EVENTS_COLLECTION);
39780
+ const audio = await batch(AUDIO_EVENTS_COLLECTION);
39781
+ const ids = [...motion, ...audio];
39782
+ if (ids.length > 0 && this.media !== void 0) try {
39783
+ await this.media.deleteForEvents(ids);
39784
+ } catch (err) {
39785
+ this.logger.warn("EventStore.evictTracklessBefore: event media delete failed", { meta: { error: String(err) } });
39786
+ }
39203
39787
  return {
39204
- motion: await batch(MOTION_EVENTS_COLLECTION),
39205
- audio: await batch(AUDIO_EVENTS_COLLECTION)
39788
+ motion,
39789
+ audio
39206
39790
  };
39207
39791
  }
39208
39792
  /**
@@ -39227,23 +39811,32 @@ var EventStore = class {
39227
39811
  * The SUBSET of `eventIds` that still exist — the orphan audit's liveness
39228
39812
  * probe for event-owned media.
39229
39813
  *
39814
+ * Motion and audio rows can own stills (`kind: 'snapshot'` on a motion
39815
+ * visit); probing only the object collection would classify those stills
39816
+ * as orphans the moment they were written.
39817
+ *
39230
39818
  * THROWS on a query failure rather than answering partially. A probe that
39231
39819
  * quietly under-reports here reads as "these events are gone", and the caller
39232
39820
  * deletes live crops on the strength of it.
39233
39821
  */
39234
39822
  async existingEventIds(eventIds) {
39235
39823
  const found = /* @__PURE__ */ new Set();
39824
+ const collections = [
39825
+ OBJECT_EVENTS_COLLECTION,
39826
+ MOTION_EVENTS_COLLECTION,
39827
+ AUDIO_EVENTS_COLLECTION
39828
+ ];
39236
39829
  for (let i = 0; i < eventIds.length; i += 500) {
39237
39830
  const chunk = eventIds.slice(i, i + 500);
39238
39831
  if (chunk.length === 0) continue;
39239
- const rows = await this.store.query.query({
39240
- collection: OBJECT_EVENTS_COLLECTION,
39832
+ const pages = await Promise.all(collections.map((collection) => this.store.query.query({
39833
+ collection,
39241
39834
  filter: {
39242
39835
  whereIn: { id: [...chunk] },
39243
39836
  limit: chunk.length
39244
39837
  }
39245
- });
39246
- for (const row of rows) found.add(row.id);
39838
+ })));
39839
+ for (const rows of pages) for (const row of rows) found.add(row.id);
39247
39840
  }
39248
39841
  return found;
39249
39842
  }
@@ -41251,6 +41844,10 @@ var TRACKS_COLUMNS = [
41251
41844
  name: "debug",
41252
41845
  type: "BOOLEAN"
41253
41846
  },
41847
+ {
41848
+ name: "favourited",
41849
+ type: "BOOLEAN"
41850
+ },
41254
41851
  {
41255
41852
  name: "hasFace",
41256
41853
  type: "BOOLEAN"
@@ -41344,6 +41941,7 @@ function cloneTrack(t) {
41344
41941
  markForTrain: t.retrainStatus === "staging"
41345
41942
  } : {},
41346
41943
  ...t.debug !== void 0 ? { debug: t.debug } : {},
41944
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {},
41347
41945
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
41348
41946
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
41349
41947
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
@@ -41689,11 +42287,13 @@ var TrackStore = class {
41689
42287
  const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
41690
42288
  if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
41691
42289
  if (patch.debug !== void 0) data["debug"] = patch.debug;
42290
+ if (patch.favourited !== void 0) data["favourited"] = patch.favourited;
41692
42291
  if (Object.keys(data).length === 0) return;
41693
42292
  const active = this.active.get(trackId);
41694
42293
  if (active) {
41695
42294
  if (nextStatus !== void 0) active.retrainStatus = nextStatus;
41696
42295
  if (patch.debug !== void 0) active.debug = patch.debug;
42296
+ if (patch.favourited !== void 0) active.favourited = patch.favourited;
41697
42297
  }
41698
42298
  try {
41699
42299
  await this.store.update.mutate({
@@ -41834,13 +42434,15 @@ var TrackStore = class {
41834
42434
  const active = this.active.get(trackId);
41835
42435
  if (active) return {
41836
42436
  retrainStatus: active.retrainStatus ?? "none",
41837
- debug: active.debug === true
42437
+ debug: active.debug === true,
42438
+ favourited: active.favourited === true
41838
42439
  };
41839
42440
  const persisted = await this.getPersistedByTrackId(trackId);
41840
42441
  if (!persisted) return null;
41841
42442
  return {
41842
42443
  retrainStatus: persisted.retrainStatus ?? "none",
41843
- debug: persisted.debug === true
42444
+ debug: persisted.debug === true,
42445
+ favourited: persisted.favourited === true
41844
42446
  };
41845
42447
  }
41846
42448
  /**
@@ -41975,6 +42577,7 @@ var TrackStore = class {
41975
42577
  filter: {
41976
42578
  where: { deviceId },
41977
42579
  whereIn: { retrainStatus: ["none", "trained"] },
42580
+ whereNot: { favourited: true },
41978
42581
  whereBetween: { lastSeen: [0, cutoffMs - 1] },
41979
42582
  orderBy: {
41980
42583
  field: "lastSeen",
@@ -42144,6 +42747,40 @@ var TrackStore = class {
42144
42747
  }
42145
42748
  }
42146
42749
  /**
42750
+ * How many of this device's tracks are FAVOURITED — optionally only those
42751
+ * older than a cutoff, i.e. the rows a sweep just declined to touch because
42752
+ * of the boolean pin (independent of `retrainStatus`).
42753
+ *
42754
+ * Mirrors {@link countStaging}: the sweep log must name held work or a
42755
+ * favourited-only camera reads as "retention did nothing". Best-effort — a
42756
+ * failure reports 0 rather than disturbing the caller.
42757
+ */
42758
+ async countFavourited(query) {
42759
+ const { deviceId, olderThanMs } = query;
42760
+ try {
42761
+ return await this.store.count.query({
42762
+ collection: TRACKS_COLLECTION,
42763
+ filter: {
42764
+ where: {
42765
+ deviceId,
42766
+ favourited: true
42767
+ },
42768
+ ...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
42769
+ }
42770
+ });
42771
+ } catch (err) {
42772
+ this.logger.warn("TrackStore.countFavourited failed", {
42773
+ tags: { deviceId },
42774
+ meta: {
42775
+ deviceId,
42776
+ olderThanMs,
42777
+ error: String(err)
42778
+ }
42779
+ });
42780
+ return 0;
42781
+ }
42782
+ }
42783
+ /**
42147
42784
  * The SUBSET of `trackIds` that still have a persisted row — the orphan
42148
42785
  * audit's liveness probe.
42149
42786
  *
@@ -42516,6 +43153,7 @@ var TrackStore = class {
42516
43153
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
42517
43154
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
42518
43155
  ...t.debug !== void 0 ? { debug: t.debug } : {},
43156
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {},
42519
43157
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
42520
43158
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
42521
43159
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
@@ -42553,6 +43191,7 @@ var TrackStore = class {
42553
43191
  const audioLabels = data["audioLabels"];
42554
43192
  const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
42555
43193
  const debug = data["debug"];
43194
+ const favourited = data["favourited"];
42556
43195
  const hasFace = data["hasFace"];
42557
43196
  /** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
42558
43197
  const hasRider = data["hasRider"];
@@ -42592,6 +43231,7 @@ var TrackStore = class {
42592
43231
  markForTrain: retrainParsed.data === "staging"
42593
43232
  } : {},
42594
43233
  ...typeof debug === "boolean" ? { debug } : {},
43234
+ ...typeof favourited === "boolean" ? { favourited } : {},
42595
43235
  ...typeof hasFace === "boolean" ? { hasFace } : {},
42596
43236
  ...typeof hasRider === "boolean" ? { hasRider } : {},
42597
43237
  ...closedReason !== void 0 ? { closedReason } : {},
@@ -44614,7 +45254,10 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
44614
45254
  EventCategory.WorkerOffline,
44615
45255
  EventCategory.PipelineNodeInferenceUnavailable,
44616
45256
  EventCategory.PipelineDetectionBlind,
44617
- EventCategory.UpdateAvailable
45257
+ EventCategory.UpdateAvailable,
45258
+ EventCategory.AddonUpdated,
45259
+ EventCategory.SystemRestartCompleted,
45260
+ EventCategory.RecordingExportCompleted
44618
45261
  ];
44619
45262
  /**
44620
45263
  * Install the addon's bus subscriptions. Identical set + relative order to the
@@ -46051,16 +46694,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
46051
46694
  /** A hanging snapshot cap must not stall the sensor state-change path. */
46052
46695
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
46053
46696
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
46054
- var SnapshotTimeoutError = class extends Error {
46697
+ var SnapshotTimeoutError$1 = class extends Error {
46055
46698
  constructor(ms) {
46056
46699
  super(`snapshot cap timed out after ${ms}ms`);
46057
46700
  this.name = "SnapshotTimeoutError";
46058
46701
  }
46059
46702
  };
46060
46703
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
46061
- function withTimeout(promise, ms) {
46704
+ function withTimeout$1(promise, ms) {
46062
46705
  return new Promise((resolve, reject) => {
46063
- const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46706
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
46064
46707
  promise.then((value) => {
46065
46708
  clearTimeout(timer);
46066
46709
  resolve(value);
@@ -46115,11 +46758,11 @@ var SyntheticTrackMaterializer = class {
46115
46758
  */
46116
46759
  async fetchSnapshot(cameraId, timeoutMs) {
46117
46760
  try {
46118
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46761
+ const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46119
46762
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
46120
46763
  return snap;
46121
46764
  } catch (err) {
46122
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46765
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46123
46766
  return null;
46124
46767
  }
46125
46768
  }
@@ -46185,6 +46828,66 @@ var SyntheticTrackMaterializer = class {
46185
46828
  return track;
46186
46829
  }
46187
46830
  };
46831
+ var SnapshotTimeoutError = class extends Error {
46832
+ constructor(ms) {
46833
+ super(`snapshot cap timed out after ${ms}ms`);
46834
+ this.name = "SnapshotTimeoutError";
46835
+ }
46836
+ };
46837
+ function withTimeout(promise, ms) {
46838
+ return new Promise((resolve, reject) => {
46839
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46840
+ promise.then((value) => {
46841
+ clearTimeout(timer);
46842
+ resolve(value);
46843
+ }, (err) => {
46844
+ clearTimeout(timer);
46845
+ reject(err instanceof Error ? err : new Error(String(err)));
46846
+ });
46847
+ });
46848
+ }
46849
+ var MotionEventSnapshotCapturer = class {
46850
+ deps;
46851
+ snapshotTimeoutMs;
46852
+ constructor(deps) {
46853
+ this.deps = deps;
46854
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
46855
+ }
46856
+ /**
46857
+ * Fetch a still and persist it as the motion event's snapshot. Never
46858
+ * throws — the durable record is the motion row, already inserted by
46859
+ * the caller.
46860
+ */
46861
+ async capture(input) {
46862
+ let snap;
46863
+ try {
46864
+ snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
46865
+ } catch (err) {
46866
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
46867
+ return;
46868
+ }
46869
+ if (snap === null) return;
46870
+ const raw = Buffer.from(snap.base64, "base64");
46871
+ let data = raw;
46872
+ try {
46873
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
46874
+ } catch (err) {
46875
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
46876
+ }
46877
+ try {
46878
+ await this.deps.media.put({
46879
+ deviceId: input.deviceId,
46880
+ ownerKind: "event",
46881
+ ownerId: input.eventId,
46882
+ kind: "snapshot",
46883
+ timestamp: input.timestamp,
46884
+ data
46885
+ });
46886
+ } catch (err) {
46887
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
46888
+ }
46889
+ }
46890
+ };
46188
46891
  var TrackingSettingsSchema = object({
46189
46892
  /**
46190
46893
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -49155,6 +49858,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
49155
49858
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
49156
49859
  * tracks — linked sensor/control state changes AND audio markers. */
49157
49860
  syntheticTracks = null;
49861
+ /** Still for a motion row that has no track (events-mode recording without
49862
+ * a detection). Lands as event-owned `kind: 'snapshot'` so videoclip
49863
+ * thumbnails resolve through the existing event-media plane. */
49864
+ motionEventSnapshots = null;
49158
49865
  /** Decides which audio frames deserve a synthetic marker (D62). */
49159
49866
  audioMarkers = null;
49160
49867
  faceStore = null;
@@ -50348,6 +51055,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50348
51055
  } })
50349
51056
  });
50350
51057
  this.syntheticTracks = syntheticTracks;
51058
+ this.motionEventSnapshots = new MotionEventSnapshotCapturer({
51059
+ snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
51060
+ media: mediaStore,
51061
+ onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
51062
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
51063
+ meta: {
51064
+ scope,
51065
+ error: errMsg(err)
51066
+ }
51067
+ })
51068
+ });
50351
51069
  this.audioMarkers = new AudioMarkerProjector({
50352
51070
  sink: syntheticTracks,
50353
51071
  isMotionActive: (deviceId, atMs, quietMs) => this.isMotionActive(deviceId, atMs, quietMs),
@@ -50713,6 +51431,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50713
51431
  },
50714
51432
  renderEventMedia: (req) => this.renderEventMedia(req),
50715
51433
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
51434
+ indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
51435
+ url: null,
51436
+ artifactId: null
51437
+ },
50716
51438
  listTargets: async () => {
50717
51439
  return (await api.notificationOutput.listTargets.query({})).map((t) => {
50718
51440
  const owner = t.config["ownerUserId"];
@@ -53414,7 +54136,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53414
54136
  const last = this.lastMotionInsertByDevice.get(deviceId);
53415
54137
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
53416
54138
  const transitionedOn = detected && (last === void 0 || last.detected === false);
53417
- if (!detected) return;
54139
+ if (!detected) {
54140
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54141
+ detected: false,
54142
+ atMs: timestamp
54143
+ });
54144
+ return;
54145
+ }
53418
54146
  if (!transitionedOn && !heartbeatDue) return;
53419
54147
  const ev = {
53420
54148
  id: randomUUID(),
@@ -53457,6 +54185,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53457
54185
  timestamp: ev.timestamp
53458
54186
  }
53459
54187
  });
54188
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54189
+ deviceId,
54190
+ eventId: ev.id,
54191
+ timestamp: ev.timestamp
54192
+ });
53460
54193
  }
53461
54194
  /**
53462
54195
  * Persist firmware-driven (onboard) motion events. Mirrors
@@ -53481,7 +54214,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53481
54214
  const last = this.lastMotionInsertByDevice.get(deviceId);
53482
54215
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
53483
54216
  const transitionedOn = detected && (last === void 0 || last.detected === false);
53484
- if (!detected) return;
54217
+ if (!detected) {
54218
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54219
+ detected: false,
54220
+ atMs: timestamp
54221
+ });
54222
+ return;
54223
+ }
53485
54224
  if (!transitionedOn && !heartbeatDue) return;
53486
54225
  const regions = payload.regions ?? [];
53487
54226
  const ev = {
@@ -53525,6 +54264,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53525
54264
  timestamp: ev.timestamp
53526
54265
  }
53527
54266
  });
54267
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54268
+ deviceId,
54269
+ eventId: ev.id,
54270
+ timestamp: ev.timestamp
54271
+ });
53528
54272
  }
53529
54273
  async sweepExpiredTracks() {
53530
54274
  if (this.shuttingDown || !this.trackStore) return;
@@ -53593,8 +54337,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53593
54337
  *
53594
54338
  * 1. the track sweep, per device, cascading;
53595
54339
  * 2. the TRACKLESS roots — motion, audio and sensor events, which carry no
53596
- * trackId, own nothing and are referenced by nothing, so the cascade can
53597
- * never reach them and a clock is the only thing that can.
54340
+ * trackId and are referenced by nothing, so the cascade can never reach
54341
+ * them and a clock is the only thing that can. Motion visit stills leave
54342
+ * with their row inside `evictTracklessBefore`.
53598
54343
  *
53599
54344
  * That clock used to be three more settings (`retentionMotionDays` /
53600
54345
  * `retentionObjectDays` / `retentionAudioDays`). It is now the device's own
@@ -55373,6 +56118,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55373
56118
  deleted: counts.tracks
55374
56119
  }
55375
56120
  });
56121
+ const favourited = await this.trackStore?.countFavourited({
56122
+ deviceId,
56123
+ olderThanMs: cutoffMs
56124
+ });
56125
+ if (favourited !== void 0 && favourited > 0) this.ctx.logger.info("track retention held favourited tracks", {
56126
+ tags: { deviceId },
56127
+ meta: {
56128
+ deviceId,
56129
+ favourited,
56130
+ cutoffMs,
56131
+ deleted: counts.tracks
56132
+ }
56133
+ });
55376
56134
  if (counts.tracks > 0) this.eventsOpsLog?.append({
55377
56135
  op: "prune",
55378
56136
  reason: "retention",