@camstack/addon-pipeline 1.2.141 → 1.2.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +617 -132
- package/dist/recorder/index.mjs +617 -132
- 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) {
|
|
@@ -848,6 +974,23 @@ function upperBoundByStart(rows, startMs) {
|
|
|
848
974
|
}
|
|
849
975
|
return lo;
|
|
850
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Index of the FIRST element whose `startMs` is at-or-after `startMs` (lower
|
|
979
|
+
* bound). On a start-sorted view this is exactly where a linear
|
|
980
|
+
* `find(s => s.startMs === x)` and a linear `find(s => s.startMs >= x)` would
|
|
981
|
+
* have stopped — which is what makes the binary-search replacements of those
|
|
982
|
+
* two scans row-for-row identical, duplicate starts included.
|
|
983
|
+
*/
|
|
984
|
+
function lowerBoundByStart(rows, startMs) {
|
|
985
|
+
let lo = 0;
|
|
986
|
+
let hi = rows.length;
|
|
987
|
+
while (lo < hi) {
|
|
988
|
+
const mid = lo + hi >> 1;
|
|
989
|
+
if (rows[mid].startMs < startMs) lo = mid + 1;
|
|
990
|
+
else hi = mid;
|
|
991
|
+
}
|
|
992
|
+
return lo;
|
|
993
|
+
}
|
|
851
994
|
var RecordingIndex = class {
|
|
852
995
|
byDevice = /* @__PURE__ */ new Map();
|
|
853
996
|
/** Start-sorted views. Built lazily on first read (one copy+sort), then kept
|
|
@@ -866,6 +1009,27 @@ var RecordingIndex = class {
|
|
|
866
1009
|
* hydrate. Empty = nothing has been looked at, which is NOT the same as
|
|
867
1010
|
* "there is nothing" — see {@link hydrationOf}. */
|
|
868
1011
|
walked = /* @__PURE__ */ new Map();
|
|
1012
|
+
/**
|
|
1013
|
+
* Upper bound on the `durMs` of any row the index holds — the ONLY thing that
|
|
1014
|
+
* turns "walk the whole sorted view" into "walk backwards a bounded number of
|
|
1015
|
+
* rows".
|
|
1016
|
+
*
|
|
1017
|
+
* A start-sorted view answers "which row STARTS at/before X" by binary
|
|
1018
|
+
* search, but every backward question here ("which row still COVERS X",
|
|
1019
|
+
* "which footage ENDS at/before X") depends on `startMs + durMs`, which is
|
|
1020
|
+
* not sorted. This scalar closes that: a row at `startMs` can never end later
|
|
1021
|
+
* than `startMs + maxDurMs`, so once a candidate's ceiling can no longer beat
|
|
1022
|
+
* the best answer found, no earlier row can either — and the walk stops.
|
|
1023
|
+
*
|
|
1024
|
+
* Monotonically non-decreasing on purpose. It is only ever an OVER-estimate
|
|
1025
|
+
* (a longer walk, never a wrong answer); shrinking it on eviction would buy
|
|
1026
|
+
* nothing and could only make it wrong.
|
|
1027
|
+
*/
|
|
1028
|
+
maxDurMs = 0;
|
|
1029
|
+
/** Widen {@link maxDurMs} for a row entering the index. */
|
|
1030
|
+
noteDuration(durMs) {
|
|
1031
|
+
if (durMs > this.maxDurMs) this.maxDurMs = durMs;
|
|
1032
|
+
}
|
|
869
1033
|
mapFor(deviceId) {
|
|
870
1034
|
let m = this.byDevice.get(deviceId);
|
|
871
1035
|
if (!m) {
|
|
@@ -954,11 +1118,13 @@ var RecordingIndex = class {
|
|
|
954
1118
|
const prev = held.get(path);
|
|
955
1119
|
if (prev !== void 0 && prev.locationId === locationId) {
|
|
956
1120
|
this.rowsReused += 1;
|
|
1121
|
+
this.noteDuration(prev.durMs);
|
|
957
1122
|
return prev;
|
|
958
1123
|
}
|
|
959
1124
|
const p = parseSegmentPath(path);
|
|
960
1125
|
if (!p || p.deviceId !== deviceId) return null;
|
|
961
1126
|
this.rowsAllocated += 1;
|
|
1127
|
+
this.noteDuration(p.durMs);
|
|
962
1128
|
return {
|
|
963
1129
|
deviceId: p.deviceId,
|
|
964
1130
|
profile: p.profile,
|
|
@@ -1056,6 +1222,7 @@ var RecordingIndex = class {
|
|
|
1056
1222
|
*/
|
|
1057
1223
|
addSegment(s) {
|
|
1058
1224
|
const m = this.mapFor(s.deviceId);
|
|
1225
|
+
this.noteDuration(s.durMs);
|
|
1059
1226
|
const replacing = m.has(s.path);
|
|
1060
1227
|
m.set(s.path, s);
|
|
1061
1228
|
if (replacing) {
|
|
@@ -1093,6 +1260,55 @@ var RecordingIndex = class {
|
|
|
1093
1260
|
while (end < segs.length && segs[end].startMs < toMs) end += 1;
|
|
1094
1261
|
return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
|
|
1095
1262
|
}
|
|
1263
|
+
/**
|
|
1264
|
+
* The row whose `startMs` is EXACTLY `startMs`, or null.
|
|
1265
|
+
*
|
|
1266
|
+
* The point lookup every byte read is: `readSegmentBytes` / `readGopBytes` /
|
|
1267
|
+
* `readWindowBytes` all address a segment by its exact start, which is what
|
|
1268
|
+
* the playback manifest and the locate answer handed the client. It used to
|
|
1269
|
+
* be `segments(deviceId, profile).find(...)` — a full COPY of the
|
|
1270
|
+
* device-profile archive plus a linear scan, per scrub tick and per playback
|
|
1271
|
+
* fetch (~10 k rows copied to reach one of them on the live hub).
|
|
1272
|
+
*
|
|
1273
|
+
* Returns the FIRST row with that start, which is what `.find` returned on
|
|
1274
|
+
* the same start-sorted view: a restart can leave two rows sharing a start,
|
|
1275
|
+
* and the byte reader must keep resolving to the same one it always did.
|
|
1276
|
+
*/
|
|
1277
|
+
segmentStartingAt(deviceId, profile, startMs) {
|
|
1278
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1279
|
+
const at = segs[lowerBoundByStart(segs, startMs)];
|
|
1280
|
+
return at !== void 0 && at.startMs === startMs ? at : null;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Start-sorted rows whose SPAN `[startMs, startMs+durMs)` overlaps
|
|
1284
|
+
* `[fromMs, toMs)` — i.e. `startMs < toMs && startMs + durMs > fromMs`.
|
|
1285
|
+
*
|
|
1286
|
+
* The overlap-inclusive twin of {@link segmentsStartingIn}, and the reason it
|
|
1287
|
+
* cannot just slice from the `fromMs` lower bound: the segment an operator is
|
|
1288
|
+
* actually watching at `fromMs` STARTED before it. Dropping it truncates the
|
|
1289
|
+
* first segment of every playback window — silently, because the window still
|
|
1290
|
+
* renders, just short. The boundary convention is the caller's, preserved
|
|
1291
|
+
* from the whole-archive filter this replaces: a segment ending EXACTLY at
|
|
1292
|
+
* `fromMs` is excluded (`end > fromMs`), a segment starting exactly at `toMs`
|
|
1293
|
+
* is excluded (`start < toMs`), and a segment straddling `fromMs` is IN.
|
|
1294
|
+
*
|
|
1295
|
+
* The backward reach is {@link maxDurMs}, not "one element": with overlapping
|
|
1296
|
+
* rows (a restart re-recording an hour) more than one predecessor can still
|
|
1297
|
+
* cover `fromMs`, and the ceiling is the only bound that cannot miss one.
|
|
1298
|
+
*/
|
|
1299
|
+
segmentsOverlapping(deviceId, profile, fromMs, toMs) {
|
|
1300
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1301
|
+
const end = lowerBoundByStart(segs, toMs);
|
|
1302
|
+
let head = lowerBoundByStart(segs, fromMs);
|
|
1303
|
+
const reach = fromMs - this.maxDurMs;
|
|
1304
|
+
while (head > 0 && segs[head - 1].startMs > reach) head -= 1;
|
|
1305
|
+
const out = [];
|
|
1306
|
+
for (let i = head; i < end; i++) {
|
|
1307
|
+
const s = segs[i];
|
|
1308
|
+
if (s.startMs + s.durMs > fromMs) out.push(s);
|
|
1309
|
+
}
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1096
1312
|
/** True when `relPath` is currently indexed for `deviceId`. O(1). */
|
|
1097
1313
|
hasSegment(deviceId, relPath) {
|
|
1098
1314
|
return this.byDevice.get(deviceId)?.has(relPath) === true;
|
|
@@ -1211,6 +1427,26 @@ var RecordingIndex = class {
|
|
|
1211
1427
|
}
|
|
1212
1428
|
return [...out.values()];
|
|
1213
1429
|
}
|
|
1430
|
+
/**
|
|
1431
|
+
* WHICH devices hold footage on any of `locationIds` — the census, without
|
|
1432
|
+
* the archive.
|
|
1433
|
+
*
|
|
1434
|
+
* `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
|
|
1435
|
+
* location: a full multi-device COPY of every row on the disk plus a sort of
|
|
1436
|
+
* it (294 k rows on the live hub), to read one number off each row and throw
|
|
1437
|
+
* the array away. This visits each device only until its first matching row.
|
|
1438
|
+
* Same membership rule as before — a device is listed when at least one of
|
|
1439
|
+
* its rows sits on one of the given locations, so footage stranded on a
|
|
1440
|
+
* location id that no longer resolves stays invisible here exactly as it was.
|
|
1441
|
+
*/
|
|
1442
|
+
deviceIdsOnLocations(locationIds) {
|
|
1443
|
+
const out = /* @__PURE__ */ new Set();
|
|
1444
|
+
for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
|
|
1445
|
+
out.add(deviceId);
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
return out;
|
|
1449
|
+
}
|
|
1214
1450
|
/** All segments on a storage location across every device, oldest-first. */
|
|
1215
1451
|
segmentsOnLocation(locationId) {
|
|
1216
1452
|
const out = [];
|
|
@@ -1247,20 +1483,59 @@ var RecordingIndex = class {
|
|
|
1247
1483
|
const segs = this.sortedSegments(deviceId, profile);
|
|
1248
1484
|
if (segs.length === 0) return null;
|
|
1249
1485
|
if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
|
|
1250
|
-
const next = segs
|
|
1486
|
+
const next = segs[lowerBoundByStart(segs, epochMs)];
|
|
1251
1487
|
return next ? next.startMs : null;
|
|
1252
1488
|
}
|
|
1253
1489
|
/**
|
|
1490
|
+
* The end of the newest footage for a (device, profile), or null when there
|
|
1491
|
+
* is none — "how far can an export reach?", asked once per render.
|
|
1492
|
+
*
|
|
1493
|
+
* NOT simply the last row's end: the view is sorted by START, and an earlier,
|
|
1494
|
+
* longer segment can end later. Walks backwards from the newest row only
|
|
1495
|
+
* while an earlier start could still, at {@link maxDurMs}, beat the best end
|
|
1496
|
+
* seen — which on a uniform-length archive is one comparison.
|
|
1497
|
+
*/
|
|
1498
|
+
latestEnd(deviceId, profile) {
|
|
1499
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1500
|
+
let best = null;
|
|
1501
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
1502
|
+
const s = segs[i];
|
|
1503
|
+
if (best !== null && s.startMs + this.maxDurMs <= best) break;
|
|
1504
|
+
const end = s.startMs + s.durMs;
|
|
1505
|
+
if (best === null || end > best) best = end;
|
|
1506
|
+
}
|
|
1507
|
+
return best;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1254
1510
|
* The exclusive END of the nearest footage ending at-or-before `epochMs`
|
|
1255
1511
|
* (the backward covered edge), or null when no footage ends at or before it.
|
|
1256
1512
|
* Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
|
|
1257
1513
|
* small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
|
|
1258
1514
|
* backward frame-step probing `startMs − 1` lands in a crack — this edge
|
|
1259
1515
|
* lets the caller hop to the true previous segment.
|
|
1516
|
+
*
|
|
1517
|
+
* ## Why this is a search and not a scan
|
|
1518
|
+
*
|
|
1519
|
+
* Called on EVERY locate that lands in a gap — during a drag across sparse
|
|
1520
|
+
* footage, every tick — and it used to walk the device-profile's whole sorted
|
|
1521
|
+
* view to do it. Two bounds replace the walk, and the second is the one that
|
|
1522
|
+
* has to be right:
|
|
1523
|
+
*
|
|
1524
|
+
* 1. A row starting AFTER `epochMs` ends after it too (`durMs >= 0`), so
|
|
1525
|
+
* nothing at-or-past the upper bound of `epochMs` can qualify.
|
|
1526
|
+
* 2. Below that bound the answer is NOT the predecessor. A row starting
|
|
1527
|
+
* before the cursor can still COVER it (disqualified: its end is past
|
|
1528
|
+
* `epochMs`), while an even earlier row ends cleanly before it and is the
|
|
1529
|
+
* true edge. So the walk continues backwards, and only {@link maxDurMs}
|
|
1530
|
+
* says when to stop: earlier rows start no later than this one, so none of
|
|
1531
|
+
* them can end past `startMs + maxDurMs`.
|
|
1260
1532
|
*/
|
|
1261
1533
|
coveredEdgeBefore(deviceId, profile, epochMs) {
|
|
1534
|
+
const segs = this.sortedSegments(deviceId, profile);
|
|
1262
1535
|
let best = null;
|
|
1263
|
-
for (
|
|
1536
|
+
for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
|
|
1537
|
+
const s = segs[i];
|
|
1538
|
+
if (best !== null && s.startMs + this.maxDurMs <= best) break;
|
|
1264
1539
|
const end = s.startMs + s.durMs;
|
|
1265
1540
|
if (end <= epochMs && (best === null || end > best)) best = end;
|
|
1266
1541
|
}
|
|
@@ -1902,6 +2177,11 @@ var macrotask$2 = () => new Promise((resolve) => {
|
|
|
1902
2177
|
* segment's start. Returns the LAST segment whose half-open span
|
|
1903
2178
|
* `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
|
|
1904
2179
|
* restart; the newest wins). Null when no segment covers the instant.
|
|
2180
|
+
*
|
|
2181
|
+
* `segments` may be a CANDIDATE WINDOW rather than the device's whole archive —
|
|
2182
|
+
* the caller narrows it with an index search. The membership test below stays
|
|
2183
|
+
* regardless: narrowing may only remove rows that could not have won, never
|
|
2184
|
+
* decide which of the survivors does.
|
|
1905
2185
|
*/
|
|
1906
2186
|
function resolveStillSource(segments, epochMs) {
|
|
1907
2187
|
let match = null;
|
|
@@ -1969,16 +2249,22 @@ var StillFrameService = class {
|
|
|
1969
2249
|
* caller replies 204 → the dialog shows its placeholder).
|
|
1970
2250
|
*/
|
|
1971
2251
|
async getStill(deviceId, epochMs) {
|
|
1972
|
-
const source = resolveStillSource(this.deps.
|
|
1973
|
-
if (source === null)
|
|
2252
|
+
const source = resolveStillSource(this.deps.lowSegmentsAt(deviceId, epochMs), epochMs);
|
|
2253
|
+
if (source === null) {
|
|
2254
|
+
this.deps.logger.debug("still: no low segment covers the instant", {
|
|
2255
|
+
tags: { deviceId },
|
|
2256
|
+
meta: { epochMs }
|
|
2257
|
+
});
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
1974
2260
|
const abs = this.deps.resolveAbsPath(source.row);
|
|
1975
2261
|
const now = (this.deps.now ?? Date.now)();
|
|
1976
2262
|
const immutable = source.row.startMs + source.row.durMs < now;
|
|
1977
2263
|
if (!await this.acquireSlot()) {
|
|
1978
|
-
this.deps.logger.debug("still: decode queue saturated, dropping request", {
|
|
1979
|
-
deviceId,
|
|
1980
|
-
epochMs
|
|
1981
|
-
}
|
|
2264
|
+
this.deps.logger.debug("still: decode queue saturated, dropping request", {
|
|
2265
|
+
tags: { deviceId },
|
|
2266
|
+
meta: { epochMs }
|
|
2267
|
+
});
|
|
1982
2268
|
return null;
|
|
1983
2269
|
}
|
|
1984
2270
|
try {
|
|
@@ -5079,7 +5365,7 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
|
|
|
5079
5365
|
if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
|
|
5080
5366
|
const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
|
|
5081
5367
|
const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
|
|
5082
|
-
const candidates = deps.index.
|
|
5368
|
+
const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
|
|
5083
5369
|
const root = rootById.get(s.locationId);
|
|
5084
5370
|
if (root === void 0) return [];
|
|
5085
5371
|
const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
|
|
@@ -5245,7 +5531,7 @@ async function evictVictims(deps, deviceId, victims) {
|
|
|
5245
5531
|
* and GOP byte-range reads so the two paths cannot drift on resolution.
|
|
5246
5532
|
*/
|
|
5247
5533
|
function resolveSegmentFile(deps, deviceId, profile, startMs) {
|
|
5248
|
-
const seg = deps.index.
|
|
5534
|
+
const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
|
|
5249
5535
|
if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
|
|
5250
5536
|
const loc = deps.locations().find((l) => l.id === seg.locationId);
|
|
5251
5537
|
if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
|
|
@@ -5414,8 +5700,7 @@ function buildRecordingProvider(deps) {
|
|
|
5414
5700
|
getStorageUsage: async () => {
|
|
5415
5701
|
const locations = deps.locations();
|
|
5416
5702
|
const devices = [];
|
|
5417
|
-
const seenDevices =
|
|
5418
|
-
for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
|
|
5703
|
+
const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
|
|
5419
5704
|
let totalUsedBytes = 0;
|
|
5420
5705
|
for (const deviceId of seenDevices) {
|
|
5421
5706
|
const accounting = deps.index.accounting(deviceId);
|
|
@@ -5919,15 +6204,14 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
|
5919
6204
|
await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
|
|
5920
6205
|
}
|
|
5921
6206
|
}
|
|
5922
|
-
/**
|
|
6207
|
+
/**
|
|
6208
|
+
* The end of the newest FINALIZED `low` segment, or null when none exists.
|
|
6209
|
+
*
|
|
6210
|
+
* One scalar off the tail of the live sorted view — it used to COPY the
|
|
6211
|
+
* device's whole `low` archive to fold a maximum out of it, per render.
|
|
6212
|
+
*/
|
|
5923
6213
|
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
5924
|
-
|
|
5925
|
-
let end = null;
|
|
5926
|
-
for (const s of rows) {
|
|
5927
|
-
const candidate = s.startMs + s.durMs;
|
|
5928
|
-
if (end === null || candidate > end) end = candidate;
|
|
5929
|
-
}
|
|
5930
|
-
return end;
|
|
6214
|
+
return renderDeps.deps.index.latestEnd(deviceId, "low");
|
|
5931
6215
|
}
|
|
5932
6216
|
/**
|
|
5933
6217
|
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
@@ -8441,6 +8725,47 @@ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
|
|
|
8441
8725
|
*/
|
|
8442
8726
|
var PENDING_GRACE_MS = 3e4;
|
|
8443
8727
|
/**
|
|
8728
|
+
* How many entries ffmpeg's `-segment_list` must retain — the argument for
|
|
8729
|
+
* `-segment_list_size`.
|
|
8730
|
+
*
|
|
8731
|
+
* ## Why the list is bounded at all
|
|
8732
|
+
*
|
|
8733
|
+
* It was not, and the muxer default (0) means "keep every segment for the
|
|
8734
|
+
* writer's whole life". `parseLivePlaylist` re-splits and re-objectifies the
|
|
8735
|
+
* WHOLE body on every 2 s tick, per writer: at 10 s segments a 5-hour writer
|
|
8736
|
+
* holds 1 800 entries ⇒ ~220 KB/tick ⇒ **3.3 MB/s across 30 writers**, growing
|
|
8737
|
+
* linearly with uptime and bounded by nothing. It is latent today only because
|
|
8738
|
+
* writers restart constantly (59 restarts in 16 minutes, measured 2026-08-29)
|
|
8739
|
+
* and ffmpeg truncates the list on every spawn.
|
|
8740
|
+
*
|
|
8741
|
+
* ## Why THIS size
|
|
8742
|
+
*
|
|
8743
|
+
* The size is what the PARSER has to be able to answer, and the parser has
|
|
8744
|
+
* exactly two questions open at any moment:
|
|
8745
|
+
*
|
|
8746
|
+
* 1. the live tail, which is deliberately held back until its successor
|
|
8747
|
+
* exists ({@link correctedDurMs}) — **1 entry**;
|
|
8748
|
+
* 2. every entry whose flat file has not landed yet. That entry stays this
|
|
8749
|
+
* watcher's business for at most {@link PENDING_GRACE_MS}, after which the
|
|
8750
|
+
* pass gives up on it and advances — and the pass that gives up may be as
|
|
8751
|
+
* far as {@link WATCHER_PASS_WORST_MS} behind the one that first saw it.
|
|
8752
|
+
*
|
|
8753
|
+
* So the window is `1 + (grace + worst pass) / segment length`, rounded up.
|
|
8754
|
+
* At the default 10 s segments that is 1 + ceil(132/10) = **15 entries**
|
|
8755
|
+
* (~2.5 minutes of playlist, ~1.8 KB) instead of an unbounded list. A shorter
|
|
8756
|
+
* segment length buys proportionally more entries, which is exactly right: the
|
|
8757
|
+
* bound is denominated in TIME, and the entry count is only how that time is
|
|
8758
|
+
* spelled for a given `-segment_time`.
|
|
8759
|
+
*
|
|
8760
|
+
* Undersizing does not lose footage — a segment that rolls off unhandled stays
|
|
8761
|
+
* flat in staging and `staging-reconcile` recovers it at the next boot — but it
|
|
8762
|
+
* delays it by a whole process lifetime, so the tick loop reports it
|
|
8763
|
+
* ({@link FinalizationPlan.rolledOff}) rather than advancing in silence.
|
|
8764
|
+
*/
|
|
8765
|
+
function segmentListSize(segmentSeconds) {
|
|
8766
|
+
return 1 + Math.ceil(132e3 / ((segmentSeconds > 0 ? segmentSeconds : 1) * 1e3));
|
|
8767
|
+
}
|
|
8768
|
+
/**
|
|
8444
8769
|
* Decide whether a missing flat file is a late flush or a previous run's entry.
|
|
8445
8770
|
*
|
|
8446
8771
|
* ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
|
|
@@ -8495,6 +8820,27 @@ function correctedDurMs(durSeconds, startMs, nextStartMs) {
|
|
|
8495
8820
|
if (gapMs <= 0) return extinfMs;
|
|
8496
8821
|
return Math.min(extinfMs, gapMs);
|
|
8497
8822
|
}
|
|
8823
|
+
var MEDIA_SEQUENCE_RE = /^#EXT-X-MEDIA-SEQUENCE:(\d+)/m;
|
|
8824
|
+
/**
|
|
8825
|
+
* How many entries ffmpeg has already dropped off the FRONT of this playlist.
|
|
8826
|
+
*
|
|
8827
|
+
* With `-segment_list_size` the list is a sliding window, so an index into the
|
|
8828
|
+
* body is not a stable name for a segment: index 0 means a different file every
|
|
8829
|
+
* time the window advances. `#EXT-X-MEDIA-SEQUENCE` is the offset that turns a
|
|
8830
|
+
* body index back into a monotone global one, and ffmpeg maintains it for
|
|
8831
|
+
* exactly this purpose — verified against the ffmpeg 6.1.1 build the hub image
|
|
8832
|
+
* ships (12 segments, `-segment_list_size 4` ⇒ 4 entries and
|
|
8833
|
+
* `#EXT-X-MEDIA-SEQUENCE:8`; unbounded ⇒ 12 entries and `:0`).
|
|
8834
|
+
*
|
|
8835
|
+
* Absent (an unbounded list, or a pre-truncation body) it is 0, which is the
|
|
8836
|
+
* old behaviour exactly.
|
|
8837
|
+
*/
|
|
8838
|
+
function parseMediaSequence(body) {
|
|
8839
|
+
const m = MEDIA_SEQUENCE_RE.exec(body);
|
|
8840
|
+
if (m?.[1] === void 0) return 0;
|
|
8841
|
+
const n = Number(m[1]);
|
|
8842
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
8843
|
+
}
|
|
8498
8844
|
/** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
|
|
8499
8845
|
function parseLivePlaylist(body) {
|
|
8500
8846
|
const lines = body.split("\n");
|
|
@@ -8572,14 +8918,30 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger, wri
|
|
|
8572
8918
|
* duration needs its SUCCESSOR's start (ffmpeg's first-segment EXTINF is
|
|
8573
8919
|
* inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
|
|
8574
8920
|
* is flushed too — by then it is never the unreliable first segment.
|
|
8921
|
+
*
|
|
8922
|
+
* ## The mark is GLOBAL, not an index into the body
|
|
8923
|
+
*
|
|
8924
|
+
* `-segment_list_size` (see {@link segmentListSize}) makes the playlist a
|
|
8925
|
+
* sliding window, and the moment it starts sliding a body index stops naming a
|
|
8926
|
+
* segment: entry 0 is a different file after every roll. A high-water mark kept
|
|
8927
|
+
* as a body index would sit at `entries.length - 1` forever and finalize
|
|
8928
|
+
* NOTHING while ffmpeg kept writing — a bounded list without this rebase would
|
|
8929
|
+
* have stopped the recorder indexing footage while looking like a memory fix.
|
|
8930
|
+
* So the mark counts segments since the writer's first, `#EXT-X-MEDIA-SEQUENCE`
|
|
8931
|
+
* supplies the offset, and an unbounded list (sequence 0) behaves exactly as
|
|
8932
|
+
* before.
|
|
8575
8933
|
*/
|
|
8576
8934
|
function planFinalizations(body, processed, forceFinal = false) {
|
|
8577
8935
|
const entries = parseLivePlaylist(body);
|
|
8578
8936
|
const endlist = body.includes("#EXT-X-ENDLIST");
|
|
8579
|
-
const
|
|
8580
|
-
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));
|
|
8581
8942
|
const toHandle = [];
|
|
8582
|
-
for (let
|
|
8943
|
+
for (let g = hw; g < finalizable; g++) {
|
|
8944
|
+
const i = g - base;
|
|
8583
8945
|
const entry = entries[i];
|
|
8584
8946
|
const startMs = parseEpochStartMs(entry.segPath);
|
|
8585
8947
|
const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
|
|
@@ -8592,7 +8954,8 @@ function planFinalizations(body, processed, forceFinal = false) {
|
|
|
8592
8954
|
return {
|
|
8593
8955
|
toHandle,
|
|
8594
8956
|
from: hw,
|
|
8595
|
-
processed: Math.max(hw, finalizable)
|
|
8957
|
+
processed: Math.max(hw, finalizable),
|
|
8958
|
+
rolledOff
|
|
8596
8959
|
};
|
|
8597
8960
|
}
|
|
8598
8961
|
/**
|
|
@@ -8620,6 +8983,11 @@ function startSegmentWatcher(deps) {
|
|
|
8620
8983
|
return;
|
|
8621
8984
|
}
|
|
8622
8985
|
const plan = planFinalizations(body, processed, forceFinal);
|
|
8986
|
+
if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
|
|
8987
|
+
rolledOff: plan.rolledOff,
|
|
8988
|
+
processed,
|
|
8989
|
+
from: plan.from
|
|
8990
|
+
} });
|
|
8623
8991
|
let index = plan.from;
|
|
8624
8992
|
for (const { entry, durMs } of plan.toHandle) {
|
|
8625
8993
|
if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
|
|
@@ -8684,6 +9052,23 @@ function startSegmentWatcher(deps) {
|
|
|
8684
9052
|
}
|
|
8685
9053
|
//#endregion
|
|
8686
9054
|
//#region src/recorder/addon/ffmpeg-args.ts
|
|
9055
|
+
/**
|
|
9056
|
+
* ffmpeg argv builder for recorder passthrough segmentation.
|
|
9057
|
+
*
|
|
9058
|
+
* Copied from the old recorder (`addon-pipeline/src/recorder/ffmpeg-args.ts`)
|
|
9059
|
+
* essentially verbatim — the passthrough + Opus-audio + fragmented-mp4 segment
|
|
9060
|
+
* recipe is proven across every vendor. The ONLY behavioural difference from
|
|
9061
|
+
* the old recorder lives in the WATCHER (recorder relocates via the v2
|
|
9062
|
+
* `SegmentStore.onFinalized`, not inline) — the ffmpeg invocation is identical:
|
|
9063
|
+
* FLAT epoch-named `.m4s` segments under `outDir` plus a rolling `live.m3u8`.
|
|
9064
|
+
*
|
|
9065
|
+
* Audio is re-encoded to **Opus** (`-c:a libopus`, in an `Opus`/`dOps`
|
|
9066
|
+
* ISOBMFF sample entry, verified muxable + mp4box-readable on the ffmpeg 7.x
|
|
9067
|
+
* builds shipped in dev/hub/agent images). Opus is what the WebRTC audio sender
|
|
9068
|
+
* already passes through un-transcoded (live PCMU/PCMA/OPUS => copy), so a
|
|
9069
|
+
* recorded fragment's audio can be read in-process (mp4box) and pushed straight
|
|
9070
|
+
* to the Opus sender with no per-fragment ffmpeg spawn on playback.
|
|
9071
|
+
*/
|
|
8687
9072
|
/** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to Opus, write
|
|
8688
9073
|
* fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
|
|
8689
9074
|
* rolling `live.m3u8` playlist. The recorder segment-watcher tails the
|
|
@@ -8722,6 +9107,8 @@ function buildPassthroughArgs(a) {
|
|
|
8722
9107
|
`${a.outDir}/live.m3u8`,
|
|
8723
9108
|
"-segment_list_type",
|
|
8724
9109
|
"m3u8",
|
|
9110
|
+
"-segment_list_size",
|
|
9111
|
+
String(segmentListSize(a.segmentSeconds)),
|
|
8725
9112
|
`${a.outDir}/%s.m4s`
|
|
8726
9113
|
];
|
|
8727
9114
|
}
|
|
@@ -11234,10 +11621,12 @@ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
|
|
|
11234
11621
|
/**
|
|
11235
11622
|
* The four aggregate columns exist so "how much footage is on this disk" is a
|
|
11236
11623
|
* `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
|
|
11237
|
-
* segment rows. They are DERIVED from `paths` — never a second authority —
|
|
11238
|
-
* {@link
|
|
11239
|
-
*
|
|
11240
|
-
*
|
|
11624
|
+
* segment rows. They are DERIVED from `paths` — never a second authority — by
|
|
11625
|
+
* {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
|
|
11626
|
+
* over every path and {@link appendSegmentPath} is that step over one, so there
|
|
11627
|
+
* is no second derivation to drift. A row loaded from a database that predates
|
|
11628
|
+
* these columns is corrected in the mirror the moment it is read and repaired
|
|
11629
|
+
* on disk by {@link SegmentHourLedger.repairAggregates}.
|
|
11241
11630
|
*/
|
|
11242
11631
|
var RECORDING_SEGMENT_HOURS_COLUMNS = [
|
|
11243
11632
|
{
|
|
@@ -11314,33 +11703,57 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
|
11314
11703
|
columns: ["locationId", "hourStartMs"]
|
|
11315
11704
|
})
|
|
11316
11705
|
];
|
|
11706
|
+
/** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
|
|
11707
|
+
var EMPTY_HOUR_AGGREGATE = {
|
|
11708
|
+
bytes: 0,
|
|
11709
|
+
segments: 0,
|
|
11710
|
+
minStartMs: 0,
|
|
11711
|
+
maxStartMs: 0
|
|
11712
|
+
};
|
|
11317
11713
|
/**
|
|
11318
|
-
*
|
|
11714
|
+
* ONE path folded into an aggregate — the whole derivation, expressed as its
|
|
11715
|
+
* single step.
|
|
11716
|
+
*
|
|
11717
|
+
* {@link hourAggregate} is this step over every path, and {@link
|
|
11718
|
+
* SegmentHourLedger.recordSegment} is this step over the one path that just
|
|
11719
|
+
* arrived. That is not two implementations that happen to agree: the full
|
|
11720
|
+
* derivation is *defined* as this function repeated, so an append is the fold
|
|
11721
|
+
* resumed from the state the held row already carries — a `SegmentHourRow`'s
|
|
11722
|
+
* aggregate half IS the fold state over its `paths`.
|
|
11723
|
+
*
|
|
11724
|
+
* Why it had to become a step: `recordSegment` re-derived the whole hour on
|
|
11725
|
+
* every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
|
|
11726
|
+
* segments, Σ1..1708 ≈ 1.46 M in the restart-storm hours of 2026-08-29, at
|
|
11727
|
+
* ~3.4 finalizes/s fleet-wide, forever.
|
|
11728
|
+
*
|
|
11729
|
+
* A path the parser refuses contributes nothing — it is not a segment, and
|
|
11730
|
+
* counting it would inflate a disk report. `segments === 0` is the first-path
|
|
11731
|
+
* sentinel for min/max, so an aggregate with no segments carries 0/0 rather
|
|
11732
|
+
* than a sentinel nobody can compare against.
|
|
11733
|
+
*/
|
|
11734
|
+
function withSegmentPath(agg, path) {
|
|
11735
|
+
const p = parseSegmentPath(path);
|
|
11736
|
+
if (p === null) return agg;
|
|
11737
|
+
return {
|
|
11738
|
+
bytes: agg.bytes + p.bytes,
|
|
11739
|
+
segments: agg.segments + 1,
|
|
11740
|
+
minStartMs: agg.segments === 0 || p.startMs < agg.minStartMs ? p.startMs : agg.minStartMs,
|
|
11741
|
+
maxStartMs: agg.segments === 0 || p.startMs > agg.maxStartMs ? p.startMs : agg.maxStartMs
|
|
11742
|
+
};
|
|
11743
|
+
}
|
|
11744
|
+
/**
|
|
11745
|
+
* Bytes / count / oldest / newest for one hour's paths — the sole FULL
|
|
11746
|
+
* derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
|
|
11747
|
+
* by the append path.
|
|
11319
11748
|
*
|
|
11320
11749
|
* Every field of a segment path encodes its own facts (`parseSegmentPath`), so
|
|
11321
11750
|
* this is a derivation, not a measurement: it can never disagree with the paths
|
|
11322
|
-
* it was computed from.
|
|
11323
|
-
* not a segment, and counting it would inflate a disk report.
|
|
11751
|
+
* it was computed from.
|
|
11324
11752
|
*/
|
|
11325
11753
|
function hourAggregate(paths) {
|
|
11326
|
-
let
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
let maxStartMs = 0;
|
|
11330
|
-
for (const path of paths) {
|
|
11331
|
-
const p = parseSegmentPath(path);
|
|
11332
|
-
if (p === null) continue;
|
|
11333
|
-
bytes += p.bytes;
|
|
11334
|
-
if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
|
|
11335
|
-
if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
|
|
11336
|
-
segments += 1;
|
|
11337
|
-
}
|
|
11338
|
-
return {
|
|
11339
|
-
bytes,
|
|
11340
|
-
segments,
|
|
11341
|
-
minStartMs,
|
|
11342
|
-
maxStartMs
|
|
11343
|
-
};
|
|
11754
|
+
let agg = EMPTY_HOUR_AGGREGATE;
|
|
11755
|
+
for (const path of paths) agg = withSegmentPath(agg, path);
|
|
11756
|
+
return agg;
|
|
11344
11757
|
}
|
|
11345
11758
|
/** Build the persisted row for an hour from its paths — the ONE constructor. */
|
|
11346
11759
|
function segmentHourRow(base) {
|
|
@@ -11350,6 +11763,33 @@ function segmentHourRow(base) {
|
|
|
11350
11763
|
};
|
|
11351
11764
|
}
|
|
11352
11765
|
/**
|
|
11766
|
+
* The row an hour becomes when ONE more path joins it.
|
|
11767
|
+
*
|
|
11768
|
+
* Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
|
|
11769
|
+
* strictly cheaper: the aggregate half of `held` is already the fold over
|
|
11770
|
+
* `held.paths`, so this resumes that fold instead of restarting it. The
|
|
11771
|
+
* equality is not an assumption — `segment-hour-append.spec.ts` pins it on
|
|
11772
|
+
* generated hours including out-of-order arrival, duplicate paths, unparseable
|
|
11773
|
+
* paths and storm-sized hours.
|
|
11774
|
+
*
|
|
11775
|
+
* The four fields are named rather than spread on purpose. {@link
|
|
11776
|
+
* withSegmentPath} hands its argument straight back for a path the parser
|
|
11777
|
+
* refuses, and `held` is a whole row — spreading that return would put the
|
|
11778
|
+
* PREVIOUS `paths` array back over the appended one. Which is exactly what it
|
|
11779
|
+
* did until the generated-hours test caught it.
|
|
11780
|
+
*/
|
|
11781
|
+
function appendSegmentPath(held, path) {
|
|
11782
|
+
const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
|
|
11783
|
+
return {
|
|
11784
|
+
...held,
|
|
11785
|
+
paths: [...held.paths, path],
|
|
11786
|
+
bytes,
|
|
11787
|
+
segments,
|
|
11788
|
+
minStartMs,
|
|
11789
|
+
maxStartMs
|
|
11790
|
+
};
|
|
11791
|
+
}
|
|
11792
|
+
/**
|
|
11353
11793
|
* The largest share of a device's held hours a single walk may delete.
|
|
11354
11794
|
*
|
|
11355
11795
|
* On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
|
|
@@ -11682,19 +12122,31 @@ var SegmentHourLedger = class {
|
|
|
11682
12122
|
} });
|
|
11683
12123
|
return repaired;
|
|
11684
12124
|
}
|
|
11685
|
-
/**
|
|
12125
|
+
/**
|
|
12126
|
+
* Write-behind: a just-finalized segment joins its hour row.
|
|
12127
|
+
*
|
|
12128
|
+
* THE hot path — every profile-writer lands here every `segmentSeconds`,
|
|
12129
|
+
* ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
|
|
12130
|
+
* one `parseSegmentPath` and four scalar updates, whatever the hour already
|
|
12131
|
+
* holds. It used to re-derive the whole hour, which made one hour
|
|
12132
|
+
* O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
|
|
12133
|
+
*/
|
|
11686
12134
|
async recordSegment(row) {
|
|
11687
12135
|
const { key, hourStartMs: hour } = hourOf(row);
|
|
11688
12136
|
const held = this.ledger.get(key);
|
|
11689
|
-
if (held
|
|
11690
|
-
|
|
11691
|
-
|
|
11692
|
-
|
|
11693
|
-
|
|
11694
|
-
|
|
11695
|
-
|
|
11696
|
-
|
|
11697
|
-
|
|
12137
|
+
if (held === void 0) {
|
|
12138
|
+
await this.ledger.put(segmentHourRow({
|
|
12139
|
+
key,
|
|
12140
|
+
deviceId: row.deviceId,
|
|
12141
|
+
profile: row.profile,
|
|
12142
|
+
locationId: row.locationId,
|
|
12143
|
+
hourStartMs: hour,
|
|
12144
|
+
paths: [row.path]
|
|
12145
|
+
}));
|
|
12146
|
+
return;
|
|
12147
|
+
}
|
|
12148
|
+
if (held.paths.includes(row.path)) return;
|
|
12149
|
+
await this.ledger.put(appendSegmentPath(held, row.path));
|
|
11698
12150
|
}
|
|
11699
12151
|
/**
|
|
11700
12152
|
* Eviction confirmed these paths are gone from disk. Drop them; forget the
|
|
@@ -11758,10 +12210,7 @@ var SegmentHourLedger = class {
|
|
|
11758
12210
|
hourStartMs: hour,
|
|
11759
12211
|
paths: [seg.path]
|
|
11760
12212
|
}));
|
|
11761
|
-
else fromIndex.set(key,
|
|
11762
|
-
...existing,
|
|
11763
|
-
paths: [...existing.paths, seg.path]
|
|
11764
|
-
}));
|
|
12213
|
+
else fromIndex.set(key, appendSegmentPath(existing, seg.path));
|
|
11765
12214
|
}
|
|
11766
12215
|
const refused = this.refusedPrunes(walked, fromIndex);
|
|
11767
12216
|
for (const row of this.ledger.snapshot()) {
|
|
@@ -12346,15 +12795,6 @@ var DEFAULT_CONFIG = {
|
|
|
12346
12795
|
recordingNodeId: RECORDING_NODE_DEFAULT,
|
|
12347
12796
|
recordingHubHostname: ""
|
|
12348
12797
|
};
|
|
12349
|
-
/** Playback-marker window padding around a motion event (EventMap only; the
|
|
12350
|
-
* controller's events-band gating uses the raw trigger ms, not this pad).
|
|
12351
|
-
* Derived from the SHARED `EVENT_PAD_MS` (R5/C1): markers are ephemeral
|
|
12352
|
-
* in-RAM and consumed by nothing today, but aligning their window with the
|
|
12353
|
-
* canonical clip pad means that IF they are ever surfaced, their boundaries
|
|
12354
|
-
* match the videoclips/admin-ui clip window for the same event — one pad
|
|
12355
|
-
* convention, not three. */
|
|
12356
|
-
var MARKER_PAD_PRE_MS = require_dist.EVENT_PAD_MS.preMs;
|
|
12357
|
-
var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
|
|
12358
12798
|
/** This addon's manifest id + the playback data-plane prefix — together they
|
|
12359
12799
|
* form the hub reverse-proxy client path `/addon/recorder/playback`. */
|
|
12360
12800
|
/**
|
|
@@ -12518,8 +12958,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12518
12958
|
});
|
|
12519
12959
|
return this.lastKnownGoodRead;
|
|
12520
12960
|
}
|
|
12521
|
-
/** Per-device motion/audio markers for playback (B3 events feed the controller). */
|
|
12522
|
-
eventMap = new EventMap();
|
|
12523
12961
|
/** Unsubscribe handle for the EventCapture motion subscription. */
|
|
12524
12962
|
eventUnsub = null;
|
|
12525
12963
|
/**
|
|
@@ -12537,6 +12975,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12537
12975
|
*/
|
|
12538
12976
|
detectionTriggerUnsub = null;
|
|
12539
12977
|
/**
|
|
12978
|
+
* Per-window, per-camera tally behind the detection-trigger subscription. A
|
|
12979
|
+
* per-event `debug` line cannot say "NOTHING qualifies any more", which is the
|
|
12980
|
+
* regime this feed sat in — 100% rejected at 3.3–3.9/s — from the day it was
|
|
12981
|
+
* wired until 2026-08-29. This is what turns that into one `warn` a minute
|
|
12982
|
+
* naming the camera and the dominant reason.
|
|
12983
|
+
*/
|
|
12984
|
+
detectionTriggerHealth = new ObjectTriggerHealth();
|
|
12985
|
+
/**
|
|
12540
12986
|
* Unsubscribe handle for the `DeviceStateChanged` subscription — the feed
|
|
12541
12987
|
* behind `RecordingTriggers.sensorDeviceIds` (Traccia B).
|
|
12542
12988
|
*/
|
|
@@ -12991,7 +13437,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
12991
13437
|
}
|
|
12992
13438
|
try {
|
|
12993
13439
|
const stillService = new StillFrameService({
|
|
12994
|
-
|
|
13440
|
+
lowSegmentsAt: (deviceId, epochMs) => this.index.segmentsOverlapping(deviceId, "low", epochMs, epochMs + 1),
|
|
12995
13441
|
resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
|
|
12996
13442
|
logger: this.ctx.logger.child("Still")
|
|
12997
13443
|
});
|
|
@@ -13125,9 +13571,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
13125
13571
|
this.recoverStagedOrphans(stagingBootMs);
|
|
13126
13572
|
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
13127
13573
|
eventBus: this.ctx.eventBus,
|
|
13128
|
-
map: this.eventMap,
|
|
13129
|
-
padPreMs: MARKER_PAD_PRE_MS,
|
|
13130
|
-
padPostMs: MARKER_PAD_POST_MS,
|
|
13131
13574
|
onMotion: (deviceId, atMs) => {
|
|
13132
13575
|
this.controller?.onTrigger(deviceId, { kind: "motion" }, atMs);
|
|
13133
13576
|
}
|
|
@@ -13139,8 +13582,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
13139
13582
|
});
|
|
13140
13583
|
if (this.detectionTriggerUnsub === null) this.detectionTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsDetectionEvent }, (event) => {
|
|
13141
13584
|
const verdict = qualifyObjectTrigger(event.data);
|
|
13585
|
+
for (const report of this.detectionTriggerHealth.record(verdict, Date.now())) this.logDetectionTriggerHealth(report);
|
|
13142
13586
|
if (!verdict.qualified) {
|
|
13143
|
-
this.ctx.logger.debug("recorder: detection event did not qualify as a trigger", {
|
|
13587
|
+
this.ctx.logger.debug("recorder: detection event did not qualify as a trigger", {
|
|
13588
|
+
...verdict.reason === "malformed" ? {} : { tags: { deviceId: verdict.deviceId } },
|
|
13589
|
+
meta: { reason: verdict.reason }
|
|
13590
|
+
});
|
|
13144
13591
|
return;
|
|
13145
13592
|
}
|
|
13146
13593
|
this.controller?.onTrigger(verdict.deviceId, {
|
|
@@ -13222,6 +13669,44 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
13222
13669
|
this.redundancySweeper?.start();
|
|
13223
13670
|
}
|
|
13224
13671
|
/**
|
|
13672
|
+
* Print one window of detection-trigger health.
|
|
13673
|
+
*
|
|
13674
|
+
* The level is the whole point: a window in which SOME object detections
|
|
13675
|
+
* opened a recording is normal operation and stays at `debug`; a window in
|
|
13676
|
+
* which a camera produced object detections and NONE of them opened one is a
|
|
13677
|
+
* dead feature, and it goes out at `warn` with the camera and the dominant
|
|
13678
|
+
* reason on it. The unbucketable `malformed` envelopes are the only line here
|
|
13679
|
+
* without a `deviceId`, because a payload whose envelope did not parse names
|
|
13680
|
+
* no camera — and that line is always `warn`, since after 2026-08-29
|
|
13681
|
+
* `malformed` can only mean the emitter drifted.
|
|
13682
|
+
*/
|
|
13683
|
+
logDetectionTriggerHealth(report) {
|
|
13684
|
+
const meta = {
|
|
13685
|
+
objectEvents: report.objectEvents,
|
|
13686
|
+
qualified: report.qualified,
|
|
13687
|
+
rejectedPct: report.rejectedPct,
|
|
13688
|
+
otherKinds: report.otherKinds,
|
|
13689
|
+
topReason: report.topReason,
|
|
13690
|
+
byReason: report.byReason,
|
|
13691
|
+
windowMs: report.windowMs
|
|
13692
|
+
};
|
|
13693
|
+
if (report.deviceId === null) {
|
|
13694
|
+
this.ctx.logger.warn("recorder: detection events with an UNREADABLE envelope — the emitter and this consumer disagree", { meta });
|
|
13695
|
+
return;
|
|
13696
|
+
}
|
|
13697
|
+
if (report.totalRejection) {
|
|
13698
|
+
this.ctx.logger.warn("recorder: EVERY object detection was rejected in this window — the object recording trigger is doing nothing for this camera", {
|
|
13699
|
+
tags: { deviceId: report.deviceId },
|
|
13700
|
+
meta
|
|
13701
|
+
});
|
|
13702
|
+
return;
|
|
13703
|
+
}
|
|
13704
|
+
this.ctx.logger.debug("recorder: object trigger window", {
|
|
13705
|
+
tags: { deviceId: report.deviceId },
|
|
13706
|
+
meta
|
|
13707
|
+
});
|
|
13708
|
+
}
|
|
13709
|
+
/**
|
|
13225
13710
|
* Rebuild the sensor trigger's reverse index from the persisted configs.
|
|
13226
13711
|
*
|
|
13227
13712
|
* D49: this is a MIRROR refreshed off the event path — at boot and on every
|