@camstack/addon-pipeline 1.2.70 → 1.2.72

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 (29) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-BPaa4_z6.mjs → dist-CNYUiN27.mjs} +158 -43
  6. package/dist/{dist-cR-pvD_z.js → dist-DwBdGpkx.js} +158 -43
  7. package/dist/{event-loop-stall-monitor-STRdnQWm.js → event-loop-stall-monitor-1o85rEbu.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-B79REtL2.mjs → event-loop-stall-monitor-EvJwrf2f.mjs} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +95 -28
  12. package/dist/pipeline-runner/index.mjs +95 -28
  13. package/dist/recorder/index.js +365 -73
  14. package/dist/recorder/index.mjs +365 -73
  15. package/dist/session-decode/decode-worker-child.js +483 -52
  16. package/dist/session-decode/decode-worker-child.mjs +483 -52
  17. package/dist/stream-broker/_stub.js +660 -556
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B-HGBNad.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BEBWZAKQ.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DkUf57A0.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CExO0piw.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-B12DHOv7.mjs → hostInit-aSIXKJAL.mjs} +3 -3
  22. package/dist/stream-broker/index.js +1 -1
  23. package/dist/stream-broker/index.mjs +1 -1
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-CBO8nwQj.mjs → worker-protocol-C_sGeitR.mjs} +26 -19
  26. package/dist/{worker-protocol-C7V1qIIA.js → worker-protocol-D0H-aG2_.js} +26 -19
  27. package/package.json +1 -1
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1CqbPrT.mjs +0 -26
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-NjvjJWz-.mjs +0 -26
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-cR-pvD_z.js");
2
+ const require_dist = require("../dist-DwBdGpkx.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_addon_utils = require("../addon-utils-CQs-AjTJ.js");
5
5
  let node_crypto = require("node:crypto");
@@ -2416,6 +2416,105 @@ function sendDirectory(res, payload) {
2416
2416
  res.end(Buffer.from(body));
2417
2417
  }
2418
2418
  //#endregion
2419
+ //#region src/recorder/addon/export-dense-map.ts
2420
+ /**
2421
+ * Shortest clip worth keeping, in ms.
2422
+ *
2423
+ * Exists only to honour `ExportDenseRangeSchema`'s `toSec > fromSec`: a range
2424
+ * clipped to a sliver of footage is still honest, but a range clipped to
2425
+ * NOTHING must not be emitted as a zero-length interval the schema refuses.
2426
+ */
2427
+ var MIN_MAPPED_MS = 1;
2428
+ /** Milliseconds of footage that precede `wallMs` in the concatenation. */
2429
+ function recordedMsBefore(segments, wallMs) {
2430
+ let acc = 0;
2431
+ for (const s of segments) if (wallMs >= s.startMs + s.durMs) acc += s.durMs;
2432
+ else if (wallMs > s.startMs) acc += wallMs - s.startMs;
2433
+ return acc;
2434
+ }
2435
+ /** Whole milliseconds → seconds, with no float tail for the filter string. */
2436
+ function msToSec(ms) {
2437
+ return Math.round(ms) / 1e3;
2438
+ }
2439
+ /**
2440
+ * Translate wall-clock dense ranges into the export's stream timeline.
2441
+ *
2442
+ * @returns the surviving ranges (in stream seconds, ordered as given) plus the
2443
+ * report the render logs.
2444
+ */
2445
+ function mapDenseRangesToStreamTime(input) {
2446
+ const segments = [...input.segments].sort((a, b) => a.startMs - b.startMs);
2447
+ const footageEndMs = segments.reduce((end, s) => Math.max(end, s.startMs + s.durMs), input.exportFromMs);
2448
+ const mapped = [];
2449
+ const dropped = [];
2450
+ for (const range of input.ranges) {
2451
+ const fromWallMs = input.exportFromMs + range.fromSec * 1e3;
2452
+ const toWallMs = input.exportFromMs + range.toSec * 1e3;
2453
+ const fromMs = Math.round(recordedMsBefore(segments, fromWallMs));
2454
+ const toMs = Math.round(recordedMsBefore(segments, toWallMs));
2455
+ if (toMs - fromMs < MIN_MAPPED_MS) {
2456
+ dropped.push({
2457
+ fromSec: range.fromSec,
2458
+ toSec: range.toSec,
2459
+ reason: fromWallMs >= footageEndMs ? "past-end" : "gap"
2460
+ });
2461
+ continue;
2462
+ }
2463
+ mapped.push({
2464
+ fromSec: msToSec(fromMs),
2465
+ toSec: msToSec(toMs)
2466
+ });
2467
+ }
2468
+ return {
2469
+ ranges: mapped,
2470
+ report: {
2471
+ requested: input.ranges.length,
2472
+ mapped: mapped.length,
2473
+ dropped
2474
+ }
2475
+ };
2476
+ }
2477
+ /**
2478
+ * One export's options with its dense ranges expressed in stream time.
2479
+ *
2480
+ * An overlay that maps to NOTHING is removed entirely rather than left with an
2481
+ * empty range list: `ExportDenseSchema` refuses `ranges: []` and
2482
+ * `selectPredicate` would emit `if(,…)`, a filter ffmpeg rejects — so a night
2483
+ * whose busy moments all fell in holes would fail the render instead of
2484
+ * delivering the uniform video it legitimately is.
2485
+ */
2486
+ function withStreamTimeDenseRanges(input) {
2487
+ const timelapse = input.options.timelapse;
2488
+ const dense = timelapse?.dense;
2489
+ if (timelapse === void 0 || dense === void 0) return {
2490
+ options: input.options,
2491
+ report: null
2492
+ };
2493
+ const { ranges, report } = mapDenseRangesToStreamTime({
2494
+ ranges: dense.ranges,
2495
+ exportFromMs: input.fromMs,
2496
+ segments: input.segments
2497
+ });
2498
+ const base = {
2499
+ everyMs: timelapse.everyMs,
2500
+ ...timelapse.outputFps !== void 0 ? { outputFps: timelapse.outputFps } : {}
2501
+ };
2502
+ const remapped = ranges.length > 0 ? {
2503
+ ...base,
2504
+ dense: {
2505
+ everyMs: dense.everyMs,
2506
+ ranges: [...ranges]
2507
+ }
2508
+ } : base;
2509
+ return {
2510
+ options: {
2511
+ ...input.options,
2512
+ timelapse: remapped
2513
+ },
2514
+ report
2515
+ };
2516
+ }
2517
+ //#endregion
2419
2518
  //#region src/recorder/addon/export-ffmpeg-args.ts
2420
2519
  /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
2421
2520
  * band audio is dropped rather than chained (kept simple + predictable). */
@@ -2681,35 +2780,76 @@ var ExportEngine = class {
2681
2780
  return;
2682
2781
  }
2683
2782
  try {
2684
- await this.spawnRender(id, rec, playlist);
2783
+ await this.spawnRender(id, rec, playlist.path, this.streamTimeOptions(rec, playlist.segments));
2685
2784
  } finally {
2686
2785
  if (this.deps.cleanupPlaylist) try {
2687
- await this.deps.cleanupPlaylist(playlist);
2786
+ await this.deps.cleanupPlaylist(playlist.path);
2688
2787
  } catch (err) {
2689
- this.deps.logger.warn("export: source playlist cleanup failed", { meta: {
2690
- exportId: id,
2691
- playlist,
2692
- error: err instanceof Error ? err.message : String(err)
2693
- } });
2788
+ this.deps.logger.warn("export: source playlist cleanup failed", {
2789
+ tags: { deviceId: rec.deviceId },
2790
+ meta: {
2791
+ exportId: id,
2792
+ playlist: playlist.path,
2793
+ error: err instanceof Error ? err.message : String(err)
2794
+ }
2795
+ });
2694
2796
  }
2695
2797
  }
2696
2798
  }
2799
+ /**
2800
+ * The record's options with any dense overlay moved onto ffmpeg's clock.
2801
+ *
2802
+ * The translation is reported on EVERY render that carries an overlay, not
2803
+ * only when something is dropped: `denseRangesRequested` vs
2804
+ * `denseRangesMapped` is the one line that can tell "the video is uniform
2805
+ * because the night was quiet" from "the video is uniform because every range
2806
+ * missed" — the distinction that went unnoticed for a full night because the
2807
+ * only line printed was the requested count.
2808
+ */
2809
+ streamTimeOptions(rec, segments) {
2810
+ const { options, report } = withStreamTimeDenseRanges({
2811
+ options: rec.options,
2812
+ fromMs: rec.fromMs,
2813
+ segments
2814
+ });
2815
+ if (report !== null) this.logDenseMapping(rec, report);
2816
+ return options;
2817
+ }
2818
+ /** One line, whatever the outcome — `warn` when ranges were dropped, because
2819
+ * a dropped range is footage the operator asked for and did not get. */
2820
+ logDenseMapping(rec, report) {
2821
+ const entry = {
2822
+ tags: { deviceId: rec.deviceId },
2823
+ meta: {
2824
+ exportId: rec.id,
2825
+ denseRangesRequested: report.requested,
2826
+ denseRangesMapped: report.mapped,
2827
+ ...report.dropped.length > 0 ? { denseRangesDropped: report.dropped } : {}
2828
+ }
2829
+ };
2830
+ const message = "export dense ranges mapped onto the concatenated stream";
2831
+ if (report.dropped.length > 0) this.deps.logger.warn(message, entry);
2832
+ else this.deps.logger.info(message, entry);
2833
+ }
2697
2834
  /** Spawn ffmpeg for one render and resolve when it has exited + finalized. */
2698
- async spawnRender(id, rec, playlist) {
2835
+ async spawnRender(id, rec, playlist, options) {
2699
2836
  const outPath = this.deps.outPath(rec);
2700
2837
  const args = buildExportArgs({
2701
2838
  inputPlaylist: playlist,
2702
2839
  outPath,
2703
- options: rec.options
2840
+ options
2704
2841
  });
2705
2842
  const totalSec = expectedOutSeconds(rec);
2706
- this.deps.logger.info("export render starting", { meta: {
2707
- exportId: id,
2708
- deviceId: rec.deviceId,
2709
- profile: rec.profile,
2710
- playlist,
2711
- outPath
2712
- } });
2843
+ this.deps.logger.info("export render starting", {
2844
+ tags: { deviceId: rec.deviceId },
2845
+ meta: {
2846
+ exportId: id,
2847
+ deviceId: rec.deviceId,
2848
+ profile: rec.profile,
2849
+ playlist,
2850
+ outPath
2851
+ }
2852
+ });
2713
2853
  this.deps.logger.debug("export ffmpeg argv", { meta: {
2714
2854
  exportId: id,
2715
2855
  args
@@ -3892,9 +4032,9 @@ function exportSourcePlaylistPath(exportsDir, deviceId, exportId) {
3892
4032
  return node_path.default.join(exportsDir, String(deviceId), `${exportId}.src.m3u8`);
3893
4033
  }
3894
4034
  /**
3895
- * Write the export's source playlist and return its absolute path, or null when
3896
- * no segment of the requested profile is present on disk for the range (the
3897
- * engine then fails the job with 'no footage for range').
4035
+ * Write the export's source playlist and return it, or null when no segment of
4036
+ * the requested profile is present on disk for the range (the engine then fails
4037
+ * the job with 'no footage for range').
3898
4038
  */
3899
4039
  async function writeExportSourcePlaylist(input) {
3900
4040
  const { segments } = await collectRangeSegments(input.deps, input.deviceId, input.profile, input.fromMs, input.toMs, "absolute");
@@ -3902,26 +4042,16 @@ async function writeExportSourcePlaylist(input) {
3902
4042
  const playlistPath = exportSourcePlaylistPath(input.exportsDir, input.deviceId, input.exportId);
3903
4043
  await node_fs.promises.mkdir(node_path.default.dirname(playlistPath), { recursive: true });
3904
4044
  await node_fs.promises.writeFile(playlistPath, buildVariantPlaylist(segments), "utf8");
3905
- return playlistPath;
4045
+ return {
4046
+ path: playlistPath,
4047
+ segments: segments.map((s) => ({
4048
+ startMs: s.startMs,
4049
+ durMs: s.durMs
4050
+ }))
4051
+ };
3906
4052
  }
3907
4053
  //#endregion
3908
4054
  //#region src/recorder/addon/footage-render.ts
3909
- /**
3910
- * One-shot media render FROM RECORDED FOOTAGE (notification attachments).
3911
- *
3912
- * Shared core behind `renderGif` and `renderClip`: both cut the same window out
3913
- * of the same `low` profile through the export machinery's per-render source
3914
- * playlist (absolute segment URIs — the ONLY playlist shape ffmpeg can consume,
3915
- * see export-source-playlist.ts), pipe it through ONE ffmpeg invocation, and
3916
- * return the bytes. Everything is transient: playlist and output are deleted on
3917
- * every outcome.
3918
- *
3919
- * Fail-closed by construction: no footage covering the window ⇒ it throws, and
3920
- * the caller (a notification rule) simply ships no attachment. A camera that is
3921
- * not recording therefore cannot produce a clip — which is exactly why the
3922
- * frame-ring plane (roadmap Phase 5) still matters for cameras with no
3923
- * recording. It is NOT a prerequisite for the cameras that do record.
3924
- */
3925
4055
  var RENDER_TIMEOUT_MS = 3e4;
3926
4056
  /**
3927
4057
  * A notification clip must sit AROUND the moment it describes. Sliding the
@@ -4019,9 +4149,9 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
4019
4149
  toMs: window.toMs
4020
4150
  });
4021
4151
  if (playlist === null) throw new Error(`no footage covers [${window.fromMs}, ${window.toMs}) for device ${input.deviceId}`);
4022
- const outPath = node_path.default.join(node_path.default.dirname(playlist), `${renderId}.${ext}`);
4152
+ const outPath = node_path.default.join(node_path.default.dirname(playlist.path), `${renderId}.${ext}`);
4023
4153
  try {
4024
- await runFfmpeg(renderDeps, buildArgs(playlist, outPath), ext);
4154
+ await runFfmpeg(renderDeps, buildArgs(playlist.path, outPath), ext);
4025
4155
  const bytes = await node_fs.promises.readFile(outPath);
4026
4156
  if (bytes.byteLength === 0) throw new Error(`${ext} render produced an empty file`);
4027
4157
  return {
@@ -4030,7 +4160,7 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
4030
4160
  toMs: window.toMs
4031
4161
  };
4032
4162
  } finally {
4033
- await node_fs.promises.rm(playlist, { force: true }).catch(() => {});
4163
+ await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
4034
4164
  await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
4035
4165
  }
4036
4166
  }
@@ -5274,6 +5404,49 @@ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
5274
5404
  return false;
5275
5405
  }
5276
5406
  //#endregion
5407
+ //#region src/recorder/addon/periodic-pass.ts
5408
+ function startPeriodicPass(deps) {
5409
+ const now = () => deps.now?.() ?? Date.now();
5410
+ let startedAt = null;
5411
+ let stopped = false;
5412
+ const fire = () => {
5413
+ if (stopped) return;
5414
+ if (startedAt !== null) {
5415
+ deps.logger.warn("recorder: pass skipped — the previous pass is still running", { meta: {
5416
+ pass: deps.name,
5417
+ inFlightMs: now() - startedAt,
5418
+ intervalMs: deps.intervalMs
5419
+ } });
5420
+ return;
5421
+ }
5422
+ const began = now();
5423
+ startedAt = began;
5424
+ deps.run().catch((err) => {
5425
+ deps.logger.warn("recorder: pass failed", { meta: {
5426
+ pass: deps.name,
5427
+ error: err instanceof Error ? err.message : String(err)
5428
+ } });
5429
+ }).finally(() => {
5430
+ const durationMs = now() - began;
5431
+ startedAt = null;
5432
+ if (durationMs > deps.slowAfterMs) deps.logger.warn("recorder: pass exceeded its budget", { meta: {
5433
+ pass: deps.name,
5434
+ durationMs,
5435
+ budgetMs: deps.slowAfterMs
5436
+ } });
5437
+ });
5438
+ };
5439
+ const timer = setInterval(fire, deps.intervalMs);
5440
+ timer.unref?.();
5441
+ return {
5442
+ stop() {
5443
+ stopped = true;
5444
+ clearInterval(timer);
5445
+ },
5446
+ isRunning: () => startedAt !== null
5447
+ };
5448
+ }
5449
+ //#endregion
5277
5450
  //#region src/recorder/addon/readiness-restore.ts
5278
5451
  var ReadinessRestore = class {
5279
5452
  deps;
@@ -5360,10 +5533,59 @@ var ReadinessRestore = class {
5360
5533
  * no subtree to classify.
5361
5534
  */
5362
5535
  var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
5363
- /** How many consecutive ticks to wait for a finalized segment's flat file to
5364
- * land before giving up on it (a genuine phantom). At a 2s watch interval this
5365
- * is ~16s far longer than ffmpeg's slow-first-segment flush. */
5366
- var MAX_PENDING_TICKS = 8;
5536
+ /**
5537
+ * How long a finalized segment's flat file may take to appear before the entry
5538
+ * is given up on.
5539
+ *
5540
+ * This used to be `MAX_PENDING_TICKS = 8` — a count of TICKS, which is not a
5541
+ * measure of time. The tick loop self-excludes while a pass is running and each
5542
+ * pass does real filesystem work, so on 2026-08-10..13 eight ticks measured
5543
+ * anywhere from 8 s to 819 s. A bound has to be denominated in the thing it
5544
+ * bounds.
5545
+ *
5546
+ * 30 s is chosen from the measurement, not from taste. Over four days the
5547
+ * segments that were retried and then DID land waited p50 7.8 s and p90 11.6 s,
5548
+ * and widening the window from 16 s to 120 s moved coverage only from 92.9% to
5549
+ * 93.8%. There is no tail to chase: past this knee a segment is not slow, it is
5550
+ * lost (a writer killed mid-segment), and waiting only delays the skip and the
5551
+ * log line that reports it.
5552
+ */
5553
+ var PENDING_GRACE_MS = 3e4;
5554
+ /**
5555
+ * How far before the writer's own start a segment may be named and still be
5556
+ * treated as belonging to THIS run.
5557
+ *
5558
+ * ffmpeg names segments with whole-second `%s`, so the first segment of a run
5559
+ * carries the second that was already in progress when the process spawned —
5560
+ * up to ~1 s "before" the writer started. Without this tolerance the very first
5561
+ * segment of every recording would be classified as a previous run's leftover
5562
+ * and dropped.
5563
+ */
5564
+ var PREVIOUS_RUN_SKEW_MS = 1e3;
5565
+ /**
5566
+ * Decide whether a missing flat file is a late flush or a previous run's entry.
5567
+ *
5568
+ * ffmpeg's `-segment_list` file is not cleared between runs and a fresh watcher
5569
+ * starts at `processed = 0`, so the first passes after every attach replay the
5570
+ * PREVIOUS run's playlist. Those entries were relocated into the bucket tree
5571
+ * long ago; their absence is the system working. Over 2026-08-10..13 they were
5572
+ * 83% of all retries (median age 1 615 s, max 17.4 h) and produced 68 of the
5573
+ * 122 `never landed` warnings — a warning that reads like data loss for a file
5574
+ * that was correctly filed hours earlier.
5575
+ *
5576
+ * Note what this deliberately does NOT do: it does not skip a previous run's
5577
+ * entry whose file is STILL THERE. That case is real crash recovery — an
5578
+ * unclean stop leaves un-relocated segments in `.rec-tmp` and the playlist is
5579
+ * the only record of their durations — so the caller stats first and only asks
5580
+ * this question once the file is known to be absent.
5581
+ */
5582
+ function classifyMissingEntry(input) {
5583
+ return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS ? "stale-previous-run" : "pending";
5584
+ }
5585
+ /** Whether a pending entry has outlived its grace. Pure — the caller clocks it. */
5586
+ function shouldGiveUpOnPending(input) {
5587
+ return input.waitedMs > input.boundMs;
5588
+ }
5367
5589
  /**
5368
5590
  * Derive `startMs` (epoch milliseconds) from a flat ffmpeg segment path
5369
5591
  * (`<epochSec>.m4s`, possibly absolute). Returns null for any non-epoch path.
@@ -5438,11 +5660,22 @@ async function statSize(p) {
5438
5660
  * Unlike the old recorder, this watcher does NOT relocate or re-stat: the
5439
5661
  * SegmentStore owns the move + the authoritative byte count.
5440
5662
  */
5441
- async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
5663
+ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger, writerStartedMs) {
5442
5664
  const flatAbsPath = node_path.default.isAbsolute(entry.segPath) ? entry.segPath : node_path.default.join(outDir, entry.segPath);
5443
5665
  const startMs = parseEpochStartMs(flatAbsPath);
5444
5666
  if (startMs === null) return "skipped";
5445
5667
  if (await statSize(flatAbsPath) === null) {
5668
+ if (classifyMissingEntry({
5669
+ segmentStartMs: startMs,
5670
+ writerStartedMs
5671
+ }) === "stale-previous-run") {
5672
+ logger.debug("segment entry predates this writer — already relocated, skipping", { meta: {
5673
+ path: flatAbsPath,
5674
+ segmentStartMs: startMs,
5675
+ writerStartedMs
5676
+ } });
5677
+ return "skipped";
5678
+ }
5446
5679
  logger.debug("segment flat file not on disk yet — will retry", { meta: { path: flatAbsPath } });
5447
5680
  return "pending";
5448
5681
  }
@@ -5488,12 +5721,13 @@ function planFinalizations(body, processed, forceFinal = false) {
5488
5721
  */
5489
5722
  function startSegmentWatcher(deps) {
5490
5723
  const playlistPath = node_path.default.join(deps.outDir, "live.m3u8");
5724
+ const now = () => deps.now?.() ?? Date.now();
5491
5725
  let processed = 0;
5492
5726
  let stopped = false;
5493
5727
  let ticking = false;
5494
5728
  let currentTick = null;
5495
5729
  let pendingIndex = -1;
5496
- let pendingTicks = 0;
5730
+ let pendingSinceMs = 0;
5497
5731
  const tick = async (forceFinal = false) => {
5498
5732
  if (stopped || ticking) return;
5499
5733
  ticking = true;
@@ -5507,19 +5741,26 @@ function startSegmentWatcher(deps) {
5507
5741
  const plan = planFinalizations(body, processed, forceFinal);
5508
5742
  let index = plan.from;
5509
5743
  for (const { entry, durMs } of plan.toHandle) {
5510
- if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger) === "pending") {
5744
+ if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
5511
5745
  if (pendingIndex === index) {
5512
- pendingTicks++;
5513
- if (pendingTicks >= MAX_PENDING_TICKS) {
5514
- deps.logger.warn("segment file never landed — skipping", { meta: { seg: entry.segPath } });
5746
+ const waitedMs = now() - pendingSinceMs;
5747
+ if (shouldGiveUpOnPending({
5748
+ waitedMs,
5749
+ boundMs: 3e4
5750
+ })) {
5751
+ deps.logger.warn("segment file never landed — skipping", { meta: {
5752
+ seg: entry.segPath,
5753
+ waitedMs,
5754
+ boundMs: PENDING_GRACE_MS
5755
+ } });
5515
5756
  pendingIndex = -1;
5516
- pendingTicks = 0;
5757
+ pendingSinceMs = 0;
5517
5758
  index++;
5518
5759
  continue;
5519
5760
  }
5520
5761
  } else {
5521
5762
  pendingIndex = index;
5522
- pendingTicks = 1;
5763
+ pendingSinceMs = now();
5523
5764
  }
5524
5765
  break;
5525
5766
  }
@@ -5527,7 +5768,7 @@ function startSegmentWatcher(deps) {
5527
5768
  }
5528
5769
  if (pendingIndex !== index) {
5529
5770
  pendingIndex = -1;
5530
- pendingTicks = 0;
5771
+ pendingSinceMs = 0;
5531
5772
  }
5532
5773
  processed = index;
5533
5774
  } finally {
@@ -5728,9 +5969,16 @@ var SegmentWriter = class {
5728
5969
  * (`shouldRecordContinuousAt`) and ensures the writer set is running iff so.
5729
5970
  * It re-evaluates on three triggers:
5730
5971
  * 1. `setDeviceConfig` (operator changed the bands / enable),
5731
- * 2. a periodic tick (catches band boundaries crossed with no config change),
5972
+ * 2. a periodic pass (catches band boundaries crossed with no config change),
5732
5973
  * 3. a `stream-broker` ready transition (boot restore — via `ReadinessRestore`).
5733
5974
  *
5975
+ * There are TWO periodic passes, on separate timers and never overlapping
5976
+ * themselves (`periodic-pass.ts`): a cheap LIVENESS pass that catches writers
5977
+ * pinned but producing nothing, and an expensive CONVERGENCE pass that
5978
+ * re-evaluates bands and placement. They were one pass until 2026-08-13, when a
5979
+ * convergence sweep wedged on unbounded broker RPCs and took the liveness
5980
+ * watchdog down with it for 9-27 minutes at a time.
5981
+ *
5734
5982
  * EVENTS bands are treated as "not recording continuously" in B2 — B3 adds
5735
5983
  * trigger-gating. The enable intent is persisted in durable-state by the
5736
5984
  * config-store, so `restoreEnabledDevices` on boot re-attaches every device
@@ -5760,6 +6008,34 @@ var TICK_MS = 3e4;
5760
6008
  */
5761
6009
  var RELEASE_RPC_TIMEOUT_MS = 2e3;
5762
6010
  /**
6011
+ * Bound on each broker RPC in the ATTACH chain (`listAllProfileSlots`,
6012
+ * `getStreamWithCodec`).
6013
+ *
6014
+ * These were the only unbounded calls left in the controller.
6015
+ * `CapabilityHandle.call` gates READINESS (10 s) and then returns `fn()`
6016
+ * untouched, so an accepted-but-unanswered request fell through to the 60 s UDS
6017
+ * default in `kernel/transport/socket-channel.ts`. On 2026-08-13 the broker was
6018
+ * degraded (516 UDS timeouts that day) and the arithmetic did the damage: one
6019
+ * device costs `listAllProfileSlots` + one `getStreamWithCodec` per profile, so
6020
+ * a 4-camera fleet on 3 profiles reached ~16 minutes inside a single recovery
6021
+ * pass — during which every device sits DETACHED (recording stopped) and in
6022
+ * `attaching` (so `evaluateDevice` returns early and the liveness watchdog,
6023
+ * which reads `active`, has nothing to report). That is the shape of the 9-27
6024
+ * minute silences and the real recording gaps behind the 80% timelapse
6025
+ * coverage.
6026
+ *
6027
+ * 20 s is generous for a real RTSP dial and far below the UDS default, so a
6028
+ * hung broker now fails the attach fast. Nothing is lost by giving up: the
6029
+ * device stays on the readiness-restore queue and the next pass retries it.
6030
+ */
6031
+ var ATTACH_RPC_TIMEOUT_MS = 2e4;
6032
+ /**
6033
+ * A pass longer than this is reported with its duration. Equal to the pass
6034
+ * cadence: a pass that cannot finish inside its own interval is the condition
6035
+ * that used to go unlogged for half an hour.
6036
+ */
6037
+ var PASS_SLOW_AFTER_MS = TICK_MS;
6038
+ /**
5763
6039
  * Race `promise` against a deadline. Handlers stay attached to the losing
5764
6040
  * promise, so a post-deadline settlement can never surface as a process-level
5765
6041
  * `unhandledRejection`.
@@ -5847,7 +6123,10 @@ var RecordingController = class {
5847
6123
  */
5848
6124
  unrecordableReported = /* @__PURE__ */ new Set();
5849
6125
  restore = null;
5850
- tickTimer = null;
6126
+ /** Cheap, never-delayed: is any pinned writer producing nothing? */
6127
+ livenessPass = null;
6128
+ /** Expensive: re-evaluate bands + placement for every configured device. */
6129
+ convergePass = null;
5851
6130
  stopped = false;
5852
6131
  /** A reversible maintenance lease. Unlike `stopped`, it never changes
5853
6132
  * persisted recording intent and `resume()` restarts normal convergence. */
@@ -5882,18 +6161,28 @@ var RecordingController = class {
5882
6161
  meta: { error: require_dist.errMsg(err) }
5883
6162
  })
5884
6163
  });
5885
- if (this.tickTimer === null) {
5886
- this.tickTimer = setInterval(() => {
5887
- this.tick();
5888
- }, TICK_MS);
5889
- this.tickTimer.unref?.();
6164
+ if (this.convergePass === null) {
6165
+ this.livenessPass = startPeriodicPass({
6166
+ name: "liveness",
6167
+ intervalMs: TICK_MS,
6168
+ slowAfterMs: PASS_SLOW_AFTER_MS,
6169
+ logger: this.deps.logger,
6170
+ now: () => this.now(),
6171
+ run: () => this.checkIdleWriters()
6172
+ });
6173
+ this.convergePass = startPeriodicPass({
6174
+ name: "convergence",
6175
+ intervalMs: TICK_MS,
6176
+ slowAfterMs: PASS_SLOW_AFTER_MS,
6177
+ logger: this.deps.logger,
6178
+ now: () => this.now(),
6179
+ run: () => this.converge()
6180
+ });
5890
6181
  }
5891
6182
  await this.restore.start(ids.map((id) => [id, true]));
5892
6183
  }
5893
- /** Re-evaluate every currently-tracked OR active device on the periodic tick. */
5894
- async tick() {
5895
- if (this.stopped) return;
5896
- await this.checkIdleWriters();
6184
+ /** Re-evaluate every currently-tracked OR active device on the periodic pass. */
6185
+ async converge() {
5897
6186
  if (this.stopped) return;
5898
6187
  const ids = new Set(this.active.keys());
5899
6188
  let persisted = [];
@@ -5909,7 +6198,7 @@ var RecordingController = class {
5909
6198
  for (const id of ids) try {
5910
6199
  await this.evaluateDevice(id);
5911
6200
  } catch (err) {
5912
- this.deps.logger.warn("recorder controller: tick evaluate failed", {
6201
+ this.deps.logger.warn("recorder controller: convergence evaluate failed", {
5913
6202
  tags: { deviceId: id },
5914
6203
  meta: { error: require_dist.errMsg(err) }
5915
6204
  });
@@ -5983,7 +6272,7 @@ var RecordingController = class {
5983
6272
  segmentSeconds: this.active.get(s.deviceId)?.find((r) => r.profile === s.profile)?.segmentSeconds
5984
6273
  }
5985
6274
  });
5986
- for (const deviceId of devices) await this.recoverWriterGaveUp(deviceId);
6275
+ await Promise.all([...devices].map((deviceId) => this.recoverWriterGaveUp(deviceId)));
5987
6276
  }
5988
6277
  /** Current trigger state for a device (cold default = no triggers seen). */
5989
6278
  triggersFor(deviceId) {
@@ -6125,7 +6414,7 @@ var RecordingController = class {
6125
6414
  * assigned sources is ignored (record every assigned source — minimum of 1).
6126
6415
  */
6127
6416
  async resolveProfiles(deviceId, override) {
6128
- const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId))), deviceId).map((slot) => slot.profile);
6417
+ const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile);
6129
6418
  if (!override || override.length === 0) return assigned;
6130
6419
  const selected = assigned.filter((p) => override.includes(p));
6131
6420
  return selected.length > 0 ? selected : assigned;
@@ -6138,14 +6427,14 @@ var RecordingController = class {
6138
6427
  */
6139
6428
  async attachProfile(deviceId, profile, segmentSeconds) {
6140
6429
  const placement = await this.resolvePlacement(deviceId, profile);
6141
- const source = await this.deps.brokerCall(() => this.deps.api.streamBroker.getStreamWithCodec.mutate({
6430
+ const source = await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.getStreamWithCodec.mutate({
6142
6431
  deviceId,
6143
6432
  video: "copy",
6144
6433
  audio: "aac",
6145
6434
  profile,
6146
6435
  ...this.deps.consumerHostname !== void 0 ? { hostname: this.deps.consumerHostname } : {},
6147
6436
  tag: `recorder:${deviceId}/${profile}`
6148
- }, require_dist.nodePin(this.deps.ownerNodeId)));
6437
+ }, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
6149
6438
  const outDir = node_path.default.join(placement.root, STAGING_DIR_NAME, String(deviceId), profile);
6150
6439
  await node_fs.promises.mkdir(outDir, { recursive: true });
6151
6440
  const deviceLog = this.deps.logger.withTags({ deviceId });
@@ -6161,6 +6450,7 @@ var RecordingController = class {
6161
6450
  this.recoverWriterGaveUp(deviceId);
6162
6451
  }
6163
6452
  });
6453
+ const writerStartedMs = this.now();
6164
6454
  writer.start();
6165
6455
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
6166
6456
  return {
@@ -6168,6 +6458,8 @@ var RecordingController = class {
6168
6458
  writer,
6169
6459
  watcher: startSegmentWatcher({
6170
6460
  outDir,
6461
+ writerStartedMs,
6462
+ now: () => this.now(),
6171
6463
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
6172
6464
  onFinalized: async (startMs, durMs, flatAbsPath) => {
6173
6465
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
@@ -6284,10 +6576,10 @@ var RecordingController = class {
6284
6576
  /** Tear EVERYTHING down: timer, restore subscription, every writer/watcher/lease. */
6285
6577
  async stop() {
6286
6578
  this.stopped = true;
6287
- if (this.tickTimer !== null) {
6288
- clearInterval(this.tickTimer);
6289
- this.tickTimer = null;
6290
- }
6579
+ this.livenessPass?.stop();
6580
+ this.livenessPass = null;
6581
+ this.convergePass?.stop();
6582
+ this.convergePass = null;
6291
6583
  this.restore?.stop();
6292
6584
  this.restore = null;
6293
6585
  await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));