@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-iJA2F7_d.js");
5
+ const require_dist = require("../dist-BypQW8jz.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -16,6 +16,7 @@ sharp = require_dist.__toESM(sharp);
16
16
  let node_os = require("node:os");
17
17
  node_os = require_dist.__toESM(node_os);
18
18
  let node_buffer = require("node:buffer");
19
+ let node_zlib = require("node:zlib");
19
20
  //#region src/pipeline-analytics/media-occupancy.ts
20
21
  /** The bare type ref legacy NULL-stamped media rows belong to. */
21
22
  var LEGACY_MEDIA_TYPE = "eventMedia";
@@ -55,6 +56,183 @@ function mediaOccupancyReports(footprint, locationIds, measuredAtMs) {
55
56
  };
56
57
  }
57
58
  //#endregion
59
+ //#region src/pipeline-analytics/motion-episode.ts
60
+ /**
61
+ * Decide what one `(detected, timestamp)` observation does to the episode
62
+ * state for one `(deviceId, source)` key. `prior` is `undefined` the first
63
+ * time this key is ever observed (fresh process, or a key never seen).
64
+ */
65
+ function decideMotionSignal(prior, detected, timestamp) {
66
+ if (!detected) {
67
+ if (prior === void 0) return { kind: "noop" };
68
+ return { kind: "observed-off" };
69
+ }
70
+ if (prior?.episode == null) return { kind: "open-episode" };
71
+ const isNewEdge = prior.lastDetected === false;
72
+ const edgeOffsetsMs = isNewEdge ? [...prior.episode.edgeOffsetsMs, timestamp - prior.episode.startedAt] : prior.episode.edgeOffsetsMs;
73
+ return {
74
+ kind: "extend-episode",
75
+ next: {
76
+ ...prior.episode,
77
+ lastOnAt: timestamp,
78
+ edgeOffsetsMs
79
+ },
80
+ isNewEdge
81
+ };
82
+ }
83
+ /** `lastOnAt - startedAt` — see the module doc for why not `closedAt - startedAt`. */
84
+ function motionEpisodeDurationMs(state) {
85
+ return state.lastOnAt - state.startedAt;
86
+ }
87
+ //#endregion
88
+ //#region src/pipeline-analytics/motion-episode-tracker.ts
89
+ function keyOf(deviceId, source) {
90
+ return `${deviceId}:${source}`;
91
+ }
92
+ var MotionEpisodeTracker = class {
93
+ deps;
94
+ signals = /* @__PURE__ */ new Map();
95
+ timers = /* @__PURE__ */ new Map();
96
+ constructor(deps) {
97
+ this.deps = deps;
98
+ }
99
+ /**
100
+ * One `(detected, timestamp)` observation for one `(deviceId, source)` key.
101
+ * `regionData` is read only if this observation OPENS a new episode — see
102
+ * {@link MotionEpisodeInsertInput}.
103
+ */
104
+ async observe(deviceId, source, detected, timestamp, regionData) {
105
+ const key = keyOf(deviceId, source);
106
+ const prior = this.signals.get(key);
107
+ const decision = decideMotionSignal(prior, detected, timestamp);
108
+ switch (decision.kind) {
109
+ case "noop": return;
110
+ case "observed-off":
111
+ this.setSignal(key, deviceId, source, false, prior?.episode ?? null);
112
+ return;
113
+ case "open-episode": {
114
+ const eventId = await this.deps.insertRow({
115
+ deviceId,
116
+ source,
117
+ timestamp,
118
+ regionData
119
+ });
120
+ if (eventId === null) {
121
+ this.deps.logger.warn("motion episode open failed — insert did not land", {
122
+ tags: { deviceId },
123
+ meta: {
124
+ source,
125
+ timestamp
126
+ }
127
+ });
128
+ this.setSignal(key, deviceId, source, true, null);
129
+ return;
130
+ }
131
+ const episode = {
132
+ eventId,
133
+ startedAt: timestamp,
134
+ lastOnAt: timestamp,
135
+ edgeOffsetsMs: [0]
136
+ };
137
+ this.setSignal(key, deviceId, source, true, episode);
138
+ this.armCloseTimer(key);
139
+ this.deps.onNewEdge?.({
140
+ deviceId,
141
+ source,
142
+ timestamp,
143
+ eventId,
144
+ isFirst: true
145
+ });
146
+ this.deps.onTick?.({
147
+ deviceId,
148
+ source,
149
+ timestamp
150
+ });
151
+ return;
152
+ }
153
+ case "extend-episode":
154
+ this.setSignal(key, deviceId, source, true, decision.next);
155
+ this.armCloseTimer(key);
156
+ if (decision.isNewEdge) this.deps.onNewEdge?.({
157
+ deviceId,
158
+ source,
159
+ timestamp,
160
+ eventId: decision.next.eventId,
161
+ isFirst: false
162
+ });
163
+ this.deps.onTick?.({
164
+ deviceId,
165
+ source,
166
+ timestamp
167
+ });
168
+ return;
169
+ }
170
+ }
171
+ /**
172
+ * Close every currently-open episode immediately, without waiting out the
173
+ * quiet window — used at addon shutdown so a row never sits with
174
+ * `durationMs: null` forever because the process that would have closed it
175
+ * is gone. Each close is logged with `reason` so an operator reading the
176
+ * row's neighbours understands why it stopped short of a natural close.
177
+ */
178
+ async closeAll(reason) {
179
+ const keys = [...this.signals.keys()];
180
+ for (const key of keys) {
181
+ const signal = this.signals.get(key);
182
+ if (!signal?.episode) continue;
183
+ await this.closeNow(key, signal, reason);
184
+ }
185
+ }
186
+ setSignal(key, deviceId, source, lastDetected, episode) {
187
+ this.signals.set(key, {
188
+ deviceId,
189
+ source,
190
+ lastDetected,
191
+ episode
192
+ });
193
+ }
194
+ armCloseTimer(key) {
195
+ const existing = this.timers.get(key);
196
+ if (existing !== void 0) clearTimeout(existing);
197
+ const timer = setTimeout(() => {
198
+ this.fireClose(key);
199
+ }, this.deps.closeAfterMs);
200
+ timer.unref?.();
201
+ this.timers.set(key, timer);
202
+ }
203
+ async fireClose(key) {
204
+ this.timers.delete(key);
205
+ const signal = this.signals.get(key);
206
+ if (!signal?.episode) return;
207
+ await this.closeNow(key, signal, "quiet-window-elapsed");
208
+ }
209
+ async closeNow(key, signal, reason) {
210
+ const episode = signal.episode;
211
+ if (!episode) return;
212
+ const timer = this.timers.get(key);
213
+ if (timer !== void 0) {
214
+ clearTimeout(timer);
215
+ this.timers.delete(key);
216
+ }
217
+ signal.episode = null;
218
+ const durationMs = motionEpisodeDurationMs(episode);
219
+ if (!await this.deps.closeRow({
220
+ deviceId: signal.deviceId,
221
+ source: signal.source,
222
+ eventId: episode.eventId,
223
+ durationMs,
224
+ edges: episode.edgeOffsetsMs
225
+ })) this.deps.logger.warn("motion episode close failed — row keeps durationMs: null", {
226
+ tags: { deviceId: signal.deviceId },
227
+ meta: {
228
+ source: signal.source,
229
+ eventId: episode.eventId,
230
+ reason
231
+ }
232
+ });
233
+ }
234
+ };
235
+ //#endregion
58
236
  //#region src/notification-center/action-token.ts
59
237
  /**
60
238
  * The authority behind a notification button.
@@ -6757,7 +6935,7 @@ function normalizeBbox(bbox, frameWidth, frameHeight) {
6757
6935
  function subjectFromObjectEvent(ev) {
6758
6936
  return {
6759
6937
  kind: "object-event",
6760
- recordId: ev.id,
6938
+ recordId: String(ev.id),
6761
6939
  deviceId: ev.deviceId,
6762
6940
  timestamp: ev.timestamp,
6763
6941
  classNames: [ev.className],
@@ -6825,7 +7003,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
6825
7003
  const eventType = readSensorEventType(ev.value);
6826
7004
  return {
6827
7005
  kind: "device-event",
6828
- recordId: ev.id,
7006
+ recordId: String(ev.id),
6829
7007
  deviceId: ev.deviceId,
6830
7008
  ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
6831
7009
  ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
@@ -6862,7 +7040,7 @@ function subjectFromAudioEvent(ev, stillOwnerId) {
6862
7040
  const macro = ev.classification?.className;
6863
7041
  return {
6864
7042
  kind: "audio-event",
6865
- recordId: ev.id,
7043
+ recordId: String(ev.id),
6866
7044
  deviceId: ev.deviceId,
6867
7045
  timestamp: ev.timestamp,
6868
7046
  classNames: macro !== void 0 ? [`audio-${macro}`] : [],
@@ -6992,7 +7170,7 @@ function occupancySubjectFromEdge(edge) {
6992
7170
  function subjectFromPackageEvent(ev, phase) {
6993
7171
  return {
6994
7172
  kind: "package-event",
6995
- recordId: ev.id,
7173
+ recordId: String(ev.id),
6996
7174
  deviceId: ev.deviceId,
6997
7175
  timestamp: ev.timestamp,
6998
7176
  classNames: [ev.className],
@@ -11674,7 +11852,7 @@ var NcDispatcher = class {
11674
11852
  recordId: entry.recordId,
11675
11853
  policy: entry.payload.media,
11676
11854
  ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
11677
- owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
11855
+ owners: [...entry.payload.subject.eventId !== void 0 ? [`object:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
11678
11856
  }
11679
11857
  });
11680
11858
  out.push(...footage.attachments);
@@ -11981,7 +12159,7 @@ var NcDispatcher = class {
11981
12159
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
11982
12160
  const owners = [];
11983
12161
  if (subject.eventId !== void 0) owners.push({
11984
- kind: "event",
12162
+ kind: "object",
11985
12163
  id: subject.eventId
11986
12164
  });
11987
12165
  if (subject.trackId !== void 0) owners.push({
@@ -28216,16 +28394,28 @@ var StoragePressureTracker = class {
28216
28394
  * operator-captured reference picture as collectable. It is the same class of
28217
28395
  * mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
28218
28396
  * which nothing else can regenerate and no cascade owns.
28397
+ *
28398
+ * `event` was DROPPED and replaced by `motion` / `object` / `audio` (D476).
28399
+ * `event` used to cover all three event tables under one owner kind, which was
28400
+ * safe while event ids were UUIDs (unique across tables). D474 made an event
28401
+ * id a per-table SQLite rowid alias, so `event:5` stopped naming one row —
28402
+ * three different rows, one per table, can all be `5`. This is the AUTHORITY
28403
+ * on what may own media; `MEDIA_OWNER_TYPES` in
28404
+ * `@camstack/types/media/media-owner-key.ts` must cover every member here —
28405
+ * `scripts` / `__tests__/owner-kinds-authority.spec.ts` guards the two lists
28406
+ * staying married.
28219
28407
  */
28220
28408
  var OWNER_KINDS = [
28221
- "event",
28222
28409
  "track",
28223
28410
  "summary",
28224
28411
  "face",
28225
28412
  "identity",
28226
28413
  "plate",
28227
28414
  "vehicle",
28228
- "scene"
28415
+ "scene",
28416
+ "motion",
28417
+ "object",
28418
+ "audio"
28229
28419
  ];
28230
28420
  /**
28231
28421
  * Owner kinds that NOTHING may write any more, but whose rows can still be on
@@ -28622,7 +28812,12 @@ function mediaRowLocation(data) {
28622
28812
  return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
28623
28813
  }
28624
28814
  function buildKey(params) {
28625
- return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
28815
+ return require_dist.formatMediaOwnerKey({
28816
+ ownerType: params.ownerKind,
28817
+ ownerId: params.ownerId,
28818
+ fileKind: params.kind,
28819
+ ...isSingleInstanceKind(params.kind) ? {} : { timestampMs: params.timestamp }
28820
+ });
28626
28821
  }
28627
28822
  function buildPath(params) {
28628
28823
  const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
@@ -29537,9 +29732,27 @@ var MediaStore = class {
29537
29732
  }
29538
29733
  return newKey;
29539
29734
  }
29540
- /** Back-compat: existing retention path deletes media for evicted events. */
29541
- async deleteForEvents(eventIds) {
29542
- return this.deleteForOwner("event", eventIds);
29735
+ /**
29736
+ * Delete event-owned media for evicted events, GROUPED by the table each id
29737
+ * came from (D476).
29738
+ *
29739
+ * There is no honest single-kind signature any more: `deleteForOwner` needs
29740
+ * ONE owner kind per call, and a flat id list can no longer say which of
29741
+ * `motion` / `object` / `audio` an id names — D474 made event ids per-table
29742
+ * rowids, so `motion:5` and `object:5` are different rows that both spell
29743
+ * `5`. Every caller (the retention cascade's per-track object-event sweep,
29744
+ * the trackless motion/audio sweep, the operator prune) already KNOWS which
29745
+ * table produced each id before it reaches here — the old flat signature
29746
+ * just threw that fact away. `ids` is a partial record so a caller supplies
29747
+ * only the kinds it actually collected.
29748
+ */
29749
+ async deleteForEvents(ids) {
29750
+ let total = 0;
29751
+ for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
29752
+ const kindIds = ids[ownerType];
29753
+ if (kindIds !== void 0 && kindIds.length > 0) total += await this.deleteForOwner(ownerType, kindIds);
29754
+ }
29755
+ return total;
29543
29756
  }
29544
29757
  /** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
29545
29758
  async deleteForTracks(trackIds) {
@@ -29554,8 +29767,9 @@ var MediaStore = class {
29554
29767
  * ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
29555
29768
  * assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
29556
29769
  * crops and leaves the enrolled gallery intact. Event-owned media
29557
- * (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
29558
- * set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
29770
+ * (`ownerKind:'motion'` / `'object'` / `'audio'`) is handled by `EventStore`
29771
+ * (which holds the per-kind event id sets see {@link deleteForEvents}).
29772
+ * `deleteForOwner` is best-effort per row, so one bad row can't abort the
29559
29773
  * batch.
29560
29774
  */
29561
29775
  async deleteByTracks(trackIds) {
@@ -36421,23 +36635,6 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
36421
36635
  }
36422
36636
  //#endregion
36423
36637
  //#region src/pipeline-analytics/pipeline/frame-processor.ts
36424
- /**
36425
- * FrameProcessor — per-device pipeline stage runner.
36426
- *
36427
- * Consumes a raw `PipelineInferenceResult` payload and runs:
36428
- * 1. Flatten first-level detections
36429
- * 2. Zone filter (ZoneEngine)
36430
- * 3. Tracking (SortTracker)
36431
- * 4. State analysis (StateAnalyzer)
36432
- * 5. Event emission (DetectionEventEmitter)
36433
- *
36434
- * Returns a structured result the addon uses to:
36435
- * - upsert TrackStore
36436
- * - insert ObjectEvent rows into the declared collection
36437
- * - emit onFrameTracked / onDetectionEvent on the bus
36438
- *
36439
- * One FrameProcessor per device — lifecycle managed by DeviceRegistry.
36440
- */
36441
36638
  /** Shared empty — a fold is rare and every other frame would allocate. */
36442
36639
  var EMPTY_RIDER_FOLDS = [];
36443
36640
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
@@ -37173,7 +37370,6 @@ var FrameProcessor = class {
37173
37370
  const crossing = crossingOf(e);
37174
37371
  const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
37175
37372
  return {
37176
- id: (0, node_crypto.randomUUID)(),
37177
37373
  deviceId: this.deviceId,
37178
37374
  timestamp,
37179
37375
  kind: "object",
@@ -37910,7 +38106,7 @@ async function cascadeDeleteTrack(stores, trackId) {
37910
38106
  for (const id of ids) {
37911
38107
  const eventIds = await stores.eventStore.deleteByTrack(id);
37912
38108
  events += eventIds.length;
37913
- if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
38109
+ if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents({ object: [...eventIds] });
37914
38110
  }
37915
38111
  return [{
37916
38112
  family: "events",
@@ -39867,6 +40063,11 @@ function runFrameProcessorPass(input) {
39867
40063
  * remaining blob are dropped — including snapshot-less rows. Enrolled gallery
39868
40064
  * media (`identity` / `vehicle` / `scene`) is never probed.
39869
40065
  */
40066
+ /** Is this an event-owned media row — `motion` / `object` / `audio` (D476;
40067
+ * `event` used to cover all three under one owner kind). */
40068
+ function isEventOwnedMediaRow(ownerKind) {
40069
+ return require_dist.EVENT_OWNER_TYPES.includes(ownerKind);
40070
+ }
39870
40071
  function shouldProbeMediaRow(ownerKind) {
39871
40072
  if (!isMediaOwnerKind(ownerKind)) return true;
39872
40073
  return !isRetentionExemptOwnerKind(ownerKind);
@@ -39897,7 +40098,7 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
39897
40098
  if (row.path.length === 0 ? false : await deps.blobExists(row.location, row.path)) {
39898
40099
  const trackId = trackIdFromMediaOwner(row.ownerKind, row.ownerId);
39899
40100
  if (trackId !== null) tracksWithMedia.add(trackId);
39900
- if (row.ownerKind === "event") eventsWithMedia.add(row.ownerId);
40101
+ if (isEventOwnedMediaRow(row.ownerKind)) eventsWithMedia.add(row.ownerId);
39901
40102
  continue;
39902
40103
  }
39903
40104
  missingKeys.push(row.key);
@@ -39958,6 +40159,191 @@ var locationDiscardActions = require_dist.defineCustomActions({
39958
40159
  "storage.discardLocationCancel": require_dist.customAction(require_dist.object({}), DiscardCancel, { auth: "admin" })
39959
40160
  });
39960
40161
  //#endregion
40162
+ //#region src/pipeline-analytics/store/motion-edge-codec.ts
40163
+ /**
40164
+ * How a motion episode's rising-edge offsets become bytes, and back (D475).
40165
+ *
40166
+ * A motion episode row (`durationMs` + `edges`) records every distinct
40167
+ * off→on transition the camera reported while the episode stayed open — "ogni
40168
+ * evento on si deve salvare" (the operator's words). `edges` holds those
40169
+ * instants as **ms offsets from the episode's `timestamp`** (its first rising
40170
+ * edge, so the first entry is always `0`), never as absolute epochs — an
40171
+ * epoch is ~13 digits per entry; an offset into a 40 s episode is 1–5.
40172
+ *
40173
+ * Mirrors `position-codec.ts` (D471) on purpose: same discipline, different
40174
+ * shape. A MALFORMED stored value decodes to an empty array, never a throw —
40175
+ * one row's garbage `edges` column must not take out the whole event read
40176
+ * path, and a legacy row (written before this column existed) has no `edges`
40177
+ * at all, which decodes to `[]` exactly like garbage does. The open/closed
40178
+ * question is answered by `durationMs` being `null` vs a number vs absent —
40179
+ * never by this column, so an empty `edges` array must never be read as "no
40180
+ * episode" or "still open".
40181
+ */
40182
+ /**
40183
+ * Marks a value this module wrote. A distinct namespace from
40184
+ * `position-codec.ts`'s `z1:` — different column, different shape, so a
40185
+ * value from one can never be mistaken for the other's.
40186
+ */
40187
+ var ENCODED_PREFIX$1 = "me1:";
40188
+ /** Round-trip a sequence of rising-edge offsets (ms, from the episode start)
40189
+ * through gzip + base64 — the same trade `position-codec.ts` made: base64
40190
+ * because the settings-store column types have no BLOB, gzip because the
40191
+ * repeated small integers compress well. */
40192
+ function encodeMotionEdges(edgesMs) {
40193
+ const json = JSON.stringify(edgesMs);
40194
+ return ENCODED_PREFIX$1 + (0, node_zlib.gzipSync)(Buffer.from(json, "utf8"), { level: 6 }).toString("base64");
40195
+ }
40196
+ /**
40197
+ * Read a stored `edges` value. Two shapes reach here: a `me1:` string this
40198
+ * module wrote, and a plain array (the store parsed the JSON column for us,
40199
+ * or a caller handed one straight through — the codec should not be the
40200
+ * reason a valid in-memory array fails to read back). Anything else —
40201
+ * `undefined` (legacy row, or an episode that never closed), a garbage
40202
+ * string, a number — answers `[]`.
40203
+ */
40204
+ function decodeMotionEdges(value) {
40205
+ if (Array.isArray(value)) return value.filter(isFiniteNumber);
40206
+ if (typeof value !== "string") return [];
40207
+ if (value.startsWith(ENCODED_PREFIX$1)) try {
40208
+ const raw = (0, node_zlib.gunzipSync)(Buffer.from(value.slice(4), "base64")).toString("utf8");
40209
+ const parsed = JSON.parse(raw);
40210
+ return Array.isArray(parsed) ? parsed.filter(isFiniteNumber) : [];
40211
+ } catch {
40212
+ return [];
40213
+ }
40214
+ try {
40215
+ const parsed = JSON.parse(value);
40216
+ return Array.isArray(parsed) ? parsed.filter(isFiniteNumber) : [];
40217
+ } catch {
40218
+ return [];
40219
+ }
40220
+ }
40221
+ function isFiniteNumber(v) {
40222
+ return typeof v === "number" && Number.isFinite(v);
40223
+ }
40224
+ //#endregion
40225
+ //#region src/pipeline-analytics/store/position-codec.ts
40226
+ /**
40227
+ * How a track's `positions[]` becomes bytes, and back (D471).
40228
+ *
40229
+ * `positions` was 94.9 % of the `tracks` table and 17 % of the whole settings
40230
+ * database — 186.5 MB — and most of that was not information. See
40231
+ * `__tests__/position-codec.spec.ts` for the measurement and for why rounding
40232
+ * is safe only since D399.
40233
+ *
40234
+ * Pure on purpose: what a MALFORMED stored value decodes to is the decision
40235
+ * worth pinning in a test, and it is "an empty trail", never a throw.
40236
+ */
40237
+ /**
40238
+ * Marks a value this module wrote. Chosen so it cannot collide with the plain
40239
+ * JSON array every pre-D471 row holds: that always starts `[`.
40240
+ */
40241
+ var ENCODED_PREFIX = "z1:";
40242
+ /** Pixels are integers. The float tail is representation noise, not precision. */
40243
+ function roundPosition(p) {
40244
+ return {
40245
+ ...p,
40246
+ x: Math.round(p.x),
40247
+ y: Math.round(p.y),
40248
+ bbox: {
40249
+ ...p.bbox,
40250
+ x: Math.round(p.bbox.x),
40251
+ y: Math.round(p.bbox.y),
40252
+ w: Math.round(p.bbox.w),
40253
+ h: Math.round(p.bbox.h)
40254
+ }
40255
+ };
40256
+ }
40257
+ /** Round, serialise, gzip, base64 — in that order, so the compressor sees the
40258
+ * smaller text and the repeated field names it compresses best. */
40259
+ function encodePositions(positions) {
40260
+ const json = JSON.stringify(positions.map(roundPosition));
40261
+ return ENCODED_PREFIX + (0, node_zlib.gzipSync)(Buffer.from(json, "utf8"), { level: 6 }).toString("base64");
40262
+ }
40263
+ /**
40264
+ * Read a stored value, whichever era wrote it.
40265
+ *
40266
+ * THREE shapes reach here and all three are ordinary, which is what makes a
40267
+ * migration unnecessary: a `z1:` string this module wrote, a plain array (the
40268
+ * store parsed the JSON column for us), and a plain JSON string (it did not).
40269
+ * Anything else is a row we cannot read, and the answer is an empty trail —
40270
+ * the timeline then draws a track with no path, which an operator can SEE,
40271
+ * instead of failing the page, which they cannot.
40272
+ */
40273
+ function decodePositions(value) {
40274
+ if (Array.isArray(value)) return value.filter(isPosition);
40275
+ if (typeof value !== "string") return [];
40276
+ if (value.startsWith(ENCODED_PREFIX)) try {
40277
+ const raw = (0, node_zlib.gunzipSync)(Buffer.from(value.slice(3), "base64")).toString("utf8");
40278
+ const parsed = JSON.parse(raw);
40279
+ return Array.isArray(parsed) ? parsed.filter(isPosition) : [];
40280
+ } catch {
40281
+ return [];
40282
+ }
40283
+ try {
40284
+ const parsed = JSON.parse(value);
40285
+ return Array.isArray(parsed) ? parsed.filter(isPosition) : [];
40286
+ } catch {
40287
+ return [];
40288
+ }
40289
+ }
40290
+ function isPosition(v) {
40291
+ if (typeof v !== "object" || v === null) return false;
40292
+ const p = { ...v };
40293
+ return typeof p["x"] === "number" && typeof p["y"] === "number" && typeof p["bbox"] === "object";
40294
+ }
40295
+ /**
40296
+ * A trajectory reduced to the ONLY thing a zone-filtered reader uses (D471).
40297
+ *
40298
+ * The client's `parsePositionBoxes` walks `positions[]` and keeps `p.bbox`,
40299
+ * discarding `x`, `y`, `timestamp` and `source` the moment they arrive. On the
40300
+ * zone path — the one path that ships positions at all — that is **59 % of the
40301
+ * payload thrown away by the receiver**: 162 B per position sent, 66 B used.
40302
+ *
40303
+ * Measured on 20 130 real positions. This is the reduction, applied server-side
40304
+ * so the bytes are never spent.
40305
+ *
40306
+ * NOT applied to any other read: a caller that asked for a full trajectory gets
40307
+ * one. This is narrowing on the request that already narrowed.
40308
+ */
40309
+ function geometryOnlyPositions(positions) {
40310
+ return positions.map((p) => ({
40311
+ x: p.bbox.x,
40312
+ y: p.bbox.y,
40313
+ timestamp: 0,
40314
+ bbox: p.bbox
40315
+ }));
40316
+ }
40317
+ /**
40318
+ * A detection box with integer pixel coordinates (D471).
40319
+ *
40320
+ * `object-events.bbox` carries the same float tail as `positions` did —
40321
+ * `{"w":1089.6000000000001,"h":398.40000000000003}` — and is 18 % of a 30 MB
40322
+ * table. Same cause: a pixel scaled by the analysis raster ratio, serialised
40323
+ * with every digit JavaScript can print.
40324
+ *
40325
+ * Returns the input unchanged when there is nothing to round, so a legacy row
40326
+ * with no bbox stays exactly what it was.
40327
+ */
40328
+ function roundBbox(bbox) {
40329
+ if (typeof bbox !== "object" || bbox === null) return bbox;
40330
+ const b = { ...bbox };
40331
+ let touched = false;
40332
+ for (const k of [
40333
+ "x",
40334
+ "y",
40335
+ "w",
40336
+ "h"
40337
+ ]) {
40338
+ const v = b[k];
40339
+ if (typeof v === "number" && Number.isFinite(v)) {
40340
+ b[k] = Math.round(v);
40341
+ touched = true;
40342
+ }
40343
+ }
40344
+ return touched ? b : bbox;
40345
+ }
40346
+ //#endregion
39961
40347
  //#region src/pipeline-analytics/store/tiered-label-columns.ts
39962
40348
  /**
39963
40349
  * The six tiered-label columns, read and written in ONE place (roadmap 4g).
@@ -40078,12 +40464,17 @@ function audioDbfsByBucket(events, since, bucketMs) {
40078
40464
  //#region src/pipeline-analytics/store/event-store.ts
40079
40465
  /**
40080
40466
  * @durable class=ledger owner=pipeline-analytics
40081
- * write="one row when a camera's motion goes off→on, then one more every 5 s
40082
- * (MOTION_EVENT_HEARTBEAT_MS) while it stays on. The analyzer path and the
40083
- * onboard-firmware path share that throttle, so a camera cannot double-count.
40084
- * Measured at ~7,200 rows/day/camera."
40467
+ * write="one row per motion EPISODE, keyed per (deviceId, source) so the
40468
+ * analyzer and a firmware/device-activity sensor on the SAME camera never
40469
+ * share one episode (D475). INSERT on a rising edge with no episode open
40470
+ * (durationMs: null, edges: [0]); a further rising edge inside the open
40471
+ * episode does NOT insert — it extends the in-memory episode and re-arms
40472
+ * the MOTION_CLOSE_AFTER_MS close timer. One UPDATE at close writes the
40473
+ * final durationMs (lastOnAt - startedAt) and the encoded edges array.
40474
+ * Replaces the pre-D475 scheme (one row off→on, then a heartbeat row
40475
+ * every 5 s while motion stayed on) — MOTION_EVENT_HEARTBEAT_MS is gone."
40085
40476
  * retention="a TERMINAL age-keyed root — no trackId. A motion row may own a
40086
- * still (`ownerKind: 'event'`, `kind: 'snapshot'`) captured on the off→on
40477
+ * still (`ownerKind: 'motion'`, `kind: 'snapshot'`) captured on the off→on
40087
40478
  * visit so events-mode recordings have a videoclip thumbnail. Deleted by
40088
40479
  * EventStore.evictTracklessBefore on the DEVICE's one retention window
40089
40480
  * (follow-recordings horizon, or trackRetentionDays, default 7), which
@@ -40118,7 +40509,7 @@ var AUDIO_EVENTS_COLLECTION = "pipeline-analytics:audio-events";
40118
40509
  var COMMON_BASE_COLUMNS = [
40119
40510
  {
40120
40511
  name: "id",
40121
- type: "TEXT",
40512
+ type: "INTEGER",
40122
40513
  primaryKey: true,
40123
40514
  notNull: true
40124
40515
  },
@@ -40151,7 +40542,31 @@ var MOTION_COLUMNS = [
40151
40542
  {
40152
40543
  name: "frameHeight",
40153
40544
  type: "INTEGER"
40154
- }
40545
+ },
40546
+ (
40547
+ /**
40548
+ * `null` while the episode this row represents is still open; the final
40549
+ * `lastOnAt - startedAt` once closed (D475). DECLARED, not merely stamped —
40550
+ * the sqlite backend silently drops an undeclared key on every write, which
40551
+ * is exactly how `frameId` was written into the void for years (see
40552
+ * `OBJECT_COLUMNS.frameId`). Absent on a row written before D475: that
40553
+ * means "closed the old way", never "open" — only an explicit `null` here
40554
+ * means open.
40555
+ */
40556
+ {
40557
+ name: "durationMs",
40558
+ type: "INTEGER"
40559
+ }),
40560
+ (
40561
+ /**
40562
+ * Encoded rising-edge offsets for the episode — see `motion-edge-codec.ts`.
40563
+ * Written once, in the same UPDATE as `durationMs`, at close; a legacy or
40564
+ * still-open row has no value here, which the codec reads as `[]`.
40565
+ */
40566
+ {
40567
+ name: "edges",
40568
+ type: "TEXT"
40569
+ })
40155
40570
  ];
40156
40571
  var OBJECT_COLUMNS = [
40157
40572
  ...COMMON_BASE_COLUMNS,
@@ -40177,6 +40592,18 @@ var OBJECT_COLUMNS = [
40177
40592
  name: "frameId",
40178
40593
  type: "TEXT"
40179
40594
  }),
40595
+ (
40596
+ /**
40597
+ * The producer's own idempotence key (D474) — see `ObjectEvent.idempotencyKey`.
40598
+ *
40599
+ * DECLARED, not merely stamped: the sqlite backend drops an undeclared key
40600
+ * without error, which is exactly how `frameId` was written into the void on
40601
+ * every row ever persisted. Nullable — only the package detector sets it.
40602
+ */
40603
+ {
40604
+ name: "idempotencyKey",
40605
+ type: "TEXT"
40606
+ }),
40180
40607
  {
40181
40608
  name: "trackId",
40182
40609
  type: "TEXT",
@@ -40310,6 +40737,42 @@ var COMMON_INDEXES = (prefix) => [{
40310
40737
  name: `idx_${prefix}_device_ts`,
40311
40738
  columns: ["deviceId", "timestamp"]
40312
40739
  }];
40740
+ /**
40741
+ * The wire value `durationMs` carries for an OPEN episode (D475).
40742
+ *
40743
+ * The app-level contract is "`null` = open, a number = closed, absent = a
40744
+ * row from before this column existed" — but SQL has no way to tell "this
40745
+ * column was never given a value because the row predates it" from
40746
+ * "explicitly set to NULL": `declareCollection` adds the column via ALTER
40747
+ * TABLE, which backfills every existing row's `durationMs` with a real SQL
40748
+ * NULL, bit-for-bit the same NULL an open episode would write. Writing an
40749
+ * actual NULL for "open" would therefore make a brand-new open episode
40750
+ * indistinguishable from a five-year-old legacy row — exactly the failure
40751
+ * mode this constant exists to avoid. `insertMotion` writes this in place of
40752
+ * `null`; `queryMotion` translates it back to `null` on the way out, so
40753
+ * nothing outside this file ever sees the sentinel. `-1` is safe because a
40754
+ * real duration is never negative.
40755
+ */
40756
+ var OPEN_EPISODE_SENTINEL = -1;
40757
+ /**
40758
+ * The key `insert` reports, narrowed to the ROWID these three collections use.
40759
+ *
40760
+ * `settingsStore.insert` answers `string | number` because the SAME method
40761
+ * serves TEXT-keyed collections, where it mints a UUID (D474). On the event
40762
+ * collections the primary key is an `INTEGER` rowid alias, so a string here is
40763
+ * not an event id at all — it means the table in front of us still has the old
40764
+ * TEXT key. Returning `null` says "no id", which is what every caller already
40765
+ * handles; the WARN says why, because a silent null would read as a failed
40766
+ * write (D474).
40767
+ */
40768
+ function eventRowId(logger, operation, deviceId, key) {
40769
+ if (typeof key === "number") return key;
40770
+ logger.warn(`${operation}: event key is not an INTEGER rowid`, {
40771
+ tags: { deviceId },
40772
+ meta: { returnedId: key }
40773
+ });
40774
+ return null;
40775
+ }
40313
40776
  var EventStore = class {
40314
40777
  store;
40315
40778
  logger;
@@ -40348,65 +40811,129 @@ var EventStore = class {
40348
40811
  indexes: COMMON_INDEXES("audio")
40349
40812
  });
40350
40813
  }
40814
+ /**
40815
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40816
+ *
40817
+ * The caller needs it on the very next line — the notification centre reads
40818
+ * it and the marker-still path builds a media key from it — and with an
40819
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40820
+ * placeholder would reach the event bus and the media keys.
40821
+ */
40351
40822
  async insertMotion(ev) {
40352
40823
  this.onMotionSample?.(ev.deviceId, ev.regionCount, ev.timestamp);
40824
+ const record = { ...ev };
40825
+ if (ev.durationMs === null) record["durationMs"] = OPEN_EPISODE_SENTINEL;
40353
40826
  try {
40354
- const { id, ...rest } = ev;
40355
- await this.store.insert.mutate({
40827
+ const inserted = await this.store.insert.mutate({
40356
40828
  collection: MOTION_EVENTS_COLLECTION,
40357
- record: {
40358
- id,
40359
- data: rest
40360
- }
40829
+ record: { data: record }
40361
40830
  });
40831
+ return eventRowId(this.logger, "insertMotion", ev.deviceId, inserted.id);
40362
40832
  } catch (err) {
40363
40833
  this.logger.warn("insertMotion failed", {
40364
40834
  tags: { deviceId: ev.deviceId },
40365
40835
  meta: {
40366
- eventId: ev.id,
40836
+ timestamp: ev.timestamp,
40837
+ error: String(err)
40838
+ }
40839
+ });
40840
+ return null;
40841
+ }
40842
+ }
40843
+ /**
40844
+ * The one place a motion EPISODE row is closed (D475). Writes `durationMs`
40845
+ * and the encoded `edges` in a single UPDATE — a row never gets a second
40846
+ * write after this one. Returns whether it landed; the caller
40847
+ * (`MotionEpisodeTracker`) logs the failure with the episode's `deviceId`
40848
+ * tag, because a `durationMs: null` row that never gets a reason is exactly
40849
+ * the silent-forever-open shape D475 exists to prevent.
40850
+ */
40851
+ async updateMotion(eventId, deviceId, patch) {
40852
+ try {
40853
+ await this.store.update.mutate({
40854
+ collection: MOTION_EVENTS_COLLECTION,
40855
+ id: String(eventId),
40856
+ data: {
40857
+ durationMs: patch.durationMs,
40858
+ edges: encodeMotionEdges(patch.edges)
40859
+ }
40860
+ });
40861
+ return true;
40862
+ } catch (err) {
40863
+ this.logger.warn("updateMotion (episode close) failed", {
40864
+ tags: { deviceId },
40865
+ meta: {
40866
+ eventId,
40367
40867
  error: String(err)
40368
40868
  }
40369
40869
  });
40870
+ return false;
40370
40871
  }
40371
40872
  }
40873
+ /**
40874
+ * The motion-activity LTS tap, exposed for a caller that extends an
40875
+ * already-open episode without inserting a new row (D475) — `insertMotion`
40876
+ * fires it on OPEN; this fires it on every further tick, so a long
40877
+ * continuous episode still samples activity at the same density the
40878
+ * pre-D475 heartbeat gave the aggregator, rather than once per episode.
40879
+ */
40880
+ noteMotionSample(deviceId, regionCount, at) {
40881
+ this.onMotionSample?.(deviceId, regionCount, at);
40882
+ }
40883
+ /**
40884
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40885
+ *
40886
+ * The caller needs it on the very next line — the notification centre reads
40887
+ * it and the marker-still path builds a media key from it — and with an
40888
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40889
+ * placeholder would reach the event bus and the media keys.
40890
+ */
40372
40891
  async insertObject(ev) {
40373
40892
  try {
40374
- const { id, ...rest } = ev;
40375
- await this.store.insert.mutate({
40893
+ const rounded = "bbox" in ev ? {
40894
+ ...ev,
40895
+ bbox: roundBbox(ev.bbox)
40896
+ } : ev;
40897
+ const inserted = await this.store.insert.mutate({
40376
40898
  collection: OBJECT_EVENTS_COLLECTION,
40377
- record: {
40378
- id,
40379
- data: rest
40380
- }
40899
+ record: { data: rounded }
40381
40900
  });
40901
+ return eventRowId(this.logger, "insertObject", ev.deviceId, inserted.id);
40382
40902
  } catch (err) {
40383
40903
  this.logger.warn("insertObject failed", {
40384
40904
  tags: { deviceId: ev.deviceId },
40385
40905
  meta: {
40386
- eventId: ev.id,
40906
+ timestamp: ev.timestamp,
40387
40907
  error: String(err)
40388
40908
  }
40389
40909
  });
40910
+ return null;
40390
40911
  }
40391
40912
  }
40913
+ /**
40914
+ * Returns the ROWID SQLite assigned, or `null` when the write failed (D474).
40915
+ *
40916
+ * The caller needs it on the very next line — the notification centre reads
40917
+ * it and the marker-still path builds a media key from it — and with an
40918
+ * auto-assigned key the insert is the ONLY place it is knowable. A
40919
+ * placeholder would reach the event bus and the media keys.
40920
+ */
40392
40921
  async insertAudio(ev) {
40393
40922
  try {
40394
- const { id, ...rest } = ev;
40395
- await this.store.insert.mutate({
40923
+ const inserted = await this.store.insert.mutate({
40396
40924
  collection: AUDIO_EVENTS_COLLECTION,
40397
- record: {
40398
- id,
40399
- data: rest
40400
- }
40925
+ record: { data: ev }
40401
40926
  });
40927
+ return eventRowId(this.logger, "insertAudio", ev.deviceId, inserted.id);
40402
40928
  } catch (err) {
40403
40929
  this.logger.warn("insertAudio failed", {
40404
40930
  tags: { deviceId: ev.deviceId },
40405
40931
  meta: {
40406
- eventId: ev.id,
40932
+ timestamp: ev.timestamp,
40407
40933
  error: String(err)
40408
40934
  }
40409
40935
  });
40936
+ return null;
40410
40937
  }
40411
40938
  }
40412
40939
  /**
@@ -40520,12 +41047,15 @@ var EventStore = class {
40520
41047
  filter: this.buildFilter(q),
40521
41048
  ...this.slimColumns(q.projection, SLIM_MOTION_COLUMNS)
40522
41049
  });
40523
- if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls$1(r.data)));
41050
+ if (q.projection === "slim") return rows.map((r) => slimMotion(Number(r.id), stripNulls$1(r.data)));
40524
41051
  return rows.map((r) => {
41052
+ const { edges: rawEdges, durationMs: rawDurationMs, ...data } = stripNulls$1(r.data);
40525
41053
  return {
40526
- id: r.id,
41054
+ id: Number(r.id),
40527
41055
  kind: "motion",
40528
- ...stripNulls$1(r.data)
41056
+ ...data,
41057
+ ...rawDurationMs !== void 0 ? { durationMs: rawDurationMs === OPEN_EPISODE_SENTINEL ? null : rawDurationMs } : {},
41058
+ ...rawEdges !== void 0 ? { edges: decodeMotionEdges(rawEdges) } : {}
40529
41059
  };
40530
41060
  });
40531
41061
  }
@@ -40572,7 +41102,7 @@ var EventStore = class {
40572
41102
  });
40573
41103
  if (rows.length === 0) break;
40574
41104
  for (const r of rows) out.push({
40575
- id: r.id,
41105
+ id: Number(r.id),
40576
41106
  kind: "object",
40577
41107
  ...stripNulls$1(r.data)
40578
41108
  });
@@ -40591,10 +41121,10 @@ var EventStore = class {
40591
41121
  filter,
40592
41122
  ...this.slimColumns(q.projection, SLIM_OBJECT_COLUMNS)
40593
41123
  });
40594
- if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls$1(r.data)));
41124
+ if (q.projection === "slim") return rows.map((r) => slimObject(Number(r.id), stripNulls$1(r.data)));
40595
41125
  return rows.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
  };
@@ -40619,7 +41149,7 @@ var EventStore = class {
40619
41149
  }
40620
41150
  })).map((r) => {
40621
41151
  return {
40622
- id: r.id,
41152
+ id: Number(r.id),
40623
41153
  kind: "object",
40624
41154
  ...stripNulls$1(r.data)
40625
41155
  };
@@ -40630,10 +41160,10 @@ var EventStore = class {
40630
41160
  collection: AUDIO_EVENTS_COLLECTION,
40631
41161
  filter: this.buildFilter(q)
40632
41162
  });
40633
- if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls$1(r.data)));
41163
+ if (q.projection === "slim") return rows.map((r) => slimAudio(Number(r.id), stripNulls$1(r.data)));
40634
41164
  return rows.map((r) => {
40635
41165
  return {
40636
- id: r.id,
41166
+ id: Number(r.id),
40637
41167
  kind: "audio",
40638
41168
  ...stripNulls$1(r.data)
40639
41169
  };
@@ -40644,9 +41174,28 @@ var EventStore = class {
40644
41174
  * resolver's track fallback: a new-style object event owns no crop, so its
40645
41175
  * `mediaUrl` degrades to the track's cadence media, addressed by this id.
40646
41176
  * Returns `null` when the id is not a persisted object event (e.g. a KeyEvent
40647
- * whose id is already a track id, or a motion/audio event) — best-effort.
40648
- */
40649
- async getTrackIdForEvent(eventId) {
41177
+ * whose id is already a track id) — best-effort.
41178
+ *
41179
+ * **D476: only an OBJECT event has a `trackId` at all** — motion and audio
41180
+ * rows carry no such column, so this probes `OBJECT_EVENTS_COLLECTION`
41181
+ * only, never the other two. That was already true structurally, but before
41182
+ * D474 an id that MISSED here was safely "not an object event" because
41183
+ * every id was a UUID, unique across all three tables. D474 made event ids
41184
+ * per-table rowid aliases, so a motion id can now coincide with an
41185
+ * UNRELATED object row of the same number — a probe that HITS is no longer
41186
+ * proof the caller's id names that object row.
41187
+ *
41188
+ * `ownerType`, when the caller already knows it (a `motion:`/`audio:`/
41189
+ * `object:` key was parsed rather than a bare legacy id), short-circuits to
41190
+ * `null` without querying for anything but `'object'` — the caller's one
41191
+ * remaining lever against the collision, since this method has nothing left
41192
+ * to guess once the kind is in the key. Its only caller today
41193
+ * (`readEventThumbnail` in `index.ts`) still reaches this with a bare,
41194
+ * kind-less id from a URL and cannot supply it — see the D476 ADR for why
41195
+ * that residual ambiguity is accepted rather than fixed here.
41196
+ */
41197
+ async getTrackIdForEvent(eventId, ownerType) {
41198
+ if (ownerType !== void 0 && ownerType !== "object") return null;
40650
41199
  try {
40651
41200
  const row = await this.store.get.query({
40652
41201
  collection: OBJECT_EVENTS_COLLECTION,
@@ -40999,9 +41548,11 @@ var EventStore = class {
40999
41548
  });
41000
41549
  const motion = await batch(MOTION_EVENTS_COLLECTION);
41001
41550
  const audio = await batch(AUDIO_EVENTS_COLLECTION);
41002
- const ids = [...motion, ...audio];
41003
- if (ids.length > 0 && this.media !== void 0) try {
41004
- await this.media.deleteForEvents(ids);
41551
+ if ((motion.length > 0 || audio.length > 0) && this.media !== void 0) try {
41552
+ await this.media.deleteForEvents({
41553
+ motion,
41554
+ audio
41555
+ });
41005
41556
  } catch (err) {
41006
41557
  this.logger.warn("EventStore.evictTracklessBefore: event media delete failed", { meta: { error: String(err) } });
41007
41558
  }
@@ -41067,7 +41618,7 @@ var EventStore = class {
41067
41618
  * {@link existingEventIds} answers "is this event alive", which is the
41068
41619
  * orphan audit's question. The media reclaim asks a narrower one: *is this
41069
41620
  * event-owned snapshot a MOTION-VISIT still* — because the selection shape
41070
- * (`ownerKind: 'event', kind: 'snapshot'`) is not itself a guarantee.
41621
+ * (`ownerKind: 'motion', kind: 'snapshot'`) is not itself a guarantee.
41071
41622
  * Nothing structurally prevents another writer landing an event-owned
41072
41623
  * snapshot on an object or audio row, and the reclaim must not be the place
41073
41624
  * that discovers it. A per-page indexed `whereIn` on one table is cheap
@@ -41134,7 +41685,12 @@ var EventStore = class {
41134
41685
  ...motionIds,
41135
41686
  ...objectIds,
41136
41687
  ...audioIds
41137
- ]
41688
+ ],
41689
+ byKind: {
41690
+ motion: motionIds,
41691
+ object: objectIds,
41692
+ audio: audioIds
41693
+ }
41138
41694
  };
41139
41695
  }
41140
41696
  /**
@@ -41199,7 +41755,7 @@ var EventStore = class {
41199
41755
  }
41200
41756
  let mediaRows = 0;
41201
41757
  if (eventIds.length > 0 && this.media !== void 0) try {
41202
- mediaRows = await this.media.deleteForEvents(eventIds);
41758
+ mediaRows = await this.media.deleteForEvents({ object: eventIds });
41203
41759
  } catch (err) {
41204
41760
  this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
41205
41761
  }
@@ -41529,8 +42085,8 @@ function rowMatchesZone(data, zone) {
41529
42085
  const fw = data["frameWidth"];
41530
42086
  const fh = data["frameHeight"];
41531
42087
  if (typeof fw !== "number" || typeof fh !== "number") return true;
41532
- const positions = data["positions"];
41533
- if (!Array.isArray(positions)) return true;
42088
+ const positions = decodePositions(data["positions"]);
42089
+ if (positions.length === 0) return true;
41534
42090
  const positionRows = [];
41535
42091
  for (const p of positions) {
41536
42092
  if (p === null || typeof p !== "object") continue;
@@ -43105,7 +43661,13 @@ var TrackStore = class {
43105
43661
  data
43106
43662
  });
43107
43663
  matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
43108
- return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
43664
+ return matched.slice(0, limit).map((r) => {
43665
+ const track = this.rowToTrack(r.id, r.data, params.projection);
43666
+ return {
43667
+ ...track,
43668
+ positions: geometryOnlyPositions(track.positions)
43669
+ };
43670
+ });
43109
43671
  }
43110
43672
  /**
43111
43673
  * Batched multi-device recent-tracks page (`listRecentTracks`): the
@@ -43360,7 +43922,7 @@ var TrackStore = class {
43360
43922
  ...t.producingDeviceName !== void 0 ? { producingDeviceName: t.producingDeviceName } : {},
43361
43923
  firstSeen: t.firstSeen,
43362
43924
  lastSeen: t.lastSeen,
43363
- positions: [...t.positions],
43925
+ positions: encodePositions(t.positions),
43364
43926
  observations: t.positions.length,
43365
43927
  snapshots: [...t.snapshots],
43366
43928
  zonesVisited: [...t.zonesVisited],
@@ -43389,7 +43951,7 @@ var TrackStore = class {
43389
43951
  ...t.source !== void 0 ? { source: t.source } : {},
43390
43952
  firstSeen: t.firstSeen,
43391
43953
  lastSeen: t.lastSeen,
43392
- positions: [...t.positions],
43954
+ positions: encodePositions(t.positions),
43393
43955
  observations: t.positions.length,
43394
43956
  snapshots: [...t.snapshots],
43395
43957
  zonesVisited: [...t.zonesVisited],
@@ -43429,7 +43991,7 @@ var TrackStore = class {
43429
43991
  */
43430
43992
  rowToTrack(id, data, projection) {
43431
43993
  const slim = projection === "slim";
43432
- const storedPositions = slim ? [] : data["positions"] ?? [];
43994
+ const storedPositions = slim ? [] : decodePositions(data["positions"]);
43433
43995
  /**
43434
43996
  * The stored count first, the blob only as a fallback — never `0` for a
43435
43997
  * column that was not read.
@@ -44396,7 +44958,7 @@ function createDiscardJob(deps) {
44396
44958
  collection: TRACKS_COLLECTION,
44397
44959
  key: owner.ownerId
44398
44960
  });
44399
- } else if (owner.ownerKind === "event") await deps.store.delete.mutate({
44961
+ } else if (owner.ownerKind === "object") await deps.store.delete.mutate({
44400
44962
  collection: OBJECT_EVENTS_COLLECTION,
44401
44963
  key: owner.ownerId
44402
44964
  });
@@ -45074,21 +45636,29 @@ function allEventIds(events) {
45074
45636
  /**
45075
45637
  * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
45076
45638
  * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
45077
- * owning TRACK's. Two batched index reads at most, and the second only over the
45078
- * object events the first did not already answer for motion and audio are
45079
- * trackless roots, so they never need it.
45639
+ * owning TRACK's. Up to four batched index reads one PER EVENT KIND that has
45640
+ * any ids (D476: `motion` / `object` / `audio` are separate owner kinds now,
45641
+ * so a single combined read can no longer ask "does any of these ids own
45642
+ * media" — a motion id and an object id can be the same number post-D474 and
45643
+ * are answered by different rows) — plus the track fallback, only over the
45644
+ * object events the first pass did not already answer for (motion and audio
45645
+ * are trackless roots, so they never need it).
45080
45646
  *
45081
45647
  * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
45082
45648
  * LIST; swallowing here would collapse those two opposite directions into one.
45083
45649
  */
45084
45650
  async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
45085
- const eventIds = allEventIds(events);
45086
- if (eventIds.length === 0) return /* @__PURE__ */ new Set();
45087
- const ownMedia = await fetchMediaOwners({
45088
- deviceId,
45089
- ownerKind: "event",
45090
- ownerIds: eventIds
45091
- });
45651
+ const ownMedia = /* @__PURE__ */ new Set();
45652
+ for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
45653
+ const ownerIds = events[ownerType].map((e) => e.id);
45654
+ if (ownerIds.length === 0) continue;
45655
+ const found = await fetchMediaOwners({
45656
+ deviceId,
45657
+ ownerKind: ownerType,
45658
+ ownerIds
45659
+ });
45660
+ for (const id of found) ownMedia.add(id);
45661
+ }
45092
45662
  const residualTracks = [];
45093
45663
  const seenTracks = /* @__PURE__ */ new Set();
45094
45664
  for (const event of events.object) {
@@ -45203,6 +45773,15 @@ function reportUnvouchedClips(deps, deviceId, clips) {
45203
45773
  });
45204
45774
  }
45205
45775
  //#endregion
45776
+ //#region src/pipeline-analytics/videoclips-shared.ts
45777
+ /** Stringify the numeric event ids of one bucket. Lossless. */
45778
+ function toEventLike(events) {
45779
+ return events.map((e) => ({
45780
+ ...e,
45781
+ id: String(e.id)
45782
+ }));
45783
+ }
45784
+ //#endregion
45206
45785
  //#region src/pipeline-analytics/viewer-settings-actions.ts
45207
45786
  /**
45208
45787
  * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
@@ -46258,7 +46837,13 @@ var AnalyticsQueryFacade = class {
46258
46837
  const store = this.deps.mediaStore();
46259
46838
  if (store === null) return [];
46260
46839
  const prefix = this.deps.eventMediaPathPrefix();
46261
- return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
46840
+ const kinds = input.kind === void 0 ? void 0 : [input.kind];
46841
+ let rows = [];
46842
+ for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
46843
+ rows = await store.listByOwner(ownerType, input.eventId, input.deviceId, kinds);
46844
+ if (rows.length > 0) break;
46845
+ }
46846
+ return rows.map((row) => toWireMediaFile(row, prefix));
46262
46847
  }
46263
46848
  /**
46264
46849
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -46300,7 +46885,12 @@ var AnalyticsQueryFacade = class {
46300
46885
  const store = this.deps.mediaStore();
46301
46886
  if (store === null) return [];
46302
46887
  const prefix = this.deps.eventMediaPathPrefix();
46303
- return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
46888
+ let rows = [];
46889
+ for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
46890
+ rows = await store.listInfoByOwner(ownerType, input.eventId, input.deviceId);
46891
+ if (rows.length > 0) break;
46892
+ }
46893
+ return rows.map((row) => toWireMediaFileInfo(row, prefix));
46304
46894
  }
46305
46895
  /**
46306
46896
  * A track's media WITHOUT the bytes.
@@ -46334,9 +46924,9 @@ var AnalyticsQueryFacade = class {
46334
46924
  object: 0,
46335
46925
  audio: 0
46336
46926
  };
46337
- const { counts, ids } = await eventStore.pruneBefore(input);
46927
+ const { counts, ids, byKind } = await eventStore.pruneBefore(input);
46338
46928
  const mediaStore = this.deps.mediaStore();
46339
- if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents([...ids]);
46929
+ if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents(byKind);
46340
46930
  const { motion, object, audio } = counts;
46341
46931
  if (motion + object + audio > 0) this.deps.logger().info("analytics event eviction (floor)", {
46342
46932
  tags: { deviceId: input.deviceId },
@@ -46458,7 +47048,11 @@ var AnalyticsQueryFacade = class {
46458
47048
  ...evicted.audio
46459
47049
  ];
46460
47050
  const mediaStore = this.deps.mediaStore();
46461
- if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents(ids);
47051
+ if (ids.length > 0 && mediaStore) await mediaStore.deleteForEvents({
47052
+ motion: evicted.motion,
47053
+ object: objectIds,
47054
+ audio: evicted.audio
47055
+ });
46462
47056
  const counts = {
46463
47057
  motion: evicted.motion.length,
46464
47058
  object: objectIds.length,
@@ -49297,9 +49891,13 @@ function resolveCropForcedMedia(files, pickClean, detailFiles = []) {
49297
49891
  * Event + track media (the body/scene) and face/plate media (the detail
49298
49892
  * crops). Face rows live under `face-${trackId}`; a track can close with
49299
49893
  * `hasEmbeddedFace` and ZERO track files (0e343f86).
49894
+ *
49895
+ * `eventFiles` is the event's OWN media, already fetched by the caller (the
49896
+ * same rows the default path reuses via {@link collectDefaultEventMedia}) —
49897
+ * this function no longer lists by owner kind `'event'` itself (D476; see
49898
+ * {@link CropForcedOwnerKind}).
49300
49899
  */
49301
- async function collectCropForcedMedia(source, id) {
49302
- const eventFiles = await source.listByOwner("event", id);
49900
+ async function collectCropForcedMedia(source, id, eventFiles) {
49303
49901
  const trackFiles = await source.listByOwner("track", id);
49304
49902
  const ownerTrackId = trackFiles.length === 0 ? await source.getTrackIdForEvent(id) : null;
49305
49903
  const owningTrackFiles = ownerTrackId === null ? [] : await source.listByOwner("track", ownerTrackId);
@@ -56253,11 +56851,23 @@ var PACKAGE_IMPORTANCE = 1;
56253
56851
  * stay `getObjectEvents`-output-valid. */
56254
56852
  var DELIVERED_STATE = "idle";
56255
56853
  var PICKED_UP_STATE = "left";
56256
- /** Deterministic durable-event ids keyed on the stationary entry id. */
56257
- function deliveredEventId(entryId) {
56854
+ /**
56855
+ * Deterministic IDEMPOTENCE keys, one per (stationary entry, phase).
56856
+ *
56857
+ * These were the event ids until D474 made an event id the SQLite rowid, which
56858
+ * the database assigns and a producer cannot choose. They kept their exact
56859
+ * spelling and moved to `ObjectEvent.idempotencyKey`: the gates below are the
56860
+ * only reason a restart does not re-deliver every parcel, and the strings are
56861
+ * on 2.1 million existing rows' primary key, so a row written before the
56862
+ * rebuild is still recognisable by the same text.
56863
+ *
56864
+ * They are NOT event ids any more. `eventId` on the emitted payload and in
56865
+ * every log line is the row's real id.
56866
+ */
56867
+ function deliveredIdempotencyKey(entryId) {
56258
56868
  return `pa-pkg-${entryId}-delivered`;
56259
56869
  }
56260
- function pickedUpEventId(entryId) {
56870
+ function pickedUpIdempotencyKey(entryId) {
56261
56871
  return `pa-pkg-${entryId}-pickedup`;
56262
56872
  }
56263
56873
  var PackageDropDetector = class {
@@ -56395,26 +57005,28 @@ var PackageDropDetector = class {
56395
57005
  });
56396
57006
  return;
56397
57007
  }
56398
- const eventId = deliveredEventId(entry.id);
56399
- if ((await this.deps.events.queryObject({
57008
+ const idempotencyKey = deliveredIdempotencyKey(entry.id);
57009
+ const alreadyDelivered = (await this.deps.events.queryObject({
56400
57010
  deviceId: entry.deviceId,
56401
- classFilter: "package"
56402
- })).some((e) => e.id === eventId)) {
57011
+ classFilter: PACKAGE_EVENT_CLASS
57012
+ })).find((e) => e.idempotencyKey === idempotencyKey || String(e.id) === idempotencyKey);
57013
+ if (alreadyDelivered !== void 0) {
56403
57014
  this.deps.logger.info("package gate 5/5 idempotence — delivery already recorded", {
56404
57015
  tags,
56405
57016
  meta: {
56406
57017
  entryId: entry.id,
56407
- eventId
57018
+ idempotencyKey,
57019
+ eventId: alreadyDelivered.id
56408
57020
  }
56409
57021
  });
56410
57022
  return;
56411
57023
  }
56412
- const ev = {
56413
- id: eventId,
57024
+ const draft = {
56414
57025
  kind: "object",
56415
57026
  deviceId: entry.deviceId,
56416
57027
  timestamp,
56417
57028
  source: "pipeline",
57029
+ idempotencyKey,
56418
57030
  trackId: entry.sourceTrackId ?? entry.id,
56419
57031
  className: PACKAGE_EVENT_CLASS,
56420
57032
  ...entry.label !== void 0 ? { label: entry.label } : {},
@@ -56432,12 +57044,26 @@ var PackageDropDetector = class {
56432
57044
  ...entry.keyFrameMediaKey !== void 0 ? { mediaKey: entry.keyFrameMediaKey } : {},
56433
57045
  importance: PACKAGE_IMPORTANCE
56434
57046
  };
56435
- await this.deps.events.insertObject(ev);
57047
+ const rowId = await this.deps.events.insertObject(draft);
57048
+ if (rowId === null) {
57049
+ this.deps.logger.warn("package delivery NOT emitted — the event row did not persist", {
57050
+ tags,
57051
+ meta: {
57052
+ entryId: entry.id,
57053
+ idempotencyKey
57054
+ }
57055
+ });
57056
+ return;
57057
+ }
57058
+ const ev = {
57059
+ ...draft,
57060
+ id: rowId
57061
+ };
56436
57062
  this.deps.onPersisted?.(ev, "delivered");
56437
57063
  this.deps.emit.delivered({
56438
57064
  deviceId: entry.deviceId,
56439
57065
  entryId: entry.id,
56440
- eventId,
57066
+ eventId: String(rowId),
56441
57067
  className: entry.className,
56442
57068
  zoneIds: hitZones,
56443
57069
  ...entry.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: entry.keyFrameMediaKey } : {},
@@ -56453,7 +57079,8 @@ var PackageDropDetector = class {
56453
57079
  tags: { deviceId: entry.deviceId },
56454
57080
  meta: {
56455
57081
  entryId: entry.id,
56456
- eventId,
57082
+ eventId: rowId,
57083
+ idempotencyKey,
56457
57084
  zoneIds: hitZones,
56458
57085
  dwellMs,
56459
57086
  areaFrac: Number(areaFrac.toFixed(5)),
@@ -56471,13 +57098,14 @@ var PackageDropDetector = class {
56471
57098
  });
56472
57099
  return;
56473
57100
  }
56474
- const deliveredId = deliveredEventId(entry.id);
56475
- const pickedUpId = pickedUpEventId(entry.id);
57101
+ const deliveredKey = deliveredIdempotencyKey(entry.id);
57102
+ const pickedUpKey = pickedUpIdempotencyKey(entry.id);
56476
57103
  const rows = await this.deps.events.queryObject({
56477
57104
  deviceId: entry.deviceId,
56478
57105
  classFilter: PACKAGE_EVENT_CLASS
56479
57106
  });
56480
- const delivered = rows.find((e) => e.id === deliveredId);
57107
+ const matchesKey = (e, key) => e.idempotencyKey === key || String(e.id) === key;
57108
+ const delivered = rows.find((e) => matchesKey(e, deliveredKey));
56481
57109
  if (delivered === void 0) {
56482
57110
  this.deps.logger.debug("package pick-up skipped — no delivery row for this entry", {
56483
57111
  tags: { deviceId: entry.deviceId },
@@ -56488,12 +57116,14 @@ var PackageDropDetector = class {
56488
57116
  });
56489
57117
  return;
56490
57118
  }
56491
- if (rows.some((e) => e.id === pickedUpId)) {
57119
+ const alreadyPickedUp = rows.find((e) => matchesKey(e, pickedUpKey));
57120
+ if (alreadyPickedUp !== void 0) {
56492
57121
  this.deps.logger.info("package pick-up skipped — already recorded", {
56493
57122
  tags: { deviceId: entry.deviceId },
56494
57123
  meta: {
56495
57124
  entryId: entry.id,
56496
- eventId: pickedUpId
57125
+ eventId: alreadyPickedUp.id,
57126
+ idempotencyKey: pickedUpKey
56497
57127
  }
56498
57128
  });
56499
57129
  return;
@@ -56505,17 +57135,17 @@ var PackageDropDetector = class {
56505
57135
  tags: { deviceId: entry.deviceId },
56506
57136
  meta: {
56507
57137
  entryId: entry.id,
56508
- eventId: pickedUpId,
57138
+ idempotencyKey: pickedUpKey,
56509
57139
  packageTrackId,
56510
57140
  pickupAt: entry.lastConfirmedAt
56511
57141
  }
56512
57142
  });
56513
- const ev = {
56514
- id: pickedUpId,
57143
+ const draft = {
56515
57144
  kind: "object",
56516
57145
  deviceId: entry.deviceId,
56517
57146
  timestamp,
56518
57147
  source: "pipeline",
57148
+ idempotencyKey: pickedUpKey,
56519
57149
  trackId: mediaTrackId,
56520
57150
  className: PACKAGE_EVENT_CLASS,
56521
57151
  ...entry.label !== void 0 ? { label: entry.label } : {},
@@ -56533,12 +57163,26 @@ var PackageDropDetector = class {
56533
57163
  ...collector?.keyFrameMediaKey !== void 0 ? { mediaKey: collector.keyFrameMediaKey } : {},
56534
57164
  importance: PACKAGE_IMPORTANCE
56535
57165
  };
56536
- await this.deps.events.insertObject(ev);
57166
+ const rowId = await this.deps.events.insertObject(draft);
57167
+ if (rowId === null) {
57168
+ this.deps.logger.warn("package pick-up NOT emitted — the event row did not persist", {
57169
+ tags: { deviceId: entry.deviceId },
57170
+ meta: {
57171
+ entryId: entry.id,
57172
+ idempotencyKey: pickedUpKey
57173
+ }
57174
+ });
57175
+ return;
57176
+ }
57177
+ const ev = {
57178
+ ...draft,
57179
+ id: rowId
57180
+ };
56537
57181
  this.deps.onPersisted?.(ev, "picked-up");
56538
57182
  this.deps.emit.pickedUp({
56539
57183
  deviceId: entry.deviceId,
56540
57184
  entryId: entry.id,
56541
- deliveredEventId: deliveredId,
57185
+ deliveredEventId: String(delivered.id),
56542
57186
  className: entry.className,
56543
57187
  ...collector !== void 0 ? { collectorTrackId: collector.trackId } : {},
56544
57188
  timestamp
@@ -56547,8 +57191,9 @@ var PackageDropDetector = class {
56547
57191
  tags: { deviceId: entry.deviceId },
56548
57192
  meta: {
56549
57193
  entryId: entry.id,
56550
- eventId: pickedUpId,
56551
- deliveredEventId: deliveredId,
57194
+ eventId: rowId,
57195
+ idempotencyKey: pickedUpKey,
57196
+ deliveredEventId: delivered.id,
56552
57197
  mediaTrackId,
56553
57198
  ...collector !== void 0 ? {
56554
57199
  collectorTrackId: collector.trackId,
@@ -58983,7 +59628,7 @@ async function loadRebuildSource(deps, trackId) {
58983
59628
  const cropInfo = media.find((m) => m.kind === "thumbnail");
58984
59629
  const peak = await deps.events.peakForTrack(trackId);
58985
59630
  const events = await deps.events.queryObjectByTrackIds([trackId]);
58986
- const best = events.find((e) => e.id === peak.bestEventId) ?? events[0];
59631
+ const best = events.find((e) => String(e.id) === peak.bestEventId) ?? events[0];
58987
59632
  if (best === void 0) return null;
58988
59633
  const common = {
58989
59634
  trackId,
@@ -62876,7 +63521,7 @@ var ANALYTICS_COLLECTIONS = [
62876
63521
  classification: {
62877
63522
  kind: "owner-indirection",
62878
63523
  derivation: "explicit",
62879
- 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."
63524
+ 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."
62880
63525
  }
62881
63526
  },
62882
63527
  {
@@ -63090,7 +63735,9 @@ function resolveMediaOwner(row) {
63090
63735
  kind: "track",
63091
63736
  trackId: ownerId
63092
63737
  };
63093
- case "event": return {
63738
+ case "motion":
63739
+ case "object":
63740
+ case "audio": return {
63094
63741
  kind: "event",
63095
63742
  eventId: ownerId
63096
63743
  };
@@ -64359,8 +65006,21 @@ var FULL_FRAME_BBOX = {
64359
65006
  w: 1,
64360
65007
  h: 1
64361
65008
  };
65009
+ /** The same classification the RUNNER applies to decide "full frame" from the
65010
+ * bbox (`getNativeCrop`), restated here so the two cannot drift. */
65011
+ function isFullFrameBbox(bbox) {
65012
+ return bbox.x <= .001 && bbox.y <= .001 && bbox.w >= .999 && bbox.h >= .999;
65013
+ }
64362
65014
  /** Throttle for the native-crop HIT/FALLBACK metric window line. */
64363
65015
  var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
65016
+ /**
65017
+ * Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (the
65018
+ * `keyFrame`/group-shot rung, ~1–1.5 MB on a 4K camera) rather than a boxed
65019
+ * timeline tile or a pinned ≤640 detection frame (tens of kB). The two belong
65020
+ * in different counters or the window line says "full frames" and means two
65021
+ * payload classes three orders of magnitude apart. (D470)
65022
+ */
65023
+ var FULL_FRAME_TILE_WIDTH_CEILING = 1920;
64364
65024
  function createNativeFrameTransport(deps) {
64365
65025
  const { api, logger } = deps;
64366
65026
  const pipelineRunnerApi = api.pipelineRunner;
@@ -64434,16 +65094,35 @@ function createNativeFrameTransport(deps) {
64434
65094
  tier: reply.tier === "ram-fullframe" ? "ram-fullframe" : "native"
64435
65095
  };
64436
65096
  };
64437
- const getRemoteFrame = async (handle) => requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame);
64438
- const fetchNativeFullFrameTiered = async (handle, maxWidth) => requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame);
65097
+ const getRemoteFrame = async (handle) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame));
65098
+ const fetchNativeFullFrameTiered = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame));
64439
65099
  const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
64440
65100
  const cropMetricLogger = logger.child("NativeCrop");
64441
65101
  let nativeHits = 0;
64442
65102
  let nativeFallbacks = 0;
64443
65103
  let lastCropMetricAt = 0;
65104
+ let roiFetches = 0;
65105
+ let fullFrames = 0;
65106
+ let nativeFullFrames = 0;
64444
65107
  const bumpCropMetric = (hit) => {
64445
65108
  if (hit) nativeHits += 1;
64446
65109
  else nativeFallbacks += 1;
65110
+ maybeLogCropWindow();
65111
+ };
65112
+ /**
65113
+ * Publish the window once `request` has SETTLED — never before, or the line
65114
+ * carries a window whose own outcome has not been counted yet and throttles
65115
+ * away the one that has. `finally`, so the deliberate throw of
65116
+ * `fetchNativeFullFrameTiered` still reaches `persistTrackKeyFrame`.
65117
+ */
65118
+ const closeWindowAfter = async (request) => {
65119
+ try {
65120
+ return await request;
65121
+ } finally {
65122
+ maybeLogCropWindow();
65123
+ }
65124
+ };
65125
+ const maybeLogCropWindow = () => {
64447
65126
  const now = Date.now();
64448
65127
  if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
64449
65128
  lastCropMetricAt = now;
@@ -64453,11 +65132,20 @@ function createNativeFrameTransport(deps) {
64453
65132
  detectionFrameFallbacks: nativeFallbacks,
64454
65133
  wireFetches: dedupe.fetched,
64455
65134
  coalesced: dedupe.coalesced,
64456
- reusedInWindow: dedupe.reused
65135
+ reusedInWindow: dedupe.reused,
65136
+ roiCalls: roiFetches,
65137
+ tileFullFrameCalls: fullFrames,
65138
+ nativeFullFrameCalls: nativeFullFrames
64457
65139
  } });
65140
+ roiFetches = 0;
65141
+ fullFrames = 0;
65142
+ nativeFullFrames = 0;
64458
65143
  };
64459
65144
  const requestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
64460
65145
  if (!pipelineRunnerApi?.getNativeCrop) return null;
65146
+ if (isFullFrameBbox(bbox)) if (maxWidth !== void 0 && maxWidth > FULL_FRAME_TILE_WIDTH_CEILING) nativeFullFrames += 1;
65147
+ else fullFrames += 1;
65148
+ else roiFetches += 1;
64461
65149
  const native = await pipelineRunnerApi.getNativeCrop.query({
64462
65150
  handle: frameHandle,
64463
65151
  bbox,
@@ -66506,7 +67194,7 @@ var EventMediaDispatcher = class {
66506
67194
  if (!childCropData) continue;
66507
67195
  await this.deps.mediaStore.put({
66508
67196
  deviceId,
66509
- ownerKind: "event",
67197
+ ownerKind: "object",
66510
67198
  ownerId: ev.eventId,
66511
67199
  kind: child.kind,
66512
67200
  timestamp: ev.timestamp,
@@ -66670,7 +67358,7 @@ var MotionEventSnapshotCapturer = class {
66670
67358
  try {
66671
67359
  await this.deps.media.put({
66672
67360
  deviceId: input.deviceId,
66673
- ownerKind: "event",
67361
+ ownerKind: "motion",
66674
67362
  ownerId: input.eventId,
66675
67363
  kind: "snapshot",
66676
67364
  timestamp: input.timestamp,
@@ -69643,48 +70331,111 @@ function compareSummaryScores(a, b) {
69643
70331
  if (a.visible !== b.visible) return a.visible - b.visible;
69644
70332
  return a.quality - b.quality;
69645
70333
  }
69646
- /** A candidate replaces the best only when it is STRICTLY better — a tie keeps
69647
- * the pixels already materialised (they cost a native round trip). */
69648
- function isBetterSummaryScore(candidate, best) {
69649
- if (candidate.visible === 0) return false;
69650
- return best === null || compareSummaryScores(candidate, best) > 0;
69651
- }
69652
- //#endregion
69653
- //#region src/pipeline-analytics/summary/group-shot.ts
69654
- /**
69655
- * The running argmax of one open summary's group shot.
69656
- *
69657
- * RAM only, on purpose: it decides WHEN pixels are worth a native round trip
69658
- * (only when the maximum improves — D9/D18), it is not the record of the
69659
- * shot. The record is the media row the caller writes under the summary's
69660
- * id; after a restart an open summary simply starts its argmax again and
69661
- * replaces the row the next time it beats what it can remember, which is
69662
- * nothing. That is a weaker shot for one session, never a wrong one.
69663
- */
69664
70334
  var GroupShotTracker = class {
69665
- best = /* @__PURE__ */ new Map();
70335
+ states = /* @__PURE__ */ new Map();
70336
+ offered = 0;
70337
+ materialised = 0;
70338
+ byGap = 0;
70339
+ byMargin = 0;
70340
+ byWorse = 0;
69666
70341
  /**
69667
- * Offer a frame. Returns `true` when it beats the summary's best so far —
69668
- * the caller's cue to materialise the pixels for THIS frame, now, while its
69669
- * handle is still leased.
70342
+ * Offer a frame. Returns `true` when it is worth materialising NOW the
70343
+ * caller's cue to fetch the pixels for THIS frame, while its handle is
70344
+ * still leased.
70345
+ *
70346
+ * The clock is `frame.timestamp`; the caller injects no second one.
69670
70347
  */
69671
70348
  offer(summaryId, score, frame) {
69672
- if (!isBetterSummaryScore(score, (this.best.get(summaryId) ?? null)?.score ?? null)) return false;
69673
- this.best.set(summaryId, {
70349
+ if (score.visible === 0) return false;
70350
+ this.offered += 1;
70351
+ const state = this.states.get(summaryId);
70352
+ if (state === void 0) {
70353
+ this.states.set(summaryId, {
70354
+ delivered: null,
70355
+ lastAttemptAt: frame.timestamp,
70356
+ unlanded: 1
70357
+ });
70358
+ this.materialised += 1;
70359
+ return true;
70360
+ }
70361
+ if (state.lastAttemptAt !== void 0 && frame.timestamp - state.lastAttemptAt < 2e3) {
70362
+ this.byGap += 1;
70363
+ return false;
70364
+ }
70365
+ if (!this.isWorthPixels(state, score)) return false;
70366
+ state.lastAttemptAt = frame.timestamp;
70367
+ state.unlanded += 1;
70368
+ this.materialised += 1;
70369
+ return true;
70370
+ }
70371
+ /**
70372
+ * Nothing has landed yet → keep trying (D58) until the budget is spent.
70373
+ * Something has landed → beat it, by the margin on the quality tiebreak.
70374
+ */
70375
+ isWorthPixels(state, score) {
70376
+ const delivered = state.delivered;
70377
+ if (delivered === null) {
70378
+ if (state.unlanded >= 4) {
70379
+ this.byMargin += 1;
70380
+ return false;
70381
+ }
70382
+ return true;
70383
+ }
70384
+ if (compareSummaryScores(score, delivered.score) <= 0) {
70385
+ this.byWorse += 1;
70386
+ return false;
70387
+ }
70388
+ if (score.visible === delivered.score.visible && score.quality - delivered.score.quality < .05) {
70389
+ this.byMargin += 1;
70390
+ return false;
70391
+ }
70392
+ return true;
70393
+ }
70394
+ /**
70395
+ * A materialisation LANDED. Only this advances the baseline the next
70396
+ * candidate must beat — a fetch that missed leaves the summary exactly as
70397
+ * picture-less as it was, and the next frame may be worse and still worth
70398
+ * taking.
70399
+ */
70400
+ noteLanded(summaryId, score, frame) {
70401
+ const state = this.states.get(summaryId);
70402
+ if (state === void 0) return;
70403
+ state.delivered = {
69674
70404
  score,
69675
70405
  frame
69676
- });
69677
- return true;
70406
+ };
70407
+ state.unlanded = 0;
69678
70408
  }
69679
- bestOf(summaryId) {
69680
- return this.best.get(summaryId) ?? null;
70409
+ /** The shot on disk for this summary, as this process last saw it land. */
70410
+ deliveredOf(summaryId) {
70411
+ return this.states.get(summaryId)?.delivered ?? null;
69681
70412
  }
69682
70413
  /** A sealed or evicted summary takes no more frames; drop its state. */
69683
70414
  forget(summaryId) {
69684
- this.best.delete(summaryId);
70415
+ this.states.delete(summaryId);
70416
+ }
70417
+ /**
70418
+ * What the gate did since the last read. Shipped WITH the gate: without it
70419
+ * the only evidence for a fetch that did NOT happen is hub-main's byte
70420
+ * counter, which answers a different question a minute later.
70421
+ */
70422
+ drainCounts() {
70423
+ const counts = {
70424
+ offered: this.offered,
70425
+ materialised: this.materialised,
70426
+ byGap: this.byGap,
70427
+ byMargin: this.byMargin,
70428
+ byWorse: this.byWorse
70429
+ };
70430
+ this.offered = 0;
70431
+ this.materialised = 0;
70432
+ this.byGap = 0;
70433
+ this.byMargin = 0;
70434
+ this.byWorse = 0;
70435
+ return counts;
69685
70436
  }
69686
70437
  get size() {
69687
- return this.best.size;
70438
+ return this.states.size;
69688
70439
  }
69689
70440
  };
69690
70441
  //#endregion
@@ -69981,7 +70732,7 @@ async function buildTrainingExportPlan(readers, input) {
69981
70732
  scope: "plate"
69982
70733
  },
69983
70734
  ...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
69984
- ownerKind: "event",
70735
+ ownerKind: "object",
69985
70736
  ownerId: eventId,
69986
70737
  scope: `events/${eventId}`
69987
70738
  }))
@@ -70823,6 +71574,8 @@ var RETRAIN_EXPORT_MAX = 2e4;
70823
71574
  * surface to turn the refinement pipeline on/off for a camera.
70824
71575
  */
70825
71576
  var TTL_SWEEP_INTERVAL_MS = 5e3;
71577
+ /** Throttle for the group-shot gate's window line (D470). */
71578
+ var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
70826
71579
  /**
70827
71580
  * How often the addon asks "is a gallery sample outside the cluster face
70828
71581
  * space?". A flip is rare and the check is two cheap reads — a minute keeps
@@ -70950,7 +71703,6 @@ var AUDIO_EPISODE_PERSIST_MS = 2e3;
70950
71703
  * the persistence (D62).
70951
71704
  */
70952
71705
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
70953
- var MOTION_EVENT_HEARTBEAT_MS = 5e3;
70954
71706
  /**
70955
71707
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
70956
71708
  * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
@@ -71079,8 +71831,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71079
71831
  summaryStore = null;
71080
71832
  /** The ONLY writer of a summary's lifecycle (D359). */
71081
71833
  summarySealer = null;
71082
- /** The running argmax of every open summary's group shot (D359 phase 3). RAM only. */
71834
+ /** The materialisation gate on every open summary's group shot (D359 phase 3, D470). RAM only. */
71083
71835
  groupShots = new GroupShotTracker();
71836
+ /** Throttle for the group-shot gate's window line (D470). */
71837
+ lastGroupShotMetricAt = 0;
71084
71838
  /**
71085
71839
  * Tracks a gallery match has named, for the group-shot score's recognition
71086
71840
  * term. "Recognised so far" rather than "at that instant": the match arrives
@@ -71517,7 +72271,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71517
72271
  secondarySubjects = new SecondarySubjectQueue();
71518
72272
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
71519
72273
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
71520
- lastMotionInsertByDevice = /* @__PURE__ */ new Map();
72274
+ lastMotionTickByDevice = /* @__PURE__ */ new Map();
72275
+ motionEpisodes = null;
71521
72276
  levelStateByDevice = /* @__PURE__ */ new Map();
71522
72277
  /** Per-device corroboration window for classified audio (see
71523
72278
  * `audio-confirm-window.ts`). Torn down wherever `levelStateByDevice` is —
@@ -72686,6 +73441,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72686
73441
  media: this.mediaStore
72687
73442
  });
72688
73443
  this.eventStore = eventStore;
73444
+ this.motionEpisodes = new MotionEpisodeTracker({
73445
+ closeAfterMs: require_dist.MOTION_CLOSE_AFTER_MS,
73446
+ logger: logger.child("MotionEpisodeTracker"),
73447
+ insertRow: (input) => this.insertMotionEpisodeRow(input.deviceId, input.timestamp, input.regionData),
73448
+ closeRow: (input) => this.closeMotionEpisodeRow(input),
73449
+ onNewEdge: (input) => this.onMotionEpisodeEdge(input),
73450
+ onTick: (input) => this.onMotionEpisodeTick(input)
73451
+ });
72689
73452
  this.sensorEventStore = new SensorEventStore({
72690
73453
  store: api.settingsStore,
72691
73454
  logger: logger.child("SensorEventStore")
@@ -73344,11 +74107,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73344
74107
  * The mosaic is filed through the SAME `mediaStore` every event thumbnail
73345
74108
  * goes through — the operator's directive, verbatim: same data structure,
73346
74109
  * same storage location, no second retained store. It is written under
73347
- * `ownerKind: 'event'` with the window's own owner id, so it lands beside the
73348
- * cameras' event media (`<deviceId>/events/event/<ownerId>/…`) and is swept
73349
- * by the same machinery. It is deliberately NOT owned by a track: a track's
73350
- * media list is a LIVE viewer surface, and a cross-camera mosaic appearing in
73351
- * one track's tiles would be a picture that lies about what it is of.
74110
+ * `ownerKind: 'object'` with the window's own SYNTHETIC owner id (never a
74111
+ * real object-event id), so it lands beside the cameras' event media
74112
+ * (`<deviceId>/events/object/<ownerId>/…`) and is swept by the same
74113
+ * ownership-based orphan audit: `resolveMediaOwner` maps any of
74114
+ * `motion`/`object`/`audio` to the same generic `{kind:'event'}` OwnerRef,
74115
+ * `EventStore.existingEventIds` never finds the synthetic id in any of the
74116
+ * three tables, and the row is reclaimed as an orphan once past the grace
74117
+ * window — same effective lifecycle as before D476, when `event` covered
74118
+ * all three. `object` is a pragmatic choice among equals here, not a
74119
+ * structural fact about the mosaic (it is not itself an object-event row);
74120
+ * flagged in the D476 ADR as a spot a future dedicated owner kind may suit
74121
+ * better. It is deliberately NOT owned by a track: a track's media list is a
74122
+ * LIVE viewer surface, and a cross-camera mosaic appearing in one track's
74123
+ * tiles would be a picture that lies about what it is of.
73352
74124
  */
73353
74125
  buildSummaryPorts(stores) {
73354
74126
  return {
@@ -73389,7 +74161,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73389
74161
  },
73390
74162
  storeMosaic: async ({ deviceId, ownerId, timestamp, jpeg }) => stores.mediaStore.put({
73391
74163
  deviceId,
73392
- ownerKind: "event",
74164
+ ownerKind: "object",
73393
74165
  ownerId,
73394
74166
  kind: "fullFrame",
73395
74167
  timestamp,
@@ -73629,7 +74401,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73629
74401
  listEventIdsForTracks: async (trackIds) => {
73630
74402
  if (!eventStore || trackIds.length === 0) return [];
73631
74403
  return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
73632
- eventId: e.id,
74404
+ eventId: String(e.id),
73633
74405
  trackId: e.trackId
73634
74406
  }]);
73635
74407
  },
@@ -74425,9 +75197,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74425
75197
  })
74426
75198
  ]);
74427
75199
  return {
74428
- motion,
74429
- object,
74430
- audio
75200
+ motion: toEventLike(motion),
75201
+ object: toEventLike(object),
75202
+ audio: toEventLike(audio)
74431
75203
  };
74432
75204
  },
74433
75205
  fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
@@ -74582,6 +75354,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74582
75354
  this.alarmPanelConvergence = null;
74583
75355
  for (const unsub of this.subscriptionUnsubs) unsub();
74584
75356
  this.subscriptionUnsubs = [];
75357
+ await this.motionEpisodes?.closeAll("addon-shutdown");
74585
75358
  await this.embeddingDispatcher?.stop();
74586
75359
  this.embeddingDispatcher = null;
74587
75360
  await this.notificationCenter?.stop();
@@ -75186,11 +75959,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75186
75959
  byState
75187
75960
  } });
75188
75961
  }
75189
- await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
75962
+ const objectEvents = await this.persistObjectEvents(deviceId, result.objectEvents);
75963
+ const appearanceEvents = await this.persistObjectEvents(deviceId, result.appearanceEvents);
75190
75964
  if (this.notificationCenter !== null) {
75191
75965
  const overlaps = processor.getLastZoneOverlaps();
75192
75966
  const rejections = processor.getLastZoneRejections();
75193
- for (const e of result.objectEvents) {
75967
+ for (const e of objectEvents) {
75194
75968
  if (e.zones && e.zones.length > 0) {
75195
75969
  const m = e.trackId ? overlaps.get(e.trackId) : void 0;
75196
75970
  this.ctx.logger.info("zone membership stamped on event", {
@@ -75244,7 +76018,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75244
76018
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
75245
76019
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
75246
76020
  if (this.eventMediaDispatcher && frameHandle) {
75247
- const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
76021
+ const mediaEvents = [...objectEvents, ...appearanceEvents];
75248
76022
  const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
75249
76023
  const uncroppable = mediaEvents.filter((e) => e.bbox === void 0 || e.bbox.w <= 0 || e.bbox.h <= 0);
75250
76024
  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)", {
@@ -75258,7 +76032,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75258
76032
  const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
75259
76033
  const childCrops = childCropsByEvent.get(e.id);
75260
76034
  return {
75261
- eventId: e.id,
76035
+ eventId: String(e.id),
75262
76036
  timestamp: e.timestamp,
75263
76037
  bbox: e.bbox,
75264
76038
  className: e.className,
@@ -75412,7 +76186,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75412
76186
  ...frameHandle !== void 0 ? { frameHandle } : {}
75413
76187
  });
75414
76188
  if (objectEmbeddingBests.length > 0) this.persistObjectEmbeddingBests(deviceId, result.timestamp, objectEmbeddingBests, frameHandle, result.frameWidth, result.frameHeight, mediaSettings.cropPadding);
75415
- for (const e of result.objectEvents) this.ctx.eventBus.emit({
76189
+ for (const e of objectEvents) this.ctx.eventBus.emit({
75416
76190
  id: `pa-${e.id}`,
75417
76191
  timestamp: new Date(e.timestamp),
75418
76192
  source: {
@@ -75510,6 +76284,45 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75510
76284
  * eight settings kinds funnel through on a cache miss, so calling the raw
75511
76285
  * reads directly here means eight kinds expiring in the same window each
75512
76286
  * pay for the same two reads — see `store/settings-raw-mirror.ts`. */
76287
+ /**
76288
+ * Insert the frame's object-event DRAFTS and return them with the id the
76289
+ * database actually assigned (D474).
76290
+ *
76291
+ * The frame path cannot name an event: the id is a SQLite rowid, minted by
76292
+ * the write. So this is the ONE place a draft becomes an `ObjectEvent`, and
76293
+ * everything downstream — the media targets, the notification-centre hook,
76294
+ * the bus emit — consumes what comes back rather than what went in.
76295
+ *
76296
+ * Order is preserved. A draft whose row did not land is DROPPED and SAID:
76297
+ * it owns no media, can be named on no bus and can match no rule, and an
76298
+ * invented id would put `event:NaN` in a media key. A branch that accepts
76299
+ * work and produces nothing must log it.
76300
+ */
76301
+ async persistObjectEvents(deviceId, drafts) {
76302
+ if (drafts.length === 0) return [];
76303
+ const store = this.eventStore;
76304
+ if (store === null) return [];
76305
+ const ids = await Promise.all(drafts.map((d) => store.insertObject(d)));
76306
+ const persisted = [];
76307
+ for (const [i, draft] of drafts.entries()) {
76308
+ const id = ids[i];
76309
+ if (id === void 0 || id === null) continue;
76310
+ persisted.push({
76311
+ ...draft,
76312
+ id
76313
+ });
76314
+ }
76315
+ const lost = drafts.length - persisted.length;
76316
+ if (lost > 0) this.ctx.logger.warn("object events did not persist — no id, so no media, no bus, no rule", {
76317
+ tags: { deviceId },
76318
+ meta: {
76319
+ lost,
76320
+ of: drafts.length,
76321
+ trackIds: [...new Set(drafts.map((d) => d.trackId ?? "none"))]
76322
+ }
76323
+ });
76324
+ return persisted;
76325
+ }
75513
76326
  async readDeviceSettings(deviceId, resolve) {
75514
76327
  const globalRaw = this.ctxIfReady !== null ? await this.rawGlobalStoreMirror.resolve() : {};
75515
76328
  const deviceRaw = await this.rawDeviceBlobMirror.resolve(deviceId);
@@ -76288,7 +77101,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76288
77101
  recognized: this.recognizedTrackIds.has(t.trackId)
76289
77102
  }))
76290
77103
  });
76291
- if (!this.groupShots.offer(open.id, score, { timestamp: result.timestamp })) return;
77104
+ const frameRef = { timestamp: result.timestamp };
77105
+ const worthPixels = this.groupShots.offer(open.id, score, frameRef);
77106
+ this.logGroupShotWindow(deviceId);
77107
+ if (!worthPixels) return;
76292
77108
  const getNative = this.getNativeKeyFrameRgb;
76293
77109
  const mediaStore = this.mediaStore;
76294
77110
  if (!getNative || !mediaStore) return;
@@ -76310,17 +77126,48 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76310
77126
  maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
76311
77127
  })
76312
77128
  }).then((outcome) => {
76313
- if (outcome?.kind === "landed") this.ctx.logger.debug("summary group shot landed", {
76314
- tags: { deviceId },
76315
- meta: {
76316
- summaryId,
76317
- visible: score.visible,
76318
- quality: Number(score.quality.toFixed(3))
76319
- }
76320
- });
77129
+ if (outcome?.kind === "landed") {
77130
+ this.groupShots.noteLanded(summaryId, score, frameRef);
77131
+ this.ctx.logger.debug("summary group shot landed", {
77132
+ tags: { deviceId },
77133
+ meta: {
77134
+ summaryId,
77135
+ visible: score.visible,
77136
+ quality: Number(score.quality.toFixed(3))
77137
+ }
77138
+ });
77139
+ }
76321
77140
  }).catch(() => {});
76322
77141
  }
76323
77142
  /**
77143
+ * The group-shot gate's window, throttled to one line per
77144
+ * {@link GROUP_SHOT_METRIC_INTERVAL_MS}. Process-global counts, tagged with
77145
+ * the device that happened to close the window — the counters are the
77146
+ * gate's, not the camera's, and the tag is there because every line about a
77147
+ * device carries one.
77148
+ *
77149
+ * `materialised` is the number of 4096-px native full-frame fetches this
77150
+ * path actually spent; `byGap` + `byMargin` + `byWorse` is what it did not.
77151
+ */
77152
+ logGroupShotWindow(deviceId) {
77153
+ const now = Date.now();
77154
+ if (now - this.lastGroupShotMetricAt < GROUP_SHOT_METRIC_INTERVAL_MS) return;
77155
+ this.lastGroupShotMetricAt = now;
77156
+ const counts = this.groupShots.drainCounts();
77157
+ if (counts.offered === 0) return;
77158
+ this.ctx.logger.info("group shot window", {
77159
+ tags: { deviceId },
77160
+ meta: {
77161
+ offered: counts.offered,
77162
+ materialised: counts.materialised,
77163
+ suppressedByGap: counts.byGap,
77164
+ suppressedByMargin: counts.byMargin,
77165
+ suppressedByWorse: counts.byWorse,
77166
+ openSummaries: this.groupShots.size
77167
+ }
77168
+ });
77169
+ }
77170
+ /**
76324
77171
  * Order the parcels a best-frame decision wants (D422). Skipped entirely at
76325
77172
  * a node the registry already called offline (D58) — logged on the
76326
77173
  * transition by `noteCapturesGated`, counted in the key-frame window.
@@ -76866,9 +77713,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76866
77713
  * The two mirrors answer different halves and neither subsumes the other:
76867
77714
  * - {@link lastTrackActivityMs} — a tracked object was alive. Present only
76868
77715
  * while the camera is ATTACHED and producing inference frames.
76869
- * - {@link lastMotionInsertByDevice} — motion was reported (analyzer or
76870
- * onboard). Covers a camera whose detector found nothing, and a camera
76871
- * on `detectionMode: 'on-motion'` in the window before it attaches.
77716
+ * - {@link lastMotionTickByDevice} — motion was reported (analyzer or
77717
+ * onboard, ANY source). Covers a camera whose detector found nothing,
77718
+ * and a camera on `detectionMode: 'on-motion'` in the window before it
77719
+ * attaches. This is a plain per-device mirror, deliberately NOT the
77720
+ * per-source episode state in {@link motionEpisodes} — this question is
77721
+ * "is something visual happening on the camera", not "which episode".
76872
77722
  *
76873
77723
  * Staleness is safe in the direction that matters: a mirror that is late
76874
77724
  * suppresses a marker, it never invents one.
@@ -76876,7 +77726,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76876
77726
  isMotionActive(deviceId, atMs, quietMs) {
76877
77727
  const trackAt = this.lastTrackActivityMs.get(deviceId);
76878
77728
  if (trackAt !== void 0 && atMs - trackAt < quietMs) return true;
76879
- const motion = this.lastMotionInsertByDevice.get(deviceId);
77729
+ const motion = this.lastMotionTickByDevice.get(deviceId);
76880
77730
  return motion !== void 0 && motion.detected && atMs - motion.atMs < quietMs;
76881
77731
  }
76882
77732
  /** Per-device audio-marker config, folded from the cached audio settings. */
@@ -76941,8 +77791,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76941
77791
  * therefore persisted nothing at all.
76942
77792
  */
76943
77793
  async persistClassifiedAudio(input) {
76944
- const ev = {
76945
- id: (0, node_crypto.randomUUID)(),
77794
+ const draft = {
76946
77795
  deviceId: input.deviceId,
76947
77796
  timestamp: input.timestamp,
76948
77797
  kind: "audio",
@@ -76958,7 +77807,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76958
77807
  className: input.className,
76959
77808
  atMs: input.timestamp
76960
77809
  });
76961
- await this.eventStore.insertAudio(ev);
77810
+ const insertedId = await this.eventStore.insertAudio(draft);
77811
+ if (insertedId === null) return;
77812
+ const ev = {
77813
+ ...draft,
77814
+ id: insertedId
77815
+ };
76962
77816
  this.notificationCenter?.onAudioEventPersisted(ev);
76963
77817
  this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
76964
77818
  this.ctx.eventBus.emit({
@@ -77169,8 +78023,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77169
78023
  });
77170
78024
  this.levelStateByDevice.set(deviceId, res.nextState);
77171
78025
  if (!res.emit) return;
77172
- const ev = {
77173
- id: (0, node_crypto.randomUUID)(),
78026
+ const draft = {
77174
78027
  deviceId,
77175
78028
  timestamp,
77176
78029
  kind: "audio",
@@ -77181,7 +78034,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77181
78034
  className: void 0,
77182
78035
  atMs: timestamp
77183
78036
  });
77184
- await this.eventStore.insertAudio(ev);
78037
+ const insertedId = await this.eventStore.insertAudio(draft);
78038
+ if (insertedId === null) return;
78039
+ const ev = {
78040
+ ...draft,
78041
+ id: insertedId
78042
+ };
77185
78043
  this.ctx.eventBus.emit({
77186
78044
  id: `pa-${ev.id}`,
77187
78045
  timestamp: new Date(ev.timestamp),
@@ -77200,10 +78058,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77200
78058
  });
77201
78059
  }
77202
78060
  /**
77203
- * Persist motion-analysis bus events. Mirrors `handleAudioResult`'s
77204
- * coalescing strategy: emit one row on off→on transition, then one
77205
- * per `MOTION_EVENT_HEARTBEAT_MS` while motion stays detected. No
77206
- * row on off→off (silence) or while heartbeat hasn't elapsed.
78061
+ * Persist motion-analysis bus events into the EPISODE tracker (D475): a
78062
+ * rising edge with no open episode INSERTs a row; a further edge inside an
78063
+ * open episode extends it in place; the episode closes on its own timer,
78064
+ * `MOTION_CLOSE_AFTER_MS` after the last rising edge. See
78065
+ * `motion-episode.ts` / `motion-episode-tracker.ts` for the state machine.
77207
78066
  *
77208
78067
  * Gated on the same `pipeline-analytics` wrapper binding as the
77209
78068
  * inference + audio handlers so toggling the wrapper off for a
@@ -77213,22 +78072,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77213
78072
  if (this.shuttingDown) return;
77214
78073
  if (!await this.bindingCache.isActive(deviceId)) return;
77215
78074
  const detected = payload.detected === true;
77216
- const last = this.lastMotionInsertByDevice.get(deviceId);
77217
- const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
77218
- const transitionedOn = detected && (last === void 0 || last.detected === false);
77219
- if (!detected) {
77220
- if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
77221
- detected: false,
77222
- atMs: timestamp
77223
- });
77224
- return;
77225
- }
77226
- if (!transitionedOn && !heartbeatDue) return;
77227
- const ev = {
77228
- id: (0, node_crypto.randomUUID)(),
77229
- deviceId,
77230
- timestamp,
77231
- kind: "motion",
78075
+ this.lastMotionTickByDevice.set(deviceId, {
78076
+ detected,
78077
+ atMs: timestamp
78078
+ });
78079
+ if (detected) this.eventStore?.noteMotionSample(deviceId, payload.regionCount, timestamp);
78080
+ await this.motionEpisodes?.observe(deviceId, "analyzer", detected, timestamp, {
77232
78081
  regionCount: payload.regionCount,
77233
78082
  regions: payload.regions.map((r) => ({
77234
78083
  bbox: {
@@ -77242,69 +78091,29 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77242
78091
  })),
77243
78092
  frameWidth: payload.frameWidth,
77244
78093
  frameHeight: payload.frameHeight
77245
- };
77246
- this.lastMotionInsertByDevice.set(deviceId, {
77247
- detected,
77248
- atMs: timestamp
77249
- });
77250
- if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
77251
- this.sceneEngine?.noteMotion(deviceId, timestamp);
77252
- await this.eventStore.insertMotion(ev);
77253
- this.ctx.eventBus.emit({
77254
- id: `pa-${ev.id}`,
77255
- timestamp: new Date(ev.timestamp),
77256
- source: {
77257
- type: "addon",
77258
- id: "pipeline-analytics",
77259
- addonId: "pipeline-analytics"
77260
- },
77261
- category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent,
77262
- data: {
77263
- deviceId,
77264
- kind: "motion",
77265
- eventId: ev.id,
77266
- timestamp: ev.timestamp
77267
- }
77268
78094
  });
77269
- if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
77270
78095
  }
77271
78096
  /**
77272
- * Persist firmware-driven (onboard) motion events. Mirrors
77273
- * {@link handleMotionAnalysis}'s coalescing — one row on off→on
77274
- * transition, then one per `MOTION_EVENT_HEARTBEAT_MS` while motion
77275
- * stays detected but reads from the {@link MotionOnMotionChangedPayload}
77276
- * shape which lacks frame dimensions and pixel-level region details
77277
- * (firmware reports a binary detected flag plus, on rare devices, a
77278
- * coarse bbox via the optional `regions` field).
77279
- *
77280
- * The same `lastMotionInsertByDevice` map is shared with the analyzer
77281
- * path so a camera that briefly switches `motionSources` between
77282
- * `onboard` and `analyzer` gets consistent throttling — both paths
77283
- * are mutually exclusive at the event-emit layer (the runner only
77284
- * fires `MotionAnalysis` when its analyzer runs; onboard providers
77285
- * never fire `MotionAnalysis`), so there's no double-count risk.
78097
+ * Persist firmware-driven (onboard / device-activity) motion events into
78098
+ * the SAME episode tracker as {@link handleMotionAnalysis}, keyed per
78099
+ * `(deviceId, source)` so the two paths can never share — or fight over —
78100
+ * one episode on a camera whose `motionSources` lists both (D475). Reads
78101
+ * from the {@link MotionOnMotionChangedPayload} shape which lacks frame
78102
+ * dimensions and pixel-level region details (firmware reports a binary
78103
+ * detected flag plus, on rare devices, a coarse bbox via the optional
78104
+ * `regions` field).
77286
78105
  */
77287
78106
  async handleOnboardMotion(deviceId, payload, timestamp) {
77288
78107
  if (this.shuttingDown) return;
77289
78108
  if (!await this.bindingCache.isActive(deviceId)) return;
77290
78109
  const detected = payload.detected === true;
77291
- const last = this.lastMotionInsertByDevice.get(deviceId);
77292
- const heartbeatDue = !last || timestamp - last.atMs >= MOTION_EVENT_HEARTBEAT_MS;
77293
- const transitionedOn = detected && (last === void 0 || last.detected === false);
77294
- if (!detected) {
77295
- if (last?.detected === true) this.lastMotionInsertByDevice.set(deviceId, {
77296
- detected: false,
77297
- atMs: timestamp
77298
- });
77299
- return;
77300
- }
77301
- if (!transitionedOn && !heartbeatDue) return;
78110
+ this.lastMotionTickByDevice.set(deviceId, {
78111
+ detected,
78112
+ atMs: timestamp
78113
+ });
77302
78114
  const regions = payload.regions ?? [];
77303
- const ev = {
77304
- id: (0, node_crypto.randomUUID)(),
77305
- deviceId,
77306
- timestamp,
77307
- kind: "motion",
78115
+ if (detected) this.eventStore?.noteMotionSample(deviceId, regions.length, timestamp);
78116
+ await this.motionEpisodes?.observe(deviceId, payload.source, detected, timestamp, {
77308
78117
  regionCount: regions.length,
77309
78118
  regions: regions.map((r) => ({
77310
78119
  bbox: {
@@ -77318,17 +78127,56 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77318
78127
  })),
77319
78128
  frameWidth: 0,
77320
78129
  frameHeight: 0
78130
+ });
78131
+ }
78132
+ /**
78133
+ * `MotionEpisodeTracker.insertRow` — builds and inserts the row that opens
78134
+ * a motion episode. `regionData` carries the per-kind fields the two motion
78135
+ * handlers differ on (regions/frameWidth/frameHeight); everything else
78136
+ * (id, durationMs, edges) is the episode's own concern, not the caller's.
78137
+ */
78138
+ async insertMotionEpisodeRow(deviceId, timestamp, regionData) {
78139
+ const draft = {
78140
+ deviceId,
78141
+ timestamp,
78142
+ kind: "motion",
78143
+ durationMs: null,
78144
+ ...regionData
77321
78145
  };
77322
- this.lastMotionInsertByDevice.set(deviceId, {
77323
- detected,
77324
- atMs: timestamp
78146
+ return this.eventStore.insertMotion(draft);
78147
+ }
78148
+ /**
78149
+ * `MotionEpisodeTracker.closeRow` — the UPDATE that closes an episode.
78150
+ */
78151
+ async closeMotionEpisodeRow(input) {
78152
+ return this.eventStore.updateMotion(input.eventId, input.deviceId, {
78153
+ durationMs: input.durationMs,
78154
+ edges: input.edges
78155
+ });
78156
+ }
78157
+ /**
78158
+ * `MotionEpisodeTracker.onNewEdge` — the side effects the pre-D475 code
78159
+ * fired on every `transitionedOn`: the birth-latency onset mark (D409, only
78160
+ * the FIRST edge of a burst moves it — a further edge inside the same
78161
+ * episode is a fresh onset in its own right, so it moves too) and the
78162
+ * motion-marker still (self-throttled by `motion-marker-policy.ts`;
78163
+ * `String` because a media key is a URL component while the id itself
78164
+ * stays a number, D474).
78165
+ */
78166
+ onMotionEpisodeEdge(input) {
78167
+ this.motionOnsets.noteRisingEdge(input.deviceId, input.timestamp);
78168
+ this.captureMotionMarkerStill(input.deviceId, String(input.eventId), input.timestamp).catch((err) => {
78169
+ this.ctx.logger.debug("motion marker still capture failed", {
78170
+ tags: { deviceId: input.deviceId },
78171
+ meta: {
78172
+ eventId: input.eventId,
78173
+ error: require_dist.errMsg(err)
78174
+ }
78175
+ });
77325
78176
  });
77326
- if (transitionedOn) this.motionOnsets.noteRisingEdge(deviceId, timestamp);
77327
- this.sceneEngine?.noteMotion(deviceId, timestamp);
77328
- await this.eventStore.insertMotion(ev);
77329
78177
  this.ctx.eventBus.emit({
77330
- id: `pa-${ev.id}`,
77331
- timestamp: new Date(ev.timestamp),
78178
+ id: `pa-${input.eventId}`,
78179
+ timestamp: new Date(input.timestamp),
77332
78180
  source: {
77333
78181
  type: "addon",
77334
78182
  id: "pipeline-analytics",
@@ -77336,13 +78184,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77336
78184
  },
77337
78185
  category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent,
77338
78186
  data: {
77339
- deviceId,
78187
+ deviceId: input.deviceId,
77340
78188
  kind: "motion",
77341
- eventId: ev.id,
77342
- timestamp: ev.timestamp
78189
+ eventId: input.eventId,
78190
+ timestamp: input.timestamp
77343
78191
  }
77344
78192
  });
77345
- if (transitionedOn) await this.captureMotionMarkerStill(deviceId, ev.id, ev.timestamp);
78193
+ }
78194
+ /**
78195
+ * `MotionEpisodeTracker.onTick` — fires on every `detected:true`
78196
+ * observation that reaches an open or newly-opened episode, transition or
78197
+ * continuation. Arms the scene quiet-edge trigger: a scene is checked
78198
+ * `quietSeconds` AFTER the last motion, never during it, so this wants
78199
+ * "motion is still happening" — the SAME question `onNewEdge` does not
78200
+ * answer, since a long continuous burst is mostly continuation ticks.
78201
+ */
78202
+ onMotionEpisodeTick(input) {
78203
+ this.sceneEngine?.noteMotion(input.deviceId, input.timestamp);
77346
78204
  }
77347
78205
  /**
77348
78206
  * The gate in front of the motion still. Both motion handlers reach it, so
@@ -79910,7 +80768,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
79910
80768
  deps: {
79911
80769
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
79912
80770
  deviceId,
79913
- ownerKind: scope === "motion-still" ? "event" : "track",
80771
+ ownerKind: scope === "motion-still" ? "motion" : "track",
79914
80772
  kind: "snapshot",
79915
80773
  afterMs,
79916
80774
  limit
@@ -80060,7 +80918,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
80060
80918
  for (const { event, score } of bestEventByTrackId.values()) {
80061
80919
  const mediaUrl = resolveSearchThumbnailUrl({
80062
80920
  baseUrl: this.eventMediaBaseUrl,
80063
- eventId: event.id,
80921
+ eventId: String(event.id),
80064
80922
  ...event.trackId !== void 0 && embeddingMediaKeyByTrackId.has(event.trackId) ? { embeddingMediaKey: embeddingMediaKeyByTrackId.get(event.trackId) } : {}
80065
80923
  });
80066
80924
  const keyFrameMediaKey = event.trackId !== void 0 ? keyFrameKeyByTrackId.get(event.trackId) : void 0;
@@ -80659,12 +81517,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
80659
81517
  deviceId: file.deviceId
80660
81518
  };
80661
81519
  };
80662
- const eventFiles = await listInfo("event", id);
81520
+ let eventFiles = [];
81521
+ for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
81522
+ eventFiles = await listInfo(ownerType, id);
81523
+ if (eventFiles.length > 0) break;
81524
+ }
80663
81525
  if (preferKind !== void 0 && preferKind.length > 0) {
80664
81526
  const layers = await collectCropForcedMedia({
80665
81527
  listByOwner: listInfo,
80666
81528
  getTrackIdForEvent
80667
- }, id);
81529
+ }, id, eventFiles);
80668
81530
  const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
80669
81531
  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);
80670
81532
  }