@camstack/addon-pipeline 1.2.19 → 1.2.28

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