@camstack/addon-pipeline 1.2.38 → 1.2.39

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-BvFLZyhW.js → dist-BtAvtroH.js} +260 -108
  6. package/dist/{dist-Du7_SNiR.mjs → dist-COgGdFMv.mjs} +260 -108
  7. package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-naFYvcOO.mjs} +139 -2
  8. package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-shfwi1_b.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-CjrKd4xX.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-B59GNAJq.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-83qNzMSL.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-BFH17kSk.mjs} +2 -2
  22. package/dist/stream-broker/index.js +717 -211
  23. package/dist/stream-broker/index.mjs +717 -211
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-yeSHfpUy.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-Df4WxM6N.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-COHSiTGq.js} +1 -1
  28. package/embed-dist/assets/index-4fJzjj4H.css +2 -0
  29. package/embed-dist/assets/index-Ct9M2v5n.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-BtAvtroH.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: {
@@ -9735,6 +9799,21 @@ var DecoderSessionProxy = class {
9735
9799
  this.api = api;
9736
9800
  this.sessionId = sessionId;
9737
9801
  }
9802
+ /**
9803
+ * Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
9804
+ * regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
9805
+ * `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
9806
+ * is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
9807
+ * peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
9808
+ * stub, an old decoder that predates `waitMs`, a test fake — would otherwise
9809
+ * turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
9810
+ * that pins the CPU, starves every timer in the process, and allocates until
9811
+ * the heap dies (observed: a 4 GB OOM in under 15 s).
9812
+ */
9813
+ async paceEmptyDrain(drainStartedAt) {
9814
+ const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
9815
+ if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
9816
+ }
9738
9817
  async pushPacket(packet) {
9739
9818
  await this.api.pushPacket({
9740
9819
  sessionId: this.sessionId,
@@ -9750,12 +9829,14 @@ var DecoderSessionProxy = class {
9750
9829
  async startPolling(onFrame) {
9751
9830
  this.polling = true;
9752
9831
  while (this.polling) {
9832
+ const drainStartedAt = Date.now();
9753
9833
  const frames = await this.api.pullFrames({
9754
9834
  sessionId: this.sessionId,
9755
9835
  maxCount: 4,
9756
9836
  waitMs: POLL_WAIT_MS
9757
9837
  });
9758
9838
  for (const frame of frames) onFrame(frame);
9839
+ if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9759
9840
  }
9760
9841
  }
9761
9842
  /**
@@ -9773,12 +9854,14 @@ var DecoderSessionProxy = class {
9773
9854
  async startHandlePolling(onHandle) {
9774
9855
  this.polling = true;
9775
9856
  while (this.polling) try {
9857
+ const drainStartedAt = Date.now();
9776
9858
  const handles = await this.api.pullHandles({
9777
9859
  sessionId: this.sessionId,
9778
9860
  maxCount: 4,
9779
9861
  waitMs: POLL_WAIT_MS
9780
9862
  });
9781
9863
  for (const handle of handles) onHandle(handle);
9864
+ if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9782
9865
  } catch (err) {
9783
9866
  this.polling = false;
9784
9867
  const msg = err instanceof Error ? err.message : String(err);
@@ -17784,6 +17867,71 @@ function withSourceResolution(consumerDefault, probedSource) {
17784
17867
  };
17785
17868
  }
17786
17869
  //#endregion
17870
+ //#region src/stream-broker/webrtc/broker-linger-registry.ts
17871
+ /** Matches the observed reopen gaps (1–2 min) with headroom; well under the
17872
+ * "operator walked away" scale where warmth would be waste. */
17873
+ var DEFAULT_LINGER_MS = 3 * 6e4;
17874
+ var BrokerLingerRegistry = class {
17875
+ lingerMs;
17876
+ subscribe;
17877
+ holds = /* @__PURE__ */ new Map();
17878
+ constructor(options) {
17879
+ this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
17880
+ this.subscribe = options.subscribe;
17881
+ }
17882
+ /**
17883
+ * Start (or restart) the linger window for a broker that just lost its
17884
+ * last session. Re-holding an already-held broker re-arms the timer
17885
+ * without stacking a second ghost subscription.
17886
+ */
17887
+ hold(brokerId) {
17888
+ const existing = this.holds.get(brokerId);
17889
+ if (existing) {
17890
+ clearTimeout(existing.timer);
17891
+ existing.timer = this.armTimer(brokerId);
17892
+ return;
17893
+ }
17894
+ const unsubscribe = this.subscribe(brokerId);
17895
+ if (unsubscribe === null) return;
17896
+ this.holds.set(brokerId, {
17897
+ unsubscribe,
17898
+ timer: this.armTimer(brokerId)
17899
+ });
17900
+ }
17901
+ /** Release the hold NOW — a new session attached, the real subscription
17902
+ * carries the demand from here. No-op when nothing is held. */
17903
+ cancel(brokerId) {
17904
+ const hold = this.holds.get(brokerId);
17905
+ if (!hold) return;
17906
+ clearTimeout(hold.timer);
17907
+ this.holds.delete(brokerId);
17908
+ this.release(hold);
17909
+ }
17910
+ /** Release every hold (server shutdown). */
17911
+ stop() {
17912
+ for (const [brokerId, hold] of this.holds) {
17913
+ clearTimeout(hold.timer);
17914
+ this.holds.delete(brokerId);
17915
+ this.release(hold);
17916
+ }
17917
+ }
17918
+ armTimer(brokerId) {
17919
+ const timer = setTimeout(() => {
17920
+ const hold = this.holds.get(brokerId);
17921
+ if (!hold) return;
17922
+ this.holds.delete(brokerId);
17923
+ this.release(hold);
17924
+ }, this.lingerMs);
17925
+ timer.unref?.();
17926
+ return timer;
17927
+ }
17928
+ release(hold) {
17929
+ try {
17930
+ hold.unsubscribe();
17931
+ } catch {}
17932
+ }
17933
+ };
17934
+ //#endregion
17787
17935
  //#region src/stream-broker/webrtc/loss-tracker.ts
17788
17936
  /** Delta between two RTCP extended (32-bit) highest-sequence values, with
17789
17937
  * 32-bit wraparound handling. */
@@ -17832,6 +17980,24 @@ var LossTracker = class {
17832
17980
  if (now < this.resetUntil) return false;
17833
17981
  return this.lossRatio() > this.config.minFailPercent;
17834
17982
  }
17983
+ /**
17984
+ * True when the window holds enough recent receiver feedback for a
17985
+ * "healthy" verdict to MEAN anything.
17986
+ *
17987
+ * `lossRatio()` returns 0 with fewer than two in-window samples, so
17988
+ * `isFailing` is false for a session that has produced NO feedback at all —
17989
+ * a link so bad that RTCP never arrives reads identically to a perfect one.
17990
+ * A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
17991
+ * UPGRADE must not, because it spends bandwidth the session has never been
17992
+ * shown to have. Silence is not health.
17993
+ */
17994
+ hasEvidence(now) {
17995
+ if (now < this.resetUntil) return false;
17996
+ const cutoff = now - this.config.analyzeWindowMs;
17997
+ let inWindow = 0;
17998
+ for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
17999
+ return inWindow >= 2;
18000
+ }
17835
18001
  };
17836
18002
  //#endregion
17837
18003
  //#region src/stream-broker/webrtc/adaptive-controller.ts
@@ -17840,8 +18006,17 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
17840
18006
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
17841
18007
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
17842
18008
  * 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).
18009
+ * transcode-down. Side effects run through injected callbacks (testable
18010
+ * without werift).
18011
+ *
18012
+ * An UPGRADE requires all three of:
18013
+ * 1. the session clock — measured from the FIRST tick, not from the unix
18014
+ * epoch — showing `upgradeHealthyMs` without a failing verdict;
18015
+ * 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
18016
+ * a session that has produced no RTCP at all is not "healthy", it is
18017
+ * silent, and climbing on silence spends bandwidth never shown to exist;
18018
+ * 3. the target tier's re-attempt backoff, which doubles per measured
18019
+ * failure of that tier in this session.
17845
18020
  */
17846
18021
  var AdaptiveController = class {
17847
18022
  opts;
@@ -17851,27 +18026,86 @@ var AdaptiveController = class {
17851
18026
  transcodeEngaged = false;
17852
18027
  lastChangeAt = 0;
17853
18028
  healthySince = 0;
18029
+ /**
18030
+ * Whether the session clock has been armed by the first evaluation. The
18031
+ * clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
18032
+ * would read as "healthy since 1970" and hand out a free upgrade on the
18033
+ * very first tick, before a single loss sample had been seen. A boolean
18034
+ * (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
18035
+ */
18036
+ armed = false;
18037
+ failures;
17854
18038
  constructor(opts) {
17855
18039
  this.opts = opts;
17856
18040
  this.tracker = new LossTracker(opts.config);
17857
18041
  this.tier = opts.initialTier;
18042
+ this.failures = new Map(opts.priorFailures ?? []);
17858
18043
  }
17859
18044
  get currentTier() {
17860
18045
  return this.tier;
17861
18046
  }
18047
+ /**
18048
+ * Per-tier measured-failure counts, INCLUDING those inherited from earlier
18049
+ * rungs of the same viewer session. The server snapshots this into the
18050
+ * adaptive intent so the session's next controller inherits it.
18051
+ */
18052
+ get failureCounts() {
18053
+ return this.failures;
18054
+ }
17862
18055
  onLoss(sample, now) {
17863
18056
  this.tracker.track(sample, now);
17864
18057
  }
18058
+ /**
18059
+ * Freeze the ladder for this evaluation instead of ticking it.
18060
+ *
18061
+ * Called while the session's track is NOT carrying the live source — D42
18062
+ * put recorded playback and scrub on the same track with a moving source
18063
+ * pointer, and in those modes the live feed is gated, so receiver feedback
18064
+ * measures the recorded feeder, not the camera tier. Acting on it is
18065
+ * incoherent in both directions: a downgrade cannot shrink a file, an
18066
+ * upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
18067
+ * is a close-and-recreate re-offer) — dropping the viewer back to live at
18068
+ * the wrong moment with nothing to restore the pointer.
18069
+ *
18070
+ * Holds the climb clock so time spent in recorded modes can never
18071
+ * accumulate into an upgrade on the return to live.
18072
+ */
18073
+ suspend(now) {
18074
+ if (!this.armed) {
18075
+ this.armed = true;
18076
+ this.lastChangeAt = now;
18077
+ }
18078
+ this.healthySince = now;
18079
+ }
17865
18080
  tick(now) {
18081
+ if (!this.armed) {
18082
+ this.armed = true;
18083
+ this.healthySince = now;
18084
+ this.lastChangeAt = now;
18085
+ }
17866
18086
  if (this.tracker.isFailing(now)) {
17867
18087
  this.healthySince = now;
17868
18088
  this.handleFailing(now);
17869
18089
  return;
17870
18090
  }
18091
+ if (!this.tracker.hasEvidence(now)) {
18092
+ this.healthySince = now;
18093
+ return;
18094
+ }
17871
18095
  this.handleHealthy(now);
17872
18096
  }
17873
18097
  canUpgrade(now) {
17874
- return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs || this.lastChangeAt === 0;
18098
+ return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
18099
+ }
18100
+ /**
18101
+ * How long the link must read healthy before `tier` may be re-attempted.
18102
+ * Doubles per measured failure of that tier in this session, capped at
18103
+ * `maxUpgradeBackoffMs`.
18104
+ */
18105
+ requiredHealthyMs(tier) {
18106
+ const failures = this.failures.get(tier) ?? 0;
18107
+ if (failures <= 0) return this.opts.config.upgradeHealthyMs;
18108
+ return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
17875
18109
  }
17876
18110
  commitChange(now) {
17877
18111
  this.lastChangeAt = now;
@@ -17880,6 +18114,8 @@ var AdaptiveController = class {
17880
18114
  handleFailing(now) {
17881
18115
  const idx = TIER_ORDER.indexOf(this.tier);
17882
18116
  if (idx < TIER_ORDER.length - 1) {
18117
+ const failed = this.tier;
18118
+ this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
17883
18119
  this.tier = TIER_ORDER[idx + 1];
17884
18120
  this.opts.onSwitchTier(this.tier);
17885
18121
  this.commitChange(now);
@@ -17898,17 +18134,17 @@ var AdaptiveController = class {
17898
18134
  }
17899
18135
  }
17900
18136
  handleHealthy(now) {
17901
- if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
17902
- if (!this.canUpgrade(now)) return;
17903
18137
  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
- }
18138
+ if (idx <= 0) return;
18139
+ const next = TIER_ORDER[idx - 1];
18140
+ if (now - this.healthySince < this.requiredHealthyMs(next)) return;
18141
+ if (!this.canUpgrade(now)) return;
18142
+ this.tier = next;
18143
+ this.mtuReduced = false;
18144
+ this.transcodeEngaged = false;
18145
+ this.opts.onSwitchTier(next);
18146
+ this.commitChange(now);
18147
+ this.healthySince = now;
17912
18148
  }
17913
18149
  };
17914
18150
  //#endregion
@@ -17933,6 +18169,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
17933
18169
  t: require_dist.literal("scrubTo"),
17934
18170
  epoch: require_dist.number()
17935
18171
  }),
18172
+ require_dist.object({ t: require_dist.literal("scrubAck") }),
17936
18173
  require_dist.object({
17937
18174
  t: require_dist.literal("setRate"),
17938
18175
  rate: require_dist.number().min(0).max(4)
@@ -18106,6 +18343,74 @@ var SCRUB_CACHE_MAX = 8;
18106
18343
  * sustained direction, so a jittery/reversing drag never wastes the slot.
18107
18344
  */
18108
18345
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
18346
+ /**
18347
+ * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
18348
+ * that SWEEPS predicted frames instead of jumping. A steady forward drag is
18349
+ * playback with a moving target: once a keyframe decoded, the predicted frames
18350
+ * after it decode in sequence at a fraction of an IDR's bytes — that is the
18351
+ * continuous flow. Beyond this window (a fling), or backwards, a jump to an
18352
+ * independently-decodable keyframe is cheaper than the burst; and because a
18353
+ * thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
18354
+ * congestion, so degradation to jump-only is emergent, and visible per push
18355
+ * (`mode` on every scrub-feeder:pushed line).
18356
+ */
18357
+ var SCRUB_SWEEP_WINDOW_MS = 4e3;
18358
+ /**
18359
+ * Receiver-credit cap for scrub pushes: at most this many keyframes may be
18360
+ * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
18361
+ * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
18362
+ * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
18363
+ * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
18364
+ * Backpressure is the receiver's: a push spends a credit, a rendered frame
18365
+ * restores one, and with zero credit the newest target PARKS on
18366
+ * `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
18367
+ */
18368
+ var SCRUB_CREDIT_CAP = 2;
18369
+ /**
18370
+ * How far a `gap` locate may be SNAPPED onto real footage.
18371
+ *
18372
+ * A snap is a CORRECTION — the playhead landed a moment outside coverage and
18373
+ * the operator plainly means the footage beside it — not a SEARCH for wherever
18374
+ * footage happens to exist. The distinction is not cosmetic, because the index
18375
+ * that answers `locate` cannot tell "there is no footage here" apart from "I
18376
+ * have not walked this far back yet": it is rebuilt by walking the archive on
18377
+ * every recorder start, and that walk takes minutes.
18378
+ *
18379
+ * Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
18380
+ * `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
18381
+ * minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
18382
+ * tick into a segment read against the addon that was busy re-walking, each one
18383
+ * blew the 8 s budget, and both surfaces died. Bounded, all of those answer
18384
+ * `gap` for free — exactly as they did before snapping existed.
18385
+ *
18386
+ * 120 s covers the real cases by a wide margin: an inter-segment crack is
18387
+ * 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
18388
+ * that the server is answering a question the viewer did not ask; the viewer
18389
+ * holds the coverage ranges and can offer "jump to nearest footage" as an
18390
+ * action instead.
18391
+ */
18392
+ var SNAP_MAX_DISTANCE_MS = 12e4;
18393
+ /**
18394
+ * Decide where a `gap` locate should snap: forward to the next footage edge,
18395
+ * else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
18396
+ * also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
18397
+ * {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
18398
+ * cursor — and so rejecting costs NO segment I/O at all.
18399
+ */
18400
+ function resolveSnapTarget(loc, requestedMs) {
18401
+ const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
18402
+ if (snapMs === null) return { kind: "none" };
18403
+ const distanceMs = Math.abs(snapMs - requestedMs);
18404
+ if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
18405
+ kind: "too-far",
18406
+ snapMs,
18407
+ distanceMs
18408
+ };
18409
+ return {
18410
+ kind: "snap",
18411
+ snapMs
18412
+ };
18413
+ }
18109
18414
  var RecordedFeeder = class {
18110
18415
  deps;
18111
18416
  clock = new MonotonicClock();
@@ -18148,6 +18453,16 @@ var RecordedFeeder = class {
18148
18453
  */
18149
18454
  scrubbing = false;
18150
18455
  scrubToken = 0;
18456
+ /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
18457
+ scrubCredit = SCRUB_CREDIT_CAP;
18458
+ /**
18459
+ * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
18460
+ * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
18461
+ * `scrubAck` settles one frame off the head, and the credit that push spent
18462
+ * comes back only when the head reaches zero — so a sweep cannot be refunded
18463
+ * once per frame for the single credit it cost.
18464
+ */
18465
+ scrubOutstanding = [];
18151
18466
  /** The newest un-processed drag target (null when the tick cycle has drained). */
18152
18467
  scrubPendingEpochMs = null;
18153
18468
  /**
@@ -18178,6 +18493,15 @@ var RecordedFeeder = class {
18178
18493
  * sign of `(newTarget − this)` is the drag direction that steers prefetch.
18179
18494
  */
18180
18495
  scrubPrevTarget = null;
18496
+ /** The last AU actually pushed this drag — the sweep-continuity cursor
18497
+ * (which fragment, and how deep into its AU list). Null until a jump lands. */
18498
+ scrubCursor = null;
18499
+ /** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
18500
+ scrubJumps = 0;
18501
+ scrubSweeps = 0;
18502
+ scrubAusPushed = 0;
18503
+ /** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
18504
+ scrubNoFootageLogged = false;
18181
18505
  /**
18182
18506
  * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
18183
18507
  * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
@@ -18215,9 +18539,19 @@ var RecordedFeeder = class {
18215
18539
  async seek(epochMs) {
18216
18540
  const token = ++this.seekToken;
18217
18541
  if (this.scrubbing) {
18542
+ this.deps.onLog?.("scrub-feeder:drag-end", {
18543
+ jumps: this.scrubJumps,
18544
+ sweeps: this.scrubSweeps,
18545
+ aus: this.scrubAusPushed
18546
+ });
18218
18547
  this.scrubbing = false;
18219
18548
  this.scrubToken++;
18220
18549
  this.scrubSegment = null;
18550
+ this.scrubCursor = null;
18551
+ this.scrubJumps = 0;
18552
+ this.scrubSweeps = 0;
18553
+ this.scrubAusPushed = 0;
18554
+ this.scrubOutstanding = [];
18221
18555
  this.scrubCache.clear();
18222
18556
  this.scrubPrevTarget = null;
18223
18557
  this.scrubDirRun = 0;
@@ -18228,29 +18562,67 @@ var RecordedFeeder = class {
18228
18562
  }
18229
18563
  this.advanceToken++;
18230
18564
  this.inFlightSeekEpochMs = epochMs;
18565
+ let snapInfo = null;
18231
18566
  try {
18232
18567
  this.deps.onState("loading");
18233
18568
  if (this.clockHasAdvanced) {
18234
18569
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18235
18570
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18236
18571
  }
18237
- const loc = await this.withTimeout(this.deps.locate({
18572
+ let loc = await this.withTimeout(this.deps.locate({
18238
18573
  deviceId: this.deps.deviceId,
18239
18574
  profile: this.deps.profile,
18240
18575
  epochMs
18241
18576
  }));
18242
18577
  if (this.disposed || token !== this.seekToken) return;
18578
+ let landMs = epochMs;
18243
18579
  if (loc.kind === "gap") {
18244
- this.currentSegment = null;
18245
- this.lastFedVideoIdx = -1;
18246
- this.stopPositionReports();
18247
- this.deps.onState("gap");
18248
- return;
18580
+ const decision = resolveSnapTarget(loc, epochMs);
18581
+ if (decision.kind !== "snap") {
18582
+ if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
18583
+ requestedMs: epochMs,
18584
+ nearestMs: decision.snapMs,
18585
+ distanceMs: decision.distanceMs
18586
+ });
18587
+ else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
18588
+ this.currentSegment = null;
18589
+ this.lastFedVideoIdx = -1;
18590
+ this.stopPositionReports();
18591
+ this.deps.onState("gap");
18592
+ return;
18593
+ }
18594
+ const snapped = await this.withTimeout(this.deps.locate({
18595
+ deviceId: this.deps.deviceId,
18596
+ profile: this.deps.profile,
18597
+ epochMs: decision.snapMs
18598
+ }));
18599
+ if (this.disposed || token !== this.seekToken) return;
18600
+ if (snapped.kind !== "segment") {
18601
+ this.deps.onLog?.("recorded:seek-no-footage", {
18602
+ requestedMs: epochMs,
18603
+ snappedMs: decision.snapMs
18604
+ });
18605
+ this.currentSegment = null;
18606
+ this.lastFedVideoIdx = -1;
18607
+ this.stopPositionReports();
18608
+ this.deps.onState("gap");
18609
+ return;
18610
+ }
18611
+ this.deps.onLog?.("recorded:seek-snapped", {
18612
+ requestedMs: epochMs,
18613
+ snappedMs: decision.snapMs
18614
+ });
18615
+ loc = snapped;
18616
+ landMs = decision.snapMs;
18617
+ snapInfo = {
18618
+ requestedMs: epochMs,
18619
+ snappedMs: decision.snapMs
18620
+ };
18249
18621
  }
18250
18622
  this.cursorMs = loc.startMs;
18251
18623
  const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
18252
18624
  if (this.disposed || token !== this.seekToken || loaded === null) return;
18253
- this.feedPrefix(loc, loaded.video, epochMs);
18625
+ this.feedPrefix(loc, loaded.video, landMs);
18254
18626
  if (this.rate === 0) {
18255
18627
  this.deps.onState("paused");
18256
18628
  this.deps.onPosition(this.cursorMs);
@@ -18270,9 +18642,20 @@ var RecordedFeeder = class {
18270
18642
  },
18271
18643
  resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
18272
18644
  });
18273
- } catch {
18645
+ } catch (err) {
18274
18646
  if (!this.disposed && token === this.seekToken) {
18275
18647
  this.stopPositionReports();
18648
+ if (snapInfo !== null) {
18649
+ this.deps.onLog?.("recorded:seek-snap-load-failed", {
18650
+ requestedMs: snapInfo.requestedMs,
18651
+ snappedMs: snapInfo.snappedMs,
18652
+ error: require_dist.errMsg(err)
18653
+ });
18654
+ this.currentSegment = null;
18655
+ this.lastFedVideoIdx = -1;
18656
+ this.deps.onState("gap");
18657
+ return;
18658
+ }
18276
18659
  this.deps.onState("error");
18277
18660
  }
18278
18661
  } finally {
@@ -18327,8 +18710,15 @@ var RecordedFeeder = class {
18327
18710
  this.scrubToken++;
18328
18711
  this.scrubPendingEpochMs = null;
18329
18712
  this.scrubSegment = null;
18713
+ this.scrubCredit = SCRUB_CREDIT_CAP;
18330
18714
  this.scrubPrevTarget = null;
18331
18715
  this.scrubDirRun = 0;
18716
+ this.scrubCursor = null;
18717
+ this.scrubJumps = 0;
18718
+ this.scrubSweeps = 0;
18719
+ this.scrubAusPushed = 0;
18720
+ this.scrubNoFootageLogged = false;
18721
+ this.scrubOutstanding = [];
18332
18722
  if (this.scrubTimer) {
18333
18723
  clearTimeout(this.scrubTimer);
18334
18724
  this.scrubTimer = null;
@@ -18348,6 +18738,24 @@ var RecordedFeeder = class {
18348
18738
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18349
18739
  }
18350
18740
  /**
18741
+ * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
18742
+ * credit (capped — over-acking never banks a burst) and, when a target sat
18743
+ * parked on an exhausted budget, resumes the tick. This — not any timer — is
18744
+ * what paces scrub pushes to what the link actually delivers.
18745
+ */
18746
+ scrubAck() {
18747
+ if (this.disposed) return;
18748
+ const head = this.scrubOutstanding[0];
18749
+ if (head === void 0) return;
18750
+ if (head > 1) {
18751
+ this.scrubOutstanding[0] = head - 1;
18752
+ return;
18753
+ }
18754
+ this.scrubOutstanding.shift();
18755
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
18756
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18757
+ }
18758
+ /**
18351
18759
  * One coalesced feeder-scrub push. Captures the newest pending target + the
18352
18760
  * current mode generation, resolves the covering fragment (reusing the memo'd
18353
18761
  * one when the target stays inside it), picks the nearest keyframe ≤ the
@@ -18358,25 +18766,68 @@ var RecordedFeeder = class {
18358
18766
  */
18359
18767
  async scrubTick() {
18360
18768
  const gen = this.scrubToken;
18769
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
18770
+ this.scrubTimer = null;
18771
+ return;
18772
+ }
18773
+ if (this.scrubCredit <= 0) {
18774
+ this.scrubTimer = null;
18775
+ this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
18776
+ return;
18777
+ }
18361
18778
  const target = this.scrubPendingEpochMs;
18362
18779
  this.scrubPendingEpochMs = null;
18363
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
18780
+ if (target === null) {
18364
18781
  this.scrubTimer = null;
18365
18782
  return;
18366
18783
  }
18367
18784
  try {
18368
- const loc = await this.withTimeout(this.deps.locate({
18785
+ let loc = await this.withTimeout(this.deps.locate({
18369
18786
  deviceId: this.deps.deviceId,
18370
18787
  profile: this.deps.profile,
18371
18788
  epochMs: target
18372
18789
  }));
18373
18790
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18374
- if (loc.kind === "gap") return;
18791
+ let effTarget = target;
18792
+ if (loc.kind === "gap") {
18793
+ const decision = resolveSnapTarget(loc, target);
18794
+ if (decision.kind !== "snap") {
18795
+ if (!this.scrubNoFootageLogged) {
18796
+ this.scrubNoFootageLogged = true;
18797
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
18798
+ epochMs: target,
18799
+ nearestMs: decision.snapMs,
18800
+ distanceMs: decision.distanceMs
18801
+ });
18802
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18803
+ }
18804
+ return;
18805
+ }
18806
+ const snapped = await this.withTimeout(this.deps.locate({
18807
+ deviceId: this.deps.deviceId,
18808
+ profile: this.deps.profile,
18809
+ epochMs: decision.snapMs
18810
+ }));
18811
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18812
+ if (snapped.kind !== "segment") {
18813
+ if (!this.scrubNoFootageLogged) {
18814
+ this.scrubNoFootageLogged = true;
18815
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18816
+ }
18817
+ return;
18818
+ }
18819
+ this.deps.onLog?.("scrub-feeder:snapped", {
18820
+ requestedMs: target,
18821
+ snappedMs: decision.snapMs
18822
+ });
18823
+ loc = snapped;
18824
+ effTarget = decision.snapMs;
18825
+ }
18375
18826
  let seg = this.scrubSegment;
18376
18827
  if (!seg || seg.startMs !== loc.startMs) {
18377
18828
  seg = this.scrubCacheGet(loc.startMs);
18378
18829
  if (!seg) {
18379
- const loaded = await this.loadAus(loc.startMs, false, true);
18830
+ const loaded = await this.loadAus(loc.startMs, false);
18380
18831
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18381
18832
  seg = {
18382
18833
  startMs: loc.startMs,
@@ -18387,26 +18838,80 @@ var RecordedFeeder = class {
18387
18838
  }
18388
18839
  this.scrubSegment = seg;
18389
18840
  }
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);
18841
+ const prevTarget = this.scrubPrevTarget;
18842
+ const cursor = this.scrubCursor;
18843
+ const targetIdx = this.pickScrubIndex(seg, effTarget, false);
18844
+ const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
18845
+ const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
18846
+ const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
18847
+ const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
18848
+ let aus;
18849
+ let firstIdx;
18850
+ let mode;
18851
+ let crossesFragment = false;
18852
+ if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
18853
+ mode = "sweep";
18854
+ firstIdx = cursor.idx + 1;
18855
+ aus = seg.aus.slice(firstIdx, targetIdx + 1);
18856
+ } else if (withinWindow && adjacentForward) {
18857
+ mode = "sweep";
18858
+ firstIdx = 0;
18859
+ aus = seg.aus.slice(0, targetIdx + 1);
18860
+ crossesFragment = true;
18861
+ } else {
18862
+ mode = "jump";
18863
+ firstIdx = this.pickScrubIndex(seg, effTarget, true);
18864
+ const kf = seg.aus[firstIdx];
18865
+ aus = kf === void 0 ? [] : [kf];
18866
+ }
18867
+ const lastAu = aus[aus.length - 1];
18868
+ if (lastAu === void 0) {
18869
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18870
+ this.scrubPrevTarget = effTarget;
18871
+ return;
18872
+ }
18873
+ if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18874
+ else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
18875
+ for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18876
+ this.scrubOutstanding.push(aus.length);
18877
+ this.scrubCredit -= 1;
18394
18878
  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);
18879
+ if (mode === "jump") this.scrubJumps += 1;
18880
+ else this.scrubSweeps += 1;
18881
+ this.scrubAusPushed += aus.length;
18882
+ this.scrubCursor = {
18883
+ fragStartMs: seg.startMs,
18884
+ fragDurMs: seg.durMs,
18885
+ idx: firstIdx + aus.length - 1
18886
+ };
18887
+ this.deps.onLog?.("scrub-feeder:pushed", {
18888
+ epochMs: effTarget,
18889
+ mode,
18890
+ aus: aus.length,
18891
+ codec: lastAu.codec,
18892
+ bytes: lastAu.data.length,
18893
+ creditLeft: this.scrubCredit
18894
+ });
18895
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18896
+ this.maybeScrubPrefetch(seg, effTarget, gen);
18897
+ } catch (err) {
18898
+ this.deps.onLog?.("scrub-feeder:tick-failed", {
18899
+ epochMs: target,
18900
+ error: err instanceof Error ? err.message : String(err)
18901
+ });
18902
+ } finally {
18903
+ 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
18904
  else this.scrubTimer = null;
18400
18905
  }
18401
18906
  }
18402
18907
  /**
18403
- * Index of the nearest keyframe at-or-before the drag target within `seg`
18908
+ * Index of the nearest AU at-or-before the drag target within `seg`
18404
18909
  * (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.
18910
+ * guarantees). `keyframesOnly` restricts the pick to sync frames the JUMP
18911
+ * mode, whose pushed frame must be standalone-decodable; sweep mode picks
18912
+ * ANY frame, which is what makes the flow frame-granular.
18408
18913
  */
18409
- pickScrubKeyframe(seg, target) {
18914
+ pickScrubIndex(seg, target, keyframesOnly) {
18410
18915
  const first = seg.aus[0];
18411
18916
  if (!first) return 0;
18412
18917
  const offsetMs = target - seg.startMs;
@@ -18415,7 +18920,7 @@ var RecordedFeeder = class {
18415
18920
  const au = seg.aus[i];
18416
18921
  if (!au) continue;
18417
18922
  if (au.ptsMs - first.ptsMs > offsetMs) break;
18418
- if (au.keyframe) idx = i;
18923
+ if (!keyframesOnly || au.keyframe) idx = i;
18419
18924
  }
18420
18925
  return idx;
18421
18926
  }
@@ -18486,7 +18991,7 @@ var RecordedFeeder = class {
18486
18991
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18487
18992
  if (loc.kind !== "segment") return;
18488
18993
  if (this.scrubCache.has(loc.startMs)) return;
18489
- const loaded = await this.loadAus(loc.startMs, false, true);
18994
+ const loaded = await this.loadAus(loc.startMs, false);
18490
18995
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18491
18996
  this.scrubCachePut({
18492
18997
  startMs: loc.startMs,
@@ -18836,10 +19341,8 @@ var RecordedFeeder = class {
18836
19341
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
18837
19342
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
18838
19343
  * 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) {
19344
+ * (paused scrubs would throw it away). */
19345
+ async loadAus(startMs, wantAudio = true) {
18843
19346
  const token = this.seekToken;
18844
19347
  const bytes = await this.withTimeout(this.deps.readBytes({
18845
19348
  deviceId: this.deps.deviceId,
@@ -18847,8 +19350,7 @@ var RecordedFeeder = class {
18847
19350
  startMs
18848
19351
  }));
18849
19352
  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));
19353
+ const video = await this.withTimeout(this.deps.demux(bytes));
18852
19354
  if (this.disposed || token !== this.seekToken) return null;
18853
19355
  let audio = [];
18854
19356
  if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
@@ -18894,6 +19396,11 @@ var RecordedFeeder = class {
18894
19396
  }
18895
19397
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
18896
19398
  dispose() {
19399
+ if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
19400
+ jumps: this.scrubJumps,
19401
+ sweeps: this.scrubSweeps,
19402
+ aus: this.scrubAusPushed
19403
+ });
18897
19404
  this.disposed = true;
18898
19405
  this.seekToken++;
18899
19406
  this.advanceToken++;
@@ -18903,6 +19410,12 @@ var RecordedFeeder = class {
18903
19410
  this.scrubCache.clear();
18904
19411
  this.scrubPrevTarget = null;
18905
19412
  this.scrubDirRun = 0;
19413
+ this.scrubCursor = null;
19414
+ this.scrubJumps = 0;
19415
+ this.scrubSweeps = 0;
19416
+ this.scrubAusPushed = 0;
19417
+ this.scrubNoFootageLogged = false;
19418
+ this.scrubOutstanding = [];
18906
19419
  if (this.scrubTimer) {
18907
19420
  clearTimeout(this.scrubTimer);
18908
19421
  this.scrubTimer = null;
@@ -18930,6 +19443,16 @@ var TimelineSession = class {
18930
19443
  this.deps = deps;
18931
19444
  deps.session.setLiveFeedGate(false);
18932
19445
  }
19446
+ /**
19447
+ * Which source the track is currently carrying. Read by the broker's
19448
+ * adaptive ladder, which must NOT act while the pointer is off live: a tier
19449
+ * switch is a close-and-recreate re-offer, and that disposes this
19450
+ * coordinator — the viewer silently lands back on live at the wrong moment
19451
+ * with no code path to restore the pointer.
19452
+ */
19453
+ get currentMode() {
19454
+ return this.mode;
19455
+ }
18933
19456
  async handleControl(msg) {
18934
19457
  switch (msg.t) {
18935
19458
  case "playRecorded":
@@ -18946,6 +19469,9 @@ var TimelineSession = class {
18946
19469
  case "scrubTo":
18947
19470
  if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
18948
19471
  break;
19472
+ case "scrubAck":
19473
+ if (this.mode === "recorded") this.feeder?.scrubAck();
19474
+ break;
18949
19475
  case "setRate":
18950
19476
  this.feeder?.setRate(msg.rate);
18951
19477
  break;
@@ -18953,6 +19479,7 @@ var TimelineSession = class {
18953
19479
  if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
18954
19480
  break;
18955
19481
  case "setProfile":
19482
+ if (msg.profile === this.profile) break;
18956
19483
  this.profile = msg.profile;
18957
19484
  if (this.mode === "recorded") this.enterRecorded();
18958
19485
  break;
@@ -25670,25 +26197,6 @@ async function extractSamples(segment, segmentStartMs) {
25670
26197
  samples: collected
25671
26198
  } : null;
25672
26199
  }
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
26200
  //#endregion
25693
26201
  //#region src/stream-broker/recorded/avcc-annexb.ts
25694
26202
  /**
@@ -26192,33 +26700,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
26192
26700
  "pipe:1"
26193
26701
  ];
26194
26702
  /**
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
26703
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
26223
26704
  * access units.
26224
26705
  *
@@ -26244,113 +26725,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26244
26725
  codec
26245
26726
  }));
26246
26727
  }
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
26728
  //#endregion
26284
26729
  //#region src/stream-broker/recorded/segment-demux-js.ts
26285
26730
  /**
26286
26731
  * segment-demux-js — PURE-JS in-process demux for recorded playback.
26287
26732
  *
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`.
26733
+ * {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26734
+ * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26735
+ * Both playback AND scrub demux through this since sweep mode (the scrub train
26736
+ * pushes predicted frames, so the whole GOP is needed either way).
26293
26737
  *
26294
- * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
26295
- * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
26738
+ * Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
26739
+ * in-process mp4box parse. A recorded `.m4s`
26296
26740
  * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
26297
26741
  * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
26298
26742
  * sample(s) are all in the bytes we already hold — no init segment to fetch.
26299
26743
  *
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.
26744
+ * Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
26745
+ * module already ships — box parsing is NOT duplicated here) yields the codec
26746
+ * `description` + length-prefixed samples; sync samples are fused with their
26747
+ * parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
26748
+ * ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
26749
+ * the first sample), exactly like the ffmpeg demux's PES-derived deltas
26750
+ * `clampIntoSegment`/position reporting are unaffected.
26313
26751
  */
26314
26752
  /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
26315
26753
  function isHevcCodec(codec) {
26316
26754
  return /^h[ev][vc]1\./i.test(codec);
26317
26755
  }
26318
26756
  /**
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
26757
  * Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
26355
26758
  * EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
26356
26759
  * `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
@@ -26659,7 +27062,8 @@ var ADAPTIVE_CONFIG = {
26659
27062
  minFailPercent: .05,
26660
27063
  waitAfterResetMs: 8e3,
26661
27064
  upgradeHealthyMs: 6e4,
26662
- minTimeBetweenChangesMs: 6e4
27065
+ minTimeBetweenChangesMs: 6e4,
27066
+ maxUpgradeBackoffMs: 9e5
26663
27067
  };
26664
27068
  /**
26665
27069
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -26845,6 +27249,24 @@ var BrokerWebrtcServer = class {
26845
27249
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
26846
27250
  brokers = /* @__PURE__ */ new Map();
26847
27251
  /**
27252
+ * Post-session warmth: when the LAST WebRTC session on a broker closes, a
27253
+ * ghost subscription keeps the source dialed and its RTP ring warm for the
27254
+ * linger window, so the dominant remote pattern — reopen within minutes —
27255
+ * reconnects with an instant-start GOP replay instead of re-paying the
27256
+ * cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
27257
+ * attribution enum; the label answers "who keeps cam X warm?" in the
27258
+ * operator widget.
27259
+ */
27260
+ brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
27261
+ const broker = this.brokers.get(brokerId);
27262
+ if (!broker) return null;
27263
+ return broker.onEncodedData(() => {}, {
27264
+ kind: "warmup",
27265
+ label: "webrtc-linger",
27266
+ media: "both"
27267
+ });
27268
+ } });
27269
+ /**
26848
27270
  * Resolves a brokerId to its current profile tier (`CamProfile`)
26849
27271
  * via the manager's `assignments` map, or `null` for brokers that
26850
27272
  * are only kept alive by a manual activation. Injected by the
@@ -27007,16 +27429,20 @@ var BrokerWebrtcServer = class {
27007
27429
  * RTC controller / Alexa handler closes the session the same way.
27008
27430
  */
27009
27431
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
27432
+ const offerStart = Date.now();
27010
27433
  const setup = await this.setupSessionForBroker(streamId, hints, {
27011
27434
  ...opts,
27012
27435
  clientOfferSdp
27013
27436
  });
27437
+ const setupMs = Date.now() - offerStart;
27014
27438
  try {
27015
27439
  if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
27440
+ const negotiateStart = Date.now();
27016
27441
  const answer = await setup.session.handleOffer({
27017
27442
  sdp: clientOfferSdp,
27018
27443
  type: "offer"
27019
27444
  }, { nonTrickle: opts.nonTrickle === true });
27445
+ const negotiateMs = Date.now() - negotiateStart;
27020
27446
  setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
27021
27447
  sessionId: setup.sessionId,
27022
27448
  deviceId: setup.deviceId,
@@ -27024,7 +27450,11 @@ var BrokerWebrtcServer = class {
27024
27450
  iceServers: setup.iceServerCount,
27025
27451
  codec: setup.sessionCodec,
27026
27452
  useRtpRepacketizer: setup.useRtpRepacketizer,
27027
- relayOnly: opts.relayOnly === true
27453
+ relayOnly: opts.relayOnly === true,
27454
+ setupMs,
27455
+ negotiateMs,
27456
+ totalMs: Date.now() - offerStart,
27457
+ ...setup.setupTimings
27028
27458
  } });
27029
27459
  return {
27030
27460
  sessionId: setup.sessionId,
@@ -27051,6 +27481,11 @@ var BrokerWebrtcServer = class {
27051
27481
  */
27052
27482
  async setupSessionForBroker(streamId, hints, opts) {
27053
27483
  if (this.stopped) throw new Error("Server stopped");
27484
+ const setupStart = Date.now();
27485
+ let ensureTierMs = 0;
27486
+ let warmUpMs = 0;
27487
+ let iceResolveMs = 0;
27488
+ let hwAccelMs = 0;
27054
27489
  const isAdaptiveStream = streamId.endsWith("/adaptive");
27055
27490
  const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
27056
27491
  const resolveHints = intent !== void 0 ? {
@@ -27060,10 +27495,13 @@ var BrokerWebrtcServer = class {
27060
27495
  if (isAdaptiveStream) {
27061
27496
  const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
27062
27497
  const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
27498
+ const ensureTierStart = Date.now();
27063
27499
  try {
27064
27500
  await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
27065
27501
  } catch (err) {
27066
27502
  throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${require_dist.errMsg(err)}`);
27503
+ } finally {
27504
+ ensureTierMs = Date.now() - ensureTierStart;
27067
27505
  }
27068
27506
  }
27069
27507
  const brokerId = this.resolveBrokerId(streamId, resolveHints);
@@ -27085,6 +27523,7 @@ var BrokerWebrtcServer = class {
27085
27523
  kind: "warmup",
27086
27524
  media: "both"
27087
27525
  });
27526
+ const warmUpStart = Date.now();
27088
27527
  await new Promise((resolve) => {
27089
27528
  const timer = setTimeout(() => {
27090
27529
  sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
@@ -27100,9 +27539,11 @@ var BrokerWebrtcServer = class {
27100
27539
  resolve();
27101
27540
  });
27102
27541
  });
27542
+ warmUpMs = Date.now() - warmUpStart;
27103
27543
  sessionLogger.info("broker warm-up complete", { meta: {
27104
27544
  brokerId,
27105
- detectedCodec: broker.getStats().codec ?? null
27545
+ detectedCodec: broker.getStats().codec ?? null,
27546
+ waitedMs: warmUpMs
27106
27547
  } });
27107
27548
  }
27108
27549
  const brokerCodec = broker.getStats().codec ?? "h264";
@@ -27286,6 +27727,7 @@ var BrokerWebrtcServer = class {
27286
27727
  }
27287
27728
  }, annexbAttribution);
27288
27729
  warmUpUnsubscribe?.();
27730
+ this.brokerLinger.cancel(brokerId);
27289
27731
  const preBuffer = broker.getPreBuffer();
27290
27732
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
27291
27733
  for (const pkt of preBuffer) if (pkt.type === "video") {
@@ -27312,7 +27754,9 @@ var BrokerWebrtcServer = class {
27312
27754
  }
27313
27755
  });
27314
27756
  }
27757
+ const iceResolveStart = Date.now();
27315
27758
  const allIceServers = await this.resolveIceServers();
27759
+ iceResolveMs = Date.now() - iceResolveStart;
27316
27760
  const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
27317
27761
  const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
27318
27762
  if (stunOnly.length === 0) return null;
@@ -27337,13 +27781,16 @@ var BrokerWebrtcServer = class {
27337
27781
  sourceProfileLevelId,
27338
27782
  offeredProfileLevelIds
27339
27783
  } });
27784
+ const hwAccelStart = Date.now();
27785
+ const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
27786
+ hwAccelMs = Date.now() - hwAccelStart;
27340
27787
  session = new AdaptiveSession({
27341
27788
  sessionId,
27342
27789
  source,
27343
27790
  sourceCodec: sessionCodec,
27344
27791
  transcodeToBaseline,
27345
27792
  transcodeProfile: effectiveTranscodeProfile,
27346
- transcodeDecodeHwAccel: await this.getTranscodeDecodeHwAccel(),
27793
+ transcodeDecodeHwAccel,
27347
27794
  resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
27348
27795
  iceConfig: {
27349
27796
  iceServers,
@@ -27455,7 +27902,6 @@ var BrokerWebrtcServer = class {
27455
27902
  const locateRecorded = this.locateRecordedSegment;
27456
27903
  const readRecorded = this.readRecordedSegmentBytes;
27457
27904
  const resolveFfmpeg = this.resolveFfmpegPath;
27458
- let scrubJsFallbackLogged = false;
27459
27905
  let fullJsFallbackLogged = false;
27460
27906
  let audioOpusFallbackLogged = false;
27461
27907
  if (locateRecorded && readRecorded) {
@@ -27483,19 +27929,6 @@ var BrokerWebrtcServer = class {
27483
27929
  } });
27484
27930
  }
27485
27931
  }),
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
27932
  demuxAudio: opusSession ? async (b) => {
27500
27933
  return (await demuxSegmentAudioOpusPreferJs(b, {
27501
27934
  ffmpegPath: resolveFfmpeg?.(),
@@ -27531,10 +27964,17 @@ var BrokerWebrtcServer = class {
27531
27964
  } });
27532
27965
  timeline._emitState(s);
27533
27966
  },
27534
- onPosition: (epochMs) => timeline._emitPosition(epochMs)
27967
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
27968
+ onLog: (msg, data) => sessionLogger.info(msg, { meta: {
27969
+ sessionId,
27970
+ deviceId,
27971
+ profile,
27972
+ ...data
27973
+ } })
27535
27974
  });
27536
27975
  }
27537
27976
  });
27977
+ entry.timeline = timeline;
27538
27978
  sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
27539
27979
  sessionId,
27540
27980
  deviceId
@@ -27584,7 +28024,7 @@ var BrokerWebrtcServer = class {
27584
28024
  };
27585
28025
  if (streamId.endsWith("/adaptive")) {
27586
28026
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
27587
- this.startAdaptive(entry, deviceId, initialTier, sessionId);
28027
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
27588
28028
  }
27589
28029
  return {
27590
28030
  sessionId,
@@ -27594,7 +28034,14 @@ var BrokerWebrtcServer = class {
27594
28034
  brokerId,
27595
28035
  sessionCodec,
27596
28036
  useRtpRepacketizer,
27597
- iceServerCount: iceServers.length
28037
+ iceServerCount: iceServers.length,
28038
+ setupTimings: {
28039
+ setupTotalMs: Date.now() - setupStart,
28040
+ ensureTierMs,
28041
+ warmUpMs,
28042
+ iceResolveMs,
28043
+ hwAccelMs
28044
+ }
27598
28045
  };
27599
28046
  }
27600
28047
  async handleAnswer(sessionId, sdpAnswer) {
@@ -27644,6 +28091,7 @@ var BrokerWebrtcServer = class {
27644
28091
  async stop() {
27645
28092
  if (this.stopped) return;
27646
28093
  this.stopped = true;
28094
+ this.brokerLinger.stop();
27647
28095
  const closes = [];
27648
28096
  for (const [sid, entry] of this.sessions) {
27649
28097
  this.cleanupSession(sid);
@@ -27678,9 +28126,10 @@ var BrokerWebrtcServer = class {
27678
28126
  * whichever produces the higher loss ratio across its window. The async
27679
28127
  * poll is guarded against overlapping ticks via `tickInFlight`.
27680
28128
  */
27681
- startAdaptive(entry, deviceId, initialTier, sessionId) {
28129
+ startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
27682
28130
  const controller = new AdaptiveController({
27683
28131
  initialTier,
28132
+ priorFailures,
27684
28133
  config: ADAPTIVE_CONFIG,
27685
28134
  onSwitchTier: (tier) => {
27686
28135
  this.switchSessionTier(entry, deviceId, tier, sessionId);
@@ -27700,6 +28149,10 @@ var BrokerWebrtcServer = class {
27700
28149
  tickInFlight = true;
27701
28150
  (async () => {
27702
28151
  try {
28152
+ if (entry.timeline?.currentMode === "recorded") {
28153
+ controller.suspend(Date.now());
28154
+ return;
28155
+ }
27703
28156
  const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
27704
28157
  const now = Date.now();
27705
28158
  if (pct !== null && pct !== void 0 && pct > 0) {
@@ -27731,9 +28184,11 @@ var BrokerWebrtcServer = class {
27731
28184
  */
27732
28185
  switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
27733
28186
  const prev = this.adaptiveIntent.get(sessionId);
28187
+ const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
27734
28188
  this.adaptiveIntent.set(sessionId, {
27735
28189
  prefersTier: tier,
27736
- forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
28190
+ forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
28191
+ failures
27737
28192
  });
27738
28193
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
27739
28194
  entry.session.pendingRenegotiation = {
@@ -27747,7 +28202,8 @@ var BrokerWebrtcServer = class {
27747
28202
  deviceId,
27748
28203
  tier,
27749
28204
  forceTranscodeDown,
27750
- epoch
28205
+ epoch,
28206
+ failures: Object.fromEntries(failures)
27751
28207
  }
27752
28208
  });
27753
28209
  }
@@ -27755,6 +28211,12 @@ var BrokerWebrtcServer = class {
27755
28211
  const entry = this.sessions.get(sessionId);
27756
28212
  if (!entry) return;
27757
28213
  this.sessions.delete(sessionId);
28214
+ let brokerStillServed = false;
28215
+ for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
28216
+ brokerStillServed = true;
28217
+ break;
28218
+ }
28219
+ if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
27758
28220
  this.adaptiveIntent.delete(sessionId);
27759
28221
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
27760
28222
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -28338,6 +28800,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
28338
28800
  }
28339
28801
  };
28340
28802
  //#endregion
28803
+ //#region src/stream-broker/event-loop-stall-monitor.ts
28804
+ var DEFAULT_INTERVAL_MS = 100;
28805
+ var DEFAULT_THRESHOLD_MS = 250;
28806
+ var EventLoopStallMonitor = class {
28807
+ intervalMs;
28808
+ thresholdMs;
28809
+ now;
28810
+ onStall;
28811
+ timer = null;
28812
+ lastTickAt = 0;
28813
+ constructor(options) {
28814
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
28815
+ this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
28816
+ this.now = options.now ?? (() => Date.now());
28817
+ this.onStall = options.onStall;
28818
+ }
28819
+ start() {
28820
+ if (this.timer) return;
28821
+ this.lastTickAt = this.now();
28822
+ this.timer = setInterval(() => {
28823
+ const at = this.now();
28824
+ const lateMs = at - this.lastTickAt - this.intervalMs;
28825
+ this.lastTickAt = at;
28826
+ if (lateMs >= this.thresholdMs) try {
28827
+ this.onStall(lateMs);
28828
+ } catch {}
28829
+ }, this.intervalMs);
28830
+ this.timer.unref?.();
28831
+ }
28832
+ stop() {
28833
+ if (!this.timer) return;
28834
+ clearInterval(this.timer);
28835
+ this.timer = null;
28836
+ }
28837
+ };
28838
+ //#endregion
28341
28839
  //#region src/stream-broker/addon.ts
28342
28840
  /**
28343
28841
  * Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
@@ -28430,6 +28928,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28430
28928
  webrtcServer = null;
28431
28929
  /** Handle for the embed SPA data-plane listener (disposed on shutdown). */
28432
28930
  embedDataPlane = null;
28931
+ /** Names this process's event-loop stalls — see onInitialize. */
28932
+ loopStallMonitor = null;
28433
28933
  /**
28434
28934
  * Snapshot-equality cache for the broker-metrics emit. Each
28435
28935
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -28530,6 +29030,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28530
29030
  }
28531
29031
  }
28532
29032
  async onInitialize() {
29033
+ this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
29034
+ this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
29035
+ } });
29036
+ this.loopStallMonitor.start();
28533
29037
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
28534
29038
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
28535
29039
  this.brokerManager.setEventBus(this.ctx.eventBus);
@@ -28872,6 +29376,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
28872
29376
  ];
28873
29377
  }
28874
29378
  async onShutdown() {
29379
+ this.loopStallMonitor?.stop();
29380
+ this.loopStallMonitor = null;
28875
29381
  this.flushProbeSnapshots(Date.now(), true);
28876
29382
  if (this.metricsSnapshotTimer) {
28877
29383
  clearInterval(this.metricsSnapshotTimer);