@camstack/addon-pipeline 1.2.142 → 1.2.144

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-Dpy1KP1q.js} +63 -29
  6. package/dist/{dist-B25xMJDp.mjs → dist-uX-YDOrc.mjs} +64 -24
  7. package/dist/{event-loop-stall-monitor-CrWg3jrh.mjs → event-loop-stall-monitor-6YKQeg7_.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-BQeD-JuB.js → event-loop-stall-monitor-D947d97M.js} +1 -1
  9. package/dist/{lazy-sharp-buaVXzJ6.js → lazy-sharp-zDl97I3e.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-CFEV-qgx.mjs → node-DuMWMV01.mjs} +1 -1
  13. package/dist/{node-Cn9gEOs4.js → node-v9ZAGK2b.js} +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-B4SGuNSB.mjs → process-memory-CNISjywZ.mjs} +1 -1
  17. package/dist/{process-memory-D7R-j96w.js → process-memory-ClAfQ1Nl.js} +1 -1
  18. package/dist/recorder/index.js +337 -72
  19. package/dist/recorder/index.mjs +337 -72
  20. package/dist/{segment-demux-js-J1AdcHSK.js → segment-demux-js-Bpedn-T8.js} +1 -1
  21. package/dist/{segment-demux-js-CwNYlHRC.mjs → segment-demux-js-CCMG51zw.mjs} +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-MRLUZgkR.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-w0ytBmjx.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-Cd-G92Qt.mjs} +1 -1
  32. package/dist/{worker-protocol-CxvZ6FP4.js → worker-protocol-Cn5x8dbW.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-Dpy1KP1q.js");
2
+ const require_node = require("../node-v9ZAGK2b.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) {
@@ -8599,6 +8725,47 @@ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
8599
8725
  */
8600
8726
  var PENDING_GRACE_MS = 3e4;
8601
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
+ /**
8602
8769
  * Decide whether a missing flat file is a late flush or a previous run's entry.
8603
8770
  *
8604
8771
  * ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
@@ -8653,6 +8820,27 @@ function correctedDurMs(durSeconds, startMs, nextStartMs) {
8653
8820
  if (gapMs <= 0) return extinfMs;
8654
8821
  return Math.min(extinfMs, gapMs);
8655
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
+ }
8656
8844
  /** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
8657
8845
  function parseLivePlaylist(body) {
8658
8846
  const lines = body.split("\n");
@@ -8730,14 +8918,30 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger, wri
8730
8918
  * duration needs its SUCCESSOR's start (ffmpeg's first-segment EXTINF is
8731
8919
  * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
8732
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.
8733
8933
  */
8734
8934
  function planFinalizations(body, processed, forceFinal = false) {
8735
8935
  const entries = parseLivePlaylist(body);
8736
8936
  const endlist = body.includes("#EXT-X-ENDLIST");
8737
- const hw = entries.length < processed ? 0 : processed;
8738
- 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));
8739
8942
  const toHandle = [];
8740
- for (let i = hw; i < finalizable; i++) {
8943
+ for (let g = hw; g < finalizable; g++) {
8944
+ const i = g - base;
8741
8945
  const entry = entries[i];
8742
8946
  const startMs = parseEpochStartMs(entry.segPath);
8743
8947
  const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
@@ -8750,7 +8954,8 @@ function planFinalizations(body, processed, forceFinal = false) {
8750
8954
  return {
8751
8955
  toHandle,
8752
8956
  from: hw,
8753
- processed: Math.max(hw, finalizable)
8957
+ processed: Math.max(hw, finalizable),
8958
+ rolledOff
8754
8959
  };
8755
8960
  }
8756
8961
  /**
@@ -8778,6 +8983,11 @@ function startSegmentWatcher(deps) {
8778
8983
  return;
8779
8984
  }
8780
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
+ } });
8781
8991
  let index = plan.from;
8782
8992
  for (const { entry, durMs } of plan.toHandle) {
8783
8993
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -8842,6 +9052,23 @@ function startSegmentWatcher(deps) {
8842
9052
  }
8843
9053
  //#endregion
8844
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
+ */
8845
9072
  /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to Opus, write
8846
9073
  * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
8847
9074
  * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
@@ -8880,6 +9107,8 @@ function buildPassthroughArgs(a) {
8880
9107
  `${a.outDir}/live.m3u8`,
8881
9108
  "-segment_list_type",
8882
9109
  "m3u8",
9110
+ "-segment_list_size",
9111
+ String(segmentListSize(a.segmentSeconds)),
8883
9112
  `${a.outDir}/%s.m4s`
8884
9113
  ];
8885
9114
  }
@@ -12566,15 +12795,6 @@ var DEFAULT_CONFIG = {
12566
12795
  recordingNodeId: RECORDING_NODE_DEFAULT,
12567
12796
  recordingHubHostname: ""
12568
12797
  };
12569
- /** Playback-marker window padding around a motion event (EventMap only; the
12570
- * controller's events-band gating uses the raw trigger ms, not this pad).
12571
- * Derived from the SHARED `EVENT_PAD_MS` (R5/C1): markers are ephemeral
12572
- * in-RAM and consumed by nothing today, but aligning their window with the
12573
- * canonical clip pad means that IF they are ever surfaced, their boundaries
12574
- * match the videoclips/admin-ui clip window for the same event — one pad
12575
- * convention, not three. */
12576
- var MARKER_PAD_PRE_MS = require_dist.EVENT_PAD_MS.preMs;
12577
- var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
12578
12798
  /** This addon's manifest id + the playback data-plane prefix — together they
12579
12799
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
12580
12800
  /**
@@ -12738,8 +12958,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12738
12958
  });
12739
12959
  return this.lastKnownGoodRead;
12740
12960
  }
12741
- /** Per-device motion/audio markers for playback (B3 events feed the controller). */
12742
- eventMap = new EventMap();
12743
12961
  /** Unsubscribe handle for the EventCapture motion subscription. */
12744
12962
  eventUnsub = null;
12745
12963
  /**
@@ -12757,6 +12975,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12757
12975
  */
12758
12976
  detectionTriggerUnsub = null;
12759
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
+ /**
12760
12986
  * Unsubscribe handle for the `DeviceStateChanged` subscription — the feed
12761
12987
  * behind `RecordingTriggers.sensorDeviceIds` (Traccia B).
12762
12988
  */
@@ -13345,9 +13571,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13345
13571
  this.recoverStagedOrphans(stagingBootMs);
13346
13572
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
13347
13573
  eventBus: this.ctx.eventBus,
13348
- map: this.eventMap,
13349
- padPreMs: MARKER_PAD_PRE_MS,
13350
- padPostMs: MARKER_PAD_POST_MS,
13351
13574
  onMotion: (deviceId, atMs) => {
13352
13575
  this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
13353
13576
  }
@@ -13359,8 +13582,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13359
13582
  });
13360
13583
  if (this.detectionTriggerUnsub === null) this.detectionTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent }, (event) => {
13361
13584
  const verdict = qualifyObjectTrigger(event.data);
13585
+ for (const report of this.detectionTriggerHealth.record(verdict, Date.now())) this.logDetectionTriggerHealth(report);
13362
13586
  if (!verdict.qualified) {
13363
- 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
+ });
13364
13591
  return;
13365
13592
  }
13366
13593
  this.controller?.onTrigger(verdict.deviceId, {
@@ -13442,6 +13669,44 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13442
13669
  this.redundancySweeper?.start();
13443
13670
  }
13444
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
+ /**
13445
13710
  * Rebuild the sensor trigger's reverse index from the persisted configs.
13446
13711
  *
13447
13712
  * D49: this is a MIRROR refreshed off the event path — at boot and on every