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