@camstack/addon-pipeline 1.2.107 → 1.2.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{addon-utils-A2S9D7pu.mjs → addon-utils-CgBCF-mE.mjs} +50 -14
  2. package/dist/{addon-utils-Vv2dCqA2.js → addon-utils-DJSN5h8c.js} +51 -15
  3. package/dist/audio-analyzer/index.js +3 -3
  4. package/dist/audio-analyzer/index.mjs +3 -3
  5. package/dist/detection-pipeline/index.js +170 -31
  6. package/dist/detection-pipeline/index.mjs +169 -30
  7. package/dist/{dist-CkXAzTZ2.mjs → dist-B763E61Y.mjs} +152 -26
  8. package/dist/{dist-L-1LMGQs.js → dist-BkO76jAp.js} +152 -26
  9. package/dist/{event-loop-stall-monitor-DwAho7HD.js → event-loop-stall-monitor-28f4R0JN.js} +234 -1
  10. package/dist/{event-loop-stall-monitor-fHkfgmlw.mjs → event-loop-stall-monitor-CYEvvp2Z.mjs} +223 -2
  11. package/dist/{lazy-sharp-DfbyQxET.js → lazy-sharp-BaPl5mRa.js} +1 -1
  12. package/dist/motion-wasm/index.js +2 -2
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/pipeline-runner/index.js +915 -107
  15. package/dist/pipeline-runner/index.mjs +914 -106
  16. package/dist/{process-memory-CgrbLo3l.js → process-memory-BCQJFZZg.js} +1 -1
  17. package/dist/{process-memory-DlSOzuHP.mjs → process-memory-CK3FnKyZ.mjs} +1 -1
  18. package/dist/recorder/index.js +446 -77
  19. package/dist/recorder/index.mjs +446 -77
  20. package/dist/session-decode/decode-worker-child.js +62 -34
  21. package/dist/session-decode/decode-worker-child.mjs +61 -33
  22. package/dist/stream-broker/_stub.js +2 -2
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BeL4_jgH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CxgvfkxP.mjs} +3 -3
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CL6Lgzdj.mjs +26 -0
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CmHtm5CX.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BAMyzCWw.mjs} +1 -1
  26. package/dist/stream-broker/{hostInit-BK9nzKAG.mjs → hostInit-CTkpxp3w.mjs} +3 -3
  27. package/dist/stream-broker/index.js +279 -198
  28. package/dist/stream-broker/index.mjs +279 -198
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/dist/{worker-protocol-bEipQT9l.mjs → worker-protocol-D_2QaXYr.mjs} +6 -3
  31. package/dist/{worker-protocol-GGnzfWu3.js → worker-protocol-owR7WMRP.js} +6 -3
  32. package/package.json +1 -1
  33. package/python/inference_pool.py +4 -1
  34. package/python/test_inference_pool_preprocess.py +87 -16
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DjndFc5V.mjs +0 -26
@@ -1,7 +1,7 @@
1
- const require_dist = require("../dist-L-1LMGQs.js");
1
+ const require_dist = require("../dist-BkO76jAp.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
3
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
- const require_addon_utils = require("../addon-utils-Vv2dCqA2.js");
4
+ const require_addon_utils = require("../addon-utils-DJSN5h8c.js");
5
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let node_child_process = require("node:child_process");
@@ -757,6 +757,23 @@ var RecordingIndex = class {
757
757
  segments(deviceId, profile) {
758
758
  return [...this.sortedSegments(deviceId, profile)];
759
759
  }
760
+ /**
761
+ * Start-sorted rows whose `startMs` is in `[fromMs, toMs)`.
762
+ * Slices the live view — does not copy the rest of the archive.
763
+ */
764
+ segmentsStartingIn(deviceId, profile, fromMs, toMs) {
765
+ const segs = this.sortedSegments(deviceId, profile);
766
+ let lo = 0;
767
+ let hi = segs.length;
768
+ while (lo < hi) {
769
+ const mid = lo + hi >> 1;
770
+ if (segs[mid].startMs < fromMs) lo = mid + 1;
771
+ else hi = mid;
772
+ }
773
+ let end = lo;
774
+ while (end < segs.length && segs[end].startMs < toMs) end += 1;
775
+ return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
776
+ }
760
777
  /** True when `relPath` is currently indexed for `deviceId`. O(1). */
761
778
  hasSegment(deviceId, relPath) {
762
779
  return this.byDevice.get(deviceId)?.has(relPath) === true;
@@ -2114,6 +2131,29 @@ function utcDayOf(epochMs) {
2114
2131
  */
2115
2132
  var RANGE_MERGE_GAP_MS$1 = 5e3;
2116
2133
  /**
2134
+ * Prefer the RAM index for one hour of the timeline.
2135
+ *
2136
+ * A hydrated hour is closed: the walk (or a targeted `hydrateWindow`) already
2137
+ * looked, so an empty array is a real gap, not "unknown". Write-through
2138
+ * `addSegment` rows ride along once that hour is walked — they do NOT by
2139
+ * themselves make the hour known. A recorder that just respawned at 11:04
2140
+ * has only the post-restart finalizes in RAM; treating those as the whole
2141
+ * hour would hide the 11:00–11:04 footage until the next walk (the 2026-08-22
2142
+ * deploy gap). Unknown hours still fall through to one directory read.
2143
+ */
2144
+ function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
2145
+ const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
2146
+ const hourEnd = hour + HOUR_MS$4;
2147
+ if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
2148
+ const rows = [];
2149
+ for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
2150
+ startMs: s.startMs,
2151
+ durMs: s.durMs,
2152
+ bytes: s.bytes
2153
+ });
2154
+ return rows;
2155
+ }
2156
+ /**
2117
2157
  * How long the CURRENT hour's answer may be reused.
2118
2158
  *
2119
2159
  * Not caching it at all was the first instinct — it is still being written, so
@@ -2344,6 +2384,11 @@ var CalendarIndex = class {
2344
2384
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
2345
2385
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
2346
2386
  const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
2387
+ const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
2388
+ if (fromIndex !== null && fromIndex !== void 0) return {
2389
+ segments: fromIndex,
2390
+ complete: true
2391
+ };
2347
2392
  const key = `${deviceId}:${profile}:${hour}`;
2348
2393
  const now = this.deps.now?.() ?? Date.now();
2349
2394
  const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
@@ -2413,6 +2458,32 @@ var CalendarIndex = class {
2413
2458
  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);
2414
2459
  }
2415
2460
  /**
2461
+ * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
2462
+ * overlaps.
2463
+ *
2464
+ * `dayRanges` always walks 24 hours. The theater asks for a lookback of a
2465
+ * couple of hours, then every 30 s poll (and every scrub that extends
2466
+ * lookback) called that full-day walk PLUS `warmAdjacent` (48 more hours)
2467
+ * on the same FUSE share the feeder was reading. 2026-08-22 15:50Z: locate
2468
+ * was 3 ms from RAM, the .m4s read hit `segment read timeout`.
2469
+ */
2470
+ async rangesIn(deviceId, profile, fromMs, toMs) {
2471
+ if (toMs <= fromMs) return [];
2472
+ const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
2473
+ const hours = [];
2474
+ for (let hour = first; hour < toMs; hour += HOUR_MS$4) hours.push(hour);
2475
+ const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
2476
+ const ranges = [];
2477
+ for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
2478
+ if (r.endMs <= fromMs || r.startMs >= toMs) continue;
2479
+ ranges.push({
2480
+ startMs: Math.max(r.startMs, fromMs),
2481
+ endMs: Math.min(r.endMs, toMs)
2482
+ });
2483
+ }
2484
+ return ranges;
2485
+ }
2486
+ /**
2416
2487
  * Warm the days either side of the one being viewed, in the BACKGROUND.
2417
2488
  *
2418
2489
  * Changing day is one click and the operator expects the timeline to be
@@ -2913,11 +2984,23 @@ function concatFileDirective(uri) {
2913
2984
  function buildExportConcatList(segments) {
2914
2985
  return `ffconcat version 1.0\n${segments.map((s) => `${concatFileDirective(s.uri)}\nduration ${sec(s.durMs / 1e3)}`).map((l) => `${l}\n`).join("")}`;
2915
2986
  }
2987
+ /**
2988
+ * Concat list for already-finished MP4s. A `duration` line here would
2989
+ * TRUNCATE each hour to whatever dummy we wrote (the stream clock is
2990
+ * already inside the file).
2991
+ */
2992
+ function buildAssembleConcatList(uris) {
2993
+ return `ffconcat version 1.0\n${uris.map((u) => `${concatFileDirective(u)}\n`).join("")}`;
2994
+ }
2916
2995
  /** Pass 1 of a timelapse: remux the ffconcat list into one continuous file.
2917
2996
  * `-safe 0` because the list's paths are absolute — which is exactly what the
2918
2997
  * per-export source list contains (`export-source-playlist.ts`). */
2919
2998
  function buildConcatPassArgs(input) {
2920
2999
  return [
3000
+ "-fflags",
3001
+ "+discardcorrupt+genpts",
3002
+ "-err_detect",
3003
+ "ignore_err",
2921
3004
  "-f",
2922
3005
  "concat",
2923
3006
  "-safe",
@@ -2932,6 +3015,21 @@ function buildConcatPassArgs(input) {
2932
3015
  input.outPath
2933
3016
  ];
2934
3017
  }
3018
+ /** Final stitch of the surviving per-hour MP4s. Already selected + encoded. */
3019
+ function buildAssembleArgs(input) {
3020
+ return [
3021
+ "-f",
3022
+ "concat",
3023
+ "-safe",
3024
+ "0",
3025
+ "-i",
3026
+ input.inputConcatList,
3027
+ "-c",
3028
+ "copy",
3029
+ ...FASTSTART,
3030
+ input.outPath
3031
+ ];
3032
+ }
2935
3033
  /** Build the ffmpeg argv for one export render. */
2936
3034
  function buildExportArgs(input) {
2937
3035
  const { inputPlaylist, outPath, options } = input;
@@ -2977,6 +3075,65 @@ function buildExportArgs(input) {
2977
3075
  ];
2978
3076
  }
2979
3077
  //#endregion
3078
+ //#region src/recorder/addon/export-timelapse-chunks.ts
3079
+ /** One hour of concatenated footage — not one hour of wall clock. */
3080
+ var TIMELAPSE_CHUNK_MS = 36e5;
3081
+ /**
3082
+ * Split a concat list into hour-sized chunks. A single file longer than the
3083
+ * chunk stays whole — it is already one open, and cutting it would need a
3084
+ * second demux we do not have.
3085
+ */
3086
+ function splitTimelapseChunks(entries, chunkMs = TIMELAPSE_CHUNK_MS) {
3087
+ if (entries.length === 0) return [];
3088
+ const chunks = [];
3089
+ let current = [];
3090
+ let currentDur = 0;
3091
+ let streamStartMs = 0;
3092
+ for (const entry of entries) {
3093
+ if (current.length > 0 && currentDur + entry.durMs > chunkMs) {
3094
+ chunks.push({
3095
+ entries: current,
3096
+ streamStartMs
3097
+ });
3098
+ streamStartMs += currentDur;
3099
+ current = [];
3100
+ currentDur = 0;
3101
+ }
3102
+ current.push(entry);
3103
+ currentDur += entry.durMs;
3104
+ }
3105
+ if (current.length > 0) chunks.push({
3106
+ entries: current,
3107
+ streamStartMs
3108
+ });
3109
+ return chunks;
3110
+ }
3111
+ /**
3112
+ * Move stream-time dense ranges onto a chunk's own `t=0`. Ranges that miss
3113
+ * the chunk disappear; a range that straddles the edge is clipped. Empty →
3114
+ * `undefined` so the select stays the single-rate form.
3115
+ */
3116
+ function denseRangesForChunk(dense, chunkStreamStartMs, chunkDurMs) {
3117
+ if (dense === void 0 || dense.ranges.length === 0) return void 0;
3118
+ const startSec = chunkStreamStartMs / 1e3;
3119
+ const endSec = (chunkStreamStartMs + chunkDurMs) / 1e3;
3120
+ const ranges = [];
3121
+ for (const r of dense.ranges) {
3122
+ const from = Math.max(r.fromSec, startSec);
3123
+ const to = Math.min(r.toSec, endSec);
3124
+ if (to <= from) continue;
3125
+ ranges.push({
3126
+ fromSec: from - startSec,
3127
+ toSec: to - startSec
3128
+ });
3129
+ }
3130
+ if (ranges.length === 0) return void 0;
3131
+ return {
3132
+ everyMs: dense.everyMs,
3133
+ ranges
3134
+ };
3135
+ }
3136
+ //#endregion
2980
3137
  //#region src/recorder/addon/export-store.ts
2981
3138
  /**
2982
3139
  * Recording-export persistence for the recorder addon.
@@ -3240,15 +3397,15 @@ function expectedOutSeconds(rec) {
3240
3397
  var ExportEngine = class {
3241
3398
  deps;
3242
3399
  queue = [];
3243
- activeId = null;
3244
- activeProc = null;
3400
+ /** In-flight jobs → their current ffmpeg (null between the two timelapse passes). */
3401
+ active = /* @__PURE__ */ new Map();
3245
3402
  cancelled = /* @__PURE__ */ new Set();
3246
3403
  constructor(deps) {
3247
3404
  this.deps = deps;
3248
3405
  }
3249
3406
  /** Queue a persisted `queued` record for rendering. */
3250
3407
  enqueue(id) {
3251
- if (this.queue.includes(id) || this.activeId === id) return;
3408
+ if (this.queue.includes(id) || this.active.has(id)) return;
3252
3409
  this.queue.push(id);
3253
3410
  this.pump();
3254
3411
  }
@@ -3257,29 +3414,32 @@ var ExportEngine = class {
3257
3414
  this.cancelled.add(id);
3258
3415
  const idx = this.queue.indexOf(id);
3259
3416
  if (idx >= 0) this.queue.splice(idx, 1);
3260
- if (this.activeId === id && this.activeProc) try {
3261
- this.activeProc.kill("SIGKILL");
3417
+ const proc = this.active.get(id);
3418
+ if (proc) try {
3419
+ proc.kill("SIGKILL");
3262
3420
  } catch {}
3263
3421
  else if (idx >= 0) await this.fail(id, "cancelled");
3264
3422
  }
3265
- async pump() {
3266
- if (this.activeId) return;
3267
- const id = this.queue.shift();
3268
- if (!id) return;
3269
- if (this.cancelled.has(id)) {
3270
- this.cancelled.delete(id);
3271
- await this.fail(id, "cancelled");
3272
- this.pump();
3273
- return;
3423
+ pump() {
3424
+ while (this.active.size < 2 && this.queue.length > 0) {
3425
+ const id = this.queue.shift();
3426
+ if (!id) return;
3427
+ if (this.cancelled.has(id)) {
3428
+ this.cancelled.delete(id);
3429
+ this.fail(id, "cancelled");
3430
+ continue;
3431
+ }
3432
+ this.active.set(id, null);
3433
+ this.runOne(id);
3274
3434
  }
3275
- this.activeId = id;
3435
+ }
3436
+ async runOne(id) {
3276
3437
  try {
3277
3438
  await this.render(id);
3278
3439
  } catch (err) {
3279
3440
  await this.fail(id, err instanceof Error ? err.message : String(err));
3280
3441
  } finally {
3281
- this.activeId = null;
3282
- this.activeProc = null;
3442
+ this.active.delete(id);
3283
3443
  this.pump();
3284
3444
  }
3285
3445
  }
@@ -3297,7 +3457,7 @@ var ExportEngine = class {
3297
3457
  try {
3298
3458
  await this.spawnRender(id, rec, playlist, this.streamTimeOptions(rec, playlist.segments));
3299
3459
  } finally {
3300
- if (this.deps.cleanupPlaylist) for (const file of [playlist.path, playlist.concatPath]) try {
3460
+ if (this.deps.cleanupPlaylist) for (const file of [playlist.path, playlist.concatPath].filter((p) => typeof p === "string" && p.length > 0)) try {
3301
3461
  await this.deps.cleanupPlaylist(file);
3302
3462
  } catch (err) {
3303
3463
  this.deps.logger.warn("export: source playlist cleanup failed", {
@@ -3360,7 +3520,7 @@ var ExportEngine = class {
3360
3520
  }
3361
3521
  });
3362
3522
  if (options.timelapse) {
3363
- await this.spawnTimelapse(id, rec, playlist.concatPath, outPath, options);
3523
+ await this.spawnTimelapse(id, rec, playlist, outPath, options);
3364
3524
  return;
3365
3525
  }
3366
3526
  const args = buildExportArgs({
@@ -3384,8 +3544,118 @@ var ExportEngine = class {
3384
3544
  * timestamp line runs on the SAME clock the dense map summed — see
3385
3545
  * `export-ffmpeg-args.ts` for what the HLS demuxer's content-stitching cost.
3386
3546
  */
3387
- async spawnTimelapse(id, rec, concatList, outPath, options) {
3547
+ async spawnTimelapse(id, rec, playlist, outPath, options) {
3548
+ const chunks = splitTimelapseChunks(playlist.concatEntries ?? []);
3549
+ if (chunks.length <= 1) {
3550
+ const concatList = playlist.concatPath;
3551
+ if (concatList === void 0) {
3552
+ await this.fail(id, "no concat list for timelapse");
3553
+ return;
3554
+ }
3555
+ await this.spawnTimelapsePair(id, rec, concatList, outPath, options, {
3556
+ floor: 0,
3557
+ ceil: 99
3558
+ });
3559
+ return;
3560
+ }
3561
+ if (this.deps.writeTextFile === void 0) {
3562
+ const concatList = playlist.concatPath;
3563
+ if (concatList === void 0) {
3564
+ await this.fail(id, "no concat list for timelapse");
3565
+ return;
3566
+ }
3567
+ await this.spawnTimelapsePair(id, rec, concatList, outPath, options, {
3568
+ floor: 0,
3569
+ ceil: 99
3570
+ });
3571
+ return;
3572
+ }
3573
+ const survivors = [];
3574
+ const sidecars = [];
3575
+ try {
3576
+ for (let i = 0; i < chunks.length; i++) {
3577
+ if (this.cancelled.has(id)) {
3578
+ await this.fail(id, "cancelled");
3579
+ return;
3580
+ }
3581
+ const chunk = chunks[i];
3582
+ const chunkDur = chunk.entries.reduce((s, e) => s + e.durMs, 0);
3583
+ const listPath = `${outPath}.chunk${i}.ffconcat`;
3584
+ const concatPath = `${outPath}.chunk${i}.concat.ts`;
3585
+ const chunkMp4 = `${outPath}.chunk${i}.mp4`;
3586
+ sidecars.push(listPath, concatPath, chunkMp4);
3587
+ await this.deps.writeTextFile(listPath, buildExportConcatList(chunk.entries));
3588
+ const span = 90 / chunks.length;
3589
+ const floor = Math.round(i * span);
3590
+ if (await this.spawnTimelapsePair(id, rec, listPath, chunkMp4, {
3591
+ ...options,
3592
+ timelapse: options.timelapse ? {
3593
+ ...options.timelapse,
3594
+ dense: denseRangesForChunk(options.timelapse.dense, chunk.streamStartMs, chunkDur)
3595
+ } : options.timelapse
3596
+ }, {
3597
+ floor,
3598
+ ceil: floor + Math.round(span * .95),
3599
+ finish: false
3600
+ }) === "ok") survivors.push(chunkMp4);
3601
+ else this.deps.logger.warn("export timelapse hour skipped", {
3602
+ tags: { deviceId: rec.deviceId },
3603
+ meta: {
3604
+ exportId: id,
3605
+ chunk: i,
3606
+ streamStartMs: chunk.streamStartMs
3607
+ }
3608
+ });
3609
+ }
3610
+ if (survivors.length === 0) {
3611
+ await this.fail(id, "every timelapse hour failed");
3612
+ return;
3613
+ }
3614
+ if (survivors.length === 1) {
3615
+ const only = survivors[0];
3616
+ const copyArgs = buildExportArgs({
3617
+ inputPlaylist: only,
3618
+ outPath,
3619
+ options: {
3620
+ includeAudio: false,
3621
+ maxLifeMs: 1,
3622
+ deleteAfterDownload: false,
3623
+ speed: 1
3624
+ }
3625
+ });
3626
+ const copied = await this.runFfmpeg(id, rec.deviceId, copyArgs, {
3627
+ totalSec: expectedOutSeconds(rec),
3628
+ floor: 95,
3629
+ ceil: 99
3630
+ });
3631
+ await this.finish(id, copied.code, outPath, copied.stderrTail);
3632
+ return;
3633
+ }
3634
+ const assembleList = `${outPath}.assemble.ffconcat`;
3635
+ sidecars.push(assembleList);
3636
+ await this.deps.writeTextFile(assembleList, buildAssembleConcatList(survivors));
3637
+ const assembled = await this.runFfmpeg(id, rec.deviceId, buildAssembleArgs({
3638
+ inputConcatList: assembleList,
3639
+ outPath
3640
+ }), {
3641
+ totalSec: expectedOutSeconds(rec),
3642
+ floor: 95,
3643
+ ceil: 99
3644
+ });
3645
+ await this.finish(id, assembled.code, outPath, assembled.stderrTail);
3646
+ } finally {
3647
+ for (const file of sidecars) try {
3648
+ await this.deps.removeFile(file);
3649
+ } catch {}
3650
+ }
3651
+ }
3652
+ /**
3653
+ * Concat remux then select. `finish: false` lets the chunk walker skip a
3654
+ * dead hour without failing the whole night.
3655
+ */
3656
+ async spawnTimelapsePair(id, rec, concatList, outPath, options, progress) {
3388
3657
  const concatPath = concatPassOutPath(outPath);
3658
+ const mid = progress.floor + Math.round((progress.ceil - progress.floor) / 2);
3389
3659
  try {
3390
3660
  const concatArgs = buildConcatPassArgs({
3391
3661
  inputConcatList: concatList,
@@ -3393,12 +3663,12 @@ var ExportEngine = class {
3393
3663
  });
3394
3664
  const concatRan = await this.runFfmpeg(id, rec.deviceId, concatArgs, {
3395
3665
  totalSec: Math.max(.001, (rec.toMs - rec.fromMs) / 1e3),
3396
- floor: 0,
3397
- ceil: 49
3666
+ floor: progress.floor,
3667
+ ceil: mid
3398
3668
  });
3399
3669
  if (this.cancelled.has(id) || concatRan.code !== 0) {
3400
- await this.finish(id, concatRan.code, concatPath, concatRan.stderrTail);
3401
- return;
3670
+ if (progress.finish !== false) await this.finish(id, concatRan.code, concatPath, concatRan.stderrTail);
3671
+ return "fail";
3402
3672
  }
3403
3673
  const selectArgs = buildExportArgs({
3404
3674
  inputPlaylist: concatPath,
@@ -3407,10 +3677,11 @@ var ExportEngine = class {
3407
3677
  });
3408
3678
  const selectRan = await this.runFfmpeg(id, rec.deviceId, selectArgs, {
3409
3679
  totalSec: expectedOutSeconds(rec),
3410
- floor: 50,
3411
- ceil: 99
3680
+ floor: mid,
3681
+ ceil: progress.ceil
3412
3682
  });
3413
- await this.finish(id, selectRan.code, outPath, selectRan.stderrTail);
3683
+ if (progress.finish !== false) await this.finish(id, selectRan.code, outPath, selectRan.stderrTail);
3684
+ return selectRan.code === 0 ? "ok" : "fail";
3414
3685
  } finally {
3415
3686
  try {
3416
3687
  await this.deps.removeFile(concatPath);
@@ -3422,10 +3693,28 @@ var ExportEngine = class {
3422
3693
  exportId: id,
3423
3694
  args
3424
3695
  } });
3696
+ const stallMs = this.deps.stallMs ?? 9e4;
3425
3697
  return new Promise((resolve) => {
3426
3698
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
3427
- this.activeProc = proc;
3699
+ this.active.set(id, proc);
3428
3700
  const stderrTail = [];
3701
+ let stallTimer = null;
3702
+ const clearStall = () => {
3703
+ if (stallTimer !== null) {
3704
+ clearTimeout(stallTimer);
3705
+ stallTimer = null;
3706
+ }
3707
+ };
3708
+ const armStall = () => {
3709
+ clearStall();
3710
+ stallTimer = setTimeout(() => {
3711
+ stderrTail.push(`ffmpeg stalled: no progress for ${stallMs}ms`);
3712
+ try {
3713
+ proc.kill("SIGKILL");
3714
+ } catch {}
3715
+ }, stallMs);
3716
+ };
3717
+ armStall();
3429
3718
  proc.stderr?.on("data", (chunk) => {
3430
3719
  const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
3431
3720
  for (const line of text.split("\n")) {
@@ -3436,6 +3725,7 @@ var ExportEngine = class {
3436
3725
  }
3437
3726
  const m = TIME_RE.exec(line);
3438
3727
  if (m) {
3728
+ armStall();
3439
3729
  const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
3440
3730
  const span = progress.ceil - progress.floor;
3441
3731
  const pct = Math.min(progress.ceil, progress.floor + Math.round(sec / progress.totalSec * span));
@@ -3448,14 +3738,16 @@ var ExportEngine = class {
3448
3738
  }
3449
3739
  });
3450
3740
  proc.on("exit", (code) => {
3451
- this.activeProc = null;
3741
+ clearStall();
3742
+ this.active.set(id, null);
3452
3743
  resolve({
3453
3744
  code: typeof code === "number" ? code : null,
3454
3745
  stderrTail
3455
3746
  });
3456
3747
  });
3457
3748
  proc.on("error", (raw) => {
3458
- this.activeProc = null;
3749
+ clearStall();
3750
+ this.active.set(id, null);
3459
3751
  const failure = readSpawnFailure(raw);
3460
3752
  this.deps.logger.error("export ffmpeg could not be spawned", {
3461
3753
  tags: { deviceId },
@@ -4405,16 +4697,11 @@ function buildRecordingProvider(deps) {
4405
4697
  ranges: []
4406
4698
  };
4407
4699
  const ranges = [];
4408
- const firstDay = Math.floor(fromMs / DAY_MS$1) * DAY_MS$1;
4409
- for (let day = firstDay; day < toMs; day += DAY_MS$1) for (const r of await calendar.dayRanges(deviceId, profile, day)) {
4410
- if (r.endMs <= fromMs || r.startMs >= toMs) continue;
4411
- ranges.push({
4412
- profile,
4413
- startMs: r.startMs,
4414
- endMs: r.endMs
4415
- });
4416
- }
4417
- calendar.warmAdjacent(deviceId, profile, firstDay);
4700
+ for (const r of await calendar.rangesIn(deviceId, profile, fromMs, toMs)) ranges.push({
4701
+ profile,
4702
+ startMs: r.startMs,
4703
+ endMs: r.endMs
4704
+ });
4418
4705
  return {
4419
4706
  deviceId,
4420
4707
  ranges
@@ -4491,6 +4778,13 @@ function buildRecordingProvider(deps) {
4491
4778
  },
4492
4779
  getDeviceConfig: async ({ deviceId }) => loadDeviceConfig(deps.configStore, deviceId),
4493
4780
  locateSegment: async ({ deviceId, profile, epochMs }) => {
4781
+ const covering = deps.index.segmentAt(deviceId, profile, epochMs);
4782
+ if (covering) return {
4783
+ kind: "segment",
4784
+ startMs: covering.startMs,
4785
+ durMs: covering.durMs,
4786
+ bytes: covering.bytes
4787
+ };
4494
4788
  const hydrateFromMs = Math.max(0, epochMs - LOCATE_HYDRATE_MARGIN_MS);
4495
4789
  const hydrateToMs = epochMs + LOCATE_HYDRATE_MARGIN_MS;
4496
4790
  if (deps.index.hydrationOf(deviceId, hydrateFromMs, hydrateToMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, hydrateFromMs, hydrateToMs);
@@ -4738,6 +5032,10 @@ async function writeExportSourcePlaylist(input) {
4738
5032
  segments: segments.map((s) => ({
4739
5033
  startMs: s.startMs,
4740
5034
  durMs: s.durMs
5035
+ })),
5036
+ concatEntries: segments.map((s) => ({
5037
+ uri: s.uri,
5038
+ durMs: s.durMs
4741
5039
  }))
4742
5040
  };
4743
5041
  }
@@ -7997,34 +8295,87 @@ var RecordingController = class {
7997
8295
  }
7998
8296
  /**
7999
8297
  * One profile's ffmpeg writer exhausted its bounded inner restarts and gave
8000
- * up. The device is still in `active` with a dead writer, so `evaluateDevice`
8001
- * would short-circuit at "already recording — converged" and never recover.
8002
- * Detach the WHOLE device (releases every broker lease + stops watchers, so the
8003
- * idle source can re-dial cleanly), then re-evaluate: if the band still demands
8004
- * recording, `evaluateDevice` re-queues + re-attaches a fresh writer set. If
8005
- * the broker is not ready, the re-attach throws and the device stays on the
8006
- * readiness queue (retried on the next broker ready) and the periodic
8007
- * reconcile is the backstop. Bounded OUTER retry (RECONCILE_TICK_MS cadence),
8298
+ * up, or the idle watchdog found it silent.
8299
+ *
8300
+ * Recover THAT profile only when siblings are still producing. Whole-device
8301
+ * detach was the 2026-08-22 gap: high stalled 139 s on 615/590/617/586 and
8302
+ * recovery unpinned low too the profile the timeline and scrub actually
8303
+ * read punching 38–172 s holes in continuous cameras. The broker re-dial
8304
+ * argument still holds when NOTHING on the device is healthy; then we detach
8305
+ * the whole set so the next evaluate starts clean.
8306
+ *
8307
+ * Bounded OUTER retry (RECONCILE_TICK_MS / fillMissingProfiles cadence),
8008
8308
  * never a tight loop.
8009
8309
  */
8010
- async recoverWriterGaveUp(deviceId) {
8310
+ async recoverWriterGaveUp(deviceId, profile) {
8011
8311
  if (this.stopped) return;
8012
- this.deps.logger.warn("recorder: writer gave up — detaching + re-evaluating device", { tags: { deviceId } });
8312
+ const current = this.active.get(deviceId);
8313
+ if (!current || current.length === 0) return;
8314
+ const stalled = profile === void 0 ? current : current.filter((r) => r.profile === profile);
8315
+ if (stalled.length === 0) return;
8316
+ const keep = profile === void 0 ? [] : current.filter((r) => r.profile !== profile);
8317
+ if (keep.length === 0) {
8318
+ this.deps.logger.warn("recorder: writer gave up — detaching + re-evaluating device", {
8319
+ tags: { deviceId },
8320
+ ...profile !== void 0 ? { meta: { profile } } : {}
8321
+ });
8322
+ try {
8323
+ await this.detachDevice(deviceId);
8324
+ await this.evaluateDevice(deviceId);
8325
+ } catch (err) {
8326
+ this.restore?.add(deviceId, true);
8327
+ this.deps.logger.warn("recorder: writer give-up recovery deferred (retries on next broker ready/reconcile)", {
8328
+ tags: { deviceId },
8329
+ meta: { error: require_dist.errMsg(err) }
8330
+ });
8331
+ }
8332
+ return;
8333
+ }
8334
+ this.deps.logger.warn("recorder: writer gave up — recovering stalled profile, healthy profiles keep recording", {
8335
+ tags: { deviceId },
8336
+ meta: {
8337
+ profile: stalled.map((r) => r.profile),
8338
+ keeping: keep.map((r) => r.profile)
8339
+ }
8340
+ });
8013
8341
  try {
8014
- await this.detachDevice(deviceId);
8015
- await this.evaluateDevice(deviceId);
8342
+ await this.recoverStalledProfiles(deviceId, stalled, keep);
8016
8343
  } catch (err) {
8017
8344
  this.restore?.add(deviceId, true);
8018
- this.deps.logger.warn("recorder: writer give-up recovery deferred (retries on next broker ready/reconcile)", {
8345
+ this.deps.logger.warn("recorder: stalled-profile recovery deferred (retries on next broker ready/reconcile)", {
8019
8346
  tags: { deviceId },
8020
- meta: { error: require_dist.errMsg(err) }
8347
+ meta: {
8348
+ error: require_dist.errMsg(err),
8349
+ profile: stalled.map((r) => r.profile)
8350
+ }
8021
8351
  });
8022
8352
  }
8023
8353
  }
8024
8354
  /**
8355
+ * Tear down only the stalled writers, keep the rest pinned, and retry the
8356
+ * missing profiles through {@link fillMissingProfiles}.
8357
+ */
8358
+ async recoverStalledProfiles(deviceId, stalled, keep) {
8359
+ for (const r of stalled) {
8360
+ this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
8361
+ this.attachRetained.delete(idleKey(deviceId, r.profile));
8362
+ this.firstSegmentSeen.delete(idleKey(deviceId, r.profile));
8363
+ }
8364
+ this.active.set(deviceId, [...keep]);
8365
+ const skipped = this.skippedProfiles.get(deviceId) ?? /* @__PURE__ */ new Set();
8366
+ for (const r of stalled) skipped.add(r.profile);
8367
+ this.skippedProfiles.set(deviceId, skipped);
8368
+ await Promise.all(stalled.map((r) => this.teardownProfile(deviceId, r)));
8369
+ const config = await this.deps.loadConfig(deviceId);
8370
+ if (this.stopped || this.paused) return;
8371
+ await this.fillMissingProfiles(deviceId, config);
8372
+ }
8373
+ /**
8025
8374
  * Find writers that hold a broker pin but have finalized nothing for longer
8026
- * than their idle bound, and recover them through the same detach +
8027
- * re-evaluate path a give-up takes.
8375
+ * than their idle bound, and recover them through the same path a give-up
8376
+ * takes. Per-profile when siblings are still producing (high stall must not
8377
+ * unpin the low timeline/scrub writer); whole-device only when every active
8378
+ * profile on that camera is silent.
8028
8379
  *
8029
8380
  * The counterpart to `onGaveUp`: that one fires when ffmpeg EXITS, this one
8030
8381
  * when it does not exit and does not work. Both must exist — a branch that
@@ -8045,7 +8396,6 @@ var RecordingController = class {
8045
8396
  boundMs
8046
8397
  });
8047
8398
  }
8048
- const devices = new Set(stalled.map((s) => s.deviceId));
8049
8399
  for (const s of stalled) this.deps.logger.warn("recorder: writer pinned but no segment finalized — re-pinning", {
8050
8400
  tags: { deviceId: s.deviceId },
8051
8401
  meta: {
@@ -8055,7 +8405,20 @@ var RecordingController = class {
8055
8405
  segmentSeconds: this.active.get(s.deviceId)?.find((r) => r.profile === s.profile)?.segmentSeconds
8056
8406
  }
8057
8407
  });
8058
- await Promise.all([...devices].map((deviceId) => this.recoverWriterGaveUp(deviceId)));
8408
+ const byDevice = /* @__PURE__ */ new Map();
8409
+ for (const s of stalled) {
8410
+ const list = byDevice.get(s.deviceId);
8411
+ if (list) list.push(s.profile);
8412
+ else byDevice.set(s.deviceId, [s.profile]);
8413
+ }
8414
+ await Promise.all([...byDevice.entries()].map(async ([deviceId, profiles]) => {
8415
+ const activeCount = this.active.get(deviceId)?.length ?? 0;
8416
+ if (profiles.length >= activeCount) {
8417
+ await this.recoverWriterGaveUp(deviceId);
8418
+ return;
8419
+ }
8420
+ for (const profile of profiles) await this.recoverWriterGaveUp(deviceId, profile);
8421
+ }));
8059
8422
  }
8060
8423
  /** Current trigger state for a device (cold default = no triggers seen). */
8061
8424
  triggersFor(deviceId) {
@@ -8464,7 +8827,7 @@ var RecordingController = class {
8464
8827
  spawn: this.deps.spawn,
8465
8828
  logger: deviceLog,
8466
8829
  onGaveUp: () => {
8467
- this.recoverWriterGaveUp(deviceId);
8830
+ this.recoverWriterGaveUp(deviceId, profile);
8468
8831
  },
8469
8832
  onResourcePressure: () => this.spawnPressure.report(deviceId)
8470
8833
  });
@@ -10235,10 +10598,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10235
10598
  /**
10236
10599
  * The calendar rungs — which days, which hours, and a day's exact ranges.
10237
10600
  *
10238
- * Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
10239
- * (retention, eviction, footprint) and is hydrated by a walk that takes
10240
- * minutes. The calendar answers what a screen asks and is built from
10241
- * directory names, so the first screen no longer waits on the walk.
10601
+ * Hours the RAM index has already walked (or the live hour it is
10602
+ * write-through on) never touch the recordings volume. Directory reads
10603
+ * remain only for hours still `unknown` that FUSE readdir is what queued
10604
+ * behind scrub's `locateSegment` and expired its 8 s budget (2026-08-22).
10242
10605
  */
10243
10606
  calendarInstance = null;
10244
10607
  /** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
@@ -10246,7 +10609,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10246
10609
  get calendar() {
10247
10610
  this.calendarInstance ??= new CalendarIndex({
10248
10611
  roots: () => this.resolvedLocations.map((l) => l.root),
10249
- logger: this.ctx.logger
10612
+ logger: this.ctx.logger,
10613
+ indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs)
10250
10614
  });
10251
10615
  return this.calendarInstance;
10252
10616
  }
@@ -10625,6 +10989,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10625
10989
  });
10626
10990
  },
10627
10991
  cleanupPlaylist: removeExportFile,
10992
+ writeTextFile: async (p, contents) => {
10993
+ await node_fs.promises.mkdir(node_path.default.dirname(p), { recursive: true });
10994
+ await node_fs.promises.writeFile(p, contents, "utf8");
10995
+ },
10628
10996
  outPath: exportOutPath,
10629
10997
  statBytes: async (p) => (await node_fs.promises.stat(p)).size,
10630
10998
  removeFile: removeExportFile,
@@ -10803,7 +11171,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10803
11171
  await this.segmentHours?.load();
10804
11172
  this.segmentHours?.hydrateIndex(this.index);
10805
11173
  } catch (err) {
10806
- this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: require_dist.errMsg(err) } });
11174
+ this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: require_dist.errMsg(err) } });
10807
11175
  }
10808
11176
  let configs;
10809
11177
  try {
@@ -10813,7 +11181,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10813
11181
  return;
10814
11182
  }
10815
11183
  this.warmToday([...configs.keys()]);
10816
- this.scheduleDeferredFullWalk([...configs.keys()]);
11184
+ this.ctx.logger.info("recorder: full-archive index walk skipped", { meta: {
11185
+ reason: "on-demand-windows",
11186
+ ledgerHours: this.segmentHours?.snapshot().length ?? 0,
11187
+ deviceCount: configs.size
11188
+ } });
10817
11189
  this.cleanupLegacyThumbsDirs();
10818
11190
  await this.bootstrapExports();
10819
11191
  const provider = this.recordingProvider;
@@ -11239,23 +11611,20 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
11239
11611
  */
11240
11612
  async warmToday(deviceIds) {
11241
11613
  const now = Date.now();
11242
- const dayMs = 1440 * 60 * 1e3;
11243
11614
  const started = now;
11615
+ const liveHour = Math.floor(now / HOUR_MS) * HOUR_MS;
11244
11616
  for (const deviceId of deviceIds) try {
11245
- const profile = await this.calendar.availabilityProfileFor(deviceId);
11246
- if (profile === null) continue;
11247
- await this.calendar.warmDayRanges(deviceId, profile, now);
11248
- await this.calendar.warmDayRanges(deviceId, profile, now - dayMs);
11617
+ await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger);
11249
11618
  } catch {}
11250
11619
  this.ctx.logger.info("recorder: today warmed", { meta: {
11251
11620
  devices: deviceIds.length,
11252
- ms: Date.now() - started
11621
+ ms: Date.now() - started,
11622
+ scope: "live-hour"
11253
11623
  } });
11254
11624
  }
11255
11625
  /**
11256
- * Arm the deferred full-archive index walk (see {@link FULL_WALK_DEFER_MS}).
11257
- * Idempotent across `onHubReachable` re-entries: an armed timer is not
11258
- * re-armed, and a walk already running is not duplicated.
11626
+ * Opt-in fleet walk NOT armed at boot (2026-08-22: starved scrub/play).
11627
+ * Kept so an operator path can still request a full reconcile later.
11259
11628
  */
11260
11629
  scheduleDeferredFullWalk(deviceIds) {
11261
11630
  if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;