@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CTIUuGRB.js");
5
+ const require_dist = require("../dist-DYoWeRRt.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -8289,6 +8289,8 @@ var en_default = {
8289
8289
  "system.node-offline.body.worker": "Worker {{nodeId}} is offline",
8290
8290
  "system.node-inference-unavailable.title": "Node has no usable inference device",
8291
8291
  "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.",
8292
+ "system.addon-crash-loop.title": "Addon stopped after repeated crashes",
8293
+ "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.",
8292
8294
  "system.detection-blind.title": "Camera detecting nothing",
8293
8295
  "system.detection-blind.body": {
8294
8296
  "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
@@ -8501,6 +8503,8 @@ var it_default = {
8501
8503
  "system.node-offline.body.worker": "Il worker {{nodeId}} è offline",
8502
8504
  "system.node-inference-unavailable.title": "Nodo senza dispositivi di inferenza utilizzabili",
8503
8505
  "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.",
8506
+ "system.addon-crash-loop.title": "Addon fermato dopo ripetuti crash",
8507
+ "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.",
8504
8508
  "system.detection-blind.title": "Camera che non rileva nulla",
8505
8509
  "system.detection-blind.body": {
8506
8510
  "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
@@ -10273,6 +10277,27 @@ function incomingFromSystemEvent(event, lookup) {
10273
10277
  nodeId
10274
10278
  });
10275
10279
  }
10280
+ case "addon.runner-failed": {
10281
+ const nodeId = stringField(data, "nodeId");
10282
+ const runnerId = stringField(data, "runnerId");
10283
+ const crashes = numberField(data, "crashesInWindow");
10284
+ if (nodeId === void 0 || runnerId === void 0 || crashes === void 0) return null;
10285
+ const ids = data["addonIds"];
10286
+ const addons = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
10287
+ return systemSubject({
10288
+ facts,
10289
+ kind: "addon-crash-loop",
10290
+ subject: `addon-crash:${nodeId}:${runnerId}`,
10291
+ titleKey: "system.addon-crash-loop.title",
10292
+ bodyKey: "system.addon-crash-loop.body",
10293
+ textParams: {
10294
+ nodeId,
10295
+ addons: addons.length > 0 ? addons.join(", ") : runnerId,
10296
+ count: String(crashes)
10297
+ },
10298
+ nodeId
10299
+ });
10300
+ }
10276
10301
  case "pipeline.detection-blind": {
10277
10302
  const deviceId = numberField(data, "deviceId");
10278
10303
  const blindForMs = numberField(data, "blindForMs");
@@ -21940,6 +21965,9 @@ function systemEventKeys(kind) {
21940
21965
  }
21941
21966
  /** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
21942
21967
  var TEST_ALARM_MODE = "away";
21968
+ /** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
21969
+ * real crashed runner, and a blank one would render "The addon on node hub". */
21970
+ var TEST_CRASHED_RUNNER = "recorder";
21943
21971
  /** The dedup subject the live intake stamps for this kind — same families. */
21944
21972
  function systemEventSubject(kind, scope) {
21945
21973
  if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
@@ -21948,6 +21976,7 @@ function systemEventSubject(kind, scope) {
21948
21976
  if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
21949
21977
  if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
21950
21978
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
21979
+ if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
21951
21980
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
21952
21981
  if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
21953
21982
  if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
@@ -22064,6 +22093,7 @@ function planRuleTest(input) {
22064
22093
  seconds: "30",
22065
22094
  devices: "cuda:0",
22066
22095
  minutes: "15",
22096
+ addons: TEST_CRASHED_RUNNER,
22067
22097
  list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
22068
22098
  nodes: "hub",
22069
22099
  fromVersion: "0.0.0",
@@ -28856,88 +28886,6 @@ var PlateGalleryProvider = class {
28856
28886
  }
28857
28887
  };
28858
28888
  //#endregion
28859
- //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
28860
- /**
28861
- * The operator button for the orphan audit.
28862
- *
28863
- * The audit engine, its five bounds and its resumable cursor already existed
28864
- * and had run in `report` mode for weeks; what it had no way to be was
28865
- * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
28866
- * draining a standing backlog meant flipping a persistent mode, waiting up to
28867
- * six hours, and remembering to flip it back — with nothing returned to look
28868
- * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
28869
- * object events and 4,543 CLIP vectors (D161), and that is not a thing to
28870
- * drain by leaving a switch on.
28871
- *
28872
- * Three properties this action does NOT leave to the caller's care:
28873
- *
28874
- * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
28875
- * logs and returns, and deletes nothing — regardless of what the durable
28876
- * `orphanAuditMode` setting says. `reclaim` is a word that has to be
28877
- * typed. The engine's own default is the same for the same reason.
28878
- * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
28879
- * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
28880
- * the thread between pages — `better-sqlite3` is synchronous and shares
28881
- * hub-main with every live frame.
28882
- * 3. **It answers.** The report comes back per collection with rows AND
28883
- * bytes, so "did it do anything" is not a question anyone has to infer
28884
- * from a log line.
28885
- */
28886
- /**
28887
- * `report` counts; `reclaim` deletes.
28888
- *
28889
- * `.default('report')` is load-bearing, not decoration: the settings form
28890
- * sends `{}` for a button with no fields, and the safe interpretation of an
28891
- * unstated intent here is the one that destroys nothing.
28892
- */
28893
- var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
28894
- var OrphanAuditActionInputSchema = require_dist.object({
28895
- mode: OrphanAuditActionModeSchema,
28896
- /**
28897
- * Start every scope from the top instead of resuming its persisted cursor.
28898
- *
28899
- * What draining a backlog wants, and never the default — the cursor exists
28900
- * so a 6-hourly pass makes progress instead of re-walking the same head.
28901
- */
28902
- restart: require_dist.boolean().optional(),
28903
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
28904
- pageSize: require_dist.number().int().min(50).max(5e3).optional(),
28905
- /** Rows EXAMINED per collection in this pass — the pacing bound. */
28906
- maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
28907
- /** Rows DELETED per collection in this pass — the blast-radius bound. */
28908
- maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
28909
- /**
28910
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
28911
- * exclusively save rows, never select them, which is what keeps this off
28912
- * the one-age-keyed-sweep guard's books.
28913
- */
28914
- graceMinutes: require_dist.number().int().min(1).max(10080).optional()
28915
- });
28916
- var OrphanAuditScopeReportSchema = require_dist.object({
28917
- collection: require_dist.string(),
28918
- examined: require_dist.number(),
28919
- orphans: require_dist.number(),
28920
- reclaimed: require_dist.number(),
28921
- bytesReclaimed: require_dist.number(),
28922
- exempt: require_dist.number(),
28923
- withinGrace: require_dist.number(),
28924
- unresolvable: require_dist.number(),
28925
- stoppedBy: require_dist.string(),
28926
- /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
28927
- nextCursor: require_dist.number()
28928
- });
28929
- var OrphanAuditActionResultSchema = require_dist.object({
28930
- /** `false` when a pass was already in flight — nothing was done. */
28931
- ran: require_dist.boolean(),
28932
- mode: require_dist._enum(["report", "reclaim"]),
28933
- scopes: require_dist.array(OrphanAuditScopeReportSchema),
28934
- totalOrphans: require_dist.number(),
28935
- totalReclaimed: require_dist.number(),
28936
- totalBytesReclaimed: require_dist.number(),
28937
- durationMs: require_dist.number()
28938
- });
28939
- var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
28940
- //#endregion
28941
28889
  //#region src/pipeline-analytics/store/storage-pressure.ts
28942
28890
  /**
28943
28891
  * storage-pressure — make a full disk something the system REACTS to, not just
@@ -29104,6 +29052,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
29104
29052
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
29105
29053
  * of a set of tracks without a `trackId` column on media rows. */
29106
29054
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
29055
+ /** Index rows per page inside one presence chunk. Must stay under
29056
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
29057
+ * silently truncate a page — an owner reported absent because a cap bit would
29058
+ * DELETE a live thumbnail from the surface. */
29059
+ var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
29107
29060
  /**
29108
29061
  * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
29109
29062
  * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
@@ -29531,6 +29484,59 @@ var MediaStore = class {
29531
29484
  });
29532
29485
  }
29533
29486
  /**
29487
+ * Which of `ownerIds` hold at least ONE media row — index only, no blob is
29488
+ * opened, no base64 is allocated.
29489
+ *
29490
+ * This is the licence `videoclips` needs to publish a `thumbnail`. The
29491
+ * event-media URL is stamped from a template for every event in the system,
29492
+ * so "the plane is up" was being served as "there are bytes": 64 of one
29493
+ * camera's 179 clips pointed at a 404 on 2026-08-17.
29494
+ *
29495
+ * Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
29496
+ * that is one query per owner, which is the N+1 this exists to prevent (and
29497
+ * `queryObjectByTrackIds` already records what believing `whereIn` was
29498
+ * unavailable cost the search path).
29499
+ *
29500
+ * `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
29501
+ *
29502
+ * THROWS on a query failure rather than answering partially — a probe that
29503
+ * quietly under-reports reads as "these owners have nothing", and the caller
29504
+ * drops thumbnails that exist.
29505
+ */
29506
+ async ownersWithMedia(ownerKind, ownerIds, deviceId) {
29507
+ const found = /* @__PURE__ */ new Set();
29508
+ const unique = [...new Set(ownerIds)];
29509
+ for (let i = 0; i < unique.length; i += 200) {
29510
+ const chunk = unique.slice(i, i + 200);
29511
+ const answered = /* @__PURE__ */ new Set();
29512
+ for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
29513
+ const rows = await this.store.query.query({
29514
+ collection: MEDIA_COLLECTION,
29515
+ filter: {
29516
+ where: {
29517
+ ownerKind,
29518
+ deviceId
29519
+ },
29520
+ whereIn: { ownerId: chunk },
29521
+ orderBy: {
29522
+ field: "ownerId",
29523
+ direction: "asc"
29524
+ },
29525
+ limit: MEDIA_PRESENCE_PAGE_SIZE,
29526
+ offset
29527
+ }
29528
+ });
29529
+ for (const row of rows) {
29530
+ const ownerId = row.data["ownerId"];
29531
+ if (typeof ownerId === "string") answered.add(ownerId);
29532
+ }
29533
+ if (rows.length < 1e3 || answered.size >= chunk.length) break;
29534
+ }
29535
+ for (const ownerId of answered) found.add(ownerId);
29536
+ }
29537
+ return found;
29538
+ }
29539
+ /**
29534
29540
  * Delete all media (blobs + index rows) for the given owners of one kind.
29535
29541
  * Best-effort per row; returns the number of index rows removed.
29536
29542
  */
@@ -29878,6 +29884,88 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
29878
29884
  };
29879
29885
  }
29880
29886
  //#endregion
29887
+ //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
29888
+ /**
29889
+ * The operator button for the orphan audit.
29890
+ *
29891
+ * The audit engine, its five bounds and its resumable cursor already existed
29892
+ * and had run in `report` mode for weeks; what it had no way to be was
29893
+ * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
29894
+ * draining a standing backlog meant flipping a persistent mode, waiting up to
29895
+ * six hours, and remembering to flip it back — with nothing returned to look
29896
+ * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
29897
+ * object events and 4,543 CLIP vectors (D161), and that is not a thing to
29898
+ * drain by leaving a switch on.
29899
+ *
29900
+ * Three properties this action does NOT leave to the caller's care:
29901
+ *
29902
+ * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
29903
+ * logs and returns, and deletes nothing — regardless of what the durable
29904
+ * `orphanAuditMode` setting says. `reclaim` is a word that has to be
29905
+ * typed. The engine's own default is the same for the same reason.
29906
+ * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
29907
+ * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
29908
+ * the thread between pages — `better-sqlite3` is synchronous and shares
29909
+ * hub-main with every live frame.
29910
+ * 3. **It answers.** The report comes back per collection with rows AND
29911
+ * bytes, so "did it do anything" is not a question anyone has to infer
29912
+ * from a log line.
29913
+ */
29914
+ /**
29915
+ * `report` counts; `reclaim` deletes.
29916
+ *
29917
+ * `.default('report')` is load-bearing, not decoration: the settings form
29918
+ * sends `{}` for a button with no fields, and the safe interpretation of an
29919
+ * unstated intent here is the one that destroys nothing.
29920
+ */
29921
+ var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
29922
+ var OrphanAuditActionInputSchema = require_dist.object({
29923
+ mode: OrphanAuditActionModeSchema,
29924
+ /**
29925
+ * Start every scope from the top instead of resuming its persisted cursor.
29926
+ *
29927
+ * What draining a backlog wants, and never the default — the cursor exists
29928
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
29929
+ */
29930
+ restart: require_dist.boolean().optional(),
29931
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
29932
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
29933
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
29934
+ maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
29935
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
29936
+ maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
29937
+ /**
29938
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
29939
+ * exclusively save rows, never select them, which is what keeps this off
29940
+ * the one-age-keyed-sweep guard's books.
29941
+ */
29942
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
29943
+ });
29944
+ var OrphanAuditScopeReportSchema = require_dist.object({
29945
+ collection: require_dist.string(),
29946
+ examined: require_dist.number(),
29947
+ orphans: require_dist.number(),
29948
+ reclaimed: require_dist.number(),
29949
+ bytesReclaimed: require_dist.number(),
29950
+ exempt: require_dist.number(),
29951
+ withinGrace: require_dist.number(),
29952
+ unresolvable: require_dist.number(),
29953
+ stoppedBy: require_dist.string(),
29954
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
29955
+ nextCursor: require_dist.number()
29956
+ });
29957
+ var OrphanAuditActionResultSchema = require_dist.object({
29958
+ /** `false` when a pass was already in flight — nothing was done. */
29959
+ ran: require_dist.boolean(),
29960
+ mode: require_dist._enum(["report", "reclaim"]),
29961
+ scopes: require_dist.array(OrphanAuditScopeReportSchema),
29962
+ totalOrphans: require_dist.number(),
29963
+ totalReclaimed: require_dist.number(),
29964
+ totalBytesReclaimed: require_dist.number(),
29965
+ durationMs: require_dist.number()
29966
+ });
29967
+ var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
29968
+ //#endregion
29881
29969
  //#region src/pipeline-analytics/videoclip-visits.ts
29882
29970
  /**
29883
29971
  * Videoclip visits — the grain a clip should have on an events-mode camera.
@@ -29944,7 +30032,22 @@ function dominantKind(attached) {
29944
30032
  for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
29945
30033
  return "motion";
29946
30034
  }
29947
- function visitsToClips(visits, events, pad) {
30035
+ /**
30036
+ * The instant a recorded still can actually be decoded inside a visit: the
30037
+ * midpoint of its LONGEST contiguous range.
30038
+ *
30039
+ * NOT the midpoint of the visit. A visit spans its holes by construction (it is
30040
+ * ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
30041
+ * lands in dead air roughly as often as not and the recorder's still route
30042
+ * answers 204 for a visit with plenty of footage.
30043
+ */
30044
+ function stillInstantMs(ranges) {
30045
+ let best;
30046
+ for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
30047
+ if (best === void 0) return void 0;
30048
+ return Math.floor((best.startMs + best.endMs) / 2);
30049
+ }
30050
+ function visitsToClips(visits, events, pad, vouched) {
29948
30051
  const tagged = [
29949
30052
  ...events.object.map((event) => ({
29950
30053
  kind: "object",
@@ -29962,7 +30065,8 @@ function visitsToClips(visits, events, pad) {
29962
30065
  return visits.map((visit) => {
29963
30066
  const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
29964
30067
  const kind = dominantKind(attached);
29965
- const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
30068
+ 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));
30069
+ const stillAtMs = stillInstantMs(visit.ranges);
29966
30070
  return {
29967
30071
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
29968
30072
  source: "analytics",
@@ -29971,8 +30075,10 @@ function visitsToClips(visits, events, pad) {
29971
30075
  startMs: visit.startMs,
29972
30076
  endMs: visit.endMs
29973
30077
  },
29974
- thumbnail: withThumb?.event.mediaUrl,
29975
- eventIds: attached.map((a) => a.event.id),
30078
+ ...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
30079
+ ...stillAtMs !== void 0 ? { stillAtMs } : {},
30080
+ eventIds: attached.slice(0, 24).map((a) => a.event.id),
30081
+ eventCount: attached.length,
29976
30082
  holes: [...visit.holes]
29977
30083
  };
29978
30084
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
@@ -29996,6 +30102,17 @@ function visitsToClips(visits, events, pad) {
29996
30102
  * injected fetch/resolve deps so the compose logic is testable without I/O.
29997
30103
  */
29998
30104
  var SOURCE = "analytics";
30105
+ /**
30106
+ * Backstop on the ANNOTATION read — never on the clip list.
30107
+ *
30108
+ * A visit is annotated by every event inside it, so the clip `limit` must not
30109
+ * reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
30110
+ * bound exists only so a pathological camera-day cannot pull an unbounded page:
30111
+ * the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
30112
+ * the EARLIEST visits silently lose their annotations — which is why hitting it
30113
+ * is logged, per camera, rather than absorbed.
30114
+ */
30115
+ var VISIT_EVENT_FETCH_LIMIT = 5e3;
29999
30116
  function clipIdFor(eventId, startMs, endMs) {
30000
30117
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
30001
30118
  }
@@ -30016,7 +30133,7 @@ function parseClipId(clipId) {
30016
30133
  };
30017
30134
  }
30018
30135
  /** Map per-kind events into time-ranged clips, newest-first. Pure. */
30019
- function eventsToClips(events, pad) {
30136
+ function eventsToClips(events, pad, vouched) {
30020
30137
  const make = (e, kind) => {
30021
30138
  const startMs = e.timestamp - pad.preMs;
30022
30139
  const endMs = e.timestamp + pad.postMs;
@@ -30028,7 +30145,7 @@ function eventsToClips(events, pad) {
30028
30145
  startMs,
30029
30146
  endMs
30030
30147
  },
30031
- thumbnail: e.mediaUrl
30148
+ ...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
30032
30149
  };
30033
30150
  };
30034
30151
  return [
@@ -30037,29 +30154,106 @@ function eventsToClips(events, pad) {
30037
30154
  ...events.audio.map((e) => make(e, "audio"))
30038
30155
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
30039
30156
  }
30157
+ /** Every id in the three buckets, in the order the composers tag them. */
30158
+ function allEventIds(events) {
30159
+ return [
30160
+ ...events.motion,
30161
+ ...events.object,
30162
+ ...events.audio
30163
+ ].map((e) => e.id);
30164
+ }
30165
+ /**
30166
+ * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
30167
+ * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
30168
+ * owning TRACK's. Two batched index reads at most, and the second only over the
30169
+ * object events the first did not already answer for — motion and audio are
30170
+ * trackless roots, so they never need it.
30171
+ *
30172
+ * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
30173
+ * LIST; swallowing here would collapse those two opposite directions into one.
30174
+ */
30175
+ async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
30176
+ const eventIds = allEventIds(events);
30177
+ if (eventIds.length === 0) return /* @__PURE__ */ new Set();
30178
+ const ownMedia = await fetchMediaOwners({
30179
+ deviceId,
30180
+ ownerKind: "event",
30181
+ ownerIds: eventIds
30182
+ });
30183
+ const residualTracks = [];
30184
+ const seenTracks = /* @__PURE__ */ new Set();
30185
+ for (const event of events.object) {
30186
+ const trackId = event.trackId;
30187
+ if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
30188
+ seenTracks.add(trackId);
30189
+ residualTracks.push(trackId);
30190
+ }
30191
+ const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
30192
+ deviceId,
30193
+ ownerKind: "track",
30194
+ ownerIds: residualTracks
30195
+ });
30196
+ const vouched = new Set(ownMedia);
30197
+ for (const event of events.object) {
30198
+ const trackId = event.trackId;
30199
+ if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
30200
+ }
30201
+ return vouched;
30202
+ }
30203
+ /** Keep only clips that actually touch the caller's window. The reads are
30204
+ * widened by {@link leftOverhangMs}; the ANSWER is not. */
30205
+ function intersectsWindow(clip, since, until) {
30206
+ return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
30207
+ }
30208
+ /**
30209
+ * How far BEFORE `since` the event + availability reads must reach.
30210
+ *
30211
+ * A visit straddling `since` keeps its heartbeat rows and loses the transition
30212
+ * row — the only one that ever carries a snapshot — so the same visit shows a
30213
+ * picture or doesn't depending on where the operator scrolled. One wider range
30214
+ * scan on an indexed column buys a list that stops changing under the cursor.
30215
+ */
30216
+ function leftOverhangMs(pad) {
30217
+ return require_dist.VISIT_MERGE_GAP_MS + pad.preMs;
30218
+ }
30040
30219
  function createVideoclipsProvider(deps) {
30041
30220
  return {
30042
30221
  listClips: async ({ deviceId, since, until, limit }) => {
30222
+ const readSince = since - leftOverhangMs(deps.defaultPad);
30043
30223
  const events = await deps.fetchEvents({
30044
30224
  deviceId,
30045
- since,
30225
+ since: readSince,
30046
30226
  until,
30047
- ...deps.fetchAvailability ? {} : { limit }
30227
+ limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
30048
30228
  });
30049
- let clips = eventsToClips(events, deps.defaultPad);
30229
+ warnOnTruncatedFetch(deps, deviceId, events);
30230
+ let vouched = /* @__PURE__ */ new Set();
30231
+ try {
30232
+ vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
30233
+ } catch (err) {
30234
+ deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
30235
+ deviceId,
30236
+ events: allEventIds(events).length,
30237
+ error: err instanceof Error ? err.message : String(err)
30238
+ });
30239
+ }
30240
+ let clips = eventsToClips(events, deps.defaultPad, vouched);
30050
30241
  if (deps.fetchAvailability) try {
30051
30242
  clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
30052
30243
  deviceId,
30053
- since,
30244
+ since: readSince,
30054
30245
  until
30055
- }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad);
30246
+ }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
30056
30247
  } catch (err) {
30057
30248
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
30058
30249
  deviceId,
30059
30250
  error: err instanceof Error ? err.message : String(err)
30060
30251
  });
30061
30252
  }
30062
- return limit != null ? clips.slice(0, limit) : clips;
30253
+ const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
30254
+ const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
30255
+ reportUnvouchedClips(deps, deviceId, answer);
30256
+ return answer;
30063
30257
  },
30064
30258
  getClipPlayback: async ({ deviceId, clipId }) => {
30065
30259
  const parsed = parseClipId(clipId);
@@ -30072,6 +30266,33 @@ function createVideoclipsProvider(deps) {
30072
30266
  }
30073
30267
  };
30074
30268
  }
30269
+ /** The annotation backstop biting is a SILENT loss of the earliest visits'
30270
+ * events — say so, per camera, instead of shipping a thinner list. */
30271
+ function warnOnTruncatedFetch(deps, deviceId, events) {
30272
+ if (!deps.fetchAvailability) return;
30273
+ const truncated = [
30274
+ "motion",
30275
+ "object",
30276
+ "audio"
30277
+ ].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
30278
+ if (truncated.length === 0) return;
30279
+ deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
30280
+ deviceId,
30281
+ kinds: truncated,
30282
+ limit: VISIT_EVENT_FETCH_LIMIT
30283
+ });
30284
+ }
30285
+ /** The operator's acceptance criterion as a number: a rising count on one
30286
+ * camera is its motion-snapshot capturer failing, not a quiet day. */
30287
+ function reportUnvouchedClips(deps, deviceId, clips) {
30288
+ const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
30289
+ if (clipsWithoutStill === 0) return;
30290
+ deps.logInfo?.("videoclips: visit has no vouched still", {
30291
+ deviceId,
30292
+ clipsWithoutStill,
30293
+ clips: clips.length
30294
+ });
30295
+ }
30075
30296
  //#endregion
30076
30297
  //#region src/pipeline-analytics/viewer-settings-actions.ts
30077
30298
  /**
@@ -45918,6 +46139,7 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
45918
46139
  require_dist.EventCategory.WorkerOffline,
45919
46140
  require_dist.EventCategory.PipelineNodeInferenceUnavailable,
45920
46141
  require_dist.EventCategory.PipelineDetectionBlind,
46142
+ require_dist.EventCategory.AddonRunnerFailed,
45921
46143
  require_dist.EventCategory.UpdateAvailable,
45922
46144
  require_dist.EventCategory.AddonUpdated,
45923
46145
  require_dist.EventCategory.SystemRestartCompleted,
@@ -47262,6 +47484,66 @@ var EventMediaDispatcher = class {
47262
47484
  }
47263
47485
  }
47264
47486
  };
47487
+ var SnapshotTimeoutError$1 = class extends Error {
47488
+ constructor(ms) {
47489
+ super(`snapshot cap timed out after ${ms}ms`);
47490
+ this.name = "SnapshotTimeoutError";
47491
+ }
47492
+ };
47493
+ function withTimeout$1(promise, ms) {
47494
+ return new Promise((resolve, reject) => {
47495
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47496
+ promise.then((value) => {
47497
+ clearTimeout(timer);
47498
+ resolve(value);
47499
+ }, (err) => {
47500
+ clearTimeout(timer);
47501
+ reject(err instanceof Error ? err : new Error(String(err)));
47502
+ });
47503
+ });
47504
+ }
47505
+ var MotionEventSnapshotCapturer = class {
47506
+ deps;
47507
+ snapshotTimeoutMs;
47508
+ constructor(deps) {
47509
+ this.deps = deps;
47510
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47511
+ }
47512
+ /**
47513
+ * Fetch a still and persist it as the motion event's snapshot. Never
47514
+ * throws — the durable record is the motion row, already inserted by
47515
+ * the caller.
47516
+ */
47517
+ async capture(input) {
47518
+ let snap;
47519
+ try {
47520
+ snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47521
+ } catch (err) {
47522
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47523
+ return;
47524
+ }
47525
+ if (snap === null) return;
47526
+ const raw = Buffer.from(snap.base64, "base64");
47527
+ let data = raw;
47528
+ try {
47529
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
47530
+ } catch (err) {
47531
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47532
+ }
47533
+ try {
47534
+ await this.deps.media.put({
47535
+ deviceId: input.deviceId,
47536
+ ownerKind: "event",
47537
+ ownerId: input.eventId,
47538
+ kind: "snapshot",
47539
+ timestamp: input.timestamp,
47540
+ data
47541
+ });
47542
+ } catch (err) {
47543
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
47544
+ }
47545
+ }
47546
+ };
47265
47547
  /**
47266
47548
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
47267
47549
  * synthetic track per camera the event belongs to. Returns the number of
@@ -47358,16 +47640,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
47358
47640
  /** A hanging snapshot cap must not stall the sensor state-change path. */
47359
47641
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
47360
47642
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
47361
- var SnapshotTimeoutError$1 = class extends Error {
47643
+ var SnapshotTimeoutError = class extends Error {
47362
47644
  constructor(ms) {
47363
47645
  super(`snapshot cap timed out after ${ms}ms`);
47364
47646
  this.name = "SnapshotTimeoutError";
47365
47647
  }
47366
47648
  };
47367
47649
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
47368
- function withTimeout$1(promise, ms) {
47650
+ function withTimeout(promise, ms) {
47369
47651
  return new Promise((resolve, reject) => {
47370
- const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47652
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47371
47653
  promise.then((value) => {
47372
47654
  clearTimeout(timer);
47373
47655
  resolve(value);
@@ -47422,11 +47704,11 @@ var SyntheticTrackMaterializer = class {
47422
47704
  */
47423
47705
  async fetchSnapshot(cameraId, timeoutMs) {
47424
47706
  try {
47425
- const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47707
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47426
47708
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
47427
47709
  return snap;
47428
47710
  } catch (err) {
47429
- this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47711
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47430
47712
  return null;
47431
47713
  }
47432
47714
  }
@@ -47492,66 +47774,6 @@ var SyntheticTrackMaterializer = class {
47492
47774
  return track;
47493
47775
  }
47494
47776
  };
47495
- var SnapshotTimeoutError = class extends Error {
47496
- constructor(ms) {
47497
- super(`snapshot cap timed out after ${ms}ms`);
47498
- this.name = "SnapshotTimeoutError";
47499
- }
47500
- };
47501
- function withTimeout(promise, ms) {
47502
- return new Promise((resolve, reject) => {
47503
- const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47504
- promise.then((value) => {
47505
- clearTimeout(timer);
47506
- resolve(value);
47507
- }, (err) => {
47508
- clearTimeout(timer);
47509
- reject(err instanceof Error ? err : new Error(String(err)));
47510
- });
47511
- });
47512
- }
47513
- var MotionEventSnapshotCapturer = class {
47514
- deps;
47515
- snapshotTimeoutMs;
47516
- constructor(deps) {
47517
- this.deps = deps;
47518
- this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47519
- }
47520
- /**
47521
- * Fetch a still and persist it as the motion event's snapshot. Never
47522
- * throws — the durable record is the motion row, already inserted by
47523
- * the caller.
47524
- */
47525
- async capture(input) {
47526
- let snap;
47527
- try {
47528
- snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47529
- } catch (err) {
47530
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47531
- return;
47532
- }
47533
- if (snap === null) return;
47534
- const raw = Buffer.from(snap.base64, "base64");
47535
- let data = raw;
47536
- try {
47537
- data = await downscaleFullFrameJpeg(raw, 960, 540);
47538
- } catch (err) {
47539
- this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47540
- }
47541
- try {
47542
- await this.deps.media.put({
47543
- deviceId: input.deviceId,
47544
- ownerKind: "event",
47545
- ownerId: input.eventId,
47546
- kind: "snapshot",
47547
- timestamp: input.timestamp,
47548
- data
47549
- });
47550
- } catch (err) {
47551
- this.deps.onError?.("snapshotMedia", err, input.deviceId);
47552
- }
47553
- }
47554
- };
47555
47777
  var TrackingSettingsSchema = require_dist.object({
47556
47778
  /**
47557
47779
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -53091,6 +53313,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53091
53313
  audio
53092
53314
  };
53093
53315
  },
53316
+ fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
53317
+ return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
53318
+ },
53094
53319
  fetchAvailability: async ({ deviceId, since, until }) => {
53095
53320
  return (await api.recording.getAvailability.query({
53096
53321
  deviceId,
@@ -53102,6 +53327,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53102
53327
  tags: { deviceId: Number(meta["deviceId"]) },
53103
53328
  meta
53104
53329
  }),
53330
+ logInfo: (message, meta) => this.ctx.logger.info(message, {
53331
+ tags: { deviceId: Number(meta["deviceId"]) },
53332
+ meta
53333
+ }),
53105
53334
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
53106
53335
  const manifest = await api.recording.getPlaybackManifest.query({
53107
53336
  deviceId,