@camstack/addon-post-analysis 1.2.224 → 1.2.226

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 VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as FailureCounters, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FULL_IMAGE_BBOX, h as FIRST_LEVEL_MACRO_CLASSES, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as LabelAttributionSchema, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-CxPGPwcy.mjs";
1
+ import { $ as TimelapseRuleSchema, $t as discriminatedUnion, A as NcConditionDescriptorSchema, At as readDeviceStateFrom, B as NcTaxonomySchema, Bt as zoneAnalyticsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as kebabToCamel, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pickClusterStepModels, E as NC_CONDITION_CATALOG, F as NcScheduleSchema, Ft as storageOccupancyCapability, Gt as createEvent, Ht as BaseAddon, I as NcSnoozeInputSchema, It as subKindsOf, J as SCENE_DIVERGED, Jt as nodePin, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as hydrateSchema, L as NcSnoozeSchema, Lt as systemEventFilterApplies, M as NcRulePatchSchema, Mt as resolveLocationMode, N as NcRuleSchema, Ot as pipelineAnalyticsCapability, P as NcRuleTargetSchema, Pt as sceneMonitorCapability, Q as TimelapseRulePatchSchema, Qt as boolean, R as NcSnoozeSuppressedSchema, Rt as vectorDimFromBase64, St as isSourceCap, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as notificationRulesCapability, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as CamProfileSchema, V as OpsLogEntrySchema, Vt as errMsg$1, W as RetrainStatusSchema, Wt as DeviceType, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as _enum, Y as SceneMonitorSchema, Yt as sleep$1, Z as TimelapseRuleInputSchema, Zt as array, _ as FULL_IMAGE_BBOX, _t as failureContributionCapability, a as CLUSTER_MODEL_SCOPED_STEPS, an as string, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isDetectionMacroClass, ct as cosineSimilarity$1, dt as deriveRecordingMode, en as literal, et as TrackSourceSchema, f as DeclaredDevices, g as FIRST_LEVEL_MACRO_CLASSES, gt as faceGalleryCapability, h as EVENT_PAD_MS, ht as evictionPolicyOfLocation, i as BirthDecisionRecordSchema, in as record, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readTimelapseGeneratedAt, k as NC_TAXONOMY, kt as plateGalleryCapability, lt as customAction, m as EVENT_OWNER_TYPES, mt as evaluateSensorEdge, n as ArchivedDebugNoteSchema, nn as object, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as unknown, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as encodeVectorBase64, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as isDeviceScopedCap, r as BaseDevice, rn as partialRecord, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as EventCategory, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as number, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, ut as defineCustomActions, v as FailureCounters, vt as formatMediaOwnerKey, w as MediaFileKindEnum, wt as mayWriteToLocation, x as MAX_BIRTH_DECISION_RECORDS, xt as isScheduleActive, y as LabelAttributionSchema, z as NcSystemEventKindSchema, zt as videoclipsCapability } from "../dist-B-cXIX_e.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";
@@ -10,6 +10,7 @@ import { startEventLoopStallMonitor } from "@camstack/system/addon-utils";
10
10
  import sharp from "sharp";
11
11
  import os from "node:os";
12
12
  import { Buffer as Buffer$1 } from "node:buffer";
13
+ import { gunzipSync, gzipSync } from "node:zlib";
13
14
  //#region src/pipeline-analytics/media-occupancy.ts
14
15
  /** The bare type ref legacy NULL-stamped media rows belong to. */
15
16
  var LEGACY_MEDIA_TYPE = "eventMedia";
@@ -49,6 +50,183 @@ function mediaOccupancyReports(footprint, locationIds, measuredAtMs) {
49
50
  };
50
51
  }
51
52
  //#endregion
53
+ //#region src/pipeline-analytics/motion-episode.ts
54
+ /**
55
+ * Decide what one `(detected, timestamp)` observation does to the episode
56
+ * state for one `(deviceId, source)` key. `prior` is `undefined` the first
57
+ * time this key is ever observed (fresh process, or a key never seen).
58
+ */
59
+ function decideMotionSignal(prior, detected, timestamp) {
60
+ if (!detected) {
61
+ if (prior === void 0) return { kind: "noop" };
62
+ return { kind: "observed-off" };
63
+ }
64
+ if (prior?.episode == null) return { kind: "open-episode" };
65
+ const isNewEdge = prior.lastDetected === false;
66
+ const edgeOffsetsMs = isNewEdge ? [...prior.episode.edgeOffsetsMs, timestamp - prior.episode.startedAt] : prior.episode.edgeOffsetsMs;
67
+ return {
68
+ kind: "extend-episode",
69
+ next: {
70
+ ...prior.episode,
71
+ lastOnAt: timestamp,
72
+ edgeOffsetsMs
73
+ },
74
+ isNewEdge
75
+ };
76
+ }
77
+ /** `lastOnAt - startedAt` — see the module doc for why not `closedAt - startedAt`. */
78
+ function motionEpisodeDurationMs(state) {
79
+ return state.lastOnAt - state.startedAt;
80
+ }
81
+ //#endregion
82
+ //#region src/pipeline-analytics/motion-episode-tracker.ts
83
+ function keyOf(deviceId, source) {
84
+ return `${deviceId}:${source}`;
85
+ }
86
+ var MotionEpisodeTracker = class {
87
+ deps;
88
+ signals = /* @__PURE__ */ new Map();
89
+ timers = /* @__PURE__ */ new Map();
90
+ constructor(deps) {
91
+ this.deps = deps;
92
+ }
93
+ /**
94
+ * One `(detected, timestamp)` observation for one `(deviceId, source)` key.
95
+ * `regionData` is read only if this observation OPENS a new episode — see
96
+ * {@link MotionEpisodeInsertInput}.
97
+ */
98
+ async observe(deviceId, source, detected, timestamp, regionData) {
99
+ const key = keyOf(deviceId, source);
100
+ const prior = this.signals.get(key);
101
+ const decision = decideMotionSignal(prior, detected, timestamp);
102
+ switch (decision.kind) {
103
+ case "noop": return;
104
+ case "observed-off":
105
+ this.setSignal(key, deviceId, source, false, prior?.episode ?? null);
106
+ return;
107
+ case "open-episode": {
108
+ const eventId = await this.deps.insertRow({
109
+ deviceId,
110
+ source,
111
+ timestamp,
112
+ regionData
113
+ });
114
+ if (eventId === null) {
115
+ this.deps.logger.warn("motion episode open failed — insert did not land", {
116
+ tags: { deviceId },
117
+ meta: {
118
+ source,
119
+ timestamp
120
+ }
121
+ });
122
+ this.setSignal(key, deviceId, source, true, null);
123
+ return;
124
+ }
125
+ const episode = {
126
+ eventId,
127
+ startedAt: timestamp,
128
+ lastOnAt: timestamp,
129
+ edgeOffsetsMs: [0]
130
+ };
131
+ this.setSignal(key, deviceId, source, true, episode);
132
+ this.armCloseTimer(key);
133
+ this.deps.onNewEdge?.({
134
+ deviceId,
135
+ source,
136
+ timestamp,
137
+ eventId,
138
+ isFirst: true
139
+ });
140
+ this.deps.onTick?.({
141
+ deviceId,
142
+ source,
143
+ timestamp
144
+ });
145
+ return;
146
+ }
147
+ case "extend-episode":
148
+ this.setSignal(key, deviceId, source, true, decision.next);
149
+ this.armCloseTimer(key);
150
+ if (decision.isNewEdge) this.deps.onNewEdge?.({
151
+ deviceId,
152
+ source,
153
+ timestamp,
154
+ eventId: decision.next.eventId,
155
+ isFirst: false
156
+ });
157
+ this.deps.onTick?.({
158
+ deviceId,
159
+ source,
160
+ timestamp
161
+ });
162
+ return;
163
+ }
164
+ }
165
+ /**
166
+ * Close every currently-open episode immediately, without waiting out the
167
+ * quiet window — used at addon shutdown so a row never sits with
168
+ * `durationMs: null` forever because the process that would have closed it
169
+ * is gone. Each close is logged with `reason` so an operator reading the
170
+ * row's neighbours understands why it stopped short of a natural close.
171
+ */
172
+ async closeAll(reason) {
173
+ const keys = [...this.signals.keys()];
174
+ for (const key of keys) {
175
+ const signal = this.signals.get(key);
176
+ if (!signal?.episode) continue;
177
+ await this.closeNow(key, signal, reason);
178
+ }
179
+ }
180
+ setSignal(key, deviceId, source, lastDetected, episode) {
181
+ this.signals.set(key, {
182
+ deviceId,
183
+ source,
184
+ lastDetected,
185
+ episode
186
+ });
187
+ }
188
+ armCloseTimer(key) {
189
+ const existing = this.timers.get(key);
190
+ if (existing !== void 0) clearTimeout(existing);
191
+ const timer = setTimeout(() => {
192
+ this.fireClose(key);
193
+ }, this.deps.closeAfterMs);
194
+ timer.unref?.();
195
+ this.timers.set(key, timer);
196
+ }
197
+ async fireClose(key) {
198
+ this.timers.delete(key);
199
+ const signal = this.signals.get(key);
200
+ if (!signal?.episode) return;
201
+ await this.closeNow(key, signal, "quiet-window-elapsed");
202
+ }
203
+ async closeNow(key, signal, reason) {
204
+ const episode = signal.episode;
205
+ if (!episode) return;
206
+ const timer = this.timers.get(key);
207
+ if (timer !== void 0) {
208
+ clearTimeout(timer);
209
+ this.timers.delete(key);
210
+ }
211
+ signal.episode = null;
212
+ const durationMs = motionEpisodeDurationMs(episode);
213
+ if (!await this.deps.closeRow({
214
+ deviceId: signal.deviceId,
215
+ source: signal.source,
216
+ eventId: episode.eventId,
217
+ durationMs,
218
+ edges: episode.edgeOffsetsMs
219
+ })) this.deps.logger.warn("motion episode close failed — row keeps durationMs: null", {
220
+ tags: { deviceId: signal.deviceId },
221
+ meta: {
222
+ source: signal.source,
223
+ eventId: episode.eventId,
224
+ reason
225
+ }
226
+ });
227
+ }
228
+ };
229
+ //#endregion
52
230
  //#region src/notification-center/action-token.ts
53
231
  /**
54
232
  * The authority behind a notification button.
@@ -6750,7 +6928,7 @@ function normalizeBbox(bbox, frameWidth, frameHeight) {
6750
6928
  function subjectFromObjectEvent(ev) {
6751
6929
  return {
6752
6930
  kind: "object-event",
6753
- recordId: ev.id,
6931
+ recordId: String(ev.id),
6754
6932
  deviceId: ev.deviceId,
6755
6933
  timestamp: ev.timestamp,
6756
6934
  classNames: [ev.className],
@@ -6818,7 +6996,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
6818
6996
  const eventType = readSensorEventType(ev.value);
6819
6997
  return {
6820
6998
  kind: "device-event",
6821
- recordId: ev.id,
6999
+ recordId: String(ev.id),
6822
7000
  deviceId: ev.deviceId,
6823
7001
  ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
6824
7002
  ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
@@ -6855,7 +7033,7 @@ function subjectFromAudioEvent(ev, stillOwnerId) {
6855
7033
  const macro = ev.classification?.className;
6856
7034
  return {
6857
7035
  kind: "audio-event",
6858
- recordId: ev.id,
7036
+ recordId: String(ev.id),
6859
7037
  deviceId: ev.deviceId,
6860
7038
  timestamp: ev.timestamp,
6861
7039
  classNames: macro !== void 0 ? [`audio-${macro}`] : [],
@@ -6985,7 +7163,7 @@ function occupancySubjectFromEdge(edge) {
6985
7163
  function subjectFromPackageEvent(ev, phase) {
6986
7164
  return {
6987
7165
  kind: "package-event",
6988
- recordId: ev.id,
7166
+ recordId: String(ev.id),
6989
7167
  deviceId: ev.deviceId,
6990
7168
  timestamp: ev.timestamp,
6991
7169
  classNames: [ev.className],
@@ -11667,7 +11845,7 @@ var NcDispatcher = class {
11667
11845
  recordId: entry.recordId,
11668
11846
  policy: entry.payload.media,
11669
11847
  ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
11670
- owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
11848
+ owners: [...entry.payload.subject.eventId !== void 0 ? [`object:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
11671
11849
  }
11672
11850
  });
11673
11851
  out.push(...footage.attachments);
@@ -11974,7 +12152,7 @@ var NcDispatcher = class {
11974
12152
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
11975
12153
  const owners = [];
11976
12154
  if (subject.eventId !== void 0) owners.push({
11977
- kind: "event",
12155
+ kind: "object",
11978
12156
  id: subject.eventId
11979
12157
  });
11980
12158
  if (subject.trackId !== void 0) owners.push({
@@ -28194,16 +28372,28 @@ var StoragePressureTracker = class {
28194
28372
  * operator-captured reference picture as collectable. It is the same class of
28195
28373
  * mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
28196
28374
  * which nothing else can regenerate and no cascade owns.
28375
+ *
28376
+ * `event` was DROPPED and replaced by `motion` / `object` / `audio` (D476).
28377
+ * `event` used to cover all three event tables under one owner kind, which was
28378
+ * safe while event ids were UUIDs (unique across tables). D474 made an event
28379
+ * id a per-table SQLite rowid alias, so `event:5` stopped naming one row —
28380
+ * three different rows, one per table, can all be `5`. This is the AUTHORITY
28381
+ * on what may own media; `MEDIA_OWNER_TYPES` in
28382
+ * `@camstack/types/media/media-owner-key.ts` must cover every member here —
28383
+ * `scripts` / `__tests__/owner-kinds-authority.spec.ts` guards the two lists
28384
+ * staying married.
28197
28385
  */
28198
28386
  var OWNER_KINDS = [
28199
- "event",
28200
28387
  "track",
28201
28388
  "summary",
28202
28389
  "face",
28203
28390
  "identity",
28204
28391
  "plate",
28205
28392
  "vehicle",
28206
- "scene"
28393
+ "scene",
28394
+ "motion",
28395
+ "object",
28396
+ "audio"
28207
28397
  ];
28208
28398
  /**
28209
28399
  * Owner kinds that NOTHING may write any more, but whose rows can still be on
@@ -28600,7 +28790,12 @@ function mediaRowLocation(data) {
28600
28790
  return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
28601
28791
  }
28602
28792
  function buildKey(params) {
28603
- return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
28793
+ return formatMediaOwnerKey({
28794
+ ownerType: params.ownerKind,
28795
+ ownerId: params.ownerId,
28796
+ fileKind: params.kind,
28797
+ ...isSingleInstanceKind(params.kind) ? {} : { timestampMs: params.timestamp }
28798
+ });
28604
28799
  }
28605
28800
  function buildPath(params) {
28606
28801
  const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
@@ -29515,9 +29710,27 @@ var MediaStore = class {
29515
29710
  }
29516
29711
  return newKey;
29517
29712
  }
29518
- /** Back-compat: existing retention path deletes media for evicted events. */
29519
- async deleteForEvents(eventIds) {
29520
- return this.deleteForOwner("event", eventIds);
29713
+ /**
29714
+ * Delete event-owned media for evicted events, GROUPED by the table each id
29715
+ * came from (D476).
29716
+ *
29717
+ * There is no honest single-kind signature any more: `deleteForOwner` needs
29718
+ * ONE owner kind per call, and a flat id list can no longer say which of
29719
+ * `motion` / `object` / `audio` an id names — D474 made event ids per-table
29720
+ * rowids, so `motion:5` and `object:5` are different rows that both spell
29721
+ * `5`. Every caller (the retention cascade's per-track object-event sweep,
29722
+ * the trackless motion/audio sweep, the operator prune) already KNOWS which
29723
+ * table produced each id before it reaches here — the old flat signature
29724
+ * just threw that fact away. `ids` is a partial record so a caller supplies
29725
+ * only the kinds it actually collected.
29726
+ */
29727
+ async deleteForEvents(ids) {
29728
+ let total = 0;
29729
+ for (const ownerType of EVENT_OWNER_TYPES) {
29730
+ const kindIds = ids[ownerType];
29731
+ if (kindIds !== void 0 && kindIds.length > 0) total += await this.deleteForOwner(ownerType, kindIds);
29732
+ }
29733
+ return total;
29521
29734
  }
29522
29735
  /** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
29523
29736
  async deleteForTracks(trackIds) {
@@ -29532,8 +29745,9 @@ var MediaStore = class {
29532
29745
  * ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
29533
29746
  * assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
29534
29747
  * crops and leaves the enrolled gallery intact. Event-owned media
29535
- * (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
29536
- * set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
29748
+ * (`ownerKind:'motion'` / `'object'` / `'audio'`) is handled by `EventStore`
29749
+ * (which holds the per-kind event id sets see {@link deleteForEvents}).
29750
+ * `deleteForOwner` is best-effort per row, so one bad row can't abort the
29537
29751
  * batch.
29538
29752
  */
29539
29753
  async deleteByTracks(trackIds) {
@@ -36396,23 +36610,6 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
36396
36610
  }
36397
36611
  //#endregion
36398
36612
  //#region src/pipeline-analytics/pipeline/frame-processor.ts
36399
- /**
36400
- * FrameProcessor — per-device pipeline stage runner.
36401
- *
36402
- * Consumes a raw `PipelineInferenceResult` payload and runs:
36403
- * 1. Flatten first-level detections
36404
- * 2. Zone filter (ZoneEngine)
36405
- * 3. Tracking (SortTracker)
36406
- * 4. State analysis (StateAnalyzer)
36407
- * 5. Event emission (DetectionEventEmitter)
36408
- *
36409
- * Returns a structured result the addon uses to:
36410
- * - upsert TrackStore
36411
- * - insert ObjectEvent rows into the declared collection
36412
- * - emit onFrameTracked / onDetectionEvent on the bus
36413
- *
36414
- * One FrameProcessor per device — lifecycle managed by DeviceRegistry.
36415
- */
36416
36613
  /** Shared empty — a fold is rare and every other frame would allocate. */
36417
36614
  var EMPTY_RIDER_FOLDS = [];
36418
36615
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
@@ -37148,7 +37345,6 @@ var FrameProcessor = class {
37148
37345
  const crossing = crossingOf(e);
37149
37346
  const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
37150
37347
  return {
37151
- id: randomUUID(),
37152
37348
  deviceId: this.deviceId,
37153
37349
  timestamp,
37154
37350
  kind: "object",
@@ -37885,7 +38081,7 @@ async function cascadeDeleteTrack(stores, trackId) {
37885
38081
  for (const id of ids) {
37886
38082
  const eventIds = await stores.eventStore.deleteByTrack(id);
37887
38083
  events += eventIds.length;
37888
- if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
38084
+ if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents({ object: [...eventIds] });
37889
38085
  }
37890
38086
  return [{
37891
38087
  family: "events",
@@ -39842,6 +40038,11 @@ function runFrameProcessorPass(input) {
39842
40038
  * remaining blob are dropped — including snapshot-less rows. Enrolled gallery
39843
40039
  * media (`identity` / `vehicle` / `scene`) is never probed.
39844
40040
  */
40041
+ /** Is this an event-owned media row — `motion` / `object` / `audio` (D476;
40042
+ * `event` used to cover all three under one owner kind). */
40043
+ function isEventOwnedMediaRow(ownerKind) {
40044
+ return EVENT_OWNER_TYPES.includes(ownerKind);
40045
+ }
39845
40046
  function shouldProbeMediaRow(ownerKind) {
39846
40047
  if (!isMediaOwnerKind(ownerKind)) return true;
39847
40048
  return !isRetentionExemptOwnerKind(ownerKind);
@@ -39872,7 +40073,7 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
39872
40073
  if (row.path.length === 0 ? false : await deps.blobExists(row.location, row.path)) {
39873
40074
  const trackId = trackIdFromMediaOwner(row.ownerKind, row.ownerId);
39874
40075
  if (trackId !== null) tracksWithMedia.add(trackId);
39875
- if (row.ownerKind === "event") eventsWithMedia.add(row.ownerId);
40076
+ if (isEventOwnedMediaRow(row.ownerKind)) eventsWithMedia.add(row.ownerId);
39876
40077
  continue;
39877
40078
  }
39878
40079
  missingKeys.push(row.key);
@@ -39933,6 +40134,191 @@ var locationDiscardActions = defineCustomActions({
39933
40134
  "storage.discardLocationCancel": customAction(object({}), DiscardCancel, { auth: "admin" })
39934
40135
  });
39935
40136
  //#endregion
40137
+ //#region src/pipeline-analytics/store/motion-edge-codec.ts
40138
+ /**
40139
+ * How a motion episode's rising-edge offsets become bytes, and back (D475).
40140
+ *
40141
+ * A motion episode row (`durationMs` + `edges`) records every distinct
40142
+ * off→on transition the camera reported while the episode stayed open — "ogni
40143
+ * evento on si deve salvare" (the operator's words). `edges` holds those
40144
+ * instants as **ms offsets from the episode's `timestamp`** (its first rising
40145
+ * edge, so the first entry is always `0`), never as absolute epochs — an
40146
+ * epoch is ~13 digits per entry; an offset into a 40 s episode is 1–5.
40147
+ *
40148
+ * Mirrors `position-codec.ts` (D471) on purpose: same discipline, different
40149
+ * shape. A MALFORMED stored value decodes to an empty array, never a throw —
40150
+ * one row's garbage `edges` column must not take out the whole event read
40151
+ * path, and a legacy row (written before this column existed) has no `edges`
40152
+ * at all, which decodes to `[]` exactly like garbage does. The open/closed
40153
+ * question is answered by `durationMs` being `null` vs a number vs absent —
40154
+ * never by this column, so an empty `edges` array must never be read as "no
40155
+ * episode" or "still open".
40156
+ */
40157
+ /**
40158
+ * Marks a value this module wrote. A distinct namespace from
40159
+ * `position-codec.ts`'s `z1:` — different column, different shape, so a
40160
+ * value from one can never be mistaken for the other's.
40161
+ */
40162
+ var ENCODED_PREFIX$1 = "me1:";
40163
+ /** Round-trip a sequence of rising-edge offsets (ms, from the episode start)
40164
+ * through gzip + base64 — the same trade `position-codec.ts` made: base64
40165
+ * because the settings-store column types have no BLOB, gzip because the
40166
+ * repeated small integers compress well. */
40167
+ function encodeMotionEdges(edgesMs) {
40168
+ const json = JSON.stringify(edgesMs);
40169
+ return ENCODED_PREFIX$1 + gzipSync(Buffer.from(json, "utf8"), { level: 6 }).toString("base64");
40170
+ }
40171
+ /**
40172
+ * Read a stored `edges` value. Two shapes reach here: a `me1:` string this
40173
+ * module wrote, and a plain array (the store parsed the JSON column for us,
40174
+ * or a caller handed one straight through — the codec should not be the
40175
+ * reason a valid in-memory array fails to read back). Anything else —
40176
+ * `undefined` (legacy row, or an episode that never closed), a garbage
40177
+ * string, a number — answers `[]`.
40178
+ */
40179
+ function decodeMotionEdges(value) {
40180
+ if (Array.isArray(value)) return value.filter(isFiniteNumber);
40181
+ if (typeof value !== "string") return [];
40182
+ if (value.startsWith(ENCODED_PREFIX$1)) try {
40183
+ const raw = gunzipSync(Buffer.from(value.slice(4), "base64")).toString("utf8");
40184
+ const parsed = JSON.parse(raw);
40185
+ return Array.isArray(parsed) ? parsed.filter(isFiniteNumber) : [];
40186
+ } catch {
40187
+ return [];
40188
+ }
40189
+ try {
40190
+ const parsed = JSON.parse(value);
40191
+ return Array.isArray(parsed) ? parsed.filter(isFiniteNumber) : [];
40192
+ } catch {
40193
+ return [];
40194
+ }
40195
+ }
40196
+ function isFiniteNumber(v) {
40197
+ return typeof v === "number" && Number.isFinite(v);
40198
+ }
40199
+ //#endregion
40200
+ //#region src/pipeline-analytics/store/position-codec.ts
40201
+ /**
40202
+ * How a track's `positions[]` becomes bytes, and back (D471).
40203
+ *
40204
+ * `positions` was 94.9 % of the `tracks` table and 17 % of the whole settings
40205
+ * database — 186.5 MB — and most of that was not information. See
40206
+ * `__tests__/position-codec.spec.ts` for the measurement and for why rounding
40207
+ * is safe only since D399.
40208
+ *
40209
+ * Pure on purpose: what a MALFORMED stored value decodes to is the decision
40210
+ * worth pinning in a test, and it is "an empty trail", never a throw.
40211
+ */
40212
+ /**
40213
+ * Marks a value this module wrote. Chosen so it cannot collide with the plain
40214
+ * JSON array every pre-D471 row holds: that always starts `[`.
40215
+ */
40216
+ var ENCODED_PREFIX = "z1:";
40217
+ /** Pixels are integers. The float tail is representation noise, not precision. */
40218
+ function roundPosition(p) {
40219
+ return {
40220
+ ...p,
40221
+ x: Math.round(p.x),
40222
+ y: Math.round(p.y),
40223
+ bbox: {
40224
+ ...p.bbox,
40225
+ x: Math.round(p.bbox.x),
40226
+ y: Math.round(p.bbox.y),
40227
+ w: Math.round(p.bbox.w),
40228
+ h: Math.round(p.bbox.h)
40229
+ }
40230
+ };
40231
+ }
40232
+ /** Round, serialise, gzip, base64 — in that order, so the compressor sees the
40233
+ * smaller text and the repeated field names it compresses best. */
40234
+ function encodePositions(positions) {
40235
+ const json = JSON.stringify(positions.map(roundPosition));
40236
+ return ENCODED_PREFIX + gzipSync(Buffer.from(json, "utf8"), { level: 6 }).toString("base64");
40237
+ }
40238
+ /**
40239
+ * Read a stored value, whichever era wrote it.
40240
+ *
40241
+ * THREE shapes reach here and all three are ordinary, which is what makes a
40242
+ * migration unnecessary: a `z1:` string this module wrote, a plain array (the
40243
+ * store parsed the JSON column for us), and a plain JSON string (it did not).
40244
+ * Anything else is a row we cannot read, and the answer is an empty trail —
40245
+ * the timeline then draws a track with no path, which an operator can SEE,
40246
+ * instead of failing the page, which they cannot.
40247
+ */
40248
+ function decodePositions(value) {
40249
+ if (Array.isArray(value)) return value.filter(isPosition);
40250
+ if (typeof value !== "string") return [];
40251
+ if (value.startsWith(ENCODED_PREFIX)) try {
40252
+ const raw = gunzipSync(Buffer.from(value.slice(3), "base64")).toString("utf8");
40253
+ const parsed = JSON.parse(raw);
40254
+ return Array.isArray(parsed) ? parsed.filter(isPosition) : [];
40255
+ } catch {
40256
+ return [];
40257
+ }
40258
+ try {
40259
+ const parsed = JSON.parse(value);
40260
+ return Array.isArray(parsed) ? parsed.filter(isPosition) : [];
40261
+ } catch {
40262
+ return [];
40263
+ }
40264
+ }
40265
+ function isPosition(v) {
40266
+ if (typeof v !== "object" || v === null) return false;
40267
+ const p = { ...v };
40268
+ return typeof p["x"] === "number" && typeof p["y"] === "number" && typeof p["bbox"] === "object";
40269
+ }
40270
+ /**
40271
+ * A trajectory reduced to the ONLY thing a zone-filtered reader uses (D471).
40272
+ *
40273
+ * The client's `parsePositionBoxes` walks `positions[]` and keeps `p.bbox`,
40274
+ * discarding `x`, `y`, `timestamp` and `source` the moment they arrive. On the
40275
+ * zone path — the one path that ships positions at all — that is **59 % of the
40276
+ * payload thrown away by the receiver**: 162 B per position sent, 66 B used.
40277
+ *
40278
+ * Measured on 20 130 real positions. This is the reduction, applied server-side
40279
+ * so the bytes are never spent.
40280
+ *
40281
+ * NOT applied to any other read: a caller that asked for a full trajectory gets
40282
+ * one. This is narrowing on the request that already narrowed.
40283
+ */
40284
+ function geometryOnlyPositions(positions) {
40285
+ return positions.map((p) => ({
40286
+ x: p.bbox.x,
40287
+ y: p.bbox.y,
40288
+ timestamp: 0,
40289
+ bbox: p.bbox
40290
+ }));
40291
+ }
40292
+ /**
40293
+ * A detection box with integer pixel coordinates (D471).
40294
+ *
40295
+ * `object-events.bbox` carries the same float tail as `positions` did —
40296
+ * `{"w":1089.6000000000001,"h":398.40000000000003}` — and is 18 % of a 30 MB
40297
+ * table. Same cause: a pixel scaled by the analysis raster ratio, serialised
40298
+ * with every digit JavaScript can print.
40299
+ *
40300
+ * Returns the input unchanged when there is nothing to round, so a legacy row
40301
+ * with no bbox stays exactly what it was.
40302
+ */
40303
+ function roundBbox(bbox) {
40304
+ if (typeof bbox !== "object" || bbox === null) return bbox;
40305
+ const b = { ...bbox };
40306
+ let touched = false;
40307
+ for (const k of [
40308
+ "x",
40309
+ "y",
40310
+ "w",
40311
+ "h"
40312
+ ]) {
40313
+ const v = b[k];
40314
+ if (typeof v === "number" && Number.isFinite(v)) {
40315
+ b[k] = Math.round(v);
40316
+ touched = true;
40317
+ }
40318
+ }
40319
+ return touched ? b : bbox;
40320
+ }
40321
+ //#endregion
39936
40322
  //#region src/pipeline-analytics/store/tiered-label-columns.ts
39937
40323
  /**
39938
40324
  * The six tiered-label columns, read and written in ONE place (roadmap 4g).
@@ -40053,12 +40439,17 @@ function audioDbfsByBucket(events, since, bucketMs) {
40053
40439
  //#region src/pipeline-analytics/store/event-store.ts
40054
40440
  /**
40055
40441
  * @durable class=ledger owner=pipeline-analytics
40056
- * write="one row when a camera's motion goes off→on, then one more every 5 s
40057
- * (MOTION_EVENT_HEARTBEAT_MS) while it stays on. The analyzer path and the
40058
- * onboard-firmware path share that throttle, so a camera cannot double-count.
40059
- * Measured at ~7,200 rows/day/camera."
40442
+ * write="one row per motion EPISODE, keyed per (deviceId, source) so the
40443
+ * analyzer and a firmware/device-activity sensor on the SAME camera never
40444
+ * share one episode (D475). INSERT on a rising edge with no episode open
40445
+ * (durationMs: null, edges: [0]); a further rising edge inside the open
40446
+ * episode does NOT insert — it extends the in-memory episode and re-arms
40447
+ * the MOTION_CLOSE_AFTER_MS close timer. One UPDATE at close writes the
40448
+ * final durationMs (lastOnAt - startedAt) and the encoded edges array.
40449
+ * Replaces the pre-D475 scheme (one row off→on, then a heartbeat row
40450
+ * every 5 s while motion stayed on) — MOTION_EVENT_HEARTBEAT_MS is gone."
40060
40451
  * retention="a TERMINAL age-keyed root — no trackId. A motion row may own a
40061
- * still (`ownerKind: 'event'`, `kind: 'snapshot'`) captured on the off→on
40452
+ * still (`ownerKind: 'motion'`, `kind: 'snapshot'`) captured on the off→on
40062
40453
  * visit so events-mode recordings have a videoclip thumbnail. Deleted by
40063
40454
  * EventStore.evictTracklessBefore on the DEVICE's one retention window
40064
40455
  * (follow-recordings horizon, or trackRetentionDays, default 7), which
@@ -40093,7 +40484,7 @@ var AUDIO_EVENTS_COLLECTION = "pipeline-analytics:audio-events";
40093
40484
  var COMMON_BASE_COLUMNS = [
40094
40485
  {
40095
40486
  name: "id",
40096
- type: "TEXT",
40487
+ type: "INTEGER",
40097
40488
  primaryKey: true,
40098
40489
  notNull: true
40099
40490
  },
@@ -40126,7 +40517,31 @@ var MOTION_COLUMNS = [
40126
40517
  {
40127
40518
  name: "frameHeight",
40128
40519
  type: "INTEGER"
40129
- }
40520
+ },
40521
+ (
40522
+ /**
40523
+ * `null` while the episode this row represents is still open; the final
40524
+ * `lastOnAt - startedAt` once closed (D475). DECLARED, not merely stamped —
40525
+ * the sqlite backend silently drops an undeclared key on every write, which
40526
+ * is exactly how `frameId` was written into the void for years (see
40527
+ * `OBJECT_COLUMNS.frameId`). Absent on a row written before D475: that
40528
+ * means "closed the old way", never "open" — only an explicit `null` here
40529
+ * means open.
40530
+ */
40531
+ {
40532
+ name: "durationMs",
40533
+ type: "INTEGER"
40534
+ }),
40535
+ (
40536
+ /**
40537
+ * Encoded rising-edge offsets for the episode — see `motion-edge-codec.ts`.
40538
+ * Written once, in the same UPDATE as `durationMs`, at close; a legacy or
40539
+ * still-open row has no value here, which the codec reads as `[]`.
40540
+ */
40541
+ {
40542
+ name: "edges",
40543
+ type: "TEXT"
40544
+ })
40130
40545
  ];
40131
40546
  var OBJECT_COLUMNS = [
40132
40547
  ...COMMON_BASE_COLUMNS,
@@ -40152,6 +40567,18 @@ var OBJECT_COLUMNS = [
40152
40567
  name: "frameId",
40153
40568
  type: "TEXT"
40154
40569
  }),
40570
+ (
40571
+ /**
40572
+ * The producer's own idempotence key (D474) — see `ObjectEvent.idempotencyKey`.
40573
+ *
40574
+ * DECLARED, not merely stamped: the sqlite backend drops an undeclared key
40575
+ * without error, which is exactly how `frameId` was written into the void on
40576
+ * every row ever persisted. Nullable — only the package detector sets it.
40577
+ */
40578
+ {
40579
+ name: "idempotencyKey",
40580
+ type: "TEXT"
40581
+ }),
40155
40582
  {
40156
40583
  name: "trackId",
40157
40584
  type: "TEXT",
@@ -40285,6 +40712,42 @@ var COMMON_INDEXES = (prefix) => [{
40285
40712
  name: `idx_${prefix}_device_ts`,
40286
40713
  columns: ["deviceId", "timestamp"]
40287
40714
  }];
40715
+ /**
40716
+ * The wire value `durationMs` carries for an OPEN episode (D475).
40717
+ *
40718
+ * The app-level contract is "`null` = open, a number = closed, absent = a
40719
+ * row from before this column existed" — but SQL has no way to tell "this
40720
+ * column was never given a value because the row predates it" from
40721
+ * "explicitly set to NULL": `declareCollection` adds the column via ALTER
40722
+ * TABLE, which backfills every existing row's `durationMs` with a real SQL
40723
+ * NULL, bit-for-bit the same NULL an open episode would write. Writing an
40724
+ * actual NULL for "open" would therefore make a brand-new open episode
40725
+ * indistinguishable from a five-year-old legacy row — exactly the failure
40726
+ * mode this constant exists to avoid. `insertMotion` writes this in place of
40727
+ * `null`; `queryMotion` translates it back to `null` on the way out, so
40728
+ * nothing outside this file ever sees the sentinel. `-1` is safe because a
40729
+ * real duration is never negative.
40730
+ */
40731
+ var OPEN_EPISODE_SENTINEL = -1;
40732
+ /**
40733
+ * The key `insert` reports, narrowed to the ROWID these three collections use.
40734
+ *
40735
+ * `settingsStore.insert` answers `string | number` because the SAME method
40736
+ * serves TEXT-keyed collections, where it mints a UUID (D474). On the event
40737
+ * collections the primary key is an `INTEGER` rowid alias, so a string here is
40738
+ * not an event id at all — it means the table in front of us still has the old
40739
+ * TEXT key. Returning `null` says "no id", which is what every caller already
40740
+ * handles; the WARN says why, because a silent null would read as a failed
40741
+ * write (D474).
40742
+ */
40743
+ function eventRowId(logger, operation, deviceId, key) {
40744
+ if (typeof key === "number") return key;
40745
+ logger.warn(`${operation}: event key is not an INTEGER rowid`, {
40746
+ tags: { deviceId },
40747
+ meta: { returnedId: key }
40748
+ });
40749
+ return null;
40750
+ }
40288
40751
  var EventStore = class {
40289
40752
  store;
40290
40753
  logger;
@@ -40323,65 +40786,129 @@ var EventStore = class {
40323
40786
  indexes: COMMON_INDEXES("audio")
40324
40787
  });
40325
40788
  }
40789
+ /**
40790
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40791
+ *
40792
+ * The caller needs it on the very next line — the notification centre reads
40793
+ * it and the marker-still path builds a media key from it — and with an
40794
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40795
+ * placeholder would reach the event bus and the media keys.
40796
+ */
40326
40797
  async insertMotion(ev) {
40327
40798
  this.onMotionSample?.(ev.deviceId, ev.regionCount, ev.timestamp);
40799
+ const record = { ...ev };
40800
+ if (ev.durationMs === null) record["durationMs"] = OPEN_EPISODE_SENTINEL;
40328
40801
  try {
40329
- const { id, ...rest } = ev;
40330
- await this.store.insert.mutate({
40802
+ const inserted = await this.store.insert.mutate({
40331
40803
  collection: MOTION_EVENTS_COLLECTION,
40332
- record: {
40333
- id,
40334
- data: rest
40335
- }
40804
+ record: { data: record }
40336
40805
  });
40806
+ return eventRowId(this.logger, "insertMotion", ev.deviceId, inserted.id);
40337
40807
  } catch (err) {
40338
40808
  this.logger.warn("insertMotion failed", {
40339
40809
  tags: { deviceId: ev.deviceId },
40340
40810
  meta: {
40341
- eventId: ev.id,
40811
+ timestamp: ev.timestamp,
40812
+ error: String(err)
40813
+ }
40814
+ });
40815
+ return null;
40816
+ }
40817
+ }
40818
+ /**
40819
+ * The one place a motion EPISODE row is closed (D475). Writes `durationMs`
40820
+ * and the encoded `edges` in a single UPDATE — a row never gets a second
40821
+ * write after this one. Returns whether it landed; the caller
40822
+ * (`MotionEpisodeTracker`) logs the failure with the episode's `deviceId`
40823
+ * tag, because a `durationMs: null` row that never gets a reason is exactly
40824
+ * the silent-forever-open shape D475 exists to prevent.
40825
+ */
40826
+ async updateMotion(eventId, deviceId, patch) {
40827
+ try {
40828
+ await this.store.update.mutate({
40829
+ collection: MOTION_EVENTS_COLLECTION,
40830
+ id: String(eventId),
40831
+ data: {
40832
+ durationMs: patch.durationMs,
40833
+ edges: encodeMotionEdges(patch.edges)
40834
+ }
40835
+ });
40836
+ return true;
40837
+ } catch (err) {
40838
+ this.logger.warn("updateMotion (episode close) failed", {
40839
+ tags: { deviceId },
40840
+ meta: {
40841
+ eventId,
40342
40842
  error: String(err)
40343
40843
  }
40344
40844
  });
40845
+ return false;
40345
40846
  }
40346
40847
  }
40848
+ /**
40849
+ * The motion-activity LTS tap, exposed for a caller that extends an
40850
+ * already-open episode without inserting a new row (D475) — `insertMotion`
40851
+ * fires it on OPEN; this fires it on every further tick, so a long
40852
+ * continuous episode still samples activity at the same density the
40853
+ * pre-D475 heartbeat gave the aggregator, rather than once per episode.
40854
+ */
40855
+ noteMotionSample(deviceId, regionCount, at) {
40856
+ this.onMotionSample?.(deviceId, regionCount, at);
40857
+ }
40858
+ /**
40859
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40860
+ *
40861
+ * The caller needs it on the very next line — the notification centre reads
40862
+ * it and the marker-still path builds a media key from it — and with an
40863
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40864
+ * placeholder would reach the event bus and the media keys.
40865
+ */
40347
40866
  async insertObject(ev) {
40348
40867
  try {
40349
- const { id, ...rest } = ev;
40350
- await this.store.insert.mutate({
40868
+ const rounded = "bbox" in ev ? {
40869
+ ...ev,
40870
+ bbox: roundBbox(ev.bbox)
40871
+ } : ev;
40872
+ const inserted = await this.store.insert.mutate({
40351
40873
  collection: OBJECT_EVENTS_COLLECTION,
40352
- record: {
40353
- id,
40354
- data: rest
40355
- }
40874
+ record: { data: rounded }
40356
40875
  });
40876
+ return eventRowId(this.logger, "insertObject", ev.deviceId, inserted.id);
40357
40877
  } catch (err) {
40358
40878
  this.logger.warn("insertObject failed", {
40359
40879
  tags: { deviceId: ev.deviceId },
40360
40880
  meta: {
40361
- eventId: ev.id,
40881
+ timestamp: ev.timestamp,
40362
40882
  error: String(err)
40363
40883
  }
40364
40884
  });
40885
+ return null;
40365
40886
  }
40366
40887
  }
40888
+ /**
40889
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40890
+ *
40891
+ * The caller needs it on the very next line — the notification centre reads
40892
+ * it and the marker-still path builds a media key from it — and with an
40893
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40894
+ * placeholder would reach the event bus and the media keys.
40895
+ */
40367
40896
  async insertAudio(ev) {
40368
40897
  try {
40369
- const { id, ...rest } = ev;
40370
- await this.store.insert.mutate({
40898
+ const inserted = await this.store.insert.mutate({
40371
40899
  collection: AUDIO_EVENTS_COLLECTION,
40372
- record: {
40373
- id,
40374
- data: rest
40375
- }
40900
+ record: { data: ev }
40376
40901
  });
40902
+ return eventRowId(this.logger, "insertAudio", ev.deviceId, inserted.id);
40377
40903
  } catch (err) {
40378
40904
  this.logger.warn("insertAudio failed", {
40379
40905
  tags: { deviceId: ev.deviceId },
40380
40906
  meta: {
40381
- eventId: ev.id,
40907
+ timestamp: ev.timestamp,
40382
40908
  error: String(err)
40383
40909
  }
40384
40910
  });
40911
+ return null;
40385
40912
  }
40386
40913
  }
40387
40914
  /**
@@ -40495,12 +41022,15 @@ var EventStore = class {
40495
41022
  filter: this.buildFilter(q),
40496
41023
  ...this.slimColumns(q.projection, SLIM_MOTION_COLUMNS)
40497
41024
  });
40498
- if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls$1(r.data)));
41025
+ if (q.projection === "slim") return rows.map((r) => slimMotion(Number(r.id), stripNulls$1(r.data)));
40499
41026
  return rows.map((r) => {
41027
+ const { edges: rawEdges, durationMs: rawDurationMs, ...data } = stripNulls$1(r.data);
40500
41028
  return {
40501
- id: r.id,
41029
+ id: Number(r.id),
40502
41030
  kind: "motion",
40503
- ...stripNulls$1(r.data)
41031
+ ...data,
41032
+ ...rawDurationMs !== void 0 ? { durationMs: rawDurationMs === OPEN_EPISODE_SENTINEL ? null : rawDurationMs } : {},
41033
+ ...rawEdges !== void 0 ? { edges: decodeMotionEdges(rawEdges) } : {}
40504
41034
  };
40505
41035
  });
40506
41036
  }
@@ -40547,7 +41077,7 @@ var EventStore = class {
40547
41077
  });
40548
41078
  if (rows.length === 0) break;
40549
41079
  for (const r of rows) out.push({
40550
- id: r.id,
41080
+ id: Number(r.id),
40551
41081
  kind: "object",
40552
41082
  ...stripNulls$1(r.data)
40553
41083
  });
@@ -40566,10 +41096,10 @@ var EventStore = class {
40566
41096
  filter,
40567
41097
  ...this.slimColumns(q.projection, SLIM_OBJECT_COLUMNS)
40568
41098
  });
40569
- if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls$1(r.data)));
41099
+ if (q.projection === "slim") return rows.map((r) => slimObject(Number(r.id), stripNulls$1(r.data)));
40570
41100
  return rows.map((r) => {
40571
41101
  return {
40572
- id: r.id,
41102
+ id: Number(r.id),
40573
41103
  kind: "object",
40574
41104
  ...stripNulls$1(r.data)
40575
41105
  };
@@ -40594,7 +41124,7 @@ var EventStore = class {
40594
41124
  }
40595
41125
  })).map((r) => {
40596
41126
  return {
40597
- id: r.id,
41127
+ id: Number(r.id),
40598
41128
  kind: "object",
40599
41129
  ...stripNulls$1(r.data)
40600
41130
  };
@@ -40605,10 +41135,10 @@ var EventStore = class {
40605
41135
  collection: AUDIO_EVENTS_COLLECTION,
40606
41136
  filter: this.buildFilter(q)
40607
41137
  });
40608
- if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls$1(r.data)));
41138
+ if (q.projection === "slim") return rows.map((r) => slimAudio(Number(r.id), stripNulls$1(r.data)));
40609
41139
  return rows.map((r) => {
40610
41140
  return {
40611
- id: r.id,
41141
+ id: Number(r.id),
40612
41142
  kind: "audio",
40613
41143
  ...stripNulls$1(r.data)
40614
41144
  };
@@ -40619,9 +41149,28 @@ var EventStore = class {
40619
41149
  * resolver's track fallback: a new-style object event owns no crop, so its
40620
41150
  * `mediaUrl` degrades to the track's cadence media, addressed by this id.
40621
41151
  * Returns `null` when the id is not a persisted object event (e.g. a KeyEvent
40622
- * whose id is already a track id, or a motion/audio event) — best-effort.
40623
- */
40624
- async getTrackIdForEvent(eventId) {
41152
+ * whose id is already a track id) — best-effort.
41153
+ *
41154
+ * **D476: only an OBJECT event has a `trackId` at all** — motion and audio
41155
+ * rows carry no such column, so this probes `OBJECT_EVENTS_COLLECTION`
41156
+ * only, never the other two. That was already true structurally, but before
41157
+ * D474 an id that MISSED here was safely "not an object event" because
41158
+ * every id was a UUID, unique across all three tables. D474 made event ids
41159
+ * per-table rowid aliases, so a motion id can now coincide with an
41160
+ * UNRELATED object row of the same number — a probe that HITS is no longer
41161
+ * proof the caller's id names that object row.
41162
+ *
41163
+ * `ownerType`, when the caller already knows it (a `motion:`/`audio:`/
41164
+ * `object:` key was parsed rather than a bare legacy id), short-circuits to
41165
+ * `null` without querying for anything but `'object'` — the caller's one
41166
+ * remaining lever against the collision, since this method has nothing left
41167
+ * to guess once the kind is in the key. Its only caller today
41168
+ * (`readEventThumbnail` in `index.ts`) still reaches this with a bare,
41169
+ * kind-less id from a URL and cannot supply it — see the D476 ADR for why
41170
+ * that residual ambiguity is accepted rather than fixed here.
41171
+ */
41172
+ async getTrackIdForEvent(eventId, ownerType) {
41173
+ if (ownerType !== void 0 && ownerType !== "object") return null;
40625
41174
  try {
40626
41175
  const row = await this.store.get.query({
40627
41176
  collection: OBJECT_EVENTS_COLLECTION,
@@ -40974,9 +41523,11 @@ var EventStore = class {
40974
41523
  });
40975
41524
  const motion = await batch(MOTION_EVENTS_COLLECTION);
40976
41525
  const audio = await batch(AUDIO_EVENTS_COLLECTION);
40977
- const ids = [...motion, ...audio];
40978
- if (ids.length > 0 && this.media !== void 0) try {
40979
- await this.media.deleteForEvents(ids);
41526
+ if ((motion.length > 0 || audio.length > 0) && this.media !== void 0) try {
41527
+ await this.media.deleteForEvents({
41528
+ motion,
41529
+ audio
41530
+ });
40980
41531
  } catch (err) {
40981
41532
  this.logger.warn("EventStore.evictTracklessBefore: event media delete failed", { meta: { error: String(err) } });
40982
41533
  }
@@ -41042,7 +41593,7 @@ var EventStore = class {
41042
41593
  * {@link existingEventIds} answers "is this event alive", which is the
41043
41594
  * orphan audit's question. The media reclaim asks a narrower one: *is this
41044
41595
  * event-owned snapshot a MOTION-VISIT still* — because the selection shape
41045
- * (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
41596
+ * (`ownerKind: 'motion', kind: 'snapshot'`) is not itself a guarantee.
41046
41597
  * Nothing structurally prevents another writer landing an event-owned
41047
41598
  * snapshot on an object or audio row, and the reclaim must not be the place
41048
41599
  * that discovers it. A per-page indexed `whereIn` on one table is cheap
@@ -41109,7 +41660,12 @@ var EventStore = class {
41109
41660
  ...motionIds,
41110
41661
  ...objectIds,
41111
41662
  ...audioIds
41112
- ]
41663
+ ],
41664
+ byKind: {
41665
+ motion: motionIds,
41666
+ object: objectIds,
41667
+ audio: audioIds
41668
+ }
41113
41669
  };
41114
41670
  }
41115
41671
  /**
@@ -41174,7 +41730,7 @@ var EventStore = class {
41174
41730
  }
41175
41731
  let mediaRows = 0;
41176
41732
  if (eventIds.length > 0 && this.media !== void 0) try {
41177
- mediaRows = await this.media.deleteForEvents(eventIds);
41733
+ mediaRows = await this.media.deleteForEvents({ object: eventIds });
41178
41734
  } catch (err) {
41179
41735
  this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
41180
41736
  }
@@ -41504,8 +42060,8 @@ function rowMatchesZone(data, zone) {
41504
42060
  const fw = data["frameWidth"];
41505
42061
  const fh = data["frameHeight"];
41506
42062
  if (typeof fw !== "number" || typeof fh !== "number") return true;
41507
- const positions = data["positions"];
41508
- if (!Array.isArray(positions)) return true;
42063
+ const positions = decodePositions(data["positions"]);
42064
+ if (positions.length === 0) return true;
41509
42065
  const positionRows = [];
41510
42066
  for (const p of positions) {
41511
42067
  if (p === null || typeof p !== "object") continue;
@@ -43080,7 +43636,13 @@ var TrackStore = class {
43080
43636
  data
43081
43637
  });
43082
43638
  matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
43083
- return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
43639
+ return matched.slice(0, limit).map((r) => {
43640
+ const track = this.rowToTrack(r.id, r.data, params.projection);
43641
+ return {
43642
+ ...track,
43643
+ positions: geometryOnlyPositions(track.positions)
43644
+ };
43645
+ });
43084
43646
  }
43085
43647
  /**
43086
43648
  * Batched multi-device recent-tracks page (`listRecentTracks`): the
@@ -43335,7 +43897,7 @@ var TrackStore = class {
43335
43897
  ...t.producingDeviceName !== void 0 ? { producingDeviceName: t.producingDeviceName } : {},
43336
43898
  firstSeen: t.firstSeen,
43337
43899
  lastSeen: t.lastSeen,
43338
- positions: [...t.positions],
43900
+ positions: encodePositions(t.positions),
43339
43901
  observations: t.positions.length,
43340
43902
  snapshots: [...t.snapshots],
43341
43903
  zonesVisited: [...t.zonesVisited],
@@ -43364,7 +43926,7 @@ var TrackStore = class {
43364
43926
  ...t.source !== void 0 ? { source: t.source } : {},
43365
43927
  firstSeen: t.firstSeen,
43366
43928
  lastSeen: t.lastSeen,
43367
- positions: [...t.positions],
43929
+ positions: encodePositions(t.positions),
43368
43930
  observations: t.positions.length,
43369
43931
  snapshots: [...t.snapshots],
43370
43932
  zonesVisited: [...t.zonesVisited],
@@ -43404,7 +43966,7 @@ var TrackStore = class {
43404
43966
  */
43405
43967
  rowToTrack(id, data, projection) {
43406
43968
  const slim = projection === "slim";
43407
- const storedPositions = slim ? [] : data["positions"] ?? [];
43969
+ const storedPositions = slim ? [] : decodePositions(data["positions"]);
43408
43970
  /**
43409
43971
  * The stored count first, the blob only as a fallback — never `0` for a
43410
43972
  * column that was not read.
@@ -44371,7 +44933,7 @@ function createDiscardJob(deps) {
44371
44933
  collection: TRACKS_COLLECTION,
44372
44934
  key: owner.ownerId
44373
44935
  });
44374
- } else if (owner.ownerKind === "event") await deps.store.delete.mutate({
44936
+ } else if (owner.ownerKind === "object") await deps.store.delete.mutate({
44375
44937
  collection: OBJECT_EVENTS_COLLECTION,
44376
44938
  key: owner.ownerId
44377
44939
  });
@@ -45001,21 +45563,29 @@ function allEventIds(events) {
45001
45563
  /**
45002
45564
  * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
45003
45565
  * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
45004
- * owning TRACK's. Two batched index reads at most, and the second only over the
45005
- * object events the first did not already answer for motion and audio are
45006
- * trackless roots, so they never need it.
45566
+ * owning TRACK's. Up to four batched index reads one PER EVENT KIND that has
45567
+ * any ids (D476: `motion` / `object` / `audio` are separate owner kinds now,
45568
+ * so a single combined read can no longer ask "does any of these ids own
45569
+ * media" — a motion id and an object id can be the same number post-D474 and
45570
+ * are answered by different rows) — plus the track fallback, only over the
45571
+ * object events the first pass did not already answer for (motion and audio
45572
+ * are trackless roots, so they never need it).
45007
45573
  *
45008
45574
  * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
45009
45575
  * LIST; swallowing here would collapse those two opposite directions into one.
45010
45576
  */
45011
45577
  async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
45012
- const eventIds = allEventIds(events);
45013
- if (eventIds.length === 0) return /* @__PURE__ */ new Set();
45014
- const ownMedia = await fetchMediaOwners({
45015
- deviceId,
45016
- ownerKind: "event",
45017
- ownerIds: eventIds
45018
- });
45578
+ const ownMedia = /* @__PURE__ */ new Set();
45579
+ for (const ownerType of EVENT_OWNER_TYPES) {
45580
+ const ownerIds = events[ownerType].map((e) => e.id);
45581
+ if (ownerIds.length === 0) continue;
45582
+ const found = await fetchMediaOwners({
45583
+ deviceId,
45584
+ ownerKind: ownerType,
45585
+ ownerIds
45586
+ });
45587
+ for (const id of found) ownMedia.add(id);
45588
+ }
45019
45589
  const residualTracks = [];
45020
45590
  const seenTracks = /* @__PURE__ */ new Set();
45021
45591
  for (const event of events.object) {
@@ -45130,6 +45700,15 @@ function reportUnvouchedClips(deps, deviceId, clips) {
45130
45700
  });
45131
45701
  }
45132
45702
  //#endregion
45703
+ //#region src/pipeline-analytics/videoclips-shared.ts
45704
+ /** Stringify the numeric event ids of one bucket. Lossless. */
45705
+ function toEventLike(events) {
45706
+ return events.map((e) => ({
45707
+ ...e,
45708
+ id: String(e.id)
45709
+ }));
45710
+ }
45711
+ //#endregion
45133
45712
  //#region src/pipeline-analytics/viewer-settings-actions.ts
45134
45713
  /**
45135
45714
  * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
@@ -46184,7 +46763,13 @@ var AnalyticsQueryFacade = class {
46184
46763
  const store = this.deps.mediaStore();
46185
46764
  if (store === null) return [];
46186
46765
  const prefix = this.deps.eventMediaPathPrefix();
46187
- return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
46766
+ const kinds = input.kind === void 0 ? void 0 : [input.kind];
46767
+ let rows = [];
46768
+ for (const ownerType of EVENT_OWNER_TYPES) {
46769
+ rows = await store.listByOwner(ownerType, input.eventId, input.deviceId, kinds);
46770
+ if (rows.length > 0) break;
46771
+ }
46772
+ return rows.map((row) => toWireMediaFile(row, prefix));
46188
46773
  }
46189
46774
  /**
46190
46775
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -46226,7 +46811,12 @@ var AnalyticsQueryFacade = class {
46226
46811
  const store = this.deps.mediaStore();
46227
46812
  if (store === null) return [];
46228
46813
  const prefix = this.deps.eventMediaPathPrefix();
46229
- return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
46814
+ let rows = [];
46815
+ for (const ownerType of EVENT_OWNER_TYPES) {
46816
+ rows = await store.listInfoByOwner(ownerType, input.eventId, input.deviceId);
46817
+ if (rows.length > 0) break;
46818
+ }
46819
+ return rows.map((row) => toWireMediaFileInfo(row, prefix));
46230
46820
  }
46231
46821
  /**
46232
46822
  * A track's media WITHOUT the bytes.
@@ -46260,9 +46850,9 @@ var AnalyticsQueryFacade = class {
46260
46850
  object: 0,
46261
46851
  audio: 0
46262
46852
  };
46263
- const { counts, ids } = await eventStore.pruneBefore(input);
46853
+ const { counts, ids, byKind } = await eventStore.pruneBefore(input);
46264
46854
  const mediaStore = this.deps.mediaStore();
46265
- if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents([...ids]);
46855
+ if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents(byKind);
46266
46856
  const { motion, object, audio } = counts;
46267
46857
  if (motion + object + audio > 0) this.deps.logger().info("analytics event eviction (floor)", {
46268
46858
  tags: { deviceId: input.deviceId },
@@ -46384,7 +46974,11 @@ var AnalyticsQueryFacade = class {
46384
46974
  ...evicted.audio
46385
46975
  ];
46386
46976
  const mediaStore = this.deps.mediaStore();
46387
- if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents(ids);
46977
+ if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents({
46978
+ motion: evicted.motion,
46979
+ object: objectIds,
46980
+ audio: evicted.audio
46981
+ });
46388
46982
  const counts = {
46389
46983
  motion: evicted.motion.length,
46390
46984
  object: objectIds.length,
@@ -49223,9 +49817,13 @@ function resolveCropForcedMedia(files, pickClean, detailFiles = []) {
49223
49817
  * Event + track media (the body/scene) and face/plate media (the detail
49224
49818
  * crops). Face rows live under `face-${trackId}`; a track can close with
49225
49819
  * `hasEmbeddedFace` and ZERO track files (0e343f86).
49820
+ *
49821
+ * `eventFiles` is the event's OWN media, already fetched by the caller (the
49822
+ * same rows the default path reuses via {@link collectDefaultEventMedia}) —
49823
+ * this function no longer lists by owner kind `'event'` itself (D476; see
49824
+ * {@link CropForcedOwnerKind}).
49226
49825
  */
49227
- async function collectCropForcedMedia(source, id) {
49228
- const eventFiles = await source.listByOwner("event", id);
49826
+ async function collectCropForcedMedia(source, id, eventFiles) {
49229
49827
  const trackFiles = await source.listByOwner("track", id);
49230
49828
  const ownerTrackId = trackFiles.length === 0 ? await source.getTrackIdForEvent(id) : null;
49231
49829
  const owningTrackFiles = ownerTrackId === null ? [] : await source.listByOwner("track", ownerTrackId);
@@ -56179,11 +56777,23 @@ var PACKAGE_IMPORTANCE = 1;
56179
56777
  * stay `getObjectEvents`-output-valid. */
56180
56778
  var DELIVERED_STATE = "idle";
56181
56779
  var PICKED_UP_STATE = "left";
56182
- /** Deterministic durable-event ids keyed on the stationary entry id. */
56183
- function deliveredEventId(entryId) {
56780
+ /**
56781
+ * Deterministic IDEMPOTENCE keys, one per (stationary entry, phase).
56782
+ *
56783
+ * These were the event ids until D474 made an event id the SQLite rowid, which
56784
+ * the database assigns and a producer cannot choose. They kept their exact
56785
+ * spelling and moved to `ObjectEvent.idempotencyKey`: the gates below are the
56786
+ * only reason a restart does not re-deliver every parcel, and the strings are
56787
+ * on 2.1 million existing rows' primary key, so a row written before the
56788
+ * rebuild is still recognisable by the same text.
56789
+ *
56790
+ * They are NOT event ids any more. `eventId` on the emitted payload and in
56791
+ * every log line is the row's real id.
56792
+ */
56793
+ function deliveredIdempotencyKey(entryId) {
56184
56794
  return `pa-pkg-${entryId}-delivered`;
56185
56795
  }
56186
- function pickedUpEventId(entryId) {
56796
+ function pickedUpIdempotencyKey(entryId) {
56187
56797
  return `pa-pkg-${entryId}-pickedup`;
56188
56798
  }
56189
56799
  var PackageDropDetector = class {
@@ -56321,26 +56931,28 @@ var PackageDropDetector = class {
56321
56931
  });
56322
56932
  return;
56323
56933
  }
56324
- const eventId = deliveredEventId(entry.id);
56325
- if ((await this.deps.events.queryObject({
56934
+ const idempotencyKey = deliveredIdempotencyKey(entry.id);
56935
+ const alreadyDelivered = (await this.deps.events.queryObject({
56326
56936
  deviceId: entry.deviceId,
56327
- classFilter: "package"
56328
- })).some((e) => e.id === eventId)) {
56937
+ classFilter: PACKAGE_EVENT_CLASS
56938
+ })).find((e) => e.idempotencyKey === idempotencyKey || String(e.id) === idempotencyKey);
56939
+ if (alreadyDelivered !== void 0) {
56329
56940
  this.deps.logger.info("package gate 5/5 idempotence — delivery already recorded", {
56330
56941
  tags,
56331
56942
  meta: {
56332
56943
  entryId: entry.id,
56333
- eventId
56944
+ idempotencyKey,
56945
+ eventId: alreadyDelivered.id
56334
56946
  }
56335
56947
  });
56336
56948
  return;
56337
56949
  }
56338
- const ev = {
56339
- id: eventId,
56950
+ const draft = {
56340
56951
  kind: "object",
56341
56952
  deviceId: entry.deviceId,
56342
56953
  timestamp,
56343
56954
  source: "pipeline",
56955
+ idempotencyKey,
56344
56956
  trackId: entry.sourceTrackId ?? entry.id,
56345
56957
  className: PACKAGE_EVENT_CLASS,
56346
56958
  ...entry.label !== void 0 ? { label: entry.label } : {},
@@ -56358,12 +56970,26 @@ var PackageDropDetector = class {
56358
56970
  ...entry.keyFrameMediaKey !== void 0 ? { mediaKey: entry.keyFrameMediaKey } : {},
56359
56971
  importance: PACKAGE_IMPORTANCE
56360
56972
  };
56361
- await this.deps.events.insertObject(ev);
56973
+ const rowId = await this.deps.events.insertObject(draft);
56974
+ if (rowId === null) {
56975
+ this.deps.logger.warn("package delivery NOT emitted — the event row did not persist", {
56976
+ tags,
56977
+ meta: {
56978
+ entryId: entry.id,
56979
+ idempotencyKey
56980
+ }
56981
+ });
56982
+ return;
56983
+ }
56984
+ const ev = {
56985
+ ...draft,
56986
+ id: rowId
56987
+ };
56362
56988
  this.deps.onPersisted?.(ev, "delivered");
56363
56989
  this.deps.emit.delivered({
56364
56990
  deviceId: entry.deviceId,
56365
56991
  entryId: entry.id,
56366
- eventId,
56992
+ eventId: String(rowId),
56367
56993
  className: entry.className,
56368
56994
  zoneIds: hitZones,
56369
56995
  ...entry.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: entry.keyFrameMediaKey } : {},
@@ -56379,7 +57005,8 @@ var PackageDropDetector = class {
56379
57005
  tags: { deviceId: entry.deviceId },
56380
57006
  meta: {
56381
57007
  entryId: entry.id,
56382
- eventId,
57008
+ eventId: rowId,
57009
+ idempotencyKey,
56383
57010
  zoneIds: hitZones,
56384
57011
  dwellMs,
56385
57012
  areaFrac: Number(areaFrac.toFixed(5)),
@@ -56397,13 +57024,14 @@ var PackageDropDetector = class {
56397
57024
  });
56398
57025
  return;
56399
57026
  }
56400
- const deliveredId = deliveredEventId(entry.id);
56401
- const pickedUpId = pickedUpEventId(entry.id);
57027
+ const deliveredKey = deliveredIdempotencyKey(entry.id);
57028
+ const pickedUpKey = pickedUpIdempotencyKey(entry.id);
56402
57029
  const rows = await this.deps.events.queryObject({
56403
57030
  deviceId: entry.deviceId,
56404
57031
  classFilter: PACKAGE_EVENT_CLASS
56405
57032
  });
56406
- const delivered = rows.find((e) => e.id === deliveredId);
57033
+ const matchesKey = (e, key) => e.idempotencyKey === key || String(e.id) === key;
57034
+ const delivered = rows.find((e) => matchesKey(e, deliveredKey));
56407
57035
  if (delivered === void 0) {
56408
57036
  this.deps.logger.debug("package pick-up skipped — no delivery row for this entry", {
56409
57037
  tags: { deviceId: entry.deviceId },
@@ -56414,12 +57042,14 @@ var PackageDropDetector = class {
56414
57042
  });
56415
57043
  return;
56416
57044
  }
56417
- if (rows.some((e) => e.id === pickedUpId)) {
57045
+ const alreadyPickedUp = rows.find((e) => matchesKey(e, pickedUpKey));
57046
+ if (alreadyPickedUp !== void 0) {
56418
57047
  this.deps.logger.info("package pick-up skipped — already recorded", {
56419
57048
  tags: { deviceId: entry.deviceId },
56420
57049
  meta: {
56421
57050
  entryId: entry.id,
56422
- eventId: pickedUpId
57051
+ eventId: alreadyPickedUp.id,
57052
+ idempotencyKey: pickedUpKey
56423
57053
  }
56424
57054
  });
56425
57055
  return;
@@ -56431,17 +57061,17 @@ var PackageDropDetector = class {
56431
57061
  tags: { deviceId: entry.deviceId },
56432
57062
  meta: {
56433
57063
  entryId: entry.id,
56434
- eventId: pickedUpId,
57064
+ idempotencyKey: pickedUpKey,
56435
57065
  packageTrackId,
56436
57066
  pickupAt: entry.lastConfirmedAt
56437
57067
  }
56438
57068
  });
56439
- const ev = {
56440
- id: pickedUpId,
57069
+ const draft = {
56441
57070
  kind: "object",
56442
57071
  deviceId: entry.deviceId,
56443
57072
  timestamp,
56444
57073
  source: "pipeline",
57074
+ idempotencyKey: pickedUpKey,
56445
57075
  trackId: mediaTrackId,
56446
57076
  className: PACKAGE_EVENT_CLASS,
56447
57077
  ...entry.label !== void 0 ? { label: entry.label } : {},
@@ -56459,12 +57089,26 @@ var PackageDropDetector = class {
56459
57089
  ...collector?.keyFrameMediaKey !== void 0 ? { mediaKey: collector.keyFrameMediaKey } : {},
56460
57090
  importance: PACKAGE_IMPORTANCE
56461
57091
  };
56462
- await this.deps.events.insertObject(ev);
57092
+ const rowId = await this.deps.events.insertObject(draft);
57093
+ if (rowId === null) {
57094
+ this.deps.logger.warn("package pick-up NOT emitted — the event row did not persist", {
57095
+ tags: { deviceId: entry.deviceId },
57096
+ meta: {
57097
+ entryId: entry.id,
57098
+ idempotencyKey: pickedUpKey
57099
+ }
57100
+ });
57101
+ return;
57102
+ }
57103
+ const ev = {
57104
+ ...draft,
57105
+ id: rowId
57106
+ };
56463
57107
  this.deps.onPersisted?.(ev, "picked-up");
56464
57108
  this.deps.emit.pickedUp({
56465
57109
  deviceId: entry.deviceId,
56466
57110
  entryId: entry.id,
56467
- deliveredEventId: deliveredId,
57111
+ deliveredEventId: String(delivered.id),
56468
57112
  className: entry.className,
56469
57113
  ...collector !== void 0 ? { collectorTrackId: collector.trackId } : {},
56470
57114
  timestamp
@@ -56473,8 +57117,9 @@ var PackageDropDetector = class {
56473
57117
  tags: { deviceId: entry.deviceId },
56474
57118
  meta: {
56475
57119
  entryId: entry.id,
56476
- eventId: pickedUpId,
56477
- deliveredEventId: deliveredId,
57120
+ eventId: rowId,
57121
+ idempotencyKey: pickedUpKey,
57122
+ deliveredEventId: delivered.id,
56478
57123
  mediaTrackId,
56479
57124
  ...collector !== void 0 ? {
56480
57125
  collectorTrackId: collector.trackId,
@@ -58909,7 +59554,7 @@ async function loadRebuildSource(deps, trackId) {
58909
59554
  const cropInfo = media.find((m) => m.kind === "thumbnail");
58910
59555
  const peak = await deps.events.peakForTrack(trackId);
58911
59556
  const events = await deps.events.queryObjectByTrackIds([trackId]);
58912
- const best = events.find((e) => e.id === peak.bestEventId) ?? events[0];
59557
+ const best = events.find((e) => String(e.id) === peak.bestEventId) ?? events[0];
58913
59558
  if (best === void 0) return null;
58914
59559
  const common = {
58915
59560
  trackId,
@@ -62802,7 +63447,7 @@ var ANALYTICS_COLLECTIONS = [
62802
63447
  classification: {
62803
63448
  kind: "owner-indirection",
62804
63449
  derivation: "explicit",
62805
- why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. A RETIRED ownerKind (the deleted group entity) resolves to an owner that can never exist again and is always an orphan. The mapping is `resolveMediaOwner` in orphan-audit.ts."
63450
+ why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'motion'/'object'/'audio' (D476 split 'event' into the three tables it used to cover), and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. A RETIRED ownerKind (the deleted group entity) resolves to an owner that can never exist again and is always an orphan. The mapping is `resolveMediaOwner` in orphan-audit.ts."
62806
63451
  }
62807
63452
  },
62808
63453
  {
@@ -63016,7 +63661,9 @@ function resolveMediaOwner(row) {
63016
63661
  kind: "track",
63017
63662
  trackId: ownerId
63018
63663
  };
63019
- case "event": return {
63664
+ case "motion":
63665
+ case "object":
63666
+ case "audio": return {
63020
63667
  kind: "event",
63021
63668
  eventId: ownerId
63022
63669
  };
@@ -64285,8 +64932,21 @@ var FULL_FRAME_BBOX = {
64285
64932
  w: 1,
64286
64933
  h: 1
64287
64934
  };
64935
+ /** The same classification the RUNNER applies to decide "full frame" from the
64936
+ * bbox (`getNativeCrop`), restated here so the two cannot drift. */
64937
+ function isFullFrameBbox(bbox) {
64938
+ return bbox.x <= .001 && bbox.y <= .001 && bbox.w >= .999 && bbox.h >= .999;
64939
+ }
64288
64940
  /** Throttle for the native-crop HIT/FALLBACK metric window line. */
64289
64941
  var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
64942
+ /**
64943
+ * Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (the
64944
+ * `keyFrame`/group-shot rung, ~1–1.5 MB on a 4K camera) rather than a boxed
64945
+ * timeline tile or a pinned ≤640 detection frame (tens of kB). The two belong
64946
+ * in different counters or the window line says "full frames" and means two
64947
+ * payload classes three orders of magnitude apart. (D470)
64948
+ */
64949
+ var FULL_FRAME_TILE_WIDTH_CEILING = 1920;
64290
64950
  function createNativeFrameTransport(deps) {
64291
64951
  const { api, logger } = deps;
64292
64952
  const pipelineRunnerApi = api.pipelineRunner;
@@ -64360,16 +65020,35 @@ function createNativeFrameTransport(deps) {
64360
65020
  tier: reply.tier === "ram-fullframe" ? "ram-fullframe" : "native"
64361
65021
  };
64362
65022
  };
64363
- const getRemoteFrame = async (handle) => requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame);
64364
- const fetchNativeFullFrameTiered = async (handle, maxWidth) => requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame);
65023
+ const getRemoteFrame = async (handle) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame));
65024
+ const fetchNativeFullFrameTiered = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame));
64365
65025
  const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
64366
65026
  const cropMetricLogger = logger.child("NativeCrop");
64367
65027
  let nativeHits = 0;
64368
65028
  let nativeFallbacks = 0;
64369
65029
  let lastCropMetricAt = 0;
65030
+ let roiFetches = 0;
65031
+ let fullFrames = 0;
65032
+ let nativeFullFrames = 0;
64370
65033
  const bumpCropMetric = (hit) => {
64371
65034
  if (hit) nativeHits += 1;
64372
65035
  else nativeFallbacks += 1;
65036
+ maybeLogCropWindow();
65037
+ };
65038
+ /**
65039
+ * Publish the window once `request` has SETTLED — never before, or the line
65040
+ * carries a window whose own outcome has not been counted yet and throttles
65041
+ * away the one that has. `finally`, so the deliberate throw of
65042
+ * `fetchNativeFullFrameTiered` still reaches `persistTrackKeyFrame`.
65043
+ */
65044
+ const closeWindowAfter = async (request) => {
65045
+ try {
65046
+ return await request;
65047
+ } finally {
65048
+ maybeLogCropWindow();
65049
+ }
65050
+ };
65051
+ const maybeLogCropWindow = () => {
64373
65052
  const now = Date.now();
64374
65053
  if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
64375
65054
  lastCropMetricAt = now;
@@ -64379,11 +65058,20 @@ function createNativeFrameTransport(deps) {
64379
65058
  detectionFrameFallbacks: nativeFallbacks,
64380
65059
  wireFetches: dedupe.fetched,
64381
65060
  coalesced: dedupe.coalesced,
64382
- reusedInWindow: dedupe.reused
65061
+ reusedInWindow: dedupe.reused,
65062
+ roiCalls: roiFetches,
65063
+ tileFullFrameCalls: fullFrames,
65064
+ nativeFullFrameCalls: nativeFullFrames
64383
65065
  } });
65066
+ roiFetches = 0;
65067
+ fullFrames = 0;
65068
+ nativeFullFrames = 0;
64384
65069
  };
64385
65070
  const requestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
64386
65071
  if (!pipelineRunnerApi?.getNativeCrop) return null;
65072
+ if (isFullFrameBbox(bbox)) if (maxWidth !== void 0 && maxWidth > FULL_FRAME_TILE_WIDTH_CEILING) nativeFullFrames += 1;
65073
+ else fullFrames += 1;
65074
+ else roiFetches += 1;
64387
65075
  const native = await pipelineRunnerApi.getNativeCrop.query({
64388
65076
  handle: frameHandle,
64389
65077
  bbox,
@@ -66432,7 +67120,7 @@ var EventMediaDispatcher = class {
66432
67120
  if (!childCropData) continue;
66433
67121
  await this.deps.mediaStore.put({
66434
67122
  deviceId,
66435
- ownerKind: "event",
67123
+ ownerKind: "object",
66436
67124
  ownerId: ev.eventId,
66437
67125
  kind: child.kind,
66438
67126
  timestamp: ev.timestamp,
@@ -66596,7 +67284,7 @@ var MotionEventSnapshotCapturer = class {
66596
67284
  try {
66597
67285
  await this.deps.media.put({
66598
67286
  deviceId: input.deviceId,
66599
- ownerKind: "event",
67287
+ ownerKind: "motion",
66600
67288
  ownerId: input.eventId,
66601
67289
  kind: "snapshot",
66602
67290
  timestamp: input.timestamp,
@@ -69569,48 +70257,111 @@ function compareSummaryScores(a, b) {
69569
70257
  if (a.visible !== b.visible) return a.visible - b.visible;
69570
70258
  return a.quality - b.quality;
69571
70259
  }
69572
- /** A candidate replaces the best only when it is STRICTLY better — a tie keeps
69573
- * the pixels already materialised (they cost a native round trip). */
69574
- function isBetterSummaryScore(candidate, best) {
69575
- if (candidate.visible === 0) return false;
69576
- return best === null || compareSummaryScores(candidate, best) > 0;
69577
- }
69578
- //#endregion
69579
- //#region src/pipeline-analytics/summary/group-shot.ts
69580
- /**
69581
- * The running argmax of one open summary's group shot.
69582
- *
69583
- * RAM only, on purpose: it decides WHEN pixels are worth a native round trip
69584
- * (only when the maximum improves — D9/D18), it is not the record of the
69585
- * shot. The record is the media row the caller writes under the summary's
69586
- * id; after a restart an open summary simply starts its argmax again and
69587
- * replaces the row the next time it beats what it can remember, which is
69588
- * nothing. That is a weaker shot for one session, never a wrong one.
69589
- */
69590
70260
  var GroupShotTracker = class {
69591
- best = /* @__PURE__ */ new Map();
70261
+ states = /* @__PURE__ */ new Map();
70262
+ offered = 0;
70263
+ materialised = 0;
70264
+ byGap = 0;
70265
+ byMargin = 0;
70266
+ byWorse = 0;
69592
70267
  /**
69593
- * Offer a frame. Returns `true` when it beats the summary's best so far —
69594
- * the caller's cue to materialise the pixels for THIS frame, now, while its
69595
- * handle is still leased.
70268
+ * Offer a frame. Returns `true` when it is worth materialising NOW the
70269
+ * caller's cue to fetch the pixels for THIS frame, while its handle is
70270
+ * still leased.
70271
+ *
70272
+ * The clock is `frame.timestamp`; the caller injects no second one.
69596
70273
  */
69597
70274
  offer(summaryId, score, frame) {
69598
- if (!isBetterSummaryScore(score, (this.best.get(summaryId) ?? null)?.score ?? null)) return false;
69599
- this.best.set(summaryId, {
70275
+ if (score.visible === 0) return false;
70276
+ this.offered += 1;
70277
+ const state = this.states.get(summaryId);
70278
+ if (state === void 0) {
70279
+ this.states.set(summaryId, {
70280
+ delivered: null,
70281
+ lastAttemptAt: frame.timestamp,
70282
+ unlanded: 1
70283
+ });
70284
+ this.materialised += 1;
70285
+ return true;
70286
+ }
70287
+ if (state.lastAttemptAt !== void 0 && frame.timestamp - state.lastAttemptAt < 2e3) {
70288
+ this.byGap += 1;
70289
+ return false;
70290
+ }
70291
+ if (!this.isWorthPixels(state, score)) return false;
70292
+ state.lastAttemptAt = frame.timestamp;
70293
+ state.unlanded += 1;
70294
+ this.materialised += 1;
70295
+ return true;
70296
+ }
70297
+ /**
70298
+ * Nothing has landed yet → keep trying (D58) until the budget is spent.
70299
+ * Something has landed → beat it, by the margin on the quality tiebreak.
70300
+ */
70301
+ isWorthPixels(state, score) {
70302
+ const delivered = state.delivered;
70303
+ if (delivered === null) {
70304
+ if (state.unlanded >= 4) {
70305
+ this.byMargin += 1;
70306
+ return false;
70307
+ }
70308
+ return true;
70309
+ }
70310
+ if (compareSummaryScores(score, delivered.score) <= 0) {
70311
+ this.byWorse += 1;
70312
+ return false;
70313
+ }
70314
+ if (score.visible === delivered.score.visible && score.quality - delivered.score.quality < .05) {
70315
+ this.byMargin += 1;
70316
+ return false;
70317
+ }
70318
+ return true;
70319
+ }
70320
+ /**
70321
+ * A materialisation LANDED. Only this advances the baseline the next
70322
+ * candidate must beat — a fetch that missed leaves the summary exactly as
70323
+ * picture-less as it was, and the next frame may be worse and still worth
70324
+ * taking.
70325
+ */
70326
+ noteLanded(summaryId, score, frame) {
70327
+ const state = this.states.get(summaryId);
70328
+ if (state === void 0) return;
70329
+ state.delivered = {
69600
70330
  score,
69601
70331
  frame
69602
- });
69603
- return true;
70332
+ };
70333
+ state.unlanded = 0;
69604
70334
  }
69605
- bestOf(summaryId) {
69606
- return this.best.get(summaryId) ?? null;
70335
+ /** The shot on disk for this summary, as this process last saw it land. */
70336
+ deliveredOf(summaryId) {
70337
+ return this.states.get(summaryId)?.delivered ?? null;
69607
70338
  }
69608
70339
  /** A sealed or evicted summary takes no more frames; drop its state. */
69609
70340
  forget(summaryId) {
69610
- this.best.delete(summaryId);
70341
+ this.states.delete(summaryId);
70342
+ }
70343
+ /**
70344
+ * What the gate did since the last read. Shipped WITH the gate: without it
70345
+ * the only evidence for a fetch that did NOT happen is hub-main's byte
70346
+ * counter, which answers a different question a minute later.
70347
+ */
70348
+ drainCounts() {
70349
+ const counts = {
70350
+ offered: this.offered,
70351
+ materialised: this.materialised,
70352
+ byGap: this.byGap,
70353
+ byMargin: this.byMargin,
70354
+ byWorse: this.byWorse
70355
+ };
70356
+ this.offered = 0;
70357
+ this.materialised = 0;
70358
+ this.byGap = 0;
70359
+ this.byMargin = 0;
70360
+ this.byWorse = 0;
70361
+ return counts;
69611
70362
  }
69612
70363
  get size() {
69613
- return this.best.size;
70364
+ return this.states.size;
69614
70365
  }
69615
70366
  };
69616
70367
  //#endregion
@@ -69907,7 +70658,7 @@ async function buildTrainingExportPlan(readers, input) {
69907
70658
  scope: "plate"
69908
70659
  },
69909
70660
  ...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
69910
- ownerKind: "event",
70661
+ ownerKind: "object",
69911
70662
  ownerId: eventId,
69912
70663
  scope: `events/${eventId}`
69913
70664
  }))
@@ -70749,6 +71500,8 @@ var RETRAIN_EXPORT_MAX = 2e4;
70749
71500
  * surface to turn the refinement pipeline on/off for a camera.
70750
71501
  */
70751
71502
  var TTL_SWEEP_INTERVAL_MS = 5e3;
71503
+ /** Throttle for the group-shot gate's window line (D470). */
71504
+ var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
70752
71505
  /**
70753
71506
  * How often the addon asks "is a gallery sample outside the cluster face
70754
71507
  * space?". A flip is rare and the check is two cheap reads — a minute keeps
@@ -70876,7 +71629,6 @@ var AUDIO_EPISODE_PERSIST_MS = 2e3;
70876
71629
  * the persistence (D62).
70877
71630
  */
70878
71631
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
70879
- var MOTION_EVENT_HEARTBEAT_MS = 5e3;
70880
71632
  /**
70881
71633
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
70882
71634
  * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
@@ -71005,8 +71757,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71005
71757
  summaryStore = null;
71006
71758
  /** The ONLY writer of a summary's lifecycle (D359). */
71007
71759
  summarySealer = null;
71008
- /** The running argmax of every open summary's group shot (D359 phase 3). RAM only. */
71760
+ /** The materialisation gate on every open summary's group shot (D359 phase 3, D470). RAM only. */
71009
71761
  groupShots = new GroupShotTracker();
71762
+ /** Throttle for the group-shot gate's window line (D470). */
71763
+ lastGroupShotMetricAt = 0;
71010
71764
  /**
71011
71765
  * Tracks a gallery match has named, for the group-shot score's recognition
71012
71766
  * term. "Recognised so far" rather than "at that instant": the match arrives
@@ -71443,7 +72197,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71443
72197
  secondarySubjects = new SecondarySubjectQueue();
71444
72198
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
71445
72199
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
71446
- lastMotionInsertByDevice = /* @__PURE__ */ new Map();
72200
+ lastMotionTickByDevice = /* @__PURE__ */ new Map();
72201
+ motionEpisodes = null;
71447
72202
  levelStateByDevice = /* @__PURE__ */ new Map();
71448
72203
  /** Per-device corroboration window for classified audio (see
71449
72204
  * `audio-confirm-window.ts`). Torn down wherever `levelStateByDevice` is —
@@ -72612,6 +73367,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72612
73367
  media: this.mediaStore
72613
73368
  });
72614
73369
  this.eventStore = eventStore;
73370
+ this.motionEpisodes = new MotionEpisodeTracker({
73371
+ closeAfterMs: MOTION_CLOSE_AFTER_MS,
73372
+ logger: logger.child("MotionEpisodeTracker"),
73373
+ insertRow: (input) => this.insertMotionEpisodeRow(input.deviceId, input.timestamp, input.regionData),
73374
+ closeRow: (input) => this.closeMotionEpisodeRow(input),
73375
+ onNewEdge: (input) => this.onMotionEpisodeEdge(input),
73376
+ onTick: (input) => this.onMotionEpisodeTick(input)
73377
+ });
72615
73378
  this.sensorEventStore = new SensorEventStore({
72616
73379
  store: api.settingsStore,
72617
73380
  logger: logger.child("SensorEventStore")
@@ -73270,11 +74033,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73270
74033
  * The mosaic is filed through the SAME `mediaStore` every event thumbnail
73271
74034
  * goes through — the operator's directive, verbatim: same data structure,
73272
74035
  * same storage location, no second retained store. It is written under
73273
- * `ownerKind: 'event'` with the window's own owner id, so it lands beside the
73274
- * cameras' event media (`<deviceId>/events/event/<ownerId>/…`) and is swept
73275
- * by the same machinery. It is deliberately NOT owned by a track: a track's
73276
- * media list is a LIVE viewer surface, and a cross-camera mosaic appearing in
73277
- * one track's tiles would be a picture that lies about what it is of.
74036
+ * `ownerKind: 'object'` with the window's own SYNTHETIC owner id (never a
74037
+ * real object-event id), so it lands beside the cameras' event media
74038
+ * (`<deviceId>/events/object/<ownerId>/…`) and is swept by the same
74039
+ * ownership-based orphan audit: `resolveMediaOwner` maps any of
74040
+ * `motion`/`object`/`audio` to the same generic `{kind:'event'}` OwnerRef,
74041
+ * `EventStore.existingEventIds` never finds the synthetic id in any of the
74042
+ * three tables, and the row is reclaimed as an orphan once past the grace
74043
+ * window — same effective lifecycle as before D476, when `event` covered
74044
+ * all three. `object` is a pragmatic choice among equals here, not a
74045
+ * structural fact about the mosaic (it is not itself an object-event row);
74046
+ * flagged in the D476 ADR as a spot a future dedicated owner kind may suit
74047
+ * better. It is deliberately NOT owned by a track: a track's media list is a
74048
+ * LIVE viewer surface, and a cross-camera mosaic appearing in one track's
74049
+ * tiles would be a picture that lies about what it is of.
73278
74050
  */
73279
74051
  buildSummaryPorts(stores) {
73280
74052
  return {
@@ -73315,7 +74087,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73315
74087
  },
73316
74088
  storeMosaic: async ({ deviceId, ownerId, timestamp, jpeg }) => stores.mediaStore.put({
73317
74089
  deviceId,
73318
- ownerKind: "event",
74090
+ ownerKind: "object",
73319
74091
  ownerId,
73320
74092
  kind: "fullFrame",
73321
74093
  timestamp,
@@ -73555,7 +74327,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73555
74327
  listEventIdsForTracks: async (trackIds) => {
73556
74328
  if (!eventStore || trackIds.length === 0) return [];
73557
74329
  return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
73558
- eventId: e.id,
74330
+ eventId: String(e.id),
73559
74331
  trackId: e.trackId
73560
74332
  }]);
73561
74333
  },
@@ -74351,9 +75123,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
74351
75123
  })
74352
75124
  ]);
74353
75125
  return {
74354
- motion,
74355
- object,
74356
- audio
75126
+ motion: toEventLike(motion),
75127
+ object: toEventLike(object),
75128
+ audio: toEventLike(audio)
74357
75129
  };
74358
75130
  },
74359
75131
  fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
@@ -74508,6 +75280,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
74508
75280
  this.alarmPanelConvergence = null;
74509
75281
  for (const unsub of this.subscriptionUnsubs) unsub();
74510
75282
  this.subscriptionUnsubs = [];
75283
+ await this.motionEpisodes?.closeAll("addon-shutdown");
74511
75284
  await this.embeddingDispatcher?.stop();
74512
75285
  this.embeddingDispatcher = null;
74513
75286
  await this.notificationCenter?.stop();
@@ -75112,11 +75885,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75112
75885
  byState
75113
75886
  } });
75114
75887
  }
75115
- await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
75888
+ const objectEvents = await this.persistObjectEvents(deviceId, result.objectEvents);
75889
+ const appearanceEvents = await this.persistObjectEvents(deviceId, result.appearanceEvents);
75116
75890
  if (this.notificationCenter !== null) {
75117
75891
  const overlaps = processor.getLastZoneOverlaps();
75118
75892
  const rejections = processor.getLastZoneRejections();
75119
- for (const e of result.objectEvents) {
75893
+ for (const e of objectEvents) {
75120
75894
  if (e.zones && e.zones.length > 0) {
75121
75895
  const m = e.trackId ? overlaps.get(e.trackId) : void 0;
75122
75896
  this.ctx.logger.info("zone membership stamped on event", {
@@ -75170,7 +75944,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75170
75944
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
75171
75945
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
75172
75946
  if (this.eventMediaDispatcher && frameHandle) {
75173
- const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
75947
+ const mediaEvents = [...objectEvents, ...appearanceEvents];
75174
75948
  const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
75175
75949
  const uncroppable = mediaEvents.filter((e) => e.bbox === void 0 || e.bbox.w <= 0 || e.bbox.h <= 0);
75176
75950
  if (uncroppable.length > 0) this.ctx.logger.debug("event media: no crop for events with no box in this frame (a departed track has nothing to cut)", {
@@ -75184,7 +75958,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75184
75958
  const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
75185
75959
  const childCrops = childCropsByEvent.get(e.id);
75186
75960
  return {
75187
- eventId: e.id,
75961
+ eventId: String(e.id),
75188
75962
  timestamp: e.timestamp,
75189
75963
  bbox: e.bbox,
75190
75964
  className: e.className,
@@ -75338,7 +76112,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75338
76112
  ...frameHandle !== void 0 ? { frameHandle } : {}
75339
76113
  });
75340
76114
  if (objectEmbeddingBests.length > 0) this.persistObjectEmbeddingBests(deviceId, result.timestamp, objectEmbeddingBests, frameHandle, result.frameWidth, result.frameHeight, mediaSettings.cropPadding);
75341
- for (const e of result.objectEvents) this.ctx.eventBus.emit({
76115
+ for (const e of objectEvents) this.ctx.eventBus.emit({
75342
76116
  id: `pa-${e.id}`,
75343
76117
  timestamp: new Date(e.timestamp),
75344
76118
  source: {
@@ -75436,6 +76210,45 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75436
76210
  * eight settings kinds funnel through on a cache miss, so calling the raw
75437
76211
  * reads directly here means eight kinds expiring in the same window each
75438
76212
  * pay for the same two reads — see `store/settings-raw-mirror.ts`. */
76213
+ /**
76214
+ * Insert the frame's object-event DRAFTS and return them with the id the
76215
+ * database actually assigned (D474).
76216
+ *
76217
+ * The frame path cannot name an event: the id is a SQLite rowid, minted by
76218
+ * the write. So this is the ONE place a draft becomes an `ObjectEvent`, and
76219
+ * everything downstream — the media targets, the notification-centre hook,
76220
+ * the bus emit — consumes what comes back rather than what went in.
76221
+ *
76222
+ * Order is preserved. A draft whose row did not land is DROPPED and SAID:
76223
+ * it owns no media, can be named on no bus and can match no rule, and an
76224
+ * invented id would put `event:NaN` in a media key. A branch that accepts
76225
+ * work and produces nothing must log it.
76226
+ */
76227
+ async persistObjectEvents(deviceId, drafts) {
76228
+ if (drafts.length === 0) return [];
76229
+ const store = this.eventStore;
76230
+ if (store === null) return [];
76231
+ const ids = await Promise.all(drafts.map((d) => store.insertObject(d)));
76232
+ const persisted = [];
76233
+ for (const [i, draft] of drafts.entries()) {
76234
+ const id = ids[i];
76235
+ if (id === void 0 || id === null) continue;
76236
+ persisted.push({
76237
+ ...draft,
76238
+ id
76239
+ });
76240
+ }
76241
+ const lost = drafts.length - persisted.length;
76242
+ if (lost > 0) this.ctx.logger.warn("object events did not persist — no id, so no media, no bus, no rule", {
76243
+ tags: { deviceId },
76244
+ meta: {
76245
+ lost,
76246
+ of: drafts.length,
76247
+ trackIds: [...new Set(drafts.map((d) => d.trackId ?? "none"))]
76248
+ }
76249
+ });
76250
+ return persisted;
76251
+ }
75439
76252
  async readDeviceSettings(deviceId, resolve) {
75440
76253
  const globalRaw = this.ctxIfReady !== null ? await this.rawGlobalStoreMirror.resolve() : {};
75441
76254
  const deviceRaw = await this.rawDeviceBlobMirror.resolve(deviceId);
@@ -76214,7 +77027,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76214
77027
  recognized: this.recognizedTrackIds.has(t.trackId)
76215
77028
  }))
76216
77029
  });
76217
- if (!this.groupShots.offer(open.id, score, { timestamp: result.timestamp })) return;
77030
+ const frameRef = { timestamp: result.timestamp };
77031
+ const worthPixels = this.groupShots.offer(open.id, score, frameRef);
77032
+ this.logGroupShotWindow(deviceId);
77033
+ if (!worthPixels) return;
76218
77034
  const getNative = this.getNativeKeyFrameRgb;
76219
77035
  const mediaStore = this.mediaStore;
76220
77036
  if (!getNative || !mediaStore) return;
@@ -76236,17 +77052,48 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76236
77052
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
76237
77053
  })
76238
77054
  }).then((outcome) => {
76239
- if (outcome?.kind === "landed") this.ctx.logger.debug("summary group shot landed", {
76240
- tags: { deviceId },
76241
- meta: {
76242
- summaryId,
76243
- visible: score.visible,
76244
- quality: Number(score.quality.toFixed(3))
76245
- }
76246
- });
77055
+ if (outcome?.kind === "landed") {
77056
+ this.groupShots.noteLanded(summaryId, score, frameRef);
77057
+ this.ctx.logger.debug("summary group shot landed", {
77058
+ tags: { deviceId },
77059
+ meta: {
77060
+ summaryId,
77061
+ visible: score.visible,
77062
+ quality: Number(score.quality.toFixed(3))
77063
+ }
77064
+ });
77065
+ }
76247
77066
  }).catch(() => {});
76248
77067
  }
76249
77068
  /**
77069
+ * The group-shot gate's window, throttled to one line per
77070
+ * {@link GROUP_SHOT_METRIC_INTERVAL_MS}. Process-global counts, tagged with
77071
+ * the device that happened to close the window — the counters are the
77072
+ * gate's, not the camera's, and the tag is there because every line about a
77073
+ * device carries one.
77074
+ *
77075
+ * `materialised` is the number of 4096-px native full-frame fetches this
77076
+ * path actually spent; `byGap` + `byMargin` + `byWorse` is what it did not.
77077
+ */
77078
+ logGroupShotWindow(deviceId) {
77079
+ const now = Date.now();
77080
+ if (now - this.lastGroupShotMetricAt < GROUP_SHOT_METRIC_INTERVAL_MS) return;
77081
+ this.lastGroupShotMetricAt = now;
77082
+ const counts = this.groupShots.drainCounts();
77083
+ if (counts.offered === 0) return;
77084
+ this.ctx.logger.info("group shot window", {
77085
+ tags: { deviceId },
77086
+ meta: {
77087
+ offered: counts.offered,
77088
+ materialised: counts.materialised,
77089
+ suppressedByGap: counts.byGap,
77090
+ suppressedByMargin: counts.byMargin,
77091
+ suppressedByWorse: counts.byWorse,
77092
+ openSummaries: this.groupShots.size
77093
+ }
77094
+ });
77095
+ }
77096
+ /**
76250
77097
  * Order the parcels a best-frame decision wants (D422). Skipped entirely at
76251
77098
  * a node the registry already called offline (D58) — logged on the
76252
77099
  * transition by `noteCapturesGated`, counted in the key-frame window.
@@ -76792,9 +77639,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76792
77639
  * The two mirrors answer different halves and neither subsumes the other:
76793
77640
  * - {@link lastTrackActivityMs} — a tracked object was alive. Present only
76794
77641
  * while the camera is ATTACHED and producing inference frames.
76795
- * - {@link lastMotionInsertByDevice} — motion was reported (analyzer or
76796
- * onboard). Covers a camera whose detector found nothing, and a camera
76797
- * on `detectionMode: 'on-motion'` in the window before it attaches.
77642
+ * - {@link lastMotionTickByDevice} — motion was reported (analyzer or
77643
+ * onboard, ANY source). Covers a camera whose detector found nothing,
77644
+ * and a camera on `detectionMode: 'on-motion'` in the window before it
77645
+ * attaches. This is a plain per-device mirror, deliberately NOT the
77646
+ * per-source episode state in {@link motionEpisodes} — this question is
77647
+ * "is something visual happening on the camera", not "which episode".
76798
77648
  *
76799
77649
  * Staleness is safe in the direction that matters: a mirror that is late
76800
77650
  * suppresses a marker, it never invents one.
@@ -76802,7 +77652,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76802
77652
  isMotionActive(deviceId, atMs, quietMs) {
76803
77653
  const trackAt = this.lastTrackActivityMs.get(deviceId);
76804
77654
  if (trackAt !== void 0 && atMs - trackAt < quietMs) return true;
76805
- const motion = this.lastMotionInsertByDevice.get(deviceId);
77655
+ const motion = this.lastMotionTickByDevice.get(deviceId);
76806
77656
  return motion !== void 0 && motion.detected && atMs - motion.atMs < quietMs;
76807
77657
  }
76808
77658
  /** Per-device audio-marker config, folded from the cached audio settings. */
@@ -76867,8 +77717,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76867
77717
  * therefore persisted nothing at all.
76868
77718
  */
76869
77719
  async persistClassifiedAudio(input) {
76870
- const ev = {
76871
- id: randomUUID(),
77720
+ const draft = {
76872
77721
  deviceId: input.deviceId,
76873
77722
  timestamp: input.timestamp,
76874
77723
  kind: "audio",
@@ -76884,7 +77733,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76884
77733
  className: input.className,
76885
77734
  atMs: input.timestamp
76886
77735
  });
76887
- await this.eventStore.insertAudio(ev);
77736
+ const insertedId = await this.eventStore.insertAudio(draft);
77737
+ if (insertedId === null) return;
77738
+ const ev = {
77739
+ ...draft,
77740
+ id: insertedId
77741
+ };
76888
77742
  this.notificationCenter?.onAudioEventPersisted(ev);
76889
77743
  this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
76890
77744
  this.ctx.eventBus.emit({
@@ -77095,8 +77949,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77095
77949
  });
77096
77950
  this.levelStateByDevice.set(deviceId, res.nextState);
77097
77951
  if (!res.emit) return;
77098
- const ev = {
77099
- id: randomUUID(),
77952
+ const draft = {
77100
77953
  deviceId,
77101
77954
  timestamp,
77102
77955
  kind: "audio",
@@ -77107,7 +77960,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77107
77960
  className: void 0,
77108
77961
  atMs: timestamp
77109
77962
  });
77110
- await this.eventStore.insertAudio(ev);
77963
+ const insertedId = await this.eventStore.insertAudio(draft);
77964
+ if (insertedId === null) return;
77965
+ const ev = {
77966
+ ...draft,
77967
+ id: insertedId
77968
+ };
77111
77969
  this.ctx.eventBus.emit({
77112
77970
  id: `pa-${ev.id}`,
77113
77971
  timestamp: new Date(ev.timestamp),
@@ -77126,10 +77984,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77126
77984
  });
77127
77985
  }
77128
77986
  /**
77129
- * Persist motion-analysis bus events. Mirrors `handleAudioResult`'s
77130
- * coalescing strategy: emit one row on off→on transition, then one
77131
- * per `MOTION_EVENT_HEARTBEAT_MS` while motion stays detected. No
77132
- * row on off→off (silence) or while heartbeat hasn't elapsed.
77987
+ * Persist motion-analysis bus events into the EPISODE tracker (D475): a
77988
+ * rising edge with no open episode INSERTs a row; a further edge inside an
77989
+ * open episode extends it in place; the episode closes on its own timer,
77990
+ * `MOTION_CLOSE_AFTER_MS` after the last rising edge. See
77991
+ * `motion-episode.ts` / `motion-episode-tracker.ts` for the state machine.
77133
77992
  *
77134
77993
  * Gated on the same `pipeline-analytics` wrapper binding as the
77135
77994
  * inference + audio handlers so toggling the wrapper off for a
@@ -77139,22 +77998,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77139
77998
  if (this.shuttingDown) return;
77140
77999
  if (!await this.bindingCache.isActive(deviceId)) return;
77141
78000
  const detected = payload.detected === true;
77142
- const last = this.lastMotionInsertByDevice.get(deviceId);
77143
- const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
77144
- const transitionedOn = detected && (last === void 0 || last.detected === false);
77145
- if (!detected) {
77146
- if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
77147
- detected: false,
77148
- atMs: timestamp
77149
- });
77150
- return;
77151
- }
77152
- if (!transitionedOn && !heartbeatDue) return;
77153
- const ev = {
77154
- id: randomUUID(),
77155
- deviceId,
77156
- timestamp,
77157
- kind: "motion",
78001
+ this.lastMotionTickByDevice.set(deviceId, {
78002
+ detected,
78003
+ atMs: timestamp
78004
+ });
78005
+ if (detected) this.eventStore?.noteMotionSample(deviceId, payload.regionCount, timestamp);
78006
+ await this.motionEpisodes?.observe(deviceId, "analyzer", detected, timestamp, {
77158
78007
  regionCount: payload.regionCount,
77159
78008
  regions: payload.regions.map((r) => ({
77160
78009
  bbox: {
@@ -77168,69 +78017,29 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77168
78017
  })),
77169
78018
  frameWidth: payload.frameWidth,
77170
78019
  frameHeight: payload.frameHeight
77171
- };
77172
- this.lastMotionInsertByDevice.set(deviceId, {
77173
- detected,
77174
- atMs: timestamp
77175
- });
77176
- if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
77177
- this.sceneEngine?.noteMotion(deviceId, timestamp);
77178
- await this.eventStore.insertMotion(ev);
77179
- this.ctx.eventBus.emit({
77180
- id: `pa-${ev.id}`,
77181
- timestamp: new Date(ev.timestamp),
77182
- source: {
77183
- type: "addon",
77184
- id: "pipeline-analytics",
77185
- addonId: "pipeline-analytics"
77186
- },
77187
- category: EventCategory.PipelineAnalyticsDetectionEvent,
77188
- data: {
77189
- deviceId,
77190
- kind: "motion",
77191
- eventId: ev.id,
77192
- timestamp: ev.timestamp
77193
- }
77194
78020
  });
77195
- if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
77196
78021
  }
77197
78022
  /**
77198
- * Persist firmware-driven (onboard) motion events. Mirrors
77199
- * {@link handleMotionAnalysis}'s coalescing — one row on off→on
77200
- * transition, then one per `MOTION_EVENT_HEARTBEAT_MS` while motion
77201
- * stays detected but reads from the {@link MotionOnMotionChangedPayload}
77202
- * shape which lacks frame dimensions and pixel-level region details
77203
- * (firmware reports a binary detected flag plus, on rare devices, a
77204
- * coarse bbox via the optional `regions` field).
77205
- *
77206
- * The same `lastMotionInsertByDevice` map is shared with the analyzer
77207
- * path so a camera that briefly switches `motionSources` between
77208
- * `onboard` and `analyzer` gets consistent throttling — both paths
77209
- * are mutually exclusive at the event-emit layer (the runner only
77210
- * fires `MotionAnalysis` when its analyzer runs; onboard providers
77211
- * never fire `MotionAnalysis`), so there's no double-count risk.
78023
+ * Persist firmware-driven (onboard / device-activity) motion events into
78024
+ * the SAME episode tracker as {@link handleMotionAnalysis}, keyed per
78025
+ * `(deviceId, source)` so the two paths can never share — or fight over —
78026
+ * one episode on a camera whose `motionSources` lists both (D475). Reads
78027
+ * from the {@link MotionOnMotionChangedPayload} shape which lacks frame
78028
+ * dimensions and pixel-level region details (firmware reports a binary
78029
+ * detected flag plus, on rare devices, a coarse bbox via the optional
78030
+ * `regions` field).
77212
78031
  */
77213
78032
  async handleOnboardMotion(deviceId, payload, timestamp) {
77214
78033
  if (this.shuttingDown) return;
77215
78034
  if (!await this.bindingCache.isActive(deviceId)) return;
77216
78035
  const detected = payload.detected === true;
77217
- const last = this.lastMotionInsertByDevice.get(deviceId);
77218
- const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
77219
- const transitionedOn = detected && (last === void 0 || last.detected === false);
77220
- if (!detected) {
77221
- if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
77222
- detected: false,
77223
- atMs: timestamp
77224
- });
77225
- return;
77226
- }
77227
- if (!transitionedOn && !heartbeatDue) return;
78036
+ this.lastMotionTickByDevice.set(deviceId, {
78037
+ detected,
78038
+ atMs: timestamp
78039
+ });
77228
78040
  const regions = payload.regions ?? [];
77229
- const ev = {
77230
- id: randomUUID(),
77231
- deviceId,
77232
- timestamp,
77233
- kind: "motion",
78041
+ if (detected) this.eventStore?.noteMotionSample(deviceId, regions.length, timestamp);
78042
+ await this.motionEpisodes?.observe(deviceId, payload.source, detected, timestamp, {
77234
78043
  regionCount: regions.length,
77235
78044
  regions: regions.map((r) => ({
77236
78045
  bbox: {
@@ -77244,17 +78053,56 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77244
78053
  })),
77245
78054
  frameWidth: 0,
77246
78055
  frameHeight: 0
78056
+ });
78057
+ }
78058
+ /**
78059
+ * `MotionEpisodeTracker.insertRow` — builds and inserts the row that opens
78060
+ * a motion episode. `regionData` carries the per-kind fields the two motion
78061
+ * handlers differ on (regions/frameWidth/frameHeight); everything else
78062
+ * (id, durationMs, edges) is the episode's own concern, not the caller's.
78063
+ */
78064
+ async insertMotionEpisodeRow(deviceId, timestamp, regionData) {
78065
+ const draft = {
78066
+ deviceId,
78067
+ timestamp,
78068
+ kind: "motion",
78069
+ durationMs: null,
78070
+ ...regionData
77247
78071
  };
77248
- this.lastMotionInsertByDevice.set(deviceId, {
77249
- detected,
77250
- atMs: timestamp
78072
+ return this.eventStore.insertMotion(draft);
78073
+ }
78074
+ /**
78075
+ * `MotionEpisodeTracker.closeRow` — the UPDATE that closes an episode.
78076
+ */
78077
+ async closeMotionEpisodeRow(input) {
78078
+ return this.eventStore.updateMotion(input.eventId, input.deviceId, {
78079
+ durationMs: input.durationMs,
78080
+ edges: input.edges
78081
+ });
78082
+ }
78083
+ /**
78084
+ * `MotionEpisodeTracker.onNewEdge` — the side effects the pre-D475 code
78085
+ * fired on every `transitionedOn`: the birth-latency onset mark (D409, only
78086
+ * the FIRST edge of a burst moves it — a further edge inside the same
78087
+ * episode is a fresh onset in its own right, so it moves too) and the
78088
+ * motion-marker still (self-throttled by `motion-marker-policy.ts`;
78089
+ * `String` because a media key is a URL component while the id itself
78090
+ * stays a number, D474).
78091
+ */
78092
+ onMotionEpisodeEdge(input) {
78093
+ this.motionOnsets.noteRisingEdge(input.deviceId, input.timestamp);
78094
+ this.captureMotionMarkerStill(input.deviceId, String(input.eventId), input.timestamp).catch((err) => {
78095
+ this.ctx.logger.debug("motion marker still capture failed", {
78096
+ tags: { deviceId: input.deviceId },
78097
+ meta: {
78098
+ eventId: input.eventId,
78099
+ error: errMsg$1(err)
78100
+ }
78101
+ });
77251
78102
  });
77252
- if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
77253
- this.sceneEngine?.noteMotion(deviceId, timestamp);
77254
- await this.eventStore.insertMotion(ev);
77255
78103
  this.ctx.eventBus.emit({
77256
- id: `pa-${ev.id}`,
77257
- timestamp: new Date(ev.timestamp),
78104
+ id: `pa-${input.eventId}`,
78105
+ timestamp: new Date(input.timestamp),
77258
78106
  source: {
77259
78107
  type: "addon",
77260
78108
  id: "pipeline-analytics",
@@ -77262,13 +78110,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77262
78110
  },
77263
78111
  category: EventCategory.PipelineAnalyticsDetectionEvent,
77264
78112
  data: {
77265
- deviceId,
78113
+ deviceId: input.deviceId,
77266
78114
  kind: "motion",
77267
- eventId: ev.id,
77268
- timestamp: ev.timestamp
78115
+ eventId: input.eventId,
78116
+ timestamp: input.timestamp
77269
78117
  }
77270
78118
  });
77271
- if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
78119
+ }
78120
+ /**
78121
+ * `MotionEpisodeTracker.onTick` — fires on every `detected:true`
78122
+ * observation that reaches an open or newly-opened episode, transition or
78123
+ * continuation. Arms the scene quiet-edge trigger: a scene is checked
78124
+ * `quietSeconds` AFTER the last motion, never during it, so this wants
78125
+ * "motion is still happening" — the SAME question `onNewEdge` does not
78126
+ * answer, since a long continuous burst is mostly continuation ticks.
78127
+ */
78128
+ onMotionEpisodeTick(input) {
78129
+ this.sceneEngine?.noteMotion(input.deviceId, input.timestamp);
77272
78130
  }
77273
78131
  /**
77274
78132
  * The gate in front of the motion still. Both motion handlers reach it, so
@@ -79836,7 +80694,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79836
80694
  deps: {
79837
80695
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
79838
80696
  deviceId,
79839
- ownerKind: scope === "motion-still" ? "event" : "track",
80697
+ ownerKind: scope === "motion-still" ? "motion" : "track",
79840
80698
  kind: "snapshot",
79841
80699
  afterMs,
79842
80700
  limit
@@ -79986,7 +80844,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79986
80844
  for (const { event, score } of bestEventByTrackId.values()) {
79987
80845
  const mediaUrl = resolveSearchThumbnailUrl({
79988
80846
  baseUrl: this.eventMediaBaseUrl,
79989
- eventId: event.id,
80847
+ eventId: String(event.id),
79990
80848
  ...event.trackId !== void 0 && embeddingMediaKeyByTrackId.has(event.trackId) ? { embeddingMediaKey: embeddingMediaKeyByTrackId.get(event.trackId) } : {}
79991
80849
  });
79992
80850
  const keyFrameMediaKey = event.trackId !== void 0 ? keyFrameKeyByTrackId.get(event.trackId) : void 0;
@@ -80585,12 +81443,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
80585
81443
  deviceId: file.deviceId
80586
81444
  };
80587
81445
  };
80588
- const eventFiles = await listInfo("event", id);
81446
+ let eventFiles = [];
81447
+ for (const ownerType of EVENT_OWNER_TYPES) {
81448
+ eventFiles = await listInfo(ownerType, id);
81449
+ if (eventFiles.length > 0) break;
81450
+ }
80589
81451
  if (preferKind !== void 0 && preferKind.length > 0) {
80590
81452
  const layers = await collectCropForcedMedia({
80591
81453
  listByOwner: listInfo,
80592
81454
  getTrackIdForEvent
80593
- }, id);
81455
+ }, id, eventFiles);
80594
81456
  const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
80595
81457
  return await readWinningBlob([...layers.bodyFiles, ...layers.detailFiles], (rows) => resolveCropForcedMedia(rows.filter((r) => bodyKeys.has(r.key)), (files) => pickCleanMedia(files, preferKind), rows.filter((r) => !bodyKeys.has(r.key))), readBlob);
80596
81458
  }