@camstack/addon-pipeline 1.2.38 → 1.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +145 -175
  4. package/dist/detection-pipeline/index.mjs +120 -150
  5. package/dist/{dist-Du7_SNiR.mjs → dist-C6p8jcji.mjs} +495 -113
  6. package/dist/{dist-BvFLZyhW.js → dist-DcMBfoCg.js} +495 -113
  7. package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-BHqCesa1.mjs} +139 -2
  8. package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-oL9xanvX.js} +150 -1
  9. package/dist/lazy-sharp-B-mb8uWx.mjs +20 -0
  10. package/dist/lazy-sharp-CcYuXtsa.js +25 -0
  11. package/dist/motion-wasm/index.js +3 -3
  12. package/dist/motion-wasm/index.mjs +3 -3
  13. package/dist/pipeline-runner/index.js +3316 -2428
  14. package/dist/pipeline-runner/index.mjs +3315 -2428
  15. package/dist/recorder/index.js +102 -664
  16. package/dist/recorder/index.mjs +102 -658
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Bt1YkfUW.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DJnh97n9.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BsNhZnfZ.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-Dyj8Vaql.mjs} +2 -2
  22. package/dist/stream-broker/index.js +723 -214
  23. package/dist/stream-broker/index.mjs +723 -214
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-m1RaK_7F.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-C00UIgFo.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-DWYllToj.js} +1 -1
  28. package/embed-dist/assets/index-4fJzjj4H.css +2 -0
  29. package/embed-dist/assets/index-BaUgbxWl.js +112 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +2 -2
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +0 -26
  34. package/embed-dist/assets/index-9Ob2YrIf.js +0 -343
  35. package/embed-dist/assets/index-CAe6Dg-X.css +0 -2
  36. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  37. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  44. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  45. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  48. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  50. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  51. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  52. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  55. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  56. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  57. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  58. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  59. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  60. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  61. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  64. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  66. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  67. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  70. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  71. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  72. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  73. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  76. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  77. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  78. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  79. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  80. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  81. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  82. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  83. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  84. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  85. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  86. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  87. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  88. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -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-C6p8jcji.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: {
@@ -6989,14 +7053,16 @@ var RENDER_TIMEOUT_MS = 2e4;
6989
7053
  function clipFfmpegArgs(input) {
6990
7054
  const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
6991
7055
  const demuxer = input.hevc === true ? "hevc" : "h264";
7056
+ const speed = input.speed ?? 2;
7057
+ const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
6992
7058
  if (input.format === "gif") return [
6993
7059
  "-y",
6994
7060
  "-f",
6995
7061
  demuxer,
6996
7062
  "-i",
6997
7063
  "pipe:0",
6998
- "-vf",
6999
- `fps=${input.fps},${scale}`,
7064
+ "-filter_complex",
7065
+ `[0:v]fps=${input.fps}${setpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
7000
7066
  "-loop",
7001
7067
  "0",
7002
7068
  "-f",
@@ -7010,7 +7076,7 @@ function clipFfmpegArgs(input) {
7010
7076
  "-i",
7011
7077
  "pipe:0",
7012
7078
  "-vf",
7013
- scale,
7079
+ `${scale}${setpts}`,
7014
7080
  "-c:v",
7015
7081
  "libx264",
7016
7082
  "-preset",
@@ -9730,6 +9796,21 @@ var DecoderSessionProxy = class {
9730
9796
  this.api = api;
9731
9797
  this.sessionId = sessionId;
9732
9798
  }
9799
+ /**
9800
+ * Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
9801
+ * regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
9802
+ * `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
9803
+ * is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
9804
+ * peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
9805
+ * stub, an old decoder that predates `waitMs`, a test fake — would otherwise
9806
+ * turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
9807
+ * that pins the CPU, starves every timer in the process, and allocates until
9808
+ * the heap dies (observed: a 4 GB OOM in under 15 s).
9809
+ */
9810
+ async paceEmptyDrain(drainStartedAt) {
9811
+ const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
9812
+ if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
9813
+ }
9733
9814
  async pushPacket(packet) {
9734
9815
  await this.api.pushPacket({
9735
9816
  sessionId: this.sessionId,
@@ -9745,12 +9826,14 @@ var DecoderSessionProxy = class {
9745
9826
  async startPolling(onFrame) {
9746
9827
  this.polling = true;
9747
9828
  while (this.polling) {
9829
+ const drainStartedAt = Date.now();
9748
9830
  const frames = await this.api.pullFrames({
9749
9831
  sessionId: this.sessionId,
9750
9832
  maxCount: 4,
9751
9833
  waitMs: POLL_WAIT_MS
9752
9834
  });
9753
9835
  for (const frame of frames) onFrame(frame);
9836
+ if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9754
9837
  }
9755
9838
  }
9756
9839
  /**
@@ -9768,12 +9851,14 @@ var DecoderSessionProxy = class {
9768
9851
  async startHandlePolling(onHandle) {
9769
9852
  this.polling = true;
9770
9853
  while (this.polling) try {
9854
+ const drainStartedAt = Date.now();
9771
9855
  const handles = await this.api.pullHandles({
9772
9856
  sessionId: this.sessionId,
9773
9857
  maxCount: 4,
9774
9858
  waitMs: POLL_WAIT_MS
9775
9859
  });
9776
9860
  for (const handle of handles) onHandle(handle);
9861
+ if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
9777
9862
  } catch (err) {
9778
9863
  this.polling = false;
9779
9864
  const msg = err instanceof Error ? err.message : String(err);
@@ -15721,6 +15806,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15721
15806
  format: input.format,
15722
15807
  maxWidth: input.maxWidth,
15723
15808
  fps: input.fps,
15809
+ ...input.speed !== void 0 ? { speed: input.speed } : {},
15724
15810
  spawn: (cmd, args) => spawn(cmd, [...args], { stdio: [
15725
15811
  "pipe",
15726
15812
  "pipe",
@@ -17779,6 +17865,71 @@ function withSourceResolution(consumerDefault, probedSource) {
17779
17865
  };
17780
17866
  }
17781
17867
  //#endregion
17868
+ //#region src/stream-broker/webrtc/broker-linger-registry.ts
17869
+ /** Matches the observed reopen gaps (1–2 min) with headroom; well under the
17870
+ * "operator walked away" scale where warmth would be waste. */
17871
+ var DEFAULT_LINGER_MS = 3 * 6e4;
17872
+ var BrokerLingerRegistry = class {
17873
+ lingerMs;
17874
+ subscribe;
17875
+ holds = /* @__PURE__ */ new Map();
17876
+ constructor(options) {
17877
+ this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
17878
+ this.subscribe = options.subscribe;
17879
+ }
17880
+ /**
17881
+ * Start (or restart) the linger window for a broker that just lost its
17882
+ * last session. Re-holding an already-held broker re-arms the timer
17883
+ * without stacking a second ghost subscription.
17884
+ */
17885
+ hold(brokerId) {
17886
+ const existing = this.holds.get(brokerId);
17887
+ if (existing) {
17888
+ clearTimeout(existing.timer);
17889
+ existing.timer = this.armTimer(brokerId);
17890
+ return;
17891
+ }
17892
+ const unsubscribe = this.subscribe(brokerId);
17893
+ if (unsubscribe === null) return;
17894
+ this.holds.set(brokerId, {
17895
+ unsubscribe,
17896
+ timer: this.armTimer(brokerId)
17897
+ });
17898
+ }
17899
+ /** Release the hold NOW — a new session attached, the real subscription
17900
+ * carries the demand from here. No-op when nothing is held. */
17901
+ cancel(brokerId) {
17902
+ const hold = this.holds.get(brokerId);
17903
+ if (!hold) return;
17904
+ clearTimeout(hold.timer);
17905
+ this.holds.delete(brokerId);
17906
+ this.release(hold);
17907
+ }
17908
+ /** Release every hold (server shutdown). */
17909
+ stop() {
17910
+ for (const [brokerId, hold] of this.holds) {
17911
+ clearTimeout(hold.timer);
17912
+ this.holds.delete(brokerId);
17913
+ this.release(hold);
17914
+ }
17915
+ }
17916
+ armTimer(brokerId) {
17917
+ const timer = setTimeout(() => {
17918
+ const hold = this.holds.get(brokerId);
17919
+ if (!hold) return;
17920
+ this.holds.delete(brokerId);
17921
+ this.release(hold);
17922
+ }, this.lingerMs);
17923
+ timer.unref?.();
17924
+ return timer;
17925
+ }
17926
+ release(hold) {
17927
+ try {
17928
+ hold.unsubscribe();
17929
+ } catch {}
17930
+ }
17931
+ };
17932
+ //#endregion
17782
17933
  //#region src/stream-broker/webrtc/loss-tracker.ts
17783
17934
  /** Delta between two RTCP extended (32-bit) highest-sequence values, with
17784
17935
  * 32-bit wraparound handling. */
@@ -17827,6 +17978,24 @@ var LossTracker = class {
17827
17978
  if (now < this.resetUntil) return false;
17828
17979
  return this.lossRatio() > this.config.minFailPercent;
17829
17980
  }
17981
+ /**
17982
+ * True when the window holds enough recent receiver feedback for a
17983
+ * "healthy" verdict to MEAN anything.
17984
+ *
17985
+ * `lossRatio()` returns 0 with fewer than two in-window samples, so
17986
+ * `isFailing` is false for a session that has produced NO feedback at all —
17987
+ * a link so bad that RTCP never arrives reads identically to a perfect one.
17988
+ * A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
17989
+ * UPGRADE must not, because it spends bandwidth the session has never been
17990
+ * shown to have. Silence is not health.
17991
+ */
17992
+ hasEvidence(now) {
17993
+ if (now < this.resetUntil) return false;
17994
+ const cutoff = now - this.config.analyzeWindowMs;
17995
+ let inWindow = 0;
17996
+ for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
17997
+ return inWindow >= 2;
17998
+ }
17830
17999
  };
17831
18000
  //#endregion
17832
18001
  //#region src/stream-broker/webrtc/adaptive-controller.ts
@@ -17835,8 +18004,17 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
17835
18004
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
17836
18005
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
17837
18006
  * 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).
18007
+ * transcode-down. Side effects run through injected callbacks (testable
18008
+ * without werift).
18009
+ *
18010
+ * An UPGRADE requires all three of:
18011
+ * 1. the session clock — measured from the FIRST tick, not from the unix
18012
+ * epoch — showing `upgradeHealthyMs` without a failing verdict;
18013
+ * 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
18014
+ * a session that has produced no RTCP at all is not "healthy", it is
18015
+ * silent, and climbing on silence spends bandwidth never shown to exist;
18016
+ * 3. the target tier's re-attempt backoff, which doubles per measured
18017
+ * failure of that tier in this session.
17840
18018
  */
17841
18019
  var AdaptiveController = class {
17842
18020
  opts;
@@ -17846,27 +18024,86 @@ var AdaptiveController = class {
17846
18024
  transcodeEngaged = false;
17847
18025
  lastChangeAt = 0;
17848
18026
  healthySince = 0;
18027
+ /**
18028
+ * Whether the session clock has been armed by the first evaluation. The
18029
+ * clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
18030
+ * would read as "healthy since 1970" and hand out a free upgrade on the
18031
+ * very first tick, before a single loss sample had been seen. A boolean
18032
+ * (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
18033
+ */
18034
+ armed = false;
18035
+ failures;
17849
18036
  constructor(opts) {
17850
18037
  this.opts = opts;
17851
18038
  this.tracker = new LossTracker(opts.config);
17852
18039
  this.tier = opts.initialTier;
18040
+ this.failures = new Map(opts.priorFailures ?? []);
17853
18041
  }
17854
18042
  get currentTier() {
17855
18043
  return this.tier;
17856
18044
  }
18045
+ /**
18046
+ * Per-tier measured-failure counts, INCLUDING those inherited from earlier
18047
+ * rungs of the same viewer session. The server snapshots this into the
18048
+ * adaptive intent so the session's next controller inherits it.
18049
+ */
18050
+ get failureCounts() {
18051
+ return this.failures;
18052
+ }
17857
18053
  onLoss(sample, now) {
17858
18054
  this.tracker.track(sample, now);
17859
18055
  }
18056
+ /**
18057
+ * Freeze the ladder for this evaluation instead of ticking it.
18058
+ *
18059
+ * Called while the session's track is NOT carrying the live source — D42
18060
+ * put recorded playback and scrub on the same track with a moving source
18061
+ * pointer, and in those modes the live feed is gated, so receiver feedback
18062
+ * measures the recorded feeder, not the camera tier. Acting on it is
18063
+ * incoherent in both directions: a downgrade cannot shrink a file, an
18064
+ * upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
18065
+ * is a close-and-recreate re-offer) — dropping the viewer back to live at
18066
+ * the wrong moment with nothing to restore the pointer.
18067
+ *
18068
+ * Holds the climb clock so time spent in recorded modes can never
18069
+ * accumulate into an upgrade on the return to live.
18070
+ */
18071
+ suspend(now) {
18072
+ if (!this.armed) {
18073
+ this.armed = true;
18074
+ this.lastChangeAt = now;
18075
+ }
18076
+ this.healthySince = now;
18077
+ }
17860
18078
  tick(now) {
18079
+ if (!this.armed) {
18080
+ this.armed = true;
18081
+ this.healthySince = now;
18082
+ this.lastChangeAt = now;
18083
+ }
17861
18084
  if (this.tracker.isFailing(now)) {
17862
18085
  this.healthySince = now;
17863
18086
  this.handleFailing(now);
17864
18087
  return;
17865
18088
  }
18089
+ if (!this.tracker.hasEvidence(now)) {
18090
+ this.healthySince = now;
18091
+ return;
18092
+ }
17866
18093
  this.handleHealthy(now);
17867
18094
  }
17868
18095
  canUpgrade(now) {
17869
- return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs || this.lastChangeAt === 0;
18096
+ return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
18097
+ }
18098
+ /**
18099
+ * How long the link must read healthy before `tier` may be re-attempted.
18100
+ * Doubles per measured failure of that tier in this session, capped at
18101
+ * `maxUpgradeBackoffMs`.
18102
+ */
18103
+ requiredHealthyMs(tier) {
18104
+ const failures = this.failures.get(tier) ?? 0;
18105
+ if (failures <= 0) return this.opts.config.upgradeHealthyMs;
18106
+ return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
17870
18107
  }
17871
18108
  commitChange(now) {
17872
18109
  this.lastChangeAt = now;
@@ -17875,6 +18112,8 @@ var AdaptiveController = class {
17875
18112
  handleFailing(now) {
17876
18113
  const idx = TIER_ORDER.indexOf(this.tier);
17877
18114
  if (idx < TIER_ORDER.length - 1) {
18115
+ const failed = this.tier;
18116
+ this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
17878
18117
  this.tier = TIER_ORDER[idx + 1];
17879
18118
  this.opts.onSwitchTier(this.tier);
17880
18119
  this.commitChange(now);
@@ -17893,17 +18132,17 @@ var AdaptiveController = class {
17893
18132
  }
17894
18133
  }
17895
18134
  handleHealthy(now) {
17896
- if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
17897
- if (!this.canUpgrade(now)) return;
17898
18135
  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
- }
18136
+ if (idx <= 0) return;
18137
+ const next = TIER_ORDER[idx - 1];
18138
+ if (now - this.healthySince < this.requiredHealthyMs(next)) return;
18139
+ if (!this.canUpgrade(now)) return;
18140
+ this.tier = next;
18141
+ this.mtuReduced = false;
18142
+ this.transcodeEngaged = false;
18143
+ this.opts.onSwitchTier(next);
18144
+ this.commitChange(now);
18145
+ this.healthySince = now;
17907
18146
  }
17908
18147
  };
17909
18148
  //#endregion
@@ -17928,6 +18167,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
17928
18167
  t: literal("scrubTo"),
17929
18168
  epoch: number()
17930
18169
  }),
18170
+ object({ t: literal("scrubAck") }),
17931
18171
  object({
17932
18172
  t: literal("setRate"),
17933
18173
  rate: number().min(0).max(4)
@@ -18101,6 +18341,74 @@ var SCRUB_CACHE_MAX = 8;
18101
18341
  * sustained direction, so a jittery/reversing drag never wastes the slot.
18102
18342
  */
18103
18343
  var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
18344
+ /**
18345
+ * Max forward TARGET delta (ms of timeline, between consecutive drag targets)
18346
+ * that SWEEPS predicted frames instead of jumping. A steady forward drag is
18347
+ * playback with a moving target: once a keyframe decoded, the predicted frames
18348
+ * after it decode in sequence at a fraction of an IDR's bytes — that is the
18349
+ * continuous flow. Beyond this window (a fling), or backwards, a jump to an
18350
+ * independently-decodable keyframe is cheaper than the burst; and because a
18351
+ * thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
18352
+ * congestion, so degradation to jump-only is emergent, and visible per push
18353
+ * (`mode` on every scrub-feeder:pushed line).
18354
+ */
18355
+ var SCRUB_SWEEP_WINDOW_MS = 4e3;
18356
+ /**
18357
+ * Receiver-credit cap for scrub pushes: at most this many keyframes may be
18358
+ * OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
18359
+ * the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
18360
+ * a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
18361
+ * floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
18362
+ * Backpressure is the receiver's: a push spends a credit, a rendered frame
18363
+ * restores one, and with zero credit the newest target PARKS on
18364
+ * `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
18365
+ */
18366
+ var SCRUB_CREDIT_CAP = 2;
18367
+ /**
18368
+ * How far a `gap` locate may be SNAPPED onto real footage.
18369
+ *
18370
+ * A snap is a CORRECTION — the playhead landed a moment outside coverage and
18371
+ * the operator plainly means the footage beside it — not a SEARCH for wherever
18372
+ * footage happens to exist. The distinction is not cosmetic, because the index
18373
+ * that answers `locate` cannot tell "there is no footage here" apart from "I
18374
+ * have not walked this far back yet": it is rebuilt by walking the archive on
18375
+ * every recorder start, and that walk takes minutes.
18376
+ *
18377
+ * Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
18378
+ * `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
18379
+ * minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
18380
+ * tick into a segment read against the addon that was busy re-walking, each one
18381
+ * blew the 8 s budget, and both surfaces died. Bounded, all of those answer
18382
+ * `gap` for free — exactly as they did before snapping existed.
18383
+ *
18384
+ * 120 s covers the real cases by a wide margin: an inter-segment crack is
18385
+ * 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
18386
+ * that the server is answering a question the viewer did not ask; the viewer
18387
+ * holds the coverage ranges and can offer "jump to nearest footage" as an
18388
+ * action instead.
18389
+ */
18390
+ var SNAP_MAX_DISTANCE_MS = 12e4;
18391
+ /**
18392
+ * Decide where a `gap` locate should snap: forward to the next footage edge,
18393
+ * else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
18394
+ * also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
18395
+ * {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
18396
+ * cursor — and so rejecting costs NO segment I/O at all.
18397
+ */
18398
+ function resolveSnapTarget(loc, requestedMs) {
18399
+ const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
18400
+ if (snapMs === null) return { kind: "none" };
18401
+ const distanceMs = Math.abs(snapMs - requestedMs);
18402
+ if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
18403
+ kind: "too-far",
18404
+ snapMs,
18405
+ distanceMs
18406
+ };
18407
+ return {
18408
+ kind: "snap",
18409
+ snapMs
18410
+ };
18411
+ }
18104
18412
  var RecordedFeeder = class {
18105
18413
  deps;
18106
18414
  clock = new MonotonicClock();
@@ -18143,6 +18451,16 @@ var RecordedFeeder = class {
18143
18451
  */
18144
18452
  scrubbing = false;
18145
18453
  scrubToken = 0;
18454
+ /** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
18455
+ scrubCredit = SCRUB_CREDIT_CAP;
18456
+ /**
18457
+ * Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
18458
+ * `1`; a sweep enqueues however many predicted frames it put on the wire. Each
18459
+ * `scrubAck` settles one frame off the head, and the credit that push spent
18460
+ * comes back only when the head reaches zero — so a sweep cannot be refunded
18461
+ * once per frame for the single credit it cost.
18462
+ */
18463
+ scrubOutstanding = [];
18146
18464
  /** The newest un-processed drag target (null when the tick cycle has drained). */
18147
18465
  scrubPendingEpochMs = null;
18148
18466
  /**
@@ -18173,6 +18491,15 @@ var RecordedFeeder = class {
18173
18491
  * sign of `(newTarget − this)` is the drag direction that steers prefetch.
18174
18492
  */
18175
18493
  scrubPrevTarget = null;
18494
+ /** The last AU actually pushed this drag — the sweep-continuity cursor
18495
+ * (which fragment, and how deep into its AU list). Null until a jump lands. */
18496
+ scrubCursor = null;
18497
+ /** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
18498
+ scrubJumps = 0;
18499
+ scrubSweeps = 0;
18500
+ scrubAusPushed = 0;
18501
+ /** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
18502
+ scrubNoFootageLogged = false;
18176
18503
  /**
18177
18504
  * Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
18178
18505
  * − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
@@ -18210,9 +18537,19 @@ var RecordedFeeder = class {
18210
18537
  async seek(epochMs) {
18211
18538
  const token = ++this.seekToken;
18212
18539
  if (this.scrubbing) {
18540
+ this.deps.onLog?.("scrub-feeder:drag-end", {
18541
+ jumps: this.scrubJumps,
18542
+ sweeps: this.scrubSweeps,
18543
+ aus: this.scrubAusPushed
18544
+ });
18213
18545
  this.scrubbing = false;
18214
18546
  this.scrubToken++;
18215
18547
  this.scrubSegment = null;
18548
+ this.scrubCursor = null;
18549
+ this.scrubJumps = 0;
18550
+ this.scrubSweeps = 0;
18551
+ this.scrubAusPushed = 0;
18552
+ this.scrubOutstanding = [];
18216
18553
  this.scrubCache.clear();
18217
18554
  this.scrubPrevTarget = null;
18218
18555
  this.scrubDirRun = 0;
@@ -18223,29 +18560,67 @@ var RecordedFeeder = class {
18223
18560
  }
18224
18561
  this.advanceToken++;
18225
18562
  this.inFlightSeekEpochMs = epochMs;
18563
+ let snapInfo = null;
18226
18564
  try {
18227
18565
  this.deps.onState("loading");
18228
18566
  if (this.clockHasAdvanced) {
18229
18567
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18230
18568
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18231
18569
  }
18232
- const loc = await this.withTimeout(this.deps.locate({
18570
+ let loc = await this.withTimeout(this.deps.locate({
18233
18571
  deviceId: this.deps.deviceId,
18234
18572
  profile: this.deps.profile,
18235
18573
  epochMs
18236
18574
  }));
18237
18575
  if (this.disposed || token !== this.seekToken) return;
18576
+ let landMs = epochMs;
18238
18577
  if (loc.kind === "gap") {
18239
- this.currentSegment = null;
18240
- this.lastFedVideoIdx = -1;
18241
- this.stopPositionReports();
18242
- this.deps.onState("gap");
18243
- return;
18578
+ const decision = resolveSnapTarget(loc, epochMs);
18579
+ if (decision.kind !== "snap") {
18580
+ if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
18581
+ requestedMs: epochMs,
18582
+ nearestMs: decision.snapMs,
18583
+ distanceMs: decision.distanceMs
18584
+ });
18585
+ else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
18586
+ this.currentSegment = null;
18587
+ this.lastFedVideoIdx = -1;
18588
+ this.stopPositionReports();
18589
+ this.deps.onState("gap");
18590
+ return;
18591
+ }
18592
+ const snapped = await this.withTimeout(this.deps.locate({
18593
+ deviceId: this.deps.deviceId,
18594
+ profile: this.deps.profile,
18595
+ epochMs: decision.snapMs
18596
+ }));
18597
+ if (this.disposed || token !== this.seekToken) return;
18598
+ if (snapped.kind !== "segment") {
18599
+ this.deps.onLog?.("recorded:seek-no-footage", {
18600
+ requestedMs: epochMs,
18601
+ snappedMs: decision.snapMs
18602
+ });
18603
+ this.currentSegment = null;
18604
+ this.lastFedVideoIdx = -1;
18605
+ this.stopPositionReports();
18606
+ this.deps.onState("gap");
18607
+ return;
18608
+ }
18609
+ this.deps.onLog?.("recorded:seek-snapped", {
18610
+ requestedMs: epochMs,
18611
+ snappedMs: decision.snapMs
18612
+ });
18613
+ loc = snapped;
18614
+ landMs = decision.snapMs;
18615
+ snapInfo = {
18616
+ requestedMs: epochMs,
18617
+ snappedMs: decision.snapMs
18618
+ };
18244
18619
  }
18245
18620
  this.cursorMs = loc.startMs;
18246
18621
  const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
18247
18622
  if (this.disposed || token !== this.seekToken || loaded === null) return;
18248
- this.feedPrefix(loc, loaded.video, epochMs);
18623
+ this.feedPrefix(loc, loaded.video, landMs);
18249
18624
  if (this.rate === 0) {
18250
18625
  this.deps.onState("paused");
18251
18626
  this.deps.onPosition(this.cursorMs);
@@ -18265,9 +18640,20 @@ var RecordedFeeder = class {
18265
18640
  },
18266
18641
  resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
18267
18642
  });
18268
- } catch {
18643
+ } catch (err) {
18269
18644
  if (!this.disposed && token === this.seekToken) {
18270
18645
  this.stopPositionReports();
18646
+ if (snapInfo !== null) {
18647
+ this.deps.onLog?.("recorded:seek-snap-load-failed", {
18648
+ requestedMs: snapInfo.requestedMs,
18649
+ snappedMs: snapInfo.snappedMs,
18650
+ error: errMsg(err)
18651
+ });
18652
+ this.currentSegment = null;
18653
+ this.lastFedVideoIdx = -1;
18654
+ this.deps.onState("gap");
18655
+ return;
18656
+ }
18271
18657
  this.deps.onState("error");
18272
18658
  }
18273
18659
  } finally {
@@ -18322,8 +18708,15 @@ var RecordedFeeder = class {
18322
18708
  this.scrubToken++;
18323
18709
  this.scrubPendingEpochMs = null;
18324
18710
  this.scrubSegment = null;
18711
+ this.scrubCredit = SCRUB_CREDIT_CAP;
18325
18712
  this.scrubPrevTarget = null;
18326
18713
  this.scrubDirRun = 0;
18714
+ this.scrubCursor = null;
18715
+ this.scrubJumps = 0;
18716
+ this.scrubSweeps = 0;
18717
+ this.scrubAusPushed = 0;
18718
+ this.scrubNoFootageLogged = false;
18719
+ this.scrubOutstanding = [];
18327
18720
  if (this.scrubTimer) {
18328
18721
  clearTimeout(this.scrubTimer);
18329
18722
  this.scrubTimer = null;
@@ -18343,6 +18736,24 @@ var RecordedFeeder = class {
18343
18736
  if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18344
18737
  }
18345
18738
  /**
18739
+ * Receiver credit: the viewer RENDERED a scrub frame. Restores one push
18740
+ * credit (capped — over-acking never banks a burst) and, when a target sat
18741
+ * parked on an exhausted budget, resumes the tick. This — not any timer — is
18742
+ * what paces scrub pushes to what the link actually delivers.
18743
+ */
18744
+ scrubAck() {
18745
+ if (this.disposed) return;
18746
+ const head = this.scrubOutstanding[0];
18747
+ if (head === void 0) return;
18748
+ if (head > 1) {
18749
+ this.scrubOutstanding[0] = head - 1;
18750
+ return;
18751
+ }
18752
+ this.scrubOutstanding.shift();
18753
+ this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
18754
+ if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
18755
+ }
18756
+ /**
18346
18757
  * One coalesced feeder-scrub push. Captures the newest pending target + the
18347
18758
  * current mode generation, resolves the covering fragment (reusing the memo'd
18348
18759
  * one when the target stays inside it), picks the nearest keyframe ≤ the
@@ -18353,25 +18764,68 @@ var RecordedFeeder = class {
18353
18764
  */
18354
18765
  async scrubTick() {
18355
18766
  const gen = this.scrubToken;
18767
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
18768
+ this.scrubTimer = null;
18769
+ return;
18770
+ }
18771
+ if (this.scrubCredit <= 0) {
18772
+ this.scrubTimer = null;
18773
+ this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
18774
+ return;
18775
+ }
18356
18776
  const target = this.scrubPendingEpochMs;
18357
18777
  this.scrubPendingEpochMs = null;
18358
- if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
18778
+ if (target === null) {
18359
18779
  this.scrubTimer = null;
18360
18780
  return;
18361
18781
  }
18362
18782
  try {
18363
- const loc = await this.withTimeout(this.deps.locate({
18783
+ let loc = await this.withTimeout(this.deps.locate({
18364
18784
  deviceId: this.deps.deviceId,
18365
18785
  profile: this.deps.profile,
18366
18786
  epochMs: target
18367
18787
  }));
18368
18788
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18369
- if (loc.kind === "gap") return;
18789
+ let effTarget = target;
18790
+ if (loc.kind === "gap") {
18791
+ const decision = resolveSnapTarget(loc, target);
18792
+ if (decision.kind !== "snap") {
18793
+ if (!this.scrubNoFootageLogged) {
18794
+ this.scrubNoFootageLogged = true;
18795
+ if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
18796
+ epochMs: target,
18797
+ nearestMs: decision.snapMs,
18798
+ distanceMs: decision.distanceMs
18799
+ });
18800
+ else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18801
+ }
18802
+ return;
18803
+ }
18804
+ const snapped = await this.withTimeout(this.deps.locate({
18805
+ deviceId: this.deps.deviceId,
18806
+ profile: this.deps.profile,
18807
+ epochMs: decision.snapMs
18808
+ }));
18809
+ if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18810
+ if (snapped.kind !== "segment") {
18811
+ if (!this.scrubNoFootageLogged) {
18812
+ this.scrubNoFootageLogged = true;
18813
+ this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
18814
+ }
18815
+ return;
18816
+ }
18817
+ this.deps.onLog?.("scrub-feeder:snapped", {
18818
+ requestedMs: target,
18819
+ snappedMs: decision.snapMs
18820
+ });
18821
+ loc = snapped;
18822
+ effTarget = decision.snapMs;
18823
+ }
18370
18824
  let seg = this.scrubSegment;
18371
18825
  if (!seg || seg.startMs !== loc.startMs) {
18372
18826
  seg = this.scrubCacheGet(loc.startMs);
18373
18827
  if (!seg) {
18374
- const loaded = await this.loadAus(loc.startMs, false, true);
18828
+ const loaded = await this.loadAus(loc.startMs, false);
18375
18829
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18376
18830
  seg = {
18377
18831
  startMs: loc.startMs,
@@ -18382,26 +18836,80 @@ var RecordedFeeder = class {
18382
18836
  }
18383
18837
  this.scrubSegment = seg;
18384
18838
  }
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);
18839
+ const prevTarget = this.scrubPrevTarget;
18840
+ const cursor = this.scrubCursor;
18841
+ const targetIdx = this.pickScrubIndex(seg, effTarget, false);
18842
+ const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
18843
+ const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
18844
+ const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
18845
+ const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
18846
+ let aus;
18847
+ let firstIdx;
18848
+ let mode;
18849
+ let crossesFragment = false;
18850
+ if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
18851
+ mode = "sweep";
18852
+ firstIdx = cursor.idx + 1;
18853
+ aus = seg.aus.slice(firstIdx, targetIdx + 1);
18854
+ } else if (withinWindow && adjacentForward) {
18855
+ mode = "sweep";
18856
+ firstIdx = 0;
18857
+ aus = seg.aus.slice(0, targetIdx + 1);
18858
+ crossesFragment = true;
18859
+ } else {
18860
+ mode = "jump";
18861
+ firstIdx = this.pickScrubIndex(seg, effTarget, true);
18862
+ const kf = seg.aus[firstIdx];
18863
+ aus = kf === void 0 ? [] : [kf];
18864
+ }
18865
+ const lastAu = aus[aus.length - 1];
18866
+ if (lastAu === void 0) {
18867
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18868
+ this.scrubPrevTarget = effTarget;
18869
+ return;
18870
+ }
18871
+ if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
18872
+ else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
18873
+ for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
18874
+ this.scrubOutstanding.push(aus.length);
18875
+ this.scrubCredit -= 1;
18389
18876
  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);
18877
+ if (mode === "jump") this.scrubJumps += 1;
18878
+ else this.scrubSweeps += 1;
18879
+ this.scrubAusPushed += aus.length;
18880
+ this.scrubCursor = {
18881
+ fragStartMs: seg.startMs,
18882
+ fragDurMs: seg.durMs,
18883
+ idx: firstIdx + aus.length - 1
18884
+ };
18885
+ this.deps.onLog?.("scrub-feeder:pushed", {
18886
+ epochMs: effTarget,
18887
+ mode,
18888
+ aus: aus.length,
18889
+ codec: lastAu.codec,
18890
+ bytes: lastAu.data.length,
18891
+ creditLeft: this.scrubCredit
18892
+ });
18893
+ this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
18894
+ this.maybeScrubPrefetch(seg, effTarget, gen);
18895
+ } catch (err) {
18896
+ this.deps.onLog?.("scrub-feeder:tick-failed", {
18897
+ epochMs: target,
18898
+ error: err instanceof Error ? err.message : String(err)
18899
+ });
18900
+ } finally {
18901
+ 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
18902
  else this.scrubTimer = null;
18395
18903
  }
18396
18904
  }
18397
18905
  /**
18398
- * Index of the nearest keyframe at-or-before the drag target within `seg`
18906
+ * Index of the nearest AU at-or-before the drag target within `seg`
18399
18907
  * (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.
18908
+ * guarantees). `keyframesOnly` restricts the pick to sync frames the JUMP
18909
+ * mode, whose pushed frame must be standalone-decodable; sweep mode picks
18910
+ * ANY frame, which is what makes the flow frame-granular.
18403
18911
  */
18404
- pickScrubKeyframe(seg, target) {
18912
+ pickScrubIndex(seg, target, keyframesOnly) {
18405
18913
  const first = seg.aus[0];
18406
18914
  if (!first) return 0;
18407
18915
  const offsetMs = target - seg.startMs;
@@ -18410,7 +18918,7 @@ var RecordedFeeder = class {
18410
18918
  const au = seg.aus[i];
18411
18919
  if (!au) continue;
18412
18920
  if (au.ptsMs - first.ptsMs > offsetMs) break;
18413
- if (au.keyframe) idx = i;
18921
+ if (!keyframesOnly || au.keyframe) idx = i;
18414
18922
  }
18415
18923
  return idx;
18416
18924
  }
@@ -18481,7 +18989,7 @@ var RecordedFeeder = class {
18481
18989
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
18482
18990
  if (loc.kind !== "segment") return;
18483
18991
  if (this.scrubCache.has(loc.startMs)) return;
18484
- const loaded = await this.loadAus(loc.startMs, false, true);
18992
+ const loaded = await this.loadAus(loc.startMs, false);
18485
18993
  if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
18486
18994
  this.scrubCachePut({
18487
18995
  startMs: loc.startMs,
@@ -18831,10 +19339,8 @@ var RecordedFeeder = class {
18831
19339
  * access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
18832
19340
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
18833
19341
  * 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) {
19342
+ * (paused scrubs would throw it away). */
19343
+ async loadAus(startMs, wantAudio = true) {
18838
19344
  const token = this.seekToken;
18839
19345
  const bytes = await this.withTimeout(this.deps.readBytes({
18840
19346
  deviceId: this.deps.deviceId,
@@ -18842,8 +19348,7 @@ var RecordedFeeder = class {
18842
19348
  startMs
18843
19349
  }));
18844
19350
  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));
19351
+ const video = await this.withTimeout(this.deps.demux(bytes));
18847
19352
  if (this.disposed || token !== this.seekToken) return null;
18848
19353
  let audio = [];
18849
19354
  if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
@@ -18889,6 +19394,11 @@ var RecordedFeeder = class {
18889
19394
  }
18890
19395
  /** Stop everything: invalidate in-flight work and tear down the position timer. */
18891
19396
  dispose() {
19397
+ if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
19398
+ jumps: this.scrubJumps,
19399
+ sweeps: this.scrubSweeps,
19400
+ aus: this.scrubAusPushed
19401
+ });
18892
19402
  this.disposed = true;
18893
19403
  this.seekToken++;
18894
19404
  this.advanceToken++;
@@ -18898,6 +19408,12 @@ var RecordedFeeder = class {
18898
19408
  this.scrubCache.clear();
18899
19409
  this.scrubPrevTarget = null;
18900
19410
  this.scrubDirRun = 0;
19411
+ this.scrubCursor = null;
19412
+ this.scrubJumps = 0;
19413
+ this.scrubSweeps = 0;
19414
+ this.scrubAusPushed = 0;
19415
+ this.scrubNoFootageLogged = false;
19416
+ this.scrubOutstanding = [];
18901
19417
  if (this.scrubTimer) {
18902
19418
  clearTimeout(this.scrubTimer);
18903
19419
  this.scrubTimer = null;
@@ -18925,6 +19441,16 @@ var TimelineSession = class {
18925
19441
  this.deps = deps;
18926
19442
  deps.session.setLiveFeedGate(false);
18927
19443
  }
19444
+ /**
19445
+ * Which source the track is currently carrying. Read by the broker's
19446
+ * adaptive ladder, which must NOT act while the pointer is off live: a tier
19447
+ * switch is a close-and-recreate re-offer, and that disposes this
19448
+ * coordinator — the viewer silently lands back on live at the wrong moment
19449
+ * with no code path to restore the pointer.
19450
+ */
19451
+ get currentMode() {
19452
+ return this.mode;
19453
+ }
18928
19454
  async handleControl(msg) {
18929
19455
  switch (msg.t) {
18930
19456
  case "playRecorded":
@@ -18941,6 +19467,9 @@ var TimelineSession = class {
18941
19467
  case "scrubTo":
18942
19468
  if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
18943
19469
  break;
19470
+ case "scrubAck":
19471
+ if (this.mode === "recorded") this.feeder?.scrubAck();
19472
+ break;
18944
19473
  case "setRate":
18945
19474
  this.feeder?.setRate(msg.rate);
18946
19475
  break;
@@ -18948,6 +19477,7 @@ var TimelineSession = class {
18948
19477
  if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
18949
19478
  break;
18950
19479
  case "setProfile":
19480
+ if (msg.profile === this.profile) break;
18951
19481
  this.profile = msg.profile;
18952
19482
  if (this.mode === "recorded") this.enterRecorded();
18953
19483
  break;
@@ -25662,25 +26192,6 @@ async function extractSamples(segment, segmentStartMs) {
25662
26192
  samples: collected
25663
26193
  } : null;
25664
26194
  }
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
26195
  //#endregion
25685
26196
  //#region src/stream-broker/recorded/avcc-annexb.ts
25686
26197
  /**
@@ -26184,33 +26695,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
26184
26695
  "pipe:1"
26185
26696
  ];
26186
26697
  /**
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
26698
  * Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
26215
26699
  * access units.
26216
26700
  *
@@ -26236,113 +26720,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
26236
26720
  codec
26237
26721
  }));
26238
26722
  }
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
26723
  //#endregion
26276
26724
  //#region src/stream-broker/recorded/segment-demux-js.ts
26277
26725
  /**
26278
26726
  * segment-demux-js — PURE-JS in-process demux for recorded playback.
26279
26727
  *
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`.
26728
+ * {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} FULL playback
26729
+ * (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
26730
+ * Both playback AND scrub demux through this since sweep mode (the scrub train
26731
+ * pushes predicted frames, so the whole GOP is needed either way).
26285
26732
  *
26286
- * Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
26287
- * a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
26733
+ * Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
26734
+ * in-process mp4box parse. A recorded `.m4s`
26288
26735
  * is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
26289
26736
  * carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
26290
26737
  * sample(s) are all in the bytes we already hold — no init segment to fetch.
26291
26738
  *
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.
26739
+ * Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
26740
+ * module already ships — box parsing is NOT duplicated here) yields the codec
26741
+ * `description` + length-prefixed samples; sync samples are fused with their
26742
+ * parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
26743
+ * ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
26744
+ * the first sample), exactly like the ffmpeg demux's PES-derived deltas
26745
+ * `clampIntoSegment`/position reporting are unaffected.
26305
26746
  */
26306
26747
  /** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
26307
26748
  function isHevcCodec(codec) {
26308
26749
  return /^h[ev][vc]1\./i.test(codec);
26309
26750
  }
26310
26751
  /**
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
26752
  * Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
26347
26753
  * EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
26348
26754
  * `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
@@ -26651,7 +27057,8 @@ var ADAPTIVE_CONFIG = {
26651
27057
  minFailPercent: .05,
26652
27058
  waitAfterResetMs: 8e3,
26653
27059
  upgradeHealthyMs: 6e4,
26654
- minTimeBetweenChangesMs: 6e4
27060
+ minTimeBetweenChangesMs: 6e4,
27061
+ maxUpgradeBackoffMs: 9e5
26655
27062
  };
26656
27063
  /**
26657
27064
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -26837,6 +27244,24 @@ var BrokerWebrtcServer = class {
26837
27244
  /** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
26838
27245
  brokers = /* @__PURE__ */ new Map();
26839
27246
  /**
27247
+ * Post-session warmth: when the LAST WebRTC session on a broker closes, a
27248
+ * ghost subscription keeps the source dialed and its RTP ring warm for the
27249
+ * linger window, so the dominant remote pattern — reopen within minutes —
27250
+ * reconnects with an instant-start GOP replay instead of re-paying the
27251
+ * cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
27252
+ * attribution enum; the label answers "who keeps cam X warm?" in the
27253
+ * operator widget.
27254
+ */
27255
+ brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
27256
+ const broker = this.brokers.get(brokerId);
27257
+ if (!broker) return null;
27258
+ return broker.onEncodedData(() => {}, {
27259
+ kind: "warmup",
27260
+ label: "webrtc-linger",
27261
+ media: "both"
27262
+ });
27263
+ } });
27264
+ /**
26840
27265
  * Resolves a brokerId to its current profile tier (`CamProfile`)
26841
27266
  * via the manager's `assignments` map, or `null` for brokers that
26842
27267
  * are only kept alive by a manual activation. Injected by the
@@ -26999,16 +27424,20 @@ var BrokerWebrtcServer = class {
26999
27424
  * RTC controller / Alexa handler closes the session the same way.
27000
27425
  */
27001
27426
  async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
27427
+ const offerStart = Date.now();
27002
27428
  const setup = await this.setupSessionForBroker(streamId, hints, {
27003
27429
  ...opts,
27004
27430
  clientOfferSdp
27005
27431
  });
27432
+ const setupMs = Date.now() - offerStart;
27006
27433
  try {
27007
27434
  if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
27435
+ const negotiateStart = Date.now();
27008
27436
  const answer = await setup.session.handleOffer({
27009
27437
  sdp: clientOfferSdp,
27010
27438
  type: "offer"
27011
27439
  }, { nonTrickle: opts.nonTrickle === true });
27440
+ const negotiateMs = Date.now() - negotiateStart;
27012
27441
  setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
27013
27442
  sessionId: setup.sessionId,
27014
27443
  deviceId: setup.deviceId,
@@ -27016,7 +27445,11 @@ var BrokerWebrtcServer = class {
27016
27445
  iceServers: setup.iceServerCount,
27017
27446
  codec: setup.sessionCodec,
27018
27447
  useRtpRepacketizer: setup.useRtpRepacketizer,
27019
- relayOnly: opts.relayOnly === true
27448
+ relayOnly: opts.relayOnly === true,
27449
+ setupMs,
27450
+ negotiateMs,
27451
+ totalMs: Date.now() - offerStart,
27452
+ ...setup.setupTimings
27020
27453
  } });
27021
27454
  return {
27022
27455
  sessionId: setup.sessionId,
@@ -27043,6 +27476,11 @@ var BrokerWebrtcServer = class {
27043
27476
  */
27044
27477
  async setupSessionForBroker(streamId, hints, opts) {
27045
27478
  if (this.stopped) throw new Error("Server stopped");
27479
+ const setupStart = Date.now();
27480
+ let ensureTierMs = 0;
27481
+ let warmUpMs = 0;
27482
+ let iceResolveMs = 0;
27483
+ let hwAccelMs = 0;
27046
27484
  const isAdaptiveStream = streamId.endsWith("/adaptive");
27047
27485
  const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
27048
27486
  const resolveHints = intent !== void 0 ? {
@@ -27052,10 +27490,13 @@ var BrokerWebrtcServer = class {
27052
27490
  if (isAdaptiveStream) {
27053
27491
  const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
27054
27492
  const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
27493
+ const ensureTierStart = Date.now();
27055
27494
  try {
27056
27495
  await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
27057
27496
  } catch (err) {
27058
27497
  throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${errMsg(err)}`);
27498
+ } finally {
27499
+ ensureTierMs = Date.now() - ensureTierStart;
27059
27500
  }
27060
27501
  }
27061
27502
  const brokerId = this.resolveBrokerId(streamId, resolveHints);
@@ -27077,6 +27518,7 @@ var BrokerWebrtcServer = class {
27077
27518
  kind: "warmup",
27078
27519
  media: "both"
27079
27520
  });
27521
+ const warmUpStart = Date.now();
27080
27522
  await new Promise((resolve) => {
27081
27523
  const timer = setTimeout(() => {
27082
27524
  sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
@@ -27092,9 +27534,11 @@ var BrokerWebrtcServer = class {
27092
27534
  resolve();
27093
27535
  });
27094
27536
  });
27537
+ warmUpMs = Date.now() - warmUpStart;
27095
27538
  sessionLogger.info("broker warm-up complete", { meta: {
27096
27539
  brokerId,
27097
- detectedCodec: broker.getStats().codec ?? null
27540
+ detectedCodec: broker.getStats().codec ?? null,
27541
+ waitedMs: warmUpMs
27098
27542
  } });
27099
27543
  }
27100
27544
  const brokerCodec = broker.getStats().codec ?? "h264";
@@ -27278,6 +27722,7 @@ var BrokerWebrtcServer = class {
27278
27722
  }
27279
27723
  }, annexbAttribution);
27280
27724
  warmUpUnsubscribe?.();
27725
+ this.brokerLinger.cancel(brokerId);
27281
27726
  const preBuffer = broker.getPreBuffer();
27282
27727
  const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
27283
27728
  for (const pkt of preBuffer) if (pkt.type === "video") {
@@ -27304,7 +27749,9 @@ var BrokerWebrtcServer = class {
27304
27749
  }
27305
27750
  });
27306
27751
  }
27752
+ const iceResolveStart = Date.now();
27307
27753
  const allIceServers = await this.resolveIceServers();
27754
+ iceResolveMs = Date.now() - iceResolveStart;
27308
27755
  const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
27309
27756
  const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
27310
27757
  if (stunOnly.length === 0) return null;
@@ -27329,13 +27776,16 @@ var BrokerWebrtcServer = class {
27329
27776
  sourceProfileLevelId,
27330
27777
  offeredProfileLevelIds
27331
27778
  } });
27779
+ const hwAccelStart = Date.now();
27780
+ const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
27781
+ hwAccelMs = Date.now() - hwAccelStart;
27332
27782
  session = new AdaptiveSession({
27333
27783
  sessionId,
27334
27784
  source,
27335
27785
  sourceCodec: sessionCodec,
27336
27786
  transcodeToBaseline,
27337
27787
  transcodeProfile: effectiveTranscodeProfile,
27338
- transcodeDecodeHwAccel: await this.getTranscodeDecodeHwAccel(),
27788
+ transcodeDecodeHwAccel,
27339
27789
  resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
27340
27790
  iceConfig: {
27341
27791
  iceServers,
@@ -27447,7 +27897,6 @@ var BrokerWebrtcServer = class {
27447
27897
  const locateRecorded = this.locateRecordedSegment;
27448
27898
  const readRecorded = this.readRecordedSegmentBytes;
27449
27899
  const resolveFfmpeg = this.resolveFfmpegPath;
27450
- let scrubJsFallbackLogged = false;
27451
27900
  let fullJsFallbackLogged = false;
27452
27901
  let audioOpusFallbackLogged = false;
27453
27902
  if (locateRecorded && readRecorded) {
@@ -27475,19 +27924,6 @@ var BrokerWebrtcServer = class {
27475
27924
  } });
27476
27925
  }
27477
27926
  }),
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
27927
  demuxAudio: opusSession ? async (b) => {
27492
27928
  return (await demuxSegmentAudioOpusPreferJs(b, {
27493
27929
  ffmpegPath: resolveFfmpeg?.(),
@@ -27523,10 +27959,17 @@ var BrokerWebrtcServer = class {
27523
27959
  } });
27524
27960
  timeline._emitState(s);
27525
27961
  },
27526
- onPosition: (epochMs) => timeline._emitPosition(epochMs)
27962
+ onPosition: (epochMs) => timeline._emitPosition(epochMs),
27963
+ onLog: (msg, data) => sessionLogger.info(msg, { meta: {
27964
+ sessionId,
27965
+ deviceId,
27966
+ profile,
27967
+ ...data
27968
+ } })
27527
27969
  });
27528
27970
  }
27529
27971
  });
27972
+ entry.timeline = timeline;
27530
27973
  sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
27531
27974
  sessionId,
27532
27975
  deviceId
@@ -27576,7 +28019,7 @@ var BrokerWebrtcServer = class {
27576
28019
  };
27577
28020
  if (streamId.endsWith("/adaptive")) {
27578
28021
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
27579
- this.startAdaptive(entry, deviceId, initialTier, sessionId);
28022
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
27580
28023
  }
27581
28024
  return {
27582
28025
  sessionId,
@@ -27586,7 +28029,14 @@ var BrokerWebrtcServer = class {
27586
28029
  brokerId,
27587
28030
  sessionCodec,
27588
28031
  useRtpRepacketizer,
27589
- iceServerCount: iceServers.length
28032
+ iceServerCount: iceServers.length,
28033
+ setupTimings: {
28034
+ setupTotalMs: Date.now() - setupStart,
28035
+ ensureTierMs,
28036
+ warmUpMs,
28037
+ iceResolveMs,
28038
+ hwAccelMs
28039
+ }
27590
28040
  };
27591
28041
  }
27592
28042
  async handleAnswer(sessionId, sdpAnswer) {
@@ -27636,6 +28086,7 @@ var BrokerWebrtcServer = class {
27636
28086
  async stop() {
27637
28087
  if (this.stopped) return;
27638
28088
  this.stopped = true;
28089
+ this.brokerLinger.stop();
27639
28090
  const closes = [];
27640
28091
  for (const [sid, entry] of this.sessions) {
27641
28092
  this.cleanupSession(sid);
@@ -27670,9 +28121,10 @@ var BrokerWebrtcServer = class {
27670
28121
  * whichever produces the higher loss ratio across its window. The async
27671
28122
  * poll is guarded against overlapping ticks via `tickInFlight`.
27672
28123
  */
27673
- startAdaptive(entry, deviceId, initialTier, sessionId) {
28124
+ startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
27674
28125
  const controller = new AdaptiveController({
27675
28126
  initialTier,
28127
+ priorFailures,
27676
28128
  config: ADAPTIVE_CONFIG,
27677
28129
  onSwitchTier: (tier) => {
27678
28130
  this.switchSessionTier(entry, deviceId, tier, sessionId);
@@ -27692,6 +28144,10 @@ var BrokerWebrtcServer = class {
27692
28144
  tickInFlight = true;
27693
28145
  (async () => {
27694
28146
  try {
28147
+ if (entry.timeline?.currentMode === "recorded") {
28148
+ controller.suspend(Date.now());
28149
+ return;
28150
+ }
27695
28151
  const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
27696
28152
  const now = Date.now();
27697
28153
  if (pct !== null && pct !== void 0 && pct > 0) {
@@ -27723,9 +28179,11 @@ var BrokerWebrtcServer = class {
27723
28179
  */
27724
28180
  switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
27725
28181
  const prev = this.adaptiveIntent.get(sessionId);
28182
+ const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
27726
28183
  this.adaptiveIntent.set(sessionId, {
27727
28184
  prefersTier: tier,
27728
- forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
28185
+ forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
28186
+ failures
27729
28187
  });
27730
28188
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
27731
28189
  entry.session.pendingRenegotiation = {
@@ -27739,7 +28197,8 @@ var BrokerWebrtcServer = class {
27739
28197
  deviceId,
27740
28198
  tier,
27741
28199
  forceTranscodeDown,
27742
- epoch
28200
+ epoch,
28201
+ failures: Object.fromEntries(failures)
27743
28202
  }
27744
28203
  });
27745
28204
  }
@@ -27747,6 +28206,12 @@ var BrokerWebrtcServer = class {
27747
28206
  const entry = this.sessions.get(sessionId);
27748
28207
  if (!entry) return;
27749
28208
  this.sessions.delete(sessionId);
28209
+ let brokerStillServed = false;
28210
+ for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
28211
+ brokerStillServed = true;
28212
+ break;
28213
+ }
28214
+ if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
27750
28215
  this.adaptiveIntent.delete(sessionId);
27751
28216
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
27752
28217
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -28330,6 +28795,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
28330
28795
  }
28331
28796
  };
28332
28797
  //#endregion
28798
+ //#region src/stream-broker/event-loop-stall-monitor.ts
28799
+ var DEFAULT_INTERVAL_MS = 100;
28800
+ var DEFAULT_THRESHOLD_MS = 250;
28801
+ var EventLoopStallMonitor = class {
28802
+ intervalMs;
28803
+ thresholdMs;
28804
+ now;
28805
+ onStall;
28806
+ timer = null;
28807
+ lastTickAt = 0;
28808
+ constructor(options) {
28809
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
28810
+ this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
28811
+ this.now = options.now ?? (() => Date.now());
28812
+ this.onStall = options.onStall;
28813
+ }
28814
+ start() {
28815
+ if (this.timer) return;
28816
+ this.lastTickAt = this.now();
28817
+ this.timer = setInterval(() => {
28818
+ const at = this.now();
28819
+ const lateMs = at - this.lastTickAt - this.intervalMs;
28820
+ this.lastTickAt = at;
28821
+ if (lateMs >= this.thresholdMs) try {
28822
+ this.onStall(lateMs);
28823
+ } catch {}
28824
+ }, this.intervalMs);
28825
+ this.timer.unref?.();
28826
+ }
28827
+ stop() {
28828
+ if (!this.timer) return;
28829
+ clearInterval(this.timer);
28830
+ this.timer = null;
28831
+ }
28832
+ };
28833
+ //#endregion
28333
28834
  //#region src/stream-broker/addon.ts
28334
28835
  /**
28335
28836
  * Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
@@ -28422,6 +28923,8 @@ var StreamBrokerAddon = class extends BaseAddon {
28422
28923
  webrtcServer = null;
28423
28924
  /** Handle for the embed SPA data-plane listener (disposed on shutdown). */
28424
28925
  embedDataPlane = null;
28926
+ /** Names this process's event-loop stalls — see onInitialize. */
28927
+ loopStallMonitor = null;
28425
28928
  /**
28426
28929
  * Snapshot-equality cache for the broker-metrics emit. Each
28427
28930
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
@@ -28522,6 +29025,10 @@ var StreamBrokerAddon = class extends BaseAddon {
28522
29025
  }
28523
29026
  }
28524
29027
  async onInitialize() {
29028
+ this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
29029
+ this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
29030
+ } });
29031
+ this.loopStallMonitor.start();
28525
29032
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
28526
29033
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
28527
29034
  this.brokerManager.setEventBus(this.ctx.eventBus);
@@ -28864,6 +29371,8 @@ var StreamBrokerAddon = class extends BaseAddon {
28864
29371
  ];
28865
29372
  }
28866
29373
  async onShutdown() {
29374
+ this.loopStallMonitor?.stop();
29375
+ this.loopStallMonitor = null;
28867
29376
  this.flushProbeSnapshots(Date.now(), true);
28868
29377
  if (this.metricsSnapshotTimer) {
28869
29378
  clearInterval(this.metricsSnapshotTimer);