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