@camstack/addon-pipeline 1.2.141 → 1.2.143

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.
Files changed (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +5 -5
  4. package/dist/detection-pipeline/index.mjs +4 -4
  5. package/dist/{dist-3bEUjzHN.js → dist-CmqMW5r3.js} +4 -29
  6. package/dist/{dist-B25xMJDp.mjs → dist-Dj-w3Fhb.mjs} +5 -24
  7. package/dist/{event-loop-stall-monitor-BQeD-JuB.js → event-loop-stall-monitor-DBI7BO_G.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-CrWg3jrh.mjs → event-loop-stall-monitor-DPtT2E7Q.mjs} +1 -1
  9. package/dist/{lazy-sharp-buaVXzJ6.js → lazy-sharp-DQcMUnU5.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-Cn9gEOs4.js → node-COf9Ht20.js} +1 -1
  13. package/dist/{node-CFEV-qgx.mjs → node-Dj3W4uyh.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-D7R-j96w.js → process-memory-ChIwi_13.js} +1 -1
  17. package/dist/{process-memory-B4SGuNSB.mjs → process-memory-WlSCnMu4.mjs} +1 -1
  18. package/dist/recorder/index.js +617 -132
  19. package/dist/recorder/index.mjs +617 -132
  20. package/dist/{segment-demux-js-CwNYlHRC.mjs → segment-demux-js-BtScdFL2.mjs} +1 -1
  21. package/dist/{segment-demux-js-J1AdcHSK.js → segment-demux-js-D8WTC7AT.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-O4QuB94V.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CFiTZLsx.mjs} +3 -3
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-DXcoBCs4.mjs → hostInit-D6Ztkap5.mjs} +3 -3
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/dist/{worker-protocol-DYOG2ris.mjs → worker-protocol-D5y0GSxE.mjs} +1 -1
  32. package/dist/{worker-protocol-CxvZ6FP4.js → worker-protocol-X2kpjAAQ.js} +1 -1
  33. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- const require_dist = require("../dist-3bEUjzHN.js");
2
- const require_node = require("../node-Cn9gEOs4.js");
1
+ const require_dist = require("../dist-CmqMW5r3.js");
2
+ const require_node = require("../node-COf9Ht20.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
5
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
@@ -11,26 +11,41 @@ let node_child_process = require("node:child_process");
11
11
  let node_fs_promises = require("node:fs/promises");
12
12
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
13
13
  //#region src/recorder/event-capture.ts
14
- function markerFromMotion(data, pad) {
15
- if (!data.detected) return null;
16
- return {
17
- t: data.timestamp,
18
- startMs: data.timestamp - pad.padPreMs,
19
- endMs: data.timestamp + pad.padPostMs,
20
- source: "motion",
21
- ...data.regions !== void 0 ? { metadata: { regions: data.regions } } : {}
22
- };
14
+ /**
15
+ * La sottoscrizione motion del recorder: bus → `onTrigger(deviceId, atMs)`.
16
+ *
17
+ * ## Cosa NON c'è più qui (2026-08-29)
18
+ *
19
+ * Fino al 2026-08-29 questo file costruiva anche un `EventMarker` per evento e
20
+ * lo spingeva in un `EventMap` per-device — annotazioni di playback in RAM, con
21
+ * dentro il payload `regions` grezzo del provider. `EventMap.events()` e
22
+ * `EventMap.prune()` non avevano **nessun chiamante in tutto il repo**: la
23
+ * mappa veniva solo scritta, e cresceva con l'uptime del runner.
24
+ *
25
+ * È stata cancellata, non cablata, e la ragione è nel suo stesso header: la
26
+ * timeline che l'operatore vede viene dalle righe durabili di
27
+ * `pipelineAnalytics` (`getMotionEvents`/`getObjectEvents`/`getAudioEvents`), i
28
+ * marker non erano esposti su nessuna cap (`recording.getPlaybackManifest`
29
+ * restituisce solo URL di playlist) e ADR-0194 aveva già rifiutato
30
+ * esplicitamente di costruirci sopra il reconcile dei trigger, perché una
31
+ * struttura in RAM non sopravvive né alla perdita dell'evento né al respawn del
32
+ * runner. Un lettore non è mai esistito e non ne era previsto uno: tenerla
33
+ * sarebbe stato "un residuo che descrive il design giusto", cioè una verifica
34
+ * che non verifica niente.
35
+ *
36
+ * Quello che resta è l'unica cosa che qualcuno leggeva davvero: il fronte di
37
+ * salita della motion che apre una finestra della banda `events` (B3).
38
+ */
39
+ /** Solo il fronte `detected: true` è un trigger. Il fronte di discesa non apre
40
+ * niente — la finestra la chiude il post-buffer, non l'evento. */
41
+ function isMotionTrigger(data) {
42
+ return data.detected;
23
43
  }
24
44
  function subscribeEventCapture(deps) {
25
- const { eventBus, map, padPreMs, padPostMs, onMotion } = deps;
45
+ const { eventBus, onMotion } = deps;
26
46
  return eventBus.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
27
- const mk = markerFromMotion(event.data, {
28
- padPreMs,
29
- padPostMs
30
- });
31
- if (!mk) return;
32
- map.record(event.data.deviceId, mk);
33
- onMotion?.(event.data.deviceId, event.data.timestamp);
47
+ if (!isMotionTrigger(event.data)) return;
48
+ onMotion(event.data.deviceId, event.data.timestamp);
34
49
  });
35
50
  }
36
51
  //#endregion
@@ -47,44 +62,180 @@ function subscribeEventCapture(deps) {
47
62
  * Puro: nessun bus, nessun orologio, nessuna config. La scelta di QUALI classi
48
63
  * ascoltare è della banda (`bandTriggerSources`); qui si decide solo se
49
64
  * l'evento è una detection utilizzabile e di che macro classe è.
50
- */
65
+ *
66
+ * ## Perché la forma è DISCRIMINATA su `kind` (2026-08-29)
67
+ *
68
+ * Fino al 2026-08-29 questo file dichiarava UNA sola forma piatta — quella
69
+ * dell'evento `object` — e la pretendeva da OGNI payload del topic. Ma
70
+ * `EventCategory.PipelineAnalyticsDetectionEvent` è un topic condiviso da tre
71
+ * emettitori in `addon-post-analysis/src/pipeline-analytics/index.ts`: object
72
+ * (`className`, `source`, `confidence`, `zones`), motion e audio — e motion e
73
+ * audio non portano `className`, perché non ne hanno una. Il risultato misurato:
74
+ * `malformed` su **394 righe campionate su 394**, a 3.3–3.9 eventi/s, per
75
+ * settimane. `malformed` significa "l'emettitore è andato in deriva" ed era
76
+ * invece il traffico normale del topic; l'unico secchio che poteva segnalare
77
+ * una vera rottura di protocollo era saturo al 100% di eventi sani, quindi
78
+ * inservibile — ed è stato letto come "il trigger object non ha mai funzionato".
79
+ *
80
+ * Quindi: si valida prima la BUSTA comune (`deviceId`, `kind`, `timestamp`),
81
+ * poi si discrimina su `kind`, e solo il ramo `object` pretende i campi
82
+ * dell'object. Un `object` senza `className` resta un difetto e ha il suo nome
83
+ * (`malformed-object`), distinto da una busta illeggibile.
84
+ */
85
+ /** Il `kind` dell'unico ramo del topic che può aprire una finestra. */
86
+ var OBJECT_EVENT_KIND = "object";
87
+ /** La sorgente implicita delle righe legacy senza `source` (`DetectionSourceSchema`). */
88
+ var DEFAULT_DETECTION_SOURCE = "pipeline";
51
89
  /** Type guard, non cast: `event.data` è `unknown` finché non passa di qui. */
52
- function isDetectionEventData(value) {
90
+ function isDetectionEventEnvelope(value) {
53
91
  if (typeof value !== "object" || value === null) return false;
54
92
  if (!("deviceId" in value) || typeof value.deviceId !== "number") return false;
93
+ if (!Number.isFinite(value.deviceId)) return false;
55
94
  if (!("kind" in value) || typeof value.kind !== "string") return false;
56
95
  if (!("timestamp" in value) || typeof value.timestamp !== "number") return false;
57
96
  if (!Number.isFinite(value.timestamp) || value.timestamp <= 0) return false;
97
+ return true;
98
+ }
99
+ /** I campi che SOLO il ramo object porta. Chiamata dopo la busta. */
100
+ function isObjectDetectionEventData(value) {
58
101
  if (!("className" in value) || typeof value.className !== "string") return false;
59
102
  if ("source" in value && value.source !== void 0 && typeof value.source !== "string") return false;
60
103
  return true;
61
104
  }
62
105
  function qualifyObjectTrigger(data) {
63
- if (!isDetectionEventData(data)) return {
106
+ if (!isDetectionEventEnvelope(data)) return {
64
107
  qualified: false,
65
108
  reason: "malformed"
66
109
  };
67
- if (data.kind !== "object") return {
110
+ const deviceId = data.deviceId;
111
+ if (data.kind !== OBJECT_EVENT_KIND) return {
112
+ qualified: false,
113
+ reason: "not-object",
114
+ deviceId
115
+ };
116
+ if (!isObjectDetectionEventData(data)) return {
68
117
  qualified: false,
69
- reason: "not-object"
118
+ reason: "malformed-object",
119
+ deviceId
70
120
  };
71
- if ((data.source ?? "pipeline") !== "pipeline") return {
121
+ if ((data.source ?? DEFAULT_DETECTION_SOURCE) !== DEFAULT_DETECTION_SOURCE) return {
72
122
  qualified: false,
73
- reason: "not-pipeline"
123
+ reason: "not-pipeline",
124
+ deviceId
74
125
  };
75
126
  const className = data.className.trim().toLowerCase();
76
127
  if (!require_dist.isFirstLevelMacroClass(className)) return {
77
128
  qualified: false,
78
- reason: "not-a-macro"
129
+ reason: "not-a-macro",
130
+ deviceId
79
131
  };
80
132
  return {
81
133
  qualified: true,
82
- deviceId: data.deviceId,
134
+ deviceId,
83
135
  macroClass: className,
84
136
  atMs: data.timestamp
85
137
  };
86
138
  }
87
139
  //#endregion
140
+ //#region src/recorder/object-trigger-health.ts
141
+ /** Ampiezza della finestra di aggregazione. Un minuto: abbastanza corto perché
142
+ * la riga arrivi entro il primo minuto di un deploy rotto, abbastanza lungo
143
+ * perché a 4 eventi/s il campione sia significativo. */
144
+ var OBJECT_TRIGGER_HEALTH_WINDOW_MS = 6e4;
145
+ function emptyTally() {
146
+ return {
147
+ objectEvents: 0,
148
+ qualified: 0,
149
+ otherKinds: 0,
150
+ byReason: /* @__PURE__ */ new Map()
151
+ };
152
+ }
153
+ function dominantReason(byReason) {
154
+ let reason = null;
155
+ let best = 0;
156
+ const counts = {};
157
+ for (const [r, n] of byReason) {
158
+ counts[r] = n;
159
+ if (n > best) {
160
+ best = n;
161
+ reason = r;
162
+ }
163
+ }
164
+ return {
165
+ reason,
166
+ counts
167
+ };
168
+ }
169
+ var ObjectTriggerHealth = class {
170
+ windowMs;
171
+ minSample;
172
+ buckets = /* @__PURE__ */ new Map();
173
+ windowStartMs = null;
174
+ constructor(windowMs = OBJECT_TRIGGER_HEALTH_WINDOW_MS, minSample = 5) {
175
+ this.windowMs = windowMs;
176
+ this.minSample = minSample;
177
+ }
178
+ /**
179
+ * Registra un verdetto e, se la finestra si è chiusa con questo evento,
180
+ * restituisce i report da stampare (e riapre la finestra). Nessun timer:
181
+ * il feed viaggia a ~4/s, quindi la chiusura arriva sempre in tempo, e una
182
+ * finestra che non si chiude è una finestra in cui non è successo nulla.
183
+ */
184
+ record(verdict, nowMs) {
185
+ const reports = this.windowStartMs !== null && nowMs - this.windowStartMs >= this.windowMs ? this.drain(nowMs) : [];
186
+ this.windowStartMs ??= nowMs;
187
+ this.count(verdict);
188
+ return reports;
189
+ }
190
+ count(verdict) {
191
+ if (verdict.qualified) {
192
+ const t = this.bucket(verdict.deviceId);
193
+ t.objectEvents += 1;
194
+ t.qualified += 1;
195
+ return;
196
+ }
197
+ if (verdict.reason === "not-object") {
198
+ this.bucket(verdict.deviceId).otherKinds += 1;
199
+ return;
200
+ }
201
+ const key = verdict.reason === "malformed" ? null : verdict.deviceId;
202
+ const t = this.bucket(key);
203
+ t.objectEvents += 1;
204
+ t.byReason.set(verdict.reason, (t.byReason.get(verdict.reason) ?? 0) + 1);
205
+ }
206
+ bucket(key) {
207
+ const existing = this.buckets.get(key);
208
+ if (existing !== void 0) return existing;
209
+ const fresh = emptyTally();
210
+ this.buckets.set(key, fresh);
211
+ return fresh;
212
+ }
213
+ /** Chiude la finestra e restituisce un report per secchio non vuoto. */
214
+ drain(nowMs) {
215
+ const windowMs = this.windowStartMs === null ? 0 : nowMs - this.windowStartMs;
216
+ const out = [];
217
+ for (const [deviceId, t] of this.buckets) {
218
+ if (t.objectEvents === 0 && t.otherKinds === 0) continue;
219
+ const rejected = t.objectEvents - t.qualified;
220
+ const { reason, counts } = dominantReason(t.byReason);
221
+ out.push({
222
+ deviceId,
223
+ windowMs,
224
+ objectEvents: t.objectEvents,
225
+ qualified: t.qualified,
226
+ rejectedPct: t.objectEvents === 0 ? 0 : Math.round(rejected / t.objectEvents * 100),
227
+ otherKinds: t.otherKinds,
228
+ topReason: reason,
229
+ byReason: counts,
230
+ totalRejection: t.objectEvents >= this.minSample && t.qualified === 0
231
+ });
232
+ }
233
+ this.buckets.clear();
234
+ this.windowStartMs = nowMs;
235
+ return out;
236
+ }
237
+ };
238
+ //#endregion
88
239
  //#region src/recorder/sensor-trigger.ts
89
240
  /**
90
241
  * Da `DeviceStateChanged` a "quali camere devono registrare".
@@ -213,31 +364,6 @@ var SensorTriggerRouter = class {
213
364
  }
214
365
  };
215
366
  //#endregion
216
- //#region src/recorder/event-map.ts
217
- var EventMap = class {
218
- byDevice = /* @__PURE__ */ new Map();
219
- record(deviceId, marker) {
220
- const list = this.byDevice.get(deviceId) ?? [];
221
- list.push({ ...marker });
222
- this.byDevice.set(deviceId, list);
223
- }
224
- events(deviceId, fromMs, toMs) {
225
- const list = this.byDevice.get(deviceId);
226
- if (!list) return [];
227
- return list.filter((e) => e.t >= fromMs && e.t <= toMs).toSorted((a, b) => a.t - b.t);
228
- }
229
- prune(nowMs, maxAgeMs) {
230
- const cutoff = nowMs - maxAgeMs;
231
- let dropped = 0;
232
- for (const [deviceId, list] of this.byDevice) {
233
- const kept = list.filter((e) => e.t >= cutoff);
234
- dropped += list.length - kept.length;
235
- this.byDevice.set(deviceId, kept);
236
- }
237
- return dropped;
238
- }
239
- };
240
- //#endregion
241
367
  //#region src/recorder/init-info.ts
242
368
  /** Walk sibling boxes in [from, to), yielding [type, start, end]. */
243
369
  function* boxes$1(buf, view, from, to) {
@@ -848,6 +974,23 @@ function upperBoundByStart(rows, startMs) {
848
974
  }
849
975
  return lo;
850
976
  }
977
+ /**
978
+ * Index of the FIRST element whose `startMs` is at-or-after `startMs` (lower
979
+ * bound). On a start-sorted view this is exactly where a linear
980
+ * `find(s => s.startMs === x)` and a linear `find(s => s.startMs >= x)` would
981
+ * have stopped — which is what makes the binary-search replacements of those
982
+ * two scans row-for-row identical, duplicate starts included.
983
+ */
984
+ function lowerBoundByStart(rows, startMs) {
985
+ let lo = 0;
986
+ let hi = rows.length;
987
+ while (lo < hi) {
988
+ const mid = lo + hi >> 1;
989
+ if (rows[mid].startMs < startMs) lo = mid + 1;
990
+ else hi = mid;
991
+ }
992
+ return lo;
993
+ }
851
994
  var RecordingIndex = class {
852
995
  byDevice = /* @__PURE__ */ new Map();
853
996
  /** Start-sorted views. Built lazily on first read (one copy+sort), then kept
@@ -866,6 +1009,27 @@ var RecordingIndex = class {
866
1009
  * hydrate. Empty = nothing has been looked at, which is NOT the same as
867
1010
  * "there is nothing" — see {@link hydrationOf}. */
868
1011
  walked = /* @__PURE__ */ new Map();
1012
+ /**
1013
+ * Upper bound on the `durMs` of any row the index holds — the ONLY thing that
1014
+ * turns "walk the whole sorted view" into "walk backwards a bounded number of
1015
+ * rows".
1016
+ *
1017
+ * A start-sorted view answers "which row STARTS at/before X" by binary
1018
+ * search, but every backward question here ("which row still COVERS X",
1019
+ * "which footage ENDS at/before X") depends on `startMs + durMs`, which is
1020
+ * not sorted. This scalar closes that: a row at `startMs` can never end later
1021
+ * than `startMs + maxDurMs`, so once a candidate's ceiling can no longer beat
1022
+ * the best answer found, no earlier row can either — and the walk stops.
1023
+ *
1024
+ * Monotonically non-decreasing on purpose. It is only ever an OVER-estimate
1025
+ * (a longer walk, never a wrong answer); shrinking it on eviction would buy
1026
+ * nothing and could only make it wrong.
1027
+ */
1028
+ maxDurMs = 0;
1029
+ /** Widen {@link maxDurMs} for a row entering the index. */
1030
+ noteDuration(durMs) {
1031
+ if (durMs > this.maxDurMs) this.maxDurMs = durMs;
1032
+ }
869
1033
  mapFor(deviceId) {
870
1034
  let m = this.byDevice.get(deviceId);
871
1035
  if (!m) {
@@ -954,11 +1118,13 @@ var RecordingIndex = class {
954
1118
  const prev = held.get(path);
955
1119
  if (prev !== void 0 && prev.locationId === locationId) {
956
1120
  this.rowsReused += 1;
1121
+ this.noteDuration(prev.durMs);
957
1122
  return prev;
958
1123
  }
959
1124
  const p = parseSegmentPath(path);
960
1125
  if (!p || p.deviceId !== deviceId) return null;
961
1126
  this.rowsAllocated += 1;
1127
+ this.noteDuration(p.durMs);
962
1128
  return {
963
1129
  deviceId: p.deviceId,
964
1130
  profile: p.profile,
@@ -1056,6 +1222,7 @@ var RecordingIndex = class {
1056
1222
  */
1057
1223
  addSegment(s) {
1058
1224
  const m = this.mapFor(s.deviceId);
1225
+ this.noteDuration(s.durMs);
1059
1226
  const replacing = m.has(s.path);
1060
1227
  m.set(s.path, s);
1061
1228
  if (replacing) {
@@ -1093,6 +1260,55 @@ var RecordingIndex = class {
1093
1260
  while (end < segs.length && segs[end].startMs < toMs) end += 1;
1094
1261
  return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
1095
1262
  }
1263
+ /**
1264
+ * The row whose `startMs` is EXACTLY `startMs`, or null.
1265
+ *
1266
+ * The point lookup every byte read is: `readSegmentBytes` / `readGopBytes` /
1267
+ * `readWindowBytes` all address a segment by its exact start, which is what
1268
+ * the playback manifest and the locate answer handed the client. It used to
1269
+ * be `segments(deviceId, profile).find(...)` — a full COPY of the
1270
+ * device-profile archive plus a linear scan, per scrub tick and per playback
1271
+ * fetch (~10 k rows copied to reach one of them on the live hub).
1272
+ *
1273
+ * Returns the FIRST row with that start, which is what `.find` returned on
1274
+ * the same start-sorted view: a restart can leave two rows sharing a start,
1275
+ * and the byte reader must keep resolving to the same one it always did.
1276
+ */
1277
+ segmentStartingAt(deviceId, profile, startMs) {
1278
+ const segs = this.sortedSegments(deviceId, profile);
1279
+ const at = segs[lowerBoundByStart(segs, startMs)];
1280
+ return at !== void 0 && at.startMs === startMs ? at : null;
1281
+ }
1282
+ /**
1283
+ * Start-sorted rows whose SPAN `[startMs, startMs+durMs)` overlaps
1284
+ * `[fromMs, toMs)` — i.e. `startMs < toMs && startMs + durMs > fromMs`.
1285
+ *
1286
+ * The overlap-inclusive twin of {@link segmentsStartingIn}, and the reason it
1287
+ * cannot just slice from the `fromMs` lower bound: the segment an operator is
1288
+ * actually watching at `fromMs` STARTED before it. Dropping it truncates the
1289
+ * first segment of every playback window — silently, because the window still
1290
+ * renders, just short. The boundary convention is the caller's, preserved
1291
+ * from the whole-archive filter this replaces: a segment ending EXACTLY at
1292
+ * `fromMs` is excluded (`end > fromMs`), a segment starting exactly at `toMs`
1293
+ * is excluded (`start < toMs`), and a segment straddling `fromMs` is IN.
1294
+ *
1295
+ * The backward reach is {@link maxDurMs}, not "one element": with overlapping
1296
+ * rows (a restart re-recording an hour) more than one predecessor can still
1297
+ * cover `fromMs`, and the ceiling is the only bound that cannot miss one.
1298
+ */
1299
+ segmentsOverlapping(deviceId, profile, fromMs, toMs) {
1300
+ const segs = this.sortedSegments(deviceId, profile);
1301
+ const end = lowerBoundByStart(segs, toMs);
1302
+ let head = lowerBoundByStart(segs, fromMs);
1303
+ const reach = fromMs - this.maxDurMs;
1304
+ while (head > 0 && segs[head - 1].startMs > reach) head -= 1;
1305
+ const out = [];
1306
+ for (let i = head; i < end; i++) {
1307
+ const s = segs[i];
1308
+ if (s.startMs + s.durMs > fromMs) out.push(s);
1309
+ }
1310
+ return out;
1311
+ }
1096
1312
  /** True when `relPath` is currently indexed for `deviceId`. O(1). */
1097
1313
  hasSegment(deviceId, relPath) {
1098
1314
  return this.byDevice.get(deviceId)?.has(relPath) === true;
@@ -1211,6 +1427,26 @@ var RecordingIndex = class {
1211
1427
  }
1212
1428
  return [...out.values()];
1213
1429
  }
1430
+ /**
1431
+ * WHICH devices hold footage on any of `locationIds` — the census, without
1432
+ * the archive.
1433
+ *
1434
+ * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1435
+ * location: a full multi-device COPY of every row on the disk plus a sort of
1436
+ * it (294 k rows on the live hub), to read one number off each row and throw
1437
+ * the array away. This visits each device only until its first matching row.
1438
+ * Same membership rule as before — a device is listed when at least one of
1439
+ * its rows sits on one of the given locations, so footage stranded on a
1440
+ * location id that no longer resolves stays invisible here exactly as it was.
1441
+ */
1442
+ deviceIdsOnLocations(locationIds) {
1443
+ const out = /* @__PURE__ */ new Set();
1444
+ for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1445
+ out.add(deviceId);
1446
+ break;
1447
+ }
1448
+ return out;
1449
+ }
1214
1450
  /** All segments on a storage location across every device, oldest-first. */
1215
1451
  segmentsOnLocation(locationId) {
1216
1452
  const out = [];
@@ -1247,20 +1483,59 @@ var RecordingIndex = class {
1247
1483
  const segs = this.sortedSegments(deviceId, profile);
1248
1484
  if (segs.length === 0) return null;
1249
1485
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
1250
- const next = segs.find((s) => s.startMs >= epochMs);
1486
+ const next = segs[lowerBoundByStart(segs, epochMs)];
1251
1487
  return next ? next.startMs : null;
1252
1488
  }
1253
1489
  /**
1490
+ * The end of the newest footage for a (device, profile), or null when there
1491
+ * is none — "how far can an export reach?", asked once per render.
1492
+ *
1493
+ * NOT simply the last row's end: the view is sorted by START, and an earlier,
1494
+ * longer segment can end later. Walks backwards from the newest row only
1495
+ * while an earlier start could still, at {@link maxDurMs}, beat the best end
1496
+ * seen — which on a uniform-length archive is one comparison.
1497
+ */
1498
+ latestEnd(deviceId, profile) {
1499
+ const segs = this.sortedSegments(deviceId, profile);
1500
+ let best = null;
1501
+ for (let i = segs.length - 1; i >= 0; i--) {
1502
+ const s = segs[i];
1503
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1504
+ const end = s.startMs + s.durMs;
1505
+ if (best === null || end > best) best = end;
1506
+ }
1507
+ return best;
1508
+ }
1509
+ /**
1254
1510
  * The exclusive END of the nearest footage ending at-or-before `epochMs`
1255
1511
  * (the backward covered edge), or null when no footage ends at or before it.
1256
1512
  * Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
1257
1513
  * small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
1258
1514
  * backward frame-step probing `startMs − 1` lands in a crack — this edge
1259
1515
  * lets the caller hop to the true previous segment.
1516
+ *
1517
+ * ## Why this is a search and not a scan
1518
+ *
1519
+ * Called on EVERY locate that lands in a gap — during a drag across sparse
1520
+ * footage, every tick — and it used to walk the device-profile's whole sorted
1521
+ * view to do it. Two bounds replace the walk, and the second is the one that
1522
+ * has to be right:
1523
+ *
1524
+ * 1. A row starting AFTER `epochMs` ends after it too (`durMs >= 0`), so
1525
+ * nothing at-or-past the upper bound of `epochMs` can qualify.
1526
+ * 2. Below that bound the answer is NOT the predecessor. A row starting
1527
+ * before the cursor can still COVER it (disqualified: its end is past
1528
+ * `epochMs`), while an even earlier row ends cleanly before it and is the
1529
+ * true edge. So the walk continues backwards, and only {@link maxDurMs}
1530
+ * says when to stop: earlier rows start no later than this one, so none of
1531
+ * them can end past `startMs + maxDurMs`.
1260
1532
  */
1261
1533
  coveredEdgeBefore(deviceId, profile, epochMs) {
1534
+ const segs = this.sortedSegments(deviceId, profile);
1262
1535
  let best = null;
1263
- for (const s of this.sortedSegments(deviceId, profile)) {
1536
+ for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
1537
+ const s = segs[i];
1538
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1264
1539
  const end = s.startMs + s.durMs;
1265
1540
  if (end <= epochMs && (best === null || end > best)) best = end;
1266
1541
  }
@@ -1902,6 +2177,11 @@ var macrotask$2 = () => new Promise((resolve) => {
1902
2177
  * segment's start. Returns the LAST segment whose half-open span
1903
2178
  * `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
1904
2179
  * restart; the newest wins). Null when no segment covers the instant.
2180
+ *
2181
+ * `segments` may be a CANDIDATE WINDOW rather than the device's whole archive —
2182
+ * the caller narrows it with an index search. The membership test below stays
2183
+ * regardless: narrowing may only remove rows that could not have won, never
2184
+ * decide which of the survivors does.
1905
2185
  */
1906
2186
  function resolveStillSource(segments, epochMs) {
1907
2187
  let match = null;
@@ -1969,16 +2249,22 @@ var StillFrameService = class {
1969
2249
  * caller replies 204 → the dialog shows its placeholder).
1970
2250
  */
1971
2251
  async getStill(deviceId, epochMs) {
1972
- const source = resolveStillSource(this.deps.listLowSegments(deviceId), epochMs);
1973
- if (source === null) return null;
2252
+ const source = resolveStillSource(this.deps.lowSegmentsAt(deviceId, epochMs), epochMs);
2253
+ if (source === null) {
2254
+ this.deps.logger.debug("still: no low segment covers the instant", {
2255
+ tags: { deviceId },
2256
+ meta: { epochMs }
2257
+ });
2258
+ return null;
2259
+ }
1974
2260
  const abs = this.deps.resolveAbsPath(source.row);
1975
2261
  const now = (this.deps.now ?? Date.now)();
1976
2262
  const immutable = source.row.startMs + source.row.durMs < now;
1977
2263
  if (!await this.acquireSlot()) {
1978
- this.deps.logger.debug("still: decode queue saturated, dropping request", { meta: {
1979
- deviceId,
1980
- epochMs
1981
- } });
2264
+ this.deps.logger.debug("still: decode queue saturated, dropping request", {
2265
+ tags: { deviceId },
2266
+ meta: { epochMs }
2267
+ });
1982
2268
  return null;
1983
2269
  }
1984
2270
  try {
@@ -5079,7 +5365,7 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
5079
5365
  if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
5080
5366
  const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
5081
5367
  const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
5082
- const candidates = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs).flatMap((s) => {
5368
+ const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
5083
5369
  const root = rootById.get(s.locationId);
5084
5370
  if (root === void 0) return [];
5085
5371
  const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
@@ -5245,7 +5531,7 @@ async function evictVictims(deps, deviceId, victims) {
5245
5531
  * and GOP byte-range reads so the two paths cannot drift on resolution.
5246
5532
  */
5247
5533
  function resolveSegmentFile(deps, deviceId, profile, startMs) {
5248
- const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
5534
+ const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
5249
5535
  if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
5250
5536
  const loc = deps.locations().find((l) => l.id === seg.locationId);
5251
5537
  if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
@@ -5414,8 +5700,7 @@ function buildRecordingProvider(deps) {
5414
5700
  getStorageUsage: async () => {
5415
5701
  const locations = deps.locations();
5416
5702
  const devices = [];
5417
- const seenDevices = /* @__PURE__ */ new Set();
5418
- for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
5703
+ const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
5419
5704
  let totalUsedBytes = 0;
5420
5705
  for (const deviceId of seenDevices) {
5421
5706
  const accounting = deps.index.accounting(deviceId);
@@ -5919,15 +6204,14 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
5919
6204
  await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
5920
6205
  }
5921
6206
  }
5922
- /** The end of the newest FINALIZED `low` segment, or null when none exists. */
6207
+ /**
6208
+ * The end of the newest FINALIZED `low` segment, or null when none exists.
6209
+ *
6210
+ * One scalar off the tail of the live sorted view — it used to COPY the
6211
+ * device's whole `low` archive to fold a maximum out of it, per render.
6212
+ */
5923
6213
  function latestFinalizedEnd(renderDeps, deviceId) {
5924
- const rows = renderDeps.deps.index.segments(deviceId, "low");
5925
- let end = null;
5926
- for (const s of rows) {
5927
- const candidate = s.startMs + s.durMs;
5928
- if (end === null || candidate > end) end = candidate;
5929
- }
5930
- return end;
6214
+ return renderDeps.deps.index.latestEnd(deviceId, "low");
5931
6215
  }
5932
6216
  /**
5933
6217
  * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
@@ -8441,6 +8725,47 @@ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
8441
8725
  */
8442
8726
  var PENDING_GRACE_MS = 3e4;
8443
8727
  /**
8728
+ * How many entries ffmpeg's `-segment_list` must retain — the argument for
8729
+ * `-segment_list_size`.
8730
+ *
8731
+ * ## Why the list is bounded at all
8732
+ *
8733
+ * It was not, and the muxer default (0) means "keep every segment for the
8734
+ * writer's whole life". `parseLivePlaylist` re-splits and re-objectifies the
8735
+ * WHOLE body on every 2 s tick, per writer: at 10 s segments a 5-hour writer
8736
+ * holds 1 800 entries ⇒ ~220 KB/tick ⇒ **3.3 MB/s across 30 writers**, growing
8737
+ * linearly with uptime and bounded by nothing. It is latent today only because
8738
+ * writers restart constantly (59 restarts in 16 minutes, measured 2026-08-29)
8739
+ * and ffmpeg truncates the list on every spawn.
8740
+ *
8741
+ * ## Why THIS size
8742
+ *
8743
+ * The size is what the PARSER has to be able to answer, and the parser has
8744
+ * exactly two questions open at any moment:
8745
+ *
8746
+ * 1. the live tail, which is deliberately held back until its successor
8747
+ * exists ({@link correctedDurMs}) — **1 entry**;
8748
+ * 2. every entry whose flat file has not landed yet. That entry stays this
8749
+ * watcher's business for at most {@link PENDING_GRACE_MS}, after which the
8750
+ * pass gives up on it and advances — and the pass that gives up may be as
8751
+ * far as {@link WATCHER_PASS_WORST_MS} behind the one that first saw it.
8752
+ *
8753
+ * So the window is `1 + (grace + worst pass) / segment length`, rounded up.
8754
+ * At the default 10 s segments that is 1 + ceil(132/10) = **15 entries**
8755
+ * (~2.5 minutes of playlist, ~1.8 KB) instead of an unbounded list. A shorter
8756
+ * segment length buys proportionally more entries, which is exactly right: the
8757
+ * bound is denominated in TIME, and the entry count is only how that time is
8758
+ * spelled for a given `-segment_time`.
8759
+ *
8760
+ * Undersizing does not lose footage — a segment that rolls off unhandled stays
8761
+ * flat in staging and `staging-reconcile` recovers it at the next boot — but it
8762
+ * delays it by a whole process lifetime, so the tick loop reports it
8763
+ * ({@link FinalizationPlan.rolledOff}) rather than advancing in silence.
8764
+ */
8765
+ function segmentListSize(segmentSeconds) {
8766
+ return 1 + Math.ceil(132e3 / ((segmentSeconds > 0 ? segmentSeconds : 1) * 1e3));
8767
+ }
8768
+ /**
8444
8769
  * Decide whether a missing flat file is a late flush or a previous run's entry.
8445
8770
  *
8446
8771
  * ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
@@ -8495,6 +8820,27 @@ function correctedDurMs(durSeconds, startMs, nextStartMs) {
8495
8820
  if (gapMs <= 0) return extinfMs;
8496
8821
  return Math.min(extinfMs, gapMs);
8497
8822
  }
8823
+ var MEDIA_SEQUENCE_RE = /^#EXT-X-MEDIA-SEQUENCE:(\d+)/m;
8824
+ /**
8825
+ * How many entries ffmpeg has already dropped off the FRONT of this playlist.
8826
+ *
8827
+ * With `-segment_list_size` the list is a sliding window, so an index into the
8828
+ * body is not a stable name for a segment: index 0 means a different file every
8829
+ * time the window advances. `#EXT-X-MEDIA-SEQUENCE` is the offset that turns a
8830
+ * body index back into a monotone global one, and ffmpeg maintains it for
8831
+ * exactly this purpose — verified against the ffmpeg 6.1.1 build the hub image
8832
+ * ships (12 segments, `-segment_list_size 4` ⇒ 4 entries and
8833
+ * `#EXT-X-MEDIA-SEQUENCE:8`; unbounded ⇒ 12 entries and `:0`).
8834
+ *
8835
+ * Absent (an unbounded list, or a pre-truncation body) it is 0, which is the
8836
+ * old behaviour exactly.
8837
+ */
8838
+ function parseMediaSequence(body) {
8839
+ const m = MEDIA_SEQUENCE_RE.exec(body);
8840
+ if (m?.[1] === void 0) return 0;
8841
+ const n = Number(m[1]);
8842
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8843
+ }
8498
8844
  /** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
8499
8845
  function parseLivePlaylist(body) {
8500
8846
  const lines = body.split("\n");
@@ -8572,14 +8918,30 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger, wri
8572
8918
  * duration needs its SUCCESSOR's start (ffmpeg's first-segment EXTINF is
8573
8919
  * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
8574
8920
  * is flushed too — by then it is never the unreliable first segment.
8921
+ *
8922
+ * ## The mark is GLOBAL, not an index into the body
8923
+ *
8924
+ * `-segment_list_size` (see {@link segmentListSize}) makes the playlist a
8925
+ * sliding window, and the moment it starts sliding a body index stops naming a
8926
+ * segment: entry 0 is a different file after every roll. A high-water mark kept
8927
+ * as a body index would sit at `entries.length - 1` forever and finalize
8928
+ * NOTHING while ffmpeg kept writing — a bounded list without this rebase would
8929
+ * have stopped the recorder indexing footage while looking like a memory fix.
8930
+ * So the mark counts segments since the writer's first, `#EXT-X-MEDIA-SEQUENCE`
8931
+ * supplies the offset, and an unbounded list (sequence 0) behaves exactly as
8932
+ * before.
8575
8933
  */
8576
8934
  function planFinalizations(body, processed, forceFinal = false) {
8577
8935
  const entries = parseLivePlaylist(body);
8578
8936
  const endlist = body.includes("#EXT-X-ENDLIST");
8579
- const hw = entries.length < processed ? 0 : processed;
8580
- const finalizable = endlist || forceFinal ? entries.length : Math.max(0, entries.length - 1);
8937
+ const base = parseMediaSequence(body);
8938
+ const restarted = base + entries.length < processed;
8939
+ const rolledOff = restarted ? 0 : Math.max(0, base - processed);
8940
+ const hw = restarted ? base : Math.max(processed, base);
8941
+ const finalizable = base + (endlist || forceFinal ? entries.length : Math.max(0, entries.length - 1));
8581
8942
  const toHandle = [];
8582
- for (let i = hw; i < finalizable; i++) {
8943
+ for (let g = hw; g < finalizable; g++) {
8944
+ const i = g - base;
8583
8945
  const entry = entries[i];
8584
8946
  const startMs = parseEpochStartMs(entry.segPath);
8585
8947
  const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
@@ -8592,7 +8954,8 @@ function planFinalizations(body, processed, forceFinal = false) {
8592
8954
  return {
8593
8955
  toHandle,
8594
8956
  from: hw,
8595
- processed: Math.max(hw, finalizable)
8957
+ processed: Math.max(hw, finalizable),
8958
+ rolledOff
8596
8959
  };
8597
8960
  }
8598
8961
  /**
@@ -8620,6 +8983,11 @@ function startSegmentWatcher(deps) {
8620
8983
  return;
8621
8984
  }
8622
8985
  const plan = planFinalizations(body, processed, forceFinal);
8986
+ if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
8987
+ rolledOff: plan.rolledOff,
8988
+ processed,
8989
+ from: plan.from
8990
+ } });
8623
8991
  let index = plan.from;
8624
8992
  for (const { entry, durMs } of plan.toHandle) {
8625
8993
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -8684,6 +9052,23 @@ function startSegmentWatcher(deps) {
8684
9052
  }
8685
9053
  //#endregion
8686
9054
  //#region src/recorder/addon/ffmpeg-args.ts
9055
+ /**
9056
+ * ffmpeg argv builder for recorder passthrough segmentation.
9057
+ *
9058
+ * Copied from the old recorder (`addon-pipeline/src/recorder/ffmpeg-args.ts`)
9059
+ * essentially verbatim — the passthrough + Opus-audio + fragmented-mp4 segment
9060
+ * recipe is proven across every vendor. The ONLY behavioural difference from
9061
+ * the old recorder lives in the WATCHER (recorder relocates via the v2
9062
+ * `SegmentStore.onFinalized`, not inline) — the ffmpeg invocation is identical:
9063
+ * FLAT epoch-named `.m4s` segments under `outDir` plus a rolling `live.m3u8`.
9064
+ *
9065
+ * Audio is re-encoded to **Opus** (`-c:a libopus`, in an `Opus`/`dOps`
9066
+ * ISOBMFF sample entry, verified muxable + mp4box-readable on the ffmpeg 7.x
9067
+ * builds shipped in dev/hub/agent images). Opus is what the WebRTC audio sender
9068
+ * already passes through un-transcoded (live PCMU/PCMA/OPUS => copy), so a
9069
+ * recorded fragment's audio can be read in-process (mp4box) and pushed straight
9070
+ * to the Opus sender with no per-fragment ffmpeg spawn on playback.
9071
+ */
8687
9072
  /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to Opus, write
8688
9073
  * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
8689
9074
  * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
@@ -8722,6 +9107,8 @@ function buildPassthroughArgs(a) {
8722
9107
  `${a.outDir}/live.m3u8`,
8723
9108
  "-segment_list_type",
8724
9109
  "m3u8",
9110
+ "-segment_list_size",
9111
+ String(segmentListSize(a.segmentSeconds)),
8725
9112
  `${a.outDir}/%s.m4s`
8726
9113
  ];
8727
9114
  }
@@ -11234,10 +11621,12 @@ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
11234
11621
  /**
11235
11622
  * The four aggregate columns exist so "how much footage is on this disk" is a
11236
11623
  * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
11237
- * segment rows. They are DERIVED from `paths` — never a second authority — and
11238
- * {@link hourAggregate} is the only place that derives them, so a row loaded
11239
- * from a database that predates these columns is corrected in the mirror the
11240
- * moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
11624
+ * segment rows. They are DERIVED from `paths` — never a second authority — by
11625
+ * {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
11626
+ * over every path and {@link appendSegmentPath} is that step over one, so there
11627
+ * is no second derivation to drift. A row loaded from a database that predates
11628
+ * these columns is corrected in the mirror the moment it is read and repaired
11629
+ * on disk by {@link SegmentHourLedger.repairAggregates}.
11241
11630
  */
11242
11631
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
11243
11632
  {
@@ -11314,33 +11703,57 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
11314
11703
  columns: ["locationId", "hourStartMs"]
11315
11704
  })
11316
11705
  ];
11706
+ /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
11707
+ var EMPTY_HOUR_AGGREGATE = {
11708
+ bytes: 0,
11709
+ segments: 0,
11710
+ minStartMs: 0,
11711
+ maxStartMs: 0
11712
+ };
11317
11713
  /**
11318
- * Bytes / count / oldest / newest for one hour's paths.
11714
+ * ONE path folded into an aggregate the whole derivation, expressed as its
11715
+ * single step.
11716
+ *
11717
+ * {@link hourAggregate} is this step over every path, and {@link
11718
+ * SegmentHourLedger.recordSegment} is this step over the one path that just
11719
+ * arrived. That is not two implementations that happen to agree: the full
11720
+ * derivation is *defined* as this function repeated, so an append is the fold
11721
+ * resumed from the state the held row already carries — a `SegmentHourRow`'s
11722
+ * aggregate half IS the fold state over its `paths`.
11723
+ *
11724
+ * Why it had to become a step: `recordSegment` re-derived the whole hour on
11725
+ * every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
11726
+ * segments, Σ1..1708 ≈ 1.46 M in the restart-storm hours of 2026-08-29, at
11727
+ * ~3.4 finalizes/s fleet-wide, forever.
11728
+ *
11729
+ * A path the parser refuses contributes nothing — it is not a segment, and
11730
+ * counting it would inflate a disk report. `segments === 0` is the first-path
11731
+ * sentinel for min/max, so an aggregate with no segments carries 0/0 rather
11732
+ * than a sentinel nobody can compare against.
11733
+ */
11734
+ function withSegmentPath(agg, path) {
11735
+ const p = parseSegmentPath(path);
11736
+ if (p === null) return agg;
11737
+ return {
11738
+ bytes: agg.bytes + p.bytes,
11739
+ segments: agg.segments + 1,
11740
+ minStartMs: agg.segments === 0 || p.startMs < agg.minStartMs ? p.startMs : agg.minStartMs,
11741
+ maxStartMs: agg.segments === 0 || p.startMs > agg.maxStartMs ? p.startMs : agg.maxStartMs
11742
+ };
11743
+ }
11744
+ /**
11745
+ * Bytes / count / oldest / newest for one hour's paths — the sole FULL
11746
+ * derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
11747
+ * by the append path.
11319
11748
  *
11320
11749
  * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
11321
11750
  * this is a derivation, not a measurement: it can never disagree with the paths
11322
- * it was computed from. A path the parser refuses contributes nothing — it is
11323
- * not a segment, and counting it would inflate a disk report.
11751
+ * it was computed from.
11324
11752
  */
11325
11753
  function hourAggregate(paths) {
11326
- let bytes = 0;
11327
- let segments = 0;
11328
- let minStartMs = 0;
11329
- let maxStartMs = 0;
11330
- for (const path of paths) {
11331
- const p = parseSegmentPath(path);
11332
- if (p === null) continue;
11333
- bytes += p.bytes;
11334
- if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
11335
- if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
11336
- segments += 1;
11337
- }
11338
- return {
11339
- bytes,
11340
- segments,
11341
- minStartMs,
11342
- maxStartMs
11343
- };
11754
+ let agg = EMPTY_HOUR_AGGREGATE;
11755
+ for (const path of paths) agg = withSegmentPath(agg, path);
11756
+ return agg;
11344
11757
  }
11345
11758
  /** Build the persisted row for an hour from its paths — the ONE constructor. */
11346
11759
  function segmentHourRow(base) {
@@ -11350,6 +11763,33 @@ function segmentHourRow(base) {
11350
11763
  };
11351
11764
  }
11352
11765
  /**
11766
+ * The row an hour becomes when ONE more path joins it.
11767
+ *
11768
+ * Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
11769
+ * strictly cheaper: the aggregate half of `held` is already the fold over
11770
+ * `held.paths`, so this resumes that fold instead of restarting it. The
11771
+ * equality is not an assumption — `segment-hour-append.spec.ts` pins it on
11772
+ * generated hours including out-of-order arrival, duplicate paths, unparseable
11773
+ * paths and storm-sized hours.
11774
+ *
11775
+ * The four fields are named rather than spread on purpose. {@link
11776
+ * withSegmentPath} hands its argument straight back for a path the parser
11777
+ * refuses, and `held` is a whole row — spreading that return would put the
11778
+ * PREVIOUS `paths` array back over the appended one. Which is exactly what it
11779
+ * did until the generated-hours test caught it.
11780
+ */
11781
+ function appendSegmentPath(held, path) {
11782
+ const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
11783
+ return {
11784
+ ...held,
11785
+ paths: [...held.paths, path],
11786
+ bytes,
11787
+ segments,
11788
+ minStartMs,
11789
+ maxStartMs
11790
+ };
11791
+ }
11792
+ /**
11353
11793
  * The largest share of a device's held hours a single walk may delete.
11354
11794
  *
11355
11795
  * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
@@ -11682,19 +12122,31 @@ var SegmentHourLedger = class {
11682
12122
  } });
11683
12123
  return repaired;
11684
12124
  }
11685
- /** Write-behind: a just-finalized segment joins its hour row. */
12125
+ /**
12126
+ * Write-behind: a just-finalized segment joins its hour row.
12127
+ *
12128
+ * THE hot path — every profile-writer lands here every `segmentSeconds`,
12129
+ * ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
12130
+ * one `parseSegmentPath` and four scalar updates, whatever the hour already
12131
+ * holds. It used to re-derive the whole hour, which made one hour
12132
+ * O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
12133
+ */
11686
12134
  async recordSegment(row) {
11687
12135
  const { key, hourStartMs: hour } = hourOf(row);
11688
12136
  const held = this.ledger.get(key);
11689
- if (held !== void 0 && held.paths.includes(row.path)) return;
11690
- await this.ledger.put(segmentHourRow({
11691
- key,
11692
- deviceId: row.deviceId,
11693
- profile: row.profile,
11694
- locationId: row.locationId,
11695
- hourStartMs: hour,
11696
- paths: held === void 0 ? [row.path] : [...held.paths, row.path]
11697
- }));
12137
+ if (held === void 0) {
12138
+ await this.ledger.put(segmentHourRow({
12139
+ key,
12140
+ deviceId: row.deviceId,
12141
+ profile: row.profile,
12142
+ locationId: row.locationId,
12143
+ hourStartMs: hour,
12144
+ paths: [row.path]
12145
+ }));
12146
+ return;
12147
+ }
12148
+ if (held.paths.includes(row.path)) return;
12149
+ await this.ledger.put(appendSegmentPath(held, row.path));
11698
12150
  }
11699
12151
  /**
11700
12152
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -11758,10 +12210,7 @@ var SegmentHourLedger = class {
11758
12210
  hourStartMs: hour,
11759
12211
  paths: [seg.path]
11760
12212
  }));
11761
- else fromIndex.set(key, segmentHourRow({
11762
- ...existing,
11763
- paths: [...existing.paths, seg.path]
11764
- }));
12213
+ else fromIndex.set(key, appendSegmentPath(existing, seg.path));
11765
12214
  }
11766
12215
  const refused = this.refusedPrunes(walked, fromIndex);
11767
12216
  for (const row of this.ledger.snapshot()) {
@@ -12346,15 +12795,6 @@ var DEFAULT_CONFIG = {
12346
12795
  recordingNodeId: RECORDING_NODE_DEFAULT,
12347
12796
  recordingHubHostname: ""
12348
12797
  };
12349
- /** Playback-marker window padding around a motion event (EventMap only; the
12350
- * controller's events-band gating uses the raw trigger ms, not this pad).
12351
- * Derived from the SHARED `EVENT_PAD_MS` (R5/C1): markers are ephemeral
12352
- * in-RAM and consumed by nothing today, but aligning their window with the
12353
- * canonical clip pad means that IF they are ever surfaced, their boundaries
12354
- * match the videoclips/admin-ui clip window for the same event — one pad
12355
- * convention, not three. */
12356
- var MARKER_PAD_PRE_MS = require_dist.EVENT_PAD_MS.preMs;
12357
- var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
12358
12798
  /** This addon's manifest id + the playback data-plane prefix — together they
12359
12799
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
12360
12800
  /**
@@ -12518,8 +12958,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12518
12958
  });
12519
12959
  return this.lastKnownGoodRead;
12520
12960
  }
12521
- /** Per-device motion/audio markers for playback (B3 events feed the controller). */
12522
- eventMap = new EventMap();
12523
12961
  /** Unsubscribe handle for the EventCapture motion subscription. */
12524
12962
  eventUnsub = null;
12525
12963
  /**
@@ -12537,6 +12975,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12537
12975
  */
12538
12976
  detectionTriggerUnsub = null;
12539
12977
  /**
12978
+ * Per-window, per-camera tally behind the detection-trigger subscription. A
12979
+ * per-event `debug` line cannot say "NOTHING qualifies any more", which is the
12980
+ * regime this feed sat in — 100% rejected at 3.3–3.9/s — from the day it was
12981
+ * wired until 2026-08-29. This is what turns that into one `warn` a minute
12982
+ * naming the camera and the dominant reason.
12983
+ */
12984
+ detectionTriggerHealth = new ObjectTriggerHealth();
12985
+ /**
12540
12986
  * Unsubscribe handle for the `DeviceStateChanged` subscription — the feed
12541
12987
  * behind `RecordingTriggers.sensorDeviceIds` (Traccia B).
12542
12988
  */
@@ -12991,7 +13437,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12991
13437
  }
12992
13438
  try {
12993
13439
  const stillService = new StillFrameService({
12994
- listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
13440
+ lowSegmentsAt: (deviceId, epochMs) => this.index.segmentsOverlapping(deviceId, "low", epochMs, epochMs + 1),
12995
13441
  resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
12996
13442
  logger: this.ctx.logger.child("Still")
12997
13443
  });
@@ -13125,9 +13571,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13125
13571
  this.recoverStagedOrphans(stagingBootMs);
13126
13572
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
13127
13573
  eventBus: this.ctx.eventBus,
13128
- map: this.eventMap,
13129
- padPreMs: MARKER_PAD_PRE_MS,
13130
- padPostMs: MARKER_PAD_POST_MS,
13131
13574
  onMotion: (deviceId, atMs) => {
13132
13575
  this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
13133
13576
  }
@@ -13139,8 +13582,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13139
13582
  });
13140
13583
  if (this.detectionTriggerUnsub === null) this.detectionTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent }, (event) => {
13141
13584
  const verdict = qualifyObjectTrigger(event.data);
13585
+ for (const report of this.detectionTriggerHealth.record(verdict, Date.now())) this.logDetectionTriggerHealth(report);
13142
13586
  if (!verdict.qualified) {
13143
- this.ctx.logger.debug("recorder: detection event did not qualify as a trigger", { meta: { reason: verdict.reason } });
13587
+ this.ctx.logger.debug("recorder: detection event did not qualify as a trigger", {
13588
+ ...verdict.reason === "malformed" ? {} : { tags: { deviceId: verdict.deviceId } },
13589
+ meta: { reason: verdict.reason }
13590
+ });
13144
13591
  return;
13145
13592
  }
13146
13593
  this.controller?.onTrigger(verdict.deviceId, {
@@ -13222,6 +13669,44 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13222
13669
  this.redundancySweeper?.start();
13223
13670
  }
13224
13671
  /**
13672
+ * Print one window of detection-trigger health.
13673
+ *
13674
+ * The level is the whole point: a window in which SOME object detections
13675
+ * opened a recording is normal operation and stays at `debug`; a window in
13676
+ * which a camera produced object detections and NONE of them opened one is a
13677
+ * dead feature, and it goes out at `warn` with the camera and the dominant
13678
+ * reason on it. The unbucketable `malformed` envelopes are the only line here
13679
+ * without a `deviceId`, because a payload whose envelope did not parse names
13680
+ * no camera — and that line is always `warn`, since after 2026-08-29
13681
+ * `malformed` can only mean the emitter drifted.
13682
+ */
13683
+ logDetectionTriggerHealth(report) {
13684
+ const meta = {
13685
+ objectEvents: report.objectEvents,
13686
+ qualified: report.qualified,
13687
+ rejectedPct: report.rejectedPct,
13688
+ otherKinds: report.otherKinds,
13689
+ topReason: report.topReason,
13690
+ byReason: report.byReason,
13691
+ windowMs: report.windowMs
13692
+ };
13693
+ if (report.deviceId === null) {
13694
+ this.ctx.logger.warn("recorder: detection events with an UNREADABLE envelope — the emitter and this consumer disagree", { meta });
13695
+ return;
13696
+ }
13697
+ if (report.totalRejection) {
13698
+ this.ctx.logger.warn("recorder: EVERY object detection was rejected in this window — the object recording trigger is doing nothing for this camera", {
13699
+ tags: { deviceId: report.deviceId },
13700
+ meta
13701
+ });
13702
+ return;
13703
+ }
13704
+ this.ctx.logger.debug("recorder: object trigger window", {
13705
+ tags: { deviceId: report.deviceId },
13706
+ meta
13707
+ });
13708
+ }
13709
+ /**
13225
13710
  * Rebuild the sensor trigger's reverse index from the persisted configs.
13226
13711
  *
13227
13712
  * D49: this is a MIRROR refreshed off the event path — at boot and on every