@camstack/addon-post-analysis 1.2.91 → 1.2.93

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 { $ as faceGalleryCapability, At as partialRecord, B as VISIT_MERGE_GAP_MS, C as NcSnoozeSchema, Ct as _enum, D as OpsLogEntrySchema, Dt as literal, E as NcTaxonomySchema, Et as discriminatedUnion, 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 string, N as SCENE_DIVERGED, Nt as unknown, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as number, P as SceneMonitorSchema, Pt as EventCategory, Q as encodeVectorBase64, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as sleep, T as NcSystemEventKindSchema, Tt as boolean, U as assertTimelapseCadences, V as addonWidgetsSourceCapability, W as audioMetricsCapability, X as deriveRecordingMode, Y as defineCustomActions, _ as NcRuleInputSchema, _t as DeviceType, at as pipelineAnalyticsCapability, b as NcRuleTargetSchema, bt as isDeviceScopedCap, c as EVENT_PAD_MS, ct as readTimelapseGeneratedAt, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as systemEventFilterApplies, f as NC_CONDITION_CATALOG, ft as vectorDimFromBase64, g as NcConditionDescriptorSchema, gt as BaseAddon, h as NC_TAXONOMY, ht as errMsg, i as DETECTION_MACRO_CLASSES, it as notificationRulesCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as record, k as RetrainStatusSchema, kt as object, l as LabelAttributionSchema, lt as sceneMonitorCapability, mt as zoneAnalyticsCapability, n as DEFAULT_EVENT_COLOR, nt as isScheduleActive, o as DeclaredDevices, ot as plateGalleryCapability, p as NC_DEFAULT_SNOOZE_MINUTES, pt as videoclipsCapability, 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 createEvent, w as NcSnoozeSuppressedSchema, wt as array, x as NcScheduleSchema, xt as nodePin, y as NcRuleSchema, yt as hydrateSchema, z as TrackSourceSchema } from "../dist-CquggoRC.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-3QRyOemL.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";
@@ -8282,6 +8282,8 @@ var en_default = {
8282
8282
  "system.node-offline.body.worker": "Worker {{nodeId}} is offline",
8283
8283
  "system.node-inference-unavailable.title": "Node has no usable inference device",
8284
8284
  "system.node-inference-unavailable.body": "Node {{nodeId}} can no longer run inference — every enabled device is unavailable ({{devices}}). It will not receive cameras until one returns.",
8285
+ "system.addon-crash-loop.title": "Addon stopped after repeated crashes",
8286
+ "system.addon-crash-loop.body": "The {{addons}} addon on node {{nodeId}} crashed {{count}} times in a row and will NOT be restarted. Everything it provides is offline until it is repaired.",
8285
8287
  "system.detection-blind.title": "Camera detecting nothing",
8286
8288
  "system.detection-blind.body": {
8287
8289
  "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
@@ -8494,6 +8496,8 @@ var it_default = {
8494
8496
  "system.node-offline.body.worker": "Il worker {{nodeId}} è offline",
8495
8497
  "system.node-inference-unavailable.title": "Nodo senza dispositivi di inferenza utilizzabili",
8496
8498
  "system.node-inference-unavailable.body": "Il nodo {{nodeId}} non può più eseguire inferenza — tutti i dispositivi abilitati non sono disponibili ({{devices}}). Non riceverà camere finché uno non torna disponibile.",
8499
+ "system.addon-crash-loop.title": "Addon fermato dopo ripetuti crash",
8500
+ "system.addon-crash-loop.body": "L'addon {{addons}} sul nodo {{nodeId}} è andato in crash {{count}} volte di seguito e NON verrà riavviato. Tutto ciò che fornisce resta offline finché non viene riparato.",
8497
8501
  "system.detection-blind.title": "Camera che non rileva nulla",
8498
8502
  "system.detection-blind.body": {
8499
8503
  "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
@@ -10266,6 +10270,27 @@ function incomingFromSystemEvent(event, lookup) {
10266
10270
  nodeId
10267
10271
  });
10268
10272
  }
10273
+ case "addon.runner-failed": {
10274
+ const nodeId = stringField(data, "nodeId");
10275
+ const runnerId = stringField(data, "runnerId");
10276
+ const crashes = numberField(data, "crashesInWindow");
10277
+ if (nodeId === void 0 || runnerId === void 0 || crashes === void 0) return null;
10278
+ const ids = data["addonIds"];
10279
+ const addons = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
10280
+ return systemSubject({
10281
+ facts,
10282
+ kind: "addon-crash-loop",
10283
+ subject: `addon-crash:${nodeId}:${runnerId}`,
10284
+ titleKey: "system.addon-crash-loop.title",
10285
+ bodyKey: "system.addon-crash-loop.body",
10286
+ textParams: {
10287
+ nodeId,
10288
+ addons: addons.length > 0 ? addons.join(", ") : runnerId,
10289
+ count: String(crashes)
10290
+ },
10291
+ nodeId
10292
+ });
10293
+ }
10269
10294
  case "pipeline.detection-blind": {
10270
10295
  const deviceId = numberField(data, "deviceId");
10271
10296
  const blindForMs = numberField(data, "blindForMs");
@@ -21918,6 +21943,9 @@ function systemEventKeys(kind) {
21918
21943
  }
21919
21944
  /** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
21920
21945
  var TEST_ALARM_MODE = "away";
21946
+ /** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
21947
+ * real crashed runner, and a blank one would render "The addon on node hub". */
21948
+ var TEST_CRASHED_RUNNER = "recorder";
21921
21949
  /** The dedup subject the live intake stamps for this kind — same families. */
21922
21950
  function systemEventSubject(kind, scope) {
21923
21951
  if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
@@ -21926,6 +21954,7 @@ function systemEventSubject(kind, scope) {
21926
21954
  if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
21927
21955
  if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
21928
21956
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
21957
+ if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
21929
21958
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
21930
21959
  if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
21931
21960
  if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
@@ -22042,6 +22071,7 @@ function planRuleTest(input) {
22042
22071
  seconds: "30",
22043
22072
  devices: "cuda:0",
22044
22073
  minutes: "15",
22074
+ addons: TEST_CRASHED_RUNNER,
22045
22075
  list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
22046
22076
  nodes: "hub",
22047
22077
  fromVersion: "0.0.0",
@@ -28833,51 +28863,6 @@ var PlateGalleryProvider = class {
28833
28863
  };
28834
28864
  }
28835
28865
  };
28836
- var OrphanAuditActionInputSchema = object({
28837
- mode: _enum(["report", "reclaim"]).default("report"),
28838
- /**
28839
- * Start every scope from the top instead of resuming its persisted cursor.
28840
- *
28841
- * What draining a backlog wants, and never the default — the cursor exists
28842
- * so a 6-hourly pass makes progress instead of re-walking the same head.
28843
- */
28844
- restart: boolean().optional(),
28845
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
28846
- pageSize: number().int().min(50).max(5e3).optional(),
28847
- /** Rows EXAMINED per collection in this pass — the pacing bound. */
28848
- maxRowsPerScope: number().int().min(1).max(2e5).optional(),
28849
- /** Rows DELETED per collection in this pass — the blast-radius bound. */
28850
- maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
28851
- /**
28852
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
28853
- * exclusively save rows, never select them, which is what keeps this off
28854
- * the one-age-keyed-sweep guard's books.
28855
- */
28856
- graceMinutes: number().int().min(1).max(10080).optional()
28857
- });
28858
- var OrphanAuditScopeReportSchema = object({
28859
- collection: string(),
28860
- examined: number(),
28861
- orphans: number(),
28862
- reclaimed: number(),
28863
- bytesReclaimed: number(),
28864
- exempt: number(),
28865
- withinGrace: number(),
28866
- unresolvable: number(),
28867
- stoppedBy: string(),
28868
- /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
28869
- nextCursor: number()
28870
- });
28871
- var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
28872
- /** `false` when a pass was already in flight — nothing was done. */
28873
- ran: boolean(),
28874
- mode: _enum(["report", "reclaim"]),
28875
- scopes: array(OrphanAuditScopeReportSchema),
28876
- totalOrphans: number(),
28877
- totalReclaimed: number(),
28878
- totalBytesReclaimed: number(),
28879
- durationMs: number()
28880
- }), { auth: "admin" }) });
28881
28866
  //#endregion
28882
28867
  //#region src/pipeline-analytics/store/storage-pressure.ts
28883
28868
  /**
@@ -29045,6 +29030,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
29045
29030
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
29046
29031
  * of a set of tracks without a `trackId` column on media rows. */
29047
29032
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
29033
+ /** Index rows per page inside one presence chunk. Must stay under
29034
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
29035
+ * silently truncate a page — an owner reported absent because a cap bit would
29036
+ * DELETE a live thumbnail from the surface. */
29037
+ var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
29048
29038
  /**
29049
29039
  * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
29050
29040
  * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
@@ -29472,6 +29462,59 @@ var MediaStore = class {
29472
29462
  });
29473
29463
  }
29474
29464
  /**
29465
+ * Which of `ownerIds` hold at least ONE media row — index only, no blob is
29466
+ * opened, no base64 is allocated.
29467
+ *
29468
+ * This is the licence `videoclips` needs to publish a `thumbnail`. The
29469
+ * event-media URL is stamped from a template for every event in the system,
29470
+ * so "the plane is up" was being served as "there are bytes": 64 of one
29471
+ * camera's 179 clips pointed at a 404 on 2026-08-17.
29472
+ *
29473
+ * Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
29474
+ * that is one query per owner, which is the N+1 this exists to prevent (and
29475
+ * `queryObjectByTrackIds` already records what believing `whereIn` was
29476
+ * unavailable cost the search path).
29477
+ *
29478
+ * `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
29479
+ *
29480
+ * THROWS on a query failure rather than answering partially — a probe that
29481
+ * quietly under-reports reads as "these owners have nothing", and the caller
29482
+ * drops thumbnails that exist.
29483
+ */
29484
+ async ownersWithMedia(ownerKind, ownerIds, deviceId) {
29485
+ const found = /* @__PURE__ */ new Set();
29486
+ const unique = [...new Set(ownerIds)];
29487
+ for (let i = 0; i < unique.length; i += 200) {
29488
+ const chunk = unique.slice(i, i + 200);
29489
+ const answered = /* @__PURE__ */ new Set();
29490
+ for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
29491
+ const rows = await this.store.query.query({
29492
+ collection: MEDIA_COLLECTION,
29493
+ filter: {
29494
+ where: {
29495
+ ownerKind,
29496
+ deviceId
29497
+ },
29498
+ whereIn: { ownerId: chunk },
29499
+ orderBy: {
29500
+ field: "ownerId",
29501
+ direction: "asc"
29502
+ },
29503
+ limit: MEDIA_PRESENCE_PAGE_SIZE,
29504
+ offset
29505
+ }
29506
+ });
29507
+ for (const row of rows) {
29508
+ const ownerId = row.data["ownerId"];
29509
+ if (typeof ownerId === "string") answered.add(ownerId);
29510
+ }
29511
+ if (rows.length < 1e3 || answered.size >= chunk.length) break;
29512
+ }
29513
+ for (const ownerId of answered) found.add(ownerId);
29514
+ }
29515
+ return found;
29516
+ }
29517
+ /**
29475
29518
  * Delete all media (blobs + index rows) for the given owners of one kind.
29476
29519
  * Best-effort per row; returns the number of index rows removed.
29477
29520
  */
@@ -29818,6 +29861,51 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
29818
29861
  events: doomedEvents.length
29819
29862
  };
29820
29863
  }
29864
+ var OrphanAuditActionInputSchema = object({
29865
+ mode: _enum(["report", "reclaim"]).default("report"),
29866
+ /**
29867
+ * Start every scope from the top instead of resuming its persisted cursor.
29868
+ *
29869
+ * What draining a backlog wants, and never the default — the cursor exists
29870
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
29871
+ */
29872
+ restart: boolean().optional(),
29873
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
29874
+ pageSize: number().int().min(50).max(5e3).optional(),
29875
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
29876
+ maxRowsPerScope: number().int().min(1).max(2e5).optional(),
29877
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
29878
+ maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
29879
+ /**
29880
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
29881
+ * exclusively save rows, never select them, which is what keeps this off
29882
+ * the one-age-keyed-sweep guard's books.
29883
+ */
29884
+ graceMinutes: number().int().min(1).max(10080).optional()
29885
+ });
29886
+ var OrphanAuditScopeReportSchema = object({
29887
+ collection: string(),
29888
+ examined: number(),
29889
+ orphans: number(),
29890
+ reclaimed: number(),
29891
+ bytesReclaimed: number(),
29892
+ exempt: number(),
29893
+ withinGrace: number(),
29894
+ unresolvable: number(),
29895
+ stoppedBy: string(),
29896
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
29897
+ nextCursor: number()
29898
+ });
29899
+ var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
29900
+ /** `false` when a pass was already in flight — nothing was done. */
29901
+ ran: boolean(),
29902
+ mode: _enum(["report", "reclaim"]),
29903
+ scopes: array(OrphanAuditScopeReportSchema),
29904
+ totalOrphans: number(),
29905
+ totalReclaimed: number(),
29906
+ totalBytesReclaimed: number(),
29907
+ durationMs: number()
29908
+ }), { auth: "admin" }) });
29821
29909
  //#endregion
29822
29910
  //#region src/pipeline-analytics/videoclip-visits.ts
29823
29911
  /**
@@ -29885,7 +29973,22 @@ function dominantKind(attached) {
29885
29973
  for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
29886
29974
  return "motion";
29887
29975
  }
29888
- function visitsToClips(visits, events, pad) {
29976
+ /**
29977
+ * The instant a recorded still can actually be decoded inside a visit: the
29978
+ * midpoint of its LONGEST contiguous range.
29979
+ *
29980
+ * NOT the midpoint of the visit. A visit spans its holes by construction (it is
29981
+ * ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
29982
+ * lands in dead air roughly as often as not and the recorder's still route
29983
+ * answers 204 for a visit with plenty of footage.
29984
+ */
29985
+ function stillInstantMs(ranges) {
29986
+ let best;
29987
+ for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
29988
+ if (best === void 0) return void 0;
29989
+ return Math.floor((best.startMs + best.endMs) / 2);
29990
+ }
29991
+ function visitsToClips(visits, events, pad, vouched) {
29889
29992
  const tagged = [
29890
29993
  ...events.object.map((event) => ({
29891
29994
  kind: "object",
@@ -29903,7 +30006,8 @@ function visitsToClips(visits, events, pad) {
29903
30006
  return visits.map((visit) => {
29904
30007
  const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
29905
30008
  const kind = dominantKind(attached);
29906
- const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
30009
+ const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
30010
+ const stillAtMs = stillInstantMs(visit.ranges);
29907
30011
  return {
29908
30012
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
29909
30013
  source: "analytics",
@@ -29912,8 +30016,10 @@ function visitsToClips(visits, events, pad) {
29912
30016
  startMs: visit.startMs,
29913
30017
  endMs: visit.endMs
29914
30018
  },
29915
- thumbnail: withThumb?.event.mediaUrl,
29916
- eventIds: attached.map((a) => a.event.id),
30019
+ ...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
30020
+ ...stillAtMs !== void 0 ? { stillAtMs } : {},
30021
+ eventIds: attached.slice(0, 24).map((a) => a.event.id),
30022
+ eventCount: attached.length,
29917
30023
  holes: [...visit.holes]
29918
30024
  };
29919
30025
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
@@ -29937,6 +30043,17 @@ function visitsToClips(visits, events, pad) {
29937
30043
  * injected fetch/resolve deps so the compose logic is testable without I/O.
29938
30044
  */
29939
30045
  var SOURCE = "analytics";
30046
+ /**
30047
+ * Backstop on the ANNOTATION read — never on the clip list.
30048
+ *
30049
+ * A visit is annotated by every event inside it, so the clip `limit` must not
30050
+ * reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
30051
+ * bound exists only so a pathological camera-day cannot pull an unbounded page:
30052
+ * the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
30053
+ * the EARLIEST visits silently lose their annotations — which is why hitting it
30054
+ * is logged, per camera, rather than absorbed.
30055
+ */
30056
+ var VISIT_EVENT_FETCH_LIMIT = 5e3;
29940
30057
  function clipIdFor(eventId, startMs, endMs) {
29941
30058
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
29942
30059
  }
@@ -29957,7 +30074,7 @@ function parseClipId(clipId) {
29957
30074
  };
29958
30075
  }
29959
30076
  /** Map per-kind events into time-ranged clips, newest-first. Pure. */
29960
- function eventsToClips(events, pad) {
30077
+ function eventsToClips(events, pad, vouched) {
29961
30078
  const make = (e, kind) => {
29962
30079
  const startMs = e.timestamp - pad.preMs;
29963
30080
  const endMs = e.timestamp + pad.postMs;
@@ -29969,7 +30086,7 @@ function eventsToClips(events, pad) {
29969
30086
  startMs,
29970
30087
  endMs
29971
30088
  },
29972
- thumbnail: e.mediaUrl
30089
+ ...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
29973
30090
  };
29974
30091
  };
29975
30092
  return [
@@ -29978,29 +30095,106 @@ function eventsToClips(events, pad) {
29978
30095
  ...events.audio.map((e) => make(e, "audio"))
29979
30096
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
29980
30097
  }
30098
+ /** Every id in the three buckets, in the order the composers tag them. */
30099
+ function allEventIds(events) {
30100
+ return [
30101
+ ...events.motion,
30102
+ ...events.object,
30103
+ ...events.audio
30104
+ ].map((e) => e.id);
30105
+ }
30106
+ /**
30107
+ * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
30108
+ * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
30109
+ * owning TRACK's. Two batched index reads at most, and the second only over the
30110
+ * object events the first did not already answer for — motion and audio are
30111
+ * trackless roots, so they never need it.
30112
+ *
30113
+ * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
30114
+ * LIST; swallowing here would collapse those two opposite directions into one.
30115
+ */
30116
+ async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
30117
+ const eventIds = allEventIds(events);
30118
+ if (eventIds.length === 0) return /* @__PURE__ */ new Set();
30119
+ const ownMedia = await fetchMediaOwners({
30120
+ deviceId,
30121
+ ownerKind: "event",
30122
+ ownerIds: eventIds
30123
+ });
30124
+ const residualTracks = [];
30125
+ const seenTracks = /* @__PURE__ */ new Set();
30126
+ for (const event of events.object) {
30127
+ const trackId = event.trackId;
30128
+ if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
30129
+ seenTracks.add(trackId);
30130
+ residualTracks.push(trackId);
30131
+ }
30132
+ const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
30133
+ deviceId,
30134
+ ownerKind: "track",
30135
+ ownerIds: residualTracks
30136
+ });
30137
+ const vouched = new Set(ownMedia);
30138
+ for (const event of events.object) {
30139
+ const trackId = event.trackId;
30140
+ if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
30141
+ }
30142
+ return vouched;
30143
+ }
30144
+ /** Keep only clips that actually touch the caller's window. The reads are
30145
+ * widened by {@link leftOverhangMs}; the ANSWER is not. */
30146
+ function intersectsWindow(clip, since, until) {
30147
+ return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
30148
+ }
30149
+ /**
30150
+ * How far BEFORE `since` the event + availability reads must reach.
30151
+ *
30152
+ * A visit straddling `since` keeps its heartbeat rows and loses the transition
30153
+ * row — the only one that ever carries a snapshot — so the same visit shows a
30154
+ * picture or doesn't depending on where the operator scrolled. One wider range
30155
+ * scan on an indexed column buys a list that stops changing under the cursor.
30156
+ */
30157
+ function leftOverhangMs(pad) {
30158
+ return VISIT_MERGE_GAP_MS + pad.preMs;
30159
+ }
29981
30160
  function createVideoclipsProvider(deps) {
29982
30161
  return {
29983
30162
  listClips: async ({ deviceId, since, until, limit }) => {
30163
+ const readSince = since - leftOverhangMs(deps.defaultPad);
29984
30164
  const events = await deps.fetchEvents({
29985
30165
  deviceId,
29986
- since,
30166
+ since: readSince,
29987
30167
  until,
29988
- ...deps.fetchAvailability ? {} : { limit }
30168
+ limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
29989
30169
  });
29990
- let clips = eventsToClips(events, deps.defaultPad);
30170
+ warnOnTruncatedFetch(deps, deviceId, events);
30171
+ let vouched = /* @__PURE__ */ new Set();
30172
+ try {
30173
+ vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
30174
+ } catch (err) {
30175
+ deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
30176
+ deviceId,
30177
+ events: allEventIds(events).length,
30178
+ error: err instanceof Error ? err.message : String(err)
30179
+ });
30180
+ }
30181
+ let clips = eventsToClips(events, deps.defaultPad, vouched);
29991
30182
  if (deps.fetchAvailability) try {
29992
30183
  clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
29993
30184
  deviceId,
29994
- since,
30185
+ since: readSince,
29995
30186
  until
29996
- }), VISIT_MERGE_GAP_MS), events, deps.defaultPad);
30187
+ }), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
29997
30188
  } catch (err) {
29998
30189
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
29999
30190
  deviceId,
30000
30191
  error: err instanceof Error ? err.message : String(err)
30001
30192
  });
30002
30193
  }
30003
- return limit != null ? clips.slice(0, limit) : clips;
30194
+ const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
30195
+ const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
30196
+ reportUnvouchedClips(deps, deviceId, answer);
30197
+ return answer;
30004
30198
  },
30005
30199
  getClipPlayback: async ({ deviceId, clipId }) => {
30006
30200
  const parsed = parseClipId(clipId);
@@ -30013,6 +30207,33 @@ function createVideoclipsProvider(deps) {
30013
30207
  }
30014
30208
  };
30015
30209
  }
30210
+ /** The annotation backstop biting is a SILENT loss of the earliest visits'
30211
+ * events — say so, per camera, instead of shipping a thinner list. */
30212
+ function warnOnTruncatedFetch(deps, deviceId, events) {
30213
+ if (!deps.fetchAvailability) return;
30214
+ const truncated = [
30215
+ "motion",
30216
+ "object",
30217
+ "audio"
30218
+ ].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
30219
+ if (truncated.length === 0) return;
30220
+ deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
30221
+ deviceId,
30222
+ kinds: truncated,
30223
+ limit: VISIT_EVENT_FETCH_LIMIT
30224
+ });
30225
+ }
30226
+ /** The operator's acceptance criterion as a number: a rising count on one
30227
+ * camera is its motion-snapshot capturer failing, not a quiet day. */
30228
+ function reportUnvouchedClips(deps, deviceId, clips) {
30229
+ const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
30230
+ if (clipsWithoutStill === 0) return;
30231
+ deps.logInfo?.("videoclips: visit has no vouched still", {
30232
+ deviceId,
30233
+ clipsWithoutStill,
30234
+ clips: clips.length
30235
+ });
30236
+ }
30016
30237
  //#endregion
30017
30238
  //#region src/pipeline-analytics/viewer-settings-actions.ts
30018
30239
  /**
@@ -45858,6 +46079,7 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
45858
46079
  EventCategory.WorkerOffline,
45859
46080
  EventCategory.PipelineNodeInferenceUnavailable,
45860
46081
  EventCategory.PipelineDetectionBlind,
46082
+ EventCategory.AddonRunnerFailed,
45861
46083
  EventCategory.UpdateAvailable,
45862
46084
  EventCategory.AddonUpdated,
45863
46085
  EventCategory.SystemRestartCompleted,
@@ -47202,6 +47424,66 @@ var EventMediaDispatcher = class {
47202
47424
  }
47203
47425
  }
47204
47426
  };
47427
+ var SnapshotTimeoutError$1 = class extends Error {
47428
+ constructor(ms) {
47429
+ super(`snapshot cap timed out after ${ms}ms`);
47430
+ this.name = "SnapshotTimeoutError";
47431
+ }
47432
+ };
47433
+ function withTimeout$1(promise, ms) {
47434
+ return new Promise((resolve, reject) => {
47435
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47436
+ promise.then((value) => {
47437
+ clearTimeout(timer);
47438
+ resolve(value);
47439
+ }, (err) => {
47440
+ clearTimeout(timer);
47441
+ reject(err instanceof Error ? err : new Error(String(err)));
47442
+ });
47443
+ });
47444
+ }
47445
+ var MotionEventSnapshotCapturer = class {
47446
+ deps;
47447
+ snapshotTimeoutMs;
47448
+ constructor(deps) {
47449
+ this.deps = deps;
47450
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47451
+ }
47452
+ /**
47453
+ * Fetch a still and persist it as the motion event's snapshot. Never
47454
+ * throws — the durable record is the motion row, already inserted by
47455
+ * the caller.
47456
+ */
47457
+ async capture(input) {
47458
+ let snap;
47459
+ try {
47460
+ snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47461
+ } catch (err) {
47462
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47463
+ return;
47464
+ }
47465
+ if (snap === null) return;
47466
+ const raw = Buffer.from(snap.base64, "base64");
47467
+ let data = raw;
47468
+ try {
47469
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
47470
+ } catch (err) {
47471
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47472
+ }
47473
+ try {
47474
+ await this.deps.media.put({
47475
+ deviceId: input.deviceId,
47476
+ ownerKind: "event",
47477
+ ownerId: input.eventId,
47478
+ kind: "snapshot",
47479
+ timestamp: input.timestamp,
47480
+ data
47481
+ });
47482
+ } catch (err) {
47483
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
47484
+ }
47485
+ }
47486
+ };
47205
47487
  /**
47206
47488
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
47207
47489
  * synthetic track per camera the event belongs to. Returns the number of
@@ -47298,16 +47580,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
47298
47580
  /** A hanging snapshot cap must not stall the sensor state-change path. */
47299
47581
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
47300
47582
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
47301
- var SnapshotTimeoutError$1 = class extends Error {
47583
+ var SnapshotTimeoutError = class extends Error {
47302
47584
  constructor(ms) {
47303
47585
  super(`snapshot cap timed out after ${ms}ms`);
47304
47586
  this.name = "SnapshotTimeoutError";
47305
47587
  }
47306
47588
  };
47307
47589
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
47308
- function withTimeout$1(promise, ms) {
47590
+ function withTimeout(promise, ms) {
47309
47591
  return new Promise((resolve, reject) => {
47310
- const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47592
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47311
47593
  promise.then((value) => {
47312
47594
  clearTimeout(timer);
47313
47595
  resolve(value);
@@ -47362,11 +47644,11 @@ var SyntheticTrackMaterializer = class {
47362
47644
  */
47363
47645
  async fetchSnapshot(cameraId, timeoutMs) {
47364
47646
  try {
47365
- const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47647
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47366
47648
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
47367
47649
  return snap;
47368
47650
  } catch (err) {
47369
- this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47651
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47370
47652
  return null;
47371
47653
  }
47372
47654
  }
@@ -47432,66 +47714,6 @@ var SyntheticTrackMaterializer = class {
47432
47714
  return track;
47433
47715
  }
47434
47716
  };
47435
- var SnapshotTimeoutError = class extends Error {
47436
- constructor(ms) {
47437
- super(`snapshot cap timed out after ${ms}ms`);
47438
- this.name = "SnapshotTimeoutError";
47439
- }
47440
- };
47441
- function withTimeout(promise, ms) {
47442
- return new Promise((resolve, reject) => {
47443
- const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47444
- promise.then((value) => {
47445
- clearTimeout(timer);
47446
- resolve(value);
47447
- }, (err) => {
47448
- clearTimeout(timer);
47449
- reject(err instanceof Error ? err : new Error(String(err)));
47450
- });
47451
- });
47452
- }
47453
- var MotionEventSnapshotCapturer = class {
47454
- deps;
47455
- snapshotTimeoutMs;
47456
- constructor(deps) {
47457
- this.deps = deps;
47458
- this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47459
- }
47460
- /**
47461
- * Fetch a still and persist it as the motion event's snapshot. Never
47462
- * throws — the durable record is the motion row, already inserted by
47463
- * the caller.
47464
- */
47465
- async capture(input) {
47466
- let snap;
47467
- try {
47468
- snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47469
- } catch (err) {
47470
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47471
- return;
47472
- }
47473
- if (snap === null) return;
47474
- const raw = Buffer.from(snap.base64, "base64");
47475
- let data = raw;
47476
- try {
47477
- data = await downscaleFullFrameJpeg(raw, 960, 540);
47478
- } catch (err) {
47479
- this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47480
- }
47481
- try {
47482
- await this.deps.media.put({
47483
- deviceId: input.deviceId,
47484
- ownerKind: "event",
47485
- ownerId: input.eventId,
47486
- kind: "snapshot",
47487
- timestamp: input.timestamp,
47488
- data
47489
- });
47490
- } catch (err) {
47491
- this.deps.onError?.("snapshotMedia", err, input.deviceId);
47492
- }
47493
- }
47494
- };
47495
47717
  var TrackingSettingsSchema = object({
47496
47718
  /**
47497
47719
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -53031,6 +53253,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53031
53253
  audio
53032
53254
  };
53033
53255
  },
53256
+ fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
53257
+ return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
53258
+ },
53034
53259
  fetchAvailability: async ({ deviceId, since, until }) => {
53035
53260
  return (await api.recording.getAvailability.query({
53036
53261
  deviceId,
@@ -53042,6 +53267,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53042
53267
  tags: { deviceId: Number(meta["deviceId"]) },
53043
53268
  meta
53044
53269
  }),
53270
+ logInfo: (message, meta) => this.ctx.logger.info(message, {
53271
+ tags: { deviceId: Number(meta["deviceId"]) },
53272
+ meta
53273
+ }),
53045
53274
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
53046
53275
  const manifest = await api.recording.getPlaybackManifest.query({
53047
53276
  deviceId,