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