@camstack/addon-post-analysis 1.2.85 → 1.2.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5715,7 +5715,7 @@ var NcAudioStillShelf = class {
5715
5715
  }
5716
5716
  async fetch(captureId, deviceId, atMs) {
5717
5717
  try {
5718
- const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5718
+ const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5719
5719
  if (shot === null || shot.base64.length === 0) {
5720
5720
  this.reportMiss(deviceId, "the camera returned no snapshot");
5721
5721
  return;
@@ -5781,7 +5781,7 @@ function audioStillMedia(data, timestamp) {
5781
5781
  ];
5782
5782
  }
5783
5783
  /** Reject if `promise` does not settle within `ms`. */
5784
- function withTimeout$2(promise, ms) {
5784
+ function withTimeout$3(promise, ms) {
5785
5785
  return new Promise((resolve, reject) => {
5786
5786
  const timer = setTimeout(() => {
5787
5787
  reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
@@ -30485,7 +30485,7 @@ var undecided = (candidate, reason) => ({
30485
30485
  reason,
30486
30486
  className: candidate.className
30487
30487
  });
30488
- function withTimeout$1(promise, timeoutMs) {
30488
+ function withTimeout$2(promise, timeoutMs) {
30489
30489
  return new Promise((resolve, reject) => {
30490
30490
  const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("confirmation-timeout")), timeoutMs);
30491
30491
  promise.then((value) => {
@@ -30525,7 +30525,7 @@ async function confirmOne(candidate, config, deps, exhausted) {
30525
30525
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
30526
30526
  if (config.minCropPx > 0 && cropPx < config.minCropPx) return undecided(candidate, "below-min-crop");
30527
30527
  try {
30528
- return await withTimeout$1(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
30528
+ return await withTimeout$2(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
30529
30529
  } catch {
30530
30530
  return undecided(candidate, "timeout");
30531
30531
  }
@@ -34772,7 +34772,8 @@ function isMediaOwnerKind(value) {
34772
34772
  * write="one row per blob written through put / putReplacing — a track's
34773
34773
  * keyFrame, thumbnail, firstFrame and lastFrame (UPSERTED on a deterministic
34774
34774
  * id, so one per track+kind however many captures race), its periodic
34775
- * snapshots, each event's crop and full frames, and the buffered face/plate
34775
+ * snapshots, each event's crop and full frames, a motion visit's still
34776
+ * (`kind: snapshot` on ownerKind event), and the buffered face/plate
34776
34777
  * crops. A row is also REOWNED here (not rewritten) when the operator enrols a
34777
34778
  * face or a plate, which is what moves it out of retention's reach."
34778
34779
  * retention="no age sweep of its own — MediaStore.evictBefore was deleted (D59)
@@ -39078,11 +39079,14 @@ function tieredLabelColumnData(patch) {
39078
39079
  * (MOTION_EVENT_HEARTBEAT_MS) while it stays on. The analyzer path and the
39079
39080
  * onboard-firmware path share that throttle, so a camera cannot double-count.
39080
39081
  * Measured at ~7,200 rows/day/camera."
39081
- * retention="a TERMINAL age-keyed root — no trackId, no media, nothing references
39082
- * it, so the track cascade can never reach it. Deleted by
39082
+ * retention="a TERMINAL age-keyed root — no trackId. A motion row may own a
39083
+ * still (`ownerKind: 'event'`, `kind: 'snapshot'`) captured on the off→on
39084
+ * visit so events-mode recordings have a videoclip thumbnail. Deleted by
39083
39085
  * EventStore.evictTracklessBefore on the DEVICE's one retention window
39084
- * (follow-recordings horizon, or trackRetentionDays, default 7). 0 days = keep
39085
- * forever, and this table is the reason that setting is dangerous."
39086
+ * (follow-recordings horizon, or trackRetentionDays, default 7), which
39087
+ * folds in MediaStore.deleteForEvents so the still leaves with the row.
39088
+ * 0 days = keep forever, and this table is the reason that setting is
39089
+ * dangerous."
39086
39090
  */
39087
39091
  var MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
39088
39092
  /**
@@ -39811,9 +39815,10 @@ var EventStore = class {
39811
39815
  * Object events now leave exactly one way: with their track, through
39812
39816
  * {@link deleteByTracks} (D61).
39813
39817
  *
39814
- * Motion and audio carry no `trackId`, own no media and are referenced by
39815
- * nothing — they are terminal retention ROOTS, so an age window on them
39816
- * cannot contradict anybody's (`retention/retention-model.ts`).
39818
+ * Motion and audio carry no `trackId` and are referenced by nothing — they
39819
+ * are terminal retention ROOTS, so an age window on them cannot contradict
39820
+ * anybody's (`retention/retention-model.ts`). A motion visit still is
39821
+ * event-owned media of that row and is deleted here in lockstep.
39817
39822
  *
39818
39823
  * **ONE cutoff for both kinds** (D62). They used to take a window each
39819
39824
  * (`retentionMotionDays` / `retentionAudioDays`), which is two clocks over
@@ -39830,9 +39835,17 @@ var EventStore = class {
39830
39835
  ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
39831
39836
  whereBetween: { timestamp: [0, params.cutoffMs] }
39832
39837
  });
39838
+ const motion = await batch(MOTION_EVENTS_COLLECTION);
39839
+ const audio = await batch(AUDIO_EVENTS_COLLECTION);
39840
+ const ids = [...motion, ...audio];
39841
+ if (ids.length > 0 && this.media !== void 0) try {
39842
+ await this.media.deleteForEvents(ids);
39843
+ } catch (err) {
39844
+ this.logger.warn("EventStore.evictTracklessBefore: event media delete failed", { meta: { error: String(err) } });
39845
+ }
39833
39846
  return {
39834
- motion: await batch(MOTION_EVENTS_COLLECTION),
39835
- audio: await batch(AUDIO_EVENTS_COLLECTION)
39847
+ motion,
39848
+ audio
39836
39849
  };
39837
39850
  }
39838
39851
  /**
@@ -39857,23 +39870,32 @@ var EventStore = class {
39857
39870
  * The SUBSET of `eventIds` that still exist — the orphan audit's liveness
39858
39871
  * probe for event-owned media.
39859
39872
  *
39873
+ * Motion and audio rows can own stills (`kind: 'snapshot'` on a motion
39874
+ * visit); probing only the object collection would classify those stills
39875
+ * as orphans the moment they were written.
39876
+ *
39860
39877
  * THROWS on a query failure rather than answering partially. A probe that
39861
39878
  * quietly under-reports here reads as "these events are gone", and the caller
39862
39879
  * deletes live crops on the strength of it.
39863
39880
  */
39864
39881
  async existingEventIds(eventIds) {
39865
39882
  const found = /* @__PURE__ */ new Set();
39883
+ const collections = [
39884
+ OBJECT_EVENTS_COLLECTION,
39885
+ MOTION_EVENTS_COLLECTION,
39886
+ AUDIO_EVENTS_COLLECTION
39887
+ ];
39866
39888
  for (let i = 0; i < eventIds.length; i += 500) {
39867
39889
  const chunk = eventIds.slice(i, i + 500);
39868
39890
  if (chunk.length === 0) continue;
39869
- const rows = await this.store.query.query({
39870
- collection: OBJECT_EVENTS_COLLECTION,
39891
+ const pages = await Promise.all(collections.map((collection) => this.store.query.query({
39892
+ collection,
39871
39893
  filter: {
39872
39894
  whereIn: { id: [...chunk] },
39873
39895
  limit: chunk.length
39874
39896
  }
39875
- });
39876
- for (const row of rows) found.add(row.id);
39897
+ })));
39898
+ for (const rows of pages) for (const row of rows) found.add(row.id);
39877
39899
  }
39878
39900
  return found;
39879
39901
  }
@@ -46731,16 +46753,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
46731
46753
  /** A hanging snapshot cap must not stall the sensor state-change path. */
46732
46754
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
46733
46755
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
46734
- var SnapshotTimeoutError = class extends Error {
46756
+ var SnapshotTimeoutError$1 = class extends Error {
46735
46757
  constructor(ms) {
46736
46758
  super(`snapshot cap timed out after ${ms}ms`);
46737
46759
  this.name = "SnapshotTimeoutError";
46738
46760
  }
46739
46761
  };
46740
46762
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
46741
- function withTimeout(promise, ms) {
46763
+ function withTimeout$1(promise, ms) {
46742
46764
  return new Promise((resolve, reject) => {
46743
- const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46765
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
46744
46766
  promise.then((value) => {
46745
46767
  clearTimeout(timer);
46746
46768
  resolve(value);
@@ -46795,11 +46817,11 @@ var SyntheticTrackMaterializer = class {
46795
46817
  */
46796
46818
  async fetchSnapshot(cameraId, timeoutMs) {
46797
46819
  try {
46798
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46820
+ const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46799
46821
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
46800
46822
  return snap;
46801
46823
  } catch (err) {
46802
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46824
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46803
46825
  return null;
46804
46826
  }
46805
46827
  }
@@ -46865,6 +46887,66 @@ var SyntheticTrackMaterializer = class {
46865
46887
  return track;
46866
46888
  }
46867
46889
  };
46890
+ var SnapshotTimeoutError = class extends Error {
46891
+ constructor(ms) {
46892
+ super(`snapshot cap timed out after ${ms}ms`);
46893
+ this.name = "SnapshotTimeoutError";
46894
+ }
46895
+ };
46896
+ function withTimeout(promise, ms) {
46897
+ return new Promise((resolve, reject) => {
46898
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46899
+ promise.then((value) => {
46900
+ clearTimeout(timer);
46901
+ resolve(value);
46902
+ }, (err) => {
46903
+ clearTimeout(timer);
46904
+ reject(err instanceof Error ? err : new Error(String(err)));
46905
+ });
46906
+ });
46907
+ }
46908
+ var MotionEventSnapshotCapturer = class {
46909
+ deps;
46910
+ snapshotTimeoutMs;
46911
+ constructor(deps) {
46912
+ this.deps = deps;
46913
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
46914
+ }
46915
+ /**
46916
+ * Fetch a still and persist it as the motion event's snapshot. Never
46917
+ * throws — the durable record is the motion row, already inserted by
46918
+ * the caller.
46919
+ */
46920
+ async capture(input) {
46921
+ let snap;
46922
+ try {
46923
+ snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
46924
+ } catch (err) {
46925
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
46926
+ return;
46927
+ }
46928
+ if (snap === null) return;
46929
+ const raw = Buffer.from(snap.base64, "base64");
46930
+ let data = raw;
46931
+ try {
46932
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
46933
+ } catch (err) {
46934
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
46935
+ }
46936
+ try {
46937
+ await this.deps.media.put({
46938
+ deviceId: input.deviceId,
46939
+ ownerKind: "event",
46940
+ ownerId: input.eventId,
46941
+ kind: "snapshot",
46942
+ timestamp: input.timestamp,
46943
+ data
46944
+ });
46945
+ } catch (err) {
46946
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
46947
+ }
46948
+ }
46949
+ };
46868
46950
  var TrackingSettingsSchema = require_dist.object({
46869
46951
  /**
46870
46952
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -49835,6 +49917,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
49835
49917
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
49836
49918
  * tracks — linked sensor/control state changes AND audio markers. */
49837
49919
  syntheticTracks = null;
49920
+ /** Still for a motion row that has no track (events-mode recording without
49921
+ * a detection). Lands as event-owned `kind: 'snapshot'` so videoclip
49922
+ * thumbnails resolve through the existing event-media plane. */
49923
+ motionEventSnapshots = null;
49838
49924
  /** Decides which audio frames deserve a synthetic marker (D62). */
49839
49925
  audioMarkers = null;
49840
49926
  faceStore = null;
@@ -51028,6 +51114,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
51028
51114
  } })
51029
51115
  });
51030
51116
  this.syntheticTracks = syntheticTracks;
51117
+ this.motionEventSnapshots = new MotionEventSnapshotCapturer({
51118
+ snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
51119
+ media: mediaStore,
51120
+ onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
51121
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
51122
+ meta: {
51123
+ scope,
51124
+ error: require_dist.errMsg(err)
51125
+ }
51126
+ })
51127
+ });
51031
51128
  this.audioMarkers = new AudioMarkerProjector({
51032
51129
  sink: syntheticTracks,
51033
51130
  isMotionActive: (deviceId, atMs, quietMs) => this.isMotionActive(deviceId, atMs, quietMs),
@@ -54098,7 +54195,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54098
54195
  const last = this.lastMotionInsertByDevice.get(deviceId);
54099
54196
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
54100
54197
  const transitionedOn = detected && (last === void 0 || last.detected === false);
54101
- if (!detected) return;
54198
+ if (!detected) {
54199
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54200
+ detected: false,
54201
+ atMs: timestamp
54202
+ });
54203
+ return;
54204
+ }
54102
54205
  if (!transitionedOn && !heartbeatDue) return;
54103
54206
  const ev = {
54104
54207
  id: (0, node_crypto.randomUUID)(),
@@ -54141,6 +54244,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54141
54244
  timestamp: ev.timestamp
54142
54245
  }
54143
54246
  });
54247
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54248
+ deviceId,
54249
+ eventId: ev.id,
54250
+ timestamp: ev.timestamp
54251
+ });
54144
54252
  }
54145
54253
  /**
54146
54254
  * Persist firmware-driven (onboard) motion events. Mirrors
@@ -54165,7 +54273,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54165
54273
  const last = this.lastMotionInsertByDevice.get(deviceId);
54166
54274
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
54167
54275
  const transitionedOn = detected && (last === void 0 || last.detected === false);
54168
- if (!detected) return;
54276
+ if (!detected) {
54277
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54278
+ detected: false,
54279
+ atMs: timestamp
54280
+ });
54281
+ return;
54282
+ }
54169
54283
  if (!transitionedOn && !heartbeatDue) return;
54170
54284
  const regions = payload.regions ?? [];
54171
54285
  const ev = {
@@ -54209,6 +54323,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54209
54323
  timestamp: ev.timestamp
54210
54324
  }
54211
54325
  });
54326
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54327
+ deviceId,
54328
+ eventId: ev.id,
54329
+ timestamp: ev.timestamp
54330
+ });
54212
54331
  }
54213
54332
  async sweepExpiredTracks() {
54214
54333
  if (this.shuttingDown || !this.trackStore) return;
@@ -54277,8 +54396,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54277
54396
  *
54278
54397
  * 1. the track sweep, per device, cascading;
54279
54398
  * 2. the TRACKLESS roots — motion, audio and sensor events, which carry no
54280
- * trackId, own nothing and are referenced by nothing, so the cascade can
54281
- * never reach them and a clock is the only thing that can.
54399
+ * trackId and are referenced by nothing, so the cascade can never reach
54400
+ * them and a clock is the only thing that can. Motion visit stills leave
54401
+ * with their row inside `evictTracklessBefore`.
54282
54402
  *
54283
54403
  * That clock used to be three more settings (`retentionMotionDays` /
54284
54404
  * `retentionObjectDays` / `retentionAudioDays`). It is now the device's own
@@ -5708,7 +5708,7 @@ var NcAudioStillShelf = class {
5708
5708
  }
5709
5709
  async fetch(captureId, deviceId, atMs) {
5710
5710
  try {
5711
- const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5711
+ const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5712
5712
  if (shot === null || shot.base64.length === 0) {
5713
5713
  this.reportMiss(deviceId, "the camera returned no snapshot");
5714
5714
  return;
@@ -5774,7 +5774,7 @@ function audioStillMedia(data, timestamp) {
5774
5774
  ];
5775
5775
  }
5776
5776
  /** Reject if `promise` does not settle within `ms`. */
5777
- function withTimeout$2(promise, ms) {
5777
+ function withTimeout$3(promise, ms) {
5778
5778
  return new Promise((resolve, reject) => {
5779
5779
  const timer = setTimeout(() => {
5780
5780
  reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
@@ -30426,7 +30426,7 @@ var undecided = (candidate, reason) => ({
30426
30426
  reason,
30427
30427
  className: candidate.className
30428
30428
  });
30429
- function withTimeout$1(promise, timeoutMs) {
30429
+ function withTimeout$2(promise, timeoutMs) {
30430
30430
  return new Promise((resolve, reject) => {
30431
30431
  const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("confirmation-timeout")), timeoutMs);
30432
30432
  promise.then((value) => {
@@ -30466,7 +30466,7 @@ async function confirmOne(candidate, config, deps, exhausted) {
30466
30466
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
30467
30467
  if (config.minCropPx > 0 && cropPx < config.minCropPx) return undecided(candidate, "below-min-crop");
30468
30468
  try {
30469
- return await withTimeout$1(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
30469
+ return await withTimeout$2(runConfirmation(candidate, config, deps, exhausted), config.timeoutMs);
30470
30470
  } catch {
30471
30471
  return undecided(candidate, "timeout");
30472
30472
  }
@@ -34713,7 +34713,8 @@ function isMediaOwnerKind(value) {
34713
34713
  * write="one row per blob written through put / putReplacing — a track's
34714
34714
  * keyFrame, thumbnail, firstFrame and lastFrame (UPSERTED on a deterministic
34715
34715
  * id, so one per track+kind however many captures race), its periodic
34716
- * snapshots, each event's crop and full frames, and the buffered face/plate
34716
+ * snapshots, each event's crop and full frames, a motion visit's still
34717
+ * (`kind: snapshot` on ownerKind event), and the buffered face/plate
34717
34718
  * crops. A row is also REOWNED here (not rewritten) when the operator enrols a
34718
34719
  * face or a plate, which is what moves it out of retention's reach."
34719
34720
  * retention="no age sweep of its own — MediaStore.evictBefore was deleted (D59)
@@ -39019,11 +39020,14 @@ function tieredLabelColumnData(patch) {
39019
39020
  * (MOTION_EVENT_HEARTBEAT_MS) while it stays on. The analyzer path and the
39020
39021
  * onboard-firmware path share that throttle, so a camera cannot double-count.
39021
39022
  * Measured at ~7,200 rows/day/camera."
39022
- * retention="a TERMINAL age-keyed root — no trackId, no media, nothing references
39023
- * it, so the track cascade can never reach it. Deleted by
39023
+ * retention="a TERMINAL age-keyed root — no trackId. A motion row may own a
39024
+ * still (`ownerKind: 'event'`, `kind: 'snapshot'`) captured on the off→on
39025
+ * visit so events-mode recordings have a videoclip thumbnail. Deleted by
39024
39026
  * EventStore.evictTracklessBefore on the DEVICE's one retention window
39025
- * (follow-recordings horizon, or trackRetentionDays, default 7). 0 days = keep
39026
- * forever, and this table is the reason that setting is dangerous."
39027
+ * (follow-recordings horizon, or trackRetentionDays, default 7), which
39028
+ * folds in MediaStore.deleteForEvents so the still leaves with the row.
39029
+ * 0 days = keep forever, and this table is the reason that setting is
39030
+ * dangerous."
39027
39031
  */
39028
39032
  var MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
39029
39033
  /**
@@ -39752,9 +39756,10 @@ var EventStore = class {
39752
39756
  * Object events now leave exactly one way: with their track, through
39753
39757
  * {@link deleteByTracks} (D61).
39754
39758
  *
39755
- * Motion and audio carry no `trackId`, own no media and are referenced by
39756
- * nothing — they are terminal retention ROOTS, so an age window on them
39757
- * cannot contradict anybody's (`retention/retention-model.ts`).
39759
+ * Motion and audio carry no `trackId` and are referenced by nothing — they
39760
+ * are terminal retention ROOTS, so an age window on them cannot contradict
39761
+ * anybody's (`retention/retention-model.ts`). A motion visit still is
39762
+ * event-owned media of that row and is deleted here in lockstep.
39758
39763
  *
39759
39764
  * **ONE cutoff for both kinds** (D62). They used to take a window each
39760
39765
  * (`retentionMotionDays` / `retentionAudioDays`), which is two clocks over
@@ -39771,9 +39776,17 @@ var EventStore = class {
39771
39776
  ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
39772
39777
  whereBetween: { timestamp: [0, params.cutoffMs] }
39773
39778
  });
39779
+ const motion = await batch(MOTION_EVENTS_COLLECTION);
39780
+ const audio = await batch(AUDIO_EVENTS_COLLECTION);
39781
+ const ids = [...motion, ...audio];
39782
+ if (ids.length > 0 && this.media !== void 0) try {
39783
+ await this.media.deleteForEvents(ids);
39784
+ } catch (err) {
39785
+ this.logger.warn("EventStore.evictTracklessBefore: event media delete failed", { meta: { error: String(err) } });
39786
+ }
39774
39787
  return {
39775
- motion: await batch(MOTION_EVENTS_COLLECTION),
39776
- audio: await batch(AUDIO_EVENTS_COLLECTION)
39788
+ motion,
39789
+ audio
39777
39790
  };
39778
39791
  }
39779
39792
  /**
@@ -39798,23 +39811,32 @@ var EventStore = class {
39798
39811
  * The SUBSET of `eventIds` that still exist — the orphan audit's liveness
39799
39812
  * probe for event-owned media.
39800
39813
  *
39814
+ * Motion and audio rows can own stills (`kind: 'snapshot'` on a motion
39815
+ * visit); probing only the object collection would classify those stills
39816
+ * as orphans the moment they were written.
39817
+ *
39801
39818
  * THROWS on a query failure rather than answering partially. A probe that
39802
39819
  * quietly under-reports here reads as "these events are gone", and the caller
39803
39820
  * deletes live crops on the strength of it.
39804
39821
  */
39805
39822
  async existingEventIds(eventIds) {
39806
39823
  const found = /* @__PURE__ */ new Set();
39824
+ const collections = [
39825
+ OBJECT_EVENTS_COLLECTION,
39826
+ MOTION_EVENTS_COLLECTION,
39827
+ AUDIO_EVENTS_COLLECTION
39828
+ ];
39807
39829
  for (let i = 0; i < eventIds.length; i += 500) {
39808
39830
  const chunk = eventIds.slice(i, i + 500);
39809
39831
  if (chunk.length === 0) continue;
39810
- const rows = await this.store.query.query({
39811
- collection: OBJECT_EVENTS_COLLECTION,
39832
+ const pages = await Promise.all(collections.map((collection) => this.store.query.query({
39833
+ collection,
39812
39834
  filter: {
39813
39835
  whereIn: { id: [...chunk] },
39814
39836
  limit: chunk.length
39815
39837
  }
39816
- });
39817
- for (const row of rows) found.add(row.id);
39838
+ })));
39839
+ for (const rows of pages) for (const row of rows) found.add(row.id);
39818
39840
  }
39819
39841
  return found;
39820
39842
  }
@@ -46672,16 +46694,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
46672
46694
  /** A hanging snapshot cap must not stall the sensor state-change path. */
46673
46695
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
46674
46696
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
46675
- var SnapshotTimeoutError = class extends Error {
46697
+ var SnapshotTimeoutError$1 = class extends Error {
46676
46698
  constructor(ms) {
46677
46699
  super(`snapshot cap timed out after ${ms}ms`);
46678
46700
  this.name = "SnapshotTimeoutError";
46679
46701
  }
46680
46702
  };
46681
46703
  /** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
46682
- function withTimeout(promise, ms) {
46704
+ function withTimeout$1(promise, ms) {
46683
46705
  return new Promise((resolve, reject) => {
46684
- const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46706
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
46685
46707
  promise.then((value) => {
46686
46708
  clearTimeout(timer);
46687
46709
  resolve(value);
@@ -46736,11 +46758,11 @@ var SyntheticTrackMaterializer = class {
46736
46758
  */
46737
46759
  async fetchSnapshot(cameraId, timeoutMs) {
46738
46760
  try {
46739
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46761
+ const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
46740
46762
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
46741
46763
  return snap;
46742
46764
  } catch (err) {
46743
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46765
+ this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
46744
46766
  return null;
46745
46767
  }
46746
46768
  }
@@ -46806,6 +46828,66 @@ var SyntheticTrackMaterializer = class {
46806
46828
  return track;
46807
46829
  }
46808
46830
  };
46831
+ var SnapshotTimeoutError = class extends Error {
46832
+ constructor(ms) {
46833
+ super(`snapshot cap timed out after ${ms}ms`);
46834
+ this.name = "SnapshotTimeoutError";
46835
+ }
46836
+ };
46837
+ function withTimeout(promise, ms) {
46838
+ return new Promise((resolve, reject) => {
46839
+ const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
46840
+ promise.then((value) => {
46841
+ clearTimeout(timer);
46842
+ resolve(value);
46843
+ }, (err) => {
46844
+ clearTimeout(timer);
46845
+ reject(err instanceof Error ? err : new Error(String(err)));
46846
+ });
46847
+ });
46848
+ }
46849
+ var MotionEventSnapshotCapturer = class {
46850
+ deps;
46851
+ snapshotTimeoutMs;
46852
+ constructor(deps) {
46853
+ this.deps = deps;
46854
+ this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
46855
+ }
46856
+ /**
46857
+ * Fetch a still and persist it as the motion event's snapshot. Never
46858
+ * throws — the durable record is the motion row, already inserted by
46859
+ * the caller.
46860
+ */
46861
+ async capture(input) {
46862
+ let snap;
46863
+ try {
46864
+ snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
46865
+ } catch (err) {
46866
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
46867
+ return;
46868
+ }
46869
+ if (snap === null) return;
46870
+ const raw = Buffer.from(snap.base64, "base64");
46871
+ let data = raw;
46872
+ try {
46873
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
46874
+ } catch (err) {
46875
+ this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
46876
+ }
46877
+ try {
46878
+ await this.deps.media.put({
46879
+ deviceId: input.deviceId,
46880
+ ownerKind: "event",
46881
+ ownerId: input.eventId,
46882
+ kind: "snapshot",
46883
+ timestamp: input.timestamp,
46884
+ data
46885
+ });
46886
+ } catch (err) {
46887
+ this.deps.onError?.("snapshotMedia", err, input.deviceId);
46888
+ }
46889
+ }
46890
+ };
46809
46891
  var TrackingSettingsSchema = object({
46810
46892
  /**
46811
46893
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -49776,6 +49858,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
49776
49858
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
49777
49859
  * tracks — linked sensor/control state changes AND audio markers. */
49778
49860
  syntheticTracks = null;
49861
+ /** Still for a motion row that has no track (events-mode recording without
49862
+ * a detection). Lands as event-owned `kind: 'snapshot'` so videoclip
49863
+ * thumbnails resolve through the existing event-media plane. */
49864
+ motionEventSnapshots = null;
49779
49865
  /** Decides which audio frames deserve a synthetic marker (D62). */
49780
49866
  audioMarkers = null;
49781
49867
  faceStore = null;
@@ -50969,6 +51055,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50969
51055
  } })
50970
51056
  });
50971
51057
  this.syntheticTracks = syntheticTracks;
51058
+ this.motionEventSnapshots = new MotionEventSnapshotCapturer({
51059
+ snapshot: { getSnapshot: (i) => api.snapshot?.getSnapshot.query(i) ?? Promise.resolve(null) },
51060
+ media: mediaStore,
51061
+ onError: (scope, err, deviceId) => logger.warn("motion event snapshot failed", {
51062
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
51063
+ meta: {
51064
+ scope,
51065
+ error: errMsg(err)
51066
+ }
51067
+ })
51068
+ });
50972
51069
  this.audioMarkers = new AudioMarkerProjector({
50973
51070
  sink: syntheticTracks,
50974
51071
  isMotionActive: (deviceId, atMs, quietMs) => this.isMotionActive(deviceId, atMs, quietMs),
@@ -54039,7 +54136,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54039
54136
  const last = this.lastMotionInsertByDevice.get(deviceId);
54040
54137
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
54041
54138
  const transitionedOn = detected && (last === void 0 || last.detected === false);
54042
- if (!detected) return;
54139
+ if (!detected) {
54140
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54141
+ detected: false,
54142
+ atMs: timestamp
54143
+ });
54144
+ return;
54145
+ }
54043
54146
  if (!transitionedOn && !heartbeatDue) return;
54044
54147
  const ev = {
54045
54148
  id: randomUUID(),
@@ -54082,6 +54185,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54082
54185
  timestamp: ev.timestamp
54083
54186
  }
54084
54187
  });
54188
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54189
+ deviceId,
54190
+ eventId: ev.id,
54191
+ timestamp: ev.timestamp
54192
+ });
54085
54193
  }
54086
54194
  /**
54087
54195
  * Persist firmware-driven (onboard) motion events. Mirrors
@@ -54106,7 +54214,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54106
54214
  const last = this.lastMotionInsertByDevice.get(deviceId);
54107
54215
  const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
54108
54216
  const transitionedOn = detected && (last === void 0 || last.detected === false);
54109
- if (!detected) return;
54217
+ if (!detected) {
54218
+ if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
54219
+ detected: false,
54220
+ atMs: timestamp
54221
+ });
54222
+ return;
54223
+ }
54110
54224
  if (!transitionedOn && !heartbeatDue) return;
54111
54225
  const regions = payload.regions ?? [];
54112
54226
  const ev = {
@@ -54150,6 +54264,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54150
54264
  timestamp: ev.timestamp
54151
54265
  }
54152
54266
  });
54267
+ if (transitionedOn) await this.motionEventSnapshots?.capture({
54268
+ deviceId,
54269
+ eventId: ev.id,
54270
+ timestamp: ev.timestamp
54271
+ });
54153
54272
  }
54154
54273
  async sweepExpiredTracks() {
54155
54274
  if (this.shuttingDown || !this.trackStore) return;
@@ -54218,8 +54337,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54218
54337
  *
54219
54338
  * 1. the track sweep, per device, cascading;
54220
54339
  * 2. the TRACKLESS roots — motion, audio and sensor events, which carry no
54221
- * trackId, own nothing and are referenced by nothing, so the cascade can
54222
- * never reach them and a clock is the only thing that can.
54340
+ * trackId and are referenced by nothing, so the cascade can never reach
54341
+ * them and a clock is the only thing that can. Motion visit stills leave
54342
+ * with their row inside `evictTracklessBefore`.
54223
54343
  *
54224
54344
  * That clock used to be three more settings (`retentionMotionDays` /
54225
54345
  * `retentionObjectDays` / `retentionAudioDays`). It is now the device's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.85",
3
+ "version": "1.2.86",
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",