@camstack/addon-pipeline 1.2.38 → 1.2.40

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 (90) 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 +145 -175
  4. package/dist/detection-pipeline/index.mjs +120 -150
  5. package/dist/{dist-Du7_SNiR.mjs → dist-C6p8jcji.mjs} +495 -113
  6. package/dist/{dist-BvFLZyhW.js → dist-DcMBfoCg.js} +495 -113
  7. package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-BHqCesa1.mjs} +139 -2
  8. package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-oL9xanvX.js} +150 -1
  9. package/dist/lazy-sharp-B-mb8uWx.mjs +20 -0
  10. package/dist/lazy-sharp-CcYuXtsa.js +25 -0
  11. package/dist/motion-wasm/index.js +3 -3
  12. package/dist/motion-wasm/index.mjs +3 -3
  13. package/dist/pipeline-runner/index.js +3316 -2428
  14. package/dist/pipeline-runner/index.mjs +3315 -2428
  15. package/dist/recorder/index.js +102 -664
  16. package/dist/recorder/index.mjs +102 -658
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Bt1YkfUW.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DJnh97n9.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-BsNhZnfZ.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-Dyj8Vaql.mjs} +2 -2
  22. package/dist/stream-broker/index.js +723 -214
  23. package/dist/stream-broker/index.mjs +723 -214
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-m1RaK_7F.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-C00UIgFo.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-DWYllToj.js} +1 -1
  28. package/embed-dist/assets/index-4fJzjj4H.css +2 -0
  29. package/embed-dist/assets/index-BaUgbxWl.js +112 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +2 -2
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +0 -26
  34. package/embed-dist/assets/index-9Ob2YrIf.js +0 -343
  35. package/embed-dist/assets/index-CAe6Dg-X.css +0 -2
  36. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  37. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  44. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  45. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  48. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  50. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  51. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  52. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  55. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  56. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  57. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  58. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  59. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  60. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  61. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  64. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  66. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  67. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  70. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  71. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  72. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  73. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  76. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  77. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  78. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  79. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  80. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  81. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  82. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  83. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  84. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  85. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  86. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  87. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  88. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -3,13 +3,13 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BvFLZyhW.js");
6
+ const require_dist = require("../dist-DcMBfoCg.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
9
- let node_os = require("node:os");
10
- node_os = require_chunk.__toESM(node_os);
11
9
  let node_child_process = require("node:child_process");
12
10
  let node_url = require("node:url");
11
+ let node_os = require("node:os");
12
+ node_os = require_chunk.__toESM(node_os);
13
13
  let node_fs = require("node:fs");
14
14
  node_fs = require_chunk.__toESM(node_fs);
15
15
  let node_path = require("node:path");
@@ -3035,6 +3035,22 @@ var AdaptiveSession = class AdaptiveSession {
3035
3035
  /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
3036
3036
  rtpBootstrap;
3037
3037
  rtpBootstrapDone = false;
3038
+ /**
3039
+ * Answered-but-silent guard. The answer deliberately returns WITHOUT
3040
+ * awaiting source bring-up (bare trickle answer, background dial); the
3041
+ * failure shape that buys is a connected client staring at a black player
3042
+ * because the source never delivered. `sourceMediaSeen` flips on the FIRST
3043
+ * evidence of source media reaching this session — a source RTP packet
3044
+ * (`forwardSourceRtpVideo`, even before DTLS lets it transmit) or a video
3045
+ * NAL write (`writeVideoNals`, the AnnexB + transcode egress) — and the
3046
+ * watchdog armed at feed start warns ONCE if the budget elapses first.
3047
+ */
3048
+ sourceMediaSeen = false;
3049
+ sourceSilenceTimer = null;
3050
+ /** Budget between "feeding started" (post-answer, post-DTLS) and the first
3051
+ * source media. Chosen above a healthy cold dial's worst case (~2–3 s) and
3052
+ * below the point a viewer gives up. */
3053
+ static SOURCE_SILENT_AFTER_ANSWER_MS = 5e3;
3038
3054
  /** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
3039
3055
  * recovery). While active, live source-RTP packets are enqueued behind
3040
3056
  * the replay tail to preserve packet order. Aborted on close/rebind. */
@@ -3500,8 +3516,10 @@ var AdaptiveSession = class AdaptiveSession {
3500
3516
  * compatible answer. This avoids werift transceiver direction issues.
3501
3517
  */
3502
3518
  async handleOffer(clientOffer, opts) {
3519
+ const negotiateStart = Date.now();
3503
3520
  const { werift, pcOptions } = await this.buildPcOptions();
3504
3521
  this.pc = new werift.RTCPeerConnection(pcOptions);
3522
+ const pcSetupMs = Date.now() - negotiateStart;
3505
3523
  this.wireControlChannel();
3506
3524
  this.pc.iceConnectionStateChange.subscribe((state) => {
3507
3525
  this.logger.info("ICE state", { meta: {
@@ -3534,7 +3552,9 @@ var AdaptiveSession = class AdaptiveSession {
3534
3552
  sdpMLineIndex: c.sdpMLineIndex ?? null
3535
3553
  });
3536
3554
  });
3555
+ const mdnsStart = Date.now();
3537
3556
  const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
3557
+ const mdnsMs = Date.now() - mdnsStart;
3538
3558
  const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
3539
3559
  if (this.debug) this.logger.info("client offer REMOTE ICE candidates", { meta: {
3540
3560
  phase: "session",
@@ -3545,7 +3565,9 @@ var AdaptiveSession = class AdaptiveSession {
3545
3565
  candidates: offerCandidates
3546
3566
  } });
3547
3567
  const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
3568
+ const setRemoteStart = Date.now();
3548
3569
  await this.pc.setRemoteDescription(remoteDesc);
3570
+ const setRemoteMs = Date.now() - setRemoteStart;
3549
3571
  const transceivers = this.pc.getTransceivers();
3550
3572
  for (const t of transceivers) {
3551
3573
  const kind = t.receiver?.track?.kind ?? t.kind;
@@ -3594,7 +3616,9 @@ var AdaptiveSession = class AdaptiveSession {
3594
3616
  const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
3595
3617
  this.audioSender = audioTransceiver.sender;
3596
3618
  }
3619
+ const createAnswerStart = Date.now();
3597
3620
  const answerDesc = await this.pc.createAnswer();
3621
+ const createAnswerMs = Date.now() - createAnswerStart;
3598
3622
  let finalSdp;
3599
3623
  if (opts?.nonTrickle === true) {
3600
3624
  await this.pc.setLocalDescription(answerDesc).catch((e) => {
@@ -3622,9 +3646,15 @@ var AdaptiveSession = class AdaptiveSession {
3622
3646
  this.logger.info("Codec negotiated (client-offer)", { meta: {
3623
3647
  phase: "session",
3624
3648
  sessionId: this.sessionId,
3649
+ deviceId: this.deviceId,
3625
3650
  source: this.sourceCodec,
3626
3651
  negotiated: this.negotiatedCodec,
3627
- needsTranscode: this.needsTranscode
3652
+ needsTranscode: this.needsTranscode,
3653
+ negotiateMs: Date.now() - negotiateStart,
3654
+ pcSetupMs,
3655
+ mdnsMs,
3656
+ setRemoteMs,
3657
+ createAnswerMs
3628
3658
  } });
3629
3659
  if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
3630
3660
  phase: "session",
@@ -3774,6 +3804,10 @@ var AdaptiveSession = class AdaptiveSession {
3774
3804
  clearInterval(this.statsTimer);
3775
3805
  this.statsTimer = null;
3776
3806
  }
3807
+ if (this.sourceSilenceTimer) {
3808
+ clearTimeout(this.sourceSilenceTimer);
3809
+ this.sourceSilenceTimer = null;
3810
+ }
3777
3811
  if (this.feedAbort) {
3778
3812
  this.feedAbort.abort();
3779
3813
  this.feedAbort = null;
@@ -3871,6 +3905,7 @@ var AdaptiveSession = class AdaptiveSession {
3871
3905
  */
3872
3906
  forwardSourceRtpVideo(rtpData) {
3873
3907
  if (this.closed) return;
3908
+ this.sourceMediaSeen = true;
3874
3909
  const codec = this.negotiatedCodec;
3875
3910
  if (codec !== "H265" && codec !== "H264") return;
3876
3911
  if (!this.videoSender || !_werift) return;
@@ -3882,6 +3917,12 @@ var AdaptiveSession = class AdaptiveSession {
3882
3917
  this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
3883
3918
  return;
3884
3919
  }
3920
+ this.logger.warn("instant-start pre-buffer empty — viewer waits for the next source IDR", { meta: {
3921
+ phase: "session",
3922
+ sessionId: this.sessionId,
3923
+ deviceId: this.deviceId,
3924
+ codec
3925
+ } });
3885
3926
  }
3886
3927
  if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
3887
3928
  this.sendSourceRtpPacket(rtpData, codec);
@@ -4074,6 +4115,27 @@ var AdaptiveSession = class AdaptiveSession {
4074
4115
  } });
4075
4116
  this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
4076
4117
  }
4118
+ /**
4119
+ * Arm the answered-but-silent watchdog: if no source media has reached
4120
+ * this session `SOURCE_SILENT_AFTER_ANSWER_MS` after feeding started, one
4121
+ * named WARN fires. Idempotent; disarmed by `close()`. Public so the guard
4122
+ * is testable without standing up werift.
4123
+ */
4124
+ armSourceSilenceWatchdog() {
4125
+ if (this.sourceSilenceTimer || this.closed) return;
4126
+ this.sourceSilenceTimer = setTimeout(() => {
4127
+ this.sourceSilenceTimer = null;
4128
+ if (this.closed || this.sourceMediaSeen) return;
4129
+ this.logger.warn("answered but NO source media reached the session — source dial stalled or camera silent", { meta: {
4130
+ phase: "session",
4131
+ sessionId: this.sessionId,
4132
+ deviceId: this.deviceId,
4133
+ budgetMs: AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS,
4134
+ needsTranscode: this.needsTranscode
4135
+ } });
4136
+ }, AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS);
4137
+ this.sourceSilenceTimer.unref?.();
4138
+ }
4077
4139
  startFeedingFrames() {
4078
4140
  this.feedAbort = new AbortController();
4079
4141
  const { signal } = this.feedAbort;
@@ -4083,6 +4145,7 @@ var AdaptiveSession = class AdaptiveSession {
4083
4145
  dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
4084
4146
  needsTranscode: this.needsTranscode
4085
4147
  } });
4148
+ this.armSourceSilenceWatchdog();
4086
4149
  if (this.needsTranscode) this.startTranscodeFeed(signal);
4087
4150
  else this.startDirectFeed(signal);
4088
4151
  }
@@ -4658,6 +4721,7 @@ var AdaptiveSession = class AdaptiveSession {
4658
4721
  });
4659
4722
  }
4660
4723
  writeVideoNals(nals, rtpTs, codec) {
4724
+ this.sourceMediaSeen = true;
4661
4725
  if (!this.videoSender || !_werift) {
4662
4726
  if (this.rtpLogCounter === 0) {
4663
4727
  this.logger.warn("writeVideoNals: no sender", { meta: {
@@ -6994,14 +7058,16 @@ var RENDER_TIMEOUT_MS = 2e4;
6994
7058
  function clipFfmpegArgs(input) {
6995
7059
  const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
6996
7060
  const demuxer = input.hevc === true ? "hevc" : "h264";
7061
+ const speed = input.speed ?? 2;
7062
+ const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
6997
7063
  if (input.format === "gif") return [
6998
7064
  "-y",
6999
7065
  "-f",
7000
7066
  demuxer,
7001
7067
  "-i",
7002
7068
  "pipe:0",
7003
- "-vf",
7004
- `fps=${input.fps},${scale}`,
7069
+ "-filter_complex",
7070
+ `[0:v]fps=${input.fps}${setpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
7005
7071
  "-loop",
7006
7072
  "0",
7007
7073
  "-f",
@@ -7015,7 +7081,7 @@ function clipFfmpegArgs(input) {
7015
7081
  "-i",
7016
7082
  "pipe:0",
7017
7083
  "-vf",
7018
- scale,
7084
+ `${scale}${setpts}`,
7019
7085
  "-c:v",
7020
7086
  "libx264",
7021
7087
  "-preset",
@@ -9735,6 +9801,21 @@ var DecoderSessionProxy = class {
9735
9801
  this.api = api;
9736
9802
  this.sessionId = sessionId;
9737
9803
  }
9804
+ /**
9805
+ * Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
9806
+ * regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
9807
+ * `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
9808
+ * is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
9809
+ * peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
9810
+ * stub, an old decoder that predates `waitMs`, a test fake — would otherwise
9811
+ * turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
9812
+ * that pins the CPU, starves every timer in the process, and allocates until
9813
+ * the heap dies (observed: a 4 GB OOM in under 15 s).
9814
+ */
9815
+ async paceEmptyDrain(drainStartedAt) {
9816
+ const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
9817
+ if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
9818
+ }
9738
9819
  async pushPacket(packet) {
9739
9820
  await this.api.pushPacket({
9740
9821
  sessionId: this.sessionId,
@@ -9750,12 +9831,14 @@ var DecoderSessionProxy = class {
9750
9831
  async startPolling(onFrame) {
9751
9832
  this.polling = true;
9752
9833
  while (this.polling) {
9834
+ const drainStartedAt = Date.now();
9753
9835
  const frames = await this.api.pullFrames({
9754
9836
  sessionId: this.sessionId,
9755
9837
  maxCount: 4,
9756
9838
  waitMs: POLL_WAIT_MS
9757
9839
  });
9758
9840
  for (const frame of frames) onFrame(frame);
9841
+ if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9759
9842
  }
9760
9843
  }
9761
9844
  /**
@@ -9773,12 +9856,14 @@ var DecoderSessionProxy = class {
9773
9856
  async startHandlePolling(onHandle) {
9774
9857
  this.polling = true;
9775
9858
  while (this.polling) try {
9859
+ const drainStartedAt = Date.now();
9776
9860
  const handles = await this.api.pullHandles({
9777
9861
  sessionId: this.sessionId,
9778
9862
  maxCount: 4,
9779
9863
  waitMs: POLL_WAIT_MS
9780
9864
  });
9781
9865
  for (const handle of handles) onHandle(handle);
9866
+ if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9782
9867
  } catch (err) {
9783
9868
  this.polling = false;
9784
9869
  const msg = err instanceof Error ? err.message : String(err);
@@ -15726,6 +15811,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15726
15811
  format: input.format,
15727
15812
  maxWidth: input.maxWidth,
15728
15813
  fps: input.fps,
15814
+ ...input.speed !== void 0 ? { speed: input.speed } : {},
15729
15815
  spawn: (cmd, args) => (0, node_child_process.spawn)(cmd, [...args], { stdio: [
15730
15816
  "pipe",
15731
15817
  "pipe",
@@ -17784,6 +17870,71 @@ function withSourceResolution(consumerDefault, probedSource) {
17784
17870
  };
17785
17871
  }
17786
17872
  //#endregion
17873
+ //#region src/stream-broker/webrtc/broker-linger-registry.ts
17874
+ /** Matches the observed reopen gaps (1–2 min) with headroom; well under the
17875
+ * "operator walked away" scale where warmth would be waste. */
17876
+ var DEFAULT_LINGER_MS = 3 * 6e4;
17877
+ var BrokerLingerRegistry = class {
17878
+ lingerMs;
17879
+ subscribe;
17880
+ holds = /* @__PURE__ */ new Map();
17881
+ constructor(options) {
17882
+ this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
17883
+ this.subscribe = options.subscribe;
17884
+ }
17885
+ /**
17886
+ * Start (or restart) the linger window for a broker that just lost its
17887
+ * last session. Re-holding an already-held broker re-arms the timer
17888
+ * without stacking a second ghost subscription.
17889
+ */
17890
+ hold(brokerId) {
17891
+ const existing = this.holds.get(brokerId);
17892
+ if (existing) {
17893
+ clearTimeout(existing.timer);
17894
+ existing.timer = this.armTimer(brokerId);
17895
+ return;
17896
+ }
17897
+ const unsubscribe = this.subscribe(brokerId);
17898
+ if (unsubscribe === null) return;
17899
+ this.holds.set(brokerId, {
17900
+ unsubscribe,
17901
+ timer: this.armTimer(brokerId)
17902
+ });
17903
+ }
17904
+ /** Release the hold NOW — a new session attached, the real subscription
17905
+ * carries the demand from here. No-op when nothing is held. */
17906
+ cancel(brokerId) {
17907
+ const hold = this.holds.get(brokerId);
17908
+ if (!hold) return;
17909
+ clearTimeout(hold.timer);
17910
+ this.holds.delete(brokerId);
17911
+ this.release(hold);
17912
+ }
17913
+ /** Release every hold (server shutdown). */
17914
+ stop() {
17915
+ for (const [brokerId, hold] of this.holds) {
17916
+ clearTimeout(hold.timer);
17917
+ this.holds.delete(brokerId);
17918
+ this.release(hold);
17919
+ }
17920
+ }
17921
+ armTimer(brokerId) {
17922
+ const timer = setTimeout(() => {
17923
+ const hold = this.holds.get(brokerId);
17924
+ if (!hold) return;
17925
+ this.holds.delete(brokerId);
17926
+ this.release(hold);
17927
+ }, this.lingerMs);
17928
+ timer.unref?.();
17929
+ return timer;
17930
+ }
17931
+ release(hold) {
17932
+ try {
17933
+ hold.unsubscribe();
17934
+ } catch {}
17935
+ }
17936
+ };
17937
+ //#endregion
17787
17938
  //#region src/stream-broker/webrtc/loss-tracker.ts
17788
17939
  /** Delta between two RTCP extended (32-bit) highest-sequence values, with
17789
17940
  * 32-bit wraparound handling. */
@@ -17832,6 +17983,24 @@ var LossTracker = class {
17832
17983
  if (now < this.resetUntil) return false;
17833
17984
  return this.lossRatio() > this.config.minFailPercent;
17834
17985
  }
17986
+ /**
17987
+ * True when the window holds enough recent receiver feedback for a
17988
+ * "healthy" verdict to MEAN anything.
17989
+ *
17990
+ * `lossRatio()` returns 0 with fewer than two in-window samples, so
17991
+ * `isFailing` is false for a session that has produced NO feedback at all —
17992
+ * a link so bad that RTCP never arrives reads identically to a perfect one.
17993
+ * A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
17994
+ * UPGRADE must not, because it spends bandwidth the session has never been
17995
+ * shown to have. Silence is not health.
17996
+ */
17997
+ hasEvidence(now) {
17998
+ if (now < this.resetUntil) return false;
17999
+ const cutoff = now - this.config.analyzeWindowMs;
18000
+ let inWindow = 0;
18001
+ for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
18002
+ return inWindow >= 2;
18003
+ }
17835
18004
  };
17836
18005
  //#endregion
17837
18006
  //#region src/stream-broker/webrtc/adaptive-controller.ts
@@ -17840,8 +18009,17 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
17840
18009
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
17841
18010
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
17842
18011
  * Downgrade order: tier high→mid→low, then MTU reduction at low, then
17843
- * transcode-down. Upgrade one tier after sustained health + hysteresis.
17844
- * Side effects run through injected callbacks (testable without werift).
18012
+ * transcode-down. Side effects run through injected callbacks (testable
18013
+ * without werift).
18014
+ *
18015
+ * An UPGRADE requires all three of:
18016
+ * 1. the session clock — measured from the FIRST tick, not from the unix
18017
+ * epoch — showing `upgradeHealthyMs` without a failing verdict;
18018
+ * 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
18019
+ * a session that has produced no RTCP at all is not "healthy", it is
18020
+ * silent, and climbing on silence spends bandwidth never shown to exist;
18021
+ * 3. the target tier's re-attempt backoff, which doubles per measured
18022
+ * failure of that tier in this session.
17845
18023
  */
17846
18024
  var AdaptiveController = class {
17847
18025
  opts;
@@ -17851,27 +18029,86 @@ var AdaptiveController = class {
17851
18029
  transcodeEngaged = false;
17852
18030
  lastChangeAt = 0;
17853
18031
  healthySince = 0;
18032
+ /**
18033
+ * Whether the session clock has been armed by the first evaluation. The
18034
+ * clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
18035
+ * would read as "healthy since 1970" and hand out a free upgrade on the
18036
+ * very first tick, before a single loss sample had been seen. A boolean
18037
+ * (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
18038
+ */
18039
+ armed = false;
18040
+ failures;
17854
18041
  constructor(opts) {
17855
18042
  this.opts = opts;
17856
18043
  this.tracker = new LossTracker(opts.config);
17857
18044
  this.tier = opts.initialTier;
18045
+ this.failures = new Map(opts.priorFailures ?? []);
17858
18046
  }
17859
18047
  get currentTier() {
17860
18048
  return this.tier;
17861
18049
  }
18050
+ /**
18051
+ * Per-tier measured-failure counts, INCLUDING those inherited from earlier
18052
+ * rungs of the same viewer session. The server snapshots this into the
18053
+ * adaptive intent so the session's next controller inherits it.
18054
+ */
18055
+ get failureCounts() {
18056
+ return this.failures;
18057
+ }
17862
18058
  onLoss(sample, now) {
17863
18059
  this.tracker.track(sample, now);
17864
18060
  }
18061
+ /**
18062
+ * Freeze the ladder for this evaluation instead of ticking it.
18063
+ *
18064
+ * Called while the session's track is NOT carrying the live source — D42
18065
+ * put recorded playback and scrub on the same track with a moving source
18066
+ * pointer, and in those modes the live feed is gated, so receiver feedback
18067
+ * measures the recorded feeder, not the camera tier. Acting on it is
18068
+ * incoherent in both directions: a downgrade cannot shrink a file, an
18069
+ * upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
18070
+ * is a close-and-recreate re-offer) — dropping the viewer back to live at
18071
+ * the wrong moment with nothing to restore the pointer.
18072
+ *
18073
+ * Holds the climb clock so time spent in recorded modes can never
18074
+ * accumulate into an upgrade on the return to live.
18075
+ */
18076
+ suspend(now) {
18077
+ if (!this.armed) {
18078
+ this.armed = true;
18079
+ this.lastChangeAt = now;
18080
+ }
18081
+ this.healthySince = now;
18082
+ }
17865
18083
  tick(now) {
18084
+ if (!this.armed) {
18085
+ this.armed = true;
18086
+ this.healthySince = now;
18087
+ this.lastChangeAt = now;
18088
+ }
17866
18089
  if (this.tracker.isFailing(now)) {
17867
18090
  this.healthySince = now;
17868
18091
  this.handleFailing(now);
17869
18092
  return;
17870
18093
  }
18094
+ if (!this.tracker.hasEvidence(now)) {
18095
+ this.healthySince = now;
18096
+ return;
18097
+ }
17871
18098
  this.handleHealthy(now);
17872
18099
  }
17873
18100
  canUpgrade(now) {
17874
- return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs || this.lastChangeAt === 0;
18101
+ return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
18102
+ }
18103
+ /**
18104
+ * How long the link must read healthy before `tier` may be re-attempted.
18105
+ * Doubles per measured failure of that tier in this session, capped at
18106
+ * `maxUpgradeBackoffMs`.
18107
+ */
18108
+ requiredHealthyMs(tier) {
18109
+ const failures = this.failures.get(tier) ?? 0;
18110
+ if (failures <= 0) return this.opts.config.upgradeHealthyMs;
18111
+ return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
17875
18112
  }
17876
18113
  commitChange(now) {
17877
18114
  this.lastChangeAt = now;
@@ -17880,6 +18117,8 @@ var AdaptiveController = class {
17880
18117
  handleFailing(now) {
17881
18118
  const idx = TIER_ORDER.indexOf(this.tier);
17882
18119
  if (idx < TIER_ORDER.length - 1) {
18120
+ const failed = this.tier;
18121
+ this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
17883
18122
  this.tier = TIER_ORDER[idx + 1];
17884
18123
  this.opts.onSwitchTier(this.tier);
17885
18124
  this.commitChange(now);
@@ -17898,17 +18137,17 @@ var AdaptiveController = class {
17898
18137
  }
17899
18138
  }
17900
18139
  handleHealthy(now) {
17901
- if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
17902
- if (!this.canUpgrade(now)) return;
17903
18140
  const idx = TIER_ORDER.indexOf(this.tier);
17904
- if (idx > 0) {
17905
- this.tier = TIER_ORDER[idx - 1];
17906
- this.mtuReduced = false;
17907
- this.transcodeEngaged = false;
17908
- this.opts.onSwitchTier(this.tier);
17909
- this.commitChange(now);
17910
- this.healthySince = now;
17911
- }
18141
+ if (idx <= 0) return;
18142
+ const next = TIER_ORDER[idx - 1];
18143
+ if (now - this.healthySince < this.requiredHealthyMs(next)) return;
18144
+ if (!this.canUpgrade(now)) return;
18145
+ this.tier = next;
18146
+ this.mtuReduced = false;
18147
+ this.transcodeEngaged = false;
18148
+ this.opts.onSwitchTier(next);
18149
+ this.commitChange(now);
18150
+ this.healthySince = now;
17912
18151
  }
17913
18152
  };
17914
18153
  //#endregion
@@ -17933,6 +18172,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
17933
18172
  t: require_dist.literal("scrubTo"),
17934
18173
  epoch: require_dist.number()
17935
18174
  }),
18175
+ require_dist.object({ t: require_dist.literal("scrubAck") }),
17936
18176
  require_dist.object({
17937
18177
  t: require_dist.literal("setRate"),
17938
18178
  rate: require_dist.number().min(0).max(4)
@@ -18106,6 +18346,74 @@ var SCRUB_CACHE_MAX = 8;
18106
18346
  * sustained direction, so a jittery/reversing drag never wastes the slot.
18107
18347
  */
18108
18348
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
18349
+ /**
18350
+ * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
18351
+ * that SWEEPS predicted frames instead of jumping. A steady forward drag is
18352
+ * playback with a moving target: once a keyframe decoded, the predicted frames
18353
+ * after it decode in sequence at a fraction of an IDR's bytes — that is the
18354
+ * continuous flow. Beyond this window (a fling), or backwards, a jump to an
18355
+ * independently-decodable keyframe is cheaper than the burst; and because a
18356
+ * thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
18357
+ * congestion, so degradation to jump-only is emergent, and visible per push
18358
+ * (`mode` on every scrub-feeder:pushed line).
18359
+ */
18360
+ var SCRUB_SWEEP_WINDOW_MS = 4e3;
18361
+ /**
18362
+ * Receiver-credit cap for scrub pushes: at most this many keyframes may be
18363
+ * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
18364
+ * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
18365
+ * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
18366
+ * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
18367
+ * Backpressure is the receiver's: a push spends a credit, a rendered frame
18368
+ * restores one, and with zero credit the newest target PARKS on
18369
+ * `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
18370
+ */
18371
+ var SCRUB_CREDIT_CAP = 2;
18372
+ /**
18373
+ * How far a `gap` locate may be SNAPPED onto real footage.
18374
+ *
18375
+ * A snap is a CORRECTION — the playhead landed a moment outside coverage and
18376
+ * the operator plainly means the footage beside it — not a SEARCH for wherever
18377
+ * footage happens to exist. The distinction is not cosmetic, because the index
18378
+ * that answers `locate` cannot tell "there is no footage here" apart from "I
18379
+ * have not walked this far back yet": it is rebuilt by walking the archive on
18380
+ * every recorder start, and that walk takes minutes.
18381
+ *
18382
+ * Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
18383
+ * `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
18384
+ * minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
18385
+ * tick into a segment read against the addon that was busy re-walking, each one
18386
+ * blew the 8 s budget, and both surfaces died. Bounded, all of those answer
18387
+ * `gap` for free — exactly as they did before snapping existed.
18388
+ *
18389
+ * 120 s covers the real cases by a wide margin: an inter-segment crack is
18390
+ * 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
18391
+ * that the server is answering a question the viewer did not ask; the viewer
18392
+ * holds the coverage ranges and can offer "jump to nearest footage" as an
18393
+ * action instead.
18394
+ */
18395
+ var SNAP_MAX_DISTANCE_MS = 12e4;
18396
+ /**
18397
+ * Decide where a `gap` locate should snap: forward to the next footage edge,
18398
+ * else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
18399
+ * also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
18400
+ * {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
18401
+ * cursor — and so rejecting costs NO segment I/O at all.
18402
+ */
18403
+ function resolveSnapTarget(loc, requestedMs) {
18404
+ const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
18405
+ if (snapMs === null) return { kind: "none" };
18406
+ const distanceMs = Math.abs(snapMs - requestedMs);
18407
+ if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
18408
+ kind: "too-far",
18409
+ snapMs,
18410
+ distanceMs
18411
+ };
18412
+ return {
18413
+ kind: "snap",
18414
+ snapMs
18415
+ };
18416
+ }
18109
18417
  var RecordedFeeder = class {
18110
18418
  deps;
18111
18419
  clock = new MonotonicClock();
@@ -18148,6 +18456,16 @@ var RecordedFeeder = class {
18148
18456
  */
18149
18457
  scrubbing = false;
18150
18458
  scrubToken = 0;
18459
+ /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
18460
+ scrubCredit = SCRUB_CREDIT_CAP;
18461
+ /**
18462
+ * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
18463
+ * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
18464
+ * `scrubAck` settles one frame off the head, and the credit that push spent
18465
+ * comes back only when the head reaches zero — so a sweep cannot be refunded
18466
+ * once per frame for the single credit it cost.
18467
+ */
18468
+ scrubOutstanding = [];
18151
18469
  /** The newest un-processed drag target (null when the tick cycle has drained). */
18152
18470
  scrubPendingEpochMs = null;
18153
18471
  /**
@@ -18178,6 +18496,15 @@ var RecordedFeeder = class {
18178
18496
  * sign of `(newTarget − this)` is the drag direction that steers prefetch.
18179
18497
  */
18180
18498
  scrubPrevTarget = null;
18499
+ /** The last AU actually pushed this drag — the sweep-continuity cursor
18500
+ * (which fragment, and how deep into its AU list). Null until a jump lands. */
18501
+ scrubCursor = null;
18502
+ /** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
18503
+ scrubJumps = 0;
18504
+ scrubSweeps = 0;
18505
+ scrubAusPushed = 0;
18506
+ /** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
18507
+ scrubNoFootageLogged = false;
18181
18508
  /**
18182
18509
  * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
18183
18510
  * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
@@ -18215,9 +18542,19 @@ var RecordedFeeder = class {
18215
18542
  async seek(epochMs) {
18216
18543
  const token = ++this.seekToken;
18217
18544
  if (this.scrubbing) {
18545
+ this.deps.onLog?.("scrub-feeder:drag-end", {
18546
+ jumps: this.scrubJumps,
18547
+ sweeps: this.scrubSweeps,
18548
+ aus: this.scrubAusPushed
18549
+ });
18218
18550
  this.scrubbing = false;
18219
18551
  this.scrubToken++;
18220
18552
  this.scrubSegment = null;
18553
+ this.scrubCursor = null;
18554
+ this.scrubJumps = 0;
18555
+ this.scrubSweeps = 0;
18556
+ this.scrubAusPushed = 0;
18557
+ this.scrubOutstanding = [];
18221
18558
  this.scrubCache.clear();
18222
18559
  this.scrubPrevTarget = null;
18223
18560
  this.scrubDirRun = 0;
@@ -18228,29 +18565,67 @@ var RecordedFeeder = class {
18228
18565
  }
18229
18566
  this.advanceToken++;
18230
18567
  this.inFlightSeekEpochMs = epochMs;
18568
+ let snapInfo = null;
18231
18569
  try {
18232
18570
  this.deps.onState("loading");
18233
18571
  if (this.clockHasAdvanced) {
18234
18572
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18235
18573
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18236
18574
  }
18237
- const loc = await this.withTimeout(this.deps.locate({
18575
+ let loc = await this.withTimeout(this.deps.locate({
18238
18576
  deviceId: this.deps.deviceId,
18239
18577
  profile: this.deps.profile,
18240
18578
  epochMs
18241
18579
  }));
18242
18580
  if (this.disposed || token !== this.seekToken) return;
18581
+ let landMs = epochMs;
18243
18582
  if (loc.kind === "gap") {
18244
- this.currentSegment = null;
18245
- this.lastFedVideoIdx = -1;
18246
- this.stopPositionReports();
18247
- this.deps.onState("gap");
18248
- return;
18583
+ const decision = resolveSnapTarget(loc, epochMs);
18584
+ if (decision.kind !== "snap") {
18585
+ if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
18586
+ requestedMs: epochMs,
18587
+ nearestMs: decision.snapMs,
18588
+ distanceMs: decision.distanceMs
18589
+ });
18590
+ else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
18591
+ this.currentSegment = null;
18592
+ this.lastFedVideoIdx = -1;
18593
+ this.stopPositionReports();
18594
+ this.deps.onState("gap");
18595
+ return;
18596
+ }
18597
+ const snapped = await this.withTimeout(this.deps.locate({
18598
+ deviceId: this.deps.deviceId,
18599
+ profile: this.deps.profile,
18600
+ epochMs: decision.snapMs
18601
+ }));
18602
+ if (this.disposed || token !== this.seekToken) return;
18603
+ if (snapped.kind !== "segment") {
18604
+ this.deps.onLog?.("recorded:seek-no-footage", {
18605
+ requestedMs: epochMs,
18606
+ snappedMs: decision.snapMs
18607
+ });
18608
+ this.currentSegment = null;
18609
+ this.lastFedVideoIdx = -1;
18610
+ this.stopPositionReports();
18611
+ this.deps.onState("gap");
18612
+ return;
18613
+ }
18614
+ this.deps.onLog?.("recorded:seek-snapped", {
18615
+ requestedMs: epochMs,
18616
+ snappedMs: decision.snapMs
18617
+ });
18618
+ loc = snapped;
18619
+ landMs = decision.snapMs;
18620
+ snapInfo = {
18621
+ requestedMs: epochMs,
18622
+ snappedMs: decision.snapMs
18623
+ };
18249
18624
  }
18250
18625
  this.cursorMs = loc.startMs;
18251
18626
  const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
18252
18627
  if (this.disposed || token !== this.seekToken || loaded === null) return;
18253
- this.feedPrefix(loc, loaded.video, epochMs);
18628
+ this.feedPrefix(loc, loaded.video, landMs);
18254
18629
  if (this.rate === 0) {
18255
18630
  this.deps.onState("paused");
18256
18631
  this.deps.onPosition(this.cursorMs);
@@ -18270,9 +18645,20 @@ var RecordedFeeder = class {
18270
18645
  },
18271
18646
  resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
18272
18647
  });
18273
- } catch {
18648
+ } catch (err) {
18274
18649
  if (!this.disposed && token === this.seekToken) {
18275
18650
  this.stopPositionReports();
18651
+ if (snapInfo !== null) {
18652
+ this.deps.onLog?.("recorded:seek-snap-load-failed", {
18653
+ requestedMs: snapInfo.requestedMs,
18654
+ snappedMs: snapInfo.snappedMs,
18655
+ error: require_dist.errMsg(err)
18656
+ });
18657
+ this.currentSegment = null;
18658
+ this.lastFedVideoIdx = -1;
18659
+ this.deps.onState("gap");
18660
+ return;
18661
+ }
18276
18662
  this.deps.onState("error");
18277
18663
  }
18278
18664
  } finally {
@@ -18327,8 +18713,15 @@ var RecordedFeeder = class {
18327
18713
  this.scrubToken++;
18328
18714
  this.scrubPendingEpochMs = null;
18329
18715
  this.scrubSegment = null;
18716
+ this.scrubCredit = SCRUB_CREDIT_CAP;
18330
18717
  this.scrubPrevTarget = null;
18331
18718
  this.scrubDirRun = 0;
18719
+ this.scrubCursor = null;
18720
+ this.scrubJumps = 0;
18721
+ this.scrubSweeps = 0;
18722
+ this.scrubAusPushed = 0;
18723
+ this.scrubNoFootageLogged = false;
18724
+ this.scrubOutstanding = [];
18332
18725
  if (this.scrubTimer) {
18333
18726
  clearTimeout(this.scrubTimer);
18334
18727
  this.scrubTimer = null;
@@ -18348,6 +18741,24 @@ var RecordedFeeder = class {
18348
18741
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18349
18742
  }
18350
18743
  /**
18744
+ * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
18745
+ * credit (capped — over-acking never banks a burst) and, when a target sat
18746
+ * parked on an exhausted budget, resumes the tick. This — not any timer — is
18747
+ * what paces scrub pushes to what the link actually delivers.
18748
+ */
18749
+ scrubAck() {
18750
+ if (this.disposed) return;
18751
+ const head = this.scrubOutstanding[0];
18752
+ if (head === void 0) return;
18753
+ if (head > 1) {
18754
+ this.scrubOutstanding[0] = head - 1;
18755
+ return;
18756
+ }
18757
+ this.scrubOutstanding.shift();
18758
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
18759
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18760
+ }
18761
+ /**
18351
18762
  * One coalesced feeder-scrub push. Captures the newest pending target + the
18352
18763
  * current mode generation, resolves the covering fragment (reusing the memo'd
18353
18764
  * one when the target stays inside it), picks the nearest keyframe ≤ the
@@ -18358,25 +18769,68 @@ var RecordedFeeder = class {
18358
18769
  */
18359
18770
  async scrubTick() {
18360
18771
  const gen = this.scrubToken;
18772
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
18773
+ this.scrubTimer = null;
18774
+ return;
18775
+ }
18776
+ if (this.scrubCredit <= 0) {
18777
+ this.scrubTimer = null;
18778
+ this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
18779
+ return;
18780
+ }
18361
18781
  const target = this.scrubPendingEpochMs;
18362
18782
  this.scrubPendingEpochMs = null;
18363
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
18783
+ if (target === null) {
18364
18784
  this.scrubTimer = null;
18365
18785
  return;
18366
18786
  }
18367
18787
  try {
18368
- const loc = await this.withTimeout(this.deps.locate({
18788
+ let loc = await this.withTimeout(this.deps.locate({
18369
18789
  deviceId: this.deps.deviceId,
18370
18790
  profile: this.deps.profile,
18371
18791
  epochMs: target
18372
18792
  }));
18373
18793
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18374
- if (loc.kind === "gap") return;
18794
+ let effTarget = target;
18795
+ if (loc.kind === "gap") {
18796
+ const decision = resolveSnapTarget(loc, target);
18797
+ if (decision.kind !== "snap") {
18798
+ if (!this.scrubNoFootageLogged) {
18799
+ this.scrubNoFootageLogged = true;
18800
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
18801
+ epochMs: target,
18802
+ nearestMs: decision.snapMs,
18803
+ distanceMs: decision.distanceMs
18804
+ });
18805
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18806
+ }
18807
+ return;
18808
+ }
18809
+ const snapped = await this.withTimeout(this.deps.locate({
18810
+ deviceId: this.deps.deviceId,
18811
+ profile: this.deps.profile,
18812
+ epochMs: decision.snapMs
18813
+ }));
18814
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18815
+ if (snapped.kind !== "segment") {
18816
+ if (!this.scrubNoFootageLogged) {
18817
+ this.scrubNoFootageLogged = true;
18818
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18819
+ }
18820
+ return;
18821
+ }
18822
+ this.deps.onLog?.("scrub-feeder:snapped", {
18823
+ requestedMs: target,
18824
+ snappedMs: decision.snapMs
18825
+ });
18826
+ loc = snapped;
18827
+ effTarget = decision.snapMs;
18828
+ }
18375
18829
  let seg = this.scrubSegment;
18376
18830
  if (!seg || seg.startMs !== loc.startMs) {
18377
18831
  seg = this.scrubCacheGet(loc.startMs);
18378
18832
  if (!seg) {
18379
- const loaded = await this.loadAus(loc.startMs, false, true);
18833
+ const loaded = await this.loadAus(loc.startMs, false);
18380
18834
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18381
18835
  seg = {
18382
18836
  startMs: loc.startMs,
@@ -18387,26 +18841,80 @@ var RecordedFeeder = class {
18387
18841
  }
18388
18842
  this.scrubSegment = seg;
18389
18843
  }
18390
- const au = seg.aus[this.pickScrubKeyframe(seg, target)];
18391
- if (!au) return;
18392
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18393
- this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18844
+ const prevTarget = this.scrubPrevTarget;
18845
+ const cursor = this.scrubCursor;
18846
+ const targetIdx = this.pickScrubIndex(seg, effTarget, false);
18847
+ const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
18848
+ const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
18849
+ const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
18850
+ const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
18851
+ let aus;
18852
+ let firstIdx;
18853
+ let mode;
18854
+ let crossesFragment = false;
18855
+ if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
18856
+ mode = "sweep";
18857
+ firstIdx = cursor.idx + 1;
18858
+ aus = seg.aus.slice(firstIdx, targetIdx + 1);
18859
+ } else if (withinWindow && adjacentForward) {
18860
+ mode = "sweep";
18861
+ firstIdx = 0;
18862
+ aus = seg.aus.slice(0, targetIdx + 1);
18863
+ crossesFragment = true;
18864
+ } else {
18865
+ mode = "jump";
18866
+ firstIdx = this.pickScrubIndex(seg, effTarget, true);
18867
+ const kf = seg.aus[firstIdx];
18868
+ aus = kf === void 0 ? [] : [kf];
18869
+ }
18870
+ const lastAu = aus[aus.length - 1];
18871
+ if (lastAu === void 0) {
18872
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18873
+ this.scrubPrevTarget = effTarget;
18874
+ return;
18875
+ }
18876
+ if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18877
+ else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
18878
+ for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18879
+ this.scrubOutstanding.push(aus.length);
18880
+ this.scrubCredit -= 1;
18394
18881
  this.clockHasAdvanced = true;
18395
- this.deps.onPosition(this.clampIntoSegment(seg, target));
18396
- this.maybeScrubPrefetch(seg, target, gen);
18397
- } catch {} finally {
18398
- if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
18882
+ if (mode === "jump") this.scrubJumps += 1;
18883
+ else this.scrubSweeps += 1;
18884
+ this.scrubAusPushed += aus.length;
18885
+ this.scrubCursor = {
18886
+ fragStartMs: seg.startMs,
18887
+ fragDurMs: seg.durMs,
18888
+ idx: firstIdx + aus.length - 1
18889
+ };
18890
+ this.deps.onLog?.("scrub-feeder:pushed", {
18891
+ epochMs: effTarget,
18892
+ mode,
18893
+ aus: aus.length,
18894
+ codec: lastAu.codec,
18895
+ bytes: lastAu.data.length,
18896
+ creditLeft: this.scrubCredit
18897
+ });
18898
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18899
+ this.maybeScrubPrefetch(seg, effTarget, gen);
18900
+ } catch (err) {
18901
+ this.deps.onLog?.("scrub-feeder:tick-failed", {
18902
+ epochMs: target,
18903
+ error: err instanceof Error ? err.message : String(err)
18904
+ });
18905
+ } finally {
18906
+ if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubCredit > 0) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
18399
18907
  else this.scrubTimer = null;
18400
18908
  }
18401
18909
  }
18402
18910
  /**
18403
- * Index of the nearest keyframe at-or-before the drag target within `seg`
18911
+ * Index of the nearest AU at-or-before the drag target within `seg`
18404
18912
  * (default 0 — the fragment's params-fused IDR, which the demux contract
18405
- * guarantees). Scans the access units whose PTS offset is ≤ the target offset
18406
- * and returns the LAST keyframe among them, so the pushed frame is standalone-
18407
- * decodable yet as close to the target as the GOP structure allows.
18913
+ * guarantees). `keyframesOnly` restricts the pick to sync frames the JUMP
18914
+ * mode, whose pushed frame must be standalone-decodable; sweep mode picks
18915
+ * ANY frame, which is what makes the flow frame-granular.
18408
18916
  */
18409
- pickScrubKeyframe(seg, target) {
18917
+ pickScrubIndex(seg, target, keyframesOnly) {
18410
18918
  const first = seg.aus[0];
18411
18919
  if (!first) return 0;
18412
18920
  const offsetMs = target - seg.startMs;
@@ -18415,7 +18923,7 @@ var RecordedFeeder = class {
18415
18923
  const au = seg.aus[i];
18416
18924
  if (!au) continue;
18417
18925
  if (au.ptsMs - first.ptsMs > offsetMs) break;
18418
- if (au.keyframe) idx = i;
18926
+ if (!keyframesOnly || au.keyframe) idx = i;
18419
18927
  }
18420
18928
  return idx;
18421
18929
  }
@@ -18486,7 +18994,7 @@ var RecordedFeeder = class {
18486
18994
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18487
18995
  if (loc.kind !== "segment") return;
18488
18996
  if (this.scrubCache.has(loc.startMs)) return;
18489
- const loaded = await this.loadAus(loc.startMs, false, true);
18997
+ const loaded = await this.loadAus(loc.startMs, false);
18490
18998
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18491
18999
  this.scrubCachePut({
18492
19000
  startMs: loc.startMs,
@@ -18836,10 +19344,8 @@ var RecordedFeeder = class {
18836
19344
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
18837
19345
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
18838
19346
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
18839
- * (paused scrubs would throw it away). `useScrubDemux: true` selects the
18840
- * keyframe-only scrub demux (`deps.demuxScrub`, falling back to `deps.demux`
18841
- * when absent — flag-off byte-identity). */
18842
- async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
19347
+ * (paused scrubs would throw it away). */
19348
+ async loadAus(startMs, wantAudio = true) {
18843
19349
  const token = this.seekToken;
18844
19350
  const bytes = await this.withTimeout(this.deps.readBytes({
18845
19351
  deviceId: this.deps.deviceId,
@@ -18847,8 +19353,7 @@ var RecordedFeeder = class {
18847
19353
  startMs
18848
19354
  }));
18849
19355
  if (this.disposed || token !== this.seekToken) return null;
18850
- const demux = useScrubDemux && this.deps.demuxScrub ? this.deps.demuxScrub : this.deps.demux;
18851
- const video = await this.withTimeout(demux(bytes));
19356
+ const video = await this.withTimeout(this.deps.demux(bytes));
18852
19357
  if (this.disposed || token !== this.seekToken) return null;
18853
19358
  let audio = [];
18854
19359
  if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
@@ -18894,6 +19399,11 @@ var RecordedFeeder = class {
18894
19399
  }
18895
19400
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
18896
19401
  dispose() {
19402
+ if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
19403
+ jumps: this.scrubJumps,
19404
+ sweeps: this.scrubSweeps,
19405
+ aus: this.scrubAusPushed
19406
+ });
18897
19407
  this.disposed = true;
18898
19408
  this.seekToken++;
18899
19409
  this.advanceToken++;
@@ -18903,6 +19413,12 @@ var RecordedFeeder = class {
18903
19413
  this.scrubCache.clear();
18904
19414
  this.scrubPrevTarget = null;
18905
19415
  this.scrubDirRun = 0;
19416
+ this.scrubCursor = null;
19417
+ this.scrubJumps = 0;
19418
+ this.scrubSweeps = 0;
19419
+ this.scrubAusPushed = 0;
19420
+ this.scrubNoFootageLogged = false;
19421
+ this.scrubOutstanding = [];
18906
19422
  if (this.scrubTimer) {
18907
19423
  clearTimeout(this.scrubTimer);
18908
19424
  this.scrubTimer = null;
@@ -18930,6 +19446,16 @@ var TimelineSession = class {
18930
19446
  this.deps = deps;
18931
19447
  deps.session.setLiveFeedGate(false);
18932
19448
  }
19449
+ /**
19450
+ * Which source the track is currently carrying. Read by the broker's
19451
+ * adaptive ladder, which must NOT act while the pointer is off live: a tier
19452
+ * switch is a close-and-recreate re-offer, and that disposes this
19453
+ * coordinator — the viewer silently lands back on live at the wrong moment
19454
+ * with no code path to restore the pointer.
19455
+ */
19456
+ get currentMode() {
19457
+ return this.mode;
19458
+ }
18933
19459
  async handleControl(msg) {
18934
19460
  switch (msg.t) {
18935
19461
  case "playRecorded":
@@ -18946,6 +19472,9 @@ var TimelineSession = class {
18946
19472
  case "scrubTo":
18947
19473
  if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
18948
19474
  break;
19475
+ case "scrubAck":
19476
+ if (this.mode === "recorded") this.feeder?.scrubAck();
19477
+ break;
18949
19478
  case "setRate":
18950
19479
  this.feeder?.setRate(msg.rate);
18951
19480
  break;
@@ -18953,6 +19482,7 @@ var TimelineSession = class {
18953
19482
  if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
18954
19483
  break;
18955
19484
  case "setProfile":
19485
+ if (msg.profile === this.profile) break;
18956
19486
  this.profile = msg.profile;
18957
19487
  if (this.mode === "recorded") this.enterRecorded();
18958
19488
  break;
@@ -25670,25 +26200,6 @@ async function extractSamples(segment, segmentStartMs) {
25670
26200
  samples: collected
25671
26201
  } : null;
25672
26202
  }
25673
- /**
25674
- * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
25675
- * WebCodecs decoder config, or null if the segment carries no video / no
25676
- * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
25677
- * epoch ms; the first sample's cts is the zero reference.
25678
- */
25679
- async function extractKeyframes(segment, segmentStartMs) {
25680
- const extracted = await extractSamples(segment, segmentStartMs);
25681
- if (!extracted) return null;
25682
- const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
25683
- ts: s.ts,
25684
- data: s.data
25685
- }));
25686
- return keyframes.length > 0 ? {
25687
- codec: extracted.codec,
25688
- description: extracted.description,
25689
- keyframes
25690
- } : null;
25691
- }
25692
26203
  //#endregion
25693
26204
  //#region src/stream-broker/recorded/avcc-annexb.ts
25694
26205
  /**
@@ -26192,33 +26703,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
26192
26703
  "pipe:1"
26193
26704
  ];
26194
26705
  /**
26195
- * ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
26196
- * `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
26197
- * stops after the FIRST output access unit — a recorded fragment is a single
26198
- * IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
26199
- * (it never renders a delta frame). This reliably yields exactly the IDR with
26200
- * its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
26201
- * `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
26202
- * the spec.
26203
- */
26204
- var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
26205
- "-hide_banner",
26206
- "-loglevel",
26207
- "error",
26208
- "-i",
26209
- "pipe:0",
26210
- "-map",
26211
- "0:v:0",
26212
- "-frames:v",
26213
- "1",
26214
- "-c:v",
26215
- "copy",
26216
- "-an",
26217
- "-f",
26218
- "mpegts",
26219
- "pipe:1"
26220
- ];
26221
- /**
26222
26706
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
26223
26707
  * access units.
26224
26708
  *
@@ -26244,113 +26728,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26244
26728
  codec
26245
26729
  }));
26246
26730
  }
26247
- /**
26248
- * Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
26249
- * (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
26250
- * The scrub path never renders delta frames — it always pushes the nearest
26251
- * keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
26252
- * scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
26253
- * loss of scrub fidelity.
26254
- *
26255
- * Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
26256
- * NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
26257
- * frequency win comes from the feeder's fragment cache + direction-aware
26258
- * prefetch (which eliminate / overlap the demux), for which this compact
26259
- * keyframe-only payload is the cache unit.
26260
- *
26261
- * Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
26262
- * AU (the demux contract's params-fused IDR) if none were flagged, so the result
26263
- * is keyframes-only yet never empty for a valid fragment.
26264
- *
26265
- * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
26266
- * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
26267
- */
26268
- async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26269
- const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
26270
- ffmpegPath,
26271
- args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
26272
- input: bytes
26273
- }));
26274
- const mapped = accessUnits.map((au) => ({
26275
- data: au.data,
26276
- ptsMs: au.ptsMs,
26277
- keyframe: au.keyframe,
26278
- codec
26279
- }));
26280
- const keyframes = mapped.filter((au) => au.keyframe);
26281
- return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
26282
- }
26283
26731
  //#endregion
26284
26732
  //#region src/stream-broker/recorded/segment-demux-js.ts
26285
26733
  /**
26286
26734
  * segment-demux-js — PURE-JS in-process demux for recorded playback.
26287
26735
  *
26288
- * Two flavours, both replacing a per-fragment ffmpeg spawn with an mp4box parse:
26289
- * - {@link demuxSegmentKeyframesJs} / {@link demuxSegmentKeyframesPreferJs}
26290
- * KEYFRAME-only, for the feeder-scrub sub-mode.
26291
- * - {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26292
- * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26736
+ * {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26737
+ * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26738
+ * Both playback AND scrub demux through this since sweep mode (the scrub train
26739
+ * pushes predicted frames, so the whole GOP is needed either way).
26293
26740
  *
26294
- * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
26295
- * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
26741
+ * Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
26742
+ * in-process mp4box parse. A recorded `.m4s`
26296
26743
  * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
26297
26744
  * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
26298
26745
  * sample(s) are all in the bytes we already hold — no init segment to fetch.
26299
26746
  *
26300
- * Flow: reuse {@link extractKeyframes} (the proven mp4box sync-sample demux the
26301
- * recorder scrub module already ships — box parsing is NOT duplicated here) to get
26302
- * the codec `description` + length-prefixed sync samples, then
26303
- * {@link buildKeyframeAnnexB} converts each to a standalone-decodable Annex-B
26304
- * access unit with the parameter sets fused on. The result matches the
26305
- * {@link AccessUnit} contract the ffmpeg path produces: `codec` H264|H265,
26306
- * `keyframe:true`, and `ptsMs` segment-relative (offset from the first sync
26307
- * sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
26308
- *
26309
- * Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
26310
- * so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
26311
- * demux's PES-derived deltas — `clampIntoSegment`/position reporting are
26312
- * unaffected.
26747
+ * Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
26748
+ * module already ships — box parsing is NOT duplicated here) yields the codec
26749
+ * `description` + length-prefixed samples; sync samples are fused with their
26750
+ * parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
26751
+ * ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
26752
+ * the first sample), exactly like the ffmpeg demux's PES-derived deltas
26753
+ * `clampIntoSegment`/position reporting are unaffected.
26313
26754
  */
26314
26755
  /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
26315
26756
  function isHevcCodec(codec) {
26316
26757
  return /^h[ev][vc]1\./i.test(codec);
26317
26758
  }
26318
26759
  /**
26319
- * Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
26320
- * fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
26321
- * mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
26322
- * Throws only on a genuinely malformed decoder config / sample — the wrapper
26323
- * ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
26324
- */
26325
- async function demuxSegmentKeyframesJs(bytes) {
26326
- const extracted = await extractKeyframes(bytes, 0);
26327
- if (!extracted || extracted.keyframes.length === 0) return null;
26328
- const isHevc = isHevcCodec(extracted.codec);
26329
- const codec = isHevc ? "H265" : "H264";
26330
- return extracted.keyframes.slice(0, 1).map((k) => ({
26331
- data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
26332
- ptsMs: k.ts,
26333
- keyframe: true,
26334
- codec
26335
- }));
26336
- }
26337
- /**
26338
- * Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
26339
- * ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
26340
- * keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
26341
- * contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
26342
- */
26343
- async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
26344
- try {
26345
- const js = await demuxSegmentKeyframesJs(bytes);
26346
- if (js && js.length > 0) return js;
26347
- opts.onFfmpegFallback?.("mp4box parse found no keyframe");
26348
- } catch (err) {
26349
- opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
26350
- }
26351
- return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
26352
- }
26353
- /**
26354
26760
  * Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
26355
26761
  * EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
26356
26762
  * `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
@@ -26659,7 +27065,8 @@ var ADAPTIVE_CONFIG = {
26659
27065
  minFailPercent: .05,
26660
27066
  waitAfterResetMs: 8e3,
26661
27067
  upgradeHealthyMs: 6e4,
26662
- minTimeBetweenChangesMs: 6e4
27068
+ minTimeBetweenChangesMs: 6e4,
27069
+ maxUpgradeBackoffMs: 9e5
26663
27070
  };
26664
27071
  /**
26665
27072
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -26845,6 +27252,24 @@ var BrokerWebrtcServer = class {
26845
27252
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
26846
27253
  brokers = /* @__PURE__ */ new Map();
26847
27254
  /**
27255
+ * Post-session warmth: when the LAST WebRTC session on a broker closes, a
27256
+ * ghost subscription keeps the source dialed and its RTP ring warm for the
27257
+ * linger window, so the dominant remote pattern — reopen within minutes —
27258
+ * reconnects with an instant-start GOP replay instead of re-paying the
27259
+ * cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
27260
+ * attribution enum; the label answers "who keeps cam X warm?" in the
27261
+ * operator widget.
27262
+ */
27263
+ brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
27264
+ const broker = this.brokers.get(brokerId);
27265
+ if (!broker) return null;
27266
+ return broker.onEncodedData(() => {}, {
27267
+ kind: "warmup",
27268
+ label: "webrtc-linger",
27269
+ media: "both"
27270
+ });
27271
+ } });
27272
+ /**
26848
27273
  * Resolves a brokerId to its current profile tier (`CamProfile`)
26849
27274
  * via the manager's `assignments` map, or `null` for brokers that
26850
27275
  * are only kept alive by a manual activation. Injected by the
@@ -27007,16 +27432,20 @@ var BrokerWebrtcServer = class {
27007
27432
  * RTC controller / Alexa handler closes the session the same way.
27008
27433
  */
27009
27434
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
27435
+ const offerStart = Date.now();
27010
27436
  const setup = await this.setupSessionForBroker(streamId, hints, {
27011
27437
  ...opts,
27012
27438
  clientOfferSdp
27013
27439
  });
27440
+ const setupMs = Date.now() - offerStart;
27014
27441
  try {
27015
27442
  if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
27443
+ const negotiateStart = Date.now();
27016
27444
  const answer = await setup.session.handleOffer({
27017
27445
  sdp: clientOfferSdp,
27018
27446
  type: "offer"
27019
27447
  }, { nonTrickle: opts.nonTrickle === true });
27448
+ const negotiateMs = Date.now() - negotiateStart;
27020
27449
  setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
27021
27450
  sessionId: setup.sessionId,
27022
27451
  deviceId: setup.deviceId,
@@ -27024,7 +27453,11 @@ var BrokerWebrtcServer = class {
27024
27453
  iceServers: setup.iceServerCount,
27025
27454
  codec: setup.sessionCodec,
27026
27455
  useRtpRepacketizer: setup.useRtpRepacketizer,
27027
- relayOnly: opts.relayOnly === true
27456
+ relayOnly: opts.relayOnly === true,
27457
+ setupMs,
27458
+ negotiateMs,
27459
+ totalMs: Date.now() - offerStart,
27460
+ ...setup.setupTimings
27028
27461
  } });
27029
27462
  return {
27030
27463
  sessionId: setup.sessionId,
@@ -27051,6 +27484,11 @@ var BrokerWebrtcServer = class {
27051
27484
  */
27052
27485
  async setupSessionForBroker(streamId, hints, opts) {
27053
27486
  if (this.stopped) throw new Error("Server stopped");
27487
+ const setupStart = Date.now();
27488
+ let ensureTierMs = 0;
27489
+ let warmUpMs = 0;
27490
+ let iceResolveMs = 0;
27491
+ let hwAccelMs = 0;
27054
27492
  const isAdaptiveStream = streamId.endsWith("/adaptive");
27055
27493
  const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
27056
27494
  const resolveHints = intent !== void 0 ? {
@@ -27060,10 +27498,13 @@ var BrokerWebrtcServer = class {
27060
27498
  if (isAdaptiveStream) {
27061
27499
  const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
27062
27500
  const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
27501
+ const ensureTierStart = Date.now();
27063
27502
  try {
27064
27503
  await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
27065
27504
  } catch (err) {
27066
27505
  throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${require_dist.errMsg(err)}`);
27506
+ } finally {
27507
+ ensureTierMs = Date.now() - ensureTierStart;
27067
27508
  }
27068
27509
  }
27069
27510
  const brokerId = this.resolveBrokerId(streamId, resolveHints);
@@ -27085,6 +27526,7 @@ var BrokerWebrtcServer = class {
27085
27526
  kind: "warmup",
27086
27527
  media: "both"
27087
27528
  });
27529
+ const warmUpStart = Date.now();
27088
27530
  await new Promise((resolve) => {
27089
27531
  const timer = setTimeout(() => {
27090
27532
  sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
@@ -27100,9 +27542,11 @@ var BrokerWebrtcServer = class {
27100
27542
  resolve();
27101
27543
  });
27102
27544
  });
27545
+ warmUpMs = Date.now() - warmUpStart;
27103
27546
  sessionLogger.info("broker warm-up complete", { meta: {
27104
27547
  brokerId,
27105
- detectedCodec: broker.getStats().codec ?? null
27548
+ detectedCodec: broker.getStats().codec ?? null,
27549
+ waitedMs: warmUpMs
27106
27550
  } });
27107
27551
  }
27108
27552
  const brokerCodec = broker.getStats().codec ?? "h264";
@@ -27286,6 +27730,7 @@ var BrokerWebrtcServer = class {
27286
27730
  }
27287
27731
  }, annexbAttribution);
27288
27732
  warmUpUnsubscribe?.();
27733
+ this.brokerLinger.cancel(brokerId);
27289
27734
  const preBuffer = broker.getPreBuffer();
27290
27735
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
27291
27736
  for (const pkt of preBuffer) if (pkt.type === "video") {
@@ -27312,7 +27757,9 @@ var BrokerWebrtcServer = class {
27312
27757
  }
27313
27758
  });
27314
27759
  }
27760
+ const iceResolveStart = Date.now();
27315
27761
  const allIceServers = await this.resolveIceServers();
27762
+ iceResolveMs = Date.now() - iceResolveStart;
27316
27763
  const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
27317
27764
  const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
27318
27765
  if (stunOnly.length === 0) return null;
@@ -27337,13 +27784,16 @@ var BrokerWebrtcServer = class {
27337
27784
  sourceProfileLevelId,
27338
27785
  offeredProfileLevelIds
27339
27786
  } });
27787
+ const hwAccelStart = Date.now();
27788
+ const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
27789
+ hwAccelMs = Date.now() - hwAccelStart;
27340
27790
  session = new AdaptiveSession({
27341
27791
  sessionId,
27342
27792
  source,
27343
27793
  sourceCodec: sessionCodec,
27344
27794
  transcodeToBaseline,
27345
27795
  transcodeProfile: effectiveTranscodeProfile,
27346
- transcodeDecodeHwAccel: await this.getTranscodeDecodeHwAccel(),
27796
+ transcodeDecodeHwAccel,
27347
27797
  resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
27348
27798
  iceConfig: {
27349
27799
  iceServers,
@@ -27455,7 +27905,6 @@ var BrokerWebrtcServer = class {
27455
27905
  const locateRecorded = this.locateRecordedSegment;
27456
27906
  const readRecorded = this.readRecordedSegmentBytes;
27457
27907
  const resolveFfmpeg = this.resolveFfmpegPath;
27458
- let scrubJsFallbackLogged = false;
27459
27908
  let fullJsFallbackLogged = false;
27460
27909
  let audioOpusFallbackLogged = false;
27461
27910
  if (locateRecorded && readRecorded) {
@@ -27483,19 +27932,6 @@ var BrokerWebrtcServer = class {
27483
27932
  } });
27484
27933
  }
27485
27934
  }),
27486
- demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
27487
- ffmpegPath: resolveFfmpeg?.(),
27488
- onFfmpegFallback: (reason) => {
27489
- if (scrubJsFallbackLogged) return;
27490
- scrubJsFallbackLogged = true;
27491
- sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
27492
- sessionId,
27493
- deviceId,
27494
- profile,
27495
- reason
27496
- } });
27497
- }
27498
- }),
27499
27935
  demuxAudio: opusSession ? async (b) => {
27500
27936
  return (await demuxSegmentAudioOpusPreferJs(b, {
27501
27937
  ffmpegPath: resolveFfmpeg?.(),
@@ -27531,10 +27967,17 @@ var BrokerWebrtcServer = class {
27531
27967
  } });
27532
27968
  timeline._emitState(s);
27533
27969
  },
27534
- onPosition: (epochMs) => timeline._emitPosition(epochMs)
27970
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
27971
+ onLog: (msg, data) => sessionLogger.info(msg, { meta: {
27972
+ sessionId,
27973
+ deviceId,
27974
+ profile,
27975
+ ...data
27976
+ } })
27535
27977
  });
27536
27978
  }
27537
27979
  });
27980
+ entry.timeline = timeline;
27538
27981
  sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
27539
27982
  sessionId,
27540
27983
  deviceId
@@ -27584,7 +28027,7 @@ var BrokerWebrtcServer = class {
27584
28027
  };
27585
28028
  if (streamId.endsWith("/adaptive")) {
27586
28029
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
27587
- this.startAdaptive(entry, deviceId, initialTier, sessionId);
28030
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
27588
28031
  }
27589
28032
  return {
27590
28033
  sessionId,
@@ -27594,7 +28037,14 @@ var BrokerWebrtcServer = class {
27594
28037
  brokerId,
27595
28038
  sessionCodec,
27596
28039
  useRtpRepacketizer,
27597
- iceServerCount: iceServers.length
28040
+ iceServerCount: iceServers.length,
28041
+ setupTimings: {
28042
+ setupTotalMs: Date.now() - setupStart,
28043
+ ensureTierMs,
28044
+ warmUpMs,
28045
+ iceResolveMs,
28046
+ hwAccelMs
28047
+ }
27598
28048
  };
27599
28049
  }
27600
28050
  async handleAnswer(sessionId, sdpAnswer) {
@@ -27644,6 +28094,7 @@ var BrokerWebrtcServer = class {
27644
28094
  async stop() {
27645
28095
  if (this.stopped) return;
27646
28096
  this.stopped = true;
28097
+ this.brokerLinger.stop();
27647
28098
  const closes = [];
27648
28099
  for (const [sid, entry] of this.sessions) {
27649
28100
  this.cleanupSession(sid);
@@ -27678,9 +28129,10 @@ var BrokerWebrtcServer = class {
27678
28129
  * whichever produces the higher loss ratio across its window. The async
27679
28130
  * poll is guarded against overlapping ticks via `tickInFlight`.
27680
28131
  */
27681
- startAdaptive(entry, deviceId, initialTier, sessionId) {
28132
+ startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
27682
28133
  const controller = new AdaptiveController({
27683
28134
  initialTier,
28135
+ priorFailures,
27684
28136
  config: ADAPTIVE_CONFIG,
27685
28137
  onSwitchTier: (tier) => {
27686
28138
  this.switchSessionTier(entry, deviceId, tier, sessionId);
@@ -27700,6 +28152,10 @@ var BrokerWebrtcServer = class {
27700
28152
  tickInFlight = true;
27701
28153
  (async () => {
27702
28154
  try {
28155
+ if (entry.timeline?.currentMode === "recorded") {
28156
+ controller.suspend(Date.now());
28157
+ return;
28158
+ }
27703
28159
  const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
27704
28160
  const now = Date.now();
27705
28161
  if (pct !== null && pct !== void 0 && pct > 0) {
@@ -27731,9 +28187,11 @@ var BrokerWebrtcServer = class {
27731
28187
  */
27732
28188
  switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
27733
28189
  const prev = this.adaptiveIntent.get(sessionId);
28190
+ const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
27734
28191
  this.adaptiveIntent.set(sessionId, {
27735
28192
  prefersTier: tier,
27736
- forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
28193
+ forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
28194
+ failures
27737
28195
  });
27738
28196
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
27739
28197
  entry.session.pendingRenegotiation = {
@@ -27747,7 +28205,8 @@ var BrokerWebrtcServer = class {
27747
28205
  deviceId,
27748
28206
  tier,
27749
28207
  forceTranscodeDown,
27750
- epoch
28208
+ epoch,
28209
+ failures: Object.fromEntries(failures)
27751
28210
  }
27752
28211
  });
27753
28212
  }
@@ -27755,6 +28214,12 @@ var BrokerWebrtcServer = class {
27755
28214
  const entry = this.sessions.get(sessionId);
27756
28215
  if (!entry) return;
27757
28216
  this.sessions.delete(sessionId);
28217
+ let brokerStillServed = false;
28218
+ for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
28219
+ brokerStillServed = true;
28220
+ break;
28221
+ }
28222
+ if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
27758
28223
  this.adaptiveIntent.delete(sessionId);
27759
28224
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
27760
28225
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -28338,6 +28803,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
28338
28803
  }
28339
28804
  };
28340
28805
  //#endregion
28806
+ //#region src/stream-broker/event-loop-stall-monitor.ts
28807
+ var DEFAULT_INTERVAL_MS = 100;
28808
+ var DEFAULT_THRESHOLD_MS = 250;
28809
+ var EventLoopStallMonitor = class {
28810
+ intervalMs;
28811
+ thresholdMs;
28812
+ now;
28813
+ onStall;
28814
+ timer = null;
28815
+ lastTickAt = 0;
28816
+ constructor(options) {
28817
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
28818
+ this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
28819
+ this.now = options.now ?? (() => Date.now());
28820
+ this.onStall = options.onStall;
28821
+ }
28822
+ start() {
28823
+ if (this.timer) return;
28824
+ this.lastTickAt = this.now();
28825
+ this.timer = setInterval(() => {
28826
+ const at = this.now();
28827
+ const lateMs = at - this.lastTickAt - this.intervalMs;
28828
+ this.lastTickAt = at;
28829
+ if (lateMs >= this.thresholdMs) try {
28830
+ this.onStall(lateMs);
28831
+ } catch {}
28832
+ }, this.intervalMs);
28833
+ this.timer.unref?.();
28834
+ }
28835
+ stop() {
28836
+ if (!this.timer) return;
28837
+ clearInterval(this.timer);
28838
+ this.timer = null;
28839
+ }
28840
+ };
28841
+ //#endregion
28341
28842
  //#region src/stream-broker/addon.ts
28342
28843
  /**
28343
28844
  * Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
@@ -28430,6 +28931,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28430
28931
  webrtcServer = null;
28431
28932
  /** Handle for the embed SPA data-plane listener (disposed on shutdown). */
28432
28933
  embedDataPlane = null;
28934
+ /** Names this process's event-loop stalls — see onInitialize. */
28935
+ loopStallMonitor = null;
28433
28936
  /**
28434
28937
  * Snapshot-equality cache for the broker-metrics emit. Each
28435
28938
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -28530,6 +29033,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28530
29033
  }
28531
29034
  }
28532
29035
  async onInitialize() {
29036
+ this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
29037
+ this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
29038
+ } });
29039
+ this.loopStallMonitor.start();
28533
29040
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
28534
29041
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
28535
29042
  this.brokerManager.setEventBus(this.ctx.eventBus);
@@ -28872,6 +29379,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28872
29379
  ];
28873
29380
  }
28874
29381
  async onShutdown() {
29382
+ this.loopStallMonitor?.stop();
29383
+ this.loopStallMonitor = null;
28875
29384
  this.flushProbeSnapshots(Date.now(), true);
28876
29385
  if (this.metricsSnapshotTimer) {
28877
29386
  clearInterval(this.metricsSnapshotTimer);