@camstack/addon-pipeline 1.2.6 → 1.2.8

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 (41) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +2 -3
  3. package/dist/{chunk-D6vf50IK.js → chunk-emK7D4bc.js} +7 -0
  4. package/dist/detection-pipeline/index.js +104 -8
  5. package/dist/detection-pipeline/index.mjs +103 -8
  6. package/dist/{dist-Bh0H8jM6.js → dist-DNjy_sr4.js} +1374 -925
  7. package/dist/{dist-Bw_V5L0r.mjs → dist-owmpaeIY.mjs} +1379 -924
  8. package/dist/fmp4-keyframes-BzTPoPVn.js +6676 -0
  9. package/dist/fmp4-keyframes-qOgJBjMO.mjs +6669 -0
  10. package/dist/{sheet-geometry-DcpuGTCm.mjs → hub-hostname-DAJXlOgV.js} +6 -39
  11. package/dist/{sheet-geometry-BR-ip0CP.js → hub-hostname-cCknRYKj.mjs} +1 -56
  12. package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-Cp9f4dk6-WO-foD7W.mjs} +26 -1
  13. package/dist/{model-download-service-Cp9f4dk6-Dv-oFwjN.js → model-download-service-Cp9f4dk6-fsdDExML.js} +1 -1
  14. package/dist/motion-wasm/index.js +1 -1
  15. package/dist/motion-wasm/index.mjs +1 -1
  16. package/dist/pipeline-runner/index.js +51 -132
  17. package/dist/pipeline-runner/index.mjs +49 -130
  18. package/dist/recorder/index.js +695 -974
  19. package/dist/recorder/index.mjs +694 -972
  20. package/dist/session-decode/decode-worker-child.js +9 -5
  21. package/dist/session-decode/decode-worker-child.mjs +8 -4
  22. package/dist/{step-definitions-xxdpJX9-.js → step-definitions-C2bNlww3.js} +1 -1
  23. package/dist/{step-definitions-aY4D9Loy.mjs → step-definitions-CkvctwuS.mjs} +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DX-Ef8CP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-XdI0RgvI.mjs} +2 -2
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DSfbOjdi.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BdxY9zld.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--zEZtV2N.mjs} +1 -1
  28. package/dist/stream-broker/{hostInit-DUL71Vjq.mjs → hostInit-DsMIdlwP.mjs} +2 -2
  29. package/dist/stream-broker/index.js +680 -15
  30. package/dist/stream-broker/index.mjs +679 -15
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CGWEeRkz.js → MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js} +1 -1
  33. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-4YbTa3Wp.js → MotionZonesSettings-NcxxQN8r-DPgVIdEn.js} +1 -1
  34. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DL_tAs9-.js → PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js} +1 -1
  35. package/embed-dist/assets/{index-CvBwcYl_.js → index-DRtbV_en.js} +17 -15
  36. package/embed-dist/index.html +1 -1
  37. package/package.json +1 -1
  38. package/python/postprocessors/ctc.py +14 -5
  39. package/python/postprocessors/test_ctc.py +35 -0
  40. package/dist/chunk-BdkLduGY.mjs +0 -5
  41. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-gchR4WlX.mjs +0 -26
@@ -1,11 +1,10 @@
1
- import { F as recordingExportCapability, H as errMsg, I as resolveScrubThumbnailGeometry, J as hydrateSchema, K as DeviceType, P as recordingCapability, R as storageEvictableCapability, U as BaseAddon, Y as isEvent, c as EVENT_PAD_MS, dt as string, et as selectAssignedProfileSlots, f as OpsLogEntrySchema, j as nodePin, k as migrateConfigToBands, p as RecordingConfigSchema, pt as EventCategory, rt as array, u as ExportRecordSchema, ut as record } from "../dist-Bw_V5L0r.mjs";
2
- import { n as computeSheetGeometry, r as resolveHubHostname, t as allTilePlacements } from "../sheet-geometry-DcpuGTCm.mjs";
3
- import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
1
+ import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
2
+ import { $ as selectAssignedProfileSlots, F as recordingExportCapability, G as DeviceType, H as BaseAddon, L as storageEvictableCapability, P as recordingCapability, V as errMsg, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, j as nodePin, k as migrateConfigToBands, lt as record, nt as array, p as RecordingConfigSchema, q as hydrateSchema, u as ExportRecordSchema, ut as string } from "../dist-owmpaeIY.mjs";
3
+ import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
+ import { t as extractKeyframes } from "../fmp4-keyframes-qOgJBjMO.mjs";
4
5
  import { spawn } from "node:child_process";
5
- import sharp from "sharp";
6
- import { createReadStream, promises } from "node:fs";
6
+ import { createReadStream, promises, realpathSync } from "node:fs";
7
7
  import path from "node:path";
8
- import { tmpdir } from "node:os";
9
8
  import { randomUUID } from "node:crypto";
10
9
  //#region src/recorder/segment-path.ts
11
10
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
@@ -141,6 +140,43 @@ var RecordingIndex = class {
141
140
  for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
142
141
  return out.toSorted((a, b) => a.startMs - b.startMs);
143
142
  }
143
+ /**
144
+ * All segments across a SET of storage locations (one eviction DOMAIN — the
145
+ * locations that alias one physical filesystem root), oldest-first across the
146
+ * whole pool. This is the correct unit for byte-eviction: two location ids over
147
+ * the same disk must be reclaimed as ONE oldest-first stream, never partitioned.
148
+ */
149
+ segmentsOnLocations(locationIds) {
150
+ const out = [];
151
+ for (const m of this.byDevice.values()) for (const s of m.values()) if (locationIds.has(s.locationId)) out.push(s);
152
+ return out.toSorted((a, b) => a.startMs - b.startMs);
153
+ }
154
+ /** Aggregate accounting across a SET of storage locations (one eviction domain). */
155
+ accountingForLocations(locationIds) {
156
+ let bytes = 0;
157
+ let count = 0;
158
+ let oldestMs = Infinity;
159
+ let newestMs = -Infinity;
160
+ for (const m of this.byDevice.values()) for (const s of m.values()) {
161
+ if (!locationIds.has(s.locationId)) continue;
162
+ bytes += s.bytes;
163
+ count += 1;
164
+ if (s.startMs < oldestMs) oldestMs = s.startMs;
165
+ if (s.startMs > newestMs) newestMs = s.startMs;
166
+ }
167
+ if (count === 0) return {
168
+ bytes: 0,
169
+ count: 0,
170
+ oldestMs: null,
171
+ newestMs: null
172
+ };
173
+ return {
174
+ bytes,
175
+ count,
176
+ oldestMs,
177
+ newestMs
178
+ };
179
+ }
144
180
  /** Aggregate accounting for a storage location across every device. */
145
181
  accountingForLocation(locationId) {
146
182
  let bytes = 0;
@@ -413,18 +449,60 @@ var StorageEvictableProvider = class {
413
449
  constructor(deps) {
414
450
  this.deps = deps;
415
451
  }
452
+ /** The eviction domain of `locationId` — aliases sharing its disk, else itself. */
453
+ domainOf(locationId) {
454
+ return this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
455
+ }
416
456
  async getEvictableUsage(input) {
417
- return { bytes: this.deps.index.accountingForLocation(input.locationId).bytes };
457
+ return { bytes: this.deps.index.accountingForLocations(this.domainOf(input.locationId)).bytes };
418
458
  }
419
459
  async evict(input) {
420
- const onLocation = this.deps.index.segmentsOnLocation(input.locationId);
421
- const victims = selectEvictionsByBytes(onLocation, input.targetBytes);
460
+ const onDomain = this.deps.index.segmentsOnLocations(this.domainOf(input.locationId));
461
+ const victims = selectEvictionsByBytes(onDomain, input.targetBytes);
462
+ const byLocation = /* @__PURE__ */ new Map();
463
+ for (const v of victims) {
464
+ const group = byLocation.get(v.locationId);
465
+ if (group) group.push(v);
466
+ else byLocation.set(v.locationId, [v]);
467
+ }
468
+ let reclaimedBytes = 0;
469
+ for (const [locationId, rows] of byLocation) reclaimedBytes += await this.deps.store.evict(locationId, rows);
470
+ if (reclaimedBytes > 0 && this.deps.onEvicted) this.deps.onEvicted(summarizeEvictionsByDevice(victims));
471
+ const exhausted = victims.length >= onDomain.length;
422
472
  return {
423
- reclaimedBytes: await this.deps.store.evict(input.locationId, victims),
424
- exhausted: victims.length >= onLocation.length
473
+ reclaimedBytes,
474
+ exhausted
425
475
  };
426
476
  }
427
477
  };
478
+ /** Group an eviction round's victims into per-(device, location) summaries (bytes
479
+ * come from the pre-delete selection — partial delete failures may overstate
480
+ * slightly). A domain round can span aliased locations, so each victim is bucketed
481
+ * by its OWN `locationId` (not a single round-level location). */
482
+ function summarizeEvictionsByDevice(victims) {
483
+ const byKey = /* @__PURE__ */ new Map();
484
+ for (const v of victims) {
485
+ const key = `${v.deviceId}${v.locationId}`;
486
+ const cur = byKey.get(key);
487
+ if (cur === void 0) byKey.set(key, {
488
+ deviceId: v.deviceId,
489
+ locationId: v.locationId,
490
+ segments: 1,
491
+ bytes: v.bytes,
492
+ fromMs: v.startMs,
493
+ toMs: v.startMs + v.durMs
494
+ });
495
+ else byKey.set(key, {
496
+ deviceId: v.deviceId,
497
+ locationId: v.locationId,
498
+ segments: cur.segments + 1,
499
+ bytes: cur.bytes + v.bytes,
500
+ fromMs: Math.min(cur.fromMs, v.startMs),
501
+ toMs: Math.max(cur.toMs, v.startMs + v.durMs)
502
+ });
503
+ }
504
+ return [...byKey.values()];
505
+ }
428
506
  //#endregion
429
507
  //#region src/recorder/redundant-segments.ts
430
508
  /**
@@ -1692,6 +1770,18 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
1692
1770
  /** The two recordings location types the recorder addon writes/reads. */
1693
1771
  var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
1694
1772
  /**
1773
+ * Route a recording profile to its storage location by the location's declared
1774
+ * `type`: the `low` profile prefers the `recordingsLow` location, every other
1775
+ * profile the `recordings` location. Falls back to the first resolved location
1776
+ * when the preferred type is absent (both types alias one disk today, so the
1777
+ * split is free-and-future-proof). Throws when no locations resolved.
1778
+ */
1779
+ function resolvePlacement(locations, profile) {
1780
+ if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
1781
+ const preferType = profile === "low" ? "recordingsLow" : "recordings";
1782
+ return locations.find((l) => l.type === preferType) ?? locations[0];
1783
+ }
1784
+ /**
1695
1785
  * Resolve every recordings storage location (`recordings` + `recordingsLow`)
1696
1786
  * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
1697
1787
  * or no recordings location exists — callers degrade rather than crash.
@@ -1701,6 +1791,7 @@ async function resolveRecordingsLocations(api, logger) {
1701
1791
  const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
1702
1792
  return Promise.all(recordings.map(async (l) => ({
1703
1793
  id: l.id,
1794
+ type: l.type,
1704
1795
  root: await api.storage.resolve.query({
1705
1796
  location: l.id,
1706
1797
  relativePath: ""
@@ -1740,6 +1831,54 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
1740
1831
  }
1741
1832
  }
1742
1833
  //#endregion
1834
+ //#region src/recorder/addon/eviction-domain.ts
1835
+ /**
1836
+ * Eviction-DOMAIN resolution for recordings locations.
1837
+ *
1838
+ * Recordings locations are operator-configurable and MAY alias one physical
1839
+ * filesystem root: today `recordings:default` + `recordingsLow:default` both
1840
+ * resolve to `/recordings`, but an operator could also point two locations at a
1841
+ * symlink or bind-mount of the same disk. Locations sharing a physical root
1842
+ * MUST evict as one oldest-first pool — otherwise disk-pressure relief on one
1843
+ * alias deletes the newest footage while the other alias still holds the old
1844
+ * (the age-partition data-loss bug).
1845
+ *
1846
+ * The domain key is the location root's PHYSICAL identity: `fs.realpathSync` of
1847
+ * the lexically-resolved path, which collapses symlinks and `.`/`..` so aliases
1848
+ * reaching one disk share a key — a plain `path.resolve` string compare would
1849
+ * miss a symlinked/bind-aliased path and silently re-introduce the partition.
1850
+ * When realpath THROWS (the root is missing at boot — a disk not yet mounted)
1851
+ * we fall back to the lexically-resolved path string: distinct-but-unmounted
1852
+ * roots then stay distinct domains. That fallback can only ever OVER-partition
1853
+ * (treat one disk as two), never MERGE two different disks — the safe failure
1854
+ * mode for a data-loss guard.
1855
+ */
1856
+ /**
1857
+ * The physical-device key of a location root: `realpath` of the resolved path,
1858
+ * or the resolved path string itself when realpath throws (root missing).
1859
+ */
1860
+ function physicalRootKey(root, realpath = realpathSync) {
1861
+ const resolved = path.resolve(root);
1862
+ try {
1863
+ return realpath(resolved);
1864
+ } catch {
1865
+ return resolved;
1866
+ }
1867
+ }
1868
+ /**
1869
+ * The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
1870
+ * itself) whose roots share `locationId`'s physical root key. An unknown
1871
+ * `locationId` (not present in `locations`) is its own singleton domain.
1872
+ */
1873
+ function resolveEvictionDomain(locations, locationId, realpath = realpathSync) {
1874
+ const self = locations.find((l) => l.id === locationId);
1875
+ if (!self) return new Set([locationId]);
1876
+ const selfKey = physicalRootKey(self.root, realpath);
1877
+ const domain = new Set([locationId]);
1878
+ for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
1879
+ return domain;
1880
+ }
1881
+ //#endregion
1743
1882
  //#region src/recorder/band-engine.ts
1744
1883
  function toMin(hhmm) {
1745
1884
  const [h, m] = hhmm.split(":");
@@ -2521,16 +2660,13 @@ var RecordingController = class {
2521
2660
  }
2522
2661
  }
2523
2662
  /**
2524
- * Resolve the storage location a profile writes to: `low` the recordingsLow
2525
- * location when present, every other profile the recordings location. Falls
2526
- * back to the first resolved location when the preferred type is absent — both
2527
- * types resolve to the same path today, so the split is free-and-future-proof.
2663
+ * Resolve the storage location a profile writes to. Delegates to the pure
2664
+ * `resolvePlacement` in `roots.ts`, which matches on the location's declared
2665
+ * `type` (`recordings` / `recordingsLow`) rather than its `<type>:<instance>`
2666
+ * id so `low` correctly routes to `recordingsLow`.
2528
2667
  */
2529
2668
  resolvePlacement(profile) {
2530
- const locations = this.deps.locations();
2531
- if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
2532
- const preferLow = profile === "low";
2533
- return locations.find((l) => preferLow ? l.id === "recordingsLow" : l.id === "recordings") ?? locations[0];
2669
+ return resolvePlacement(this.deps.locations(), profile);
2534
2670
  }
2535
2671
  /** Stop one profile's writer + watcher and release its broker lease. */
2536
2672
  async teardownProfile(deviceId, r) {
@@ -2603,8 +2739,8 @@ function makeBrokerCall(handle) {
2603
2739
  * sweeper only decides WHICH devices to prune and WHEN).
2604
2740
  *
2605
2741
  * Owns its own timer + single-flight guard so a slow sweep is never overlapped
2606
- * by the next tick — the same subsystem-owns-its-timer shape as
2607
- * `ThumbnailService`. Pure over injected deps (config read + prune + logger +
2742
+ * by the next tick — the same subsystem-owns-its-timer shape as the
2743
+ * scrub-pack prune. Pure over injected deps (config read + prune + logger +
2608
2744
  * clock via `setInterval`), so it is unit-testable with fake timers and a mock
2609
2745
  * `prune`. Every pass is best-effort: a per-device prune failure is logged and
2610
2746
  * never aborts the rest, and the sweep never throws.
@@ -2720,943 +2856,324 @@ var RetentionSweeper = class {
2720
2856
  */
2721
2857
  var resolveRecordingHubHostname = resolveHubHostname;
2722
2858
  //#endregion
2723
- //#region src/thumbnails/thumb-index.ts
2724
- /**
2725
- * Build the index + geometry for a window's samples. Samples are sorted by
2726
- * capture time; each gets a row-major placement. Throws when there are no
2727
- * samples (an empty window is never written).
2728
- */
2729
- function buildThumbnailIndex(input) {
2730
- if (input.samples.length === 0) throw new Error("buildThumbnailIndex: no samples");
2731
- const tileWidth = input.tileWidth ?? 480;
2732
- const tileHeight = input.tileHeight ?? 270;
2733
- const maxCols = input.maxCols ?? 10;
2734
- const ordered = [...input.samples].sort((a, b) => a.t - b.t);
2735
- const geometry = computeSheetGeometry(ordered.length, {
2736
- tileWidth,
2737
- tileHeight,
2738
- maxCols
2739
- });
2740
- const placements = allTilePlacements(ordered.length, geometry);
2741
- const tiles = ordered.map((s, i) => {
2742
- const p = placements[i];
2743
- return {
2744
- t: s.t,
2745
- x: p.x,
2746
- y: p.y
2747
- };
2748
- });
2859
+ //#region src/recorder/scrub/scrub-pack-format.ts
2860
+ function encodeScrubPack(header, samples) {
2861
+ const frames = [];
2862
+ let offset = 0;
2863
+ for (const s of samples) {
2864
+ frames.push({
2865
+ ts: s.ts,
2866
+ offset,
2867
+ size: s.data.byteLength
2868
+ });
2869
+ offset += s.data.byteLength;
2870
+ }
2871
+ const full = {
2872
+ v: 1,
2873
+ codec: header.codec,
2874
+ description: header.description,
2875
+ frames
2876
+ };
2877
+ const headerBytes = new TextEncoder().encode(JSON.stringify(full));
2878
+ const out = new Uint8Array(4 + headerBytes.byteLength + offset);
2879
+ new DataView(out.buffer).setUint32(0, headerBytes.byteLength, true);
2880
+ out.set(headerBytes, 4);
2881
+ let cursor = 4 + headerBytes.byteLength;
2882
+ for (const s of samples) {
2883
+ out.set(s.data, cursor);
2884
+ cursor += s.data.byteLength;
2885
+ }
2886
+ return out;
2887
+ }
2888
+ function decodeScrubPackHeader(buf) {
2889
+ if (buf.byteLength < 4) throw new Error("scrub pack: truncated (no length prefix)");
2890
+ const headerLen = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(0, true);
2891
+ if (headerLen <= 0 || 4 + headerLen > buf.byteLength) throw new Error(`scrub pack: bogus header length ${headerLen}`);
2892
+ const json = new TextDecoder().decode(buf.subarray(4, 4 + headerLen));
2893
+ const header = JSON.parse(json);
2894
+ if (header.v !== 1) throw new Error(`scrub pack: unsupported version ${String(header.v)}`);
2895
+ if (!Array.isArray(header.frames)) throw new Error("scrub pack: malformed header (frames)");
2749
2896
  return {
2750
- index: {
2751
- version: 1,
2752
- deviceId: input.deviceId,
2753
- windowStartMs: input.windowStartMs,
2754
- windowMs: input.windowMs,
2755
- intervalMs: input.intervalMs,
2756
- tileWidth,
2757
- tileHeight,
2758
- cols: geometry.cols,
2759
- rows: geometry.rows,
2760
- sheet: `sheet-${input.windowStartMs}.jpg`,
2761
- source: input.source,
2762
- tiles
2763
- },
2764
- geometry,
2765
- orderedSamples: ordered
2897
+ header,
2898
+ payloadOffset: 4 + headerLen
2766
2899
  };
2767
2900
  }
2768
2901
  //#endregion
2769
- //#region src/thumbnails/thumb-window.ts
2770
- /**
2771
- * Thumbnail window + on-disk path math — pure, UTC-aligned, deterministic.
2772
- *
2773
- * A thumbnail "window" is a fixed UTC-aligned span (default 5 min). Every
2774
- * timestamp maps to exactly one window; the window start, its UTC bucket
2775
- * (Y/M/D/H like the segment store), and the sheet/index filenames are all
2776
- * derived from it with NO state — so the recorder writes and the viewer reads
2777
- * the SAME deterministic path from a bare timestamp (no directory listing, no
2778
- * cap round-trip).
2779
- */
2780
- /** Default window span — 5 minutes. */
2781
- var DEFAULT_WINDOW_MS = 3e5;
2782
- /** Zero-padded 2-digit. */
2783
- function p2(n) {
2784
- return String(n).padStart(2, "0");
2902
+ //#region src/recorder/scrub/scrub-pack-service.ts
2903
+ var SCRUB_WINDOW_MS = 6e4;
2904
+ function utcDay(ms) {
2905
+ return new Date(ms).toISOString().slice(0, 10);
2785
2906
  }
2786
- /** UTC-aligned window start (epoch ms) containing `t`. */
2787
- function windowStartFor(t, windowMs = DEFAULT_WINDOW_MS) {
2788
- if (!Number.isFinite(t)) throw new Error(`windowStartFor: t must be finite, got ${t}`);
2789
- if (!(windowMs > 0)) throw new Error(`windowStartFor: windowMs must be > 0, got ${windowMs}`);
2790
- return Math.floor(t / windowMs) * windowMs;
2791
- }
2792
- /** UTC `Y/M/D/H` bucket dir (relative) for a window start — mirrors
2793
- * `segmentRelPath`'s bucketing so thumbs co-locate with the same hour tree. */
2794
- function bucketDir(windowStartMs) {
2795
- const d = new Date(windowStartMs);
2796
- return `${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
2797
- }
2798
- /** Sheet image filename for a window start. */
2799
- function sheetFileName(windowStartMs) {
2800
- return `sheet-${windowStartMs}.jpg`;
2801
- }
2802
- /** Index sidecar filename for a window start. */
2803
- function indexFileName(windowStartMs) {
2804
- return `index-${windowStartMs}.json`;
2805
- }
2806
- /**
2807
- * Device-relative directory (under the thumbs root) for a window:
2808
- * `<deviceId>/<Y>/<M>/<D>/<H>`. The device is the top segment (thumbs are
2809
- * profile-agnostic, unlike segments which nest under a profile).
2810
- */
2811
- function windowRelDir(deviceId, windowStartMs) {
2812
- return `${deviceId}/${bucketDir(windowStartMs)}`;
2813
- }
2814
- //#endregion
2815
- //#region src/thumbnails/thumbnail-sink.ts
2816
- var ThumbnailSink = class {
2907
+ var ScrubPackService = class {
2817
2908
  deps;
2818
- now;
2819
- open = /* @__PURE__ */ new Map();
2820
- lastFlushed = /* @__PURE__ */ new Map();
2821
- /** In-flight flush promises per device — chained so a device never composes
2822
- * two windows concurrently (ordering + bounded work). */
2823
- flushChain = /* @__PURE__ */ new Map();
2909
+ inflight = /* @__PURE__ */ new Map();
2824
2910
  constructor(deps) {
2825
2911
  this.deps = deps;
2826
- this.now = deps.now ?? (() => Date.now());
2827
- }
2828
- /** Accept a sampled thumb. Rolls over (flushes) the device's open window when
2829
- * a strictly-later window's sample arrives. */
2830
- add(deviceId, sample) {
2831
- const windowStartMs = windowStartFor(sample.capturedAt, this.deps.windowMs);
2832
- const lastFlushed = this.lastFlushed.get(deviceId);
2833
- if (lastFlushed !== void 0 && windowStartMs <= lastFlushed) return;
2834
- let cur = this.open.get(deviceId);
2835
- if (cur && windowStartMs > cur.windowStartMs) {
2836
- this.scheduleFlush(deviceId, cur);
2837
- this.open.delete(deviceId);
2838
- cur = void 0;
2839
- }
2840
- if (cur && windowStartMs < cur.windowStartMs) return;
2841
- if (!cur) {
2842
- cur = {
2843
- windowStartMs,
2844
- samples: []
2845
- };
2846
- this.open.set(deviceId, cur);
2847
- }
2848
- if (cur.samples.length < this.deps.maxTilesPerWindow) cur.samples.push(sample);
2849
- }
2850
- /** Flush every open window whose end + grace has passed (idle cameras). */
2851
- flushExpired() {
2852
- const now = this.now();
2853
- for (const [deviceId, win] of [...this.open]) if (now - (win.windowStartMs + this.deps.windowMs) >= this.deps.idleGraceMs) {
2854
- this.scheduleFlush(deviceId, win);
2855
- this.open.delete(deviceId);
2856
- }
2857
2912
  }
2858
- /** Flush all open windows (shutdown). Returns when every flush settles. */
2859
- async flushAll() {
2860
- for (const [deviceId, win] of [...this.open]) {
2861
- this.scheduleFlush(deviceId, win);
2862
- this.open.delete(deviceId);
2913
+ async getOrBuild(deviceId, windowStartMs) {
2914
+ const key = `${deviceId}/${windowStartMs}`;
2915
+ const pending = this.inflight.get(key);
2916
+ if (pending) return pending;
2917
+ const run = this.getOrBuildInner(deviceId, windowStartMs).finally(() => this.inflight.delete(key));
2918
+ this.inflight.set(key, run);
2919
+ return run;
2920
+ }
2921
+ cachePath(deviceId, windowStartMs) {
2922
+ return path.join(this.deps.cacheDir, String(deviceId), utcDay(windowStartMs), `${windowStartMs}.pack`);
2923
+ }
2924
+ async getOrBuildInner(deviceId, windowStartMs) {
2925
+ const now = (this.deps.now ?? Date.now)();
2926
+ const immutable = windowStartMs + SCRUB_WINDOW_MS < now;
2927
+ const cacheFile = this.cachePath(deviceId, windowStartMs);
2928
+ if (immutable) try {
2929
+ const cached = new Uint8Array(await promises.readFile(cacheFile));
2930
+ try {
2931
+ decodeScrubPackHeader(cached);
2932
+ } catch {
2933
+ this.deps.logger.debug("scrub pack: corrupt cache — rebuilding", { meta: { cacheFile } });
2934
+ await promises.rm(cacheFile, { force: true }).catch(() => {});
2935
+ throw new Error("corrupt cache");
2936
+ }
2937
+ return {
2938
+ pack: cached,
2939
+ immutable
2940
+ };
2941
+ } catch {}
2942
+ const end = windowStartMs + SCRUB_WINDOW_MS;
2943
+ const rows = this.deps.listLowSegments(deviceId).filter((r) => r.startMs < end && r.startMs + r.durMs > windowStartMs).sort((a, b) => a.startMs - b.startMs);
2944
+ if (rows.length === 0) return null;
2945
+ let codec = null;
2946
+ let description = null;
2947
+ const samples = [];
2948
+ for (const row of rows) {
2949
+ let bytes;
2950
+ try {
2951
+ bytes = await this.deps.readSegmentBytes(row);
2952
+ } catch (err) {
2953
+ this.deps.logger.debug("scrub pack: segment read failed — skipping", { meta: {
2954
+ relPath: row.path,
2955
+ error: String(err)
2956
+ } });
2957
+ continue;
2958
+ }
2959
+ const extracted = await extractKeyframes(bytes, row.startMs);
2960
+ if (!extracted) continue;
2961
+ if (codec === null) {
2962
+ codec = extracted.codec;
2963
+ description = extracted.description;
2964
+ } else if (extracted.codec !== codec) {
2965
+ this.deps.logger.warn("scrub pack: mid-window codec change — dropping segment", { meta: {
2966
+ relPath: row.path,
2967
+ expected: codec,
2968
+ got: extracted.codec
2969
+ } });
2970
+ continue;
2971
+ }
2972
+ for (const k of extracted.keyframes) if (k.ts >= windowStartMs && k.ts < end) samples.push(k);
2973
+ }
2974
+ if (codec === null || description === null || samples.length === 0) return null;
2975
+ samples.sort((a, b) => a.ts - b.ts);
2976
+ const pack = encodeScrubPack({
2977
+ codec,
2978
+ description: Buffer.from(description).toString("base64")
2979
+ }, samples);
2980
+ if (immutable) try {
2981
+ await promises.mkdir(path.dirname(cacheFile), { recursive: true });
2982
+ const tmpFile = `${cacheFile}.tmp`;
2983
+ await promises.writeFile(tmpFile, pack);
2984
+ await promises.rename(tmpFile, cacheFile);
2985
+ } catch (err) {
2986
+ this.deps.logger.debug("scrub pack: cache write failed (serving anyway)", { meta: {
2987
+ cacheFile,
2988
+ error: String(err)
2989
+ } });
2863
2990
  }
2864
- await Promise.all([...this.flushChain.values()]);
2865
- }
2866
- /** Test/diagnostic: number of open windows. */
2867
- get openWindowCount() {
2868
- return this.open.size;
2869
- }
2870
- scheduleFlush(deviceId, win) {
2871
- const next = (this.flushChain.get(deviceId) ?? Promise.resolve()).then(() => this.doFlush(deviceId, win)).catch((err) => {
2872
- this.deps.logger.warn("thumbnail sink: window flush failed", {
2873
- tags: { deviceId },
2874
- meta: {
2875
- windowStartMs: win.windowStartMs,
2876
- error: err instanceof Error ? err.message : err
2877
- }
2878
- });
2879
- });
2880
- this.flushChain.set(deviceId, next);
2881
- this.lastFlushed.set(deviceId, Math.max(this.lastFlushed.get(deviceId) ?? -1, win.windowStartMs));
2882
- }
2883
- async doFlush(deviceId, win) {
2884
- if (win.samples.length === 0) return;
2885
- const preset = await this.deps.resolvePreset(deviceId);
2886
- const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
2887
- const built = buildThumbnailIndex({
2888
- deviceId,
2889
- windowStartMs: win.windowStartMs,
2890
- windowMs: this.deps.windowMs,
2891
- intervalMs: this.deps.intervalMs,
2892
- source: this.deps.source,
2893
- tileWidth: preset.tileWidth,
2894
- tileHeight: preset.tileHeight,
2895
- maxCols: this.deps.maxCols,
2896
- samples: ordered.map((s) => ({ t: s.capturedAt }))
2897
- });
2898
- const placements = allTilePlacements(ordered.length, built.geometry);
2899
- const sheetBytes = await this.deps.compose({
2900
- tiles: ordered.map((s) => s.jpeg),
2901
- placements,
2902
- geometry: built.geometry,
2903
- quality: preset.quality
2904
- });
2905
- await this.deps.persist({
2906
- deviceId,
2907
- windowStartMs: win.windowStartMs,
2908
- relDir: windowRelDir(deviceId, win.windowStartMs),
2909
- sheetName: sheetFileName(win.windowStartMs),
2910
- sheetBytes,
2911
- indexName: indexFileName(win.windowStartMs),
2912
- index: built.index
2913
- });
2914
- }
2915
- };
2916
- //#endregion
2917
- //#region src/thumbnails/sheet-composer.ts
2918
- /**
2919
- * Resize each tile to the uniform tile size and composite onto a black canvas
2920
- * at its placement. Returns the sheet JPEG bytes. Throws if tiles/placements
2921
- * lengths disagree.
2922
- */
2923
- async function composeSheet(sharp, input) {
2924
- const { tiles, placements, geometry } = input;
2925
- if (tiles.length !== placements.length) throw new Error(`composeSheet: tiles(${tiles.length}) != placements(${placements.length})`);
2926
- if (tiles.length === 0) throw new Error("composeSheet: no tiles");
2927
- const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
2928
- fit: "cover",
2929
- position: "centre"
2930
- }).jpeg({ quality: input.quality ?? 72 }).toBuffer()))).map((buf, i) => {
2931
- const p = placements[i];
2932
2991
  return {
2933
- input: buf,
2934
- left: p.x,
2935
- top: p.y
2992
+ pack,
2993
+ immutable
2936
2994
  };
2937
- });
2938
- return sharp({ create: {
2939
- width: geometry.sheetWidth,
2940
- height: geometry.sheetHeight,
2941
- channels: 3,
2942
- background: {
2943
- r: 0,
2944
- g: 0,
2945
- b: 0
2946
- }
2947
- } }).composite(entries).jpeg({ quality: input.quality ?? 72 }).toBuffer();
2948
- }
2949
- //#endregion
2950
- //#region src/thumbnails/thumbnail-persister.ts
2951
- /**
2952
- * Thumbnail disk persister — writes a window's sheet JPEG + index JSON
2953
- * write-once under `<thumbsRoot>/<dev>/Y/M/D/H/`. Small fs wrapper the recorder
2954
- * wires with a real root; kept separate so the sink stays fs-free and testable.
2955
- */
2956
- /** Root dir thumbs are written under for a location (`<locationRoot>/.thumbs`). */
2957
- var THUMBS_SUBDIR = ".thumbs";
2958
- /**
2959
- * Persist one window. Writes are best-effort atomic (write to a `.tmp` then
2960
- * rename) so a crash mid-write never leaves a truncated sheet a viewer would
2961
- * cache as `immutable`. Idempotent: an existing sheet is left as-is (write-once).
2962
- */
2963
- async function persistThumbnailWindow(thumbsRoot, req) {
2964
- const dir = path.join(thumbsRoot, req.relDir);
2965
- await promises.mkdir(dir, { recursive: true });
2966
- const sheetPath = path.join(dir, req.sheetName);
2967
- const indexPath = path.join(dir, req.indexName);
2968
- await Promise.all([writeOnce(sheetPath, req.sheetBytes), writeOnce(indexPath, Buffer.from(JSON.stringify(req.index)))]);
2969
- }
2970
- async function writeOnce(absPath, bytes) {
2971
- try {
2972
- await promises.access(absPath);
2973
- return;
2974
- } catch {}
2975
- const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
2976
- await promises.writeFile(tmp, bytes);
2977
- try {
2978
- await promises.rename(tmp, absPath);
2979
- } catch (err) {
2980
- await promises.rm(tmp, { force: true }).catch(() => {});
2981
- throw err;
2982
2995
  }
2983
- }
2984
- var HOUR_MS = 36e5;
2985
- /**
2986
- * Buckets whose whole hour ends before the retention cutoff. Cutoff = the
2987
- * device's oldest indexed segment (thumbs live exactly as long as footage),
2988
- * or `now - fallback` when the device has no segments.
2989
- */
2990
- function planThumbPrune(input) {
2991
- const cutoffMs = input.oldestSegmentStartMs ?? input.nowMs - 2592e6;
2992
- return input.buckets.filter((b) => b.hourStartMs + HOUR_MS <= cutoffMs);
2993
- }
2994
- /**
2995
- * Parse a `Y/M/D/H` relative bucket path (as walked under a device dir) into
2996
- * its UTC hour start. Returns null for anything that isn't a valid bucket
2997
- * (staging debris, foreign files) so the janitor never deletes what it does
2998
- * not understand.
2999
- */
3000
- function parseHourBucket(relYmdh) {
3001
- const m = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{2})$/.exec(relYmdh);
3002
- if (!m) return null;
3003
- const [, y, mo, d, h] = m;
3004
- const year = Number(y);
3005
- const month = Number(mo);
3006
- const day = Number(d);
3007
- const hour = Number(h);
3008
- if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
3009
- const t = Date.UTC(year, month - 1, day, hour);
3010
- const dt = new Date(t);
3011
- if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day || dt.getUTCHours() !== hour) return null;
3012
- return t;
3013
- }
3014
- //#endregion
3015
- //#region src/thumbnails/thumbs-media-handler.ts
3016
- var IMMUTABLE = "public, max-age=31536000, immutable";
3017
- /** Reject traversal / absolute / NUL and normalise leading slashes. Returns the
3018
- * safe rel path or null. Exported for unit coverage. */
3019
- function sanitizeThumbsPath(url) {
3020
- const qIdx = url.indexOf("?");
3021
- const raw = qIdx === -1 ? url : url.slice(0, qIdx);
3022
- let rel;
3023
- try {
3024
- rel = decodeURIComponent(raw.replace(/^\/+/, ""));
3025
- } catch {
3026
- return null;
3027
- }
3028
- if (rel.length === 0) return null;
3029
- if (rel.includes("\0")) return null;
3030
- if (rel.startsWith("/")) return null;
3031
- if (rel.split("/").some((seg) => seg === ".." || seg === ".")) return null;
3032
- if (!/\.(jpe?g|json)$/i.test(rel)) return null;
3033
- return rel;
3034
- }
3035
- /** Content type for a thumbs file by extension. */
3036
- function thumbsContentType(relPath) {
3037
- if (/\.json$/i.test(relPath)) return "application/json";
3038
- return "image/jpeg";
3039
- }
3040
- function createThumbsMediaHandler(deps) {
3041
- return async (req, res) => {
3042
- if (req.method !== "GET" && req.method !== "HEAD") {
3043
- res.writeHead(405, { allow: "GET, HEAD" }).end();
3044
- return;
3045
- }
3046
- const rel = sanitizeThumbsPath(req.url ?? "/");
3047
- if (rel === null) {
3048
- res.writeHead(404).end();
3049
- return;
3050
- }
3051
- const etag = `"${rel}"`;
3052
- if (req.headers["if-none-match"] === etag) {
3053
- res.writeHead(304, {
3054
- etag,
3055
- "cache-control": IMMUTABLE
3056
- }).end();
3057
- return;
3058
- }
3059
- let file;
2996
+ /** Remove per-device day directories older than the retention horizon. */
2997
+ async prune(retentionDays) {
2998
+ const cutoff = (this.deps.now ?? Date.now)() - retentionDays * 864e5;
2999
+ let deviceDirs = [];
3060
3000
  try {
3061
- file = await deps.getFile(rel);
3001
+ deviceDirs = await promises.readdir(this.deps.cacheDir);
3062
3002
  } catch {
3063
- const body = "Internal server error";
3064
- res.writeHead(500, {
3065
- "content-type": "text/plain",
3066
- "content-length": String(Buffer.byteLength(body))
3067
- });
3068
- if (req.method === "HEAD") res.end();
3069
- else res.end(body);
3070
3003
  return;
3071
3004
  }
3072
- if (file === null) {
3073
- res.writeHead(404).end();
3074
- return;
3005
+ for (const dev of deviceDirs) {
3006
+ const devPath = path.join(this.deps.cacheDir, dev);
3007
+ let days = [];
3008
+ try {
3009
+ days = await promises.readdir(devPath);
3010
+ } catch {
3011
+ continue;
3012
+ }
3013
+ for (const day of days) {
3014
+ const dayMs = Date.parse(`${day}T00:00:00Z`);
3015
+ if (Number.isFinite(dayMs) && dayMs < cutoff) await promises.rm(path.join(devPath, day), {
3016
+ recursive: true,
3017
+ force: true
3018
+ }).catch(() => {});
3019
+ }
3020
+ try {
3021
+ if ((await promises.readdir(devPath)).length === 0) await promises.rm(devPath, {
3022
+ recursive: true,
3023
+ force: true
3024
+ }).catch(() => {});
3025
+ } catch {}
3075
3026
  }
3076
- res.writeHead(200, {
3077
- "content-type": file.contentType,
3078
- "cache-control": IMMUTABLE,
3079
- etag,
3080
- "content-length": String(file.bytes.byteLength)
3081
- });
3082
- if (req.method === "HEAD") res.end();
3083
- else res.end(Buffer.from(file.bytes));
3084
- };
3085
- }
3086
- //#endregion
3087
- //#region src/thumbnails/thumb-backfill-plan.ts
3088
- /**
3089
- * Backfill PLANNER — pure (no ffmpeg, no I/O). Given a device's low segments
3090
- * and the window starts already covered by a sampled/backfilled index, decide
3091
- * which windows still need a keyframe-only ffmpeg pass and which segments cover
3092
- * each. The executor (recorder) runs at most ONE ffmpeg per planned window,
3093
- * single-flighted; this module owns only the "what to do" decision so it is
3094
- * fully unit-testable.
3095
- *
3096
- * A window is a backfill candidate when:
3097
- * - at least one low segment overlaps it (there is footage to sample), AND
3098
- * - no index already covers it (sampling didn't reach it), AND
3099
- * - the window is fully in the past by `settleMs` (no new segment can still
3100
- * land in it — mirrors the segment store's current-hour eviction guard).
3101
- */
3102
- /** True when `[startMs, startMs+durMs)` overlaps `[winStart, winEnd)`. */
3103
- function overlaps(startMs, durMs, winStart, winEnd) {
3104
- return startMs < winEnd && startMs + durMs > winStart;
3105
- }
3106
- /**
3107
- * Compute the ordered (ascending window) backfill plan. Deterministic and
3108
- * bounded: returns at most `maxWindows` window plans (oldest first), each with
3109
- * its overlapping segments.
3110
- */
3111
- function planBackfill(input) {
3112
- const { segments, coveredWindows, windowMs, now, settleMs } = input;
3113
- const maxWindows = input.maxWindows ?? 500;
3114
- if (!(windowMs > 0)) return [];
3115
- const candidates = /* @__PURE__ */ new Set();
3116
- for (const seg of segments) {
3117
- if (seg.durMs <= 0) continue;
3118
- const firstW = windowStartFor(seg.startMs, windowMs);
3119
- const lastW = windowStartFor(seg.startMs + seg.durMs - 1, windowMs);
3120
- for (let w = firstW; w <= lastW; w += windowMs) candidates.add(w);
3121
- }
3122
- const plans = [];
3123
- for (const windowStartMs of [...candidates].sort((a, b) => a - b)) {
3124
- if (coveredWindows.has(windowStartMs)) continue;
3125
- const windowEndMs = windowStartMs + windowMs;
3126
- if (now - windowEndMs < settleMs) continue;
3127
- const covering = segments.filter((s) => s.durMs > 0 && overlaps(s.startMs, s.durMs, windowStartMs, windowEndMs)).sort((a, b) => a.startMs - b.startMs);
3128
- if (covering.length === 0) continue;
3129
- plans.push({
3130
- windowStartMs,
3131
- windowEndMs,
3132
- segments: covering
3133
- });
3134
- if (plans.length >= maxWindows) break;
3135
3027
  }
3136
- return plans;
3137
- }
3028
+ };
3138
3029
  //#endregion
3139
- //#region src/thumbnails/backfill-ffmpeg.ts
3030
+ //#region src/recorder/still/still-source.ts
3140
3031
  /**
3141
- * Map a JPEG quality (higher = better; the scrub presets span ~55..85) onto
3142
- * ffmpeg's INVERSE `-q:v` scale (2 = best .. 31 = worst). Inverse-linear over
3143
- * the full JPEG 1..100 domain, monotonic-decreasing (a higher JPEG quality
3144
- * yields a lower/better ffmpeg q), clamped to [2, 31] for any input.
3145
- *
3146
- * These extracted keyframes are INTERMEDIATE — `composeSheet` re-encodes them
3147
- * into the sheet at the preset's final JPEG quality — so this only governs the
3148
- * extract's crispness. Anchoring the extract to the same preset keeps a
3149
- * high-preset camera's backfill from being the bottleneck while a low-preset
3150
- * camera spends fewer bytes on throwaway intermediates.
3032
+ * Resolve the low segment covering `epochMs` and the seek offset from that
3033
+ * segment's start. Returns the LAST segment whose half-open span
3034
+ * `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
3035
+ * restart; the newest wins). Null when no segment covers the instant.
3151
3036
  */
3152
- function jpegQualityToFfmpegQ(jpegQuality) {
3153
- const q = Math.round(31 - (Math.min(100, Math.max(1, jpegQuality)) - 1) / 99 * 29);
3154
- return Math.min(31, Math.max(2, q));
3155
- }
3156
- /**
3157
- * ffmpeg args to extract keyframes from one segment as scaled JPEGs. `-skip_frame
3158
- * nokey` + `-vsync 0` (a.k.a. passthrough) keeps only I-frames without
3159
- * duplicating; `-an` drops audio. The caller resolves timestamps from the
3160
- * segment start + the extracted frame order (keyframes are ~GOP-spaced).
3161
- */
3162
- function buildKeyframeExtractArgs(input) {
3163
- const q = input.quality ?? 4;
3164
- return [
3165
- "-hide_banner",
3166
- "-loglevel",
3167
- "error",
3168
- "-skip_frame",
3169
- "nokey",
3170
- "-i",
3171
- input.inputPath,
3172
- "-an",
3173
- "-vsync",
3174
- "0",
3175
- "-vf",
3176
- `scale=${input.tileWidth}:-2`,
3177
- "-q:v",
3178
- String(q),
3179
- input.outputPattern
3180
- ];
3037
+ function resolveStillSource(segments, epochMs) {
3038
+ let match = null;
3039
+ for (const s of segments) if (epochMs >= s.startMs && epochMs < s.startMs + s.durMs) {
3040
+ if (match === null || s.startMs >= match.startMs) match = s;
3041
+ }
3042
+ if (match === null) return null;
3043
+ const offsetSec = Math.max(0, (epochMs - match.startMs) / 1e3);
3044
+ return {
3045
+ row: match,
3046
+ offsetSec
3047
+ };
3181
3048
  }
3182
3049
  //#endregion
3183
- //#region src/recorder/addon/thumbnail-service.ts
3050
+ //#region src/recorder/still/still-frame-service.ts
3184
3051
  /**
3185
- * Recorder-side scrub-thumbnail SERVICEthe wiring that turns
3186
- * `recording.thumb-sampled` telemetry into served sprite sheets.
3187
- *
3188
- * runner (any node) --recording.thumb-sampled event--> THIS (recording node)
3189
- * -> ThumbnailSink accumulates a 5-min window
3190
- * -> composeSheet (sharp) + persistThumbnailWindow (write-once)
3191
- * -> data-plane `/addon/recorder/thumbs/<dev>/Y/M/D/H/{sheet,index}`
3192
- *
3193
- * Cross-node decision (documented, simplest per the analysis §3 open
3194
- * questions): the thumb rides the EVENT BUS. The recorder is a SINGLE
3195
- * designated node, so its data-plane prefix (`/addon/recorder/thumbs`) is
3196
- * unambiguous — serving from any decode node's own prefix would be
3197
- * multi-node-ambiguous. Sampling is compressed (~10 KB) at 1/5 Hz — well inside
3198
- * D9 — and telemetry-grade (a lost thumb is a scrub gap the keyframe backfill
3199
- * fills), so no new cap method and no RPC hop are needed.
3052
+ * Recorder STILL-frame servicedecode ONE JPEG near a wall-clock instant from
3053
+ * the recorded LOW footage, on demand, for the export dialog's start/end frame
3054
+ * previews. Replaces the retired sprite-sheet scrub-thumbnail tier for this use.
3200
3055
  *
3201
- * Backfill (phase 2): hourly, for windows with recorded LOW footage but no
3202
- * sampled coverage, ONE keyframe-only ffmpeg pass per window fills quiet-period
3203
- * heartbeat thumbs. Bounded concurrency + single-flight so a burst never spawns
3204
- * a storm.
3056
+ * A one-off still (a dialog preview, not frame-rate playback), so the simplest
3057
+ * robust path is chosen: feed ffmpeg the segment file directly with an INPUT
3058
+ * seek (`-ss` before `-i`, which lands on the nearest keyframe at/before the
3059
+ * offset — cheap, no full decode) and ask for a single frame on stdout. No
3060
+ * WebCodecs, no mp4box parse, no persistent cache — a ~100ms one-shot is fine
3061
+ * for a dialog and keeps the endpoint stateless.
3205
3062
  */
3206
- var THUMBS_PREFIX = "thumbs";
3207
- var RECORDER_ADDON_ID$1 = "recorder";
3208
- /** Idle window flush grace a window whose end passed this long ago is
3209
- * composed even if no later sample rolled it over (idle camera). */
3210
- var IDLE_GRACE_MS = 3e4;
3211
- /** Flush-timer cadence. */
3212
- var FLUSH_TICK_MS = 15e3;
3213
- /** Backfill scan cadence — 15 min so scrub coverage holes close within the
3214
- * hour instead of lingering (spawns stay bounded: keyframe-only ffmpeg over
3215
- * low segments, concurrency-gated, per-device window budget). */
3216
- var BACKFILL_TICK_MS = 15 * 6e4;
3217
- /** A window must have ended this long ago before backfill considers it. */
3218
- var BACKFILL_SETTLE_MS = 5 * DEFAULT_WINDOW_MS;
3219
- /** Max concurrent backfill ffmpeg passes across all cameras. */
3220
- var BACKFILL_MAX_CONCURRENT = 2;
3221
- /** Max windows filled PER DEVICE per backfill pass. A recording camera accrues
3222
- * ≤12 uncovered 5-min windows/hour, so 24 lets a device also DRAIN backlog.
3223
- * (This was a GLOBAL cap: 24/pass vs ~12×N windows/hour needed cluster-wide —
3224
- * devices later in the list starved and coverage grew permanent holes,
3225
- * live-measured 2026-07-17 on 590: gaps at −55…−110 min.) */
3226
- var BACKFILL_MAX_WINDOWS_PER_DEVICE = 24;
3227
- /** Global safety cap per pass (runaway bound across all cameras). */
3228
- var BACKFILL_MAX_WINDOWS_PER_PASS = 240;
3229
- /** Hard cap on tiles per window (bounds sheet size + compose cost). */
3230
- var MAX_TILES_PER_WINDOW = 90;
3231
- /** Retention-prune cadence (sheets are small — a slow sweep is plenty). */
3232
- var PRUNE_TICK_MS = 6 * 36e5;
3233
- /** First prune runs this long after start (off the boot churn). */
3234
- var PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
3235
- var SHARP = sharp;
3236
- var ThumbnailService = class {
3063
+ /** Hard ceiling on a single still decode (a stuck ffmpeg must never leak). */
3064
+ var FFMPEG_TIMEOUT_MS = 15e3;
3065
+ /** At most this many concurrent ffmpeg decodes (the endpoint is authenticated
3066
+ * but request-driven without a cap a client loop could fork-bomb the node). */
3067
+ var MAX_CONCURRENT_DECODES = 3;
3068
+ /** Requests parked beyond the concurrency cap; overflow answers 204 instead of
3069
+ * queueing unboundedly. The real dialog fetches 2 stills — this is headroom. */
3070
+ var MAX_WAIT_QUEUE = 8;
3071
+ var StillFrameService = class {
3237
3072
  deps;
3238
- sink;
3239
- unsub = null;
3240
- dataPlaneDispose = null;
3241
- flushTimer = null;
3242
- backfillTimer = null;
3243
- pruneTimer = null;
3244
- pruneInitialTimer = null;
3245
- backfillActive = 0;
3246
- backfillInFlight = /* @__PURE__ */ new Set();
3247
- stopped = false;
3073
+ inFlight = 0;
3074
+ waitQueue = [];
3248
3075
  constructor(deps) {
3249
3076
  this.deps = deps;
3250
- this.sink = new ThumbnailSink({
3251
- windowMs: deps.config.windowMs,
3252
- intervalMs: deps.config.intervalMs,
3253
- maxCols: 10,
3254
- resolvePreset: (deviceId) => deps.resolvePreset(deviceId),
3255
- maxTilesPerWindow: MAX_TILES_PER_WINDOW,
3256
- idleGraceMs: IDLE_GRACE_MS,
3257
- source: "sampled",
3258
- compose: (req) => composeSheet(SHARP, {
3259
- tiles: [...req.tiles],
3260
- placements: req.placements,
3261
- geometry: req.geometry,
3262
- quality: req.quality
3263
- }),
3264
- persist: (req) => this.persist(req),
3265
- logger: deps.logger.child("thumbnail-sink")
3266
- });
3267
- }
3268
- /** First writable thumbs root (`<locationRoot>/.thumbs`), or null when no
3269
- * recordings location is resolved yet. */
3270
- writeRoot() {
3271
- const loc = this.deps.locations()[0];
3272
- return loc ? path.join(loc.root, THUMBS_SUBDIR) : null;
3273
- }
3274
- /** Every thumbs root (for the data-plane getFile search). */
3275
- thumbsRoots() {
3276
- return this.deps.locations().map((l) => path.join(l.root, THUMBS_SUBDIR));
3277
- }
3278
- /**
3279
- * Serve ONLY the `thumbs` data-plane endpoint, without starting the sink /
3280
- * timers. Called from the recorder's `onInitialize` — BEFORE the
3281
- * node-registration handshake — because the hub pulls a forked addon's
3282
- * data-plane endpoints once, off the caps-registered signal: an endpoint
3283
- * served later (the `start()` in `onHubReachable` lands minutes after boot)
3284
- * never reaches the reverse-proxy and `/addon/recorder/thumbs/*` 404s to the
3285
- * SPA (live-diagnosed 2026-07-17). Safe to serve early: the handler resolves
3286
- * roots lazily per request (mirrors the playback data-plane pattern).
3287
- * Idempotent — `start()` re-invokes it harmlessly.
3288
- */
3289
- async serveEarly() {
3290
- if (!this.deps.config.enabled) return;
3291
- if (this.dataPlaneDispose !== null) return;
3292
- await this.serveDataPlane();
3293
3077
  }
3294
- async start() {
3295
- if (!this.deps.config.enabled) {
3296
- this.deps.logger.info("thumbnails disabled service inert");
3297
- return;
3298
- }
3299
- if (this.unsub !== null) return;
3300
- this.unsub = this.deps.eventBus.subscribe({ category: EventCategory.RecordingThumbSampled }, (event) => {
3301
- if (!isEvent(event, EventCategory.RecordingThumbSampled)) return;
3302
- const d = event.data;
3303
- try {
3304
- this.sink.add(d.deviceId, {
3305
- capturedAt: d.capturedAt,
3306
- jpeg: Buffer.from(d.jpeg)
3307
- });
3308
- } catch (err) {
3309
- this.deps.logger.debug("thumbnail add failed", {
3310
- tags: { deviceId: d.deviceId },
3311
- meta: { error: err instanceof Error ? err.message : String(err) }
3312
- });
3313
- }
3078
+ /** Acquire a decode slot; resolves false when the wait queue is saturated. */
3079
+ acquireSlot() {
3080
+ if (this.inFlight < MAX_CONCURRENT_DECODES) {
3081
+ this.inFlight += 1;
3082
+ return Promise.resolve(true);
3083
+ }
3084
+ if (this.waitQueue.length >= MAX_WAIT_QUEUE) return Promise.resolve(false);
3085
+ return new Promise((resolve) => {
3086
+ this.waitQueue.push(() => {
3087
+ this.inFlight += 1;
3088
+ resolve(true);
3089
+ });
3314
3090
  });
3315
- if (this.dataPlaneDispose === null) await this.serveDataPlane();
3316
- this.flushTimer = setInterval(() => this.sink.flushExpired(), FLUSH_TICK_MS);
3317
- if (typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref?.();
3318
- if (this.deps.config.backfillEnabled) {
3319
- this.backfillTimer = setInterval(() => {
3320
- this.runBackfillPass();
3321
- }, BACKFILL_TICK_MS);
3322
- this.backfillTimer.unref?.();
3323
- }
3324
- this.pruneInitialTimer = setTimeout(() => {
3325
- this.runPrunePass();
3326
- }, PRUNE_INITIAL_DELAY_MS);
3327
- this.pruneInitialTimer.unref?.();
3328
- this.pruneTimer = setInterval(() => {
3329
- this.runPrunePass();
3330
- }, PRUNE_TICK_MS);
3331
- this.pruneTimer.unref?.();
3332
- this.deps.logger.info("thumbnail service started", { meta: {
3333
- windowMs: this.deps.config.windowMs,
3334
- intervalMs: this.deps.config.intervalMs,
3335
- backfill: this.deps.config.backfillEnabled
3336
- } });
3337
3091
  }
3338
- async stop() {
3339
- this.stopped = true;
3340
- this.unsub?.();
3341
- this.unsub = null;
3342
- if (this.flushTimer) clearInterval(this.flushTimer);
3343
- if (this.backfillTimer) clearInterval(this.backfillTimer);
3344
- if (this.pruneTimer) clearInterval(this.pruneTimer);
3345
- if (this.pruneInitialTimer) clearTimeout(this.pruneInitialTimer);
3346
- this.flushTimer = null;
3347
- this.backfillTimer = null;
3348
- this.pruneTimer = null;
3349
- this.pruneInitialTimer = null;
3350
- try {
3351
- await this.sink.flushAll();
3352
- } catch {}
3353
- await this.dataPlaneDispose?.().catch(() => {});
3354
- this.dataPlaneDispose = null;
3355
- }
3356
- async serveDataPlane() {
3357
- const handler = createThumbsMediaHandler({ getFile: async (rel) => {
3358
- for (const root of this.thumbsRoots()) {
3359
- const abs = path.join(root, rel);
3360
- if (!abs.startsWith(root + path.sep)) continue;
3361
- try {
3362
- return {
3363
- bytes: await promises.readFile(abs),
3364
- contentType: thumbsContentType(rel)
3365
- };
3366
- } catch {}
3367
- }
3368
- return null;
3369
- } });
3370
- try {
3371
- const served = await this.deps.dataPlane?.serve({
3372
- prefix: THUMBS_PREFIX,
3373
- access: "authenticated",
3374
- handler
3375
- });
3376
- if (served) {
3377
- this.dataPlaneDispose = () => served.dispose();
3378
- this.deps.logger.info("thumbnail data-plane served", { meta: { clientBase: `/addon/${RECORDER_ADDON_ID$1}/${THUMBS_PREFIX}` } });
3379
- }
3380
- } catch (err) {
3381
- this.deps.logger.warn("thumbnail data-plane failed to serve", { meta: { error: err instanceof Error ? err.message : String(err) } });
3382
- }
3092
+ releaseSlot() {
3093
+ this.inFlight -= 1;
3094
+ const next = this.waitQueue.shift();
3095
+ if (next !== void 0) next();
3383
3096
  }
3384
3097
  /**
3385
- * One retention sweep: for every device dir under every thumbs root, drop
3386
- * the hour buckets fully older than the device's oldest indexed segment
3387
- * (thumbs live exactly as long as footage; fixed fallback age when the
3388
- * device has no segments). Pure decision in `planThumbPrune`; only valid
3389
- * `Y/M/D/H` buckets are ever deleted. Best-effort — a failed rm retries on
3390
- * the next sweep.
3098
+ * Decode the frame covering `epochMs`. Returns the JPEG + immutability, or
3099
+ * null when no footage covers the instant / the decode yields no bytes (the
3100
+ * caller replies 204 the dialog shows its placeholder).
3391
3101
  */
3392
- async runPrunePass() {
3393
- if (this.stopped) return;
3394
- let pruned = 0;
3395
- for (const root of this.thumbsRoots()) {
3396
- let deviceDirs;
3397
- try {
3398
- deviceDirs = (await promises.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && /^\d+$/.test(e.name)).map((e) => e.name);
3399
- } catch {
3400
- continue;
3401
- }
3402
- for (const dev of deviceDirs) {
3403
- if (this.stopped) return;
3404
- const deviceId = Number(dev);
3405
- const deviceDir = path.join(root, dev);
3406
- const buckets = [];
3407
- try {
3408
- for (const rel of await promises.readdir(deviceDir, { recursive: true })) {
3409
- const relPosix = String(rel).split(path.sep).join("/");
3410
- const hourStartMs = parseHourBucket(relPosix);
3411
- if (hourStartMs !== null) buckets.push({
3412
- relDir: relPosix,
3413
- hourStartMs
3414
- });
3415
- }
3416
- } catch {
3417
- continue;
3418
- }
3419
- const segments = this.deps.lowSegments(deviceId);
3420
- const plan = planThumbPrune({
3421
- buckets,
3422
- oldestSegmentStartMs: segments.length > 0 ? Math.min(...segments.map((s) => s.startMs)) : null,
3423
- nowMs: Date.now()
3424
- });
3425
- for (const b of plan) try {
3426
- await promises.rm(path.join(deviceDir, b.relDir), {
3427
- recursive: true,
3428
- force: true
3429
- });
3430
- pruned += 1;
3431
- } catch {}
3432
- if (plan.length > 0) await removeEmptyParents(deviceDir);
3433
- }
3434
- }
3435
- if (pruned > 0) this.deps.logger.info("thumbnail retention prune removed buckets", { meta: { pruned } });
3436
- }
3437
- async persist(req) {
3438
- const root = this.writeRoot();
3439
- if (!root) {
3440
- this.deps.logger.warn("thumbnail persist skipped — no recordings location resolved", { tags: { deviceId: req.deviceId } });
3441
- return;
3442
- }
3443
- await persistThumbnailWindow(root, req);
3444
- }
3445
- /** One backfill scan: fill uncovered, settled windows with recorded low
3446
- * footage, bounded per pass + per concurrency. Best-effort. */
3447
- async runBackfillPass() {
3448
- if (this.stopped || !this.deps.config.backfillEnabled) return;
3449
- const root = this.writeRoot();
3450
- if (!root) return;
3451
- let deviceIds;
3452
- try {
3453
- deviceIds = await this.deps.recordingDeviceIds();
3454
- } catch {
3455
- return;
3456
- }
3457
- let filled = 0;
3458
- for (const deviceId of deviceIds) {
3459
- if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || this.stopped) break;
3460
- const segments = this.deps.lowSegments(deviceId).map((s) => ({
3461
- startMs: s.startMs,
3462
- durMs: s.durMs,
3463
- path: s.path
3464
- }));
3465
- if (segments.length === 0) continue;
3466
- const plans = planBackfill({
3467
- segments,
3468
- coveredWindows: await this.readCoveredWindows(root, deviceId),
3469
- windowMs: this.deps.config.windowMs,
3470
- now: Date.now(),
3471
- settleMs: BACKFILL_SETTLE_MS,
3472
- maxWindows: Math.min(BACKFILL_MAX_WINDOWS_PER_DEVICE, BACKFILL_MAX_WINDOWS_PER_PASS - filled)
3473
- });
3474
- let filledForDevice = 0;
3475
- for (const plan of plans) {
3476
- if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || filledForDevice >= BACKFILL_MAX_WINDOWS_PER_DEVICE || this.stopped) break;
3477
- const key = `${deviceId}:${plan.windowStartMs}`;
3478
- if (this.backfillInFlight.has(key)) continue;
3479
- this.backfillInFlight.add(key);
3480
- try {
3481
- await this.gateBackfill(() => this.backfillWindow(deviceId, plan.windowStartMs, plan.segments, root));
3482
- filled += 1;
3483
- filledForDevice += 1;
3484
- } catch (err) {
3485
- this.deps.logger.debug("backfill window failed", {
3486
- tags: { deviceId },
3487
- meta: {
3488
- windowStartMs: plan.windowStartMs,
3489
- error: err instanceof Error ? err.message : err
3490
- }
3491
- });
3492
- } finally {
3493
- this.backfillInFlight.delete(key);
3494
- }
3495
- }
3496
- }
3497
- if (filled > 0) this.deps.logger.info("thumbnail backfill pass filled windows", { meta: { filled } });
3498
- }
3499
- /** Bounded-concurrency gate (spin-wait, coarse — backfill is rare/hourly). */
3500
- async gateBackfill(fn) {
3501
- while (this.backfillActive >= BACKFILL_MAX_CONCURRENT && !this.stopped) await new Promise((r) => setTimeout(r, 200));
3502
- this.backfillActive += 1;
3503
- try {
3504
- return await fn();
3505
- } finally {
3506
- this.backfillActive -= 1;
3507
- }
3508
- }
3509
- /** Which windows already have an index sidecar on disk (sampled or prior backfill). */
3510
- async readCoveredWindows(root, deviceId) {
3511
- const covered = /* @__PURE__ */ new Set();
3512
- const deviceDir = path.join(root, String(deviceId));
3513
- let entries;
3514
- try {
3515
- entries = await promises.readdir(deviceDir, { recursive: true });
3516
- } catch {
3517
- return covered;
3518
- }
3519
- for (const e of entries) {
3520
- const m = /index-(\d+)\.json$/.exec(e);
3521
- if (m) covered.add(Number(m[1]));
3102
+ async getStill(deviceId, epochMs) {
3103
+ const source = resolveStillSource(this.deps.listLowSegments(deviceId), epochMs);
3104
+ if (source === null) return null;
3105
+ const abs = this.deps.resolveAbsPath(source.row);
3106
+ const now = (this.deps.now ?? Date.now)();
3107
+ const immutable = source.row.startMs + source.row.durMs < now;
3108
+ if (!await this.acquireSlot()) {
3109
+ this.deps.logger.debug("still: decode queue saturated, dropping request", { meta: {
3110
+ deviceId,
3111
+ epochMs
3112
+ } });
3113
+ return null;
3522
3114
  }
3523
- return covered;
3524
- }
3525
- /** Fill ONE window: extract keyframes from its covering low segments, compose
3526
- * a sheet, persist with `source:'backfill'`. */
3527
- async backfillWindow(deviceId, windowStartMs, segments, root) {
3528
- const preset = await this.deps.resolvePreset(deviceId);
3529
- const workDir = await promises.mkdtemp(path.join(tmpdir(), `csthumb-${deviceId}-`));
3530
3115
  try {
3531
- const samples = [];
3532
- for (const seg of segments) {
3533
- const absSeg = this.resolveSegmentAbs(deviceId, seg.path);
3534
- if (!absSeg) continue;
3535
- const jpegs = await this.extractKeyframes(absSeg, workDir, preset);
3536
- const n = jpegs.length;
3537
- for (let i = 0; i < n; i += 1) {
3538
- const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
3539
- if (t >= windowStartMs && t < windowStartMs + this.deps.config.windowMs) samples.push({
3540
- t,
3541
- jpeg: jpegs[i]
3542
- });
3543
- }
3544
- }
3545
- if (samples.length === 0) return;
3546
- if (samples.length > MAX_TILES_PER_WINDOW) samples.length = MAX_TILES_PER_WINDOW;
3547
- const ordered = samples.sort((a, b) => a.t - b.t);
3548
- const built = buildThumbnailIndex({
3549
- deviceId,
3550
- windowStartMs,
3551
- windowMs: this.deps.config.windowMs,
3552
- intervalMs: this.deps.config.intervalMs,
3553
- source: "backfill",
3554
- tileWidth: preset.tileWidth,
3555
- tileHeight: preset.tileHeight,
3556
- maxCols: 10,
3557
- samples: ordered.map((s) => ({ t: s.t }))
3558
- });
3559
- const geometry = computeSheetGeometry(ordered.length, {
3560
- tileWidth: preset.tileWidth,
3561
- tileHeight: preset.tileHeight,
3562
- maxCols: 10
3563
- });
3564
- const placements = allTilePlacements(ordered.length, geometry);
3565
- const sheetBytes = await composeSheet(SHARP, {
3566
- tiles: ordered.map((s) => s.jpeg),
3567
- placements,
3568
- geometry,
3569
- quality: preset.quality
3570
- });
3571
- await persistThumbnailWindow(root, {
3572
- deviceId,
3573
- windowStartMs,
3574
- relDir: `${deviceId}/${bucketDir(windowStartMs)}`,
3575
- sheetName: sheetFileName(windowStartMs),
3576
- sheetBytes,
3577
- indexName: indexFileName(windowStartMs),
3578
- index: built.index
3579
- });
3116
+ const jpeg = await this.decodeFrame(abs, source.offsetSec);
3117
+ if (jpeg === null || jpeg.byteLength === 0) return null;
3118
+ return {
3119
+ jpeg,
3120
+ immutable
3121
+ };
3580
3122
  } finally {
3581
- await promises.rm(workDir, {
3582
- recursive: true,
3583
- force: true
3584
- }).catch(() => {});
3585
- }
3586
- }
3587
- /** Resolve a low segment's absolute path from its location root + rel path. */
3588
- resolveSegmentAbs(deviceId, relPath) {
3589
- const row = this.deps.lowSegments(deviceId).find((r) => r.path === relPath);
3590
- if (!row) return null;
3591
- const loc = this.deps.locations().find((l) => l.id === row.locationId);
3592
- if (!loc) return null;
3593
- return path.join(loc.root, relPath);
3594
- }
3595
- /** Run ONE keyframe-only ffmpeg pass over a segment; return the extracted
3596
- * JPEG buffers in order. */
3597
- async extractKeyframes(absSeg, workDir, preset) {
3598
- const args = buildKeyframeExtractArgs({
3599
- inputPath: absSeg,
3600
- outputPattern: path.join(workDir, "kf-%04d.jpg"),
3601
- tileWidth: preset.tileWidth,
3602
- quality: jpegQualityToFfmpegQ(preset.quality)
3603
- });
3604
- await this.spawnFfmpeg(args);
3605
- const files = (await promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
3606
- const out = [];
3607
- for (const f of files) {
3608
- out.push(await promises.readFile(path.join(workDir, f)));
3609
- await promises.rm(path.join(workDir, f), { force: true }).catch(() => {});
3610
- }
3611
- return out;
3612
- }
3613
- spawnFfmpeg(args) {
3614
- return new Promise((resolve, reject) => {
3123
+ this.releaseSlot();
3124
+ }
3125
+ }
3126
+ /** Run ONE ffmpeg input-seek + single-frame decode; JPEG bytes on stdout. */
3127
+ decodeFrame(absPath, offsetSec) {
3128
+ const args = [
3129
+ "-hide_banner",
3130
+ "-loglevel",
3131
+ "error",
3132
+ "-ss",
3133
+ offsetSec.toFixed(3),
3134
+ "-i",
3135
+ absPath,
3136
+ "-frames:v",
3137
+ "1",
3138
+ "-f",
3139
+ "mjpeg",
3140
+ "pipe:1"
3141
+ ];
3142
+ const spawn$1 = this.deps.spawn ?? spawn;
3143
+ const bin = this.deps.ffmpegPath ?? "ffmpeg";
3144
+ return new Promise((resolve) => {
3615
3145
  let child;
3616
3146
  try {
3617
- child = spawn("ffmpeg", [...args], { stdio: [
3618
- "ignore",
3619
- "ignore",
3620
- "ignore"
3621
- ] });
3147
+ child = spawn$1(bin, args);
3622
3148
  } catch (err) {
3623
- reject(err instanceof Error ? err : new Error(String(err)));
3149
+ this.deps.logger.debug("still: ffmpeg spawn failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3150
+ resolve(null);
3624
3151
  return;
3625
3152
  }
3153
+ const chunks = [];
3154
+ child.stdout?.on("data", (c) => chunks.push(c));
3626
3155
  const timer = setTimeout(() => {
3627
3156
  child.kill("SIGKILL");
3628
- reject(/* @__PURE__ */ new Error("ffmpeg keyframe extract timed out"));
3629
- }, 3e4);
3157
+ this.deps.logger.debug("still: ffmpeg decode timed out", { meta: { absPath } });
3158
+ resolve(null);
3159
+ }, FFMPEG_TIMEOUT_MS);
3630
3160
  timer.unref?.();
3631
3161
  child.on("error", (err) => {
3632
3162
  clearTimeout(timer);
3633
- reject(err);
3163
+ this.deps.logger.debug("still: ffmpeg error", { meta: { error: err.message } });
3164
+ resolve(null);
3634
3165
  });
3635
3166
  child.on("close", (code) => {
3636
3167
  clearTimeout(timer);
3637
- if (code === 0) resolve();
3638
- else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${code}`));
3168
+ if (code !== 0 && chunks.length === 0) {
3169
+ resolve(null);
3170
+ return;
3171
+ }
3172
+ resolve(chunks.length > 0 ? Buffer.concat(chunks) : null);
3639
3173
  });
3640
3174
  });
3641
3175
  }
3642
3176
  };
3643
- /**
3644
- * Best-effort removal of now-empty `Y/M/D` parent dirs after a prune (never
3645
- * the device dir itself). `rmdir` refuses non-empty dirs, so this can never
3646
- * delete content — failures are simply left for the next sweep.
3647
- */
3648
- async function removeEmptyParents(deviceDir) {
3649
- let entries;
3650
- try {
3651
- entries = (await promises.readdir(deviceDir, { recursive: true })).map(String);
3652
- } catch {
3653
- return;
3654
- }
3655
- const dirs = entries.map((rel) => rel.split(path.sep).join("/")).filter((rel) => /^\d{4}(\/\d{2}){0,3}$/.test(rel)).sort((a, b) => b.length - a.length);
3656
- for (const rel of dirs) try {
3657
- await promises.rmdir(path.join(deviceDir, rel));
3658
- } catch {}
3659
- }
3660
3177
  //#endregion
3661
3178
  //#region src/recorder/addon/index.ts
3662
3179
  /**
@@ -3691,9 +3208,7 @@ var DEFAULT_CONFIG = {
3691
3208
  segmentSeconds: 10,
3692
3209
  watchIntervalMs: 2e3,
3693
3210
  recordingNodeId: RECORDING_NODE_DEFAULT,
3694
- recordingHubHostname: "",
3695
- thumbnailsEnabled: true,
3696
- thumbnailBackfillEnabled: true
3211
+ recordingHubHostname: ""
3697
3212
  };
3698
3213
  /** Playback-marker window padding around a motion event (EventMap only; the
3699
3214
  * controller's events-band gating uses the raw trigger ms, not this pad).
@@ -3708,6 +3223,12 @@ var MARKER_PAD_POST_MS = EVENT_PAD_MS.postMs;
3708
3223
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
3709
3224
  var RECORDER_ADDON_ID = "recorder";
3710
3225
  var PLAYBACK_PREFIX = "playback";
3226
+ /** Data-plane prefix for keyframe-only scrub packs →
3227
+ * `/addon/recorder/scrub/<deviceId>/<windowStartSec>`. */
3228
+ var SCRUB_PREFIX = "scrub";
3229
+ /** Data-plane prefix for on-demand single stills →
3230
+ * `/addon/recorder/still/<deviceId>/<epochSec>`. */
3231
+ var STILL_PREFIX = "still";
3711
3232
  /** Data-plane prefix for export MP4 downloads → `/addon/recorder/exports/<id>.mp4`. */
3712
3233
  var EXPORTS_PREFIX = "exports";
3713
3234
  /** How often the export janitor sweeps expired exports. */
@@ -3718,6 +3239,19 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
3718
3239
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
3719
3240
  * the recording-spec §6 periodic retention. */
3720
3241
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
3242
+ /** Scrub-pack cache prune cadence — mirrors the thumbnail service's own prune
3243
+ * tick (6h) + initial delay (10min off boot churn). Packs are a rebuild-on-
3244
+ * demand cache, so a slow sweep is plenty. */
3245
+ var SCRUB_PRUNE_TICK_MS = 6 * 36e5;
3246
+ var SCRUB_PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
3247
+ /** Retention horizon for the scrub-pack cache (days). Packs are a keyframe-only
3248
+ * CACHE derived from the LOW footage — never primary data — so a pruned window
3249
+ * simply rebuilds on the next request while its segments still exist. A fixed,
3250
+ * generous horizon (comfortably past any typical per-device footage max-age)
3251
+ * reclaims disk from truly-old orphaned packs without racing footage retention.
3252
+ * The recorder has no single global footage max-age (it is per-device), so a
3253
+ * fixed cache horizon is the correct scope here. */
3254
+ var SCRUB_PACK_RETENTION_DAYS = 90;
3721
3255
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
3722
3256
  var EXPORT_DELETE_GRACE_MS = 6e4;
3723
3257
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -3747,8 +3281,15 @@ var RecorderV2Addon = class extends BaseAddon {
3747
3281
  resolvedLocations = [];
3748
3282
  /** Base URL of the playback HTTP data-plane, null until served in onHubReachable. */
3749
3283
  playbackBaseUrl = null;
3750
- /** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
3751
- thumbnailService = null;
3284
+ /** On-demand single-still decode service (export-dialog frame previews). Null
3285
+ * on non-recording nodes / when the data-plane serve fails. */
3286
+ /** Keyframe-only scrub-pack service (built + served in onInitialize). Null on
3287
+ * non-recording nodes / when the data-plane serve fails. */
3288
+ scrubPackService = null;
3289
+ /** Periodic scrub-pack cache prune timer + its one-shot initial delay. Cleared
3290
+ * on shutdown. */
3291
+ scrubPruneTimer = null;
3292
+ scrubPruneInitialTimer = null;
3752
3293
  /** Render engine for recording exports. Null on non-recording nodes. */
3753
3294
  exportEngine = null;
3754
3295
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
@@ -3870,7 +3411,31 @@ var RecorderV2Addon = class extends BaseAddon {
3870
3411
  this.recordingProvider = recordingProvider;
3871
3412
  const evictableProvider = new StorageEvictableProvider({
3872
3413
  index: this.index,
3873
- store: this.segmentStore
3414
+ store: this.segmentStore,
3415
+ resolveDomain: (locationId) => this.locationDomain(locationId),
3416
+ onEvicted: (summaries) => {
3417
+ for (const s of summaries) this.ctx.eventBus.emit({
3418
+ id: randomUUID(),
3419
+ timestamp: /* @__PURE__ */ new Date(),
3420
+ source: {
3421
+ type: "device",
3422
+ id: s.deviceId,
3423
+ addonId: this.ctx.id,
3424
+ deviceId: s.deviceId,
3425
+ nodeId: this.nodeId
3426
+ },
3427
+ category: EventCategory.RecordingRetentionCompleted,
3428
+ data: {
3429
+ deviceId: s.deviceId,
3430
+ locationId: s.locationId,
3431
+ segments: s.segments,
3432
+ bytes: s.bytes,
3433
+ fromMs: s.fromMs,
3434
+ toMs: s.toMs,
3435
+ trigger: "disk-pressure"
3436
+ }
3437
+ });
3438
+ }
3874
3439
  });
3875
3440
  const exportsState = this.exportsStore();
3876
3441
  const exportCodecCache = /* @__PURE__ */ new Map();
@@ -3917,6 +3482,55 @@ var RecorderV2Addon = class extends BaseAddon {
3917
3482
  } catch (err) {
3918
3483
  this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: errMsg(err) } });
3919
3484
  }
3485
+ try {
3486
+ const scrubService = new ScrubPackService({
3487
+ listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
3488
+ readSegmentBytes: async (row) => {
3489
+ const abs = this.resolveSegmentAbsPath(row);
3490
+ return new Uint8Array(await promises.readFile(abs));
3491
+ },
3492
+ cacheDir: path.join(this.ctx.dataDir, "scrub-packs"),
3493
+ logger: this.ctx.logger.child("ScrubPacks")
3494
+ });
3495
+ this.scrubPackService = scrubService;
3496
+ const served = await this.ctx.dataPlane?.serve({
3497
+ prefix: SCRUB_PREFIX,
3498
+ access: "authenticated",
3499
+ handler: (req, res) => this.serveScrubPack(scrubService, req, res)
3500
+ });
3501
+ this.scrubPruneInitialTimer = setTimeout(() => {
3502
+ this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
3503
+ }, SCRUB_PRUNE_INITIAL_DELAY_MS);
3504
+ this.scrubPruneInitialTimer.unref?.();
3505
+ this.scrubPruneTimer = setInterval(() => {
3506
+ this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
3507
+ }, SCRUB_PRUNE_TICK_MS);
3508
+ this.scrubPruneTimer.unref?.();
3509
+ this.ctx.logger.info("recorder scrub-pack data-plane served", { meta: {
3510
+ clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${SCRUB_PREFIX}` : "(no dataPlane)",
3511
+ internal: served?.baseUrl
3512
+ } });
3513
+ } catch (err) {
3514
+ this.ctx.logger.warn("scrub-pack data-plane serve failed", { meta: { error: errMsg(err) } });
3515
+ }
3516
+ try {
3517
+ const stillService = new StillFrameService({
3518
+ listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
3519
+ resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
3520
+ logger: this.ctx.logger.child("Still")
3521
+ });
3522
+ const served = await this.ctx.dataPlane?.serve({
3523
+ prefix: STILL_PREFIX,
3524
+ access: "authenticated",
3525
+ handler: (req, res) => this.serveStill(stillService, req, res)
3526
+ });
3527
+ this.ctx.logger.info("recorder still data-plane served", { meta: {
3528
+ clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${STILL_PREFIX}` : "(no dataPlane)",
3529
+ internal: served?.baseUrl
3530
+ } });
3531
+ } catch (err) {
3532
+ this.ctx.logger.warn("still data-plane serve failed", { meta: { error: errMsg(err) } });
3533
+ }
3920
3534
  try {
3921
3535
  const served = await this.ctx.dataPlane?.serve({
3922
3536
  prefix: EXPORTS_PREFIX,
@@ -3930,7 +3544,6 @@ var RecorderV2Addon = class extends BaseAddon {
3930
3544
  } catch (err) {
3931
3545
  this.ctx.logger.warn("recorder exports data-plane failed to serve", { meta: { error: errMsg(err) } });
3932
3546
  }
3933
- await this.ensureThumbnailService().serveEarly();
3934
3547
  this.ctx.logger.info("recorder addon started (continuous write path active)", {
3935
3548
  tags: { nodeId: this.nodeId },
3936
3549
  meta: {
@@ -3959,8 +3572,15 @@ var RecorderV2Addon = class extends BaseAddon {
3959
3572
  await this.controller?.stop();
3960
3573
  this.controller = null;
3961
3574
  this.segmentStore = null;
3962
- await this.thumbnailService?.stop();
3963
- this.thumbnailService = null;
3575
+ if (this.scrubPruneInitialTimer !== null) {
3576
+ clearTimeout(this.scrubPruneInitialTimer);
3577
+ this.scrubPruneInitialTimer = null;
3578
+ }
3579
+ if (this.scrubPruneTimer !== null) {
3580
+ clearInterval(this.scrubPruneTimer);
3581
+ this.scrubPruneTimer = null;
3582
+ }
3583
+ this.scrubPackService = null;
3964
3584
  if (this.exportSweepTimer !== null) {
3965
3585
  clearInterval(this.exportSweepTimer);
3966
3586
  this.exportSweepTimer = null;
@@ -3971,6 +3591,25 @@ var RecorderV2Addon = class extends BaseAddon {
3971
3591
  this.recordingProvider = null;
3972
3592
  }
3973
3593
  /**
3594
+ * The eviction DOMAIN of `locationId`: every resolved recordings location that
3595
+ * shares its physical filesystem root, including itself. Managed locations that
3596
+ * alias one disk (today `recordings:default` + `recordingsLow:default` both at
3597
+ * `/recordings`) form one oldest-first eviction pool — otherwise disk-pressure
3598
+ * relief on one alias deletes the newest footage while the other alias holds the
3599
+ * old (the age-partition data-loss bug).
3600
+ *
3601
+ * Grouping keys by `fs.realpathSync` of the resolved root (see
3602
+ * `resolveEvictionDomain`), NOT the resolved string alone: a symlinked or
3603
+ * bind-aliased path reaching the same disk collapses to one key, closing the
3604
+ * loophole where two aliases with different path strings would re-partition.
3605
+ * When realpath throws (root missing at boot) it falls back to the resolved
3606
+ * string — distinct roots stay distinct domains (safe: over-partitions at
3607
+ * worst, never merges two disks).
3608
+ */
3609
+ locationDomain(locationId) {
3610
+ return resolveEvictionDomain(this.resolvedLocations, locationId);
3611
+ }
3612
+ /**
3974
3613
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
3975
3614
  * recordings locations and rebuild the in-memory index from `storage.list`
3976
3615
  * for every device that has a persisted config. The list result is the
@@ -3984,6 +3623,20 @@ var RecorderV2Addon = class extends BaseAddon {
3984
3623
  this.ctx.logger.warn("recorder: no recordings storage locations resolved — index not hydrated");
3985
3624
  return;
3986
3625
  }
3626
+ try {
3627
+ await this.controller?.start();
3628
+ } catch (err) {
3629
+ this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
3630
+ }
3631
+ if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
3632
+ eventBus: this.ctx.eventBus,
3633
+ map: this.eventMap,
3634
+ padPreMs: MARKER_PAD_PRE_MS,
3635
+ padPostMs: MARKER_PAD_POST_MS,
3636
+ onMotion: (deviceId, atMs) => {
3637
+ this.controller?.onTrigger(deviceId, "motion", atMs);
3638
+ }
3639
+ });
3987
3640
  let configs;
3988
3641
  try {
3989
3642
  configs = await readDeviceConfigs(this.configStore());
@@ -3996,6 +3649,23 @@ var RecorderV2Addon = class extends BaseAddon {
3996
3649
  deviceCount: configs.size,
3997
3650
  locationCount: this.resolvedLocations.length
3998
3651
  } });
3652
+ const loggedDomains = /* @__PURE__ */ new Set();
3653
+ for (const loc of this.resolvedLocations) {
3654
+ const domain = this.locationDomain(loc.id);
3655
+ const domainKey = [...domain].toSorted().join("+");
3656
+ if (loggedDomains.has(domainKey)) continue;
3657
+ loggedDomains.add(domainKey);
3658
+ const acct = this.index.accountingForLocations(domain);
3659
+ this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
3660
+ locationIds: [...domain].toSorted(),
3661
+ root: loc.root,
3662
+ count: acct.count,
3663
+ bytes: acct.bytes,
3664
+ oldestMs: acct.oldestMs,
3665
+ newestMs: acct.newestMs
3666
+ } });
3667
+ }
3668
+ this.cleanupLegacyThumbsDirs();
3999
3669
  const store = this.segmentStore;
4000
3670
  if (store) try {
4001
3671
  await runRedundancyJanitor({
@@ -4007,25 +3677,6 @@ var RecorderV2Addon = class extends BaseAddon {
4007
3677
  } catch (err) {
4008
3678
  this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: errMsg(err) } });
4009
3679
  }
4010
- try {
4011
- await this.controller?.start();
4012
- } catch (err) {
4013
- this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
4014
- }
4015
- if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
4016
- eventBus: this.ctx.eventBus,
4017
- map: this.eventMap,
4018
- padPreMs: MARKER_PAD_PRE_MS,
4019
- padPostMs: MARKER_PAD_POST_MS,
4020
- onMotion: (deviceId, atMs) => {
4021
- this.controller?.onTrigger(deviceId, "motion", atMs);
4022
- }
4023
- });
4024
- try {
4025
- await this.ensureThumbnailService().start();
4026
- } catch (err) {
4027
- this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: errMsg(err) } });
4028
- }
4029
3680
  await this.bootstrapExports();
4030
3681
  const provider = this.recordingProvider;
4031
3682
  if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
@@ -4068,31 +3719,68 @@ var RecorderV2Addon = class extends BaseAddon {
4068
3719
  this.exportSweepTimer.unref?.();
4069
3720
  }
4070
3721
  }
4071
- /** Construct the thumbnail service once — every dep is a lazy closure, so
4072
- * construction is safe in `onInitialize` (before locations resolve). */
4073
- ensureThumbnailService() {
4074
- if (this.thumbnailService === null) this.thumbnailService = new ThumbnailService({
4075
- logger: this.ctx.logger.child("thumbnails"),
4076
- eventBus: this.ctx.eventBus,
4077
- ...this.ctx.dataPlane !== void 0 ? { dataPlane: this.ctx.dataPlane } : {},
4078
- locations: () => this.resolvedLocations,
4079
- lowSegments: (deviceId) => this.index.segments(deviceId, "low"),
4080
- recordingDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
4081
- resolvePreset: async (deviceId) => {
4082
- try {
4083
- return resolveScrubThumbnailGeometry((await loadDeviceConfig(this.configStore(), deviceId)).scrubThumbnails);
4084
- } catch {
4085
- return resolveScrubThumbnailGeometry(void 0);
4086
- }
4087
- },
4088
- config: {
4089
- enabled: this.config.thumbnailsEnabled,
4090
- backfillEnabled: this.config.thumbnailBackfillEnabled,
4091
- windowMs: DEFAULT_WINDOW_MS,
4092
- intervalMs: 5e3
3722
+ /**
3723
+ * One-time boot cleanup of the retired sprite-sheet scrub-thumbnail dirs
3724
+ * (`<locationRoot>/.thumbs`). Scrub previews now use the WebCodecs keyframe
3725
+ * packs + the on-demand still route, so the sheets are dead weight on the
3726
+ * recordings share. Best-effort per location — a failed rm just retries next
3727
+ * boot; a missing dir is a no-op.
3728
+ */
3729
+ async cleanupLegacyThumbsDirs() {
3730
+ const LEGACY_THUMBS_SUBDIR = ".thumbs";
3731
+ for (const loc of this.resolvedLocations) {
3732
+ const dir = path.join(loc.root, LEGACY_THUMBS_SUBDIR);
3733
+ try {
3734
+ await promises.rm(dir, {
3735
+ recursive: true,
3736
+ force: true
3737
+ });
3738
+ this.ctx.logger.info("recorder: removed legacy scrub-thumbnail dir", { meta: { dir } });
3739
+ } catch (err) {
3740
+ this.ctx.logger.debug("recorder: legacy thumbs cleanup skipped", { meta: {
3741
+ dir,
3742
+ error: errMsg(err)
3743
+ } });
4093
3744
  }
4094
- });
4095
- return this.thumbnailService;
3745
+ }
3746
+ }
3747
+ /**
3748
+ * Still request handler: `GET /<deviceId>/<epochSec>` → `image/jpeg` (the
3749
+ * frame nearest the instant in the LOW recording) | 204 (no footage covers
3750
+ * the instant / decode produced nothing) | 400 (bad path) | 500. A fully-past
3751
+ * segment yields an immutable-cacheable still; the still-open live edge is
3752
+ * `no-store`.
3753
+ */
3754
+ async serveStill(service, req, res) {
3755
+ const m = /^\/(\d+)\/(\d+)$/.exec(req.url?.split("?")[0] ?? "");
3756
+ if (!m) {
3757
+ res.writeHead(400).end();
3758
+ return;
3759
+ }
3760
+ const deviceId = Number(m[1]);
3761
+ const epochMs = Number(m[2]) * 1e3;
3762
+ try {
3763
+ const built = await service.getStill(deviceId, epochMs);
3764
+ if (!built) {
3765
+ res.writeHead(204).end();
3766
+ return;
3767
+ }
3768
+ res.writeHead(200, {
3769
+ "content-type": "image/jpeg",
3770
+ "content-length": built.jpeg.byteLength,
3771
+ "cache-control": built.immutable ? "private, max-age=31536000, immutable" : "no-store"
3772
+ });
3773
+ res.end(built.jpeg);
3774
+ } catch (err) {
3775
+ this.ctx.logger.warn("still request failed", {
3776
+ tags: { deviceId },
3777
+ meta: {
3778
+ epochMs,
3779
+ error: errMsg(err)
3780
+ }
3781
+ });
3782
+ res.writeHead(500).end();
3783
+ }
4096
3784
  }
4097
3785
  /** Re-resolve recordings locations (rescanStorage picks up newly-added disks). */
4098
3786
  async refreshLocations() {
@@ -4169,24 +3857,6 @@ var RecorderV2Addon = class extends BaseAddon {
4169
3857
  description: "Advanced: only used when the recording node is an AGENT. The hub's host the agent dials to pull broker streams (the RTSP restream port must be reachable there). Leave empty to auto-derive from the agent's hub connection.",
4170
3858
  default: ""
4171
3859
  }]
4172
- }, {
4173
- id: "scrub-thumbnails",
4174
- title: "Scrub thumbnails",
4175
- description: "Timeline scrub previews. Small JPEG sprite sheets packed from the runner-sampled detection frames (zero extra decode) + an hourly keyframe-only backfill for quiet windows. Served write-once over the authenticated data-plane; works on every viewer platform (no client video decode).",
4176
- columns: 1,
4177
- fields: [{
4178
- type: "boolean",
4179
- key: "thumbnailsEnabled",
4180
- label: "Serve scrub thumbnails",
4181
- description: "Accumulate + serve sprite sheets. Disable to stop packing/serving thumbnails.",
4182
- default: true
4183
- }, {
4184
- type: "boolean",
4185
- key: "thumbnailBackfillEnabled",
4186
- label: "Keyframe backfill",
4187
- description: "Hourly, fill windows that have recorded low footage but no sampled thumbs with a single keyframe-only ffmpeg pass per window (≤24 spawns/day/camera worst case).",
4188
- default: true
4189
- }]
4190
3860
  }] });
4191
3861
  }
4192
3862
  /** Durable handle config-store reads/writes the deviceId→config blob on —
@@ -4231,6 +3901,58 @@ var RecorderV2Addon = class extends BaseAddon {
4231
3901
  });
4232
3902
  }
4233
3903
  /**
3904
+ * Absolute on-disk path for a scrub segment row. Mirrors the playback path
3905
+ * resolution exactly: the file playback serves for `row.path` lives at
3906
+ * `<root for row.locationId>/<row.path>` (same `rootById` lookup the playback
3907
+ * manifest uses). The scrub packs read the SAME source segments as playback.
3908
+ */
3909
+ resolveSegmentAbsPath(row) {
3910
+ const loc = this.resolvedLocations.find((l) => l.id === row.locationId);
3911
+ if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
3912
+ return path.join(loc.root, row.path);
3913
+ }
3914
+ /**
3915
+ * Scrub-pack request handler: `GET /<deviceId>/<windowStartSec>` →
3916
+ * `application/octet-stream` pack | 204 (no footage) | 400 (bad path / window
3917
+ * not aligned to `SCRUB_WINDOW_MS`) | 500. Immutable (fully past) windows carry
3918
+ * a 1-year immutable cache header; the still-open current window is `no-store`.
3919
+ */
3920
+ async serveScrubPack(service, req, res) {
3921
+ const m = /^\/(\d+)\/(\d+)$/.exec(req.url?.split("?")[0] ?? "");
3922
+ if (!m) {
3923
+ res.writeHead(400).end();
3924
+ return;
3925
+ }
3926
+ const deviceId = Number(m[1]);
3927
+ const windowStartMs = Number(m[2]) * 1e3;
3928
+ if (windowStartMs % 6e4 !== 0) {
3929
+ res.writeHead(400).end();
3930
+ return;
3931
+ }
3932
+ try {
3933
+ const built = await service.getOrBuild(deviceId, windowStartMs);
3934
+ if (!built) {
3935
+ res.writeHead(204).end();
3936
+ return;
3937
+ }
3938
+ res.writeHead(200, {
3939
+ "content-type": "application/octet-stream",
3940
+ "content-length": built.pack.byteLength,
3941
+ "cache-control": built.immutable ? "private, max-age=31536000, immutable" : "no-store"
3942
+ });
3943
+ res.end(Buffer.from(built.pack));
3944
+ } catch (err) {
3945
+ this.ctx.logger.warn("scrub pack request failed", {
3946
+ tags: { deviceId },
3947
+ meta: {
3948
+ windowStartMs,
3949
+ error: errMsg(err)
3950
+ }
3951
+ });
3952
+ res.writeHead(500).end();
3953
+ }
3954
+ }
3955
+ /**
4234
3956
  * Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
4235
3957
  * row's file (traversal-guarded by the id regex + the exact stored path),
4236
3958
  * streams it with HTTP `Range`, and — when the FINAL byte of a full-file GET is