@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
- 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-uX-YDOrc.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DuMWMV01.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) {
@@ -8597,6 +8723,47 @@ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
8597
8723
  */
8598
8724
  var PENDING_GRACE_MS = 3e4;
8599
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
+ /**
8600
8767
  * Decide whether a missing flat file is a late flush or a previous run's entry.
8601
8768
  *
8602
8769
  * ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
@@ -8651,6 +8818,27 @@ function correctedDurMs(durSeconds, startMs, nextStartMs) {
8651
8818
  if (gapMs <= 0) return extinfMs;
8652
8819
  return Math.min(extinfMs, gapMs);
8653
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
+ }
8654
8842
  /** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
8655
8843
  function parseLivePlaylist(body) {
8656
8844
  const lines = body.split("\n");
@@ -8728,14 +8916,30 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger, wri
8728
8916
  * duration needs its SUCCESSOR's start (ffmpeg's first-segment EXTINF is
8729
8917
  * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
8730
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.
8731
8931
  */
8732
8932
  function planFinalizations(body, processed, forceFinal = false) {
8733
8933
  const entries = parseLivePlaylist(body);
8734
8934
  const endlist = body.includes("#EXT-X-ENDLIST");
8735
- const hw = entries.length < processed ? 0 : processed;
8736
- 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));
8737
8940
  const toHandle = [];
8738
- for (let i = hw; i < finalizable; i++) {
8941
+ for (let g = hw; g < finalizable; g++) {
8942
+ const i = g - base;
8739
8943
  const entry = entries[i];
8740
8944
  const startMs = parseEpochStartMs(entry.segPath);
8741
8945
  const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
@@ -8748,7 +8952,8 @@ function planFinalizations(body, processed, forceFinal = false) {
8748
8952
  return {
8749
8953
  toHandle,
8750
8954
  from: hw,
8751
- processed: Math.max(hw, finalizable)
8955
+ processed: Math.max(hw, finalizable),
8956
+ rolledOff
8752
8957
  };
8753
8958
  }
8754
8959
  /**
@@ -8776,6 +8981,11 @@ function startSegmentWatcher(deps) {
8776
8981
  return;
8777
8982
  }
8778
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
+ } });
8779
8989
  let index = plan.from;
8780
8990
  for (const { entry, durMs } of plan.toHandle) {
8781
8991
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -8840,6 +9050,23 @@ function startSegmentWatcher(deps) {
8840
9050
  }
8841
9051
  //#endregion
8842
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
+ */
8843
9070
  /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to Opus, write
8844
9071
  * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
8845
9072
  * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
@@ -8878,6 +9105,8 @@ function buildPassthroughArgs(a) {
8878
9105
  `${a.outDir}/live.m3u8`,
8879
9106
  "-segment_list_type",
8880
9107
  "m3u8",
9108
+ "-segment_list_size",
9109
+ String(segmentListSize(a.segmentSeconds)),
8881
9110
  `${a.outDir}/%s.m4s`
8882
9111
  ];
8883
9112
  }
@@ -12564,15 +12793,6 @@ var DEFAULT_CONFIG = {
12564
12793
  recordingNodeId: RECORDING_NODE_DEFAULT,
12565
12794
  recordingHubHostname: ""
12566
12795
  };
12567
- /** Playback-marker window padding around a motion event (EventMap only; the
12568
- * controller's events-band gating uses the raw trigger ms, not this pad).
12569
- * Derived from the SHARED `EVENT_PAD_MS` (R5/C1): markers are ephemeral
12570
- * in-RAM and consumed by nothing today, but aligning their window with the
12571
- * canonical clip pad means that IF they are ever surfaced, their boundaries
12572
- * match the videoclips/admin-ui clip window for the same event — one pad
12573
- * convention, not three. */
12574
- var MARKER_PAD_PRE_MS = EVENT_PAD_MS.preMs;
12575
- var MARKER_PAD_POST_MS = EVENT_PAD_MS.postMs;
12576
12796
  /** This addon's manifest id + the playback data-plane prefix — together they
12577
12797
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
12578
12798
  /**
@@ -12736,8 +12956,6 @@ var RecorderV2Addon = class extends BaseAddon {
12736
12956
  });
12737
12957
  return this.lastKnownGoodRead;
12738
12958
  }
12739
- /** Per-device motion/audio markers for playback (B3 events feed the controller). */
12740
- eventMap = new EventMap();
12741
12959
  /** Unsubscribe handle for the EventCapture motion subscription. */
12742
12960
  eventUnsub = null;
12743
12961
  /**
@@ -12755,6 +12973,14 @@ var RecorderV2Addon = class extends BaseAddon {
12755
12973
  */
12756
12974
  detectionTriggerUnsub = null;
12757
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
+ /**
12758
12984
  * Unsubscribe handle for the `DeviceStateChanged` subscription — the feed
12759
12985
  * behind `RecordingTriggers.sensorDeviceIds` (Traccia B).
12760
12986
  */
@@ -13343,9 +13569,6 @@ var RecorderV2Addon = class extends BaseAddon {
13343
13569
  this.recoverStagedOrphans(stagingBootMs);
13344
13570
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
13345
13571
  eventBus: this.ctx.eventBus,
13346
- map: this.eventMap,
13347
- padPreMs: MARKER_PAD_PRE_MS,
13348
- padPostMs: MARKER_PAD_POST_MS,
13349
13572
  onMotion: (deviceId, atMs) => {
13350
13573
  this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
13351
13574
  }
@@ -13357,8 +13580,12 @@ var RecorderV2Addon = class extends BaseAddon {
13357
13580
  });
13358
13581
  if (this.detectionTriggerUnsub === null) this.detectionTriggerUnsub = this.ctx.eventBus.subscribe({ category: EventCategory.PipelineAnalyticsDetectionEvent }, (event) => {
13359
13582
  const verdict = qualifyObjectTrigger(event.data);
13583
+ for (const report of this.detectionTriggerHealth.record(verdict, Date.now())) this.logDetectionTriggerHealth(report);
13360
13584
  if (!verdict.qualified) {
13361
- 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
+ });
13362
13589
  return;
13363
13590
  }
13364
13591
  this.controller?.onTrigger(verdict.deviceId, {
@@ -13440,6 +13667,44 @@ var RecorderV2Addon = class extends BaseAddon {
13440
13667
  this.redundancySweeper?.start();
13441
13668
  }
13442
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
+ /**
13443
13708
  * Rebuild the sensor trigger's reverse index from the persisted configs.
13444
13709
  *
13445
13710
  * D49: this is a MIRROR refreshed off the event path — at boot and on every
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-3bEUjzHN.js");
1
+ const require_dist = require("./dist-Dpy1KP1q.js");
2
2
  let node_child_process = require("node:child_process");
3
3
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
4
4
  var require_mp4box_all = /* @__PURE__ */ require_dist.__commonJSMin(((exports) => {
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./chunk-DnnnRqeS.mjs";
2
- import { Tt as errMsg } from "./dist-B25xMJDp.mjs";
2
+ import { wt as errMsg } from "./dist-uX-YDOrc.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_worker_protocol = require("../worker-protocol-CxvZ6FP4.js");
3
- const require_lazy_sharp = require("../lazy-sharp-buaVXzJ6.js");
2
+ const require_worker_protocol = require("../worker-protocol-Cn5x8dbW.js");
3
+ const require_lazy_sharp = require("../lazy-sharp-zDl97I3e.js");
4
4
  //#region src/session-decode/color-conversion.ts
5
5
  /**
6
6
  * Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
@@ -1,4 +1,4 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-DYOG2ris.mjs";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-Cd-G92Qt.mjs";
2
2
  import { n as setSharpWarnSink, r as hostExternalEntryUrls, t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
3
3
  //#region src/session-decode/color-conversion.ts
4
4
  /**
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.42",
6
+ version: "1.2.44",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.120",
21
+ version: "1.2.122",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.83",
36
+ version: "1.2.85",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -1,4 +1,4 @@
1
- const require_segment_demux_js = require("../segment-demux-js-J1AdcHSK.js");
1
+ const require_segment_demux_js = require("../segment-demux-js-Bpedn-T8.js");
2
2
  let node_worker_threads = require("node:worker_threads");
3
3
  //#region src/stream-broker/recorded/demux-worker-child.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { t as demuxSegmentFullJs } from "../segment-demux-js-CwNYlHRC.mjs";
1
+ import { t as demuxSegmentFullJs } from "../segment-demux-js-CCMG51zw.mjs";
2
2
  import { parentPort } from "node:worker_threads";
3
3
  //#region src/stream-broker/recorded/demux-worker-child.ts
4
4
  /**
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.120",
39
+ version: "1.2.122",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.42",
48
+ version: "1.2.44",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.83",
84
+ version: "1.2.85",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,