@camstack/addon-pipeline 1.2.142 → 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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +5 -5
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-3bEUjzHN.js → dist-CmqMW5r3.js} +4 -29
- package/dist/{dist-B25xMJDp.mjs → dist-Dj-w3Fhb.mjs} +5 -24
- package/dist/{event-loop-stall-monitor-BQeD-JuB.js → event-loop-stall-monitor-DBI7BO_G.js} +1 -1
- package/dist/{event-loop-stall-monitor-CrWg3jrh.mjs → event-loop-stall-monitor-DPtT2E7Q.mjs} +1 -1
- package/dist/{lazy-sharp-buaVXzJ6.js → lazy-sharp-DQcMUnU5.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Cn9gEOs4.js → node-COf9Ht20.js} +1 -1
- package/dist/{node-CFEV-qgx.mjs → node-Dj3W4uyh.mjs} +1 -1
- package/dist/pipeline-runner/index.js +5 -5
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/{process-memory-D7R-j96w.js → process-memory-ChIwi_13.js} +1 -1
- package/dist/{process-memory-B4SGuNSB.mjs → process-memory-WlSCnMu4.mjs} +1 -1
- package/dist/recorder/index.js +337 -72
- package/dist/recorder/index.mjs +337 -72
- package/dist/{segment-demux-js-CwNYlHRC.mjs → segment-demux-js-BtScdFL2.mjs} +1 -1
- package/dist/{segment-demux-js-J1AdcHSK.js → segment-demux-js-D8WTC7AT.js} +1 -1
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-DXcoBCs4.mjs → hostInit-D6Ztkap5.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DYOG2ris.mjs → worker-protocol-D5y0GSxE.mjs} +1 -1
- package/dist/{worker-protocol-CxvZ6FP4.js → worker-protocol-X2kpjAAQ.js} +1 -1
- package/package.json +1 -1
package/dist/recorder/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
2
|
-
const require_node = require("../node-
|
|
1
|
+
const require_dist = require("../dist-CmqMW5r3.js");
|
|
2
|
+
const require_node = require("../node-COf9Ht20.js");
|
|
3
3
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
4
4
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
5
5
|
const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
|
|
@@ -11,26 +11,41 @@ let node_child_process = require("node:child_process");
|
|
|
11
11
|
let node_fs_promises = require("node:fs/promises");
|
|
12
12
|
let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
|
|
13
13
|
//#region src/recorder/event-capture.ts
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
45
|
+
const { eventBus, onMotion } = deps;
|
|
26
46
|
return eventBus.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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 (!
|
|
106
|
+
if (!isDetectionEventEnvelope(data)) return {
|
|
64
107
|
qualified: false,
|
|
65
108
|
reason: "malformed"
|
|
66
109
|
};
|
|
67
|
-
|
|
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: "
|
|
118
|
+
reason: "malformed-object",
|
|
119
|
+
deviceId
|
|
70
120
|
};
|
|
71
|
-
if ((data.source ??
|
|
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
|
|
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
|
|
8738
|
-
const
|
|
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
|
|
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", {
|
|
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
|