@camstack/addon-pipeline 1.2.88 → 1.2.90

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 (28) hide show
  1. package/dist/{addon-utils-B8tPjc8F.js → addon-utils-yc-chXuC.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-DCnOL8bz.mjs → dist-BJYwWEZ9.mjs} +54 -11
  7. package/dist/{dist-CMHxEH6s.js → dist-n4Nbnxx5.js} +54 -11
  8. package/dist/{event-loop-stall-monitor-BLJ0S3w2.mjs → event-loop-stall-monitor-BiPffk0D.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-asYJVMb1.js → event-loop-stall-monitor-DG7QY00k.js} +1 -1
  10. package/dist/{lazy-sharp-1s0ymn1V.js → lazy-sharp-EoSOHgoj.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +735 -67
  16. package/dist/recorder/index.mjs +734 -66
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +1 -1
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BeSS5V2H.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CsrMZYLB.mjs} +1 -1
  21. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B2scqOH7.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DNSELPGi.mjs} +1 -1
  22. package/dist/stream-broker/{hostInit-BC9QIyzJ.mjs → hostInit-DoFovQmc.mjs} +1 -1
  23. package/dist/stream-broker/index.js +3 -2
  24. package/dist/stream-broker/index.mjs +3 -2
  25. package/dist/stream-broker/remoteEntry.js +1 -1
  26. package/dist/{worker-protocol-BzvA7rx4.js → worker-protocol-DjuxhGo3.js} +1 -1
  27. package/dist/{worker-protocol-CBTyzyds.mjs → worker-protocol-XZ1e36rM.mjs} +1 -1
  28. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { At as number, Ft as EventCategory, I as deriveRecordingMode, Mt as record, Nt as string, S as RecordingConfigSchema, St as selectAssignedProfileSlots, Tt as array, X as recordingCapability, Z as recordingExportCapability, et as storageEvictableCapability, f as EVENT_PAD_MS, ht as hydrateSchema, it as errMsg, jt as object, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, pt as DeviceType, ut as BaseAddon, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES, yt as nodePin } from "../dist-DCnOL8bz.mjs";
1
+ import { At as number, Ft as EventCategory, I as deriveRecordingMode, Mt as record, Nt as string, S as RecordingConfigSchema, St as selectAssignedProfileSlots, Tt as array, X as recordingCapability, Z as recordingExportCapability, et as storageEvictableCapability, f as EVENT_PAD_MS, ht as hydrateSchema, it as errMsg, jt as object, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, pt as DeviceType, ut as BaseAddon, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES, yt as nodePin } from "../dist-BJYwWEZ9.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -506,14 +506,14 @@ function parseSegmentPath(relPath) {
506
506
  //#region src/recorder/recording-index.ts
507
507
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
508
508
  * already gives us — so "have I walked this?" costs one Set lookup. */
509
- var HOUR_MS$4 = 36e5;
509
+ var HOUR_MS$5 = 36e5;
510
510
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
511
511
  * the retention window into the set. */
512
512
  var ALL_HOURS = Symbol("all-hours");
513
513
  /** The hour buckets a half-open span touches. */
514
514
  function* hourBuckets(fromMs, toMs) {
515
- const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
516
- for (let h = first; h < toMs; h += HOUR_MS$4) yield h;
515
+ const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
516
+ for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
517
517
  }
518
518
  var RecordingIndex = class {
519
519
  byDevice = /* @__PURE__ */ new Map();
@@ -629,7 +629,7 @@ var RecordingIndex = class {
629
629
  });
630
630
  }
631
631
  this.invalidateSorted(deviceId);
632
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$4);
632
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
633
633
  }
634
634
  /** Insert/replace one finalized segment (idempotent by path). */
635
635
  addSegment(s) {
@@ -1370,6 +1370,8 @@ var SegmentStore = class SegmentStore {
1370
1370
  if (removed.length > 0) {
1371
1371
  this.deps.index.removeSegments(removed);
1372
1372
  this.notifyEvicted(rows);
1373
+ const gone = new Set(removed);
1374
+ this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
1373
1375
  }
1374
1376
  await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
1375
1377
  this.deps.logger.info("recorder: eviction round complete", { meta: {
@@ -1935,7 +1937,7 @@ async function profileHasFootage(root, deviceId, profile) {
1935
1937
  //#endregion
1936
1938
  //#region src/recorder/addon/calendar-index.ts
1937
1939
  var DAY_MS$3 = 864e5;
1938
- var HOUR_MS$3 = 36e5;
1940
+ var HOUR_MS$4 = 36e5;
1939
1941
  /**
1940
1942
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
1941
1943
  *
@@ -2108,7 +2110,7 @@ var CalendarIndex = class {
2108
2110
  }));
2109
2111
  const days = /* @__PURE__ */ new Set();
2110
2112
  for (const { day, hours } of perDay) for (const h of hours) {
2111
- const at = day + h * HOUR_MS$3;
2113
+ const at = day + h * HOUR_MS$4;
2112
2114
  if (at < fromMs || at >= toMs) continue;
2113
2115
  days.add(localDayStart(at, tzOffsetMinutes));
2114
2116
  }
@@ -2170,7 +2172,7 @@ var CalendarIndex = class {
2170
2172
  const day = utcDayOf(utcDayMs);
2171
2173
  for (let h = 0; h < 24; h++) {
2172
2174
  await this.awaitQuiet();
2173
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$3, true);
2175
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
2174
2176
  }
2175
2177
  }
2176
2178
  /**
@@ -2191,10 +2193,10 @@ var CalendarIndex = class {
2191
2193
  */
2192
2194
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
2193
2195
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
2194
- const hour = Math.floor(hourStartMs / HOUR_MS$3) * HOUR_MS$3;
2196
+ const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
2195
2197
  const key = `${deviceId}:${profile}:${hour}`;
2196
2198
  const now = this.deps.now?.() ?? Date.now();
2197
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$3) * HOUR_MS$3;
2199
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
2198
2200
  const cached = this.hourSegs.get(key);
2199
2201
  if (cached !== void 0) {
2200
2202
  if (!isCurrentHour) return {
@@ -2258,7 +2260,7 @@ var CalendarIndex = class {
2258
2260
  */
2259
2261
  async dayRanges(deviceId, profile, utcDayMs) {
2260
2262
  const day = utcDayOf(utcDayMs);
2261
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$3), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2263
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2262
2264
  }
2263
2265
  /**
2264
2266
  * Warm the days either side of the one being viewed, in the BACKGROUND.
@@ -2436,7 +2438,7 @@ function activeModeForConfig(config) {
2436
2438
  }
2437
2439
  //#endregion
2438
2440
  //#region src/recorder/addon/directory-handler.ts
2439
- var HOUR_MS$2 = 36e5;
2441
+ var HOUR_MS$3 = 36e5;
2440
2442
  /** A window wider than this is refused: it is a client bug, and answering it
2441
2443
  * would walk the archive on the read path — the thing this replaces. */
2442
2444
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -2483,9 +2485,9 @@ function withDirectoryRoute(deps, next) {
2483
2485
  return;
2484
2486
  }
2485
2487
  const rows = [];
2486
- const firstHour = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
2488
+ const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
2487
2489
  let hydrated = true;
2488
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$2) {
2490
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
2489
2491
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
2490
2492
  if (!complete) hydrated = false;
2491
2493
  for (const s of segments) {
@@ -2633,6 +2635,28 @@ function withStreamTimeDenseRanges(input) {
2633
2635
  }
2634
2636
  //#endregion
2635
2637
  //#region src/recorder/addon/export-ffmpeg-args.ts
2638
+ /**
2639
+ * ffmpeg argv builder for recording EXPORTS (render a footage range → one MP4).
2640
+ *
2641
+ * Input is the export's OWN source playlist for the range — a per-export file
2642
+ * whose segment URIs are absolute on-disk paths (`export-source-playlist.ts`).
2643
+ * It is deliberately NOT the playback manifest's `localMasterPath`: those URIs
2644
+ * resolve only through the data-plane's multi-root search, which ffmpeg does not
2645
+ * have. Output is a single faststart MP4. Three shapes, from `ExportOptions`:
2646
+ * - 1× + audio → pure remux (`-c copy`), zero re-encode.
2647
+ * - speed ≠ 1 → `setpts=PTS/speed` video re-encode; audio kept
2648
+ * only for 0.5–2× (single `atempo`), else dropped.
2649
+ * - timelapse → sample one frame per `everyMs`, output at
2650
+ * `outputFps`, always silent.
2651
+ * Distinct from `buildPassthroughArgs` (live segmentation) — do not conflate.
2652
+ *
2653
+ * A **timelapse of a long HLS concat** is two argv sets, not one. Pass 1
2654
+ * (`buildConcatPassArgs`) remuxes the playlist to a single mpegts with `-c
2655
+ * copy`. Pass 2 (`buildExportArgs` with that file as `-i`) then selects
2656
+ * frames. Selecting WHILE walking `#EXT-X-DISCONTINUITY` (one per 10s
2657
+ * segment from `-reset_timestamps`) is what parked a 30h salone job at
2658
+ * `rendering 0%` / 0% CPU — ffmpeg never left the concat demuxer.
2659
+ */
2636
2660
  /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
2637
2661
  * band audio is dropped rather than chained (kept simple + predictable). */
2638
2662
  var ATEMPO_MIN = .5;
@@ -2696,6 +2720,29 @@ function selectPredicate(everySec, dense) {
2696
2720
  if (dense === void 0 || dense.ranges.length === 0) return `${seed}+${elapsed(everySec)}`;
2697
2721
  return `${seed}+if(${dense.ranges.map((r) => `between(t,${sec(r.fromSec)},${sec(r.toSec)})`).join("+")},${elapsed(dense.everyMs / 1e3)},${elapsed(everySec)})`;
2698
2722
  }
2723
+ /**
2724
+ * Sidecar for timelapse pass 1: same directory as the final MP4, `.concat.ts`.
2725
+ * mpegts (not MP4) because `-c copy` across HLS discontinuities is reliable
2726
+ * in a transport stream and often is not in a fragmented MP4.
2727
+ */
2728
+ function concatPassOutPath(outPath) {
2729
+ const dir = path.dirname(outPath);
2730
+ const base = path.basename(outPath, path.extname(outPath));
2731
+ return path.join(dir, `${base}.concat.ts`);
2732
+ }
2733
+ /** Pass 1 of a timelapse: remux the HLS playlist into one continuous file. */
2734
+ function buildConcatPassArgs(input) {
2735
+ return [
2736
+ "-i",
2737
+ input.inputPlaylist,
2738
+ "-c",
2739
+ "copy",
2740
+ "-an",
2741
+ "-f",
2742
+ "mpegts",
2743
+ input.outPath
2744
+ ];
2745
+ }
2699
2746
  /** Build the ffmpeg argv for one export render. */
2700
2747
  function buildExportArgs(input) {
2701
2748
  const { inputPlaylist, outPath, options } = input;
@@ -2982,12 +3029,6 @@ var ExportEngine = class {
2982
3029
  /** Spawn ffmpeg for one render and resolve when it has exited + finalized. */
2983
3030
  async spawnRender(id, rec, playlist, options) {
2984
3031
  const outPath = this.deps.outPath(rec);
2985
- const args = buildExportArgs({
2986
- inputPlaylist: playlist,
2987
- outPath,
2988
- options
2989
- });
2990
- const totalSec = expectedOutSeconds(rec);
2991
3032
  this.deps.logger.info("export render starting", {
2992
3033
  tags: { deviceId: rec.deviceId },
2993
3034
  meta: {
@@ -2998,11 +3039,67 @@ var ExportEngine = class {
2998
3039
  outPath
2999
3040
  }
3000
3041
  });
3042
+ if (options.timelapse) {
3043
+ await this.spawnTimelapse(id, rec, playlist, outPath, options);
3044
+ return;
3045
+ }
3046
+ const args = buildExportArgs({
3047
+ inputPlaylist: playlist,
3048
+ outPath,
3049
+ options
3050
+ });
3051
+ const ran = await this.runFfmpeg(id, args, {
3052
+ totalSec: expectedOutSeconds(rec),
3053
+ floor: 0,
3054
+ ceil: 99
3055
+ });
3056
+ await this.finish(id, ran.code, outPath, ran.stderrTail);
3057
+ }
3058
+ /**
3059
+ * Concat the HLS pieces into one continuous file, THEN select frames.
3060
+ * The select filter on a discontinuity-heavy playlist is what parked a
3061
+ * 30h job at `rendering 0%` — pass 1 is `-c copy` into mpegts so pass 2
3062
+ * sees a single timestamp line.
3063
+ */
3064
+ async spawnTimelapse(id, rec, playlist, outPath, options) {
3065
+ const concatPath = concatPassOutPath(outPath);
3066
+ try {
3067
+ const concatArgs = buildConcatPassArgs({
3068
+ inputPlaylist: playlist,
3069
+ outPath: concatPath
3070
+ });
3071
+ const concatRan = await this.runFfmpeg(id, concatArgs, {
3072
+ totalSec: Math.max(.001, (rec.toMs - rec.fromMs) / 1e3),
3073
+ floor: 0,
3074
+ ceil: 49
3075
+ });
3076
+ if (this.cancelled.has(id) || concatRan.code !== 0) {
3077
+ await this.finish(id, concatRan.code, concatPath, concatRan.stderrTail);
3078
+ return;
3079
+ }
3080
+ const selectArgs = buildExportArgs({
3081
+ inputPlaylist: concatPath,
3082
+ outPath,
3083
+ options
3084
+ });
3085
+ const selectRan = await this.runFfmpeg(id, selectArgs, {
3086
+ totalSec: expectedOutSeconds(rec),
3087
+ floor: 50,
3088
+ ceil: 99
3089
+ });
3090
+ await this.finish(id, selectRan.code, outPath, selectRan.stderrTail);
3091
+ } finally {
3092
+ try {
3093
+ await this.deps.removeFile(concatPath);
3094
+ } catch {}
3095
+ }
3096
+ }
3097
+ runFfmpeg(id, args, progress) {
3001
3098
  this.deps.logger.debug("export ffmpeg argv", { meta: {
3002
3099
  exportId: id,
3003
3100
  args
3004
3101
  } });
3005
- await new Promise((resolve) => {
3102
+ return new Promise((resolve) => {
3006
3103
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
3007
3104
  this.activeProc = proc;
3008
3105
  const stderrTail = [];
@@ -3017,7 +3114,8 @@ var ExportEngine = class {
3017
3114
  const m = TIME_RE.exec(line);
3018
3115
  if (m) {
3019
3116
  const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
3020
- const pct = Math.min(99, Math.round(sec / totalSec * 100));
3117
+ const span = progress.ceil - progress.floor;
3118
+ const pct = Math.min(progress.ceil, progress.floor + Math.round(sec / progress.totalSec * span));
3021
3119
  patchExport(this.deps.state, id, { progressPct: pct });
3022
3120
  this.deps.emit(EventCategory.RecordingExportProgress, {
3023
3121
  exportId: id,
@@ -3028,7 +3126,10 @@ var ExportEngine = class {
3028
3126
  });
3029
3127
  proc.on("exit", (code) => {
3030
3128
  this.activeProc = null;
3031
- this.finish(id, typeof code === "number" ? code : null, outPath, stderrTail).then(resolve);
3129
+ resolve({
3130
+ code: typeof code === "number" ? code : null,
3131
+ stderrTail
3132
+ });
3032
3133
  });
3033
3134
  });
3034
3135
  }
@@ -3079,9 +3180,15 @@ var ExportEngine = class {
3079
3180
  outPath,
3080
3181
  bytes
3081
3182
  } });
3183
+ const rec = await getExportRecord(this.deps.state, id);
3082
3184
  this.deps.emit(EventCategory.RecordingExportCompleted, {
3083
3185
  exportId: id,
3084
- fileBytes: bytes
3186
+ fileBytes: bytes,
3187
+ ...rec !== null ? {
3188
+ deviceId: rec.deviceId,
3189
+ ...rec.options.title !== void 0 ? { title: rec.options.title } : {},
3190
+ ...rec.options.notifyTargetIds !== void 0 && rec.options.notifyTargetIds.length > 0 ? { notifyTargetIds: rec.options.notifyTargetIds } : {}
3191
+ } : {}
3085
3192
  });
3086
3193
  }
3087
3194
  async fail(id, error) {
@@ -5080,7 +5187,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
5080
5187
  const p2 = (n) => String(n).padStart(2, "0");
5081
5188
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
5082
5189
  }
5083
- var HOUR_MS$1 = 36e5;
5190
+ var HOUR_MS$2 = 36e5;
5084
5191
  /**
5085
5192
  * Hydrate ONLY the hours a window spans, instead of walking the archive.
5086
5193
  *
@@ -5115,8 +5222,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
5115
5222
  });
5116
5223
  continue;
5117
5224
  }
5118
- const first = Math.floor(fromMs / HOUR_MS$1) * HOUR_MS$1;
5119
- for (let hour = first; hour < toMs; hour += HOUR_MS$1) for (const profile of profiles) {
5225
+ const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
5226
+ for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
5120
5227
  const rel = hourDirRelPath(deviceId, profile, hour);
5121
5228
  let files;
5122
5229
  try {
@@ -5758,8 +5865,19 @@ function startPeriodicPass(deps) {
5758
5865
  isRunning: () => startedAt !== null
5759
5866
  };
5760
5867
  }
5761
- //#endregion
5762
- //#region src/recorder/addon/readiness-restore.ts
5868
+ async function runBounded(items, concurrency, worker) {
5869
+ if (items.length === 0) return;
5870
+ const queue = [...items];
5871
+ const lanes = Math.max(1, Math.min(concurrency, queue.length));
5872
+ const lane = async () => {
5873
+ for (;;) {
5874
+ const item = queue.shift();
5875
+ if (item === void 0) return;
5876
+ await worker(item);
5877
+ }
5878
+ };
5879
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
5880
+ }
5763
5881
  var ReadinessRestore = class {
5764
5882
  deps;
5765
5883
  pending = /* @__PURE__ */ new Map();
@@ -5769,6 +5887,9 @@ var ReadinessRestore = class {
5769
5887
  constructor(deps) {
5770
5888
  this.deps = deps;
5771
5889
  }
5890
+ get concurrency() {
5891
+ return Math.max(1, this.deps.concurrency ?? 8);
5892
+ }
5772
5893
  /** Seed the pending set, subscribe to readiness, and drain once immediately
5773
5894
  * (covers the already-ready case). Returns when the first drain settles. */
5774
5895
  async start(items) {
@@ -5799,7 +5920,8 @@ var ReadinessRestore = class {
5799
5920
  return this.pending.has(key);
5800
5921
  }
5801
5922
  /** Attempt every still-pending item; repeat once if a readiness signal arrived
5802
- * mid-flight. Re-entrant and self-coalescing. */
5923
+ * mid-flight. Re-entrant and self-coalescing. Overlapping up to
5924
+ * {@link ReadinessRestoreDeps.concurrency}. */
5803
5925
  async drain() {
5804
5926
  if (this.draining) {
5805
5927
  this.rerun = true;
@@ -5810,12 +5932,15 @@ var ReadinessRestore = class {
5810
5932
  try {
5811
5933
  do {
5812
5934
  this.rerun = false;
5813
- for (const [key, value] of Array.from(this.pending)) try {
5814
- await this.deps.attempt(key, value);
5815
- this.pending.delete(key);
5816
- } catch (err) {
5817
- this.deps.onDefer?.(key, value, err);
5818
- }
5935
+ await runBounded(Array.from(this.pending), this.concurrency, async ([key, value]) => {
5936
+ if (!this.pending.has(key)) return;
5937
+ try {
5938
+ await this.deps.attempt(key, value);
5939
+ this.pending.delete(key);
5940
+ } catch (err) {
5941
+ this.deps.onDefer?.(key, value, err);
5942
+ }
5943
+ });
5819
5944
  } while (this.rerun && this.pending.size > 0);
5820
5945
  } finally {
5821
5946
  this.draining = false;
@@ -6510,14 +6635,16 @@ var RecordingController = class {
6510
6635
  } catch (err) {
6511
6636
  this.deps.logger.warn("recorder controller: placement recompute failed", { meta: { error: errMsg(err) } });
6512
6637
  }
6513
- for (const id of ids) try {
6514
- await this.evaluateDevice(id);
6515
- } catch (err) {
6516
- this.deps.logger.warn("recorder controller: convergence evaluate failed", {
6517
- tags: { deviceId: id },
6518
- meta: { error: errMsg(err) }
6519
- });
6520
- }
6638
+ await runBounded([...ids], 8, async (id) => {
6639
+ try {
6640
+ await this.evaluateDevice(id);
6641
+ } catch (err) {
6642
+ this.deps.logger.warn("recorder controller: convergence evaluate failed", {
6643
+ tags: { deviceId: id },
6644
+ meta: { error: errMsg(err) }
6645
+ });
6646
+ }
6647
+ });
6521
6648
  }
6522
6649
  /**
6523
6650
  * Re-evaluate one device after a config change (called from `setDeviceConfig`).
@@ -6914,6 +7041,503 @@ function makeBrokerCall(handle) {
6914
7041
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
6915
7042
  }
6916
7043
  //#endregion
7044
+ //#region src/durable/durable-ledger.ts
7045
+ /** Default reseed cap — every current consumer's row set is installation-bounded. */
7046
+ var DEFAULT_LOAD_LIMIT = 1e5;
7047
+ var DurableLedger = class DurableLedger {
7048
+ mirror = /* @__PURE__ */ new Map();
7049
+ spec;
7050
+ store;
7051
+ logger;
7052
+ constructor(deps) {
7053
+ this.spec = deps.spec;
7054
+ this.store = deps.store;
7055
+ this.logger = deps.logger;
7056
+ }
7057
+ /**
7058
+ * Register the collection. MUST run at boot, before any read or write: the
7059
+ * SQLite backend answers 412 for an undeclared collection and takes the whole
7060
+ * runner down with it (the addon-ai boot-crash lesson).
7061
+ */
7062
+ static declare(store, spec) {
7063
+ return store.declareCollection.mutate({
7064
+ collection: spec.collection,
7065
+ columns: [...spec.columns],
7066
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
7067
+ });
7068
+ }
7069
+ declare() {
7070
+ return DurableLedger.declare(this.store, this.spec);
7071
+ }
7072
+ /** The collection this ledger owns — for the caller's own log lines. */
7073
+ get collection() {
7074
+ return this.spec.collection;
7075
+ }
7076
+ /**
7077
+ * Boot reseed. Replaces the mirror with what the store holds and returns the
7078
+ * rows, so a caller that must hydrate something else (a watcher, a registry)
7079
+ * gets them without a second read.
7080
+ *
7081
+ * **A failure returns what is already mirrored** rather than clearing it —
7082
+ * see contract rule 2. The count is worth logging out loud at the call site:
7083
+ * "loaded 0" after a container recreate is the one line that explains a
7084
+ * silent flood.
7085
+ */
7086
+ async load() {
7087
+ try {
7088
+ const records = await this.store.query.query({
7089
+ collection: this.spec.collection,
7090
+ filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
7091
+ });
7092
+ const next = /* @__PURE__ */ new Map();
7093
+ let skipped = 0;
7094
+ for (const record of records) {
7095
+ const row = this.spec.fromRecord(record.id, record.data);
7096
+ if (row === null) {
7097
+ skipped += 1;
7098
+ continue;
7099
+ }
7100
+ next.set(this.spec.keyOf(row), row);
7101
+ }
7102
+ this.mirror.clear();
7103
+ for (const [key, row] of next) this.mirror.set(key, row);
7104
+ if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
7105
+ collection: this.spec.collection,
7106
+ skipped
7107
+ } });
7108
+ return [...this.mirror.values()];
7109
+ } catch (err) {
7110
+ this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
7111
+ collection: this.spec.collection,
7112
+ error: String(err),
7113
+ held: this.mirror.size
7114
+ } });
7115
+ return [...this.mirror.values()];
7116
+ }
7117
+ }
7118
+ /** Every mirrored row, insertion-ordered. */
7119
+ snapshot() {
7120
+ return [...this.mirror.values()];
7121
+ }
7122
+ /** The row currently held for a key, if any. Pure RAM — never I/O. */
7123
+ get(key) {
7124
+ return this.mirror.get(key);
7125
+ }
7126
+ has(key) {
7127
+ return this.mirror.has(key);
7128
+ }
7129
+ get size() {
7130
+ return this.mirror.size;
7131
+ }
7132
+ /**
7133
+ * Judge ONE observation against what the ledger already accepted, and advance
7134
+ * it.
7135
+ *
7136
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
7137
+ * alone, so a decision can never be gated on an I/O that might fail (D49).
7138
+ * The durable write is kicked off behind it and its failure changes no
7139
+ * verdict.
7140
+ *
7141
+ * `no-flip` leaves the held row UNTOUCHED — including any timestamp it
7142
+ * carries, which therefore means "when this key last CHANGED", not "when it
7143
+ * last spoke". That is the timestamp anyone reading the table wants.
7144
+ */
7145
+ observe(row) {
7146
+ const equalFact = this.spec.equalFact;
7147
+ if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
7148
+ const key = this.spec.keyOf(row);
7149
+ const held = this.mirror.get(key);
7150
+ if (held !== void 0 && equalFact(held, row)) return "no-flip";
7151
+ this.mirror.set(key, row);
7152
+ this.persist(row);
7153
+ return held === void 0 ? "seeded" : "flip";
7154
+ }
7155
+ /**
7156
+ * Upsert a row without a verdict — the write path for a ledger whose owner
7157
+ * has already decided the value changed.
7158
+ *
7159
+ * The order of the mirror advance and the durable write is the spec's
7160
+ * {@link DurableWriteMode}, not the call site's: two call sites that
7161
+ * disagreed about it would be two different durability guarantees on one
7162
+ * collection.
7163
+ */
7164
+ async put(row) {
7165
+ const key = this.spec.keyOf(row);
7166
+ if (this.spec.writeMode === "write-behind") {
7167
+ this.mirror.set(key, row);
7168
+ await this.persist(row);
7169
+ return;
7170
+ }
7171
+ await this.store.set.mutate({
7172
+ collection: this.spec.collection,
7173
+ key,
7174
+ value: this.spec.toValue(row)
7175
+ });
7176
+ this.mirror.set(key, row);
7177
+ }
7178
+ /**
7179
+ * Advance the mirror WITHOUT persisting, for an owner that deliberately
7180
+ * coalesces its writes.
7181
+ *
7182
+ * The stationary registry is the reason this exists: a parked car is
7183
+ * re-confirmed on every processed frame (5–30 Hz), and persisting each
7184
+ * confirmation would offer thousands of commits a day to the checkpoint
7185
+ * lottery (D96) to maintain a handful of rows. It stages the advance and
7186
+ * flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
7187
+ *
7188
+ * **The cost is stated, not hidden**: a staged value that is never flushed
7189
+ * is lost on a crash. An owner may only stage a field whose staleness its
7190
+ * own TTL absorbs. Anything that GATES work must go through {@link put} or
7191
+ * {@link observe}.
7192
+ */
7193
+ stage(row) {
7194
+ this.mirror.set(this.spec.keyOf(row), row);
7195
+ }
7196
+ /**
7197
+ * Drop a key from the MIRROR only — the durable row survives.
7198
+ *
7199
+ * What a scope-unbind needs: this process stops holding the value, and a
7200
+ * rebind reloads it from the store. Deliberately distinct from
7201
+ * {@link forget}, which deletes; conflating the two is how an unbind turns
7202
+ * into a wipe.
7203
+ */
7204
+ evict(key) {
7205
+ this.mirror.delete(key);
7206
+ }
7207
+ /**
7208
+ * Drop one key, mirror and row. Best-effort on the durable half: a failed
7209
+ * delete leaves a row that the next load will re-mirror, which is a stale
7210
+ * value rather than a lost one.
7211
+ */
7212
+ async forget(key) {
7213
+ this.mirror.delete(key);
7214
+ try {
7215
+ await this.store.delete.mutate({
7216
+ collection: this.spec.collection,
7217
+ key
7218
+ });
7219
+ } catch (err) {
7220
+ this.logger.debug("durable delete failed", { meta: {
7221
+ collection: this.spec.collection,
7222
+ key,
7223
+ error: String(err)
7224
+ } });
7225
+ }
7226
+ }
7227
+ /**
7228
+ * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
7229
+ *
7230
+ * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
7231
+ * fallible read is work destroyed on an error (D49/D130) — that is why this
7232
+ * is a method a feature opts into rather than a policy the primitive runs.
7233
+ * Best-effort per row: a failed delete keeps the key (retried next prune)
7234
+ * rather than aborting the sweep.
7235
+ */
7236
+ async pruneExcept(activeKeys) {
7237
+ let pruned = 0;
7238
+ for (const key of [...this.mirror.keys()]) {
7239
+ if (activeKeys.has(key)) continue;
7240
+ try {
7241
+ await this.store.delete.mutate({
7242
+ collection: this.spec.collection,
7243
+ key
7244
+ });
7245
+ this.mirror.delete(key);
7246
+ pruned += 1;
7247
+ } catch (err) {
7248
+ this.logger.debug("durable prune delete failed", { meta: {
7249
+ collection: this.spec.collection,
7250
+ key,
7251
+ error: String(err)
7252
+ } });
7253
+ }
7254
+ }
7255
+ return pruned;
7256
+ }
7257
+ /**
7258
+ * Write-behind durable upsert. Best-effort and logged, never thrown at the
7259
+ * decision path: the mirror already holds the truth for this process, and the
7260
+ * worst a lost write can do is one silent re-seed after the next restart.
7261
+ */
7262
+ async persist(row) {
7263
+ const deviceId = this.spec.deviceIdOf?.(row);
7264
+ try {
7265
+ await this.store.set.mutate({
7266
+ collection: this.spec.collection,
7267
+ key: this.spec.keyOf(row),
7268
+ value: this.spec.toValue(row)
7269
+ });
7270
+ } catch (err) {
7271
+ this.logger.warn("durable persist failed — this key may re-seed on boot", {
7272
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7273
+ meta: {
7274
+ collection: this.spec.collection,
7275
+ key: this.spec.keyOf(row),
7276
+ error: String(err)
7277
+ }
7278
+ });
7279
+ }
7280
+ }
7281
+ };
7282
+ //#endregion
7283
+ //#region src/recorder/addon/segment-hour-ledger.ts
7284
+ var HOUR_MS$1 = 36e5;
7285
+ /**
7286
+ * @durable class=mirror owner=recorder
7287
+ * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
7288
+ * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
7289
+ */
7290
+ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
7291
+ var RECORDING_SEGMENT_HOURS_COLUMNS = [
7292
+ {
7293
+ name: "key",
7294
+ type: "TEXT",
7295
+ primaryKey: true,
7296
+ notNull: true
7297
+ },
7298
+ {
7299
+ name: "deviceId",
7300
+ type: "INTEGER",
7301
+ notNull: true
7302
+ },
7303
+ {
7304
+ name: "profile",
7305
+ type: "TEXT",
7306
+ notNull: true
7307
+ },
7308
+ {
7309
+ name: "locationId",
7310
+ type: "TEXT",
7311
+ notNull: true
7312
+ },
7313
+ {
7314
+ name: "hourStartMs",
7315
+ type: "INTEGER",
7316
+ notNull: true
7317
+ },
7318
+ {
7319
+ name: "paths",
7320
+ type: "TEXT",
7321
+ notNull: true
7322
+ }
7323
+ ];
7324
+ var RECORDING_SEGMENT_HOURS_INDEXES = [{
7325
+ name: "idx_recorder_segment_hours_device",
7326
+ columns: ["deviceId"]
7327
+ }, {
7328
+ name: "idx_recorder_segment_hours_hour",
7329
+ columns: ["hourStartMs"]
7330
+ }];
7331
+ function hourStartMs(startMs) {
7332
+ return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
7333
+ }
7334
+ function segmentHourKey(deviceId, profile, locationId, hour) {
7335
+ return `${deviceId}|${profile}|${locationId}|${hour}`;
7336
+ }
7337
+ function hourOf(row) {
7338
+ const hour = hourStartMs(row.startMs);
7339
+ return {
7340
+ hourStartMs: hour,
7341
+ key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
7342
+ };
7343
+ }
7344
+ function uniquePaths(paths) {
7345
+ const seen = /* @__PURE__ */ new Set();
7346
+ const out = [];
7347
+ for (const p of paths) {
7348
+ if (seen.has(p)) continue;
7349
+ seen.add(p);
7350
+ out.push(p);
7351
+ }
7352
+ return out;
7353
+ }
7354
+ function samePaths(a, b) {
7355
+ if (a.length !== b.length) return false;
7356
+ const set = new Set(a);
7357
+ for (const p of b) if (!set.has(p)) return false;
7358
+ return true;
7359
+ }
7360
+ function rowToValue(row) {
7361
+ return {
7362
+ deviceId: row.deviceId,
7363
+ profile: row.profile,
7364
+ locationId: row.locationId,
7365
+ hourStartMs: row.hourStartMs,
7366
+ paths: JSON.stringify(row.paths)
7367
+ };
7368
+ }
7369
+ function recordToRow(key, data) {
7370
+ const deviceId = Number(data["deviceId"]);
7371
+ const profile = data["profile"];
7372
+ const locationId = data["locationId"];
7373
+ const hour = Number(data["hourStartMs"]);
7374
+ let raw = data["paths"];
7375
+ if (typeof raw === "string") try {
7376
+ raw = JSON.parse(raw);
7377
+ } catch {
7378
+ return null;
7379
+ }
7380
+ if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
7381
+ return {
7382
+ key,
7383
+ deviceId,
7384
+ profile,
7385
+ locationId,
7386
+ hourStartMs: hour,
7387
+ paths: raw
7388
+ };
7389
+ }
7390
+ var SPEC = {
7391
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
7392
+ columns: RECORDING_SEGMENT_HOURS_COLUMNS,
7393
+ indexes: RECORDING_SEGMENT_HOURS_INDEXES,
7394
+ writeMode: "write-behind",
7395
+ loadLimit: 1e5,
7396
+ keyOf: (row) => row.key,
7397
+ toValue: rowToValue,
7398
+ fromRecord: recordToRow,
7399
+ deviceIdOf: (row) => row.deviceId
7400
+ };
7401
+ var SegmentHourLedger = class {
7402
+ ledger;
7403
+ logger;
7404
+ constructor(deps) {
7405
+ this.logger = deps.logger;
7406
+ this.ledger = new DurableLedger({
7407
+ spec: SPEC,
7408
+ store: deps.store,
7409
+ logger: deps.logger
7410
+ });
7411
+ }
7412
+ static declare(store) {
7413
+ return DurableLedger.declare(store, SPEC);
7414
+ }
7415
+ declare() {
7416
+ return this.ledger.declare();
7417
+ }
7418
+ async load() {
7419
+ const rows = await this.ledger.load();
7420
+ this.logger.info("recorder: segment-hour ledger loaded", { meta: {
7421
+ hours: rows.length,
7422
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION
7423
+ } });
7424
+ return rows;
7425
+ }
7426
+ snapshot() {
7427
+ return this.ledger.snapshot();
7428
+ }
7429
+ /**
7430
+ * Seed {@link RecordingIndex} from the mirrored hour rows.
7431
+ *
7432
+ * Hours present in the ledger become `hydrated` so a seek in those windows
7433
+ * does not pay for a disk walk. Hours the ledger never held stay `unknown`
7434
+ * — that is the fail-toward-walk direction.
7435
+ */
7436
+ hydrateIndex(index) {
7437
+ for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
7438
+ }
7439
+ /** Write-behind: a just-finalized segment joins its hour row. */
7440
+ async recordSegment(row) {
7441
+ const { key, hourStartMs: hour } = hourOf(row);
7442
+ const held = this.ledger.get(key);
7443
+ if (held !== void 0 && held.paths.includes(row.path)) return;
7444
+ await this.ledger.put({
7445
+ key,
7446
+ deviceId: row.deviceId,
7447
+ profile: row.profile,
7448
+ locationId: row.locationId,
7449
+ hourStartMs: hour,
7450
+ paths: held === void 0 ? [row.path] : [...held.paths, row.path]
7451
+ });
7452
+ }
7453
+ /**
7454
+ * Eviction confirmed these paths are gone from disk. Drop them; forget the
7455
+ * hour when it holds nothing.
7456
+ */
7457
+ async dropSegments(rows) {
7458
+ const doomed = /* @__PURE__ */ new Map();
7459
+ for (const row of rows) {
7460
+ const { key } = hourOf(row);
7461
+ let set = doomed.get(key);
7462
+ if (!set) {
7463
+ set = /* @__PURE__ */ new Set();
7464
+ doomed.set(key, set);
7465
+ }
7466
+ set.add(row.path);
7467
+ }
7468
+ for (const [key, paths] of doomed) {
7469
+ const held = this.ledger.get(key);
7470
+ if (held === void 0) continue;
7471
+ const next = held.paths.filter((p) => !paths.has(p));
7472
+ if (next.length === 0) {
7473
+ await this.ledger.forget(key);
7474
+ continue;
7475
+ }
7476
+ if (next.length === held.paths.length) continue;
7477
+ await this.ledger.put({
7478
+ ...held,
7479
+ paths: next
7480
+ });
7481
+ }
7482
+ }
7483
+ /**
7484
+ * After a disk walk of `deviceIds`, make the ledger match the index — with
7485
+ * two D148 exceptions:
7486
+ *
7487
+ * - an hour the walk did not see is dropped ONLY if it is in the past;
7488
+ * the current hour may still hold a live writer whose file the readdir
7489
+ * raced;
7490
+ * - the current hour UNIONs ledger paths with index paths rather than
7491
+ * replacing, for the same race.
7492
+ *
7493
+ * Devices not in `deviceIds` are left untouched (a partial walk must not
7494
+ * prune cameras it did not look at).
7495
+ */
7496
+ async reconcileFromIndex(index, deviceIds, nowMs) {
7497
+ const walked = new Set(deviceIds);
7498
+ const currentHour = hourStartMs(nowMs);
7499
+ const fromIndex = /* @__PURE__ */ new Map();
7500
+ for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
7501
+ const { key, hourStartMs: hour } = hourOf(seg);
7502
+ const existing = fromIndex.get(key);
7503
+ if (existing === void 0) fromIndex.set(key, {
7504
+ key,
7505
+ deviceId: seg.deviceId,
7506
+ profile: seg.profile,
7507
+ locationId: seg.locationId,
7508
+ hourStartMs: hour,
7509
+ paths: [seg.path]
7510
+ });
7511
+ else fromIndex.set(key, {
7512
+ ...existing,
7513
+ paths: [...existing.paths, seg.path]
7514
+ });
7515
+ }
7516
+ for (const row of this.ledger.snapshot()) {
7517
+ if (!walked.has(row.deviceId)) continue;
7518
+ const disk = fromIndex.get(row.key);
7519
+ if (disk === void 0) {
7520
+ if (row.hourStartMs === currentHour) continue;
7521
+ await this.ledger.forget(row.key);
7522
+ continue;
7523
+ }
7524
+ if (row.hourStartMs === currentHour) {
7525
+ const union = uniquePaths([...disk.paths, ...row.paths]);
7526
+ if (!samePaths(union, row.paths)) await this.ledger.put({
7527
+ ...row,
7528
+ paths: union
7529
+ });
7530
+ continue;
7531
+ }
7532
+ if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
7533
+ }
7534
+ for (const [key, disk] of fromIndex) {
7535
+ if (this.ledger.has(key)) continue;
7536
+ await this.ledger.put(disk);
7537
+ }
7538
+ }
7539
+ };
7540
+ //#endregion
6917
7541
  //#region src/recorder/addon/recording-export-provider.ts
6918
7542
  /** Content type of every rendered export — the engine only ever writes MP4. */
6919
7543
  var EXPORT_CONTENT_TYPE = "video/mp4";
@@ -6941,6 +7565,12 @@ function exportFileName(rec) {
6941
7565
  async function hasFootage(index, deviceId, profile, fromMs, toMs) {
6942
7566
  return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
6943
7567
  }
7568
+ /** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
7569
+ function resolveExportProfiles(input) {
7570
+ if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
7571
+ if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
7572
+ return [];
7573
+ }
6944
7574
  /** Load a record or throw a clear NOT_FOUND-style error. */
6945
7575
  async function requireRecord(state, exportId) {
6946
7576
  const rec = await getExportRecord(state, exportId);
@@ -6950,30 +7580,37 @@ async function requireRecord(state, exportId) {
6950
7580
  /** Build the `recordingExport` provider over the injected deps. */
6951
7581
  function buildRecordingExportProvider(deps) {
6952
7582
  return {
6953
- createExport: async ({ deviceId, profile, fromMs, toMs, options }) => {
7583
+ createExport: async (input) => {
7584
+ const { deviceId, fromMs, toMs, options } = input;
7585
+ const profiles = resolveExportProfiles(input);
7586
+ if (profiles.length < 1) throw new Error("recording-export: profiles must contain at least one profile");
6954
7587
  if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
6955
- if (!await hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recording-export: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
7588
+ for (const profile of profiles) if (!await hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recording-export: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
6956
7589
  const now = deps.now();
6957
- const record = {
6958
- id: deps.newId(),
6959
- deviceId,
6960
- profile,
6961
- fromMs,
6962
- toMs,
6963
- options,
6964
- state: "queued",
6965
- progressPct: null,
6966
- fileBytes: null,
6967
- expiresAt: now + options.maxLifeMs,
6968
- deleteAfterDownload: options.deleteAfterDownload,
6969
- downloadedAt: null,
6970
- createdAt: now,
6971
- createdBy: deps.createdBy(),
6972
- error: null
6973
- };
6974
- const saved = await upsertExport(deps.state, record);
6975
- deps.engine.enqueue(saved.id);
6976
- return saved;
7590
+ let first = null;
7591
+ for (const profile of profiles) {
7592
+ const record = {
7593
+ id: deps.newId(),
7594
+ deviceId,
7595
+ profile,
7596
+ fromMs,
7597
+ toMs,
7598
+ options,
7599
+ state: "queued",
7600
+ progressPct: null,
7601
+ fileBytes: null,
7602
+ expiresAt: now + options.maxLifeMs,
7603
+ deleteAfterDownload: options.deleteAfterDownload,
7604
+ downloadedAt: null,
7605
+ createdAt: now,
7606
+ createdBy: deps.createdBy(),
7607
+ error: null
7608
+ };
7609
+ const saved = await upsertExport(deps.state, record);
7610
+ deps.engine.enqueue(saved.id);
7611
+ first ??= saved;
7612
+ }
7613
+ return first;
6977
7614
  },
6978
7615
  listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
6979
7616
  getExport: ({ exportId }) => requireRecord(deps.state, exportId),
@@ -7722,6 +8359,12 @@ var RecorderV2Addon = class extends BaseAddon {
7722
8359
  isRecordingNode = true;
7723
8360
  index = new RecordingIndex();
7724
8361
  /**
8362
+ * Durable hour-directory copy of {@link index}. Null on an inert
8363
+ * (non-recording) node. Boot reseeds the RAM index from this; the
8364
+ * deferred archive walk reconciles it against disk.
8365
+ */
8366
+ segmentHours = null;
8367
+ /**
7725
8368
  * The calendar rungs — which days, which hours, and a day's exact ranges.
7726
8369
  *
7727
8370
  * Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
@@ -7793,6 +8436,16 @@ var RecorderV2Addon = class extends BaseAddon {
7793
8436
  } });
7794
8437
  return { providers: [] };
7795
8438
  }
8439
+ this.segmentHours = new SegmentHourLedger({
8440
+ store: this.ctx.api.settingsStore,
8441
+ logger: this.ctx.logger
8442
+ });
8443
+ try {
8444
+ await this.segmentHours.declare();
8445
+ } catch (err) {
8446
+ this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: errMsg(err) } });
8447
+ this.segmentHours = null;
8448
+ }
7796
8449
  const ingestOwner = await this.resolveIngestOwner();
7797
8450
  const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
7798
8451
  if (this.nodeId !== ingestOwner.ownerNodeId) {
@@ -7829,7 +8482,11 @@ var RecorderV2Addon = class extends BaseAddon {
7829
8482
  onEvicted: (deviceId, atMs) => {
7830
8483
  this.calendar.invalidate(deviceId, atMs);
7831
8484
  },
8485
+ onDeindexed: (rows) => {
8486
+ this.segmentHours?.dropSegments(rows);
8487
+ },
7832
8488
  onIndexed: (row, absPath) => {
8489
+ this.segmentHours?.recordSegment(row);
7833
8490
  if (!isMfraTableServable(row.startMs, Date.now())) return;
7834
8491
  readMfraTable(absPath, row.bytes).then((table) => {
7835
8492
  this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
@@ -8206,6 +8863,12 @@ var RecorderV2Addon = class extends BaseAddon {
8206
8863
  this.controller?.onTrigger(deviceId, "motion", atMs);
8207
8864
  }
8208
8865
  });
8866
+ try {
8867
+ await this.segmentHours?.load();
8868
+ this.segmentHours?.hydrateIndex(this.index);
8869
+ } catch (err) {
8870
+ this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: errMsg(err) } });
8871
+ }
8209
8872
  let configs;
8210
8873
  try {
8211
8874
  configs = await readDeviceConfigs(this.configStore());
@@ -8693,6 +9356,11 @@ var RecorderV2Addon = class extends BaseAddon {
8693
9356
  locationCount: this.resolvedLocations.length,
8694
9357
  ms: Date.now() - hydrateStartedMs
8695
9358
  } });
9359
+ try {
9360
+ await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
9361
+ } catch (err) {
9362
+ this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: errMsg(err) } });
9363
+ }
8696
9364
  const loggedDomains = /* @__PURE__ */ new Set();
8697
9365
  for (const loc of this.resolvedLocations) {
8698
9366
  const domain = this.locationDomain(loc.id);