@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
@@ -1,12 +1,12 @@
1
1
  import { n as __require, r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { B as errMsg, G as createEvent, H as CAM_PROFILE_ORDER, J as makeProfileBrokerId, L as streamBrokerCapability, Q as parseProfileBrokerId, U as DeviceFeature, V as BaseAddon, W as DeviceType, X as nodePin, Y as makeSourceBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, k as maskUrlCredentials, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, q as isEvent, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability, z as webrtcSessionCapability } from "../dist-Du7_SNiR.mjs";
2
+ import { B as errMsg, G as createEvent, H as CAM_PROFILE_ORDER, J as makeProfileBrokerId, L as streamBrokerCapability, Q as parseProfileBrokerId, U as DeviceFeature, V as BaseAddon, W as DeviceType, X as nodePin, Y as makeSourceBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, k as maskUrlCredentials, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, q as isEvent, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability, z as webrtcSessionCapability } from "../dist-COgGdFMv.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
4
  import { n as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
5
5
  import { createRequire } from "node:module";
6
- import * as os from "node:os";
7
- import { networkInterfaces, tmpdir } from "node:os";
8
6
  import { spawn } from "node:child_process";
9
7
  import { pathToFileURL } from "node:url";
8
+ import * as os from "node:os";
9
+ import { networkInterfaces, tmpdir } from "node:os";
10
10
  import * as fs from "node:fs";
11
11
  import * as path$1 from "node:path";
12
12
  import path, { join } from "node:path";
@@ -3030,6 +3030,22 @@ var AdaptiveSession = class AdaptiveSession {
3030
3030
  /** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
3031
3031
  rtpBootstrap;
3032
3032
  rtpBootstrapDone = false;
3033
+ /**
3034
+ * Answered-but-silent guard. The answer deliberately returns WITHOUT
3035
+ * awaiting source bring-up (bare trickle answer, background dial); the
3036
+ * failure shape that buys is a connected client staring at a black player
3037
+ * because the source never delivered. `sourceMediaSeen` flips on the FIRST
3038
+ * evidence of source media reaching this session — a source RTP packet
3039
+ * (`forwardSourceRtpVideo`, even before DTLS lets it transmit) or a video
3040
+ * NAL write (`writeVideoNals`, the AnnexB + transcode egress) — and the
3041
+ * watchdog armed at feed start warns ONCE if the budget elapses first.
3042
+ */
3043
+ sourceMediaSeen = false;
3044
+ sourceSilenceTimer = null;
3045
+ /** Budget between "feeding started" (post-answer, post-DTLS) and the first
3046
+ * source media. Chosen above a healthy cold dial's worst case (~2–3 s) and
3047
+ * below the point a viewer gives up. */
3048
+ static SOURCE_SILENT_AFTER_ANSWER_MS = 5e3;
3033
3049
  /** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
3034
3050
  * recovery). While active, live source-RTP packets are enqueued behind
3035
3051
  * the replay tail to preserve packet order. Aborted on close/rebind. */
@@ -3495,8 +3511,10 @@ var AdaptiveSession = class AdaptiveSession {
3495
3511
  * compatible answer. This avoids werift transceiver direction issues.
3496
3512
  */
3497
3513
  async handleOffer(clientOffer, opts) {
3514
+ const negotiateStart = Date.now();
3498
3515
  const { werift, pcOptions } = await this.buildPcOptions();
3499
3516
  this.pc = new werift.RTCPeerConnection(pcOptions);
3517
+ const pcSetupMs = Date.now() - negotiateStart;
3500
3518
  this.wireControlChannel();
3501
3519
  this.pc.iceConnectionStateChange.subscribe((state) => {
3502
3520
  this.logger.info("ICE state", { meta: {
@@ -3529,7 +3547,9 @@ var AdaptiveSession = class AdaptiveSession {
3529
3547
  sdpMLineIndex: c.sdpMLineIndex ?? null
3530
3548
  });
3531
3549
  });
3550
+ const mdnsStart = Date.now();
3532
3551
  const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
3552
+ const mdnsMs = Date.now() - mdnsStart;
3533
3553
  const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
3534
3554
  if (this.debug) this.logger.info("client offer REMOTE ICE candidates", { meta: {
3535
3555
  phase: "session",
@@ -3540,7 +3560,9 @@ var AdaptiveSession = class AdaptiveSession {
3540
3560
  candidates: offerCandidates
3541
3561
  } });
3542
3562
  const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
3563
+ const setRemoteStart = Date.now();
3543
3564
  await this.pc.setRemoteDescription(remoteDesc);
3565
+ const setRemoteMs = Date.now() - setRemoteStart;
3544
3566
  const transceivers = this.pc.getTransceivers();
3545
3567
  for (const t of transceivers) {
3546
3568
  const kind = t.receiver?.track?.kind ?? t.kind;
@@ -3589,7 +3611,9 @@ var AdaptiveSession = class AdaptiveSession {
3589
3611
  const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
3590
3612
  this.audioSender = audioTransceiver.sender;
3591
3613
  }
3614
+ const createAnswerStart = Date.now();
3592
3615
  const answerDesc = await this.pc.createAnswer();
3616
+ const createAnswerMs = Date.now() - createAnswerStart;
3593
3617
  let finalSdp;
3594
3618
  if (opts?.nonTrickle === true) {
3595
3619
  await this.pc.setLocalDescription(answerDesc).catch((e) => {
@@ -3617,9 +3641,15 @@ var AdaptiveSession = class AdaptiveSession {
3617
3641
  this.logger.info("Codec negotiated (client-offer)", { meta: {
3618
3642
  phase: "session",
3619
3643
  sessionId: this.sessionId,
3644
+ deviceId: this.deviceId,
3620
3645
  source: this.sourceCodec,
3621
3646
  negotiated: this.negotiatedCodec,
3622
- needsTranscode: this.needsTranscode
3647
+ needsTranscode: this.needsTranscode,
3648
+ negotiateMs: Date.now() - negotiateStart,
3649
+ pcSetupMs,
3650
+ mdnsMs,
3651
+ setRemoteMs,
3652
+ createAnswerMs
3623
3653
  } });
3624
3654
  if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
3625
3655
  phase: "session",
@@ -3769,6 +3799,10 @@ var AdaptiveSession = class AdaptiveSession {
3769
3799
  clearInterval(this.statsTimer);
3770
3800
  this.statsTimer = null;
3771
3801
  }
3802
+ if (this.sourceSilenceTimer) {
3803
+ clearTimeout(this.sourceSilenceTimer);
3804
+ this.sourceSilenceTimer = null;
3805
+ }
3772
3806
  if (this.feedAbort) {
3773
3807
  this.feedAbort.abort();
3774
3808
  this.feedAbort = null;
@@ -3866,6 +3900,7 @@ var AdaptiveSession = class AdaptiveSession {
3866
3900
  */
3867
3901
  forwardSourceRtpVideo(rtpData) {
3868
3902
  if (this.closed) return;
3903
+ this.sourceMediaSeen = true;
3869
3904
  const codec = this.negotiatedCodec;
3870
3905
  if (codec !== "H265" && codec !== "H264") return;
3871
3906
  if (!this.videoSender || !_werift) return;
@@ -3877,6 +3912,12 @@ var AdaptiveSession = class AdaptiveSession {
3877
3912
  this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
3878
3913
  return;
3879
3914
  }
3915
+ this.logger.warn("instant-start pre-buffer empty — viewer waits for the next source IDR", { meta: {
3916
+ phase: "session",
3917
+ sessionId: this.sessionId,
3918
+ deviceId: this.deviceId,
3919
+ codec
3920
+ } });
3880
3921
  }
3881
3922
  if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
3882
3923
  this.sendSourceRtpPacket(rtpData, codec);
@@ -4069,6 +4110,27 @@ var AdaptiveSession = class AdaptiveSession {
4069
4110
  } });
4070
4111
  this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
4071
4112
  }
4113
+ /**
4114
+ * Arm the answered-but-silent watchdog: if no source media has reached
4115
+ * this session `SOURCE_SILENT_AFTER_ANSWER_MS` after feeding started, one
4116
+ * named WARN fires. Idempotent; disarmed by `close()`. Public so the guard
4117
+ * is testable without standing up werift.
4118
+ */
4119
+ armSourceSilenceWatchdog() {
4120
+ if (this.sourceSilenceTimer || this.closed) return;
4121
+ this.sourceSilenceTimer = setTimeout(() => {
4122
+ this.sourceSilenceTimer = null;
4123
+ if (this.closed || this.sourceMediaSeen) return;
4124
+ this.logger.warn("answered but NO source media reached the session — source dial stalled or camera silent", { meta: {
4125
+ phase: "session",
4126
+ sessionId: this.sessionId,
4127
+ deviceId: this.deviceId,
4128
+ budgetMs: AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS,
4129
+ needsTranscode: this.needsTranscode
4130
+ } });
4131
+ }, AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS);
4132
+ this.sourceSilenceTimer.unref?.();
4133
+ }
4072
4134
  startFeedingFrames() {
4073
4135
  this.feedAbort = new AbortController();
4074
4136
  const { signal } = this.feedAbort;
@@ -4078,6 +4140,7 @@ var AdaptiveSession = class AdaptiveSession {
4078
4140
  dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
4079
4141
  needsTranscode: this.needsTranscode
4080
4142
  } });
4143
+ this.armSourceSilenceWatchdog();
4081
4144
  if (this.needsTranscode) this.startTranscodeFeed(signal);
4082
4145
  else this.startDirectFeed(signal);
4083
4146
  }
@@ -4653,6 +4716,7 @@ var AdaptiveSession = class AdaptiveSession {
4653
4716
  });
4654
4717
  }
4655
4718
  writeVideoNals(nals, rtpTs, codec) {
4719
+ this.sourceMediaSeen = true;
4656
4720
  if (!this.videoSender || !_werift) {
4657
4721
  if (this.rtpLogCounter === 0) {
4658
4722
  this.logger.warn("writeVideoNals: no sender", { meta: {
@@ -9730,6 +9794,21 @@ var DecoderSessionProxy = class {
9730
9794
  this.api = api;
9731
9795
  this.sessionId = sessionId;
9732
9796
  }
9797
+ /**
9798
+ * Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
9799
+ * regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
9800
+ * `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
9801
+ * is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
9802
+ * peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
9803
+ * stub, an old decoder that predates `waitMs`, a test fake — would otherwise
9804
+ * turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
9805
+ * that pins the CPU, starves every timer in the process, and allocates until
9806
+ * the heap dies (observed: a 4 GB OOM in under 15 s).
9807
+ */
9808
+ async paceEmptyDrain(drainStartedAt) {
9809
+ const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
9810
+ if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
9811
+ }
9733
9812
  async pushPacket(packet) {
9734
9813
  await this.api.pushPacket({
9735
9814
  sessionId: this.sessionId,
@@ -9745,12 +9824,14 @@ var DecoderSessionProxy = class {
9745
9824
  async startPolling(onFrame) {
9746
9825
  this.polling = true;
9747
9826
  while (this.polling) {
9827
+ const drainStartedAt = Date.now();
9748
9828
  const frames = await this.api.pullFrames({
9749
9829
  sessionId: this.sessionId,
9750
9830
  maxCount: 4,
9751
9831
  waitMs: POLL_WAIT_MS
9752
9832
  });
9753
9833
  for (const frame of frames) onFrame(frame);
9834
+ if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9754
9835
  }
9755
9836
  }
9756
9837
  /**
@@ -9768,12 +9849,14 @@ var DecoderSessionProxy = class {
9768
9849
  async startHandlePolling(onHandle) {
9769
9850
  this.polling = true;
9770
9851
  while (this.polling) try {
9852
+ const drainStartedAt = Date.now();
9771
9853
  const handles = await this.api.pullHandles({
9772
9854
  sessionId: this.sessionId,
9773
9855
  maxCount: 4,
9774
9856
  waitMs: POLL_WAIT_MS
9775
9857
  });
9776
9858
  for (const handle of handles) onHandle(handle);
9859
+ if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9777
9860
  } catch (err) {
9778
9861
  this.polling = false;
9779
9862
  const msg = err instanceof Error ? err.message : String(err);
@@ -17779,6 +17862,71 @@ function withSourceResolution(consumerDefault, probedSource) {
17779
17862
  };
17780
17863
  }
17781
17864
  //#endregion
17865
+ //#region src/stream-broker/webrtc/broker-linger-registry.ts
17866
+ /** Matches the observed reopen gaps (1–2 min) with headroom; well under the
17867
+ * "operator walked away" scale where warmth would be waste. */
17868
+ var DEFAULT_LINGER_MS = 3 * 6e4;
17869
+ var BrokerLingerRegistry = class {
17870
+ lingerMs;
17871
+ subscribe;
17872
+ holds = /* @__PURE__ */ new Map();
17873
+ constructor(options) {
17874
+ this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
17875
+ this.subscribe = options.subscribe;
17876
+ }
17877
+ /**
17878
+ * Start (or restart) the linger window for a broker that just lost its
17879
+ * last session. Re-holding an already-held broker re-arms the timer
17880
+ * without stacking a second ghost subscription.
17881
+ */
17882
+ hold(brokerId) {
17883
+ const existing = this.holds.get(brokerId);
17884
+ if (existing) {
17885
+ clearTimeout(existing.timer);
17886
+ existing.timer = this.armTimer(brokerId);
17887
+ return;
17888
+ }
17889
+ const unsubscribe = this.subscribe(brokerId);
17890
+ if (unsubscribe === null) return;
17891
+ this.holds.set(brokerId, {
17892
+ unsubscribe,
17893
+ timer: this.armTimer(brokerId)
17894
+ });
17895
+ }
17896
+ /** Release the hold NOW — a new session attached, the real subscription
17897
+ * carries the demand from here. No-op when nothing is held. */
17898
+ cancel(brokerId) {
17899
+ const hold = this.holds.get(brokerId);
17900
+ if (!hold) return;
17901
+ clearTimeout(hold.timer);
17902
+ this.holds.delete(brokerId);
17903
+ this.release(hold);
17904
+ }
17905
+ /** Release every hold (server shutdown). */
17906
+ stop() {
17907
+ for (const [brokerId, hold] of this.holds) {
17908
+ clearTimeout(hold.timer);
17909
+ this.holds.delete(brokerId);
17910
+ this.release(hold);
17911
+ }
17912
+ }
17913
+ armTimer(brokerId) {
17914
+ const timer = setTimeout(() => {
17915
+ const hold = this.holds.get(brokerId);
17916
+ if (!hold) return;
17917
+ this.holds.delete(brokerId);
17918
+ this.release(hold);
17919
+ }, this.lingerMs);
17920
+ timer.unref?.();
17921
+ return timer;
17922
+ }
17923
+ release(hold) {
17924
+ try {
17925
+ hold.unsubscribe();
17926
+ } catch {}
17927
+ }
17928
+ };
17929
+ //#endregion
17782
17930
  //#region src/stream-broker/webrtc/loss-tracker.ts
17783
17931
  /** Delta between two RTCP extended (32-bit) highest-sequence values, with
17784
17932
  * 32-bit wraparound handling. */
@@ -17827,6 +17975,24 @@ var LossTracker = class {
17827
17975
  if (now < this.resetUntil) return false;
17828
17976
  return this.lossRatio() > this.config.minFailPercent;
17829
17977
  }
17978
+ /**
17979
+ * True when the window holds enough recent receiver feedback for a
17980
+ * "healthy" verdict to MEAN anything.
17981
+ *
17982
+ * `lossRatio()` returns 0 with fewer than two in-window samples, so
17983
+ * `isFailing` is false for a session that has produced NO feedback at all —
17984
+ * a link so bad that RTCP never arrives reads identically to a perfect one.
17985
+ * A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
17986
+ * UPGRADE must not, because it spends bandwidth the session has never been
17987
+ * shown to have. Silence is not health.
17988
+ */
17989
+ hasEvidence(now) {
17990
+ if (now < this.resetUntil) return false;
17991
+ const cutoff = now - this.config.analyzeWindowMs;
17992
+ let inWindow = 0;
17993
+ for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
17994
+ return inWindow >= 2;
17995
+ }
17830
17996
  };
17831
17997
  //#endregion
17832
17998
  //#region src/stream-broker/webrtc/adaptive-controller.ts
@@ -17835,8 +18001,17 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
17835
18001
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
17836
18002
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
17837
18003
  * Downgrade order: tier high→mid→low, then MTU reduction at low, then
17838
- * transcode-down. Upgrade one tier after sustained health + hysteresis.
17839
- * Side effects run through injected callbacks (testable without werift).
18004
+ * transcode-down. Side effects run through injected callbacks (testable
18005
+ * without werift).
18006
+ *
18007
+ * An UPGRADE requires all three of:
18008
+ * 1. the session clock — measured from the FIRST tick, not from the unix
18009
+ * epoch — showing `upgradeHealthyMs` without a failing verdict;
18010
+ * 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
18011
+ * a session that has produced no RTCP at all is not "healthy", it is
18012
+ * silent, and climbing on silence spends bandwidth never shown to exist;
18013
+ * 3. the target tier's re-attempt backoff, which doubles per measured
18014
+ * failure of that tier in this session.
17840
18015
  */
17841
18016
  var AdaptiveController = class {
17842
18017
  opts;
@@ -17846,27 +18021,86 @@ var AdaptiveController = class {
17846
18021
  transcodeEngaged = false;
17847
18022
  lastChangeAt = 0;
17848
18023
  healthySince = 0;
18024
+ /**
18025
+ * Whether the session clock has been armed by the first evaluation. The
18026
+ * clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
18027
+ * would read as "healthy since 1970" and hand out a free upgrade on the
18028
+ * very first tick, before a single loss sample had been seen. A boolean
18029
+ * (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
18030
+ */
18031
+ armed = false;
18032
+ failures;
17849
18033
  constructor(opts) {
17850
18034
  this.opts = opts;
17851
18035
  this.tracker = new LossTracker(opts.config);
17852
18036
  this.tier = opts.initialTier;
18037
+ this.failures = new Map(opts.priorFailures ?? []);
17853
18038
  }
17854
18039
  get currentTier() {
17855
18040
  return this.tier;
17856
18041
  }
18042
+ /**
18043
+ * Per-tier measured-failure counts, INCLUDING those inherited from earlier
18044
+ * rungs of the same viewer session. The server snapshots this into the
18045
+ * adaptive intent so the session's next controller inherits it.
18046
+ */
18047
+ get failureCounts() {
18048
+ return this.failures;
18049
+ }
17857
18050
  onLoss(sample, now) {
17858
18051
  this.tracker.track(sample, now);
17859
18052
  }
18053
+ /**
18054
+ * Freeze the ladder for this evaluation instead of ticking it.
18055
+ *
18056
+ * Called while the session's track is NOT carrying the live source — D42
18057
+ * put recorded playback and scrub on the same track with a moving source
18058
+ * pointer, and in those modes the live feed is gated, so receiver feedback
18059
+ * measures the recorded feeder, not the camera tier. Acting on it is
18060
+ * incoherent in both directions: a downgrade cannot shrink a file, an
18061
+ * upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
18062
+ * is a close-and-recreate re-offer) — dropping the viewer back to live at
18063
+ * the wrong moment with nothing to restore the pointer.
18064
+ *
18065
+ * Holds the climb clock so time spent in recorded modes can never
18066
+ * accumulate into an upgrade on the return to live.
18067
+ */
18068
+ suspend(now) {
18069
+ if (!this.armed) {
18070
+ this.armed = true;
18071
+ this.lastChangeAt = now;
18072
+ }
18073
+ this.healthySince = now;
18074
+ }
17860
18075
  tick(now) {
18076
+ if (!this.armed) {
18077
+ this.armed = true;
18078
+ this.healthySince = now;
18079
+ this.lastChangeAt = now;
18080
+ }
17861
18081
  if (this.tracker.isFailing(now)) {
17862
18082
  this.healthySince = now;
17863
18083
  this.handleFailing(now);
17864
18084
  return;
17865
18085
  }
18086
+ if (!this.tracker.hasEvidence(now)) {
18087
+ this.healthySince = now;
18088
+ return;
18089
+ }
17866
18090
  this.handleHealthy(now);
17867
18091
  }
17868
18092
  canUpgrade(now) {
17869
- return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs || this.lastChangeAt === 0;
18093
+ return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
18094
+ }
18095
+ /**
18096
+ * How long the link must read healthy before `tier` may be re-attempted.
18097
+ * Doubles per measured failure of that tier in this session, capped at
18098
+ * `maxUpgradeBackoffMs`.
18099
+ */
18100
+ requiredHealthyMs(tier) {
18101
+ const failures = this.failures.get(tier) ?? 0;
18102
+ if (failures <= 0) return this.opts.config.upgradeHealthyMs;
18103
+ return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
17870
18104
  }
17871
18105
  commitChange(now) {
17872
18106
  this.lastChangeAt = now;
@@ -17875,6 +18109,8 @@ var AdaptiveController = class {
17875
18109
  handleFailing(now) {
17876
18110
  const idx = TIER_ORDER.indexOf(this.tier);
17877
18111
  if (idx < TIER_ORDER.length - 1) {
18112
+ const failed = this.tier;
18113
+ this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
17878
18114
  this.tier = TIER_ORDER[idx + 1];
17879
18115
  this.opts.onSwitchTier(this.tier);
17880
18116
  this.commitChange(now);
@@ -17893,17 +18129,17 @@ var AdaptiveController = class {
17893
18129
  }
17894
18130
  }
17895
18131
  handleHealthy(now) {
17896
- if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
17897
- if (!this.canUpgrade(now)) return;
17898
18132
  const idx = TIER_ORDER.indexOf(this.tier);
17899
- if (idx > 0) {
17900
- this.tier = TIER_ORDER[idx - 1];
17901
- this.mtuReduced = false;
17902
- this.transcodeEngaged = false;
17903
- this.opts.onSwitchTier(this.tier);
17904
- this.commitChange(now);
17905
- this.healthySince = now;
17906
- }
18133
+ if (idx <= 0) return;
18134
+ const next = TIER_ORDER[idx - 1];
18135
+ if (now - this.healthySince < this.requiredHealthyMs(next)) return;
18136
+ if (!this.canUpgrade(now)) return;
18137
+ this.tier = next;
18138
+ this.mtuReduced = false;
18139
+ this.transcodeEngaged = false;
18140
+ this.opts.onSwitchTier(next);
18141
+ this.commitChange(now);
18142
+ this.healthySince = now;
17907
18143
  }
17908
18144
  };
17909
18145
  //#endregion
@@ -17928,6 +18164,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
17928
18164
  t: literal("scrubTo"),
17929
18165
  epoch: number()
17930
18166
  }),
18167
+ object({ t: literal("scrubAck") }),
17931
18168
  object({
17932
18169
  t: literal("setRate"),
17933
18170
  rate: number().min(0).max(4)
@@ -18101,6 +18338,74 @@ var SCRUB_CACHE_MAX = 8;
18101
18338
  * sustained direction, so a jittery/reversing drag never wastes the slot.
18102
18339
  */
18103
18340
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
18341
+ /**
18342
+ * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
18343
+ * that SWEEPS predicted frames instead of jumping. A steady forward drag is
18344
+ * playback with a moving target: once a keyframe decoded, the predicted frames
18345
+ * after it decode in sequence at a fraction of an IDR's bytes — that is the
18346
+ * continuous flow. Beyond this window (a fling), or backwards, a jump to an
18347
+ * independently-decodable keyframe is cheaper than the burst; and because a
18348
+ * thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
18349
+ * congestion, so degradation to jump-only is emergent, and visible per push
18350
+ * (`mode` on every scrub-feeder:pushed line).
18351
+ */
18352
+ var SCRUB_SWEEP_WINDOW_MS = 4e3;
18353
+ /**
18354
+ * Receiver-credit cap for scrub pushes: at most this many keyframes may be
18355
+ * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
18356
+ * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
18357
+ * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
18358
+ * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
18359
+ * Backpressure is the receiver's: a push spends a credit, a rendered frame
18360
+ * restores one, and with zero credit the newest target PARKS on
18361
+ * `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
18362
+ */
18363
+ var SCRUB_CREDIT_CAP = 2;
18364
+ /**
18365
+ * How far a `gap` locate may be SNAPPED onto real footage.
18366
+ *
18367
+ * A snap is a CORRECTION — the playhead landed a moment outside coverage and
18368
+ * the operator plainly means the footage beside it — not a SEARCH for wherever
18369
+ * footage happens to exist. The distinction is not cosmetic, because the index
18370
+ * that answers `locate` cannot tell "there is no footage here" apart from "I
18371
+ * have not walked this far back yet": it is rebuilt by walking the archive on
18372
+ * every recorder start, and that walk takes minutes.
18373
+ *
18374
+ * Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
18375
+ * `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
18376
+ * minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
18377
+ * tick into a segment read against the addon that was busy re-walking, each one
18378
+ * blew the 8 s budget, and both surfaces died. Bounded, all of those answer
18379
+ * `gap` for free — exactly as they did before snapping existed.
18380
+ *
18381
+ * 120 s covers the real cases by a wide margin: an inter-segment crack is
18382
+ * 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
18383
+ * that the server is answering a question the viewer did not ask; the viewer
18384
+ * holds the coverage ranges and can offer "jump to nearest footage" as an
18385
+ * action instead.
18386
+ */
18387
+ var SNAP_MAX_DISTANCE_MS = 12e4;
18388
+ /**
18389
+ * Decide where a `gap` locate should snap: forward to the next footage edge,
18390
+ * else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
18391
+ * also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
18392
+ * {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
18393
+ * cursor — and so rejecting costs NO segment I/O at all.
18394
+ */
18395
+ function resolveSnapTarget(loc, requestedMs) {
18396
+ const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
18397
+ if (snapMs === null) return { kind: "none" };
18398
+ const distanceMs = Math.abs(snapMs - requestedMs);
18399
+ if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
18400
+ kind: "too-far",
18401
+ snapMs,
18402
+ distanceMs
18403
+ };
18404
+ return {
18405
+ kind: "snap",
18406
+ snapMs
18407
+ };
18408
+ }
18104
18409
  var RecordedFeeder = class {
18105
18410
  deps;
18106
18411
  clock = new MonotonicClock();
@@ -18143,6 +18448,16 @@ var RecordedFeeder = class {
18143
18448
  */
18144
18449
  scrubbing = false;
18145
18450
  scrubToken = 0;
18451
+ /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
18452
+ scrubCredit = SCRUB_CREDIT_CAP;
18453
+ /**
18454
+ * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
18455
+ * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
18456
+ * `scrubAck` settles one frame off the head, and the credit that push spent
18457
+ * comes back only when the head reaches zero — so a sweep cannot be refunded
18458
+ * once per frame for the single credit it cost.
18459
+ */
18460
+ scrubOutstanding = [];
18146
18461
  /** The newest un-processed drag target (null when the tick cycle has drained). */
18147
18462
  scrubPendingEpochMs = null;
18148
18463
  /**
@@ -18173,6 +18488,15 @@ var RecordedFeeder = class {
18173
18488
  * sign of `(newTarget − this)` is the drag direction that steers prefetch.
18174
18489
  */
18175
18490
  scrubPrevTarget = null;
18491
+ /** The last AU actually pushed this drag — the sweep-continuity cursor
18492
+ * (which fragment, and how deep into its AU list). Null until a jump lands. */
18493
+ scrubCursor = null;
18494
+ /** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
18495
+ scrubJumps = 0;
18496
+ scrubSweeps = 0;
18497
+ scrubAusPushed = 0;
18498
+ /** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
18499
+ scrubNoFootageLogged = false;
18176
18500
  /**
18177
18501
  * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
18178
18502
  * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
@@ -18210,9 +18534,19 @@ var RecordedFeeder = class {
18210
18534
  async seek(epochMs) {
18211
18535
  const token = ++this.seekToken;
18212
18536
  if (this.scrubbing) {
18537
+ this.deps.onLog?.("scrub-feeder:drag-end", {
18538
+ jumps: this.scrubJumps,
18539
+ sweeps: this.scrubSweeps,
18540
+ aus: this.scrubAusPushed
18541
+ });
18213
18542
  this.scrubbing = false;
18214
18543
  this.scrubToken++;
18215
18544
  this.scrubSegment = null;
18545
+ this.scrubCursor = null;
18546
+ this.scrubJumps = 0;
18547
+ this.scrubSweeps = 0;
18548
+ this.scrubAusPushed = 0;
18549
+ this.scrubOutstanding = [];
18216
18550
  this.scrubCache.clear();
18217
18551
  this.scrubPrevTarget = null;
18218
18552
  this.scrubDirRun = 0;
@@ -18223,29 +18557,67 @@ var RecordedFeeder = class {
18223
18557
  }
18224
18558
  this.advanceToken++;
18225
18559
  this.inFlightSeekEpochMs = epochMs;
18560
+ let snapInfo = null;
18226
18561
  try {
18227
18562
  this.deps.onState("loading");
18228
18563
  if (this.clockHasAdvanced) {
18229
18564
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18230
18565
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18231
18566
  }
18232
- const loc = await this.withTimeout(this.deps.locate({
18567
+ let loc = await this.withTimeout(this.deps.locate({
18233
18568
  deviceId: this.deps.deviceId,
18234
18569
  profile: this.deps.profile,
18235
18570
  epochMs
18236
18571
  }));
18237
18572
  if (this.disposed || token !== this.seekToken) return;
18573
+ let landMs = epochMs;
18238
18574
  if (loc.kind === "gap") {
18239
- this.currentSegment = null;
18240
- this.lastFedVideoIdx = -1;
18241
- this.stopPositionReports();
18242
- this.deps.onState("gap");
18243
- return;
18575
+ const decision = resolveSnapTarget(loc, epochMs);
18576
+ if (decision.kind !== "snap") {
18577
+ if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
18578
+ requestedMs: epochMs,
18579
+ nearestMs: decision.snapMs,
18580
+ distanceMs: decision.distanceMs
18581
+ });
18582
+ else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
18583
+ this.currentSegment = null;
18584
+ this.lastFedVideoIdx = -1;
18585
+ this.stopPositionReports();
18586
+ this.deps.onState("gap");
18587
+ return;
18588
+ }
18589
+ const snapped = await this.withTimeout(this.deps.locate({
18590
+ deviceId: this.deps.deviceId,
18591
+ profile: this.deps.profile,
18592
+ epochMs: decision.snapMs
18593
+ }));
18594
+ if (this.disposed || token !== this.seekToken) return;
18595
+ if (snapped.kind !== "segment") {
18596
+ this.deps.onLog?.("recorded:seek-no-footage", {
18597
+ requestedMs: epochMs,
18598
+ snappedMs: decision.snapMs
18599
+ });
18600
+ this.currentSegment = null;
18601
+ this.lastFedVideoIdx = -1;
18602
+ this.stopPositionReports();
18603
+ this.deps.onState("gap");
18604
+ return;
18605
+ }
18606
+ this.deps.onLog?.("recorded:seek-snapped", {
18607
+ requestedMs: epochMs,
18608
+ snappedMs: decision.snapMs
18609
+ });
18610
+ loc = snapped;
18611
+ landMs = decision.snapMs;
18612
+ snapInfo = {
18613
+ requestedMs: epochMs,
18614
+ snappedMs: decision.snapMs
18615
+ };
18244
18616
  }
18245
18617
  this.cursorMs = loc.startMs;
18246
18618
  const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
18247
18619
  if (this.disposed || token !== this.seekToken || loaded === null) return;
18248
- this.feedPrefix(loc, loaded.video, epochMs);
18620
+ this.feedPrefix(loc, loaded.video, landMs);
18249
18621
  if (this.rate === 0) {
18250
18622
  this.deps.onState("paused");
18251
18623
  this.deps.onPosition(this.cursorMs);
@@ -18265,9 +18637,20 @@ var RecordedFeeder = class {
18265
18637
  },
18266
18638
  resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
18267
18639
  });
18268
- } catch {
18640
+ } catch (err) {
18269
18641
  if (!this.disposed && token === this.seekToken) {
18270
18642
  this.stopPositionReports();
18643
+ if (snapInfo !== null) {
18644
+ this.deps.onLog?.("recorded:seek-snap-load-failed", {
18645
+ requestedMs: snapInfo.requestedMs,
18646
+ snappedMs: snapInfo.snappedMs,
18647
+ error: errMsg(err)
18648
+ });
18649
+ this.currentSegment = null;
18650
+ this.lastFedVideoIdx = -1;
18651
+ this.deps.onState("gap");
18652
+ return;
18653
+ }
18271
18654
  this.deps.onState("error");
18272
18655
  }
18273
18656
  } finally {
@@ -18322,8 +18705,15 @@ var RecordedFeeder = class {
18322
18705
  this.scrubToken++;
18323
18706
  this.scrubPendingEpochMs = null;
18324
18707
  this.scrubSegment = null;
18708
+ this.scrubCredit = SCRUB_CREDIT_CAP;
18325
18709
  this.scrubPrevTarget = null;
18326
18710
  this.scrubDirRun = 0;
18711
+ this.scrubCursor = null;
18712
+ this.scrubJumps = 0;
18713
+ this.scrubSweeps = 0;
18714
+ this.scrubAusPushed = 0;
18715
+ this.scrubNoFootageLogged = false;
18716
+ this.scrubOutstanding = [];
18327
18717
  if (this.scrubTimer) {
18328
18718
  clearTimeout(this.scrubTimer);
18329
18719
  this.scrubTimer = null;
@@ -18343,6 +18733,24 @@ var RecordedFeeder = class {
18343
18733
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18344
18734
  }
18345
18735
  /**
18736
+ * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
18737
+ * credit (capped — over-acking never banks a burst) and, when a target sat
18738
+ * parked on an exhausted budget, resumes the tick. This — not any timer — is
18739
+ * what paces scrub pushes to what the link actually delivers.
18740
+ */
18741
+ scrubAck() {
18742
+ if (this.disposed) return;
18743
+ const head = this.scrubOutstanding[0];
18744
+ if (head === void 0) return;
18745
+ if (head > 1) {
18746
+ this.scrubOutstanding[0] = head - 1;
18747
+ return;
18748
+ }
18749
+ this.scrubOutstanding.shift();
18750
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
18751
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18752
+ }
18753
+ /**
18346
18754
  * One coalesced feeder-scrub push. Captures the newest pending target + the
18347
18755
  * current mode generation, resolves the covering fragment (reusing the memo'd
18348
18756
  * one when the target stays inside it), picks the nearest keyframe ≤ the
@@ -18353,25 +18761,68 @@ var RecordedFeeder = class {
18353
18761
  */
18354
18762
  async scrubTick() {
18355
18763
  const gen = this.scrubToken;
18764
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
18765
+ this.scrubTimer = null;
18766
+ return;
18767
+ }
18768
+ if (this.scrubCredit <= 0) {
18769
+ this.scrubTimer = null;
18770
+ this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
18771
+ return;
18772
+ }
18356
18773
  const target = this.scrubPendingEpochMs;
18357
18774
  this.scrubPendingEpochMs = null;
18358
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
18775
+ if (target === null) {
18359
18776
  this.scrubTimer = null;
18360
18777
  return;
18361
18778
  }
18362
18779
  try {
18363
- const loc = await this.withTimeout(this.deps.locate({
18780
+ let loc = await this.withTimeout(this.deps.locate({
18364
18781
  deviceId: this.deps.deviceId,
18365
18782
  profile: this.deps.profile,
18366
18783
  epochMs: target
18367
18784
  }));
18368
18785
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18369
- if (loc.kind === "gap") return;
18786
+ let effTarget = target;
18787
+ if (loc.kind === "gap") {
18788
+ const decision = resolveSnapTarget(loc, target);
18789
+ if (decision.kind !== "snap") {
18790
+ if (!this.scrubNoFootageLogged) {
18791
+ this.scrubNoFootageLogged = true;
18792
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
18793
+ epochMs: target,
18794
+ nearestMs: decision.snapMs,
18795
+ distanceMs: decision.distanceMs
18796
+ });
18797
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18798
+ }
18799
+ return;
18800
+ }
18801
+ const snapped = await this.withTimeout(this.deps.locate({
18802
+ deviceId: this.deps.deviceId,
18803
+ profile: this.deps.profile,
18804
+ epochMs: decision.snapMs
18805
+ }));
18806
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18807
+ if (snapped.kind !== "segment") {
18808
+ if (!this.scrubNoFootageLogged) {
18809
+ this.scrubNoFootageLogged = true;
18810
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18811
+ }
18812
+ return;
18813
+ }
18814
+ this.deps.onLog?.("scrub-feeder:snapped", {
18815
+ requestedMs: target,
18816
+ snappedMs: decision.snapMs
18817
+ });
18818
+ loc = snapped;
18819
+ effTarget = decision.snapMs;
18820
+ }
18370
18821
  let seg = this.scrubSegment;
18371
18822
  if (!seg || seg.startMs !== loc.startMs) {
18372
18823
  seg = this.scrubCacheGet(loc.startMs);
18373
18824
  if (!seg) {
18374
- const loaded = await this.loadAus(loc.startMs, false, true);
18825
+ const loaded = await this.loadAus(loc.startMs, false);
18375
18826
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18376
18827
  seg = {
18377
18828
  startMs: loc.startMs,
@@ -18382,26 +18833,80 @@ var RecordedFeeder = class {
18382
18833
  }
18383
18834
  this.scrubSegment = seg;
18384
18835
  }
18385
- const au = seg.aus[this.pickScrubKeyframe(seg, target)];
18386
- if (!au) return;
18387
- if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18388
- this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18836
+ const prevTarget = this.scrubPrevTarget;
18837
+ const cursor = this.scrubCursor;
18838
+ const targetIdx = this.pickScrubIndex(seg, effTarget, false);
18839
+ const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
18840
+ const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
18841
+ const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
18842
+ const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
18843
+ let aus;
18844
+ let firstIdx;
18845
+ let mode;
18846
+ let crossesFragment = false;
18847
+ if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
18848
+ mode = "sweep";
18849
+ firstIdx = cursor.idx + 1;
18850
+ aus = seg.aus.slice(firstIdx, targetIdx + 1);
18851
+ } else if (withinWindow && adjacentForward) {
18852
+ mode = "sweep";
18853
+ firstIdx = 0;
18854
+ aus = seg.aus.slice(0, targetIdx + 1);
18855
+ crossesFragment = true;
18856
+ } else {
18857
+ mode = "jump";
18858
+ firstIdx = this.pickScrubIndex(seg, effTarget, true);
18859
+ const kf = seg.aus[firstIdx];
18860
+ aus = kf === void 0 ? [] : [kf];
18861
+ }
18862
+ const lastAu = aus[aus.length - 1];
18863
+ if (lastAu === void 0) {
18864
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18865
+ this.scrubPrevTarget = effTarget;
18866
+ return;
18867
+ }
18868
+ if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18869
+ else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
18870
+ for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18871
+ this.scrubOutstanding.push(aus.length);
18872
+ this.scrubCredit -= 1;
18389
18873
  this.clockHasAdvanced = true;
18390
- this.deps.onPosition(this.clampIntoSegment(seg, target));
18391
- this.maybeScrubPrefetch(seg, target, gen);
18392
- } catch {} finally {
18393
- if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
18874
+ if (mode === "jump") this.scrubJumps += 1;
18875
+ else this.scrubSweeps += 1;
18876
+ this.scrubAusPushed += aus.length;
18877
+ this.scrubCursor = {
18878
+ fragStartMs: seg.startMs,
18879
+ fragDurMs: seg.durMs,
18880
+ idx: firstIdx + aus.length - 1
18881
+ };
18882
+ this.deps.onLog?.("scrub-feeder:pushed", {
18883
+ epochMs: effTarget,
18884
+ mode,
18885
+ aus: aus.length,
18886
+ codec: lastAu.codec,
18887
+ bytes: lastAu.data.length,
18888
+ creditLeft: this.scrubCredit
18889
+ });
18890
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18891
+ this.maybeScrubPrefetch(seg, effTarget, gen);
18892
+ } catch (err) {
18893
+ this.deps.onLog?.("scrub-feeder:tick-failed", {
18894
+ epochMs: target,
18895
+ error: err instanceof Error ? err.message : String(err)
18896
+ });
18897
+ } finally {
18898
+ 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);
18394
18899
  else this.scrubTimer = null;
18395
18900
  }
18396
18901
  }
18397
18902
  /**
18398
- * Index of the nearest keyframe at-or-before the drag target within `seg`
18903
+ * Index of the nearest AU at-or-before the drag target within `seg`
18399
18904
  * (default 0 — the fragment's params-fused IDR, which the demux contract
18400
- * guarantees). Scans the access units whose PTS offset is ≤ the target offset
18401
- * and returns the LAST keyframe among them, so the pushed frame is standalone-
18402
- * decodable yet as close to the target as the GOP structure allows.
18905
+ * guarantees). `keyframesOnly` restricts the pick to sync frames the JUMP
18906
+ * mode, whose pushed frame must be standalone-decodable; sweep mode picks
18907
+ * ANY frame, which is what makes the flow frame-granular.
18403
18908
  */
18404
- pickScrubKeyframe(seg, target) {
18909
+ pickScrubIndex(seg, target, keyframesOnly) {
18405
18910
  const first = seg.aus[0];
18406
18911
  if (!first) return 0;
18407
18912
  const offsetMs = target - seg.startMs;
@@ -18410,7 +18915,7 @@ var RecordedFeeder = class {
18410
18915
  const au = seg.aus[i];
18411
18916
  if (!au) continue;
18412
18917
  if (au.ptsMs - first.ptsMs > offsetMs) break;
18413
- if (au.keyframe) idx = i;
18918
+ if (!keyframesOnly || au.keyframe) idx = i;
18414
18919
  }
18415
18920
  return idx;
18416
18921
  }
@@ -18481,7 +18986,7 @@ var RecordedFeeder = class {
18481
18986
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18482
18987
  if (loc.kind !== "segment") return;
18483
18988
  if (this.scrubCache.has(loc.startMs)) return;
18484
- const loaded = await this.loadAus(loc.startMs, false, true);
18989
+ const loaded = await this.loadAus(loc.startMs, false);
18485
18990
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18486
18991
  this.scrubCachePut({
18487
18992
  startMs: loc.startMs,
@@ -18831,10 +19336,8 @@ var RecordedFeeder = class {
18831
19336
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
18832
19337
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
18833
19338
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
18834
- * (paused scrubs would throw it away). `useScrubDemux: true` selects the
18835
- * keyframe-only scrub demux (`deps.demuxScrub`, falling back to `deps.demux`
18836
- * when absent — flag-off byte-identity). */
18837
- async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
19339
+ * (paused scrubs would throw it away). */
19340
+ async loadAus(startMs, wantAudio = true) {
18838
19341
  const token = this.seekToken;
18839
19342
  const bytes = await this.withTimeout(this.deps.readBytes({
18840
19343
  deviceId: this.deps.deviceId,
@@ -18842,8 +19345,7 @@ var RecordedFeeder = class {
18842
19345
  startMs
18843
19346
  }));
18844
19347
  if (this.disposed || token !== this.seekToken) return null;
18845
- const demux = useScrubDemux && this.deps.demuxScrub ? this.deps.demuxScrub : this.deps.demux;
18846
- const video = await this.withTimeout(demux(bytes));
19348
+ const video = await this.withTimeout(this.deps.demux(bytes));
18847
19349
  if (this.disposed || token !== this.seekToken) return null;
18848
19350
  let audio = [];
18849
19351
  if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
@@ -18889,6 +19391,11 @@ var RecordedFeeder = class {
18889
19391
  }
18890
19392
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
18891
19393
  dispose() {
19394
+ if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
19395
+ jumps: this.scrubJumps,
19396
+ sweeps: this.scrubSweeps,
19397
+ aus: this.scrubAusPushed
19398
+ });
18892
19399
  this.disposed = true;
18893
19400
  this.seekToken++;
18894
19401
  this.advanceToken++;
@@ -18898,6 +19405,12 @@ var RecordedFeeder = class {
18898
19405
  this.scrubCache.clear();
18899
19406
  this.scrubPrevTarget = null;
18900
19407
  this.scrubDirRun = 0;
19408
+ this.scrubCursor = null;
19409
+ this.scrubJumps = 0;
19410
+ this.scrubSweeps = 0;
19411
+ this.scrubAusPushed = 0;
19412
+ this.scrubNoFootageLogged = false;
19413
+ this.scrubOutstanding = [];
18901
19414
  if (this.scrubTimer) {
18902
19415
  clearTimeout(this.scrubTimer);
18903
19416
  this.scrubTimer = null;
@@ -18925,6 +19438,16 @@ var TimelineSession = class {
18925
19438
  this.deps = deps;
18926
19439
  deps.session.setLiveFeedGate(false);
18927
19440
  }
19441
+ /**
19442
+ * Which source the track is currently carrying. Read by the broker's
19443
+ * adaptive ladder, which must NOT act while the pointer is off live: a tier
19444
+ * switch is a close-and-recreate re-offer, and that disposes this
19445
+ * coordinator — the viewer silently lands back on live at the wrong moment
19446
+ * with no code path to restore the pointer.
19447
+ */
19448
+ get currentMode() {
19449
+ return this.mode;
19450
+ }
18928
19451
  async handleControl(msg) {
18929
19452
  switch (msg.t) {
18930
19453
  case "playRecorded":
@@ -18941,6 +19464,9 @@ var TimelineSession = class {
18941
19464
  case "scrubTo":
18942
19465
  if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
18943
19466
  break;
19467
+ case "scrubAck":
19468
+ if (this.mode === "recorded") this.feeder?.scrubAck();
19469
+ break;
18944
19470
  case "setRate":
18945
19471
  this.feeder?.setRate(msg.rate);
18946
19472
  break;
@@ -18948,6 +19474,7 @@ var TimelineSession = class {
18948
19474
  if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
18949
19475
  break;
18950
19476
  case "setProfile":
19477
+ if (msg.profile === this.profile) break;
18951
19478
  this.profile = msg.profile;
18952
19479
  if (this.mode === "recorded") this.enterRecorded();
18953
19480
  break;
@@ -25662,25 +26189,6 @@ async function extractSamples(segment, segmentStartMs) {
25662
26189
  samples: collected
25663
26190
  } : null;
25664
26191
  }
25665
- /**
25666
- * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
25667
- * WebCodecs decoder config, or null if the segment carries no video / no
25668
- * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
25669
- * epoch ms; the first sample's cts is the zero reference.
25670
- */
25671
- async function extractKeyframes(segment, segmentStartMs) {
25672
- const extracted = await extractSamples(segment, segmentStartMs);
25673
- if (!extracted) return null;
25674
- const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
25675
- ts: s.ts,
25676
- data: s.data
25677
- }));
25678
- return keyframes.length > 0 ? {
25679
- codec: extracted.codec,
25680
- description: extracted.description,
25681
- keyframes
25682
- } : null;
25683
- }
25684
26192
  //#endregion
25685
26193
  //#region src/stream-broker/recorded/avcc-annexb.ts
25686
26194
  /**
@@ -26184,33 +26692,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
26184
26692
  "pipe:1"
26185
26693
  ];
26186
26694
  /**
26187
- * ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
26188
- * `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
26189
- * stops after the FIRST output access unit — a recorded fragment is a single
26190
- * IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
26191
- * (it never renders a delta frame). This reliably yields exactly the IDR with
26192
- * its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
26193
- * `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
26194
- * the spec.
26195
- */
26196
- var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
26197
- "-hide_banner",
26198
- "-loglevel",
26199
- "error",
26200
- "-i",
26201
- "pipe:0",
26202
- "-map",
26203
- "0:v:0",
26204
- "-frames:v",
26205
- "1",
26206
- "-c:v",
26207
- "copy",
26208
- "-an",
26209
- "-f",
26210
- "mpegts",
26211
- "pipe:1"
26212
- ];
26213
- /**
26214
26695
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
26215
26696
  * access units.
26216
26697
  *
@@ -26236,113 +26717,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26236
26717
  codec
26237
26718
  }));
26238
26719
  }
26239
- /**
26240
- * Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
26241
- * (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
26242
- * The scrub path never renders delta frames — it always pushes the nearest
26243
- * keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
26244
- * scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
26245
- * loss of scrub fidelity.
26246
- *
26247
- * Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
26248
- * NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
26249
- * frequency win comes from the feeder's fragment cache + direction-aware
26250
- * prefetch (which eliminate / overlap the demux), for which this compact
26251
- * keyframe-only payload is the cache unit.
26252
- *
26253
- * Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
26254
- * AU (the demux contract's params-fused IDR) if none were flagged, so the result
26255
- * is keyframes-only yet never empty for a valid fragment.
26256
- *
26257
- * @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
26258
- * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
26259
- */
26260
- async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26261
- const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
26262
- ffmpegPath,
26263
- args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
26264
- input: bytes
26265
- }));
26266
- const mapped = accessUnits.map((au) => ({
26267
- data: au.data,
26268
- ptsMs: au.ptsMs,
26269
- keyframe: au.keyframe,
26270
- codec
26271
- }));
26272
- const keyframes = mapped.filter((au) => au.keyframe);
26273
- return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
26274
- }
26275
26720
  //#endregion
26276
26721
  //#region src/stream-broker/recorded/segment-demux-js.ts
26277
26722
  /**
26278
26723
  * segment-demux-js — PURE-JS in-process demux for recorded playback.
26279
26724
  *
26280
- * Two flavours, both replacing a per-fragment ffmpeg spawn with an mp4box parse:
26281
- * - {@link demuxSegmentKeyframesJs} / {@link demuxSegmentKeyframesPreferJs}
26282
- * KEYFRAME-only, for the feeder-scrub sub-mode.
26283
- * - {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26284
- * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26725
+ * {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26726
+ * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26727
+ * Both playback AND scrub demux through this since sweep mode (the scrub train
26728
+ * pushes predicted frames, so the whole GOP is needed either way).
26285
26729
  *
26286
- * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
26287
- * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
26730
+ * Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
26731
+ * in-process mp4box parse. A recorded `.m4s`
26288
26732
  * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
26289
26733
  * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
26290
26734
  * sample(s) are all in the bytes we already hold — no init segment to fetch.
26291
26735
  *
26292
- * Flow: reuse {@link extractKeyframes} (the proven mp4box sync-sample demux the
26293
- * recorder scrub module already ships — box parsing is NOT duplicated here) to get
26294
- * the codec `description` + length-prefixed sync samples, then
26295
- * {@link buildKeyframeAnnexB} converts each to a standalone-decodable Annex-B
26296
- * access unit with the parameter sets fused on. The result matches the
26297
- * {@link AccessUnit} contract the ffmpeg path produces: `codec` H264|H265,
26298
- * `keyframe:true`, and `ptsMs` segment-relative (offset from the first sync
26299
- * sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
26300
- *
26301
- * Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
26302
- * so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
26303
- * demux's PES-derived deltas — `clampIntoSegment`/position reporting are
26304
- * unaffected.
26736
+ * Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
26737
+ * module already ships — box parsing is NOT duplicated here) yields the codec
26738
+ * `description` + length-prefixed samples; sync samples are fused with their
26739
+ * parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
26740
+ * ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
26741
+ * the first sample), exactly like the ffmpeg demux's PES-derived deltas
26742
+ * `clampIntoSegment`/position reporting are unaffected.
26305
26743
  */
26306
26744
  /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
26307
26745
  function isHevcCodec(codec) {
26308
26746
  return /^h[ev][vc]1\./i.test(codec);
26309
26747
  }
26310
26748
  /**
26311
- * Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
26312
- * fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
26313
- * mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
26314
- * Throws only on a genuinely malformed decoder config / sample — the wrapper
26315
- * ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
26316
- */
26317
- async function demuxSegmentKeyframesJs(bytes) {
26318
- const extracted = await extractKeyframes(bytes, 0);
26319
- if (!extracted || extracted.keyframes.length === 0) return null;
26320
- const isHevc = isHevcCodec(extracted.codec);
26321
- const codec = isHevc ? "H265" : "H264";
26322
- return extracted.keyframes.slice(0, 1).map((k) => ({
26323
- data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
26324
- ptsMs: k.ts,
26325
- keyframe: true,
26326
- codec
26327
- }));
26328
- }
26329
- /**
26330
- * Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
26331
- * ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
26332
- * keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
26333
- * contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
26334
- */
26335
- async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
26336
- try {
26337
- const js = await demuxSegmentKeyframesJs(bytes);
26338
- if (js && js.length > 0) return js;
26339
- opts.onFfmpegFallback?.("mp4box parse found no keyframe");
26340
- } catch (err) {
26341
- opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
26342
- }
26343
- return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
26344
- }
26345
- /**
26346
26749
  * Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
26347
26750
  * EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
26348
26751
  * `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
@@ -26651,7 +27054,8 @@ var ADAPTIVE_CONFIG = {
26651
27054
  minFailPercent: .05,
26652
27055
  waitAfterResetMs: 8e3,
26653
27056
  upgradeHealthyMs: 6e4,
26654
- minTimeBetweenChangesMs: 6e4
27057
+ minTimeBetweenChangesMs: 6e4,
27058
+ maxUpgradeBackoffMs: 9e5
26655
27059
  };
26656
27060
  /**
26657
27061
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -26837,6 +27241,24 @@ var BrokerWebrtcServer = class {
26837
27241
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
26838
27242
  brokers = /* @__PURE__ */ new Map();
26839
27243
  /**
27244
+ * Post-session warmth: when the LAST WebRTC session on a broker closes, a
27245
+ * ghost subscription keeps the source dialed and its RTP ring warm for the
27246
+ * linger window, so the dominant remote pattern — reopen within minutes —
27247
+ * reconnects with an instant-start GOP replay instead of re-paying the
27248
+ * cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
27249
+ * attribution enum; the label answers "who keeps cam X warm?" in the
27250
+ * operator widget.
27251
+ */
27252
+ brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
27253
+ const broker = this.brokers.get(brokerId);
27254
+ if (!broker) return null;
27255
+ return broker.onEncodedData(() => {}, {
27256
+ kind: "warmup",
27257
+ label: "webrtc-linger",
27258
+ media: "both"
27259
+ });
27260
+ } });
27261
+ /**
26840
27262
  * Resolves a brokerId to its current profile tier (`CamProfile`)
26841
27263
  * via the manager's `assignments` map, or `null` for brokers that
26842
27264
  * are only kept alive by a manual activation. Injected by the
@@ -26999,16 +27421,20 @@ var BrokerWebrtcServer = class {
26999
27421
  * RTC controller / Alexa handler closes the session the same way.
27000
27422
  */
27001
27423
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
27424
+ const offerStart = Date.now();
27002
27425
  const setup = await this.setupSessionForBroker(streamId, hints, {
27003
27426
  ...opts,
27004
27427
  clientOfferSdp
27005
27428
  });
27429
+ const setupMs = Date.now() - offerStart;
27006
27430
  try {
27007
27431
  if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
27432
+ const negotiateStart = Date.now();
27008
27433
  const answer = await setup.session.handleOffer({
27009
27434
  sdp: clientOfferSdp,
27010
27435
  type: "offer"
27011
27436
  }, { nonTrickle: opts.nonTrickle === true });
27437
+ const negotiateMs = Date.now() - negotiateStart;
27012
27438
  setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
27013
27439
  sessionId: setup.sessionId,
27014
27440
  deviceId: setup.deviceId,
@@ -27016,7 +27442,11 @@ var BrokerWebrtcServer = class {
27016
27442
  iceServers: setup.iceServerCount,
27017
27443
  codec: setup.sessionCodec,
27018
27444
  useRtpRepacketizer: setup.useRtpRepacketizer,
27019
- relayOnly: opts.relayOnly === true
27445
+ relayOnly: opts.relayOnly === true,
27446
+ setupMs,
27447
+ negotiateMs,
27448
+ totalMs: Date.now() - offerStart,
27449
+ ...setup.setupTimings
27020
27450
  } });
27021
27451
  return {
27022
27452
  sessionId: setup.sessionId,
@@ -27043,6 +27473,11 @@ var BrokerWebrtcServer = class {
27043
27473
  */
27044
27474
  async setupSessionForBroker(streamId, hints, opts) {
27045
27475
  if (this.stopped) throw new Error("Server stopped");
27476
+ const setupStart = Date.now();
27477
+ let ensureTierMs = 0;
27478
+ let warmUpMs = 0;
27479
+ let iceResolveMs = 0;
27480
+ let hwAccelMs = 0;
27046
27481
  const isAdaptiveStream = streamId.endsWith("/adaptive");
27047
27482
  const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
27048
27483
  const resolveHints = intent !== void 0 ? {
@@ -27052,10 +27487,13 @@ var BrokerWebrtcServer = class {
27052
27487
  if (isAdaptiveStream) {
27053
27488
  const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
27054
27489
  const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
27490
+ const ensureTierStart = Date.now();
27055
27491
  try {
27056
27492
  await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
27057
27493
  } catch (err) {
27058
27494
  throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${errMsg(err)}`);
27495
+ } finally {
27496
+ ensureTierMs = Date.now() - ensureTierStart;
27059
27497
  }
27060
27498
  }
27061
27499
  const brokerId = this.resolveBrokerId(streamId, resolveHints);
@@ -27077,6 +27515,7 @@ var BrokerWebrtcServer = class {
27077
27515
  kind: "warmup",
27078
27516
  media: "both"
27079
27517
  });
27518
+ const warmUpStart = Date.now();
27080
27519
  await new Promise((resolve) => {
27081
27520
  const timer = setTimeout(() => {
27082
27521
  sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
@@ -27092,9 +27531,11 @@ var BrokerWebrtcServer = class {
27092
27531
  resolve();
27093
27532
  });
27094
27533
  });
27534
+ warmUpMs = Date.now() - warmUpStart;
27095
27535
  sessionLogger.info("broker warm-up complete", { meta: {
27096
27536
  brokerId,
27097
- detectedCodec: broker.getStats().codec ?? null
27537
+ detectedCodec: broker.getStats().codec ?? null,
27538
+ waitedMs: warmUpMs
27098
27539
  } });
27099
27540
  }
27100
27541
  const brokerCodec = broker.getStats().codec ?? "h264";
@@ -27278,6 +27719,7 @@ var BrokerWebrtcServer = class {
27278
27719
  }
27279
27720
  }, annexbAttribution);
27280
27721
  warmUpUnsubscribe?.();
27722
+ this.brokerLinger.cancel(brokerId);
27281
27723
  const preBuffer = broker.getPreBuffer();
27282
27724
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
27283
27725
  for (const pkt of preBuffer) if (pkt.type === "video") {
@@ -27304,7 +27746,9 @@ var BrokerWebrtcServer = class {
27304
27746
  }
27305
27747
  });
27306
27748
  }
27749
+ const iceResolveStart = Date.now();
27307
27750
  const allIceServers = await this.resolveIceServers();
27751
+ iceResolveMs = Date.now() - iceResolveStart;
27308
27752
  const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
27309
27753
  const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
27310
27754
  if (stunOnly.length === 0) return null;
@@ -27329,13 +27773,16 @@ var BrokerWebrtcServer = class {
27329
27773
  sourceProfileLevelId,
27330
27774
  offeredProfileLevelIds
27331
27775
  } });
27776
+ const hwAccelStart = Date.now();
27777
+ const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
27778
+ hwAccelMs = Date.now() - hwAccelStart;
27332
27779
  session = new AdaptiveSession({
27333
27780
  sessionId,
27334
27781
  source,
27335
27782
  sourceCodec: sessionCodec,
27336
27783
  transcodeToBaseline,
27337
27784
  transcodeProfile: effectiveTranscodeProfile,
27338
- transcodeDecodeHwAccel: await this.getTranscodeDecodeHwAccel(),
27785
+ transcodeDecodeHwAccel,
27339
27786
  resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
27340
27787
  iceConfig: {
27341
27788
  iceServers,
@@ -27447,7 +27894,6 @@ var BrokerWebrtcServer = class {
27447
27894
  const locateRecorded = this.locateRecordedSegment;
27448
27895
  const readRecorded = this.readRecordedSegmentBytes;
27449
27896
  const resolveFfmpeg = this.resolveFfmpegPath;
27450
- let scrubJsFallbackLogged = false;
27451
27897
  let fullJsFallbackLogged = false;
27452
27898
  let audioOpusFallbackLogged = false;
27453
27899
  if (locateRecorded && readRecorded) {
@@ -27475,19 +27921,6 @@ var BrokerWebrtcServer = class {
27475
27921
  } });
27476
27922
  }
27477
27923
  }),
27478
- demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
27479
- ffmpegPath: resolveFfmpeg?.(),
27480
- onFfmpegFallback: (reason) => {
27481
- if (scrubJsFallbackLogged) return;
27482
- scrubJsFallbackLogged = true;
27483
- sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
27484
- sessionId,
27485
- deviceId,
27486
- profile,
27487
- reason
27488
- } });
27489
- }
27490
- }),
27491
27924
  demuxAudio: opusSession ? async (b) => {
27492
27925
  return (await demuxSegmentAudioOpusPreferJs(b, {
27493
27926
  ffmpegPath: resolveFfmpeg?.(),
@@ -27523,10 +27956,17 @@ var BrokerWebrtcServer = class {
27523
27956
  } });
27524
27957
  timeline._emitState(s);
27525
27958
  },
27526
- onPosition: (epochMs) => timeline._emitPosition(epochMs)
27959
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
27960
+ onLog: (msg, data) => sessionLogger.info(msg, { meta: {
27961
+ sessionId,
27962
+ deviceId,
27963
+ profile,
27964
+ ...data
27965
+ } })
27527
27966
  });
27528
27967
  }
27529
27968
  });
27969
+ entry.timeline = timeline;
27530
27970
  sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
27531
27971
  sessionId,
27532
27972
  deviceId
@@ -27576,7 +28016,7 @@ var BrokerWebrtcServer = class {
27576
28016
  };
27577
28017
  if (streamId.endsWith("/adaptive")) {
27578
28018
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
27579
- this.startAdaptive(entry, deviceId, initialTier, sessionId);
28019
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
27580
28020
  }
27581
28021
  return {
27582
28022
  sessionId,
@@ -27586,7 +28026,14 @@ var BrokerWebrtcServer = class {
27586
28026
  brokerId,
27587
28027
  sessionCodec,
27588
28028
  useRtpRepacketizer,
27589
- iceServerCount: iceServers.length
28029
+ iceServerCount: iceServers.length,
28030
+ setupTimings: {
28031
+ setupTotalMs: Date.now() - setupStart,
28032
+ ensureTierMs,
28033
+ warmUpMs,
28034
+ iceResolveMs,
28035
+ hwAccelMs
28036
+ }
27590
28037
  };
27591
28038
  }
27592
28039
  async handleAnswer(sessionId, sdpAnswer) {
@@ -27636,6 +28083,7 @@ var BrokerWebrtcServer = class {
27636
28083
  async stop() {
27637
28084
  if (this.stopped) return;
27638
28085
  this.stopped = true;
28086
+ this.brokerLinger.stop();
27639
28087
  const closes = [];
27640
28088
  for (const [sid, entry] of this.sessions) {
27641
28089
  this.cleanupSession(sid);
@@ -27670,9 +28118,10 @@ var BrokerWebrtcServer = class {
27670
28118
  * whichever produces the higher loss ratio across its window. The async
27671
28119
  * poll is guarded against overlapping ticks via `tickInFlight`.
27672
28120
  */
27673
- startAdaptive(entry, deviceId, initialTier, sessionId) {
28121
+ startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
27674
28122
  const controller = new AdaptiveController({
27675
28123
  initialTier,
28124
+ priorFailures,
27676
28125
  config: ADAPTIVE_CONFIG,
27677
28126
  onSwitchTier: (tier) => {
27678
28127
  this.switchSessionTier(entry, deviceId, tier, sessionId);
@@ -27692,6 +28141,10 @@ var BrokerWebrtcServer = class {
27692
28141
  tickInFlight = true;
27693
28142
  (async () => {
27694
28143
  try {
28144
+ if (entry.timeline?.currentMode === "recorded") {
28145
+ controller.suspend(Date.now());
28146
+ return;
28147
+ }
27695
28148
  const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
27696
28149
  const now = Date.now();
27697
28150
  if (pct !== null && pct !== void 0 && pct > 0) {
@@ -27723,9 +28176,11 @@ var BrokerWebrtcServer = class {
27723
28176
  */
27724
28177
  switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
27725
28178
  const prev = this.adaptiveIntent.get(sessionId);
28179
+ const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
27726
28180
  this.adaptiveIntent.set(sessionId, {
27727
28181
  prefersTier: tier,
27728
- forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
28182
+ forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
28183
+ failures
27729
28184
  });
27730
28185
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
27731
28186
  entry.session.pendingRenegotiation = {
@@ -27739,7 +28194,8 @@ var BrokerWebrtcServer = class {
27739
28194
  deviceId,
27740
28195
  tier,
27741
28196
  forceTranscodeDown,
27742
- epoch
28197
+ epoch,
28198
+ failures: Object.fromEntries(failures)
27743
28199
  }
27744
28200
  });
27745
28201
  }
@@ -27747,6 +28203,12 @@ var BrokerWebrtcServer = class {
27747
28203
  const entry = this.sessions.get(sessionId);
27748
28204
  if (!entry) return;
27749
28205
  this.sessions.delete(sessionId);
28206
+ let brokerStillServed = false;
28207
+ for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
28208
+ brokerStillServed = true;
28209
+ break;
28210
+ }
28211
+ if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
27750
28212
  this.adaptiveIntent.delete(sessionId);
27751
28213
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
27752
28214
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -28330,6 +28792,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
28330
28792
  }
28331
28793
  };
28332
28794
  //#endregion
28795
+ //#region src/stream-broker/event-loop-stall-monitor.ts
28796
+ var DEFAULT_INTERVAL_MS = 100;
28797
+ var DEFAULT_THRESHOLD_MS = 250;
28798
+ var EventLoopStallMonitor = class {
28799
+ intervalMs;
28800
+ thresholdMs;
28801
+ now;
28802
+ onStall;
28803
+ timer = null;
28804
+ lastTickAt = 0;
28805
+ constructor(options) {
28806
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
28807
+ this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
28808
+ this.now = options.now ?? (() => Date.now());
28809
+ this.onStall = options.onStall;
28810
+ }
28811
+ start() {
28812
+ if (this.timer) return;
28813
+ this.lastTickAt = this.now();
28814
+ this.timer = setInterval(() => {
28815
+ const at = this.now();
28816
+ const lateMs = at - this.lastTickAt - this.intervalMs;
28817
+ this.lastTickAt = at;
28818
+ if (lateMs >= this.thresholdMs) try {
28819
+ this.onStall(lateMs);
28820
+ } catch {}
28821
+ }, this.intervalMs);
28822
+ this.timer.unref?.();
28823
+ }
28824
+ stop() {
28825
+ if (!this.timer) return;
28826
+ clearInterval(this.timer);
28827
+ this.timer = null;
28828
+ }
28829
+ };
28830
+ //#endregion
28333
28831
  //#region src/stream-broker/addon.ts
28334
28832
  /**
28335
28833
  * Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
@@ -28422,6 +28920,8 @@ var StreamBrokerAddon = class extends BaseAddon {
28422
28920
  webrtcServer = null;
28423
28921
  /** Handle for the embed SPA data-plane listener (disposed on shutdown). */
28424
28922
  embedDataPlane = null;
28923
+ /** Names this process's event-loop stalls — see onInitialize. */
28924
+ loopStallMonitor = null;
28425
28925
  /**
28426
28926
  * Snapshot-equality cache for the broker-metrics emit. Each
28427
28927
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -28522,6 +29022,10 @@ var StreamBrokerAddon = class extends BaseAddon {
28522
29022
  }
28523
29023
  }
28524
29024
  async onInitialize() {
29025
+ this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
29026
+ this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
29027
+ } });
29028
+ this.loopStallMonitor.start();
28525
29029
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
28526
29030
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
28527
29031
  this.brokerManager.setEventBus(this.ctx.eventBus);
@@ -28864,6 +29368,8 @@ var StreamBrokerAddon = class extends BaseAddon {
28864
29368
  ];
28865
29369
  }
28866
29370
  async onShutdown() {
29371
+ this.loopStallMonitor?.stop();
29372
+ this.loopStallMonitor = null;
28867
29373
  this.flushProbeSnapshots(Date.now(), true);
28868
29374
  if (this.metricsSnapshotTimer) {
28869
29375
  clearInterval(this.metricsSnapshotTimer);