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