@camstack/addon-post-analysis 1.2.91 → 1.2.92

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-BfrtqGyh.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");
@@ -28856,88 +28856,6 @@ var PlateGalleryProvider = class {
28856
28856
  }
28857
28857
  };
28858
28858
  //#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
28859
  //#region src/pipeline-analytics/store/storage-pressure.ts
28942
28860
  /**
28943
28861
  * storage-pressure — make a full disk something the system REACTS to, not just
@@ -29104,6 +29022,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
29104
29022
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
29105
29023
  * of a set of tracks without a `trackId` column on media rows. */
29106
29024
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
29025
+ /** Index rows per page inside one presence chunk. Must stay under
29026
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
29027
+ * silently truncate a page — an owner reported absent because a cap bit would
29028
+ * DELETE a live thumbnail from the surface. */
29029
+ var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
29107
29030
  /**
29108
29031
  * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
29109
29032
  * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
@@ -29531,6 +29454,59 @@ var MediaStore = class {
29531
29454
  });
29532
29455
  }
29533
29456
  /**
29457
+ * Which of `ownerIds` hold at least ONE media row — index only, no blob is
29458
+ * opened, no base64 is allocated.
29459
+ *
29460
+ * This is the licence `videoclips` needs to publish a `thumbnail`. The
29461
+ * event-media URL is stamped from a template for every event in the system,
29462
+ * so "the plane is up" was being served as "there are bytes": 64 of one
29463
+ * camera's 179 clips pointed at a 404 on 2026-08-17.
29464
+ *
29465
+ * Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
29466
+ * that is one query per owner, which is the N+1 this exists to prevent (and
29467
+ * `queryObjectByTrackIds` already records what believing `whereIn` was
29468
+ * unavailable cost the search path).
29469
+ *
29470
+ * `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
29471
+ *
29472
+ * THROWS on a query failure rather than answering partially — a probe that
29473
+ * quietly under-reports reads as "these owners have nothing", and the caller
29474
+ * drops thumbnails that exist.
29475
+ */
29476
+ async ownersWithMedia(ownerKind, ownerIds, deviceId) {
29477
+ const found = /* @__PURE__ */ new Set();
29478
+ const unique = [...new Set(ownerIds)];
29479
+ for (let i = 0; i < unique.length; i += 200) {
29480
+ const chunk = unique.slice(i, i + 200);
29481
+ const answered = /* @__PURE__ */ new Set();
29482
+ for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
29483
+ const rows = await this.store.query.query({
29484
+ collection: MEDIA_COLLECTION,
29485
+ filter: {
29486
+ where: {
29487
+ ownerKind,
29488
+ deviceId
29489
+ },
29490
+ whereIn: { ownerId: chunk },
29491
+ orderBy: {
29492
+ field: "ownerId",
29493
+ direction: "asc"
29494
+ },
29495
+ limit: MEDIA_PRESENCE_PAGE_SIZE,
29496
+ offset
29497
+ }
29498
+ });
29499
+ for (const row of rows) {
29500
+ const ownerId = row.data["ownerId"];
29501
+ if (typeof ownerId === "string") answered.add(ownerId);
29502
+ }
29503
+ if (rows.length < 1e3 || answered.size >= chunk.length) break;
29504
+ }
29505
+ for (const ownerId of answered) found.add(ownerId);
29506
+ }
29507
+ return found;
29508
+ }
29509
+ /**
29534
29510
  * Delete all media (blobs + index rows) for the given owners of one kind.
29535
29511
  * Best-effort per row; returns the number of index rows removed.
29536
29512
  */
@@ -29878,6 +29854,88 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
29878
29854
  };
29879
29855
  }
29880
29856
  //#endregion
29857
+ //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
29858
+ /**
29859
+ * The operator button for the orphan audit.
29860
+ *
29861
+ * The audit engine, its five bounds and its resumable cursor already existed
29862
+ * and had run in `report` mode for weeks; what it had no way to be was
29863
+ * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
29864
+ * draining a standing backlog meant flipping a persistent mode, waiting up to
29865
+ * six hours, and remembering to flip it back — with nothing returned to look
29866
+ * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
29867
+ * object events and 4,543 CLIP vectors (D161), and that is not a thing to
29868
+ * drain by leaving a switch on.
29869
+ *
29870
+ * Three properties this action does NOT leave to the caller's care:
29871
+ *
29872
+ * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
29873
+ * logs and returns, and deletes nothing — regardless of what the durable
29874
+ * `orphanAuditMode` setting says. `reclaim` is a word that has to be
29875
+ * typed. The engine's own default is the same for the same reason.
29876
+ * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
29877
+ * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
29878
+ * the thread between pages — `better-sqlite3` is synchronous and shares
29879
+ * hub-main with every live frame.
29880
+ * 3. **It answers.** The report comes back per collection with rows AND
29881
+ * bytes, so "did it do anything" is not a question anyone has to infer
29882
+ * from a log line.
29883
+ */
29884
+ /**
29885
+ * `report` counts; `reclaim` deletes.
29886
+ *
29887
+ * `.default('report')` is load-bearing, not decoration: the settings form
29888
+ * sends `{}` for a button with no fields, and the safe interpretation of an
29889
+ * unstated intent here is the one that destroys nothing.
29890
+ */
29891
+ var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
29892
+ var OrphanAuditActionInputSchema = require_dist.object({
29893
+ mode: OrphanAuditActionModeSchema,
29894
+ /**
29895
+ * Start every scope from the top instead of resuming its persisted cursor.
29896
+ *
29897
+ * What draining a backlog wants, and never the default — the cursor exists
29898
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
29899
+ */
29900
+ restart: require_dist.boolean().optional(),
29901
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
29902
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
29903
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
29904
+ maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
29905
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
29906
+ maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
29907
+ /**
29908
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
29909
+ * exclusively save rows, never select them, which is what keeps this off
29910
+ * the one-age-keyed-sweep guard's books.
29911
+ */
29912
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
29913
+ });
29914
+ var OrphanAuditScopeReportSchema = require_dist.object({
29915
+ collection: require_dist.string(),
29916
+ examined: require_dist.number(),
29917
+ orphans: require_dist.number(),
29918
+ reclaimed: require_dist.number(),
29919
+ bytesReclaimed: require_dist.number(),
29920
+ exempt: require_dist.number(),
29921
+ withinGrace: require_dist.number(),
29922
+ unresolvable: require_dist.number(),
29923
+ stoppedBy: require_dist.string(),
29924
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
29925
+ nextCursor: require_dist.number()
29926
+ });
29927
+ var OrphanAuditActionResultSchema = require_dist.object({
29928
+ /** `false` when a pass was already in flight — nothing was done. */
29929
+ ran: require_dist.boolean(),
29930
+ mode: require_dist._enum(["report", "reclaim"]),
29931
+ scopes: require_dist.array(OrphanAuditScopeReportSchema),
29932
+ totalOrphans: require_dist.number(),
29933
+ totalReclaimed: require_dist.number(),
29934
+ totalBytesReclaimed: require_dist.number(),
29935
+ durationMs: require_dist.number()
29936
+ });
29937
+ var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
29938
+ //#endregion
29881
29939
  //#region src/pipeline-analytics/videoclip-visits.ts
29882
29940
  /**
29883
29941
  * Videoclip visits — the grain a clip should have on an events-mode camera.
@@ -29944,7 +30002,22 @@ function dominantKind(attached) {
29944
30002
  for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
29945
30003
  return "motion";
29946
30004
  }
29947
- function visitsToClips(visits, events, pad) {
30005
+ /**
30006
+ * The instant a recorded still can actually be decoded inside a visit: the
30007
+ * midpoint of its LONGEST contiguous range.
30008
+ *
30009
+ * NOT the midpoint of the visit. A visit spans its holes by construction (it is
30010
+ * ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
30011
+ * lands in dead air roughly as often as not and the recorder's still route
30012
+ * answers 204 for a visit with plenty of footage.
30013
+ */
30014
+ function stillInstantMs(ranges) {
30015
+ let best;
30016
+ for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
30017
+ if (best === void 0) return void 0;
30018
+ return Math.floor((best.startMs + best.endMs) / 2);
30019
+ }
30020
+ function visitsToClips(visits, events, pad, vouched) {
29948
30021
  const tagged = [
29949
30022
  ...events.object.map((event) => ({
29950
30023
  kind: "object",
@@ -29962,7 +30035,8 @@ function visitsToClips(visits, events, pad) {
29962
30035
  return visits.map((visit) => {
29963
30036
  const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
29964
30037
  const kind = dominantKind(attached);
29965
- const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
30038
+ 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));
30039
+ const stillAtMs = stillInstantMs(visit.ranges);
29966
30040
  return {
29967
30041
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
29968
30042
  source: "analytics",
@@ -29971,8 +30045,10 @@ function visitsToClips(visits, events, pad) {
29971
30045
  startMs: visit.startMs,
29972
30046
  endMs: visit.endMs
29973
30047
  },
29974
- thumbnail: withThumb?.event.mediaUrl,
29975
- eventIds: attached.map((a) => a.event.id),
30048
+ ...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
30049
+ ...stillAtMs !== void 0 ? { stillAtMs } : {},
30050
+ eventIds: attached.slice(0, 24).map((a) => a.event.id),
30051
+ eventCount: attached.length,
29976
30052
  holes: [...visit.holes]
29977
30053
  };
29978
30054
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
@@ -29996,6 +30072,17 @@ function visitsToClips(visits, events, pad) {
29996
30072
  * injected fetch/resolve deps so the compose logic is testable without I/O.
29997
30073
  */
29998
30074
  var SOURCE = "analytics";
30075
+ /**
30076
+ * Backstop on the ANNOTATION read — never on the clip list.
30077
+ *
30078
+ * A visit is annotated by every event inside it, so the clip `limit` must not
30079
+ * reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
30080
+ * bound exists only so a pathological camera-day cannot pull an unbounded page:
30081
+ * the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
30082
+ * the EARLIEST visits silently lose their annotations — which is why hitting it
30083
+ * is logged, per camera, rather than absorbed.
30084
+ */
30085
+ var VISIT_EVENT_FETCH_LIMIT = 5e3;
29999
30086
  function clipIdFor(eventId, startMs, endMs) {
30000
30087
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
30001
30088
  }
@@ -30016,7 +30103,7 @@ function parseClipId(clipId) {
30016
30103
  };
30017
30104
  }
30018
30105
  /** Map per-kind events into time-ranged clips, newest-first. Pure. */
30019
- function eventsToClips(events, pad) {
30106
+ function eventsToClips(events, pad, vouched) {
30020
30107
  const make = (e, kind) => {
30021
30108
  const startMs = e.timestamp - pad.preMs;
30022
30109
  const endMs = e.timestamp + pad.postMs;
@@ -30028,7 +30115,7 @@ function eventsToClips(events, pad) {
30028
30115
  startMs,
30029
30116
  endMs
30030
30117
  },
30031
- thumbnail: e.mediaUrl
30118
+ ...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
30032
30119
  };
30033
30120
  };
30034
30121
  return [
@@ -30037,29 +30124,106 @@ function eventsToClips(events, pad) {
30037
30124
  ...events.audio.map((e) => make(e, "audio"))
30038
30125
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
30039
30126
  }
30127
+ /** Every id in the three buckets, in the order the composers tag them. */
30128
+ function allEventIds(events) {
30129
+ return [
30130
+ ...events.motion,
30131
+ ...events.object,
30132
+ ...events.audio
30133
+ ].map((e) => e.id);
30134
+ }
30135
+ /**
30136
+ * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
30137
+ * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
30138
+ * owning TRACK's. Two batched index reads at most, and the second only over the
30139
+ * object events the first did not already answer for — motion and audio are
30140
+ * trackless roots, so they never need it.
30141
+ *
30142
+ * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
30143
+ * LIST; swallowing here would collapse those two opposite directions into one.
30144
+ */
30145
+ async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
30146
+ const eventIds = allEventIds(events);
30147
+ if (eventIds.length === 0) return /* @__PURE__ */ new Set();
30148
+ const ownMedia = await fetchMediaOwners({
30149
+ deviceId,
30150
+ ownerKind: "event",
30151
+ ownerIds: eventIds
30152
+ });
30153
+ const residualTracks = [];
30154
+ const seenTracks = /* @__PURE__ */ new Set();
30155
+ for (const event of events.object) {
30156
+ const trackId = event.trackId;
30157
+ if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
30158
+ seenTracks.add(trackId);
30159
+ residualTracks.push(trackId);
30160
+ }
30161
+ const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
30162
+ deviceId,
30163
+ ownerKind: "track",
30164
+ ownerIds: residualTracks
30165
+ });
30166
+ const vouched = new Set(ownMedia);
30167
+ for (const event of events.object) {
30168
+ const trackId = event.trackId;
30169
+ if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
30170
+ }
30171
+ return vouched;
30172
+ }
30173
+ /** Keep only clips that actually touch the caller's window. The reads are
30174
+ * widened by {@link leftOverhangMs}; the ANSWER is not. */
30175
+ function intersectsWindow(clip, since, until) {
30176
+ return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
30177
+ }
30178
+ /**
30179
+ * How far BEFORE `since` the event + availability reads must reach.
30180
+ *
30181
+ * A visit straddling `since` keeps its heartbeat rows and loses the transition
30182
+ * row — the only one that ever carries a snapshot — so the same visit shows a
30183
+ * picture or doesn't depending on where the operator scrolled. One wider range
30184
+ * scan on an indexed column buys a list that stops changing under the cursor.
30185
+ */
30186
+ function leftOverhangMs(pad) {
30187
+ return require_dist.VISIT_MERGE_GAP_MS + pad.preMs;
30188
+ }
30040
30189
  function createVideoclipsProvider(deps) {
30041
30190
  return {
30042
30191
  listClips: async ({ deviceId, since, until, limit }) => {
30192
+ const readSince = since - leftOverhangMs(deps.defaultPad);
30043
30193
  const events = await deps.fetchEvents({
30044
30194
  deviceId,
30045
- since,
30195
+ since: readSince,
30046
30196
  until,
30047
- ...deps.fetchAvailability ? {} : { limit }
30197
+ limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
30048
30198
  });
30049
- let clips = eventsToClips(events, deps.defaultPad);
30199
+ warnOnTruncatedFetch(deps, deviceId, events);
30200
+ let vouched = /* @__PURE__ */ new Set();
30201
+ try {
30202
+ vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
30203
+ } catch (err) {
30204
+ deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
30205
+ deviceId,
30206
+ events: allEventIds(events).length,
30207
+ error: err instanceof Error ? err.message : String(err)
30208
+ });
30209
+ }
30210
+ let clips = eventsToClips(events, deps.defaultPad, vouched);
30050
30211
  if (deps.fetchAvailability) try {
30051
30212
  clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
30052
30213
  deviceId,
30053
- since,
30214
+ since: readSince,
30054
30215
  until
30055
- }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad);
30216
+ }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
30056
30217
  } catch (err) {
30057
30218
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
30058
30219
  deviceId,
30059
30220
  error: err instanceof Error ? err.message : String(err)
30060
30221
  });
30061
30222
  }
30062
- return limit != null ? clips.slice(0, limit) : clips;
30223
+ const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
30224
+ const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
30225
+ reportUnvouchedClips(deps, deviceId, answer);
30226
+ return answer;
30063
30227
  },
30064
30228
  getClipPlayback: async ({ deviceId, clipId }) => {
30065
30229
  const parsed = parseClipId(clipId);
@@ -30072,6 +30236,33 @@ function createVideoclipsProvider(deps) {
30072
30236
  }
30073
30237
  };
30074
30238
  }
30239
+ /** The annotation backstop biting is a SILENT loss of the earliest visits'
30240
+ * events — say so, per camera, instead of shipping a thinner list. */
30241
+ function warnOnTruncatedFetch(deps, deviceId, events) {
30242
+ if (!deps.fetchAvailability) return;
30243
+ const truncated = [
30244
+ "motion",
30245
+ "object",
30246
+ "audio"
30247
+ ].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
30248
+ if (truncated.length === 0) return;
30249
+ deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
30250
+ deviceId,
30251
+ kinds: truncated,
30252
+ limit: VISIT_EVENT_FETCH_LIMIT
30253
+ });
30254
+ }
30255
+ /** The operator's acceptance criterion as a number: a rising count on one
30256
+ * camera is its motion-snapshot capturer failing, not a quiet day. */
30257
+ function reportUnvouchedClips(deps, deviceId, clips) {
30258
+ const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
30259
+ if (clipsWithoutStill === 0) return;
30260
+ deps.logInfo?.("videoclips: visit has no vouched still", {
30261
+ deviceId,
30262
+ clipsWithoutStill,
30263
+ clips: clips.length
30264
+ });
30265
+ }
30075
30266
  //#endregion
30076
30267
  //#region src/pipeline-analytics/viewer-settings-actions.ts
30077
30268
  /**
@@ -47262,6 +47453,66 @@ var EventMediaDispatcher = class {
47262
47453
  }
47263
47454
  }
47264
47455
  };
47456
+ var SnapshotTimeoutError$1 = class extends Error {
47457
+ constructor(ms) {
47458
+ super(`snapshot cap timed out after ${ms}ms`);
47459
+ this.name = "SnapshotTimeoutError";
47460
+ }
47461
+ };
47462
+ function withTimeout$1(promise, ms) {
47463
+ return new Promise((resolve, reject) => {
47464
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47465
+ promise.then((value) => {
47466
+ clearTimeout(timer);
47467
+ resolve(value);
47468
+ }, (err) => {
47469
+ clearTimeout(timer);
47470
+ reject(err instanceof Error ? err : new Error(String(err)));
47471
+ });
47472
+ });
47473
+ }
47474
+ var MotionEventSnapshotCapturer = class {
47475
+ deps;
47476
+ snapshotTimeoutMs;
47477
+ constructor(deps) {
47478
+ this.deps = deps;
47479
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47480
+ }
47481
+ /**
47482
+ * Fetch a still and persist it as the motion event's snapshot. Never
47483
+ * throws — the durable record is the motion row, already inserted by
47484
+ * the caller.
47485
+ */
47486
+ async capture(input) {
47487
+ let snap;
47488
+ try {
47489
+ snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47490
+ } catch (err) {
47491
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47492
+ return;
47493
+ }
47494
+ if (snap === null) return;
47495
+ const raw = Buffer.from(snap.base64, "base64");
47496
+ let data = raw;
47497
+ try {
47498
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
47499
+ } catch (err) {
47500
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47501
+ }
47502
+ try {
47503
+ await this.deps.media.put({
47504
+ deviceId: input.deviceId,
47505
+ ownerKind: "event",
47506
+ ownerId: input.eventId,
47507
+ kind: "snapshot",
47508
+ timestamp: input.timestamp,
47509
+ data
47510
+ });
47511
+ } catch (err) {
47512
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
47513
+ }
47514
+ }
47515
+ };
47265
47516
  /**
47266
47517
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
47267
47518
  * synthetic track per camera the event belongs to. Returns the number of
@@ -47358,16 +47609,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
47358
47609
  /** A hanging snapshot cap must not stall the sensor state-change path. */
47359
47610
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
47360
47611
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
47361
- var SnapshotTimeoutError$1 = class extends Error {
47612
+ var SnapshotTimeoutError = class extends Error {
47362
47613
  constructor(ms) {
47363
47614
  super(`snapshot cap timed out after ${ms}ms`);
47364
47615
  this.name = "SnapshotTimeoutError";
47365
47616
  }
47366
47617
  };
47367
47618
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
47368
- function withTimeout$1(promise, ms) {
47619
+ function withTimeout(promise, ms) {
47369
47620
  return new Promise((resolve, reject) => {
47370
- const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47621
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47371
47622
  promise.then((value) => {
47372
47623
  clearTimeout(timer);
47373
47624
  resolve(value);
@@ -47422,11 +47673,11 @@ var SyntheticTrackMaterializer = class {
47422
47673
  */
47423
47674
  async fetchSnapshot(cameraId, timeoutMs) {
47424
47675
  try {
47425
- const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47676
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47426
47677
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
47427
47678
  return snap;
47428
47679
  } catch (err) {
47429
- this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47680
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47430
47681
  return null;
47431
47682
  }
47432
47683
  }
@@ -47492,66 +47743,6 @@ var SyntheticTrackMaterializer = class {
47492
47743
  return track;
47493
47744
  }
47494
47745
  };
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
47746
  var TrackingSettingsSchema = require_dist.object({
47556
47747
  /**
47557
47748
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -53091,6 +53282,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53091
53282
  audio
53092
53283
  };
53093
53284
  },
53285
+ fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
53286
+ return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
53287
+ },
53094
53288
  fetchAvailability: async ({ deviceId, since, until }) => {
53095
53289
  return (await api.recording.getAvailability.query({
53096
53290
  deviceId,
@@ -53102,6 +53296,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53102
53296
  tags: { deviceId: Number(meta["deviceId"]) },
53103
53297
  meta
53104
53298
  }),
53299
+ logInfo: (message, meta) => this.ctx.logger.info(message, {
53300
+ tags: { deviceId: Number(meta["deviceId"]) },
53301
+ meta
53302
+ }),
53105
53303
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
53106
53304
  const manifest = await api.recording.getPlaybackManifest.query({
53107
53305
  deviceId,