@camstack/addon-pipeline 1.2.19 → 1.2.27

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.
Files changed (35) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +3 -2
  3. package/dist/chunk-DnnnRqeS.mjs +26 -0
  4. package/dist/detection-pipeline/index.js +2 -2
  5. package/dist/detection-pipeline/index.mjs +4 -3
  6. package/dist/{dist-DZBePRjP.mjs → dist-CsKaXmP8.mjs} +415 -126
  7. package/dist/{dist-DKqOqH5j.js → dist-DUHlZcWd.js} +420 -131
  8. package/dist/{model-download-service-Cp9f4dk6-WO-foD7W.mjs → model-download-service-Cp9f4dk6-CwUb5v3Y.mjs} +1 -26
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +162 -19
  12. package/dist/pipeline-runner/index.mjs +162 -19
  13. package/dist/recorder/index.js +2604 -458
  14. package/dist/recorder/index.mjs +2599 -459
  15. package/dist/{step-definitions-zDq8cP8M.js → step-definitions-Bo1hJQQw.js} +1 -1
  16. package/dist/{step-definitions-BwB6TBMB.mjs → step-definitions-C8A78dBi.mjs} +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. 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
  19. 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
  20. 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
  21. package/dist/stream-broker/{hostInit-DC0aArn1.mjs → hostInit-CIwilJUZ.mjs} +2 -2
  22. package/dist/stream-broker/index.js +7374 -250
  23. package/dist/stream-broker/index.mjs +7371 -249
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C2hbIwzB.js → MaskShapeCanvas-DI4BY7W2-B7VdNJlO.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BpJ-foJW.js → MotionZonesSettings-NcxxQN8r-YyB4uLdH.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BjLs9Z3w.js → PrivacyMaskSettings-APgPLF7p-DfqixEad.js} +1 -1
  28. package/embed-dist/assets/{index-Dv5H-32C.css → index-CAe6Dg-X.css} +1 -1
  29. package/embed-dist/assets/index-zVLLVdIS.js +343 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +5 -1
  32. package/dist/fmp4-keyframes-5s8T9Mng.js +0 -6711
  33. package/dist/fmp4-keyframes-CV0X1mGy.mjs +0 -6694
  34. 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
  35. package/embed-dist/assets/index-_BRQTmdE.js +0 -116
@@ -1,11 +1,1535 @@
1
- import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
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 extractKeyframes } from "../fmp4-keyframes-CV0X1mGy.mjs";
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
- * In-memory footage index, keyed by deviceId (relativePath SegmentRow).
58
- * The framework `storage` cap's `list()` is the ground truth: `hydrateDevice`
59
- * rebuilds a camera's set from a `list()` result, and `addSegment`/`removeSegments`
60
- * keep it write-through on finalize/evict. Because every row is derived from a
61
- * segment path (which encodes start/dur/bytes), the index can never drift from
62
- * disk. No SQLite, no persistence of its own.
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) this.deps.index.removeSegments(removed);
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 itself. */
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
- return this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
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
- * On read the legacy `mode`/`schedules`/`triggers` fields are derived once into
851
- * the authoritative `bands` via `migrateConfigToBands`, then persisted, so the
852
- * UI reads `bands` directly forever after.
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
- /** Persisted shape: stringified numeric deviceId → full RecordingConfig. */
857
- var RecordingConfigsBlobSchema = record(string(), RecordingConfigSchema);
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 with `bands` guaranteed materialized.
880
- * Legacy blobs (mode/schedules/triggers, no bands) are migrated into bands and
881
- * re-persisted so the stored shape converges on the authoritative model. An
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
- const stored = (await readDeviceConfigs(state)).get(deviceId);
886
- if (!stored) return DEFAULT_DEVICE_CONFIG;
887
- if (stored.bands) return stored;
888
- const migrated = {
889
- ...stored,
890
- bands: migrateConfigToBands(stored)
891
- };
892
- await saveDeviceConfig(state, deviceId, migrated);
893
- return migrated;
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 validated = RecordingConfigSchema.parse(config);
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
- * Determine the active recording mode for a config's bands — the value the
909
- * `recording` cap status reports. `continuous` wins when any band records
910
- * continuously, else `events` when any band exists, else `off`.
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
- const bands = config.bands ?? [];
914
- if (!config.enabled || bands.length === 0) return "off";
915
- if (bands.some((b) => b.mode === "continuous")) return "continuous";
916
- return "events";
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, or whose file has been evicted since the
997
- * index row was written, is skipped so the playlist never references a
998
- * non-existent segment.
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 rows = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
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) continue;
1009
- const abs = path.join(root, rel);
1010
- try {
1011
- await promises.stat(abs);
1012
- } catch {
1013
- continue;
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
- if (firstPresentAbs === null) firstPresentAbs = abs;
1016
- segments.push({
1017
- uri: uriMode === "absolute" ? abs : rel.split("/").slice(1).join("/"),
1018
- startMs: s.startMs,
1019
- durMs: s.durMs
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 locationUsages = await Promise.all(locations.map(async (loc) => {
1160
- const cap = await deps.capacity(loc.root);
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: loc.id,
1163
- usedBytes: deps.index.accountingForLocation(loc.id).bytes,
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. Falls back to the first resolved location
1873
- * when the preferred type is absent (both types alias one disk today, so the
1874
- * split is free-and-future-proof). Throws when no locations resolved.
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 locations.find((l) => l.type === preferType) ?? locations[0];
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
- //#endregion
1931
- //#region src/recorder/addon/eviction-domain.ts
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
- * Eviction-DOMAIN resolution for recordings locations.
3713
+ * Hydrate ONLY the hours a window spans, instead of walking the archive.
1934
3714
  *
1935
- * Recordings locations are operator-configurable and MAY alias one physical
1936
- * filesystem root: today `recordings:default` + `recordingsLow:default` both
1937
- * resolve to `/recordings`, but an operator could also point two locations at a
1938
- * symlink or bind-mount of the same disk. Locations sharing a physical root
1939
- * MUST evict as one oldest-first pool otherwise disk-pressure relief on one
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 domain key is the location root's PHYSICAL identity: `fs.realpathSync` of
1944
- * the lexically-resolved path, which collapses symlinks and `.`/`..` so aliases
1945
- * reaching one disk share a key a plain `path.resolve` string compare would
1946
- * miss a symlinked/bind-aliased path and silently re-introduce the partition.
1947
- * When realpath THROWS (the root is missing at boot — a disk not yet mounted)
1948
- * we fall back to the lexically-resolved path string: distinct-but-unmounted
1949
- * roots then stay distinct domains. That fallback can only ever OVER-partition
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 physicalRootKey(root, realpath = realpathSync) {
1958
- const resolved = path.resolve(root);
1959
- try {
1960
- return realpath(resolved);
1961
- } catch {
1962
- return resolved;
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
- * scrub-pack prune. Pure over injected deps (config read + prune + logger +
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/addon/recording-hub-host.ts
4801
+ //#region src/recorder/relocate-engine.ts
2945
4802
  /**
2946
- * Resolve the host a REMOTE recording node should use to dial the hub-only
2947
- * stream-broker's restream port.
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
- * The implementation was promoted to the package-shared
2950
- * `src/shared/hub-hostname.ts` in P2d (cross-node Phase 2) so the
2951
- * pipeline-runner's remote-source leg shares the exact same resolver instead
2952
- * of copying it. This module keeps the recorder-facing name as a thin alias
2953
- * resolution order and semantics are unchanged (operator override first, then
2954
- * the host extracted from `CAMSTACK_HUB_URL`).
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 resolveRecordingHubHostname = resolveHubHostname;
2957
- //#endregion
2958
- //#region src/recorder/scrub/scrub-pack-format.ts
2959
- function encodeScrubPack(header, samples) {
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
- header,
2997
- payloadOffset: 4 + headerLen
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
- //#endregion
3001
- //#region src/recorder/scrub/scrub-pack-service.ts
3002
- var SCRUB_WINDOW_MS = 6e4;
3003
- function utcDay(ms) {
3004
- return new Date(ms).toISOString().slice(0, 10);
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 ScrubPackService = class {
4856
+ var RelocateEngine = class {
3007
4857
  deps;
3008
- inflight = /* @__PURE__ */ new Map();
4858
+ jobs = /* @__PURE__ */ new Map();
3009
4859
  constructor(deps) {
3010
4860
  this.deps = deps;
3011
4861
  }
3012
- async getOrBuild(deviceId, windowStartMs) {
3013
- const key = `${deviceId}/${windowStartMs}`;
3014
- const pending = this.inflight.get(key);
3015
- if (pending) return pending;
3016
- const run = this.getOrBuildInner(deviceId, windowStartMs).finally(() => this.inflight.delete(key));
3017
- this.inflight.set(key, run);
3018
- return run;
3019
- }
3020
- resolveCacheDir() {
3021
- const c = this.deps.cacheDir;
3022
- return typeof c === "function" ? c() : c;
3023
- }
3024
- cachePath(deviceId, windowStartMs) {
3025
- return path.join(this.resolveCacheDir(), String(deviceId), utcDay(windowStartMs), `${windowStartMs}.pack`);
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 getOrBuildInner(deviceId, windowStartMs) {
3028
- const now = (this.deps.now ?? Date.now)();
3029
- const immutable = windowStartMs + SCRUB_WINDOW_MS < now;
3030
- const cacheFile = this.cachePath(deviceId, windowStartMs);
3031
- if (immutable) try {
3032
- const cached = new Uint8Array(await promises.readFile(cacheFile));
3033
- try {
3034
- decodeScrubPackHeader(cached);
3035
- } catch {
3036
- this.deps.logger.debug("scrub pack: corrupt cache — rebuilding", { meta: { cacheFile } });
3037
- await promises.rm(cacheFile, { force: true }).catch(() => {});
3038
- throw new Error("corrupt cache");
3039
- }
3040
- return {
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 extracted = await extractKeyframes(bytes, row.startMs);
3063
- if (!extracted) continue;
3064
- if (codec === null) {
3065
- codec = extracted.codec;
3066
- description = extracted.description;
3067
- } else if (extracted.codec !== codec) {
3068
- this.deps.logger.warn("scrub pack: mid-window codec change — dropping segment", { meta: {
3069
- relPath: row.path,
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
- for (const k of extracted.keyframes) if (k.ts >= windowStartMs && k.ts < end) samples.push(k);
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
- this.deps.logger.debug("scrub pack: cache write failed (serving anyway)", { meta: {
3090
- cacheFile,
3091
- error: String(err)
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
- /** Remove per-device day directories older than the retention horizon. */
3100
- async prune(retentionDays) {
3101
- const cutoff = (this.deps.now ?? Date.now)() - retentionDays * 864e5;
3102
- const cacheDir = this.resolveCacheDir();
3103
- let deviceDirs = [];
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
- deviceDirs = await promises.readdir(cacheDir);
4965
+ entries = await promises.readdir(stripDir, { recursive: true });
3106
4966
  } catch {
3107
- return;
4967
+ continue;
3108
4968
  }
3109
- for (const dev of deviceDirs) {
3110
- const devPath = path.join(cacheDir, dev);
3111
- let days = [];
3112
- try {
3113
- days = await promises.readdir(devPath);
3114
- } catch {
3115
- continue;
3116
- }
3117
- for (const day of days) {
3118
- const dayMs = Date.parse(`${day}T00:00:00Z`);
3119
- if (Number.isFinite(dayMs) && dayMs < cutoff) await promises.rm(path.join(devPath, day), {
3120
- recursive: true,
3121
- force: true
3122
- }).catch(() => {});
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
- if ((await promises.readdir(devPath)).length === 0) await promises.rm(devPath, {
3126
- recursive: true,
3127
- force: true
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) this.ctx.eventBus.emit({
3522
- id: randomUUID(),
3523
- timestamp: /* @__PURE__ */ new Date(),
3524
- source: {
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
- locationId: s.locationId,
3535
- segments: s.segments,
3536
- bytes: s.bytes,
3537
- fromMs: s.fromMs,
3538
- toMs: s.toMs,
3539
- trigger: "disk-pressure"
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 = createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
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
- this.playbackBaseUrl = served ? `/addon/${RECORDER_ADDON_ID}/${PLAYBACK_PREFIX}` : null;
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
- return { providers: [
3668
- {
3669
- capability: recordingCapability,
3670
- provider: recordingProvider
3671
- },
3672
- {
3673
- capability: storageEvictableCapability,
3674
- provider: evictableProvider
3675
- },
3676
- {
3677
- capability: recordingExportCapability,
3678
- provider: exportProvider
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
- * Cache root for the scrub packs a hidden `.scrub-packs` dir UNDER a
4033
- * recordings storage root (the `recordingsLow` location the packs derive from,
4034
- * else the first resolved location). Packs are a regenerable keyframe cache of
4035
- * the LOW segments; keeping them beside the recordings (a top-level mount, NOT
4036
- * backed up) instead of in `addons-data` — keeps the backup perimeter pure
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 serveScrubPack(service, req, res) {
4055
- const m = /^\/(\d+)\/(\d+)$/.exec(req.url?.split("?")[0] ?? "");
4056
- if (!m) {
4057
- res.writeHead(400).end();
4058
- return;
4059
- }
4060
- const deviceId = Number(m[1]);
4061
- const windowStartMs = Number(m[2]) * 1e3;
4062
- if (windowStartMs % 6e4 !== 0) {
4063
- res.writeHead(400).end();
4064
- return;
4065
- }
4066
- try {
4067
- const built = await service.getOrBuild(deviceId, windowStartMs);
4068
- if (!built) {
4069
- res.writeHead(204).end();
4070
- return;
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
- res.writeHead(200, {
4073
- "content-type": "application/octet-stream",
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
- windowStartMs,
4083
- error: errMsg(err)
6217
+ done,
6218
+ failed,
6219
+ total: rows.length,
6220
+ days
4084
6221
  }
4085
6222
  });
4086
- res.writeHead(500).end();
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 };