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