@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.
@@ -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 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-VnaTfwAL.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";
@@ -28833,51 +28833,6 @@ var PlateGalleryProvider = class {
28833
28833
  };
28834
28834
  }
28835
28835
  };
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
28836
  //#endregion
28882
28837
  //#region src/pipeline-analytics/store/storage-pressure.ts
28883
28838
  /**
@@ -29045,6 +29000,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
29045
29000
  * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
29046
29001
  * of a set of tracks without a `trackId` column on media rows. */
29047
29002
  var PLATE_MEDIA_OWNER_PREFIX = "plate-";
29003
+ /** Index rows per page inside one presence chunk. Must stay under
29004
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
29005
+ * silently truncate a page — an owner reported absent because a cap bit would
29006
+ * DELETE a live thumbnail from the surface. */
29007
+ var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
29048
29008
  /**
29049
29009
  * Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
29050
29010
  * "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
@@ -29472,6 +29432,59 @@ var MediaStore = class {
29472
29432
  });
29473
29433
  }
29474
29434
  /**
29435
+ * Which of `ownerIds` hold at least ONE media row — index only, no blob is
29436
+ * opened, no base64 is allocated.
29437
+ *
29438
+ * This is the licence `videoclips` needs to publish a `thumbnail`. The
29439
+ * event-media URL is stamped from a template for every event in the system,
29440
+ * so "the plane is up" was being served as "there are bytes": 64 of one
29441
+ * camera's 179 clips pointed at a 404 on 2026-08-17.
29442
+ *
29443
+ * Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
29444
+ * that is one query per owner, which is the N+1 this exists to prevent (and
29445
+ * `queryObjectByTrackIds` already records what believing `whereIn` was
29446
+ * unavailable cost the search path).
29447
+ *
29448
+ * `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
29449
+ *
29450
+ * THROWS on a query failure rather than answering partially — a probe that
29451
+ * quietly under-reports reads as "these owners have nothing", and the caller
29452
+ * drops thumbnails that exist.
29453
+ */
29454
+ async ownersWithMedia(ownerKind, ownerIds, deviceId) {
29455
+ const found = /* @__PURE__ */ new Set();
29456
+ const unique = [...new Set(ownerIds)];
29457
+ for (let i = 0; i < unique.length; i += 200) {
29458
+ const chunk = unique.slice(i, i + 200);
29459
+ const answered = /* @__PURE__ */ new Set();
29460
+ for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
29461
+ const rows = await this.store.query.query({
29462
+ collection: MEDIA_COLLECTION,
29463
+ filter: {
29464
+ where: {
29465
+ ownerKind,
29466
+ deviceId
29467
+ },
29468
+ whereIn: { ownerId: chunk },
29469
+ orderBy: {
29470
+ field: "ownerId",
29471
+ direction: "asc"
29472
+ },
29473
+ limit: MEDIA_PRESENCE_PAGE_SIZE,
29474
+ offset
29475
+ }
29476
+ });
29477
+ for (const row of rows) {
29478
+ const ownerId = row.data["ownerId"];
29479
+ if (typeof ownerId === "string") answered.add(ownerId);
29480
+ }
29481
+ if (rows.length < 1e3 || answered.size >= chunk.length) break;
29482
+ }
29483
+ for (const ownerId of answered) found.add(ownerId);
29484
+ }
29485
+ return found;
29486
+ }
29487
+ /**
29475
29488
  * Delete all media (blobs + index rows) for the given owners of one kind.
29476
29489
  * Best-effort per row; returns the number of index rows removed.
29477
29490
  */
@@ -29818,6 +29831,51 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
29818
29831
  events: doomedEvents.length
29819
29832
  };
29820
29833
  }
29834
+ var OrphanAuditActionInputSchema = object({
29835
+ mode: _enum(["report", "reclaim"]).default("report"),
29836
+ /**
29837
+ * Start every scope from the top instead of resuming its persisted cursor.
29838
+ *
29839
+ * What draining a backlog wants, and never the default — the cursor exists
29840
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
29841
+ */
29842
+ restart: boolean().optional(),
29843
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
29844
+ pageSize: number().int().min(50).max(5e3).optional(),
29845
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
29846
+ maxRowsPerScope: number().int().min(1).max(2e5).optional(),
29847
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
29848
+ maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
29849
+ /**
29850
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
29851
+ * exclusively save rows, never select them, which is what keeps this off
29852
+ * the one-age-keyed-sweep guard's books.
29853
+ */
29854
+ graceMinutes: number().int().min(1).max(10080).optional()
29855
+ });
29856
+ var OrphanAuditScopeReportSchema = object({
29857
+ collection: string(),
29858
+ examined: number(),
29859
+ orphans: number(),
29860
+ reclaimed: number(),
29861
+ bytesReclaimed: number(),
29862
+ exempt: number(),
29863
+ withinGrace: number(),
29864
+ unresolvable: number(),
29865
+ stoppedBy: string(),
29866
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
29867
+ nextCursor: number()
29868
+ });
29869
+ var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
29870
+ /** `false` when a pass was already in flight — nothing was done. */
29871
+ ran: boolean(),
29872
+ mode: _enum(["report", "reclaim"]),
29873
+ scopes: array(OrphanAuditScopeReportSchema),
29874
+ totalOrphans: number(),
29875
+ totalReclaimed: number(),
29876
+ totalBytesReclaimed: number(),
29877
+ durationMs: number()
29878
+ }), { auth: "admin" }) });
29821
29879
  //#endregion
29822
29880
  //#region src/pipeline-analytics/videoclip-visits.ts
29823
29881
  /**
@@ -29885,7 +29943,22 @@ function dominantKind(attached) {
29885
29943
  for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
29886
29944
  return "motion";
29887
29945
  }
29888
- function visitsToClips(visits, events, pad) {
29946
+ /**
29947
+ * The instant a recorded still can actually be decoded inside a visit: the
29948
+ * midpoint of its LONGEST contiguous range.
29949
+ *
29950
+ * NOT the midpoint of the visit. A visit spans its holes by construction (it is
29951
+ * ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
29952
+ * lands in dead air roughly as often as not and the recorder's still route
29953
+ * answers 204 for a visit with plenty of footage.
29954
+ */
29955
+ function stillInstantMs(ranges) {
29956
+ let best;
29957
+ for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
29958
+ if (best === void 0) return void 0;
29959
+ return Math.floor((best.startMs + best.endMs) / 2);
29960
+ }
29961
+ function visitsToClips(visits, events, pad, vouched) {
29889
29962
  const tagged = [
29890
29963
  ...events.object.map((event) => ({
29891
29964
  kind: "object",
@@ -29903,7 +29976,8 @@ function visitsToClips(visits, events, pad) {
29903
29976
  return visits.map((visit) => {
29904
29977
  const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
29905
29978
  const kind = dominantKind(attached);
29906
- const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
29979
+ 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));
29980
+ const stillAtMs = stillInstantMs(visit.ranges);
29907
29981
  return {
29908
29982
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
29909
29983
  source: "analytics",
@@ -29912,8 +29986,10 @@ function visitsToClips(visits, events, pad) {
29912
29986
  startMs: visit.startMs,
29913
29987
  endMs: visit.endMs
29914
29988
  },
29915
- thumbnail: withThumb?.event.mediaUrl,
29916
- eventIds: attached.map((a) => a.event.id),
29989
+ ...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
29990
+ ...stillAtMs !== void 0 ? { stillAtMs } : {},
29991
+ eventIds: attached.slice(0, 24).map((a) => a.event.id),
29992
+ eventCount: attached.length,
29917
29993
  holes: [...visit.holes]
29918
29994
  };
29919
29995
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
@@ -29937,6 +30013,17 @@ function visitsToClips(visits, events, pad) {
29937
30013
  * injected fetch/resolve deps so the compose logic is testable without I/O.
29938
30014
  */
29939
30015
  var SOURCE = "analytics";
30016
+ /**
30017
+ * Backstop on the ANNOTATION read — never on the clip list.
30018
+ *
30019
+ * A visit is annotated by every event inside it, so the clip `limit` must not
30020
+ * reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
30021
+ * bound exists only so a pathological camera-day cannot pull an unbounded page:
30022
+ * the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
30023
+ * the EARLIEST visits silently lose their annotations — which is why hitting it
30024
+ * is logged, per camera, rather than absorbed.
30025
+ */
30026
+ var VISIT_EVENT_FETCH_LIMIT = 5e3;
29940
30027
  function clipIdFor(eventId, startMs, endMs) {
29941
30028
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
29942
30029
  }
@@ -29957,7 +30044,7 @@ function parseClipId(clipId) {
29957
30044
  };
29958
30045
  }
29959
30046
  /** Map per-kind events into time-ranged clips, newest-first. Pure. */
29960
- function eventsToClips(events, pad) {
30047
+ function eventsToClips(events, pad, vouched) {
29961
30048
  const make = (e, kind) => {
29962
30049
  const startMs = e.timestamp - pad.preMs;
29963
30050
  const endMs = e.timestamp + pad.postMs;
@@ -29969,7 +30056,7 @@ function eventsToClips(events, pad) {
29969
30056
  startMs,
29970
30057
  endMs
29971
30058
  },
29972
- thumbnail: e.mediaUrl
30059
+ ...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
29973
30060
  };
29974
30061
  };
29975
30062
  return [
@@ -29978,29 +30065,106 @@ function eventsToClips(events, pad) {
29978
30065
  ...events.audio.map((e) => make(e, "audio"))
29979
30066
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
29980
30067
  }
30068
+ /** Every id in the three buckets, in the order the composers tag them. */
30069
+ function allEventIds(events) {
30070
+ return [
30071
+ ...events.motion,
30072
+ ...events.object,
30073
+ ...events.audio
30074
+ ].map((e) => e.id);
30075
+ }
30076
+ /**
30077
+ * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
30078
+ * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
30079
+ * owning TRACK's. Two batched index reads at most, and the second only over the
30080
+ * object events the first did not already answer for — motion and audio are
30081
+ * trackless roots, so they never need it.
30082
+ *
30083
+ * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
30084
+ * LIST; swallowing here would collapse those two opposite directions into one.
30085
+ */
30086
+ async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
30087
+ const eventIds = allEventIds(events);
30088
+ if (eventIds.length === 0) return /* @__PURE__ */ new Set();
30089
+ const ownMedia = await fetchMediaOwners({
30090
+ deviceId,
30091
+ ownerKind: "event",
30092
+ ownerIds: eventIds
30093
+ });
30094
+ const residualTracks = [];
30095
+ const seenTracks = /* @__PURE__ */ new Set();
30096
+ for (const event of events.object) {
30097
+ const trackId = event.trackId;
30098
+ if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
30099
+ seenTracks.add(trackId);
30100
+ residualTracks.push(trackId);
30101
+ }
30102
+ const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
30103
+ deviceId,
30104
+ ownerKind: "track",
30105
+ ownerIds: residualTracks
30106
+ });
30107
+ const vouched = new Set(ownMedia);
30108
+ for (const event of events.object) {
30109
+ const trackId = event.trackId;
30110
+ if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
30111
+ }
30112
+ return vouched;
30113
+ }
30114
+ /** Keep only clips that actually touch the caller's window. The reads are
30115
+ * widened by {@link leftOverhangMs}; the ANSWER is not. */
30116
+ function intersectsWindow(clip, since, until) {
30117
+ return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
30118
+ }
30119
+ /**
30120
+ * How far BEFORE `since` the event + availability reads must reach.
30121
+ *
30122
+ * A visit straddling `since` keeps its heartbeat rows and loses the transition
30123
+ * row — the only one that ever carries a snapshot — so the same visit shows a
30124
+ * picture or doesn't depending on where the operator scrolled. One wider range
30125
+ * scan on an indexed column buys a list that stops changing under the cursor.
30126
+ */
30127
+ function leftOverhangMs(pad) {
30128
+ return VISIT_MERGE_GAP_MS + pad.preMs;
30129
+ }
29981
30130
  function createVideoclipsProvider(deps) {
29982
30131
  return {
29983
30132
  listClips: async ({ deviceId, since, until, limit }) => {
30133
+ const readSince = since - leftOverhangMs(deps.defaultPad);
29984
30134
  const events = await deps.fetchEvents({
29985
30135
  deviceId,
29986
- since,
30136
+ since: readSince,
29987
30137
  until,
29988
- ...deps.fetchAvailability ? {} : { limit }
30138
+ limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
29989
30139
  });
29990
- let clips = eventsToClips(events, deps.defaultPad);
30140
+ warnOnTruncatedFetch(deps, deviceId, events);
30141
+ let vouched = /* @__PURE__ */ new Set();
30142
+ try {
30143
+ vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
30144
+ } catch (err) {
30145
+ deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
30146
+ deviceId,
30147
+ events: allEventIds(events).length,
30148
+ error: err instanceof Error ? err.message : String(err)
30149
+ });
30150
+ }
30151
+ let clips = eventsToClips(events, deps.defaultPad, vouched);
29991
30152
  if (deps.fetchAvailability) try {
29992
30153
  clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
29993
30154
  deviceId,
29994
- since,
30155
+ since: readSince,
29995
30156
  until
29996
- }), VISIT_MERGE_GAP_MS), events, deps.defaultPad);
30157
+ }), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
29997
30158
  } catch (err) {
29998
30159
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
29999
30160
  deviceId,
30000
30161
  error: err instanceof Error ? err.message : String(err)
30001
30162
  });
30002
30163
  }
30003
- return limit != null ? clips.slice(0, limit) : clips;
30164
+ const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
30165
+ const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
30166
+ reportUnvouchedClips(deps, deviceId, answer);
30167
+ return answer;
30004
30168
  },
30005
30169
  getClipPlayback: async ({ deviceId, clipId }) => {
30006
30170
  const parsed = parseClipId(clipId);
@@ -30013,6 +30177,33 @@ function createVideoclipsProvider(deps) {
30013
30177
  }
30014
30178
  };
30015
30179
  }
30180
+ /** The annotation backstop biting is a SILENT loss of the earliest visits'
30181
+ * events — say so, per camera, instead of shipping a thinner list. */
30182
+ function warnOnTruncatedFetch(deps, deviceId, events) {
30183
+ if (!deps.fetchAvailability) return;
30184
+ const truncated = [
30185
+ "motion",
30186
+ "object",
30187
+ "audio"
30188
+ ].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
30189
+ if (truncated.length === 0) return;
30190
+ deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
30191
+ deviceId,
30192
+ kinds: truncated,
30193
+ limit: VISIT_EVENT_FETCH_LIMIT
30194
+ });
30195
+ }
30196
+ /** The operator's acceptance criterion as a number: a rising count on one
30197
+ * camera is its motion-snapshot capturer failing, not a quiet day. */
30198
+ function reportUnvouchedClips(deps, deviceId, clips) {
30199
+ const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
30200
+ if (clipsWithoutStill === 0) return;
30201
+ deps.logInfo?.("videoclips: visit has no vouched still", {
30202
+ deviceId,
30203
+ clipsWithoutStill,
30204
+ clips: clips.length
30205
+ });
30206
+ }
30016
30207
  //#endregion
30017
30208
  //#region src/pipeline-analytics/viewer-settings-actions.ts
30018
30209
  /**
@@ -47202,6 +47393,66 @@ var EventMediaDispatcher = class {
47202
47393
  }
47203
47394
  }
47204
47395
  };
47396
+ var SnapshotTimeoutError$1 = class extends Error {
47397
+ constructor(ms) {
47398
+ super(`snapshot cap timed out after ${ms}ms`);
47399
+ this.name = "SnapshotTimeoutError";
47400
+ }
47401
+ };
47402
+ function withTimeout$1(promise, ms) {
47403
+ return new Promise((resolve, reject) => {
47404
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47405
+ promise.then((value) => {
47406
+ clearTimeout(timer);
47407
+ resolve(value);
47408
+ }, (err) => {
47409
+ clearTimeout(timer);
47410
+ reject(err instanceof Error ? err : new Error(String(err)));
47411
+ });
47412
+ });
47413
+ }
47414
+ var MotionEventSnapshotCapturer = class {
47415
+ deps;
47416
+ snapshotTimeoutMs;
47417
+ constructor(deps) {
47418
+ this.deps = deps;
47419
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
47420
+ }
47421
+ /**
47422
+ * Fetch a still and persist it as the motion event's snapshot. Never
47423
+ * throws — the durable record is the motion row, already inserted by
47424
+ * the caller.
47425
+ */
47426
+ async capture(input) {
47427
+ let snap;
47428
+ try {
47429
+ snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
47430
+ } catch (err) {
47431
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
47432
+ return;
47433
+ }
47434
+ if (snap === null) return;
47435
+ const raw = Buffer.from(snap.base64, "base64");
47436
+ let data = raw;
47437
+ try {
47438
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
47439
+ } catch (err) {
47440
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
47441
+ }
47442
+ try {
47443
+ await this.deps.media.put({
47444
+ deviceId: input.deviceId,
47445
+ ownerKind: "event",
47446
+ ownerId: input.eventId,
47447
+ kind: "snapshot",
47448
+ timestamp: input.timestamp,
47449
+ data
47450
+ });
47451
+ } catch (err) {
47452
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
47453
+ }
47454
+ }
47455
+ };
47205
47456
  /**
47206
47457
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
47207
47458
  * synthetic track per camera the event belongs to. Returns the number of
@@ -47298,16 +47549,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
47298
47549
  /** A hanging snapshot cap must not stall the sensor state-change path. */
47299
47550
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
47300
47551
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
47301
- var SnapshotTimeoutError$1 = class extends Error {
47552
+ var SnapshotTimeoutError = class extends Error {
47302
47553
  constructor(ms) {
47303
47554
  super(`snapshot cap timed out after ${ms}ms`);
47304
47555
  this.name = "SnapshotTimeoutError";
47305
47556
  }
47306
47557
  };
47307
47558
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
47308
- function withTimeout$1(promise, ms) {
47559
+ function withTimeout(promise, ms) {
47309
47560
  return new Promise((resolve, reject) => {
47310
- const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
47561
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
47311
47562
  promise.then((value) => {
47312
47563
  clearTimeout(timer);
47313
47564
  resolve(value);
@@ -47362,11 +47613,11 @@ var SyntheticTrackMaterializer = class {
47362
47613
  */
47363
47614
  async fetchSnapshot(cameraId, timeoutMs) {
47364
47615
  try {
47365
- const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47616
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
47366
47617
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
47367
47618
  return snap;
47368
47619
  } catch (err) {
47369
- this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47620
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
47370
47621
  return null;
47371
47622
  }
47372
47623
  }
@@ -47432,66 +47683,6 @@ var SyntheticTrackMaterializer = class {
47432
47683
  return track;
47433
47684
  }
47434
47685
  };
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
47686
  var TrackingSettingsSchema = object({
47496
47687
  /**
47497
47688
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -53031,6 +53222,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53031
53222
  audio
53032
53223
  };
53033
53224
  },
53225
+ fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
53226
+ return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
53227
+ },
53034
53228
  fetchAvailability: async ({ deviceId, since, until }) => {
53035
53229
  return (await api.recording.getAvailability.query({
53036
53230
  deviceId,
@@ -53042,6 +53236,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53042
53236
  tags: { deviceId: Number(meta["deviceId"]) },
53043
53237
  meta
53044
53238
  }),
53239
+ logInfo: (message, meta) => this.ctx.logger.info(message, {
53240
+ tags: { deviceId: Number(meta["deviceId"]) },
53241
+ meta
53242
+ }),
53045
53243
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
53046
53244
  const manifest = await api.recording.getPlaybackManifest.query({
53047
53245
  deviceId,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Q3EjHtct.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BIDGLXRs.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.91",
3
+ "version": "1.2.92",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",