@camstack/addon-pipeline 1.2.19 → 1.2.28
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 +1 -1
- package/dist/audio-analyzer/index.mjs +3 -2
- package/dist/chunk-DnnnRqeS.mjs +26 -0
- package/dist/detection-pipeline/index.js +2 -2
- package/dist/detection-pipeline/index.mjs +4 -3
- package/dist/{dist-DZBePRjP.mjs → dist-CsKaXmP8.mjs} +415 -126
- package/dist/{dist-DKqOqH5j.js → dist-DUHlZcWd.js} +420 -131
- package/dist/{model-download-service-Cp9f4dk6-WO-foD7W.mjs → model-download-service-Cp9f4dk6-CwUb5v3Y.mjs} +1 -26
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +230 -28
- package/dist/pipeline-runner/index.mjs +230 -28
- package/dist/recorder/index.js +2604 -458
- package/dist/recorder/index.mjs +2599 -459
- package/dist/{step-definitions-zDq8cP8M.js → step-definitions-Bo1hJQQw.js} +1 -1
- package/dist/{step-definitions-BwB6TBMB.mjs → step-definitions-C8A78dBi.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Bwuv7iK_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tHahN-lK.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1zJmtUe.mjs +26 -0
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfnRAN5T.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CSWUWZ8c.mjs} +1 -1
- package/dist/stream-broker/{hostInit-DC0aArn1.mjs → hostInit-CIwilJUZ.mjs} +2 -2
- package/dist/stream-broker/index.js +7374 -250
- package/dist/stream-broker/index.mjs +7371 -249
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C2hbIwzB.js → MaskShapeCanvas-DI4BY7W2-B7VdNJlO.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BpJ-foJW.js → MotionZonesSettings-NcxxQN8r-YyB4uLdH.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BjLs9Z3w.js → PrivacyMaskSettings-APgPLF7p-DfqixEad.js} +1 -1
- package/embed-dist/assets/{index-Dv5H-32C.css → index-CAe6Dg-X.css} +1 -1
- package/embed-dist/assets/index-zVLLVdIS.js +343 -0
- package/embed-dist/index.html +2 -2
- package/package.json +5 -1
- package/dist/fmp4-keyframes-5s8T9Mng.js +0 -6711
- package/dist/fmp4-keyframes-CV0X1mGy.mjs +0 -6694
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BDeEvhV5.mjs +0 -26
- package/embed-dist/assets/index-_BRQTmdE.js +0 -116
package/dist/recorder/index.js
CHANGED
|
@@ -1,13 +1,1542 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
1
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-DUHlZcWd.js");
|
|
3
7
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
4
8
|
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
|
|
5
|
-
const require_fmp4_keyframes = require("../fmp4-keyframes-5s8T9Mng.js");
|
|
6
9
|
let node_child_process = require("node:child_process");
|
|
7
10
|
let node_fs = require("node:fs");
|
|
8
11
|
let node_path = require("node:path");
|
|
9
12
|
node_path = require_chunk.__toESM(node_path);
|
|
10
13
|
let node_crypto = require("node:crypto");
|
|
14
|
+
let node_fs_promises = require("node:fs/promises");
|
|
15
|
+
//#region src/recorder/addon/calendar-scan.ts
|
|
16
|
+
/**
|
|
17
|
+
* The top two rungs of the hydration ladder: which DAYS have footage, and which
|
|
18
|
+
* HOURS of a day — answered from the directory tree, without a segment index.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS EXISTS
|
|
21
|
+
* ───────────────
|
|
22
|
+
* `getDaysWithRecordings` and `getAvailability` answer from
|
|
23
|
+
* `index.segments(deviceId)`, so the date selector — the first thing an operator
|
|
24
|
+
* sees — cannot resolve until the archive-wide walk has finished. Measured on
|
|
25
|
+
* the live archive (device 590, `low`): that walk is **68,457 ms over 258,449
|
|
26
|
+
* files**, while the questions it is being asked cost
|
|
27
|
+
*
|
|
28
|
+
* which days of a month 28 entries 1 ms
|
|
29
|
+
* which hours of a day 8 entries 1 ms
|
|
30
|
+
*
|
|
31
|
+
* The cheapest question in the product was waiting on the most expensive data in
|
|
32
|
+
* it. These read the rung that answers, and nothing below it.
|
|
33
|
+
*
|
|
34
|
+
* THE TRAP, AND WHY IT IS NOT PAID FOR WITH READS
|
|
35
|
+
* ───────────────────────────────────────────────
|
|
36
|
+
* Directory existence is NOT footage: on the real archive **15 of 28 day
|
|
37
|
+
* directories are empty**, because retention deletes files and leaves the tree
|
|
38
|
+
* behind. Trusting existence would put fifteen phantom days in the selector.
|
|
39
|
+
*
|
|
40
|
+
* But the shape of that garbage matters. Measured: those 15 day directories
|
|
41
|
+
* hold NO entries at all — they are shells — and of 261 hour directories
|
|
42
|
+
* exactly ONE is empty. So the answer does not need a read inside every hour;
|
|
43
|
+
* it needs the NAMES one level down, which is a single read per directory:
|
|
44
|
+
*
|
|
45
|
+
* readdir(month) → day names 1 read
|
|
46
|
+
* readdir(day) → hour names 1 read per day
|
|
47
|
+
*
|
|
48
|
+
* 14 reads for a 13-day month, against the 41 an inside-every-hour probe costs,
|
|
49
|
+
* and against 258,449 files for an archive walk.
|
|
50
|
+
*
|
|
51
|
+
* The residual — an hour directory emptied but not removed — is SELF-HEALED
|
|
52
|
+
* rather than worked around: a directory found empty while scanning is deleted
|
|
53
|
+
* on the spot. Retention stops accumulating shells, the existing ones clear as
|
|
54
|
+
* they are visited, and no second code path exists to tolerate them.
|
|
55
|
+
*/
|
|
56
|
+
var p2 = (n) => String(n).padStart(2, "0");
|
|
57
|
+
/**
|
|
58
|
+
* Preference order for the ONE profile availability is read from.
|
|
59
|
+
*
|
|
60
|
+
* Availability is a single bar: it answers "was anything recorded", not "at
|
|
61
|
+
* what quality". Enumerating every profile triples the reads for an identical
|
|
62
|
+
* answer — measured 75 reads / 16,028 ms against 25 / 1,489 ms for a cold day.
|
|
63
|
+
*
|
|
64
|
+
* Lowest first because it is the smallest and the one always recorded (the
|
|
65
|
+
* scrub reads it too, so its directories are the ones already warm). A device
|
|
66
|
+
* that records only `high` still answers — the pick is over what EXISTS.
|
|
67
|
+
*
|
|
68
|
+
* The trade, stated: a device whose profile set CHANGED mid-history has hours
|
|
69
|
+
* only under a profile this does not pick, and those hours would not show. That
|
|
70
|
+
* is a configuration change, not a recording gap, and reading three profiles
|
|
71
|
+
* for ever is the wrong price for it.
|
|
72
|
+
*/
|
|
73
|
+
var PROFILE_PREFERENCE = [
|
|
74
|
+
"low",
|
|
75
|
+
"mid",
|
|
76
|
+
"high"
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* The lowest profile this device actually RECORDS to, or null when it has none.
|
|
80
|
+
*
|
|
81
|
+
* Non-emptiness, not existence: a `low` directory left behind by a config
|
|
82
|
+
* change would otherwise be picked over the `high` that holds the footage, and
|
|
83
|
+
* the whole camera would read as having none. One shallow read per candidate,
|
|
84
|
+
* at most three, and the answer is stable for a session.
|
|
85
|
+
*/
|
|
86
|
+
async function availabilityProfile(root, deviceId) {
|
|
87
|
+
const present = await profilesOf(root, deviceId);
|
|
88
|
+
if (present.length === 0) return null;
|
|
89
|
+
const ordered = [...PROFILE_PREFERENCE.filter((p) => present.includes(p)), ...present.filter((p) => !PROFILE_PREFERENCE.includes(p))];
|
|
90
|
+
for (const p of ordered) if ((await listDir(node_path.default.join(root, String(deviceId), p))).length > 0) return p;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/** The canonical recording profiles — the ONLY `<deviceId>/` subdirectories
|
|
94
|
+
* that hold segments. The camera dir also hosts per-camera artifacts that are
|
|
95
|
+
* NOT profiles (`strips/`, `events/`); a walker that trusts readdir would
|
|
96
|
+
* offer them as playable profiles. */
|
|
97
|
+
var KNOWN_PROFILES = new Set([
|
|
98
|
+
"high",
|
|
99
|
+
"mid",
|
|
100
|
+
"low"
|
|
101
|
+
]);
|
|
102
|
+
/** `<root>/<deviceId>/` PROFILE subdirectories. Empty when absent. */
|
|
103
|
+
async function profilesOf(root, deviceId) {
|
|
104
|
+
try {
|
|
105
|
+
return (await node_fs.promises.readdir(node_path.default.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES.has(e));
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function listDir(abs) {
|
|
111
|
+
try {
|
|
112
|
+
return await node_fs.promises.readdir(abs);
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Remove a directory that turned out to be empty. Best-effort by design: it is
|
|
119
|
+
* housekeeping on a read path, so a failure (a racing writer recreating it, a
|
|
120
|
+
* read-only mount) must never affect the answer.
|
|
121
|
+
*/
|
|
122
|
+
async function reapIfEmpty(abs, entries) {
|
|
123
|
+
if (entries.length > 0) return;
|
|
124
|
+
try {
|
|
125
|
+
await node_fs.promises.rmdir(abs);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Hours (0–23) of a day that hold footage, ascending — ONE read per day.
|
|
130
|
+
*
|
|
131
|
+
* The hour directory's NAME is the evidence. Reading inside each one to confirm
|
|
132
|
+
* would cost a read per hour (nine for a typical day, 117 for a month) to
|
|
133
|
+
* correct a case measured at **1 in 261**: an hour emptied by retention whose
|
|
134
|
+
* directory survives.
|
|
135
|
+
*
|
|
136
|
+
* That residual is not tolerated, it is HEALED ONE RUNG DOWN: the segment scan
|
|
137
|
+
* (`hydrateWindowFromStorage`) reads inside an hour when someone actually seeks
|
|
138
|
+
* into it, and reaps it if it is empty. So each rung pays only its own cost, and
|
|
139
|
+
* the garbage clears where a read was happening anyway. A day whose only hour is
|
|
140
|
+
* such a shell shows in the selector until someone opens it — the smallest
|
|
141
|
+
* wrong answer available, and self-correcting.
|
|
142
|
+
*/
|
|
143
|
+
async function hoursWithFootage(root, deviceId, year, month, day, knownProfile) {
|
|
144
|
+
const hours = /* @__PURE__ */ new Set();
|
|
145
|
+
const profile = knownProfile ?? await availabilityProfile(root, deviceId);
|
|
146
|
+
if (profile !== null) {
|
|
147
|
+
const dayDir = node_path.default.join(root, String(deviceId), profile, String(year), p2(month), p2(day));
|
|
148
|
+
for (const hour of await listDir(dayDir)) {
|
|
149
|
+
const n = Number(hour);
|
|
150
|
+
if (Number.isInteger(n)) hours.add(n);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...hours].sort((a, b) => a - b);
|
|
154
|
+
}
|
|
155
|
+
/** `<startMs>-<durMs>-<bytes>.m4s` — the name is the record. */
|
|
156
|
+
var SEGMENT_NAME = /^(\d+)-(\d+)-(\d+)\.m4s$/;
|
|
157
|
+
/**
|
|
158
|
+
* The segments of ONE hour, from their names — one directory read.
|
|
159
|
+
*
|
|
160
|
+
* This is the exact granularity the progressive timeline needs, and it costs
|
|
161
|
+
* what the coarse answer costs: measured on the live archive, a cold hour is
|
|
162
|
+
* **61 ms** and holds ~350 segments. A whole day on one profile is 25 reads,
|
|
163
|
+
* 1,489 ms cold and 19 ms warm.
|
|
164
|
+
*
|
|
165
|
+
* ONE profile, not all three. The timeline draws a single bar, so per-profile
|
|
166
|
+
* detail buys nothing and triples the reads — 75 against 25, measured at
|
|
167
|
+
* 16,028 ms against 1,489 ms.
|
|
168
|
+
*
|
|
169
|
+
* Hour granularity is NOT a substitute for this. A real day on this archive has
|
|
170
|
+
* 88 gaps over a second, including one of **1,011 s**: rendering the hour that
|
|
171
|
+
* contains it as solid would hide seventeen minutes of missing footage.
|
|
172
|
+
*/
|
|
173
|
+
async function segmentsOfHour(root, deviceId, profile, hourStartMs) {
|
|
174
|
+
const d = new Date(hourStartMs);
|
|
175
|
+
const dir = node_path.default.join(root, String(deviceId), profile, String(d.getUTCFullYear()), p2(d.getUTCMonth() + 1), p2(d.getUTCDate()), p2(d.getUTCHours()));
|
|
176
|
+
const entries = await listDir(dir);
|
|
177
|
+
if (entries.length === 0) {
|
|
178
|
+
await reapIfEmpty(dir, entries);
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const name of entries) {
|
|
183
|
+
const m = SEGMENT_NAME.exec(name);
|
|
184
|
+
if (m) out.push({
|
|
185
|
+
startMs: Number(m[1]),
|
|
186
|
+
durMs: Number(m[2]),
|
|
187
|
+
bytes: Number(m[3])
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return out.sort((a, b) => a.startMs - b.startMs);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Whether this device records `profile` at all — a shallow non-emptiness check,
|
|
194
|
+
* same contract as {@link availabilityProfile} but for one named profile. Lets
|
|
195
|
+
* the directory route serve the profile the client asked for when it exists,
|
|
196
|
+
* and fall back to the availability pick when it does not.
|
|
197
|
+
*/
|
|
198
|
+
async function profileHasFootage(root, deviceId, profile) {
|
|
199
|
+
return (await listDir(node_path.default.join(root, String(deviceId), profile))).length > 0;
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/recorder/merge-ranges.ts
|
|
203
|
+
function mergeRanges(segments, gapToleranceMs) {
|
|
204
|
+
if (segments.length === 0) return [];
|
|
205
|
+
const sorted = [...segments].toSorted((a, b) => a.startMs - b.startMs);
|
|
206
|
+
const out = [];
|
|
207
|
+
let curStart = sorted[0].startMs;
|
|
208
|
+
let curEnd = sorted[0].startMs + sorted[0].durMs;
|
|
209
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
210
|
+
const s = sorted[i];
|
|
211
|
+
if (s.startMs <= curEnd + gapToleranceMs) curEnd = Math.max(curEnd, s.startMs + s.durMs);
|
|
212
|
+
else {
|
|
213
|
+
out.push({
|
|
214
|
+
startMs: curStart,
|
|
215
|
+
endMs: curEnd
|
|
216
|
+
});
|
|
217
|
+
curStart = s.startMs;
|
|
218
|
+
curEnd = s.startMs + s.durMs;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
out.push({
|
|
222
|
+
startMs: curStart,
|
|
223
|
+
endMs: curEnd
|
|
224
|
+
});
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/recorder/addon/calendar-index.ts
|
|
229
|
+
var DAY_MS$3 = 864e5;
|
|
230
|
+
var HOUR_MS$4 = 36e5;
|
|
231
|
+
/**
|
|
232
|
+
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
233
|
+
*
|
|
234
|
+
* The cap returns local-midnight epochs, not date strings, so the shift has to
|
|
235
|
+
* survive the round trip: add the offset to land in local time, floor to the
|
|
236
|
+
* day, then subtract it to get back to a real instant.
|
|
237
|
+
*/
|
|
238
|
+
function localDayStart(epochMs, tzOffsetMinutes) {
|
|
239
|
+
const off = tzOffsetMinutes * 6e4;
|
|
240
|
+
return Math.floor((epochMs + off) / DAY_MS$3) * DAY_MS$3 - off;
|
|
241
|
+
}
|
|
242
|
+
/** Midnight UTC of the day containing `epochMs`. */
|
|
243
|
+
function utcDayOf(epochMs) {
|
|
244
|
+
return Math.floor(epochMs / DAY_MS$3) * DAY_MS$3;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Gap below which two segments are ONE range.
|
|
248
|
+
*
|
|
249
|
+
* Adjacent segments are written back-to-back but their recorded durations
|
|
250
|
+
* under-cover the span to the next start by a few ms of rounding, so a
|
|
251
|
+
* tolerance of 0 splits continuous footage into one range PER SEGMENT —
|
|
252
|
+
* hundreds an hour. Measured on a real day: 88 gaps over a second, of which ~80
|
|
253
|
+
* are that 1–2 s jitter. The ones that matter survive: 15 s, 23 s, and 1,011 s.
|
|
254
|
+
*/
|
|
255
|
+
var RANGE_MERGE_GAP_MS$1 = 5e3;
|
|
256
|
+
/**
|
|
257
|
+
* How long the CURRENT hour's answer may be reused.
|
|
258
|
+
*
|
|
259
|
+
* Not caching it at all was the first instinct — it is still being written, so
|
|
260
|
+
* a cached answer could hide a segment that just landed. But the timeline polls,
|
|
261
|
+
* and re-reading the live hour on every poll made a query that is normally 65 ms
|
|
262
|
+
* spike to 2,405 ms whenever that read met a contended moment on the array
|
|
263
|
+
* (measured: 65, 65, 65, 2405, 226).
|
|
264
|
+
*
|
|
265
|
+
* Segments land every 8–12 s, so three seconds of staleness at the live edge
|
|
266
|
+
* cannot hide a whole one — and the live view owns "now" regardless. Past hours
|
|
267
|
+
* stay cached indefinitely; only this one expires.
|
|
268
|
+
*/
|
|
269
|
+
var CURRENT_HOUR_TTL_MS = 3e3;
|
|
270
|
+
/**
|
|
271
|
+
* How quiet the disk must be before background work takes another read.
|
|
272
|
+
*
|
|
273
|
+
* Background reads do not block the event loop — they are async I/O — they
|
|
274
|
+
* block the ARRAY: a real request's own read queues behind them. Measured, when
|
|
275
|
+
* a startup warm was still running: a directory query that costs 0.2 s took
|
|
276
|
+
* **38 s**.
|
|
277
|
+
*
|
|
278
|
+
* That startup warm is gone (it bet on what would be asked, and the cache
|
|
279
|
+
* already covers the second query). What remains is `warmAdjacent`, which is a
|
|
280
|
+
* prediction from an ACTION the operator took — and it still yields.
|
|
281
|
+
*/
|
|
282
|
+
var BACKGROUND_QUIET_MS = 300;
|
|
283
|
+
/** How often the warm re-checks whether the foreground has gone quiet. */
|
|
284
|
+
var BACKGROUND_POLL_MS = 100;
|
|
285
|
+
/**
|
|
286
|
+
* Directory reads in flight for one query.
|
|
287
|
+
*
|
|
288
|
+
* A day is 24 independent reads and a month ~32; issued one after another they
|
|
289
|
+
* measured seconds, because each pays the array's full latency. Bounded,
|
|
290
|
+
* because an unbounded fan-out across devices and days becomes exactly the
|
|
291
|
+
* contention it is meant to avoid — that is what the startup warm did.
|
|
292
|
+
*/
|
|
293
|
+
var READ_CONCURRENCY = 8;
|
|
294
|
+
/** `Promise.all` with a ceiling on how many run at once. */
|
|
295
|
+
async function mapBounded(items, limit, fn) {
|
|
296
|
+
const out = new Array(items.length);
|
|
297
|
+
let next = 0;
|
|
298
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
299
|
+
for (;;) {
|
|
300
|
+
const i = next++;
|
|
301
|
+
const item = items[i];
|
|
302
|
+
if (item === void 0) return;
|
|
303
|
+
out[i] = await fn(item);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
await Promise.all(workers);
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
var CalendarIndex = class {
|
|
310
|
+
deps;
|
|
311
|
+
/** `deviceId` → (UTC day ms → hours). Absent = never scanned. */
|
|
312
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
313
|
+
/** Scans in flight, so a burst of queries costs one walk. */
|
|
314
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
315
|
+
/** `deviceId:profile:hourMs` → that hour's segments. The exact rung. */
|
|
316
|
+
hourSegs = /* @__PURE__ */ new Map();
|
|
317
|
+
segsInFlight = /* @__PURE__ */ new Map();
|
|
318
|
+
/** When each CURRENT-hour entry was read, for {@link CURRENT_HOUR_TTL_MS}. */
|
|
319
|
+
segsReadAt = /* @__PURE__ */ new Map();
|
|
320
|
+
/** The profile availability reads from, resolved once per device. */
|
|
321
|
+
profileFor = /* @__PURE__ */ new Map();
|
|
322
|
+
/** `deviceId:profile` → whether the device records that profile, resolved
|
|
323
|
+
* once. Same stability trade as {@link profileFor}: a config change that
|
|
324
|
+
* adds a profile mid-session is picked up on restart, not on a timer. */
|
|
325
|
+
recordsProfile = /* @__PURE__ */ new Map();
|
|
326
|
+
recordsInFlight = /* @__PURE__ */ new Map();
|
|
327
|
+
/** When the last FOREGROUND scan ran, so background work can yield to it. */
|
|
328
|
+
lastForegroundAt = 0;
|
|
329
|
+
profileInFlight = /* @__PURE__ */ new Map();
|
|
330
|
+
constructor(deps) {
|
|
331
|
+
this.deps = deps;
|
|
332
|
+
}
|
|
333
|
+
mapFor(deviceId) {
|
|
334
|
+
let m = this.byDevice.get(deviceId);
|
|
335
|
+
if (!m) {
|
|
336
|
+
m = /* @__PURE__ */ new Map();
|
|
337
|
+
this.byDevice.set(deviceId, m);
|
|
338
|
+
}
|
|
339
|
+
return m;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Hours of one UTC day that hold footage.
|
|
343
|
+
*
|
|
344
|
+
* Cached, EXCEPT the day containing "now": its last hour is still being
|
|
345
|
+
* written, so a cached answer would hide footage that has just appeared.
|
|
346
|
+
*/
|
|
347
|
+
async hoursOfUtcDay(deviceId, utcDayMs, background = false) {
|
|
348
|
+
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
349
|
+
const day = utcDayOf(utcDayMs);
|
|
350
|
+
const isToday = day === utcDayOf(this.deps.now?.() ?? Date.now());
|
|
351
|
+
const cached = this.mapFor(deviceId).get(day);
|
|
352
|
+
if (cached !== void 0 && !isToday) return cached;
|
|
353
|
+
const key = `${deviceId}:${day}`;
|
|
354
|
+
const running = this.inFlight.get(key);
|
|
355
|
+
if (running) return running;
|
|
356
|
+
const scan = this.scanDay(deviceId, day);
|
|
357
|
+
this.inFlight.set(key, scan);
|
|
358
|
+
try {
|
|
359
|
+
const hours = await scan;
|
|
360
|
+
this.mapFor(deviceId).set(day, hours);
|
|
361
|
+
return hours;
|
|
362
|
+
} finally {
|
|
363
|
+
if (this.inFlight.get(key) === scan) this.inFlight.delete(key);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async scanDay(deviceId, utcDayMs) {
|
|
367
|
+
const d = new Date(utcDayMs);
|
|
368
|
+
const scan = this.deps.scanHours ?? hoursWithFootage;
|
|
369
|
+
const profile = await this.availabilityProfileFor(deviceId) ?? void 0;
|
|
370
|
+
const merged = /* @__PURE__ */ new Set();
|
|
371
|
+
for (const root of this.deps.roots()) try {
|
|
372
|
+
const hours = await scan(root, deviceId, d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), profile);
|
|
373
|
+
for (const h of hours) merged.add(h);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
this.deps.logger.warn("calendar: day scan failed", {
|
|
376
|
+
tags: { deviceId },
|
|
377
|
+
meta: {
|
|
378
|
+
root,
|
|
379
|
+
utcDayMs,
|
|
380
|
+
error: err instanceof Error ? err.message : String(err)
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return [...merged].sort((a, b) => a - b);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The operator's LOCAL days in `[fromMs, toMs)` that hold footage.
|
|
388
|
+
*
|
|
389
|
+
* Widened by a UTC day at each end: a local day at either edge is fed by a
|
|
390
|
+
* UTC day outside the range (23:30Z on the 28th is the 29th in CEST).
|
|
391
|
+
*/
|
|
392
|
+
async localDays(deviceId, fromMs, toMs, tzOffsetMinutes) {
|
|
393
|
+
const first = utcDayOf(fromMs) - DAY_MS$3;
|
|
394
|
+
const last = utcDayOf(toMs) + DAY_MS$3;
|
|
395
|
+
const wanted = [];
|
|
396
|
+
for (let day = first; day <= last; day += DAY_MS$3) wanted.push(day);
|
|
397
|
+
const perDay = await mapBounded(wanted, READ_CONCURRENCY, async (day) => ({
|
|
398
|
+
day,
|
|
399
|
+
hours: await this.hoursOfUtcDay(deviceId, day)
|
|
400
|
+
}));
|
|
401
|
+
const days = /* @__PURE__ */ new Set();
|
|
402
|
+
for (const { day, hours } of perDay) for (const h of hours) {
|
|
403
|
+
const at = day + h * HOUR_MS$4;
|
|
404
|
+
if (at < fromMs || at >= toMs) continue;
|
|
405
|
+
days.add(localDayStart(at, tzOffsetMinutes));
|
|
406
|
+
}
|
|
407
|
+
return [...days].sort((a, b) => a - b);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Drop what eviction changed. Retention is the ONLY thing that makes a past
|
|
411
|
+
* day stop holding footage, and it runs in this process — which is why these
|
|
412
|
+
* entries can be cached indefinitely rather than expired on a timer.
|
|
413
|
+
*/
|
|
414
|
+
/** Full reset — a relocation re-pointed index rows across locations, so
|
|
415
|
+
* every cached day/hour derivation may now be stale. Cheap: the caches
|
|
416
|
+
* rebuild lazily on the next question. */
|
|
417
|
+
invalidateAll() {
|
|
418
|
+
this.byDevice.clear();
|
|
419
|
+
this.hourSegs.clear();
|
|
420
|
+
}
|
|
421
|
+
invalidate(deviceId, atMs) {
|
|
422
|
+
const day = utcDayOf(atMs);
|
|
423
|
+
this.byDevice.get(deviceId)?.delete(day);
|
|
424
|
+
const prefix = `${deviceId}:`;
|
|
425
|
+
for (const key of [...this.hourSegs.keys()]) {
|
|
426
|
+
if (!key.startsWith(prefix)) continue;
|
|
427
|
+
if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Scan a span in the background so the first screen is already answered.
|
|
432
|
+
*
|
|
433
|
+
* Startup runs this instead of waiting on the archive-wide walk: ~13 cold
|
|
434
|
+
* directory reads per month against 258,449 files, and it answers the
|
|
435
|
+
* question the operator actually asks first.
|
|
436
|
+
*
|
|
437
|
+
* Also used to warm the days ADJACENT to one being viewed — changing day is a
|
|
438
|
+
* single click and the operator expects the summary to be there. That warm
|
|
439
|
+
* belongs in the background: at ~650 ms per cold read, doing it inline would
|
|
440
|
+
* only move the wait.
|
|
441
|
+
*/
|
|
442
|
+
async warm(deviceId, fromMs, toMs) {
|
|
443
|
+
for (let day = utcDayOf(fromMs); day <= utcDayOf(toMs); day += DAY_MS$3) {
|
|
444
|
+
await this.awaitQuiet();
|
|
445
|
+
await this.hoursOfUtcDay(deviceId, day, true);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Hold background work until the foreground has been quiet.
|
|
450
|
+
*
|
|
451
|
+
* This is the whole point of the background flag: warming must never be the
|
|
452
|
+
* reason an operator's query waits on the array.
|
|
453
|
+
*/
|
|
454
|
+
async awaitQuiet() {
|
|
455
|
+
for (;;) {
|
|
456
|
+
if ((this.deps.now?.() ?? Date.now()) - this.lastForegroundAt >= BACKGROUND_QUIET_MS) return;
|
|
457
|
+
await new Promise((r) => setTimeout(r, BACKGROUND_POLL_MS));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** A day's exact ranges, taken as BACKGROUND work — yields to any query. */
|
|
461
|
+
async warmDayRanges(deviceId, profile, utcDayMs) {
|
|
462
|
+
const day = utcDayOf(utcDayMs);
|
|
463
|
+
for (let h = 0; h < 24; h++) {
|
|
464
|
+
await this.awaitQuiet();
|
|
465
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The segments of one hour, exact, cached.
|
|
470
|
+
*
|
|
471
|
+
* As with days, the CURRENT hour is never cached: it is still being written.
|
|
472
|
+
*/
|
|
473
|
+
async segmentsOf(deviceId, profile, hourStartMs, background = false) {
|
|
474
|
+
return (await this.segmentsOfChecked(deviceId, profile, hourStartMs, background)).segments;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* `segmentsOf`, stating whether the answer is COMPLETE — every root's
|
|
478
|
+
* directory was actually read. A root that throws yields a partial answer,
|
|
479
|
+
* and a partial answer must never be cached (it would serve a root's missing
|
|
480
|
+
* footage as "none" until eviction) nor claimed as looked-at on the wire:
|
|
481
|
+
* the directory route's `hydrated` flag derives from this, so the client
|
|
482
|
+
* retries instead of caching a gap.
|
|
483
|
+
*/
|
|
484
|
+
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
485
|
+
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
486
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
|
|
487
|
+
const key = `${deviceId}:${profile}:${hour}`;
|
|
488
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
489
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
|
|
490
|
+
const cached = this.hourSegs.get(key);
|
|
491
|
+
if (cached !== void 0) {
|
|
492
|
+
if (!isCurrentHour) return {
|
|
493
|
+
segments: cached,
|
|
494
|
+
complete: true
|
|
495
|
+
};
|
|
496
|
+
if (now - (this.segsReadAt.get(key) ?? 0) < CURRENT_HOUR_TTL_MS) return {
|
|
497
|
+
segments: cached,
|
|
498
|
+
complete: true
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const running = this.segsInFlight.get(key);
|
|
502
|
+
if (running) return running;
|
|
503
|
+
const scan = (async () => {
|
|
504
|
+
const read = this.deps.scanSegments ?? segmentsOfHour;
|
|
505
|
+
const merged = [];
|
|
506
|
+
let complete = true;
|
|
507
|
+
for (const root of this.deps.roots()) try {
|
|
508
|
+
merged.push(...await read(root, deviceId, profile, hour));
|
|
509
|
+
} catch (err) {
|
|
510
|
+
complete = false;
|
|
511
|
+
this.deps.logger.warn("calendar: hour scan failed", {
|
|
512
|
+
tags: { deviceId },
|
|
513
|
+
meta: {
|
|
514
|
+
root,
|
|
515
|
+
hour,
|
|
516
|
+
error: err instanceof Error ? err.message : String(err)
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
segments: merged.sort((a, b) => a.startMs - b.startMs),
|
|
522
|
+
complete
|
|
523
|
+
};
|
|
524
|
+
})();
|
|
525
|
+
this.segsInFlight.set(key, scan);
|
|
526
|
+
try {
|
|
527
|
+
const { segments, complete } = await scan;
|
|
528
|
+
if (complete) {
|
|
529
|
+
this.hourSegs.set(key, segments);
|
|
530
|
+
this.segsReadAt.set(key, this.deps.now?.() ?? Date.now());
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
segments,
|
|
534
|
+
complete
|
|
535
|
+
};
|
|
536
|
+
} finally {
|
|
537
|
+
if (this.segsInFlight.get(key) === scan) this.segsInFlight.delete(key);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Exact availability ranges for a UTC day — what the progressive timeline
|
|
542
|
+
* draws.
|
|
543
|
+
*
|
|
544
|
+
* ONE profile. The timeline is a single bar, so per-profile detail buys
|
|
545
|
+
* nothing and triples the reads: measured 75 reads / 16,028 ms against 25
|
|
546
|
+
* reads / 1,489 ms cold, 19 ms warm.
|
|
547
|
+
*
|
|
548
|
+
* Hour granularity cannot stand in for this: a real day holds a 1,011 s gap,
|
|
549
|
+
* and the hour containing it would render solid.
|
|
550
|
+
*/
|
|
551
|
+
async dayRanges(deviceId, profile, utcDayMs) {
|
|
552
|
+
const day = utcDayOf(utcDayMs);
|
|
553
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Warm the days either side of the one being viewed, in the BACKGROUND.
|
|
557
|
+
*
|
|
558
|
+
* Changing day is one click and the operator expects the timeline to be
|
|
559
|
+
* there. At ~1.5 s for a cold day this belongs off the critical path — doing
|
|
560
|
+
* it inline would only move the wait onto the day they are already looking at.
|
|
561
|
+
*/
|
|
562
|
+
warmAdjacent(deviceId, profile, utcDayMs) {
|
|
563
|
+
const day = utcDayOf(utcDayMs);
|
|
564
|
+
for (const d of [day - DAY_MS$3, day + DAY_MS$3]) this.warmDayRanges(deviceId, profile, d).catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* The lowest profile the device actually records to, resolved once.
|
|
568
|
+
*
|
|
569
|
+
* De-duplicated in flight, not merely cached: the day sweep now runs eight
|
|
570
|
+
* reads at a time and they ALL ask for the profile before the first answer
|
|
571
|
+
* lands, so a cache alone let the pick run eight times. Parallelism found it.
|
|
572
|
+
*/
|
|
573
|
+
async availabilityProfileFor(deviceId) {
|
|
574
|
+
const cached = this.profileFor.get(deviceId);
|
|
575
|
+
if (cached !== void 0) return cached;
|
|
576
|
+
const running = this.profileInFlight.get(deviceId);
|
|
577
|
+
if (running) return running;
|
|
578
|
+
const resolve = (async () => {
|
|
579
|
+
for (const root of this.deps.roots()) {
|
|
580
|
+
const pick = await (this.deps.pickProfile ?? availabilityProfile)(root, deviceId);
|
|
581
|
+
if (pick !== null) {
|
|
582
|
+
this.profileFor.set(deviceId, pick);
|
|
583
|
+
return pick;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
})();
|
|
588
|
+
this.profileInFlight.set(deviceId, resolve);
|
|
589
|
+
try {
|
|
590
|
+
return await resolve;
|
|
591
|
+
} finally {
|
|
592
|
+
if (this.profileInFlight.get(deviceId) === resolve) this.profileInFlight.delete(deviceId);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The profile the directory serves for a request: the one ASKED for when the
|
|
597
|
+
* device records it, else the availability pick. The CSD1 payload states
|
|
598
|
+
* which was served, so a fallback is visible to the client, never silent.
|
|
599
|
+
*/
|
|
600
|
+
async servedProfileFor(deviceId, requested) {
|
|
601
|
+
if (requested === void 0 || requested === "") return this.availabilityProfileFor(deviceId);
|
|
602
|
+
const key = `${deviceId}:${requested}`;
|
|
603
|
+
const cached = this.recordsProfile.get(key);
|
|
604
|
+
if (cached !== void 0) return cached ? requested : this.availabilityProfileFor(deviceId);
|
|
605
|
+
const running = this.recordsInFlight.get(key);
|
|
606
|
+
return (running !== void 0 ? await running : await (async () => {
|
|
607
|
+
const probe = (async () => {
|
|
608
|
+
const check = this.deps.hasProfile ?? profileHasFootage;
|
|
609
|
+
for (const root of this.deps.roots()) if (await check(root, deviceId, requested)) return true;
|
|
610
|
+
return false;
|
|
611
|
+
})();
|
|
612
|
+
this.recordsInFlight.set(key, probe);
|
|
613
|
+
try {
|
|
614
|
+
const r = await probe;
|
|
615
|
+
this.recordsProfile.set(key, r);
|
|
616
|
+
return r;
|
|
617
|
+
} finally {
|
|
618
|
+
if (this.recordsInFlight.get(key) === probe) this.recordsInFlight.delete(key);
|
|
619
|
+
}
|
|
620
|
+
})()) ? requested : this.availabilityProfileFor(deviceId);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/recorder/segment-directory.ts
|
|
625
|
+
/**
|
|
626
|
+
* The playback data-plane's client path, shared with the embed so neither side
|
|
627
|
+
* needs a round trip to DISCOVER it: the directory and the segment bytes both
|
|
628
|
+
* live under `<path>/<deviceId>/…`, reverse-proxied by the hub. The recorder
|
|
629
|
+
* addon asserts it serves at exactly this path.
|
|
630
|
+
*/
|
|
631
|
+
var PLAYBACK_PLANE_PATH = "/addon/recorder/playback";
|
|
632
|
+
/** v2 adds the optional per-row mfra table (a zero sample-count = absent).
|
|
633
|
+
* The decoder accepts both; the halves ship together in this package, so no
|
|
634
|
+
* deployed client ever sees a version it cannot read. */
|
|
635
|
+
var VERSION = 2;
|
|
636
|
+
/** LEB128, unsigned. Every value here is a non-negative integer. */
|
|
637
|
+
function writeVarint$1(out, value) {
|
|
638
|
+
let v = Math.max(0, Math.trunc(value));
|
|
639
|
+
while (v >= 128) {
|
|
640
|
+
out.push(v & 127 | 128);
|
|
641
|
+
v = Math.floor(v / 128);
|
|
642
|
+
}
|
|
643
|
+
out.push(v);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Encode a window of segments.
|
|
647
|
+
*
|
|
648
|
+
* `rows` MUST be ascending by `startMs` — the deltas are meaningless otherwise,
|
|
649
|
+
* and a caller that hands over an unsorted list would produce a payload that
|
|
650
|
+
* decodes into plausible nonsense.
|
|
651
|
+
*/
|
|
652
|
+
function encodeDirectory(payload) {
|
|
653
|
+
const out = [];
|
|
654
|
+
out.push(67, 83, 68, 49);
|
|
655
|
+
out.push(VERSION);
|
|
656
|
+
out.push(payload.hydrated ? 1 : 0);
|
|
657
|
+
const profile = new TextEncoder().encode(payload.profile);
|
|
658
|
+
writeVarint$1(out, profile.length);
|
|
659
|
+
for (const b of profile) out.push(b);
|
|
660
|
+
writeVarint$1(out, payload.asOfMs);
|
|
661
|
+
writeVarint$1(out, payload.rows.length);
|
|
662
|
+
let prevStart = 0;
|
|
663
|
+
for (const r of payload.rows) {
|
|
664
|
+
writeVarint$1(out, r.startMs - prevStart);
|
|
665
|
+
writeVarint$1(out, r.durMs);
|
|
666
|
+
writeVarint$1(out, r.bytes);
|
|
667
|
+
prevStart = r.startMs;
|
|
668
|
+
const mfra = r.mfra;
|
|
669
|
+
if (mfra === void 0 || mfra.samples.length === 0) {
|
|
670
|
+
writeVarint$1(out, 0);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
writeVarint$1(out, mfra.samples.length);
|
|
674
|
+
writeVarint$1(out, mfra.trackId);
|
|
675
|
+
writeVarint$1(out, mfra.mfraOffset);
|
|
676
|
+
let prevTicks = 0;
|
|
677
|
+
let prevMoof = 0;
|
|
678
|
+
for (const s of mfra.samples) {
|
|
679
|
+
writeVarint$1(out, s.timeTicks - prevTicks);
|
|
680
|
+
writeVarint$1(out, s.moofOffset - prevMoof);
|
|
681
|
+
prevTicks = s.timeTicks;
|
|
682
|
+
prevMoof = s.moofOffset;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return Uint8Array.from(out);
|
|
686
|
+
}
|
|
687
|
+
//#endregion
|
|
688
|
+
//#region src/recorder/addon/directory-handler.ts
|
|
689
|
+
var HOUR_MS$3 = 36e5;
|
|
690
|
+
/** A window wider than this is refused: it is a client bug, and answering it
|
|
691
|
+
* would walk the archive on the read path — the thing this replaces. */
|
|
692
|
+
var MAX_WINDOW_MS = 32 * 864e5;
|
|
693
|
+
/** `/<deviceId>/directory` — the rest of the prefix belongs to the file handler. */
|
|
694
|
+
var DIRECTORY_PATH = /^\/(\d+)\/directory$/;
|
|
695
|
+
/**
|
|
696
|
+
* Wrap a file handler so the `/directory` path is answered from memory and
|
|
697
|
+
* everything else falls through unchanged.
|
|
698
|
+
*/
|
|
699
|
+
function withDirectoryRoute(deps, next) {
|
|
700
|
+
return async (req, res) => {
|
|
701
|
+
const raw = req.url ?? "/";
|
|
702
|
+
const qs = raw.indexOf("?");
|
|
703
|
+
const pathname = qs === -1 ? raw : raw.slice(0, qs);
|
|
704
|
+
const match = DIRECTORY_PATH.exec(pathname);
|
|
705
|
+
if (!match) {
|
|
706
|
+
await next(req, res);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const deviceId = Number(match[1]);
|
|
710
|
+
const params = new URLSearchParams(qs === -1 ? "" : raw.slice(qs + 1));
|
|
711
|
+
const fromMs = Number(params.get("from"));
|
|
712
|
+
const toMs = Number(params.get("to"));
|
|
713
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) {
|
|
714
|
+
res.statusCode = 400;
|
|
715
|
+
res.end("directory: from/to required");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (toMs - fromMs > MAX_WINDOW_MS) {
|
|
719
|
+
res.statusCode = 400;
|
|
720
|
+
res.end("directory: window too wide");
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const requested = params.get("profile") ?? void 0;
|
|
724
|
+
try {
|
|
725
|
+
const profile = await deps.source.servedProfileFor(deviceId, requested);
|
|
726
|
+
if (profile === null) {
|
|
727
|
+
sendDirectory(res, {
|
|
728
|
+
profile: "",
|
|
729
|
+
hydrated: false,
|
|
730
|
+
asOfMs: 0,
|
|
731
|
+
rows: []
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const rows = [];
|
|
736
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
|
|
737
|
+
let hydrated = true;
|
|
738
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
|
|
739
|
+
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
740
|
+
if (!complete) hydrated = false;
|
|
741
|
+
for (const s of segments) {
|
|
742
|
+
if (s.startMs + s.durMs <= fromMs || s.startMs >= toMs) continue;
|
|
743
|
+
const mfra = deps.mfraFor?.(deviceId, profile, s.startMs);
|
|
744
|
+
rows.push({
|
|
745
|
+
startMs: s.startMs,
|
|
746
|
+
durMs: s.durMs,
|
|
747
|
+
bytes: s.bytes,
|
|
748
|
+
...mfra ? { mfra } : {}
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
rows.sort((a, b) => a.startMs - b.startMs);
|
|
753
|
+
sendDirectory(res, {
|
|
754
|
+
profile,
|
|
755
|
+
hydrated,
|
|
756
|
+
asOfMs: deps.now?.() ?? Date.now(),
|
|
757
|
+
rows
|
|
758
|
+
});
|
|
759
|
+
deps.onLog?.("directory:served", {
|
|
760
|
+
deviceId,
|
|
761
|
+
profile,
|
|
762
|
+
requested: requested ?? null,
|
|
763
|
+
rows: rows.length,
|
|
764
|
+
hydrated
|
|
765
|
+
});
|
|
766
|
+
} catch (err) {
|
|
767
|
+
deps.onLog?.("directory:failed", {
|
|
768
|
+
deviceId,
|
|
769
|
+
error: err instanceof Error ? err.message : String(err)
|
|
770
|
+
});
|
|
771
|
+
res.statusCode = 500;
|
|
772
|
+
res.end("directory: failed");
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function sendDirectory(res, payload) {
|
|
777
|
+
const body = encodeDirectory(payload);
|
|
778
|
+
res.statusCode = 200;
|
|
779
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
780
|
+
res.setHeader("content-length", String(body.length));
|
|
781
|
+
res.setHeader("content-encoding", "identity");
|
|
782
|
+
res.setHeader("cache-control", "private, max-age=2");
|
|
783
|
+
res.end(Buffer.from(body));
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/recorder/init-info.ts
|
|
787
|
+
/** Walk sibling boxes in [from, to), yielding [type, start, end]. */
|
|
788
|
+
function* boxes$1(buf, view, from, to) {
|
|
789
|
+
let p = from;
|
|
790
|
+
while (p + 8 <= to) {
|
|
791
|
+
const size = view.getUint32(p);
|
|
792
|
+
if (size < 8) return;
|
|
793
|
+
const type = String.fromCharCode(buf[p + 4], buf[p + 5], buf[p + 6], buf[p + 7]);
|
|
794
|
+
const end = p + size;
|
|
795
|
+
if (end > to) return;
|
|
796
|
+
yield [
|
|
797
|
+
type,
|
|
798
|
+
p,
|
|
799
|
+
end
|
|
800
|
+
];
|
|
801
|
+
p = end;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/** FullBox version byte sits right after the 8-byte box header. */
|
|
805
|
+
function versionOf(buf, boxStart) {
|
|
806
|
+
return buf[boxStart + 8] ?? 0;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Parse `moov` for the video track's id and timescale.
|
|
810
|
+
*
|
|
811
|
+
* Returns null when the bytes hold no usable `moov` — a truncated read, or a
|
|
812
|
+
* response that was not the segment head at all. The caller's answer to null is
|
|
813
|
+
* to fall back to fetching the whole segment, which is slow but correct; a
|
|
814
|
+
* guessed timescale would be fast and wrong.
|
|
815
|
+
*/
|
|
816
|
+
function parseInitInfo(bytes) {
|
|
817
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
818
|
+
for (const [type, start, end] of boxes$1(bytes, view, 0, bytes.length)) {
|
|
819
|
+
if (type !== "moov") continue;
|
|
820
|
+
for (const [t2, s2, e2] of boxes$1(bytes, view, start + 8, end)) {
|
|
821
|
+
if (t2 !== "trak") continue;
|
|
822
|
+
const found = readTrak(bytes, view, s2, e2);
|
|
823
|
+
if (found) return {
|
|
824
|
+
...found,
|
|
825
|
+
initEnd: end
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
/** A single `trak`: its id, its timescale, and whether it is the video one. */
|
|
832
|
+
function readTrak(bytes, view, start, end) {
|
|
833
|
+
let trackId = null;
|
|
834
|
+
let timescale = null;
|
|
835
|
+
let isVideo = false;
|
|
836
|
+
for (const [t3, s3, e3] of boxes$1(bytes, view, start + 8, end)) if (t3 === "tkhd") trackId = view.getUint32(s3 + 12 + (versionOf(bytes, s3) === 1 ? 16 : 8));
|
|
837
|
+
else if (t3 === "mdia") {
|
|
838
|
+
for (const [t4, s4, e4] of boxes$1(bytes, view, s3 + 8, e3)) if (t4 === "mdhd") timescale = view.getUint32(s4 + 12 + (versionOf(bytes, s4) === 1 ? 16 : 8));
|
|
839
|
+
else if (t4 === "hdlr") {
|
|
840
|
+
const h = s4 + 16;
|
|
841
|
+
if (h + 4 <= e4) isVideo = String.fromCharCode(bytes[h], bytes[h + 1], bytes[h + 2], bytes[h + 3]) === "vide";
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (!isVideo || trackId === null || timescale === null || timescale <= 0) return null;
|
|
845
|
+
return {
|
|
846
|
+
videoTrackId: trackId,
|
|
847
|
+
videoTimescale: timescale
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/recorder/mfra-index.ts
|
|
852
|
+
/** Thrown when a tail does not contain a usable index. */
|
|
853
|
+
var MfraParseError = class extends Error {
|
|
854
|
+
needBytes;
|
|
855
|
+
constructor(message, needBytes) {
|
|
856
|
+
super(message);
|
|
857
|
+
this.needBytes = needBytes;
|
|
858
|
+
this.name = "MfraParseError";
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
var MFRO_BOX_SIZE = 16;
|
|
862
|
+
function asDataView(tail) {
|
|
863
|
+
return new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
|
|
864
|
+
}
|
|
865
|
+
/** Index of the last occurrence of a 4-char box type in `buf`. */
|
|
866
|
+
function lastIndexOfType(buf, type) {
|
|
867
|
+
const a = type.charCodeAt(0);
|
|
868
|
+
const b = type.charCodeAt(1);
|
|
869
|
+
const c = type.charCodeAt(2);
|
|
870
|
+
const d = type.charCodeAt(3);
|
|
871
|
+
for (let i = buf.length - 4; i >= 0; i--) if (buf[i] === a && buf[i + 1] === b && buf[i + 2] === c && buf[i + 3] === d) return i;
|
|
872
|
+
return -1;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* How many tail bytes to request on the first try. Comfortably covers the
|
|
876
|
+
* observed index sizes (a handful of sync samples) without a second round trip;
|
|
877
|
+
* `MfraParseError.needBytes` asks for more on the rare segment that exceeds it.
|
|
878
|
+
*/
|
|
879
|
+
var DEFAULT_TAIL_BYTES = 2048;
|
|
880
|
+
/**
|
|
881
|
+
* Position of the `tfra` for `trackId` (or the first one when unspecified).
|
|
882
|
+
*
|
|
883
|
+
* Walks the `mfra`'s children properly rather than scanning for the ASCII
|
|
884
|
+
* `tfra`: a byte scan cannot tell one table from the next, and picking "the
|
|
885
|
+
* first match" is what made track selection accidental.
|
|
886
|
+
*/
|
|
887
|
+
function findTfra(mfra, mv, trackId) {
|
|
888
|
+
let p = 8;
|
|
889
|
+
while (p + 8 <= mfra.length) {
|
|
890
|
+
const size = mv.getUint32(p);
|
|
891
|
+
if (size < 8 || p + size > mfra.length) break;
|
|
892
|
+
if (String.fromCharCode(mfra[p + 4], mfra[p + 5], mfra[p + 6], mfra[p + 7]) === "tfra") {
|
|
893
|
+
if (trackId === void 0 || mv.getUint32(p + 12) === trackId) return p + 4;
|
|
894
|
+
}
|
|
895
|
+
p += size;
|
|
896
|
+
}
|
|
897
|
+
return -1;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Parse a segment's trailing bytes into its sync-sample table.
|
|
901
|
+
*
|
|
902
|
+
* @param tail the LAST `tail.length` bytes of the segment
|
|
903
|
+
* @param fileSize the segment's total size, needed to turn positions inside
|
|
904
|
+
* the tail into absolute file offsets
|
|
905
|
+
*/
|
|
906
|
+
function parseMfra(tail, fileSize, opts = {}) {
|
|
907
|
+
if (tail.length < MFRO_BOX_SIZE) throw new MfraParseError("tail too short to contain mfro", DEFAULT_TAIL_BYTES);
|
|
908
|
+
const view = asDataView(tail);
|
|
909
|
+
const mfroPos = lastIndexOfType(tail, "mfro");
|
|
910
|
+
if (mfroPos < 0) throw new MfraParseError("no mfro box in tail");
|
|
911
|
+
const mfraSize = view.getUint32(mfroPos + 8);
|
|
912
|
+
if (mfraSize <= 0 || mfraSize > fileSize) throw new MfraParseError(`implausible mfra size ${mfraSize}`);
|
|
913
|
+
if (mfraSize > tail.length) throw new MfraParseError(`mfra spans ${mfraSize} bytes, tail has ${tail.length}`, mfraSize);
|
|
914
|
+
const mfraStart = tail.length - mfraSize;
|
|
915
|
+
const mfra = tail.subarray(mfraStart);
|
|
916
|
+
const mfraOffset = fileSize - mfraSize;
|
|
917
|
+
const mv = asDataView(mfra);
|
|
918
|
+
const tfraPos = findTfra(mfra, mv, opts.trackId);
|
|
919
|
+
if (tfraPos < 0) throw new MfraParseError(opts.trackId === void 0 ? "no tfra box inside mfra" : `no tfra box for track ${opts.trackId}`);
|
|
920
|
+
let p = tfraPos + 4;
|
|
921
|
+
const version = mfra[p] ?? 0;
|
|
922
|
+
p += 4;
|
|
923
|
+
const trackId = mv.getUint32(p);
|
|
924
|
+
p += 4;
|
|
925
|
+
const sizes = mv.getUint32(p);
|
|
926
|
+
p += 4;
|
|
927
|
+
const lenTraf = (sizes >> 4 & 3) + 1;
|
|
928
|
+
const lenTrun = (sizes >> 2 & 3) + 1;
|
|
929
|
+
const lenSample = (sizes & 3) + 1;
|
|
930
|
+
const count = mv.getUint32(p);
|
|
931
|
+
p += 4;
|
|
932
|
+
const samples = [];
|
|
933
|
+
for (let i = 0; i < count; i++) {
|
|
934
|
+
let timeTicks;
|
|
935
|
+
let moofOffset;
|
|
936
|
+
if (version === 1) {
|
|
937
|
+
timeTicks = Number(mv.getBigUint64(p));
|
|
938
|
+
p += 8;
|
|
939
|
+
moofOffset = Number(mv.getBigUint64(p));
|
|
940
|
+
p += 8;
|
|
941
|
+
} else {
|
|
942
|
+
timeTicks = mv.getUint32(p);
|
|
943
|
+
p += 4;
|
|
944
|
+
moofOffset = mv.getUint32(p);
|
|
945
|
+
p += 4;
|
|
946
|
+
}
|
|
947
|
+
p += lenTraf + lenTrun + lenSample;
|
|
948
|
+
if (p > mfra.length) throw new MfraParseError(`tfra declares ${count} entries but the box ends early`);
|
|
949
|
+
samples.push({
|
|
950
|
+
timeTicks,
|
|
951
|
+
moofOffset
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
if (samples.length === 0) throw new MfraParseError("tfra has no entries");
|
|
955
|
+
return {
|
|
956
|
+
samples,
|
|
957
|
+
mfraOffset,
|
|
958
|
+
trackId
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/recorder/mfra-capture.ts
|
|
963
|
+
/**
|
|
964
|
+
* Capture a segment's sync-sample table at FINALIZE, while the file is still
|
|
965
|
+
* in page cache, and keep it in RAM for the directory to serve.
|
|
966
|
+
*
|
|
967
|
+
* WHY AT FINALIZE
|
|
968
|
+
* ───────────────
|
|
969
|
+
* The tables are tiny (2–4 sync samples), but they are NOT in RAM: serving
|
|
970
|
+
* them lazily from the read path would cost ~350 tail reads per hour per
|
|
971
|
+
* camera on a contended array. At finalize the segment was written
|
|
972
|
+
* milliseconds ago — both reads (a 4 KB head for the video track id, a 2 KB
|
|
973
|
+
* tail for the `tfra`) come from page cache and cost microseconds.
|
|
974
|
+
*
|
|
975
|
+
* Rows finalized before this process booted have no table; the client falls
|
|
976
|
+
* back to fetching the tail itself — same mechanism, one extra round trip.
|
|
977
|
+
*/
|
|
978
|
+
/** Bytes of the head that reliably contain `ftyp` + `moov` (mirrors the client). */
|
|
979
|
+
var HEAD_BYTES = 4096;
|
|
980
|
+
/**
|
|
981
|
+
* Read `absPath`'s video sync-sample table. `fileBytes` is the segment's total
|
|
982
|
+
* size (already stat-ed by the finalize path). Throws on a segment without a
|
|
983
|
+
* parsable index — the caller records nothing and the client falls back.
|
|
984
|
+
*/
|
|
985
|
+
async function readMfraTable(absPath, fileBytes) {
|
|
986
|
+
const fh = await (0, node_fs_promises.open)(absPath, "r");
|
|
987
|
+
try {
|
|
988
|
+
const head = new Uint8Array(Math.min(HEAD_BYTES, fileBytes));
|
|
989
|
+
await fh.read(head, 0, head.length, 0);
|
|
990
|
+
const info = parseInitInfo(head);
|
|
991
|
+
let tailLen = Math.min(DEFAULT_TAIL_BYTES, fileBytes);
|
|
992
|
+
for (let attempt = 0;; attempt++) {
|
|
993
|
+
const tail = new Uint8Array(tailLen);
|
|
994
|
+
await fh.read(tail, 0, tailLen, fileBytes - tailLen);
|
|
995
|
+
try {
|
|
996
|
+
const idx = parseMfra(tail, fileBytes, info === null ? {} : { trackId: info.videoTrackId });
|
|
997
|
+
return {
|
|
998
|
+
table: {
|
|
999
|
+
trackId: idx.trackId,
|
|
1000
|
+
mfraOffset: idx.mfraOffset,
|
|
1001
|
+
samples: idx.samples
|
|
1002
|
+
},
|
|
1003
|
+
videoTimescale: info?.videoTimescale ?? null
|
|
1004
|
+
};
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
if (err instanceof MfraParseError && err.needBytes && attempt === 0) {
|
|
1007
|
+
tailLen = Math.min(err.needBytes + 64, fileBytes);
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
throw err;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
} finally {
|
|
1014
|
+
await fh.close();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Wall-clock instants for a segment's keyframes: `-skip_frame nokey` emits
|
|
1019
|
+
* them in decode order, so frame i IS sync sample i. Without a timescale the
|
|
1020
|
+
* samples are spread evenly — a degraded position beats a missing thumbnail.
|
|
1021
|
+
*/
|
|
1022
|
+
function keyframeInstants(startMs, durMs, captured) {
|
|
1023
|
+
const { samples } = captured.table;
|
|
1024
|
+
const first = samples[0]?.timeTicks ?? 0;
|
|
1025
|
+
const ts = captured.videoTimescale;
|
|
1026
|
+
return samples.map((s, i) => {
|
|
1027
|
+
if (ts !== null && ts > 0) return Math.round(startMs + (s.timeTicks - first) / ts * 1e3);
|
|
1028
|
+
return Math.round(startMs + i / samples.length * durMs);
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
/** How long a table stays served. Directory asks are for recent footage far
|
|
1032
|
+
* more often than not, and a bounded window keeps this store O(day), not
|
|
1033
|
+
* O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
|
|
1034
|
+
var RETAIN_MS = 26 * 36e5;
|
|
1035
|
+
/**
|
|
1036
|
+
* The RAM store the directory reads. Keys are `(deviceId, profile, startMs)`
|
|
1037
|
+
* — exactly how a directory row identifies a segment. Insertion is
|
|
1038
|
+
* time-ordered (segments finalize as they are written), so pruning walks the
|
|
1039
|
+
* oldest entries only.
|
|
1040
|
+
*/
|
|
1041
|
+
var MfraTableStore = class {
|
|
1042
|
+
now;
|
|
1043
|
+
tables = /* @__PURE__ */ new Map();
|
|
1044
|
+
constructor(now = () => Date.now()) {
|
|
1045
|
+
this.now = now;
|
|
1046
|
+
}
|
|
1047
|
+
key(deviceId, profile, startMs) {
|
|
1048
|
+
return `${deviceId}:${profile}:${startMs}`;
|
|
1049
|
+
}
|
|
1050
|
+
record(deviceId, profile, startMs, table) {
|
|
1051
|
+
this.tables.set(this.key(deviceId, profile, startMs), {
|
|
1052
|
+
startMs,
|
|
1053
|
+
table
|
|
1054
|
+
});
|
|
1055
|
+
this.prune();
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The table for one directory row, or undefined (pre-boot segment / capture
|
|
1059
|
+
* failed / pruned). Eviction needs no hook here: a table only ever ATTACHES
|
|
1060
|
+
* to a row the directory scan actually found on disk, so a deleted segment's
|
|
1061
|
+
* entry is dead weight the TTL reclaims, never a wrong answer.
|
|
1062
|
+
*/
|
|
1063
|
+
get(deviceId, profile, startMs) {
|
|
1064
|
+
return this.tables.get(this.key(deviceId, profile, startMs))?.table;
|
|
1065
|
+
}
|
|
1066
|
+
get size() {
|
|
1067
|
+
return this.tables.size;
|
|
1068
|
+
}
|
|
1069
|
+
/** Insertion order is time order, so the expired prefix is contiguous. */
|
|
1070
|
+
prune() {
|
|
1071
|
+
const cutoff = this.now() - RETAIN_MS;
|
|
1072
|
+
for (const [key, entry] of this.tables) {
|
|
1073
|
+
if (entry.startMs >= cutoff) break;
|
|
1074
|
+
this.tables.delete(key);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/recorder/strip-format.ts
|
|
1080
|
+
/**
|
|
1081
|
+
* The thumbnail STRIP — the fast-drag depth of the scrub ladder.
|
|
1082
|
+
*
|
|
1083
|
+
* One append-only file per camera-hour holds every keyframe of the LOW
|
|
1084
|
+
* recording as a JPEG at the low profile's NATIVE resolution. A fast drag
|
|
1085
|
+
* crosses an hour in seconds and wants ~20 scattered stills; without the strip
|
|
1086
|
+
* that is 20 keyframe reads in 20 different segments under latest-wins —
|
|
1087
|
+
* structurally incapable of painting a flow. With it, the positions a drag
|
|
1088
|
+
* crosses are a handful of ~25–40 KB ranged reads from ONE file, painted at
|
|
1089
|
+
* the capped cadence.
|
|
1090
|
+
*
|
|
1091
|
+
* TWO wire shapes live here, both halves in one module so they cannot drift:
|
|
1092
|
+
*
|
|
1093
|
+
* - the STRIP FILE (`CSS1`): [magic][version] then records of
|
|
1094
|
+
* [u32 jpegLen][u64 tsMs][jpeg]. Self-describing, so the index is
|
|
1095
|
+
* derivable from the file alone after a restart.
|
|
1096
|
+
* - the STRIP INDEX (`CSI1`): the varint answer to "what instants does this
|
|
1097
|
+
* hour hold, and which bytes paint each one" — served from RAM, consumed by
|
|
1098
|
+
* the client to turn a cursor position into one ranged read.
|
|
1099
|
+
*/
|
|
1100
|
+
/** 'CSS1' — camstack strip, v1. */
|
|
1101
|
+
var FILE_MAGIC = new Uint8Array([
|
|
1102
|
+
67,
|
|
1103
|
+
83,
|
|
1104
|
+
83,
|
|
1105
|
+
49
|
|
1106
|
+
]);
|
|
1107
|
+
var FILE_VERSION = 1;
|
|
1108
|
+
var STRIP_FILE_HEADER_BYTES = FILE_MAGIC.length + 1;
|
|
1109
|
+
function stripFileHeader() {
|
|
1110
|
+
const out = new Uint8Array(STRIP_FILE_HEADER_BYTES);
|
|
1111
|
+
out.set(FILE_MAGIC, 0);
|
|
1112
|
+
out[FILE_MAGIC.length] = FILE_VERSION;
|
|
1113
|
+
return out;
|
|
1114
|
+
}
|
|
1115
|
+
function encodeStripRecord(tsMs, jpeg) {
|
|
1116
|
+
const out = new Uint8Array(12 + jpeg.length);
|
|
1117
|
+
const view = new DataView(out.buffer);
|
|
1118
|
+
view.setUint32(0, jpeg.length);
|
|
1119
|
+
view.setBigUint64(4, BigInt(Math.trunc(tsMs)));
|
|
1120
|
+
out.set(jpeg, 12);
|
|
1121
|
+
return out;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Walk a strip file's bytes into its entry list. Tolerates a TRUNCATED final
|
|
1125
|
+
* record (a crash mid-append) by stopping at it — everything before is intact
|
|
1126
|
+
* because records are append-only.
|
|
1127
|
+
*/
|
|
1128
|
+
function parseStripEntries(bytes) {
|
|
1129
|
+
if (bytes.length < STRIP_FILE_HEADER_BYTES) return [];
|
|
1130
|
+
for (let i = 0; i < FILE_MAGIC.length; i++) if (bytes[i] !== FILE_MAGIC[i]) throw new Error("CSS1: bad magic");
|
|
1131
|
+
if (bytes[FILE_MAGIC.length] !== FILE_VERSION) throw new Error(`CSS1: unsupported version ${String(bytes[FILE_MAGIC.length])}`);
|
|
1132
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1133
|
+
const entries = [];
|
|
1134
|
+
let p = STRIP_FILE_HEADER_BYTES;
|
|
1135
|
+
while (p + 12 <= bytes.length) {
|
|
1136
|
+
const len = view.getUint32(p);
|
|
1137
|
+
const tsMs = Number(view.getBigUint64(p + 4));
|
|
1138
|
+
const offset = p + 12;
|
|
1139
|
+
if (offset + len > bytes.length) break;
|
|
1140
|
+
entries.push({
|
|
1141
|
+
tsMs,
|
|
1142
|
+
offset,
|
|
1143
|
+
bytes: len
|
|
1144
|
+
});
|
|
1145
|
+
p = offset + len;
|
|
1146
|
+
}
|
|
1147
|
+
return entries;
|
|
1148
|
+
}
|
|
1149
|
+
var INDEX_VERSION = 1;
|
|
1150
|
+
function writeVarint(out, value) {
|
|
1151
|
+
let v = Math.max(0, Math.trunc(value));
|
|
1152
|
+
while (v >= 128) {
|
|
1153
|
+
out.push(v & 127 | 128);
|
|
1154
|
+
v = Math.floor(v / 128);
|
|
1155
|
+
}
|
|
1156
|
+
out.push(v);
|
|
1157
|
+
}
|
|
1158
|
+
/** Entries MUST be ascending by tsMs and offset — append order guarantees it. */
|
|
1159
|
+
function encodeStripIndex(entries) {
|
|
1160
|
+
const out = [];
|
|
1161
|
+
out.push(67, 83, 73, 49);
|
|
1162
|
+
out.push(INDEX_VERSION);
|
|
1163
|
+
writeVarint(out, entries.length);
|
|
1164
|
+
let prevTs = 0;
|
|
1165
|
+
let prevOffset = 0;
|
|
1166
|
+
for (const e of entries) {
|
|
1167
|
+
writeVarint(out, e.tsMs - prevTs);
|
|
1168
|
+
writeVarint(out, e.offset - prevOffset);
|
|
1169
|
+
writeVarint(out, e.bytes);
|
|
1170
|
+
prevTs = e.tsMs;
|
|
1171
|
+
prevOffset = e.offset;
|
|
1172
|
+
}
|
|
1173
|
+
return Uint8Array.from(out);
|
|
1174
|
+
}
|
|
1175
|
+
//#endregion
|
|
1176
|
+
//#region src/recorder/strip-store.ts
|
|
1177
|
+
/**
|
|
1178
|
+
* The strip files on disk: append at finalize, index on demand.
|
|
1179
|
+
*
|
|
1180
|
+
* Layout: `<location root>/<deviceId>/strips/YYYY/MM/DD/HH.strip` — rooted
|
|
1181
|
+
* under the CAMERA directory like every other per-camera artifact (segments,
|
|
1182
|
+
* event media). The profile walkers (`profilesOf`, `hydrateWindowFromStorage`)
|
|
1183
|
+
* whitelist the canonical profile names, so `strips/` (and `events/`) sitting
|
|
1184
|
+
* next to `high|mid|low/` is never offered as a playable profile.
|
|
1185
|
+
*
|
|
1186
|
+
* Appends are serialized per file (a per-key promise chain): two segments of
|
|
1187
|
+
* the same hour finalizing close together must not interleave their records.
|
|
1188
|
+
*/
|
|
1189
|
+
var HOUR_MS$2 = 36e5;
|
|
1190
|
+
/** Location-relative path of one camera-hour's strip. */
|
|
1191
|
+
function stripRelPath(deviceId, hourStartMs) {
|
|
1192
|
+
const d = /* @__PURE__ */ new Date(Math.floor(hourStartMs / HOUR_MS$2) * HOUR_MS$2);
|
|
1193
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
1194
|
+
return node_path.default.join(String(deviceId), "strips", String(d.getUTCFullYear()), p2(d.getUTCMonth() + 1), p2(d.getUTCDate()), `${p2(d.getUTCHours())}.strip`);
|
|
1195
|
+
}
|
|
1196
|
+
var StripStore = class {
|
|
1197
|
+
rootFor;
|
|
1198
|
+
/** Per-file append chain — see the header comment. */
|
|
1199
|
+
appending = /* @__PURE__ */ new Map();
|
|
1200
|
+
/** Parsed entries per file, invalidated when the SIZE moves (append-only
|
|
1201
|
+
* files: same size ⟹ same content). */
|
|
1202
|
+
indexCache = /* @__PURE__ */ new Map();
|
|
1203
|
+
constructor(rootFor) {
|
|
1204
|
+
this.rootFor = rootFor;
|
|
1205
|
+
}
|
|
1206
|
+
/** Absolute path for a camera-hour, or null before locations resolve. */
|
|
1207
|
+
absPathFor(deviceId, hourStartMs) {
|
|
1208
|
+
const root = this.rootFor();
|
|
1209
|
+
if (root === null) return null;
|
|
1210
|
+
return node_path.default.join(root, stripRelPath(deviceId, hourStartMs));
|
|
1211
|
+
}
|
|
1212
|
+
/** Append thumbnails (one segment's keyframes — same hour by construction). */
|
|
1213
|
+
async append(deviceId, thumbs) {
|
|
1214
|
+
const first = thumbs[0];
|
|
1215
|
+
if (first === void 0) return;
|
|
1216
|
+
const abs = this.absPathFor(deviceId, first.tsMs);
|
|
1217
|
+
if (abs === null) return;
|
|
1218
|
+
const run = (this.appending.get(abs) ?? Promise.resolve()).then(async () => {
|
|
1219
|
+
await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
|
|
1220
|
+
let created = false;
|
|
1221
|
+
try {
|
|
1222
|
+
await node_fs.promises.access(abs);
|
|
1223
|
+
} catch {
|
|
1224
|
+
created = true;
|
|
1225
|
+
}
|
|
1226
|
+
const parts = created ? [stripFileHeader()] : [];
|
|
1227
|
+
for (const t of thumbs) parts.push(encodeStripRecord(t.tsMs, t.jpeg));
|
|
1228
|
+
await node_fs.promises.appendFile(abs, Buffer.concat(parts.map((p) => Buffer.from(p))));
|
|
1229
|
+
this.indexCache.delete(abs);
|
|
1230
|
+
});
|
|
1231
|
+
this.appending.set(abs, run.catch(() => {}));
|
|
1232
|
+
try {
|
|
1233
|
+
await run;
|
|
1234
|
+
} finally {
|
|
1235
|
+
if (this.appending.get(abs) === run) this.appending.delete(abs);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The entry list for a camera-hour, or null when the hour has no strip.
|
|
1240
|
+
* Cached by file size — append-only files cannot change under a same-size
|
|
1241
|
+
* cache.
|
|
1242
|
+
*/
|
|
1243
|
+
async index(deviceId, hourStartMs) {
|
|
1244
|
+
const abs = this.absPathFor(deviceId, hourStartMs);
|
|
1245
|
+
if (abs === null) return null;
|
|
1246
|
+
let size;
|
|
1247
|
+
try {
|
|
1248
|
+
size = (await node_fs.promises.stat(abs)).size;
|
|
1249
|
+
} catch {
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
const cached = this.indexCache.get(abs);
|
|
1253
|
+
if (cached && cached.size === size) return cached.entries;
|
|
1254
|
+
const entries = parseStripEntries(new Uint8Array(await node_fs.promises.readFile(abs)));
|
|
1255
|
+
this.indexCache.set(abs, {
|
|
1256
|
+
size,
|
|
1257
|
+
entries
|
|
1258
|
+
});
|
|
1259
|
+
return entries;
|
|
1260
|
+
}
|
|
1261
|
+
/** A regeneration clears the device's whole strip tree first. */
|
|
1262
|
+
async removeDevice(deviceId) {
|
|
1263
|
+
const root = this.rootFor();
|
|
1264
|
+
if (root === null) return;
|
|
1265
|
+
for (const key of [...this.indexCache.keys()]) if (key.includes(node_path.default.join(String(deviceId), "strips") + node_path.default.sep)) this.indexCache.delete(key);
|
|
1266
|
+
await node_fs.promises.rm(node_path.default.join(root, String(deviceId), "strips"), {
|
|
1267
|
+
recursive: true,
|
|
1268
|
+
force: true
|
|
1269
|
+
}).catch(() => {});
|
|
1270
|
+
}
|
|
1271
|
+
/** Eviction removed an hour's footage — its strip goes with it. */
|
|
1272
|
+
async removeHour(deviceId, hourStartMs) {
|
|
1273
|
+
const abs = this.absPathFor(deviceId, hourStartMs);
|
|
1274
|
+
if (abs === null) return;
|
|
1275
|
+
this.indexCache.delete(abs);
|
|
1276
|
+
await node_fs.promises.rm(abs, { force: true }).catch(() => {});
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/recorder/strip-capture.ts
|
|
1281
|
+
/**
|
|
1282
|
+
* Keyframe thumbnails from a JUST-FINALIZED low segment — one short-lived
|
|
1283
|
+
* ffmpeg per segment, `-skip_frame nokey`, so only the 2–4 keyframes are ever
|
|
1284
|
+
* decoded (~15 ms of CPU on a 360p segment still sitting in page cache).
|
|
1285
|
+
*
|
|
1286
|
+
* WHY NOT A SECOND OUTPUT ON THE RECORDER'S FFMPEG
|
|
1287
|
+
* ────────────────────────────────────────────────
|
|
1288
|
+
* The write path is the one process that must never be destabilised, and an
|
|
1289
|
+
* image pipe has no per-frame PTS — pairing frames to instants would need NUT
|
|
1290
|
+
* parsing or stderr scraping. At finalize the mfra capture has ALREADY read
|
|
1291
|
+
* the sync-sample table: `-skip_frame nokey` emits keyframes in decode order,
|
|
1292
|
+
* so frame i IS sample i and the timestamps come for free. A failed capture
|
|
1293
|
+
* costs one hour's drag a thumbnail, nothing else.
|
|
1294
|
+
*
|
|
1295
|
+
* Bounded: at most `MAX_CONCURRENT` ffmpegs, and a backlog beyond
|
|
1296
|
+
* `MAX_QUEUED` drops the OLDEST waiting job — the strip is a best-effort
|
|
1297
|
+
* accelerator, and a stampede of finalizes (20 cameras, one segment cadence)
|
|
1298
|
+
* must never queue decode work behind the array.
|
|
1299
|
+
*/
|
|
1300
|
+
var MAX_CONCURRENT = 2;
|
|
1301
|
+
var MAX_QUEUED = 32;
|
|
1302
|
+
/** A one-shot decode of 2–4 keyframes must never wedge a slot. */
|
|
1303
|
+
var TIMEOUT_MS = 15e3;
|
|
1304
|
+
/** Split an MJPEG byte stream into its JPEGs on SOI markers (FFD8 FFE0/DB…).
|
|
1305
|
+
* ffmpeg's image2pipe emits baseline JPEGs back to back; scanning for the
|
|
1306
|
+
* next SOI right after an EOI is unambiguous for its output. */
|
|
1307
|
+
function splitJpegs(stream) {
|
|
1308
|
+
const out = [];
|
|
1309
|
+
let start = -1;
|
|
1310
|
+
for (let i = 0; i + 1 < stream.length; i++) if (stream[i] === 255 && stream[i + 1] === 216 && (start === -1 || wasEoi(stream, i))) {
|
|
1311
|
+
if (start !== -1) out.push(stream.subarray(start, i));
|
|
1312
|
+
start = i;
|
|
1313
|
+
}
|
|
1314
|
+
if (start !== -1) out.push(stream.subarray(start));
|
|
1315
|
+
return out;
|
|
1316
|
+
}
|
|
1317
|
+
/** Whether the two bytes immediately before `i` are an EOI — the only place a
|
|
1318
|
+
* new SOI can legitimately begin inside a concatenated stream. */
|
|
1319
|
+
function wasEoi(stream, i) {
|
|
1320
|
+
return i >= 2 && stream[i - 2] === 255 && stream[i - 1] === 217;
|
|
1321
|
+
}
|
|
1322
|
+
var StripCapture = class {
|
|
1323
|
+
deps;
|
|
1324
|
+
queue = [];
|
|
1325
|
+
running = 0;
|
|
1326
|
+
constructor(deps = {}) {
|
|
1327
|
+
this.deps = deps;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* The segment's keyframe JPEGs in decode order, or null when the capture was
|
|
1331
|
+
* dropped (backlog) or failed. Never throws — the strip is best-effort.
|
|
1332
|
+
*/
|
|
1333
|
+
capture(absPath) {
|
|
1334
|
+
return new Promise((resolve) => {
|
|
1335
|
+
this.queue.push({
|
|
1336
|
+
absPath,
|
|
1337
|
+
resolve
|
|
1338
|
+
});
|
|
1339
|
+
while (this.queue.length > MAX_QUEUED) {
|
|
1340
|
+
const dropped = this.queue.shift();
|
|
1341
|
+
dropped?.resolve(null);
|
|
1342
|
+
this.deps.onLog?.("strip:dropped", { path: dropped?.absPath ?? "" });
|
|
1343
|
+
}
|
|
1344
|
+
this.pump();
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
pump() {
|
|
1348
|
+
while (this.running < MAX_CONCURRENT) {
|
|
1349
|
+
const job = this.queue.shift();
|
|
1350
|
+
if (job === void 0) return;
|
|
1351
|
+
this.running++;
|
|
1352
|
+
(this.deps.runFfmpeg ?? this.runFfmpeg)(job.absPath).then((stream) => job.resolve(splitJpegs(stream))).catch((err) => {
|
|
1353
|
+
this.deps.onLog?.("strip:capture-failed", {
|
|
1354
|
+
path: job.absPath,
|
|
1355
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1356
|
+
});
|
|
1357
|
+
job.resolve(null);
|
|
1358
|
+
}).finally(() => {
|
|
1359
|
+
this.running--;
|
|
1360
|
+
this.pump();
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
runFfmpeg = (absPath) => {
|
|
1365
|
+
return new Promise((resolve, reject) => {
|
|
1366
|
+
const child = (0, node_child_process.spawn)(this.deps.ffmpegPath ?? "ffmpeg", [
|
|
1367
|
+
"-hide_banner",
|
|
1368
|
+
"-loglevel",
|
|
1369
|
+
"error",
|
|
1370
|
+
"-skip_frame",
|
|
1371
|
+
"nokey",
|
|
1372
|
+
"-i",
|
|
1373
|
+
absPath,
|
|
1374
|
+
"-c:v",
|
|
1375
|
+
"mjpeg",
|
|
1376
|
+
"-q:v",
|
|
1377
|
+
"7",
|
|
1378
|
+
"-f",
|
|
1379
|
+
"image2pipe",
|
|
1380
|
+
"-"
|
|
1381
|
+
], { stdio: [
|
|
1382
|
+
"ignore",
|
|
1383
|
+
"pipe",
|
|
1384
|
+
"pipe"
|
|
1385
|
+
] });
|
|
1386
|
+
const chunks = [];
|
|
1387
|
+
const errChunks = [];
|
|
1388
|
+
const timer = setTimeout(() => {
|
|
1389
|
+
child.kill("SIGKILL");
|
|
1390
|
+
reject(/* @__PURE__ */ new Error(`strip capture timed out after ${TIMEOUT_MS}ms`));
|
|
1391
|
+
}, TIMEOUT_MS);
|
|
1392
|
+
timer.unref?.();
|
|
1393
|
+
child.stdout.on("data", (c) => chunks.push(c));
|
|
1394
|
+
child.stderr.on("data", (c) => errChunks.push(c));
|
|
1395
|
+
child.on("error", (err) => {
|
|
1396
|
+
clearTimeout(timer);
|
|
1397
|
+
reject(err);
|
|
1398
|
+
});
|
|
1399
|
+
child.on("close", (code) => {
|
|
1400
|
+
clearTimeout(timer);
|
|
1401
|
+
if (code === 0) resolve(new Uint8Array(Buffer.concat(chunks)));
|
|
1402
|
+
else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${String(code)}: ${Buffer.concat(errChunks).toString("utf8").slice(0, 300)}`));
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
};
|
|
1406
|
+
};
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/recorder/addon/strip-routes.ts
|
|
1409
|
+
/**
|
|
1410
|
+
* The strip's two questions, answered on the playback plane:
|
|
1411
|
+
*
|
|
1412
|
+
* `GET /<deviceId>/strip-index?hour=<hourStartMs>` → `CSI1` (what instants
|
|
1413
|
+
* the hour holds, and which bytes paint each one)
|
|
1414
|
+
* `GET /<deviceId>/strip?hour=<hourStartMs>` + `Range` → the strip file's
|
|
1415
|
+
* bytes (the client asks for exactly the records it needs)
|
|
1416
|
+
*
|
|
1417
|
+
* Same prefix, same auth, same fetch seam as the segments and the directory —
|
|
1418
|
+
* one plane, one credential path. The strip files live under `<deviceId>/strips/`
|
|
1419
|
+
* (the per-camera layout; the profile walkers whitelist high|mid|low so the
|
|
1420
|
+
* availability pick never misreads it), and these routes are their only door.
|
|
1421
|
+
*/
|
|
1422
|
+
var INDEX_PATH = /^\/(\d+)\/strip-index$/;
|
|
1423
|
+
var FILE_PATH = /^\/(\d+)\/strip$/;
|
|
1424
|
+
/** `bytes=a-b` → [a, b] clamped to `size`, or null (serve the whole file). */
|
|
1425
|
+
function parseRange(header, size) {
|
|
1426
|
+
if (!header) return null;
|
|
1427
|
+
const m = /^bytes=(\d+)-(\d+)?$/.exec(header);
|
|
1428
|
+
if (!m) return null;
|
|
1429
|
+
const start = Number(m[1]);
|
|
1430
|
+
const end = m[2] === void 0 ? size - 1 : Math.min(Number(m[2]), size - 1);
|
|
1431
|
+
if (start > end || start >= size) return null;
|
|
1432
|
+
return [start, end];
|
|
1433
|
+
}
|
|
1434
|
+
function withStripRoutes(deps, next) {
|
|
1435
|
+
return async (req, res) => {
|
|
1436
|
+
const raw = req.url ?? "/";
|
|
1437
|
+
const qs = raw.indexOf("?");
|
|
1438
|
+
const pathname = qs === -1 ? raw : raw.slice(0, qs);
|
|
1439
|
+
const params = new URLSearchParams(qs === -1 ? "" : raw.slice(qs + 1));
|
|
1440
|
+
const asIndex = INDEX_PATH.exec(pathname);
|
|
1441
|
+
const asFile = asIndex === null ? FILE_PATH.exec(pathname) : null;
|
|
1442
|
+
if (asIndex === null && asFile === null) {
|
|
1443
|
+
await next(req, res);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const deviceId = Number((asIndex ?? asFile)[1]);
|
|
1447
|
+
const hour = Number(params.get("hour"));
|
|
1448
|
+
if (!Number.isFinite(hour) || hour <= 0) {
|
|
1449
|
+
res.statusCode = 400;
|
|
1450
|
+
res.end("strip: hour required");
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
if (asIndex !== null) {
|
|
1455
|
+
const entries = await deps.store.index(deviceId, hour);
|
|
1456
|
+
if (entries === null) {
|
|
1457
|
+
res.statusCode = 204;
|
|
1458
|
+
res.end();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const body = encodeStripIndex(entries);
|
|
1462
|
+
res.statusCode = 200;
|
|
1463
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
1464
|
+
res.setHeader("content-length", String(body.length));
|
|
1465
|
+
res.setHeader("content-encoding", "identity");
|
|
1466
|
+
const immutable = hour + 36e5 < Date.now();
|
|
1467
|
+
res.setHeader("cache-control", immutable ? "private, max-age=31536000, immutable" : "private, max-age=2");
|
|
1468
|
+
res.end(Buffer.from(body));
|
|
1469
|
+
deps.onLog?.("strip:index-served", {
|
|
1470
|
+
deviceId,
|
|
1471
|
+
hour,
|
|
1472
|
+
entries: entries.length
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const abs = deps.store.absPathFor(deviceId, hour);
|
|
1477
|
+
if (abs === null) {
|
|
1478
|
+
res.statusCode = 204;
|
|
1479
|
+
res.end();
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
let size;
|
|
1483
|
+
try {
|
|
1484
|
+
size = (await node_fs.promises.stat(abs)).size;
|
|
1485
|
+
} catch {
|
|
1486
|
+
res.statusCode = 204;
|
|
1487
|
+
res.end();
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const range = parseRange(req.headers.range, size);
|
|
1491
|
+
const [start, end] = range ?? [0, size - 1];
|
|
1492
|
+
res.statusCode = range === null ? 200 : 206;
|
|
1493
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
1494
|
+
res.setHeader("content-length", String(end - start + 1));
|
|
1495
|
+
res.setHeader("accept-ranges", "bytes");
|
|
1496
|
+
res.setHeader("content-encoding", "identity");
|
|
1497
|
+
if (range !== null) res.setHeader("content-range", `bytes ${start}-${end}/${size}`);
|
|
1498
|
+
const stream = (0, node_fs.createReadStream)(abs, {
|
|
1499
|
+
start,
|
|
1500
|
+
end
|
|
1501
|
+
});
|
|
1502
|
+
stream.on("error", () => res.destroy());
|
|
1503
|
+
stream.pipe(res);
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
deps.onLog?.("strip:failed", {
|
|
1506
|
+
deviceId,
|
|
1507
|
+
hour,
|
|
1508
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1509
|
+
});
|
|
1510
|
+
res.statusCode = 500;
|
|
1511
|
+
res.end("strip: failed");
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
//#endregion
|
|
1516
|
+
//#region src/recorder/addon/strip-actions.ts
|
|
1517
|
+
/**
|
|
1518
|
+
* Recorder custom actions: strip regeneration.
|
|
1519
|
+
*
|
|
1520
|
+
* `regenerateStrips` clears a camera's thumbnail strips and rebuilds them from
|
|
1521
|
+
* the low segments already on disk — which is also the BACKFILL for footage
|
|
1522
|
+
* recorded before strips existed (or before the operator opted the camera in).
|
|
1523
|
+
*
|
|
1524
|
+
* A custom action, not a cap method: the generated AppRouter sits at
|
|
1525
|
+
* TypeScript's expansion limit, so caps cannot gain methods (docs/roadmap.md).
|
|
1526
|
+
*/
|
|
1527
|
+
var RegenerateStripsInputSchema = require_dist.object({
|
|
1528
|
+
deviceId: require_dist.number().int().nonnegative(),
|
|
1529
|
+
/** Days back from now to rebuild (default {@link REGEN_DEFAULT_DAYS}). */
|
|
1530
|
+
days: require_dist.number().int().positive().max(60).optional()
|
|
1531
|
+
});
|
|
1532
|
+
var RegenerateStripsResultSchema = require_dist.object({
|
|
1533
|
+
/** False when a regeneration for this device is already running. */
|
|
1534
|
+
started: require_dist.boolean(),
|
|
1535
|
+
/** Segments queued for thumbnail extraction (0 when not started). */
|
|
1536
|
+
segments: require_dist.number()
|
|
1537
|
+
});
|
|
1538
|
+
var recorderStripActions = require_dist.defineCustomActions({ regenerateStrips: require_dist.customAction(RegenerateStripsInputSchema, RegenerateStripsResultSchema, { kind: "mutation" }) });
|
|
1539
|
+
//#endregion
|
|
11
1540
|
//#region src/recorder/segment-path.ts
|
|
12
1541
|
function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
|
|
13
1542
|
if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
|
|
@@ -28,43 +1557,24 @@ function parseSegmentPath(relPath) {
|
|
|
28
1557
|
};
|
|
29
1558
|
}
|
|
30
1559
|
//#endregion
|
|
31
|
-
//#region src/recorder/merge-ranges.ts
|
|
32
|
-
function mergeRanges(segments, gapToleranceMs) {
|
|
33
|
-
if (segments.length === 0) return [];
|
|
34
|
-
const sorted = [...segments].toSorted((a, b) => a.startMs - b.startMs);
|
|
35
|
-
const out = [];
|
|
36
|
-
let curStart = sorted[0].startMs;
|
|
37
|
-
let curEnd = sorted[0].startMs + sorted[0].durMs;
|
|
38
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
39
|
-
const s = sorted[i];
|
|
40
|
-
if (s.startMs <= curEnd + gapToleranceMs) curEnd = Math.max(curEnd, s.startMs + s.durMs);
|
|
41
|
-
else {
|
|
42
|
-
out.push({
|
|
43
|
-
startMs: curStart,
|
|
44
|
-
endMs: curEnd
|
|
45
|
-
});
|
|
46
|
-
curStart = s.startMs;
|
|
47
|
-
curEnd = s.startMs + s.durMs;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
out.push({
|
|
51
|
-
startMs: curStart,
|
|
52
|
-
endMs: curEnd
|
|
53
|
-
});
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
//#endregion
|
|
57
1560
|
//#region src/recorder/recording-index.ts
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1561
|
+
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
1562
|
+
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
1563
|
+
var HOUR_MS$1 = 36e5;
|
|
1564
|
+
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
1565
|
+
* the retention window into the set. */
|
|
1566
|
+
var ALL_HOURS = Symbol("all-hours");
|
|
1567
|
+
/** The hour buckets a half-open span touches. */
|
|
1568
|
+
function* hourBuckets(fromMs, toMs) {
|
|
1569
|
+
const first = Math.floor(fromMs / HOUR_MS$1) * HOUR_MS$1;
|
|
1570
|
+
for (let h = first; h < toMs; h += HOUR_MS$1) yield h;
|
|
1571
|
+
}
|
|
66
1572
|
var RecordingIndex = class {
|
|
67
1573
|
byDevice = /* @__PURE__ */ new Map();
|
|
1574
|
+
/** Hour buckets this device has been walked for; `ALL_HOURS` after a full
|
|
1575
|
+
* hydrate. Empty = nothing has been looked at, which is NOT the same as
|
|
1576
|
+
* "there is nothing" — see {@link hydrationOf}. */
|
|
1577
|
+
walked = /* @__PURE__ */ new Map();
|
|
68
1578
|
mapFor(deviceId) {
|
|
69
1579
|
let m = this.byDevice.get(deviceId);
|
|
70
1580
|
if (!m) {
|
|
@@ -94,6 +1604,63 @@ var RecordingIndex = class {
|
|
|
94
1604
|
});
|
|
95
1605
|
}
|
|
96
1606
|
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Whether the index has WALKED `[fromMs, toMs)` for a device.
|
|
1609
|
+
*
|
|
1610
|
+
* The distinction this exists for: an hour that was walked and held nothing is
|
|
1611
|
+
* a real gap in the footage; an hour that was never walked is not, and must
|
|
1612
|
+
* not be reported as one. Hydration takes minutes over a >1M-file archive, so
|
|
1613
|
+
* for most of that time the honest answer is `unknown`.
|
|
1614
|
+
*/
|
|
1615
|
+
hydrationOf(deviceId, fromMs, toMs) {
|
|
1616
|
+
const w = this.walked.get(deviceId);
|
|
1617
|
+
if (w === void 0) return "unknown";
|
|
1618
|
+
if (w === ALL_HOURS) return "hydrated";
|
|
1619
|
+
let seen = 0;
|
|
1620
|
+
let total = 0;
|
|
1621
|
+
for (const h of hourBuckets(fromMs, toMs)) {
|
|
1622
|
+
total++;
|
|
1623
|
+
if (w.has(h)) seen++;
|
|
1624
|
+
}
|
|
1625
|
+
if (total === 0 || seen === total) return "hydrated";
|
|
1626
|
+
return seen === 0 ? "unknown" : "partial";
|
|
1627
|
+
}
|
|
1628
|
+
/** Record that `[fromMs, toMs)` has been walked, whatever it contained. */
|
|
1629
|
+
markHydrated(deviceId, fromMs, toMs) {
|
|
1630
|
+
const w = this.walked.get(deviceId);
|
|
1631
|
+
if (w === ALL_HOURS) return;
|
|
1632
|
+
const set = w ?? /* @__PURE__ */ new Set();
|
|
1633
|
+
for (const h of hourBuckets(fromMs, toMs)) set.add(h);
|
|
1634
|
+
this.walked.set(deviceId, set);
|
|
1635
|
+
}
|
|
1636
|
+
/** Record that the device was walked end-to-end (the background full walk). */
|
|
1637
|
+
markHydratedAll(deviceId) {
|
|
1638
|
+
this.walked.set(deviceId, ALL_HOURS);
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Merge one hour's segments for a location, and mark that hour walked.
|
|
1642
|
+
*
|
|
1643
|
+
* MERGES, unlike {@link hydrateDevice}, which replaces a whole location —
|
|
1644
|
+
* right for the full walk, catastrophic for an incremental one, where
|
|
1645
|
+
* hydrating hour 5 would erase hours 0–4.
|
|
1646
|
+
*/
|
|
1647
|
+
hydrateHour(deviceId, locationId, hourStartMs, relPaths) {
|
|
1648
|
+
const m = this.mapFor(deviceId);
|
|
1649
|
+
for (const path of relPaths) {
|
|
1650
|
+
const p = parseSegmentPath(path);
|
|
1651
|
+
if (!p || p.deviceId !== deviceId) continue;
|
|
1652
|
+
m.set(path, {
|
|
1653
|
+
deviceId: p.deviceId,
|
|
1654
|
+
profile: p.profile,
|
|
1655
|
+
startMs: p.startMs,
|
|
1656
|
+
durMs: p.durMs,
|
|
1657
|
+
bytes: p.bytes,
|
|
1658
|
+
path,
|
|
1659
|
+
locationId
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$1);
|
|
1663
|
+
}
|
|
97
1664
|
/** Insert/replace one finalized segment (idempotent by path). */
|
|
98
1665
|
addSegment(s) {
|
|
99
1666
|
this.mapFor(s.deviceId).set(s.path, s);
|
|
@@ -308,6 +1875,7 @@ var SegmentStore = class SegmentStore {
|
|
|
308
1875
|
locationId: input.locationId
|
|
309
1876
|
};
|
|
310
1877
|
this.deps.index.addSegment(row);
|
|
1878
|
+
this.deps.onIndexed?.(row, toAbs);
|
|
311
1879
|
return row;
|
|
312
1880
|
}
|
|
313
1881
|
/**
|
|
@@ -364,9 +1932,46 @@ var SegmentStore = class SegmentStore {
|
|
|
364
1932
|
});
|
|
365
1933
|
}
|
|
366
1934
|
}
|
|
367
|
-
if (removed.length > 0)
|
|
1935
|
+
if (removed.length > 0) {
|
|
1936
|
+
this.deps.index.removeSegments(removed);
|
|
1937
|
+
for (const r of rows) this.deps.onEvicted?.(r.deviceId, r.startMs);
|
|
1938
|
+
}
|
|
1939
|
+
await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
|
|
368
1940
|
return reclaimed;
|
|
369
1941
|
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Walk up from each emptied hour directory removing ancestors that are now
|
|
1944
|
+
* empty, so that **a directory's existence means footage**.
|
|
1945
|
+
*
|
|
1946
|
+
* Hours were already removed when fully evicted; days were not. Measured on
|
|
1947
|
+
* the live archive: 15 of 28 day directories held nothing at all, while only
|
|
1948
|
+
* 1 of 261 hour directories did. Those shells are why the date selector could
|
|
1949
|
+
* not simply read `.../YYYY/MM/` and answer — the cheapest possible question
|
|
1950
|
+
* needed a confirming read inside every day.
|
|
1951
|
+
*
|
|
1952
|
+
* Only ever `rmdir`, never a recursive delete: an ancestor that still holds
|
|
1953
|
+
* anything must survive, and the failure of a non-empty `rmdir` IS the guard.
|
|
1954
|
+
* Stops at the profile level — `<deviceId>/<profile>/` are structural.
|
|
1955
|
+
*/
|
|
1956
|
+
async pruneEmptyAncestors(location, bucketDirs) {
|
|
1957
|
+
const prune = this.deps.removeDirIfEmpty;
|
|
1958
|
+
if (!prune) return;
|
|
1959
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1960
|
+
for (const bucket of bucketDirs) {
|
|
1961
|
+
if (bucket === "") continue;
|
|
1962
|
+
const parts = bucket.split("/");
|
|
1963
|
+
for (let depth = parts.length - 1; depth > 2; depth--) {
|
|
1964
|
+
const dir = parts.slice(0, depth).join("/");
|
|
1965
|
+
if (seen.has(dir)) break;
|
|
1966
|
+
seen.add(dir);
|
|
1967
|
+
try {
|
|
1968
|
+
await prune(location, dir);
|
|
1969
|
+
} catch {
|
|
1970
|
+
break;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
370
1975
|
/** Hour of one UTC hour-bucket in ms. */
|
|
371
1976
|
static HOUR_MS = 36e5;
|
|
372
1977
|
/**
|
|
@@ -451,9 +2056,15 @@ var StorageEvictableProvider = class {
|
|
|
451
2056
|
constructor(deps) {
|
|
452
2057
|
this.deps = deps;
|
|
453
2058
|
}
|
|
454
|
-
/** The eviction domain of `locationId` — aliases sharing its disk, else
|
|
2059
|
+
/** The eviction domain of `locationId` — aliases sharing its disk, else
|
|
2060
|
+
* itself — MINUS read-only members (drain mode: their bytes are neither
|
|
2061
|
+
* reported evictable nor selected as victims). */
|
|
455
2062
|
domainOf(locationId) {
|
|
456
|
-
|
|
2063
|
+
const domain = this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
|
|
2064
|
+
if (!this.deps.isReadOnly) return domain;
|
|
2065
|
+
const writable = /* @__PURE__ */ new Set();
|
|
2066
|
+
for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
|
|
2067
|
+
return writable;
|
|
457
2068
|
}
|
|
458
2069
|
async getEvictableUsage(input) {
|
|
459
2070
|
return { bytes: this.deps.index.accountingForLocations(this.domainOf(input.locationId)).bytes };
|
|
@@ -849,14 +2460,22 @@ function buildRecordingDeviceSchema() {
|
|
|
849
2460
|
* logging); {@link recordingConfigsState} adapts a raw store slice for test
|
|
850
2461
|
* fakes. Same store key, same persisted blob shape as before.
|
|
851
2462
|
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
2463
|
+
* `bands` is the ONLY authored recording intent (the legacy
|
|
2464
|
+
* mode/schedules/triggers/rules authoring surface was retired 2026-07-30); on
|
|
2465
|
+
* save `mode` is stamped as their derived summary for cheap consumers.
|
|
855
2466
|
*/
|
|
856
2467
|
/** The addon-store key holding the deviceId → RecordingConfig map. */
|
|
857
2468
|
var RECORDING_CONFIGS_KEY = "recordingConfigs";
|
|
858
|
-
/**
|
|
859
|
-
|
|
2469
|
+
/**
|
|
2470
|
+
* Persisted shape: stringified numeric deviceId → full RecordingConfig.
|
|
2471
|
+
*
|
|
2472
|
+
* READ is forgiving where the wire contract is strict: `RecordingConfigSchema`
|
|
2473
|
+
* rejects unknown keys so a stale caller fails loudly, but a blob written by an
|
|
2474
|
+
* older build may still carry retired keys — and one unparseable device would
|
|
2475
|
+
* take the WHOLE blob down to the empty fallback (= every camera silently
|
|
2476
|
+
* unconfigured). `.strip()` drops them instead.
|
|
2477
|
+
*/
|
|
2478
|
+
var RecordingConfigsBlobSchema = require_dist.record(require_dist.string(), require_dist.RecordingConfigSchema.strip());
|
|
860
2479
|
/** Cold-start default for a camera that has never been configured: disabled, no bands. */
|
|
861
2480
|
var DEFAULT_DEVICE_CONFIG = {
|
|
862
2481
|
enabled: false,
|
|
@@ -878,21 +2497,31 @@ async function readDeviceConfigs(state) {
|
|
|
878
2497
|
return map;
|
|
879
2498
|
}
|
|
880
2499
|
/**
|
|
881
|
-
* The persisted config for a device
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
* unconfigured device returns {@link DEFAULT_DEVICE_CONFIG}.
|
|
2500
|
+
* The persisted config for a device — `bands` is always materialized (the
|
|
2501
|
+
* schema defaults it to `[]`). An unconfigured device returns
|
|
2502
|
+
* {@link DEFAULT_DEVICE_CONFIG}.
|
|
885
2503
|
*/
|
|
886
2504
|
async function loadDeviceConfig(state, deviceId) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
2505
|
+
return (await readDeviceConfigs(state)).get(deviceId) ?? DEFAULT_DEVICE_CONFIG;
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* The devices whose stored config claims to record (`enabled` + a non-`off`
|
|
2509
|
+
* derived `mode`) but has no band left — the one shape this store cannot
|
|
2510
|
+
* repair. It can only come from a pre-retirement blob whose legacy authoring
|
|
2511
|
+
* fields were stripped on read: the camera records NOTHING until its bands are
|
|
2512
|
+
* re-authored, so the recorder shouts about it once at boot
|
|
2513
|
+
* ({@link warnLostBands}) instead of failing silently.
|
|
2514
|
+
*/
|
|
2515
|
+
function deviceIdsWithLostBands(configs) {
|
|
2516
|
+
const lost = [];
|
|
2517
|
+
for (const [deviceId, config] of configs) if (config.enabled && config.bands.length === 0 && config.mode && config.mode !== "off") lost.push(deviceId);
|
|
2518
|
+
return lost.toSorted((a, b) => a - b);
|
|
2519
|
+
}
|
|
2520
|
+
/** Boot-time check: log the {@link deviceIdsWithLostBands} once, if any. */
|
|
2521
|
+
async function warnLostBands(state, logger) {
|
|
2522
|
+
const lost = deviceIdsWithLostBands(await readDeviceConfigs(state));
|
|
2523
|
+
if (lost.length === 0) return;
|
|
2524
|
+
logger.warn(`recording config: device(s) ${lost.join(", ")} claim a recording mode but have NO bands — a pre-retirement config that lost its legacy authoring fields. Nothing is recorded for them until their bands are re-authored.`);
|
|
896
2525
|
}
|
|
897
2526
|
/**
|
|
898
2527
|
* Persist (insert or replace) a device's config. Validates against
|
|
@@ -900,22 +2529,71 @@ async function loadDeviceConfig(state, deviceId) {
|
|
|
900
2529
|
* validated config that was persisted.
|
|
901
2530
|
*/
|
|
902
2531
|
async function saveDeviceConfig(state, deviceId, config) {
|
|
903
|
-
const
|
|
2532
|
+
const parsed = require_dist.RecordingConfigSchema.parse(config);
|
|
2533
|
+
const validated = {
|
|
2534
|
+
...parsed,
|
|
2535
|
+
mode: parsed.enabled ? activeModeForConfig(parsed) : "off"
|
|
2536
|
+
};
|
|
904
2537
|
const map = await readDeviceConfigs(state);
|
|
905
2538
|
map.set(deviceId, validated);
|
|
906
2539
|
await state.set(serialize(map));
|
|
907
2540
|
return validated;
|
|
908
2541
|
}
|
|
909
2542
|
/**
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
2543
|
+
* The active recording mode for a config's bands — the value the `recording`
|
|
2544
|
+
* cap status reports. Thin alias over the canonical {@link deriveRecordingMode}
|
|
2545
|
+
* in `@camstack/types`, which the settings UI derives from too.
|
|
913
2546
|
*/
|
|
914
2547
|
function activeModeForConfig(config) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
2548
|
+
return require_dist.deriveRecordingMode(config);
|
|
2549
|
+
}
|
|
2550
|
+
//#endregion
|
|
2551
|
+
//#region src/recorder/addon/eviction-domain.ts
|
|
2552
|
+
/**
|
|
2553
|
+
* Eviction-DOMAIN resolution for recordings locations.
|
|
2554
|
+
*
|
|
2555
|
+
* Recordings locations are operator-configurable and MAY alias one physical
|
|
2556
|
+
* filesystem root: today `recordings:default` + `recordingsLow:default` both
|
|
2557
|
+
* resolve to `/recordings`, but an operator could also point two locations at a
|
|
2558
|
+
* symlink or bind-mount of the same disk. Locations sharing a physical root
|
|
2559
|
+
* MUST evict as one oldest-first pool — otherwise disk-pressure relief on one
|
|
2560
|
+
* alias deletes the newest footage while the other alias still holds the old
|
|
2561
|
+
* (the age-partition data-loss bug).
|
|
2562
|
+
*
|
|
2563
|
+
* The domain key is the location root's PHYSICAL identity: `fs.realpathSync` of
|
|
2564
|
+
* the lexically-resolved path, which collapses symlinks and `.`/`..` so aliases
|
|
2565
|
+
* reaching one disk share a key — a plain `path.resolve` string compare would
|
|
2566
|
+
* miss a symlinked/bind-aliased path and silently re-introduce the partition.
|
|
2567
|
+
* When realpath THROWS (the root is missing at boot — a disk not yet mounted)
|
|
2568
|
+
* we fall back to the lexically-resolved path string: distinct-but-unmounted
|
|
2569
|
+
* roots then stay distinct domains. That fallback can only ever OVER-partition
|
|
2570
|
+
* (treat one disk as two), never MERGE two different disks — the safe failure
|
|
2571
|
+
* mode for a data-loss guard.
|
|
2572
|
+
*/
|
|
2573
|
+
/**
|
|
2574
|
+
* The physical-device key of a location root: `realpath` of the resolved path,
|
|
2575
|
+
* or the resolved path string itself when realpath throws (root missing).
|
|
2576
|
+
*/
|
|
2577
|
+
function physicalRootKey(root, realpath = node_fs.realpathSync) {
|
|
2578
|
+
const resolved = node_path.default.resolve(root);
|
|
2579
|
+
try {
|
|
2580
|
+
return realpath(resolved);
|
|
2581
|
+
} catch {
|
|
2582
|
+
return resolved;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
|
|
2587
|
+
* itself) whose roots share `locationId`'s physical root key. An unknown
|
|
2588
|
+
* `locationId` (not present in `locations`) is its own singleton domain.
|
|
2589
|
+
*/
|
|
2590
|
+
function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpathSync) {
|
|
2591
|
+
const self = locations.find((l) => l.id === locationId);
|
|
2592
|
+
if (!self) return new Set([locationId]);
|
|
2593
|
+
const selfKey = physicalRootKey(self.root, realpath);
|
|
2594
|
+
const domain = new Set([locationId]);
|
|
2595
|
+
for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
|
|
2596
|
+
return domain;
|
|
919
2597
|
}
|
|
920
2598
|
//#endregion
|
|
921
2599
|
//#region src/recorder/addon/recording-provider.ts
|
|
@@ -995,35 +2673,73 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
|
|
|
995
2673
|
*
|
|
996
2674
|
* Absolute resolution is the SAME idiom as the scrub path's
|
|
997
2675
|
* `resolveSegmentAbsPath`: `<root for the row's locationId>/<relative path>`.
|
|
998
|
-
* A row whose location is unknown
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
2676
|
+
* A row whose location is unknown is skipped — it cannot be addressed at all.
|
|
2677
|
+
* Whether the FILES are verified depends on the caller: export ('absolute')
|
|
2678
|
+
* checks every one, playback does not. See the comment at the branch.
|
|
1001
2679
|
*/
|
|
1002
2680
|
async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
|
|
2681
|
+
if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
|
|
2682
|
+
const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
|
|
1003
2683
|
const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
|
|
1004
|
-
const
|
|
1005
|
-
const segments = [];
|
|
1006
|
-
let firstPresentAbs = null;
|
|
1007
|
-
for (const s of rows) {
|
|
1008
|
-
const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
|
|
2684
|
+
const candidates = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs).flatMap((s) => {
|
|
1009
2685
|
const root = rootById.get(s.locationId);
|
|
1010
|
-
if (root === void 0)
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
2686
|
+
if (root === void 0) return [];
|
|
2687
|
+
const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
|
|
2688
|
+
return [{
|
|
2689
|
+
row: s,
|
|
2690
|
+
rel,
|
|
2691
|
+
abs: node_path.default.join(root, rel)
|
|
2692
|
+
}];
|
|
2693
|
+
});
|
|
2694
|
+
const statFile = deps.statFile ?? ((abs) => node_fs.promises.stat(abs));
|
|
2695
|
+
const toSegment = (c) => ({
|
|
2696
|
+
uri: uriMode === "absolute" ? c.abs : c.rel.split("/").slice(1).join("/"),
|
|
2697
|
+
startMs: c.row.startMs,
|
|
2698
|
+
durMs: c.row.durMs
|
|
2699
|
+
});
|
|
2700
|
+
if (uriMode === "absolute") {
|
|
2701
|
+
const present = Array.from({ length: candidates.length }, () => false);
|
|
2702
|
+
for (let i = 0; i < candidates.length; i += STAT_BATCH) {
|
|
2703
|
+
const batch = candidates.slice(i, i + STAT_BATCH);
|
|
2704
|
+
const results = await Promise.all(batch.map(async (c) => {
|
|
2705
|
+
try {
|
|
2706
|
+
await statFile(c.abs);
|
|
2707
|
+
return true;
|
|
2708
|
+
} catch {
|
|
2709
|
+
return false;
|
|
2710
|
+
}
|
|
2711
|
+
}));
|
|
2712
|
+
for (let j = 0; j < results.length; j++) present[i + j] = results[j] ?? false;
|
|
1016
2713
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
2714
|
+
const segments = [];
|
|
2715
|
+
let firstPresentAbs = null;
|
|
2716
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2717
|
+
if (!present[i]) continue;
|
|
2718
|
+
const c = candidates[i];
|
|
2719
|
+
if (c === void 0) continue;
|
|
2720
|
+
if (firstPresentAbs === null) firstPresentAbs = c.abs;
|
|
2721
|
+
segments.push(toSegment(c));
|
|
2722
|
+
}
|
|
2723
|
+
return {
|
|
2724
|
+
segments,
|
|
2725
|
+
firstPresentAbs,
|
|
2726
|
+
hydration
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
let firstPresentAbs = null;
|
|
2730
|
+
for (let i = 0; i < candidates.length && i < CODEC_PROBE_SCAN_LIMIT; i++) {
|
|
2731
|
+
const c = candidates[i];
|
|
2732
|
+
if (c === void 0) continue;
|
|
2733
|
+
try {
|
|
2734
|
+
await statFile(c.abs);
|
|
2735
|
+
firstPresentAbs = c.abs;
|
|
2736
|
+
break;
|
|
2737
|
+
} catch {}
|
|
1023
2738
|
}
|
|
1024
2739
|
return {
|
|
1025
|
-
segments,
|
|
1026
|
-
firstPresentAbs
|
|
2740
|
+
segments: candidates.map(toSegment),
|
|
2741
|
+
firstPresentAbs,
|
|
2742
|
+
hydration
|
|
1027
2743
|
};
|
|
1028
2744
|
}
|
|
1029
2745
|
/**
|
|
@@ -1035,6 +2751,14 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
|
|
|
1035
2751
|
* URIs are relative so the master + variants + segments form a self-contained
|
|
1036
2752
|
* tree the player resolves against the master URL.
|
|
1037
2753
|
*/
|
|
2754
|
+
/** How many segments to try before giving up on finding one present file for
|
|
2755
|
+
* the CODECS probe. Bounded so a device whose index rows outlive its files
|
|
2756
|
+
* cannot turn this back into a per-segment scan. */
|
|
2757
|
+
var CODEC_PROBE_SCAN_LIMIT = 8;
|
|
2758
|
+
/** Concurrent `stat` fan-out width for the EXPORT path, which must verify every
|
|
2759
|
+
* file. High enough to hide per-call latency on a contended array, low enough
|
|
2760
|
+
* not to starve the recorder's own writes. */
|
|
2761
|
+
var STAT_BATCH = 64;
|
|
1038
2762
|
async function buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs) {
|
|
1039
2763
|
const base = deps.playbackBaseUrl();
|
|
1040
2764
|
if (base === null) return noPlayback(deviceId);
|
|
@@ -1128,6 +2852,29 @@ function buildRecordingProvider(deps) {
|
|
|
1128
2852
|
return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
|
|
1129
2853
|
},
|
|
1130
2854
|
getAvailability: async ({ deviceId, fromMs, toMs }) => {
|
|
2855
|
+
const calendar = deps.calendar;
|
|
2856
|
+
if (calendar) {
|
|
2857
|
+
const profile = await calendar.availabilityProfileFor(deviceId);
|
|
2858
|
+
if (profile === null) return {
|
|
2859
|
+
deviceId,
|
|
2860
|
+
ranges: []
|
|
2861
|
+
};
|
|
2862
|
+
const ranges = [];
|
|
2863
|
+
const firstDay = Math.floor(fromMs / DAY_MS) * DAY_MS;
|
|
2864
|
+
for (let day = firstDay; day < toMs; day += DAY_MS) for (const r of await calendar.dayRanges(deviceId, profile, day)) {
|
|
2865
|
+
if (r.endMs <= fromMs || r.startMs >= toMs) continue;
|
|
2866
|
+
ranges.push({
|
|
2867
|
+
profile,
|
|
2868
|
+
startMs: r.startMs,
|
|
2869
|
+
endMs: r.endMs
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
calendar.warmAdjacent(deviceId, profile, firstDay);
|
|
2873
|
+
return {
|
|
2874
|
+
deviceId,
|
|
2875
|
+
ranges
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
1131
2878
|
return {
|
|
1132
2879
|
deviceId,
|
|
1133
2880
|
ranges: [...new Set(deps.index.segments(deviceId).map((s) => s.profile))].flatMap((profile) => deps.index.ranges(deviceId, profile, fromMs, toMs, RANGE_MERGE_GAP_MS).map((r) => ({
|
|
@@ -1138,6 +2885,11 @@ function buildRecordingProvider(deps) {
|
|
|
1138
2885
|
};
|
|
1139
2886
|
},
|
|
1140
2887
|
getDaysWithRecordings: async ({ deviceId, fromMs, toMs, tzOffsetMinutes }) => {
|
|
2888
|
+
const calendar = deps.calendar;
|
|
2889
|
+
if (calendar) return {
|
|
2890
|
+
deviceId,
|
|
2891
|
+
days: await calendar.localDays(deviceId, fromMs, toMs, tzOffsetMinutes)
|
|
2892
|
+
};
|
|
1141
2893
|
return {
|
|
1142
2894
|
deviceId,
|
|
1143
2895
|
days: distinctRecordingDays(deps.index.segments(deviceId).map((s) => s.startMs), fromMs, toMs, tzOffsetMinutes)
|
|
@@ -1158,11 +2910,21 @@ function buildRecordingProvider(deps) {
|
|
|
1158
2910
|
usedBytes
|
|
1159
2911
|
});
|
|
1160
2912
|
}
|
|
1161
|
-
const
|
|
1162
|
-
|
|
2913
|
+
const byPhysical = /* @__PURE__ */ new Map();
|
|
2914
|
+
for (const loc of locations) {
|
|
2915
|
+
const key = physicalRootKey(loc.root);
|
|
2916
|
+
const group = byPhysical.get(key);
|
|
2917
|
+
if (group) group.push(loc);
|
|
2918
|
+
else byPhysical.set(key, [loc]);
|
|
2919
|
+
}
|
|
2920
|
+
const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
|
|
2921
|
+
const first = group[0];
|
|
2922
|
+
const cap = await deps.capacity(first.root);
|
|
2923
|
+
const ids = group.map((l) => l.id);
|
|
1163
2924
|
return {
|
|
1164
|
-
locationId:
|
|
1165
|
-
|
|
2925
|
+
locationId: first.id,
|
|
2926
|
+
locationIds: ids,
|
|
2927
|
+
usedBytes: deps.index.accountingForLocations(new Set(ids)).bytes,
|
|
1166
2928
|
availableBytes: cap.availableBytes,
|
|
1167
2929
|
totalBytes: cap.totalBytes
|
|
1168
2930
|
};
|
|
@@ -1270,6 +3032,20 @@ function buildRecordingProvider(deps) {
|
|
|
1270
3032
|
deviceId,
|
|
1271
3033
|
limit
|
|
1272
3034
|
}) ?? [],
|
|
3035
|
+
renderClip: async (input) => {
|
|
3036
|
+
if (!deps.clipRender) throw new Error("clip rendering unavailable on this node");
|
|
3037
|
+
return deps.clipRender(input);
|
|
3038
|
+
},
|
|
3039
|
+
renderGif: async (input) => {
|
|
3040
|
+
if (!deps.gifRender) throw new Error("gif rendering unavailable on this node");
|
|
3041
|
+
return deps.gifRender(input);
|
|
3042
|
+
},
|
|
3043
|
+
relocateFootage: async (input) => {
|
|
3044
|
+
if (!deps.relocate) throw new Error("relocation unavailable on this node");
|
|
3045
|
+
return { jobId: deps.relocate.start(input) };
|
|
3046
|
+
},
|
|
3047
|
+
getRelocateStatus: async () => deps.relocate?.list() ?? [],
|
|
3048
|
+
cancelRelocate: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
|
|
1273
3049
|
getDeviceSettingsContribution: async ({ deviceId }) => {
|
|
1274
3050
|
if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
|
|
1275
3051
|
return require_dist.hydrateSchema(buildRecordingDeviceSchema(), {});
|
|
@@ -1871,14 +3647,17 @@ var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
|
1871
3647
|
/**
|
|
1872
3648
|
* Route a recording profile to its storage location by the location's declared
|
|
1873
3649
|
* `type`: the `low` profile prefers the `recordingsLow` location, every other
|
|
1874
|
-
* profile the `recordings` location.
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
3650
|
+
* profile the `recordings` location. READ-ONLY locations are never write
|
|
3651
|
+
* targets (the drain model): when the preferred type has only read-only
|
|
3652
|
+
* instances, any writable recordings-type location serves. Throws when NO
|
|
3653
|
+
* writable location exists — recording cannot start, and that must be loud.
|
|
1877
3654
|
*/
|
|
1878
3655
|
function resolvePlacement(locations, profile) {
|
|
1879
3656
|
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
3657
|
+
const writable = locations.filter((l) => !l.readOnly);
|
|
3658
|
+
if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
|
|
1880
3659
|
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1881
|
-
return
|
|
3660
|
+
return writable.find((l) => l.type === preferType) ?? writable[0];
|
|
1882
3661
|
}
|
|
1883
3662
|
/**
|
|
1884
3663
|
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
@@ -1894,7 +3673,8 @@ async function resolveRecordingsLocations(api, logger) {
|
|
|
1894
3673
|
root: await api.storage.resolve.query({
|
|
1895
3674
|
location: l.id,
|
|
1896
3675
|
relativePath: ""
|
|
1897
|
-
})
|
|
3676
|
+
}),
|
|
3677
|
+
readOnly: l.config["readOnly"] === true
|
|
1898
3678
|
})));
|
|
1899
3679
|
} catch (err) {
|
|
1900
3680
|
logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
|
|
@@ -1929,54 +3709,61 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
1929
3709
|
index.hydrateDevice(deviceId, loc.id, relPaths);
|
|
1930
3710
|
}
|
|
1931
3711
|
}
|
|
1932
|
-
|
|
1933
|
-
|
|
3712
|
+
/** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
|
|
3713
|
+
function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
3714
|
+
const d = new Date(hourStartMs);
|
|
3715
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
3716
|
+
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
3717
|
+
}
|
|
3718
|
+
var HOUR_MS = 36e5;
|
|
1934
3719
|
/**
|
|
1935
|
-
*
|
|
3720
|
+
* Hydrate ONLY the hours a window spans, instead of walking the archive.
|
|
1936
3721
|
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
1941
|
-
*
|
|
1942
|
-
* alias deletes the newest footage while the other alias still holds the old
|
|
1943
|
-
* (the age-partition data-loss bug).
|
|
3722
|
+
* `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
|
|
3723
|
+
* subtree — ">1M files and takes minutes" per its own comment. Until it lands,
|
|
3724
|
+
* every read of past footage sees an empty index, which the server reports as
|
|
3725
|
+
* "no footage": measured after a deploy, a camera-day held 7 segments and grew
|
|
3726
|
+
* only at the rate of NEW writes for ~9 minutes.
|
|
1944
3727
|
*
|
|
1945
|
-
* The
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
* (treat one disk as two), never MERGE two different disks — the safe failure
|
|
1953
|
-
* mode for a data-loss guard.
|
|
1954
|
-
*/
|
|
1955
|
-
/**
|
|
1956
|
-
* The physical-device key of a location root: `realpath` of the resolved path,
|
|
1957
|
-
* or the resolved path string itself when realpath throws (root missing).
|
|
3728
|
+
* The path convention makes the window addressable directly: one SHALLOW
|
|
3729
|
+
* `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
|
|
3730
|
+
* background full walk still runs, for the consumers that need the whole
|
|
3731
|
+
* archive (footprint, eviction domains, retention).
|
|
3732
|
+
*
|
|
3733
|
+
* Hours already walked are skipped, so a drag across an hour costs nothing the
|
|
3734
|
+
* second time.
|
|
1958
3735
|
*/
|
|
1959
|
-
function
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
3736
|
+
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger) {
|
|
3737
|
+
if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
3738
|
+
for (const loc of locations) {
|
|
3739
|
+
const deviceDir = node_path.default.join(loc.root, String(deviceId));
|
|
3740
|
+
let profiles;
|
|
3741
|
+
try {
|
|
3742
|
+
profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
|
|
3743
|
+
} catch (err) {
|
|
3744
|
+
if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
|
|
3745
|
+
tags: { deviceId },
|
|
3746
|
+
meta: {
|
|
3747
|
+
locationId: loc.id,
|
|
3748
|
+
error: require_dist.errMsg(err)
|
|
3749
|
+
}
|
|
3750
|
+
});
|
|
3751
|
+
continue;
|
|
3752
|
+
}
|
|
3753
|
+
const first = Math.floor(fromMs / HOUR_MS) * HOUR_MS;
|
|
3754
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS) for (const profile of profiles) {
|
|
3755
|
+
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
3756
|
+
let files;
|
|
3757
|
+
try {
|
|
3758
|
+
files = await node_fs.promises.readdir(node_path.default.join(loc.root, rel));
|
|
3759
|
+
} catch {
|
|
3760
|
+
continue;
|
|
3761
|
+
}
|
|
3762
|
+
index.hydrateHour(deviceId, loc.id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
|
|
3763
|
+
}
|
|
3764
|
+
index.markHydrated(deviceId, fromMs, toMs);
|
|
1965
3765
|
}
|
|
1966
3766
|
}
|
|
1967
|
-
/**
|
|
1968
|
-
* The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
|
|
1969
|
-
* itself) whose roots share `locationId`'s physical root key. An unknown
|
|
1970
|
-
* `locationId` (not present in `locations`) is its own singleton domain.
|
|
1971
|
-
*/
|
|
1972
|
-
function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpathSync) {
|
|
1973
|
-
const self = locations.find((l) => l.id === locationId);
|
|
1974
|
-
if (!self) return new Set([locationId]);
|
|
1975
|
-
const selfKey = physicalRootKey(self.root, realpath);
|
|
1976
|
-
const domain = new Set([locationId]);
|
|
1977
|
-
for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
|
|
1978
|
-
return domain;
|
|
1979
|
-
}
|
|
1980
3767
|
//#endregion
|
|
1981
3768
|
//#region src/recorder/band-engine.ts
|
|
1982
3769
|
function toMin(hhmm) {
|
|
@@ -2470,9 +4257,34 @@ var SegmentWriter = class {
|
|
|
2470
4257
|
* handle (`useCapability(...).call(fn)`) — no bespoke retry/backoff, the
|
|
2471
4258
|
* `ReadinessRegistry` is the single source of truth.
|
|
2472
4259
|
*/
|
|
2473
|
-
var DEFAULT_WATCH_INTERVAL_MS = 2e3;
|
|
2474
|
-
/** Periodic re-evaluation to catch band boundaries crossed with no config change. */
|
|
2475
|
-
var TICK_MS = 3e4;
|
|
4260
|
+
var DEFAULT_WATCH_INTERVAL_MS = 2e3;
|
|
4261
|
+
/** Periodic re-evaluation to catch band boundaries crossed with no config change. */
|
|
4262
|
+
var TICK_MS = 3e4;
|
|
4263
|
+
/**
|
|
4264
|
+
* Floor for "this writer is pinned but producing nothing".
|
|
4265
|
+
*
|
|
4266
|
+
* The give-up path only fires when ffmpeg EXITS. On 2026-07-29 the opposite
|
|
4267
|
+
* happened on 590 + 617: after a deploy the writers pinned the broker and hung
|
|
4268
|
+
* — process alive, no exit, no error, no retry — and ~68 minutes of recording
|
|
4269
|
+
* were lost while `evaluateDevice` kept short-circuiting at "already recording
|
|
4270
|
+
* — converged". A supervisor that only watches for termination cannot see
|
|
4271
|
+
* silence.
|
|
4272
|
+
*/
|
|
4273
|
+
var WRITER_IDLE_MIN_MS = 9e4;
|
|
4274
|
+
/**
|
|
4275
|
+
* The bound also scales with segment length, so a camera on long segments is
|
|
4276
|
+
* not declared dead while its FIRST segment is still legitimately being
|
|
4277
|
+
* written. Three segments' worth is comfortably past any single hiccup.
|
|
4278
|
+
*/
|
|
4279
|
+
var WRITER_IDLE_SEGMENT_MULTIPLIER = 3;
|
|
4280
|
+
/** How long a writer may go without finalizing a segment before it is recovered. */
|
|
4281
|
+
function writerIdleBoundMs(segmentSeconds) {
|
|
4282
|
+
return Math.max(WRITER_IDLE_MIN_MS, segmentSeconds * 1e3 * WRITER_IDLE_SEGMENT_MULTIPLIER);
|
|
4283
|
+
}
|
|
4284
|
+
/** Key for the per-(device, profile) liveness stamp. */
|
|
4285
|
+
function idleKey(deviceId, profile) {
|
|
4286
|
+
return `${deviceId}:${profile}`;
|
|
4287
|
+
}
|
|
2476
4288
|
/**
|
|
2477
4289
|
* Staging dir name, created UNDER each recordings location root
|
|
2478
4290
|
* (`<locationRoot>/.rec-tmp/<deviceId>/<profile>`). Per-location staging means
|
|
@@ -2498,6 +4310,14 @@ var RecordingController = class {
|
|
|
2498
4310
|
attaching = /* @__PURE__ */ new Set();
|
|
2499
4311
|
/** Latest qualifying trigger timestamps per device — drives `events`-band gating. */
|
|
2500
4312
|
triggers = /* @__PURE__ */ new Map();
|
|
4313
|
+
/**
|
|
4314
|
+
* Last sign of life per `deviceId:profile` — seeded at attach (so the grace
|
|
4315
|
+
* window starts at the pin, not at the first segment) and stamped on every
|
|
4316
|
+
* finalize. Read by {@link checkIdleWriters}. Stamped BEFORE the keep/discard
|
|
4317
|
+
* policy runs: a discarded `events`-band segment still proves the writer is
|
|
4318
|
+
* alive, which is the only question here.
|
|
4319
|
+
*/
|
|
4320
|
+
lastSegmentAt = /* @__PURE__ */ new Map();
|
|
2501
4321
|
restore = null;
|
|
2502
4322
|
tickTimer = null;
|
|
2503
4323
|
stopped = false;
|
|
@@ -2541,6 +4361,8 @@ var RecordingController = class {
|
|
|
2541
4361
|
}
|
|
2542
4362
|
/** Re-evaluate every currently-tracked OR active device on the periodic tick. */
|
|
2543
4363
|
async tick() {
|
|
4364
|
+
if (this.stopped) return;
|
|
4365
|
+
await this.checkIdleWriters();
|
|
2544
4366
|
if (this.stopped) return;
|
|
2545
4367
|
const ids = new Set(this.active.keys());
|
|
2546
4368
|
let persisted = [];
|
|
@@ -2591,6 +4413,42 @@ var RecordingController = class {
|
|
|
2591
4413
|
});
|
|
2592
4414
|
}
|
|
2593
4415
|
}
|
|
4416
|
+
/**
|
|
4417
|
+
* Find writers that hold a broker pin but have finalized nothing for longer
|
|
4418
|
+
* than their idle bound, and recover them through the same detach +
|
|
4419
|
+
* re-evaluate path a give-up takes.
|
|
4420
|
+
*
|
|
4421
|
+
* The counterpart to `onGaveUp`: that one fires when ffmpeg EXITS, this one
|
|
4422
|
+
* when it does not exit and does not work. Both must exist — a branch that
|
|
4423
|
+
* drops work has to say so, and the 2026-07-29 loss said nothing at all.
|
|
4424
|
+
*/
|
|
4425
|
+
async checkIdleWriters() {
|
|
4426
|
+
const now = this.now();
|
|
4427
|
+
const stalled = [];
|
|
4428
|
+
for (const [deviceId, recordings] of this.active) for (const r of recordings) {
|
|
4429
|
+
const since = this.lastSegmentAt.get(idleKey(deviceId, r.profile));
|
|
4430
|
+
if (since === void 0) continue;
|
|
4431
|
+
const idleMs = now - since;
|
|
4432
|
+
const boundMs = writerIdleBoundMs(r.segmentSeconds);
|
|
4433
|
+
if (idleMs > boundMs) stalled.push({
|
|
4434
|
+
deviceId,
|
|
4435
|
+
profile: r.profile,
|
|
4436
|
+
idleMs,
|
|
4437
|
+
boundMs
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
const devices = new Set(stalled.map((s) => s.deviceId));
|
|
4441
|
+
for (const s of stalled) this.deps.logger.warn("recorder: writer pinned but no segment finalized — re-pinning", {
|
|
4442
|
+
tags: { deviceId: s.deviceId },
|
|
4443
|
+
meta: {
|
|
4444
|
+
profile: s.profile,
|
|
4445
|
+
idleMs: s.idleMs,
|
|
4446
|
+
boundMs: s.boundMs,
|
|
4447
|
+
segmentSeconds: this.active.get(s.deviceId)?.find((r) => r.profile === s.profile)?.segmentSeconds
|
|
4448
|
+
}
|
|
4449
|
+
});
|
|
4450
|
+
for (const deviceId of devices) await this.recoverWriterGaveUp(deviceId);
|
|
4451
|
+
}
|
|
2594
4452
|
/** Current trigger state for a device (cold default = no triggers seen). */
|
|
2595
4453
|
triggersFor(deviceId) {
|
|
2596
4454
|
return this.triggers.get(deviceId) ?? {
|
|
@@ -2711,6 +4569,7 @@ var RecordingController = class {
|
|
|
2711
4569
|
}
|
|
2712
4570
|
});
|
|
2713
4571
|
writer.start();
|
|
4572
|
+
this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
|
|
2714
4573
|
return {
|
|
2715
4574
|
profile,
|
|
2716
4575
|
writer,
|
|
@@ -2718,12 +4577,14 @@ var RecordingController = class {
|
|
|
2718
4577
|
outDir,
|
|
2719
4578
|
intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
|
|
2720
4579
|
onFinalized: (startMs, durMs, flatAbsPath) => {
|
|
4580
|
+
this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
|
|
2721
4581
|
this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
|
|
2722
4582
|
},
|
|
2723
4583
|
logger: deviceLog
|
|
2724
4584
|
}),
|
|
2725
4585
|
pipelineKey: source.pipelineKey,
|
|
2726
|
-
outDir
|
|
4586
|
+
outDir,
|
|
4587
|
+
segmentSeconds
|
|
2727
4588
|
};
|
|
2728
4589
|
}
|
|
2729
4590
|
/**
|
|
@@ -2795,6 +4656,7 @@ var RecordingController = class {
|
|
|
2795
4656
|
const recordings = this.active.get(deviceId);
|
|
2796
4657
|
if (!recordings) return;
|
|
2797
4658
|
this.active.delete(deviceId);
|
|
4659
|
+
for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
|
|
2798
4660
|
for (const r of recordings) await this.teardownProfile(deviceId, r);
|
|
2799
4661
|
this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
|
|
2800
4662
|
tags: { deviceId },
|
|
@@ -2841,7 +4703,7 @@ function makeBrokerCall(handle) {
|
|
|
2841
4703
|
*
|
|
2842
4704
|
* Owns its own timer + single-flight guard so a slow sweep is never overlapped
|
|
2843
4705
|
* by the next tick — the same subsystem-owns-its-timer shape as the
|
|
2844
|
-
*
|
|
4706
|
+
* export janitor. Pure over injected deps (config read + prune + logger +
|
|
2845
4707
|
* clock via `setInterval`), so it is unit-testable with fake timers and a mock
|
|
2846
4708
|
* `prune`. Every pass is best-effort: a per-device prune failure is logged and
|
|
2847
4709
|
* never aborts the rest, and the sweep never throws.
|
|
@@ -2943,195 +4805,380 @@ var RetentionSweeper = class {
|
|
|
2943
4805
|
}
|
|
2944
4806
|
};
|
|
2945
4807
|
//#endregion
|
|
2946
|
-
//#region src/recorder/
|
|
4808
|
+
//#region src/recorder/relocate-engine.ts
|
|
2947
4809
|
/**
|
|
2948
|
-
*
|
|
2949
|
-
*
|
|
4810
|
+
* Footage relocation engine (entity-routing spec, Phase 4) — the mover half
|
|
4811
|
+
* of disk decommissioning: mark the old location read-only (Phase 2), then
|
|
4812
|
+
* relocate its footage here, then delete the location.
|
|
2950
4813
|
*
|
|
2951
|
-
*
|
|
2952
|
-
*
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
2955
|
-
*
|
|
2956
|
-
*
|
|
4814
|
+
* Mechanics, per file: throttled copy → size-verify → delete source → index
|
|
4815
|
+
* re-point. The copy is `copy-if-absent` (an equal-size destination is
|
|
4816
|
+
* adopted, not re-copied), so cancel/crash/re-run RESUMES instead of
|
|
4817
|
+
* restarting. Throttling is file-level pacing (segments are ~1–8 MB /
|
|
4818
|
+
* ~4 s, so per-file granularity bounds the rate tightly enough) — the drain
|
|
4819
|
+
* is a background chore that must never starve live writers.
|
|
4820
|
+
*
|
|
4821
|
+
* Runs INSIDE the recorder process — container uid, same-uid files, no host
|
|
4822
|
+
* chown trap (see docs/history 2026-07-29).
|
|
2957
4823
|
*/
|
|
2958
|
-
var
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
function
|
|
2962
|
-
const frames = [];
|
|
2963
|
-
let offset = 0;
|
|
2964
|
-
for (const s of samples) {
|
|
2965
|
-
frames.push({
|
|
2966
|
-
ts: s.ts,
|
|
2967
|
-
offset,
|
|
2968
|
-
size: s.data.byteLength
|
|
2969
|
-
});
|
|
2970
|
-
offset += s.data.byteLength;
|
|
2971
|
-
}
|
|
2972
|
-
const full = {
|
|
2973
|
-
v: 1,
|
|
2974
|
-
codec: header.codec,
|
|
2975
|
-
description: header.description,
|
|
2976
|
-
frames
|
|
2977
|
-
};
|
|
2978
|
-
const headerBytes = new TextEncoder().encode(JSON.stringify(full));
|
|
2979
|
-
const out = new Uint8Array(4 + headerBytes.byteLength + offset);
|
|
2980
|
-
new DataView(out.buffer).setUint32(0, headerBytes.byteLength, true);
|
|
2981
|
-
out.set(headerBytes, 4);
|
|
2982
|
-
let cursor = 4 + headerBytes.byteLength;
|
|
2983
|
-
for (const s of samples) {
|
|
2984
|
-
out.set(s.data, cursor);
|
|
2985
|
-
cursor += s.data.byteLength;
|
|
2986
|
-
}
|
|
2987
|
-
return out;
|
|
2988
|
-
}
|
|
2989
|
-
function decodeScrubPackHeader(buf) {
|
|
2990
|
-
if (buf.byteLength < 4) throw new Error("scrub pack: truncated (no length prefix)");
|
|
2991
|
-
const headerLen = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(0, true);
|
|
2992
|
-
if (headerLen <= 0 || 4 + headerLen > buf.byteLength) throw new Error(`scrub pack: bogus header length ${headerLen}`);
|
|
2993
|
-
const json = new TextDecoder().decode(buf.subarray(4, 4 + headerLen));
|
|
2994
|
-
const header = JSON.parse(json);
|
|
2995
|
-
if (header.v !== 1) throw new Error(`scrub pack: unsupported version ${String(header.v)}`);
|
|
2996
|
-
if (!Array.isArray(header.frames)) throw new Error("scrub pack: malformed header (frames)");
|
|
4824
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
4825
|
+
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
4826
|
+
var PROGRESS_EVERY_FILES = 25;
|
|
4827
|
+
function snapshot(j) {
|
|
2997
4828
|
return {
|
|
2998
|
-
|
|
2999
|
-
|
|
4829
|
+
jobId: j.jobId,
|
|
4830
|
+
state: j.state,
|
|
4831
|
+
fromLocationId: j.fromLocationId,
|
|
4832
|
+
toLocationId: j.toLocationId,
|
|
4833
|
+
deviceId: j.deviceId,
|
|
4834
|
+
entities: [...j.entities],
|
|
4835
|
+
filesMoved: j.filesMoved,
|
|
4836
|
+
bytesMoved: j.bytesMoved,
|
|
4837
|
+
filesTotal: j.filesTotal,
|
|
4838
|
+
startedAt: j.startedAt,
|
|
4839
|
+
finishedAt: j.finishedAt,
|
|
4840
|
+
error: j.error
|
|
3000
4841
|
};
|
|
3001
4842
|
}
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
4843
|
+
/** One file's move: copy-if-absent → verify size → delete source. Returns the
|
|
4844
|
+
* bytes that MOVED (0 when the destination was already complete). */
|
|
4845
|
+
async function moveFile(src, dst, srcBytes) {
|
|
4846
|
+
let copied = 0;
|
|
4847
|
+
let dstBytes = null;
|
|
4848
|
+
try {
|
|
4849
|
+
dstBytes = (await node_fs.promises.stat(dst)).size;
|
|
4850
|
+
} catch {
|
|
4851
|
+
dstBytes = null;
|
|
4852
|
+
}
|
|
4853
|
+
if (dstBytes !== srcBytes) {
|
|
4854
|
+
await node_fs.promises.mkdir(node_path.default.dirname(dst), { recursive: true });
|
|
4855
|
+
await node_fs.promises.copyFile(src, dst);
|
|
4856
|
+
const after = await node_fs.promises.stat(dst);
|
|
4857
|
+
if (after.size !== srcBytes) throw new Error(`relocate verify failed: ${dst} is ${after.size}B, expected ${srcBytes}B`);
|
|
4858
|
+
copied = srcBytes;
|
|
4859
|
+
}
|
|
4860
|
+
await node_fs.promises.rm(src, { force: true });
|
|
4861
|
+
return copied;
|
|
3007
4862
|
}
|
|
3008
|
-
var
|
|
4863
|
+
var RelocateEngine = class {
|
|
3009
4864
|
deps;
|
|
3010
|
-
|
|
4865
|
+
jobs = /* @__PURE__ */ new Map();
|
|
3011
4866
|
constructor(deps) {
|
|
3012
4867
|
this.deps = deps;
|
|
3013
4868
|
}
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
const
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
4869
|
+
list() {
|
|
4870
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
4871
|
+
}
|
|
4872
|
+
cancel(jobId) {
|
|
4873
|
+
const job = this.jobs.get(jobId);
|
|
4874
|
+
if (!job || job.state !== "running") return false;
|
|
4875
|
+
job.cancelRequested = true;
|
|
4876
|
+
return true;
|
|
4877
|
+
}
|
|
4878
|
+
/** Start a relocation. Throws on invalid locations or when a job is already
|
|
4879
|
+
* running (single-flight — two movers on one disk thrash both). */
|
|
4880
|
+
start(input) {
|
|
4881
|
+
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a relocation is already running (${j.jobId})`);
|
|
4882
|
+
const locs = this.deps.locations();
|
|
4883
|
+
const from = locs.find((l) => l.id === input.fromLocationId);
|
|
4884
|
+
const to = locs.find((l) => l.id === input.toLocationId);
|
|
4885
|
+
if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
|
|
4886
|
+
if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
|
|
4887
|
+
if (from.id === to.id) throw new Error("source and target locations are identical");
|
|
4888
|
+
if (from.root === to.root) throw new Error("source and target locations share one root — nothing to move");
|
|
4889
|
+
if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
|
|
4890
|
+
const job = {
|
|
4891
|
+
jobId: this.deps.newId(),
|
|
4892
|
+
state: "running",
|
|
4893
|
+
fromLocationId: from.id,
|
|
4894
|
+
toLocationId: to.id,
|
|
4895
|
+
deviceId: input.deviceId ?? null,
|
|
4896
|
+
entities: input.entities ? [...input.entities] : ["segments", "strips"],
|
|
4897
|
+
filesMoved: 0,
|
|
4898
|
+
bytesMoved: 0,
|
|
4899
|
+
filesTotal: null,
|
|
4900
|
+
startedAt: this.deps.now(),
|
|
4901
|
+
finishedAt: null,
|
|
4902
|
+
error: null,
|
|
4903
|
+
cancelRequested: false
|
|
4904
|
+
};
|
|
4905
|
+
this.jobs.set(job.jobId, job);
|
|
4906
|
+
this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
|
|
4907
|
+
return job.jobId;
|
|
3028
4908
|
}
|
|
3029
|
-
async
|
|
3030
|
-
const
|
|
3031
|
-
const
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
const
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
this.
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
pack: cached,
|
|
3044
|
-
immutable
|
|
3045
|
-
};
|
|
3046
|
-
} catch {}
|
|
3047
|
-
const end = windowStartMs + SCRUB_WINDOW_MS;
|
|
3048
|
-
const rows = this.deps.listLowSegments(deviceId).filter((r) => r.startMs < end && r.startMs + r.durMs > windowStartMs).sort((a, b) => a.startMs - b.startMs);
|
|
3049
|
-
if (rows.length === 0) return null;
|
|
3050
|
-
let codec = null;
|
|
3051
|
-
let description = null;
|
|
3052
|
-
const samples = [];
|
|
3053
|
-
for (const row of rows) {
|
|
3054
|
-
let bytes;
|
|
3055
|
-
try {
|
|
3056
|
-
bytes = await this.deps.readSegmentBytes(row);
|
|
3057
|
-
} catch (err) {
|
|
3058
|
-
this.deps.logger.debug("scrub pack: segment read failed — skipping", { meta: {
|
|
3059
|
-
relPath: row.path,
|
|
3060
|
-
error: String(err)
|
|
3061
|
-
} });
|
|
3062
|
-
continue;
|
|
4909
|
+
async run(job, from, to, throttleMbps) {
|
|
4910
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
4911
|
+
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
4912
|
+
try {
|
|
4913
|
+
const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).sort((a, b) => a.startMs - b.startMs);
|
|
4914
|
+
const stripFiles = job.entities.includes("strips") ? await listStripFiles(from.root, job.deviceId) : [];
|
|
4915
|
+
job.filesTotal = segRows.length + stripFiles.length;
|
|
4916
|
+
for (const row of segRows) {
|
|
4917
|
+
if (job.cancelRequested) break;
|
|
4918
|
+
await this.moveSegment(row, from, to);
|
|
4919
|
+
job.filesMoved++;
|
|
4920
|
+
job.bytesMoved += row.bytes;
|
|
4921
|
+
if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
|
|
4922
|
+
await sleep(row.bytes / bytesPerMs);
|
|
3063
4923
|
}
|
|
3064
|
-
const
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
expected: codec,
|
|
3073
|
-
got: extracted.codec
|
|
3074
|
-
} });
|
|
3075
|
-
continue;
|
|
4924
|
+
for (const rel of stripFiles) {
|
|
4925
|
+
if (job.cancelRequested) break;
|
|
4926
|
+
const src = node_path.default.join(from.root, rel);
|
|
4927
|
+
const bytes = (await node_fs.promises.stat(src)).size;
|
|
4928
|
+
await moveFile(src, node_path.default.join(to.root, rel), bytes);
|
|
4929
|
+
job.filesMoved++;
|
|
4930
|
+
job.bytesMoved += bytes;
|
|
4931
|
+
await sleep(bytes / bytesPerMs);
|
|
3076
4932
|
}
|
|
3077
|
-
|
|
3078
|
-
}
|
|
3079
|
-
if (codec === null || description === null || samples.length === 0) return null;
|
|
3080
|
-
samples.sort((a, b) => a.ts - b.ts);
|
|
3081
|
-
const pack = encodeScrubPack({
|
|
3082
|
-
codec,
|
|
3083
|
-
description: Buffer.from(description).toString("base64")
|
|
3084
|
-
}, samples);
|
|
3085
|
-
if (immutable) try {
|
|
3086
|
-
await node_fs.promises.mkdir(node_path.default.dirname(cacheFile), { recursive: true });
|
|
3087
|
-
const tmpFile = `${cacheFile}.tmp`;
|
|
3088
|
-
await node_fs.promises.writeFile(tmpFile, pack);
|
|
3089
|
-
await node_fs.promises.rename(tmpFile, cacheFile);
|
|
4933
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
3090
4934
|
} catch (err) {
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
4935
|
+
job.state = "failed";
|
|
4936
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
4937
|
+
this.deps.logger.warn("relocate job failed", { meta: {
|
|
4938
|
+
jobId: job.jobId,
|
|
4939
|
+
error: job.error
|
|
3094
4940
|
} });
|
|
4941
|
+
} finally {
|
|
4942
|
+
job.finishedAt = this.deps.now();
|
|
4943
|
+
this.deps.onFinished?.(snapshot(job));
|
|
3095
4944
|
}
|
|
3096
|
-
return {
|
|
3097
|
-
pack,
|
|
3098
|
-
immutable
|
|
3099
|
-
};
|
|
3100
4945
|
}
|
|
3101
|
-
/**
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
4946
|
+
/** Move one segment file and re-point its index row to the target location
|
|
4947
|
+
* (same location-relative path — the layout is identical on every root). */
|
|
4948
|
+
async moveSegment(row, from, to) {
|
|
4949
|
+
await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
|
|
4950
|
+
this.deps.index.removeSegments([row.path]);
|
|
4951
|
+
this.deps.index.addSegment({
|
|
4952
|
+
...row,
|
|
4953
|
+
locationId: to.id
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4956
|
+
};
|
|
4957
|
+
/** The location-relative strip files under `<root>/<dev>/strips/…`, optionally
|
|
4958
|
+
* scoped to one device. Missing dirs are data (no strips), not failures. */
|
|
4959
|
+
async function listStripFiles(root, deviceId) {
|
|
4960
|
+
const devices = [];
|
|
4961
|
+
if (deviceId !== null) devices.push(String(deviceId));
|
|
4962
|
+
else try {
|
|
4963
|
+
for (const e of await node_fs.promises.readdir(root)) if (/^\d+$/.test(e)) devices.push(e);
|
|
4964
|
+
} catch {
|
|
4965
|
+
return [];
|
|
4966
|
+
}
|
|
4967
|
+
const out = [];
|
|
4968
|
+
for (const dev of devices) {
|
|
4969
|
+
const stripDir = node_path.default.join(root, dev, "strips");
|
|
4970
|
+
let entries;
|
|
3106
4971
|
try {
|
|
3107
|
-
|
|
4972
|
+
entries = await node_fs.promises.readdir(stripDir, { recursive: true });
|
|
3108
4973
|
} catch {
|
|
3109
|
-
|
|
4974
|
+
continue;
|
|
3110
4975
|
}
|
|
3111
|
-
for (const
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
4976
|
+
for (const e of entries) if (e.endsWith(".strip")) out.push(node_path.default.join(dev, "strips", e.split(node_path.default.sep).join("/")));
|
|
4977
|
+
}
|
|
4978
|
+
return out;
|
|
4979
|
+
}
|
|
4980
|
+
//#endregion
|
|
4981
|
+
//#region src/recorder/addon/footage-render.ts
|
|
4982
|
+
/**
|
|
4983
|
+
* One-shot media render FROM RECORDED FOOTAGE (notification attachments).
|
|
4984
|
+
*
|
|
4985
|
+
* Shared core behind `renderGif` and `renderClip`: both cut the same window out
|
|
4986
|
+
* of the same `low` profile through the export machinery's per-render source
|
|
4987
|
+
* playlist (absolute segment URIs — the ONLY playlist shape ffmpeg can consume,
|
|
4988
|
+
* see export-source-playlist.ts), pipe it through ONE ffmpeg invocation, and
|
|
4989
|
+
* return the bytes. Everything is transient: playlist and output are deleted on
|
|
4990
|
+
* every outcome.
|
|
4991
|
+
*
|
|
4992
|
+
* Fail-closed by construction: no footage covering the window ⇒ it throws, and
|
|
4993
|
+
* the caller (a notification rule) simply ships no attachment. A camera that is
|
|
4994
|
+
* not recording therefore cannot produce a clip — which is exactly why the
|
|
4995
|
+
* frame-ring plane (roadmap Phase 5) still matters for cameras with no
|
|
4996
|
+
* recording. It is NOT a prerequisite for the cameras that do record.
|
|
4997
|
+
*/
|
|
4998
|
+
var RENDER_TIMEOUT_MS = 3e4;
|
|
4999
|
+
/**
|
|
5000
|
+
* A notification clip must sit AROUND the moment it describes. Sliding the
|
|
5001
|
+
* window back to the last finalized segment made the attachment exist, but it
|
|
5002
|
+
* showed the seconds BEFORE the trigger — an operator watching it sees the
|
|
5003
|
+
* approach and not the event, which is worse than a short wait (operator,
|
|
5004
|
+
* 2026-07-30). The caller therefore asks for the window it wants and the
|
|
5005
|
+
* renderer refuses until the footage covering it exists; the DISPATCHER waits
|
|
5006
|
+
* (the outbox already retries with backoff) and gives up into a still-only
|
|
5007
|
+
* notification rather than delaying delivery indefinitely.
|
|
5008
|
+
*/
|
|
5009
|
+
var FOOTAGE_NOT_READY = "footage-not-yet-finalized";
|
|
5010
|
+
/**
|
|
5011
|
+
* GIF for the widest notifier reach (every backend that takes an image takes a
|
|
5012
|
+
* gif); `fps` decimates and lanczos downscales.
|
|
5013
|
+
*/
|
|
5014
|
+
async function renderGif(renderDeps, input) {
|
|
5015
|
+
const out = await renderFootage(renderDeps, input, "gif", (playlistPath, outPath) => [
|
|
5016
|
+
"-y",
|
|
5017
|
+
"-i",
|
|
5018
|
+
playlistPath,
|
|
5019
|
+
"-vf",
|
|
5020
|
+
`fps=${input.fps},scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
5021
|
+
"-loop",
|
|
5022
|
+
"0",
|
|
5023
|
+
"-f",
|
|
5024
|
+
"gif",
|
|
5025
|
+
outPath
|
|
5026
|
+
]);
|
|
5027
|
+
return {
|
|
5028
|
+
gifBase64: out.bytes.toString("base64"),
|
|
5029
|
+
fromMs: out.fromMs,
|
|
5030
|
+
toMs: out.toMs
|
|
5031
|
+
};
|
|
5032
|
+
}
|
|
5033
|
+
/**
|
|
5034
|
+
* MP4 clip — the "send a clip, not a gif" attachment. Re-encodes rather than
|
|
5035
|
+
* stream-copying: a concat of segments whose encoder parameters differ (a
|
|
5036
|
+
* profile switch, a broker redial) produces an mp4 no player will scrub, and a
|
|
5037
|
+
* notification attachment that will not play is worse than none. `faststart`
|
|
5038
|
+
* puts the moov up front so a chat client can play it without the whole file.
|
|
5039
|
+
*/
|
|
5040
|
+
async function renderClip(renderDeps, input) {
|
|
5041
|
+
const out = await renderFootage(renderDeps, input, "mp4", (playlistPath, outPath) => [
|
|
5042
|
+
"-y",
|
|
5043
|
+
"-i",
|
|
5044
|
+
playlistPath,
|
|
5045
|
+
"-vf",
|
|
5046
|
+
`scale=${input.maxWidth}:-2:flags=lanczos`,
|
|
5047
|
+
"-c:v",
|
|
5048
|
+
"libx264",
|
|
5049
|
+
"-preset",
|
|
5050
|
+
"veryfast",
|
|
5051
|
+
"-crf",
|
|
5052
|
+
"28",
|
|
5053
|
+
"-profile:v",
|
|
5054
|
+
"main",
|
|
5055
|
+
"-pix_fmt",
|
|
5056
|
+
"yuv420p",
|
|
5057
|
+
"-movflags",
|
|
5058
|
+
"+faststart",
|
|
5059
|
+
"-an",
|
|
5060
|
+
"-f",
|
|
5061
|
+
"mp4",
|
|
5062
|
+
outPath
|
|
5063
|
+
]);
|
|
5064
|
+
return {
|
|
5065
|
+
clipBase64: out.bytes.toString("base64"),
|
|
5066
|
+
mime: "video/mp4",
|
|
5067
|
+
fromMs: out.fromMs,
|
|
5068
|
+
toMs: out.toMs,
|
|
5069
|
+
bytes: out.bytes.byteLength
|
|
5070
|
+
};
|
|
5071
|
+
}
|
|
5072
|
+
/** The shared playlist → ffmpeg → bytes pipeline. */
|
|
5073
|
+
async function renderFootage(renderDeps, input, ext, buildArgs) {
|
|
5074
|
+
const now = renderDeps.now ?? Date.now;
|
|
5075
|
+
const fromMs = input.aroundMs - input.preRollSec * 1e3;
|
|
5076
|
+
const toMs = input.aroundMs + input.postRollSec * 1e3;
|
|
5077
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) throw new Error(`invalid ${ext} window [${fromMs}, ${toMs}) for device ${input.deviceId} — aroundMs=${input.aroundMs} preRollSec=${input.preRollSec} postRollSec=${input.postRollSec}`);
|
|
5078
|
+
const renderId = `${ext}-${now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
5079
|
+
const availableEnd = latestFinalizedEnd(renderDeps, input.deviceId);
|
|
5080
|
+
if (availableEnd === null || availableEnd < toMs) throw new Error(`${FOOTAGE_NOT_READY}: [${fromMs}, ${toMs}) for device ${input.deviceId} — finalized footage ends at ${availableEnd ?? "never"}`);
|
|
5081
|
+
const window = {
|
|
5082
|
+
fromMs,
|
|
5083
|
+
toMs
|
|
5084
|
+
};
|
|
5085
|
+
const playlist = await writeExportSourcePlaylist({
|
|
5086
|
+
deps: renderDeps.deps,
|
|
5087
|
+
exportsDir: renderDeps.exportsDir,
|
|
5088
|
+
exportId: renderId,
|
|
5089
|
+
deviceId: input.deviceId,
|
|
5090
|
+
profile: "low",
|
|
5091
|
+
fromMs: window.fromMs,
|
|
5092
|
+
toMs: window.toMs
|
|
5093
|
+
});
|
|
5094
|
+
if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
|
|
5095
|
+
const outPath = node_path.default.join(node_path.default.dirname(playlist), `${renderId}.${ext}`);
|
|
5096
|
+
try {
|
|
5097
|
+
await runFfmpeg(renderDeps, buildArgs(playlist, outPath), ext);
|
|
5098
|
+
const bytes = await node_fs.promises.readFile(outPath);
|
|
5099
|
+
if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
|
|
5100
|
+
return {
|
|
5101
|
+
bytes,
|
|
5102
|
+
fromMs: window.fromMs,
|
|
5103
|
+
toMs: window.toMs
|
|
5104
|
+
};
|
|
5105
|
+
} finally {
|
|
5106
|
+
await node_fs.promises.rm(playlist, { force: true }).catch(() => {});
|
|
5107
|
+
await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
/** The end of the newest FINALIZED `low` segment, or null when none exists. */
|
|
5111
|
+
function latestFinalizedEnd(renderDeps, deviceId) {
|
|
5112
|
+
const rows = renderDeps.deps.index.segments(deviceId, "low");
|
|
5113
|
+
let end = null;
|
|
5114
|
+
for (const s of rows) {
|
|
5115
|
+
const candidate = s.startMs + s.durMs;
|
|
5116
|
+
if (end === null || candidate > end) end = candidate;
|
|
5117
|
+
}
|
|
5118
|
+
return end;
|
|
5119
|
+
}
|
|
5120
|
+
/**
|
|
5121
|
+
* Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
|
|
5122
|
+
*
|
|
5123
|
+
* The previous shape leaked on two paths: a `spawn` error (`ENOENT`, a bad
|
|
5124
|
+
* path) rejected while a process could still be alive, and a `close` with a
|
|
5125
|
+
* non-zero code rejected without ever reaping stdio. These renders fire on
|
|
5126
|
+
* every notification with a gif or clip, so a leak here is a slow accumulation
|
|
5127
|
+
* of ffmpeg processes on the recording node — the kind that shows up days later
|
|
5128
|
+
* as "the hub got slow" (operator, 2026-07-30).
|
|
5129
|
+
*
|
|
5130
|
+
* `settle` is idempotent and always kills first: killing an already-exited pid
|
|
5131
|
+
* throws ESRCH, which is swallowed, so the cost of being wrong is nothing while
|
|
5132
|
+
* the cost of NOT killing is a zombie per render.
|
|
5133
|
+
*/
|
|
5134
|
+
function runFfmpeg(renderDeps, args, ext) {
|
|
5135
|
+
return new Promise((resolve, reject) => {
|
|
5136
|
+
const proc = renderDeps.spawn(renderDeps.ffmpegPath ?? "ffmpeg", args);
|
|
5137
|
+
let done = false;
|
|
5138
|
+
let timer = null;
|
|
5139
|
+
const settle = (err) => {
|
|
5140
|
+
if (done) return;
|
|
5141
|
+
done = true;
|
|
5142
|
+
if (timer !== null) {
|
|
5143
|
+
clearTimeout(timer);
|
|
5144
|
+
timer = null;
|
|
3125
5145
|
}
|
|
5146
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
5147
|
+
proc.kill("SIGKILL");
|
|
5148
|
+
} catch {}
|
|
3126
5149
|
try {
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
}).catch(() => {});
|
|
5150
|
+
proc.stderr?.removeAllListeners();
|
|
5151
|
+
proc.stdout?.destroy();
|
|
5152
|
+
proc.stderr?.destroy();
|
|
3131
5153
|
} catch {}
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
};
|
|
5154
|
+
if (err === null) resolve();
|
|
5155
|
+
else reject(err);
|
|
5156
|
+
};
|
|
5157
|
+
timer = setTimeout(() => settle(/* @__PURE__ */ new Error(`${ext} render timed out`)), RENDER_TIMEOUT_MS);
|
|
5158
|
+
let stderrTail = "";
|
|
5159
|
+
proc.stderr?.on("data", (c) => {
|
|
5160
|
+
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
5161
|
+
});
|
|
5162
|
+
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
5163
|
+
proc.on("close", (code) => {
|
|
5164
|
+
settle(code === 0 ? null : /* @__PURE__ */ new Error(`ffmpeg ${ext} render exited ${code}: ${stderrTail}`));
|
|
5165
|
+
});
|
|
5166
|
+
});
|
|
5167
|
+
}
|
|
5168
|
+
//#endregion
|
|
5169
|
+
//#region src/recorder/addon/recording-hub-host.ts
|
|
5170
|
+
/**
|
|
5171
|
+
* Resolve the host a REMOTE recording node should use to dial the hub-only
|
|
5172
|
+
* stream-broker's restream port.
|
|
5173
|
+
*
|
|
5174
|
+
* The implementation was promoted to the package-shared
|
|
5175
|
+
* `src/shared/hub-hostname.ts` in P2d (cross-node Phase 2) so the
|
|
5176
|
+
* pipeline-runner's remote-source leg shares the exact same resolver instead
|
|
5177
|
+
* of copying it. This module keeps the recorder-facing name as a thin alias —
|
|
5178
|
+
* resolution order and semantics are unchanged (operator override first, then
|
|
5179
|
+
* the host extracted from `CAMSTACK_HUB_URL`).
|
|
5180
|
+
*/
|
|
5181
|
+
var resolveRecordingHubHostname = require_hub_hostname.resolveHubHostname;
|
|
3135
5182
|
//#endregion
|
|
3136
5183
|
//#region src/recorder/still/still-source.ts
|
|
3137
5184
|
/**
|
|
@@ -3329,9 +5376,6 @@ var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
|
|
|
3329
5376
|
* form the hub reverse-proxy client path `/addon/recorder/playback`. */
|
|
3330
5377
|
var RECORDER_ADDON_ID = "recorder";
|
|
3331
5378
|
var PLAYBACK_PREFIX = "playback";
|
|
3332
|
-
/** Data-plane prefix for keyframe-only scrub packs →
|
|
3333
|
-
* `/addon/recorder/scrub/<deviceId>/<windowStartSec>`. */
|
|
3334
|
-
var SCRUB_PREFIX = "scrub";
|
|
3335
5379
|
/** Data-plane prefix for on-demand single stills →
|
|
3336
5380
|
* `/addon/recorder/still/<deviceId>/<epochSec>`. */
|
|
3337
5381
|
var STILL_PREFIX = "still";
|
|
@@ -3345,19 +5389,6 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
3345
5389
|
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
3346
5390
|
* the recording-spec §6 periodic retention. */
|
|
3347
5391
|
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
3348
|
-
/** Scrub-pack cache prune cadence — mirrors the thumbnail service's own prune
|
|
3349
|
-
* tick (6h) + initial delay (10min off boot churn). Packs are a rebuild-on-
|
|
3350
|
-
* demand cache, so a slow sweep is plenty. */
|
|
3351
|
-
var SCRUB_PRUNE_TICK_MS = 6 * 36e5;
|
|
3352
|
-
var SCRUB_PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
|
|
3353
|
-
/** Retention horizon for the scrub-pack cache (days). Packs are a keyframe-only
|
|
3354
|
-
* CACHE derived from the LOW footage — never primary data — so a pruned window
|
|
3355
|
-
* simply rebuilds on the next request while its segments still exist. A fixed,
|
|
3356
|
-
* generous horizon (comfortably past any typical per-device footage max-age)
|
|
3357
|
-
* reclaims disk from truly-old orphaned packs without racing footage retention.
|
|
3358
|
-
* The recorder has no single global footage max-age (it is per-device), so a
|
|
3359
|
-
* fixed cache horizon is the correct scope here. */
|
|
3360
|
-
var SCRUB_PACK_RETENTION_DAYS = 90;
|
|
3361
5392
|
/** Grace after a completed download before a `deleteAfterDownload` file is removed. */
|
|
3362
5393
|
var EXPORT_DELETE_GRACE_MS = 6e4;
|
|
3363
5394
|
/** Only `.mp4` export ids matching this shape are servable (traversal guard). */
|
|
@@ -3376,7 +5407,36 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3376
5407
|
* playback, runs no controller. Resolved in `onInitialize`. */
|
|
3377
5408
|
isRecordingNode = true;
|
|
3378
5409
|
index = new RecordingIndex();
|
|
5410
|
+
/**
|
|
5411
|
+
* The calendar rungs — which days, which hours, and a day's exact ranges.
|
|
5412
|
+
*
|
|
5413
|
+
* Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
|
|
5414
|
+
* (retention, eviction, footprint) and is hydrated by a walk that takes
|
|
5415
|
+
* minutes. The calendar answers what a screen asks and is built from
|
|
5416
|
+
* directory names, so the first screen no longer waits on the walk.
|
|
5417
|
+
*/
|
|
5418
|
+
calendarInstance = null;
|
|
5419
|
+
/** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
|
|
5420
|
+
* reading `this.ctx` there throws — the same trap that bit the playback tier. */
|
|
5421
|
+
get calendar() {
|
|
5422
|
+
this.calendarInstance ??= new CalendarIndex({
|
|
5423
|
+
roots: () => this.resolvedLocations.map((l) => l.root),
|
|
5424
|
+
logger: this.ctx.logger
|
|
5425
|
+
});
|
|
5426
|
+
return this.calendarInstance;
|
|
5427
|
+
}
|
|
3379
5428
|
segmentStore = null;
|
|
5429
|
+
/** Sync-sample tables captured at finalize, served by the directory so a
|
|
5430
|
+
* playback seek needs no mfra tail fetch. RAM-only, TTL-bounded. */
|
|
5431
|
+
mfraTables = new MfraTableStore();
|
|
5432
|
+
/** Keyframe thumbnail strips (fast-drag scrub depth), one file per
|
|
5433
|
+
* camera-hour under the LOW recordings root. */
|
|
5434
|
+
stripStore = new StripStore(() => {
|
|
5435
|
+
const writable = this.resolvedLocations.filter((l) => !l.readOnly);
|
|
5436
|
+
return (writable.find((l) => l.type === "recordingsLow") ?? writable[0])?.root ?? null;
|
|
5437
|
+
});
|
|
5438
|
+
/** Bounded one-shot ffmpeg pool feeding the strip at finalize. */
|
|
5439
|
+
stripCapture = new StripCapture({ onLog: (msg, data) => this.ctx.logger.debug(msg, { meta: data }) });
|
|
3380
5440
|
/** Continuous + events band write-path controller (ffmpeg writers + watchers). */
|
|
3381
5441
|
controller = null;
|
|
3382
5442
|
/** Per-device motion/audio markers for playback (B3 events feed the controller). */
|
|
@@ -3389,13 +5449,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3389
5449
|
playbackBaseUrl = null;
|
|
3390
5450
|
/** On-demand single-still decode service (export-dialog frame previews). Null
|
|
3391
5451
|
* on non-recording nodes / when the data-plane serve fails. */
|
|
3392
|
-
/** Keyframe-only scrub-pack service (built + served in onInitialize). Null on
|
|
3393
|
-
* non-recording nodes / when the data-plane serve fails. */
|
|
3394
|
-
scrubPackService = null;
|
|
3395
|
-
/** Periodic scrub-pack cache prune timer + its one-shot initial delay. Cleared
|
|
3396
|
-
* on shutdown. */
|
|
3397
|
-
scrubPruneTimer = null;
|
|
3398
|
-
scrubPruneInitialTimer = null;
|
|
3399
5452
|
/** Render engine for recording exports. Null on non-recording nodes. */
|
|
3400
5453
|
exportEngine = null;
|
|
3401
5454
|
/** Periodic export-expiry sweep timer. Cleared on shutdown. */
|
|
@@ -3455,6 +5508,38 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3455
5508
|
force: true
|
|
3456
5509
|
});
|
|
3457
5510
|
},
|
|
5511
|
+
onEvicted: (deviceId, atMs) => {
|
|
5512
|
+
this.calendar.invalidate(deviceId, atMs);
|
|
5513
|
+
this.stripStore.removeHour(deviceId, atMs);
|
|
5514
|
+
},
|
|
5515
|
+
onIndexed: (row, absPath) => {
|
|
5516
|
+
readMfraTable(absPath, row.bytes).then(async (captured) => {
|
|
5517
|
+
this.mfraTables.record(row.deviceId, row.profile, row.startMs, captured.table);
|
|
5518
|
+
if (row.profile !== "low") return;
|
|
5519
|
+
if ((await loadDeviceConfig(this.configStore(), row.deviceId)).stripsEnabled !== true) return;
|
|
5520
|
+
const jpegs = await this.stripCapture.capture(absPath);
|
|
5521
|
+
if (jpegs === null || jpegs.length === 0) return;
|
|
5522
|
+
const instants = keyframeInstants(row.startMs, row.durMs, captured);
|
|
5523
|
+
const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
|
|
5524
|
+
tsMs: instants[i] ?? row.startMs,
|
|
5525
|
+
jpeg
|
|
5526
|
+
}));
|
|
5527
|
+
await this.stripStore.append(row.deviceId, thumbs);
|
|
5528
|
+
}).catch((err) => {
|
|
5529
|
+
this.ctx.logger.debug("mfra/strip capture failed", {
|
|
5530
|
+
tags: { deviceId: row.deviceId },
|
|
5531
|
+
meta: {
|
|
5532
|
+
path: row.path,
|
|
5533
|
+
error: require_dist.errMsg(err)
|
|
5534
|
+
}
|
|
5535
|
+
});
|
|
5536
|
+
});
|
|
5537
|
+
},
|
|
5538
|
+
removeDirIfEmpty: async (locationId, relDir) => {
|
|
5539
|
+
const loc = this.resolvedLocations.find((l) => l.id === locationId);
|
|
5540
|
+
if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
|
|
5541
|
+
await node_fs.promises.rmdir(node_path.default.join(loc.root, relDir));
|
|
5542
|
+
},
|
|
3458
5543
|
logger: { warn: (message, extras) => {
|
|
3459
5544
|
if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
|
|
3460
5545
|
tags: extras.tags,
|
|
@@ -3490,6 +5575,32 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3490
5575
|
newId: () => (0, node_crypto.randomUUID)(),
|
|
3491
5576
|
logger: this.ctx.logger.child("ops-log")
|
|
3492
5577
|
});
|
|
5578
|
+
const relocateEngine = new RelocateEngine({
|
|
5579
|
+
index: this.index,
|
|
5580
|
+
locations: () => this.resolvedLocations,
|
|
5581
|
+
logger: this.ctx.logger.child("relocate"),
|
|
5582
|
+
now: () => Date.now(),
|
|
5583
|
+
newId: () => (0, node_crypto.randomUUID)(),
|
|
5584
|
+
onFinished: (job) => {
|
|
5585
|
+
opsLog.append({
|
|
5586
|
+
op: "relocate",
|
|
5587
|
+
reason: "operator",
|
|
5588
|
+
deviceId: job.deviceId,
|
|
5589
|
+
itemsAffected: job.filesMoved,
|
|
5590
|
+
bytesReclaimed: 0,
|
|
5591
|
+
detail: `${job.state}: ${job.fromLocationId} → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
|
|
5592
|
+
});
|
|
5593
|
+
this.calendar.invalidateAll();
|
|
5594
|
+
},
|
|
5595
|
+
onProgress: (job) => {
|
|
5596
|
+
this.ctx.logger.info("relocate progress", { meta: {
|
|
5597
|
+
jobId: job.jobId,
|
|
5598
|
+
filesMoved: job.filesMoved,
|
|
5599
|
+
filesTotal: job.filesTotal,
|
|
5600
|
+
bytesMoved: job.bytesMoved
|
|
5601
|
+
} });
|
|
5602
|
+
}
|
|
5603
|
+
});
|
|
3493
5604
|
const recordingDeps = {
|
|
3494
5605
|
index: this.index,
|
|
3495
5606
|
segmentStore: this.segmentStore,
|
|
@@ -3497,10 +5608,23 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3497
5608
|
logger: this.ctx.logger,
|
|
3498
5609
|
configStore: this.configStore(),
|
|
3499
5610
|
opsLog,
|
|
5611
|
+
relocate: relocateEngine,
|
|
5612
|
+
gifRender: (input) => renderGif({
|
|
5613
|
+
deps: recordingDeps,
|
|
5614
|
+
exportsDir: node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX),
|
|
5615
|
+
spawn: node_child_process.spawn
|
|
5616
|
+
}, input),
|
|
5617
|
+
clipRender: (input) => renderClip({
|
|
5618
|
+
deps: recordingDeps,
|
|
5619
|
+
exportsDir: node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX),
|
|
5620
|
+
spawn: node_child_process.spawn
|
|
5621
|
+
}, input),
|
|
3500
5622
|
locations: () => this.resolvedLocations,
|
|
3501
5623
|
refreshLocations: () => this.refreshLocations(),
|
|
3502
5624
|
capacity: (root) => this.locationCapacity(root),
|
|
3503
5625
|
hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
|
|
5626
|
+
calendar: this.calendar,
|
|
5627
|
+
hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
|
|
3504
5628
|
onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
|
|
3505
5629
|
dataDir: this.ctx.dataDir,
|
|
3506
5630
|
playbackBaseUrl: () => this.playbackBaseUrl,
|
|
@@ -3519,28 +5643,39 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3519
5643
|
index: this.index,
|
|
3520
5644
|
store: this.segmentStore,
|
|
3521
5645
|
resolveDomain: (locationId) => this.locationDomain(locationId),
|
|
5646
|
+
isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
|
|
3522
5647
|
onEvicted: (summaries) => {
|
|
3523
|
-
for (const s of summaries)
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
type: "device",
|
|
3528
|
-
id: s.deviceId,
|
|
3529
|
-
addonId: this.ctx.id,
|
|
3530
|
-
deviceId: s.deviceId,
|
|
3531
|
-
nodeId: this.nodeId
|
|
3532
|
-
},
|
|
3533
|
-
category: require_dist.EventCategory.RecordingRetentionCompleted,
|
|
3534
|
-
data: {
|
|
5648
|
+
for (const s of summaries) {
|
|
5649
|
+
opsLog.append({
|
|
5650
|
+
op: "prune",
|
|
5651
|
+
reason: "quota",
|
|
3535
5652
|
deviceId: s.deviceId,
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
5653
|
+
itemsAffected: s.segments,
|
|
5654
|
+
bytesReclaimed: s.bytes,
|
|
5655
|
+
detail: `disk-pressure evicted [${new Date(s.fromMs).toISOString()}, ${new Date(s.toMs).toISOString()}) on ${s.locationId}`
|
|
5656
|
+
});
|
|
5657
|
+
this.ctx.eventBus.emit({
|
|
5658
|
+
id: (0, node_crypto.randomUUID)(),
|
|
5659
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5660
|
+
source: {
|
|
5661
|
+
type: "device",
|
|
5662
|
+
id: s.deviceId,
|
|
5663
|
+
addonId: this.ctx.id,
|
|
5664
|
+
deviceId: s.deviceId,
|
|
5665
|
+
nodeId: this.nodeId
|
|
5666
|
+
},
|
|
5667
|
+
category: require_dist.EventCategory.RecordingRetentionCompleted,
|
|
5668
|
+
data: {
|
|
5669
|
+
deviceId: s.deviceId,
|
|
5670
|
+
locationId: s.locationId,
|
|
5671
|
+
segments: s.segments,
|
|
5672
|
+
bytes: s.bytes,
|
|
5673
|
+
fromMs: s.fromMs,
|
|
5674
|
+
toMs: s.toMs,
|
|
5675
|
+
trigger: "disk-pressure"
|
|
5676
|
+
}
|
|
5677
|
+
});
|
|
5678
|
+
}
|
|
3544
5679
|
}
|
|
3545
5680
|
});
|
|
3546
5681
|
const exportsState = this.exportsStore();
|
|
@@ -3583,13 +5718,22 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3583
5718
|
downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
|
|
3584
5719
|
});
|
|
3585
5720
|
try {
|
|
3586
|
-
const handler =
|
|
5721
|
+
const handler = withStripRoutes({
|
|
5722
|
+
store: this.stripStore,
|
|
5723
|
+
onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
|
|
5724
|
+
}, withDirectoryRoute({
|
|
5725
|
+
source: this.calendar,
|
|
5726
|
+
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
5727
|
+
onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
|
|
5728
|
+
}, require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() })));
|
|
3587
5729
|
const served = await this.ctx.dataPlane?.serve({
|
|
3588
5730
|
prefix: PLAYBACK_PREFIX,
|
|
3589
5731
|
access: "authenticated",
|
|
3590
5732
|
handler
|
|
3591
5733
|
});
|
|
3592
|
-
|
|
5734
|
+
const clientPath = `/addon/${RECORDER_ADDON_ID}/${PLAYBACK_PREFIX}`;
|
|
5735
|
+
if (clientPath !== "/addon/recorder/playback") throw new Error(`recorder playback path drifted: serving ${clientPath}, clients use ${PLAYBACK_PLANE_PATH}`);
|
|
5736
|
+
this.playbackBaseUrl = served ? clientPath : null;
|
|
3593
5737
|
this.ctx.logger.info("recorder playback data-plane served", { meta: {
|
|
3594
5738
|
clientBase: this.playbackBaseUrl ?? "(no dataPlane facility)",
|
|
3595
5739
|
internal: served?.baseUrl
|
|
@@ -3597,37 +5741,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3597
5741
|
} catch (err) {
|
|
3598
5742
|
this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
3599
5743
|
}
|
|
3600
|
-
try {
|
|
3601
|
-
const scrubService = new ScrubPackService({
|
|
3602
|
-
listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
3603
|
-
readSegmentBytes: async (row) => {
|
|
3604
|
-
const abs = this.resolveSegmentAbsPath(row);
|
|
3605
|
-
return new Uint8Array(await node_fs.promises.readFile(abs));
|
|
3606
|
-
},
|
|
3607
|
-
cacheDir: () => this.resolveScrubCacheRoot(),
|
|
3608
|
-
logger: this.ctx.logger.child("ScrubPacks")
|
|
3609
|
-
});
|
|
3610
|
-
this.scrubPackService = scrubService;
|
|
3611
|
-
const served = await this.ctx.dataPlane?.serve({
|
|
3612
|
-
prefix: SCRUB_PREFIX,
|
|
3613
|
-
access: "authenticated",
|
|
3614
|
-
handler: (req, res) => this.serveScrubPack(scrubService, req, res)
|
|
3615
|
-
});
|
|
3616
|
-
this.scrubPruneInitialTimer = setTimeout(() => {
|
|
3617
|
-
this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
|
|
3618
|
-
}, SCRUB_PRUNE_INITIAL_DELAY_MS);
|
|
3619
|
-
this.scrubPruneInitialTimer.unref?.();
|
|
3620
|
-
this.scrubPruneTimer = setInterval(() => {
|
|
3621
|
-
this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
|
|
3622
|
-
}, SCRUB_PRUNE_TICK_MS);
|
|
3623
|
-
this.scrubPruneTimer.unref?.();
|
|
3624
|
-
this.ctx.logger.info("recorder scrub-pack data-plane served", { meta: {
|
|
3625
|
-
clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${SCRUB_PREFIX}` : "(no dataPlane)",
|
|
3626
|
-
internal: served?.baseUrl
|
|
3627
|
-
} });
|
|
3628
|
-
} catch (err) {
|
|
3629
|
-
this.ctx.logger.warn("scrub-pack data-plane serve failed", { meta: { error: require_dist.errMsg(err) } });
|
|
3630
|
-
}
|
|
3631
5744
|
try {
|
|
3632
5745
|
const stillService = new StillFrameService({
|
|
3633
5746
|
listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
@@ -3666,20 +5779,25 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3666
5779
|
watchIntervalMs: this.config.watchIntervalMs
|
|
3667
5780
|
}
|
|
3668
5781
|
});
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
5782
|
+
warnLostBands(this.configStore(), this.ctx.logger);
|
|
5783
|
+
return {
|
|
5784
|
+
providers: [
|
|
5785
|
+
{
|
|
5786
|
+
capability: require_dist.recordingCapability,
|
|
5787
|
+
provider: recordingProvider
|
|
5788
|
+
},
|
|
5789
|
+
{
|
|
5790
|
+
capability: require_dist.storageEvictableCapability,
|
|
5791
|
+
provider: evictableProvider
|
|
5792
|
+
},
|
|
5793
|
+
{
|
|
5794
|
+
capability: require_dist.recordingExportCapability,
|
|
5795
|
+
provider: exportProvider
|
|
5796
|
+
}
|
|
5797
|
+
],
|
|
5798
|
+
customActions: recorderStripActions,
|
|
5799
|
+
actionHandlers: { regenerateStrips: async (input) => this.regenerateStrips(RegenerateStripsInputSchema.parse(input)) }
|
|
5800
|
+
};
|
|
3683
5801
|
}
|
|
3684
5802
|
async onShutdown() {
|
|
3685
5803
|
this.eventUnsub?.();
|
|
@@ -3687,15 +5805,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3687
5805
|
await this.controller?.stop();
|
|
3688
5806
|
this.controller = null;
|
|
3689
5807
|
this.segmentStore = null;
|
|
3690
|
-
if (this.scrubPruneInitialTimer !== null) {
|
|
3691
|
-
clearTimeout(this.scrubPruneInitialTimer);
|
|
3692
|
-
this.scrubPruneInitialTimer = null;
|
|
3693
|
-
}
|
|
3694
|
-
if (this.scrubPruneTimer !== null) {
|
|
3695
|
-
clearInterval(this.scrubPruneTimer);
|
|
3696
|
-
this.scrubPruneTimer = null;
|
|
3697
|
-
}
|
|
3698
|
-
this.scrubPackService = null;
|
|
3699
5808
|
if (this.exportSweepTimer !== null) {
|
|
3700
5809
|
clearInterval(this.exportSweepTimer);
|
|
3701
5810
|
this.exportSweepTimer = null;
|
|
@@ -3742,6 +5851,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3742
5851
|
recursive: true,
|
|
3743
5852
|
force: true
|
|
3744
5853
|
}).catch(() => {});
|
|
5854
|
+
for (const loc of this.resolvedLocations) node_fs.promises.rm(node_path.default.join(loc.root, ".scrub-packs"), {
|
|
5855
|
+
recursive: true,
|
|
5856
|
+
force: true
|
|
5857
|
+
}).catch(() => {});
|
|
3745
5858
|
try {
|
|
3746
5859
|
await this.controller?.start();
|
|
3747
5860
|
} catch (err) {
|
|
@@ -3763,7 +5876,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3763
5876
|
this.ctx.logger.warn("recorder: reading persisted device configs failed — skipping hydrate", { meta: { error: require_dist.errMsg(err) } });
|
|
3764
5877
|
return;
|
|
3765
5878
|
}
|
|
5879
|
+
this.warmToday([...configs.keys()]);
|
|
3766
5880
|
for (const deviceId of configs.keys()) await hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, this.resolvedLocations, this.ctx.logger);
|
|
5881
|
+
for (const deviceId of configs.keys()) this.index.markHydratedAll(deviceId);
|
|
3767
5882
|
this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
|
|
3768
5883
|
deviceCount: configs.size,
|
|
3769
5884
|
locationCount: this.resolvedLocations.length
|
|
@@ -3981,6 +6096,27 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3981
6096
|
/** Durable handle config-store reads/writes the deviceId→config blob on —
|
|
3982
6097
|
* the sanctioned `BaseAddon.state()` wrapper (same key, same blob shape;
|
|
3983
6098
|
* adds the read-retry budget + parse-warning logging over the raw slice). */
|
|
6099
|
+
/**
|
|
6100
|
+
* Fill TODAY's exact ranges, per camera, as background work.
|
|
6101
|
+
*
|
|
6102
|
+
* Deliberately the smallest useful prediction: the day the operator opens.
|
|
6103
|
+
* Anything wider was a bet that cost more than it bought.
|
|
6104
|
+
*/
|
|
6105
|
+
async warmToday(deviceIds) {
|
|
6106
|
+
const now = Date.now();
|
|
6107
|
+
const dayMs = 1440 * 60 * 1e3;
|
|
6108
|
+
const started = now;
|
|
6109
|
+
for (const deviceId of deviceIds) try {
|
|
6110
|
+
const profile = await this.calendar.availabilityProfileFor(deviceId);
|
|
6111
|
+
if (profile === null) continue;
|
|
6112
|
+
await this.calendar.warmDayRanges(deviceId, profile, now);
|
|
6113
|
+
await this.calendar.warmDayRanges(deviceId, profile, now - dayMs);
|
|
6114
|
+
} catch {}
|
|
6115
|
+
this.ctx.logger.info("recorder: today warmed", { meta: {
|
|
6116
|
+
devices: deviceIds.length,
|
|
6117
|
+
ms: Date.now() - started
|
|
6118
|
+
} });
|
|
6119
|
+
}
|
|
3984
6120
|
configStore() {
|
|
3985
6121
|
return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
|
|
3986
6122
|
}
|
|
@@ -4030,63 +6166,72 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
4030
6166
|
if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
|
|
4031
6167
|
return node_path.default.join(loc.root, row.path);
|
|
4032
6168
|
}
|
|
6169
|
+
/** Devices with a strip regeneration in flight — one at a time per device. */
|
|
6170
|
+
stripRegenRunning = /* @__PURE__ */ new Set();
|
|
4033
6171
|
/**
|
|
4034
|
-
*
|
|
4035
|
-
*
|
|
4036
|
-
*
|
|
4037
|
-
*
|
|
4038
|
-
*
|
|
4039
|
-
* operator state so it can be tarred whole with no exclusion list. See ADR-0027.
|
|
4040
|
-
*
|
|
4041
|
-
* Resolved lazily: `resolvedLocations` is only populated in `onHubReachable`,
|
|
4042
|
-
* after the ScrubPackService is constructed in `onInitialize`. getOrBuild/prune
|
|
4043
|
-
* run only at request time / on a delayed timer, by when locations are ready.
|
|
4044
|
-
*/
|
|
4045
|
-
resolveScrubCacheRoot() {
|
|
4046
|
-
const loc = this.resolvedLocations.find((l) => l.type === "recordingsLow") ?? this.resolvedLocations[0];
|
|
4047
|
-
if (loc === void 0) return node_path.default.join(this.ctx.dataDir, "scrub-packs");
|
|
4048
|
-
return node_path.default.join(loc.root, ".scrub-packs");
|
|
4049
|
-
}
|
|
4050
|
-
/**
|
|
4051
|
-
* Scrub-pack request handler: `GET /<deviceId>/<windowStartSec>` →
|
|
4052
|
-
* `application/octet-stream` pack | 204 (no footage) | 400 (bad path / window
|
|
4053
|
-
* not aligned to `SCRUB_WINDOW_MS`) | 500. Immutable (fully past) windows carry
|
|
4054
|
-
* a 1-year immutable cache header; the still-open current window is `no-store`.
|
|
6172
|
+
* Clear one camera's strips and rebuild them from the low segments on disk —
|
|
6173
|
+
* the backfill for footage recorded before strips were enabled. Walks the
|
|
6174
|
+
* RECENT window (default {@link REGEN_DEFAULT_DAYS} days), newest hour
|
|
6175
|
+
* first, so the part scrubbing actually visits is ready soonest. Background;
|
|
6176
|
+
* the one-shot ffmpeg pool bounds the decode concurrency.
|
|
4055
6177
|
*/
|
|
4056
|
-
async
|
|
4057
|
-
const
|
|
4058
|
-
if (
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
}
|
|
4062
|
-
const
|
|
4063
|
-
const
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
6178
|
+
async regenerateStrips(input) {
|
|
6179
|
+
const { deviceId } = input;
|
|
6180
|
+
if (this.stripRegenRunning.has(deviceId)) return {
|
|
6181
|
+
started: false,
|
|
6182
|
+
segments: 0
|
|
6183
|
+
};
|
|
6184
|
+
const days = input.days ?? 3;
|
|
6185
|
+
const cutoff = Date.now() - days * 864e5;
|
|
6186
|
+
const rows = this.index.segments(deviceId, "low").filter((r) => r.startMs >= cutoff).toSorted((a, b) => b.startMs - a.startMs);
|
|
6187
|
+
this.stripRegenRunning.add(deviceId);
|
|
6188
|
+
await this.stripStore.removeDevice(deviceId);
|
|
6189
|
+
(async () => {
|
|
6190
|
+
let done = 0;
|
|
6191
|
+
let failed = 0;
|
|
6192
|
+
for (const row of rows) {
|
|
6193
|
+
try {
|
|
6194
|
+
const abs = this.resolveSegmentAbsPath(row);
|
|
6195
|
+
const captured = await readMfraTable(abs, row.bytes);
|
|
6196
|
+
const jpegs = await this.stripCapture.capture(abs);
|
|
6197
|
+
if (jpegs === null || jpegs.length === 0) {
|
|
6198
|
+
failed++;
|
|
6199
|
+
continue;
|
|
6200
|
+
}
|
|
6201
|
+
const instants = keyframeInstants(row.startMs, row.durMs, captured);
|
|
6202
|
+
const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
|
|
6203
|
+
tsMs: instants[i] ?? row.startMs,
|
|
6204
|
+
jpeg
|
|
6205
|
+
}));
|
|
6206
|
+
await this.stripStore.append(deviceId, thumbs);
|
|
6207
|
+
done++;
|
|
6208
|
+
} catch {
|
|
6209
|
+
failed++;
|
|
6210
|
+
}
|
|
6211
|
+
if ((done + failed) % 500 === 0) this.ctx.logger.info("strip regeneration progress", {
|
|
6212
|
+
tags: { deviceId },
|
|
6213
|
+
meta: {
|
|
6214
|
+
done,
|
|
6215
|
+
failed,
|
|
6216
|
+
total: rows.length
|
|
6217
|
+
}
|
|
6218
|
+
});
|
|
4073
6219
|
}
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
"content-length": built.pack.byteLength,
|
|
4077
|
-
"cache-control": built.immutable ? "private, max-age=31536000, immutable" : "no-store"
|
|
4078
|
-
});
|
|
4079
|
-
res.end(Buffer.from(built.pack));
|
|
4080
|
-
} catch (err) {
|
|
4081
|
-
this.ctx.logger.warn("scrub pack request failed", {
|
|
6220
|
+
this.stripRegenRunning.delete(deviceId);
|
|
6221
|
+
this.ctx.logger.info("strip regeneration complete", {
|
|
4082
6222
|
tags: { deviceId },
|
|
4083
6223
|
meta: {
|
|
4084
|
-
|
|
4085
|
-
|
|
6224
|
+
done,
|
|
6225
|
+
failed,
|
|
6226
|
+
total: rows.length,
|
|
6227
|
+
days
|
|
4086
6228
|
}
|
|
4087
6229
|
});
|
|
4088
|
-
|
|
4089
|
-
|
|
6230
|
+
})();
|
|
6231
|
+
return {
|
|
6232
|
+
started: true,
|
|
6233
|
+
segments: rows.length
|
|
6234
|
+
};
|
|
4090
6235
|
}
|
|
4091
6236
|
/**
|
|
4092
6237
|
* Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
|
|
@@ -4174,4 +6319,5 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
4174
6319
|
}
|
|
4175
6320
|
};
|
|
4176
6321
|
//#endregion
|
|
4177
|
-
|
|
6322
|
+
exports.customActions = recorderStripActions;
|
|
6323
|
+
exports.default = RecorderV2Addon;
|