replicas-computer 0.3.13 → 0.3.15

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.
package/dist/index.js CHANGED
@@ -2937,7 +2937,7 @@ async function computerStatusCommand() {
2937
2937
  }
2938
2938
 
2939
2939
  // src/version.ts
2940
- var COMPUTER_USE_VERSION = "0.3.13";
2940
+ var COMPUTER_USE_VERSION = "0.3.15";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3251,13 +3251,17 @@ import { createInterface } from "readline";
3251
3251
 
3252
3252
  // src/rtp.ts
3253
3253
  var RTP_HEADER_BYTES = 12;
3254
+ var NAL_SLICE = 1;
3254
3255
  var NAL_IDR = 5;
3256
+ var NAL_SEI = 6;
3255
3257
  var NAL_SPS = 7;
3258
+ var SEI_RECOVERY_POINT = 6;
3256
3259
  var NAL_STAP_A = 24;
3257
3260
  var NAL_FU_A = 28;
3258
3261
  var nalType = (byte) => byte & 31;
3259
3262
  var headerBytesOf = (packet) => RTP_HEADER_BYTES + (packet[0] & 15) * 4;
3260
3263
  var joinable = (nal) => nal === NAL_IDR || nal === NAL_SPS;
3264
+ var refreshFrame = (nal) => nalType(nal[0]) === NAL_SLICE || nalType(nal[0]) === NAL_SEI && nal[1] === SEI_RECOVERY_POINT;
3261
3265
  var isRtp = (data) => data.length >= RTP_HEADER_BYTES && data[0] >> 6 === 2 && (data[1] < 192 || data[1] > 223);
3262
3266
  var rtpPayload = (packet) => packet.subarray(headerBytesOf(packet));
3263
3267
  function aggregatedNals(payload) {
@@ -3286,7 +3290,10 @@ function isKeyframeStart(payload) {
3286
3290
  const fragment = fragmentHeader(payload);
3287
3291
  return fragment.start && joinable(fragment.type);
3288
3292
  }
3289
- if (type === NAL_STAP_A) return aggregatedNals(payload).nals.some((nal) => joinable(nalType(nal[0])));
3293
+ if (type === NAL_STAP_A) {
3294
+ const { nals } = aggregatedNals(payload);
3295
+ return nals.some((nal) => joinable(nalType(nal[0]))) && !nals.some(refreshFrame);
3296
+ }
3290
3297
  return joinable(type);
3291
3298
  }
3292
3299
  var RtpSplicer = class {
@@ -3363,6 +3370,8 @@ var RtpClock = class {
3363
3370
  };
3364
3371
  var startCode = new Uint8Array([0, 0, 0, 1]);
3365
3372
  var H264RtpAssembler = class {
3373
+ /** Frames abandoned for a missing or out-of-order packet; every delta after one is undecodable until a keyframe. */
3374
+ discarded = 0;
3366
3375
  clock = new RtpClock(9e4);
3367
3376
  chunks = [];
3368
3377
  key = false;
@@ -3380,6 +3389,7 @@ var H264RtpAssembler = class {
3380
3389
  }
3381
3390
  push(packet) {
3382
3391
  if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
3392
+ if (this.chunks.length > 0 || this.damaged) this.discarded += 1;
3383
3393
  this.clearFrame();
3384
3394
  }
3385
3395
  this.timestamp ??= packet.timestamp;
@@ -3412,6 +3422,7 @@ var H264RtpAssembler = class {
3412
3422
  } else this.damaged = true;
3413
3423
  if (!packet.marker) return null;
3414
3424
  if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
3425
+ this.discarded += 1;
3415
3426
  this.clearFrame();
3416
3427
  return null;
3417
3428
  }
@@ -3469,7 +3480,9 @@ function superviseEncoder(options) {
3469
3480
  const warmupFrames = active ? options.warmupFrames ?? 0 : 0;
3470
3481
  const command = options.command?.() ?? "ffmpeg";
3471
3482
  const args = options.args(socket.address().port, warmupFrames);
3472
- const child = spawn3(command, args, { env: options.env, stdio: ["ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
3483
+ const child = spawn3(command, args, { env: options.env, stdio: [options.controllable?.(command) ? "pipe" : "ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
3484
+ child.stdin?.on("error", () => {
3485
+ });
3473
3486
  child.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
3474
3487
  const generation = { id: ++nextId, child, socket, live: true, startedAt: Date.now(), warmupFrames };
3475
3488
  candidate = generation;
@@ -3539,10 +3552,18 @@ function superviseEncoder(options) {
3539
3552
  candidate = void 0;
3540
3553
  active = void 0;
3541
3554
  });
3555
+ const control = (line) => {
3556
+ for (const generation of [candidate, active]) {
3557
+ if (generation?.live && generation.child.stdin?.writable) generation.child.stdin.write(`${line}
3558
+ `);
3559
+ }
3560
+ return Boolean(active?.live && active.child.stdin?.writable);
3561
+ };
3542
3562
  return {
3543
3563
  start,
3544
3564
  replace,
3545
3565
  stop,
3566
+ control,
3546
3567
  running: () => Boolean(active && alive(active.child)),
3547
3568
  replacing: () => Boolean(candidate),
3548
3569
  generation: () => active?.id ?? 0,
@@ -3625,9 +3646,13 @@ function validInput(value) {
3625
3646
  switch (value.type) {
3626
3647
  case "release":
3627
3648
  case "keyframe":
3649
+ case "retry-webrtc":
3650
+ case "webrtc-ready":
3628
3651
  return true;
3629
3652
  case "theme":
3630
3653
  return value.theme === "light" || value.theme === "dark";
3654
+ case "standby":
3655
+ return typeof value.standby === "boolean";
3631
3656
  case "ping":
3632
3657
  case "pong":
3633
3658
  return finite(value.sentAt);
@@ -4069,13 +4094,14 @@ var WebRtcViewer = class {
4069
4094
  // src/video-encoder.ts
4070
4095
  var captureBinary = resolve2(dirname6(fileURLToPath(import.meta.url)), "..", "dist", "replicas-capture");
4071
4096
  function superviseVideoEncoder(options) {
4072
- const { display: display2, width: width2, height: height2, fps: fps2, colorMode: colorMode2 } = options;
4097
+ const { display: display2, width: width2, height: height2, colorMode: colorMode2 } = options;
4073
4098
  const env = { ...process.env, DISPLAY: display2 };
4074
4099
  let nativeCapture = process.env.REPLICAS_DESKTOP_STREAM_CAPTURE !== "ffmpeg" && existsSync6(captureBinary);
4075
4100
  let captureMetrics;
4076
4101
  const encoder = superviseEncoder({
4077
4102
  label: "video",
4078
4103
  command: () => nativeCapture ? captureBinary : "ffmpeg",
4104
+ controllable: (command) => command === captureBinary,
4079
4105
  onFailure: (command) => {
4080
4106
  if (command !== captureBinary) return;
4081
4107
  nativeCapture = false;
@@ -4098,7 +4124,7 @@ function superviseVideoEncoder(options) {
4098
4124
  onPacket: options.onPacket,
4099
4125
  joinable: (packet) => isKeyframeStart(rtpPayload(packet)),
4100
4126
  args: (udpPort, warmupFrames) => {
4101
- const { quality, keyframeMs, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
4127
+ const { quality, keyframeMs, fps: fps2, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
4102
4128
  const keyframeInterval = Math.max(1, Math.round(fps2 * keyframeMs / 1e3));
4103
4129
  if (nativeCapture) return [
4104
4130
  "--width",
@@ -4232,6 +4258,8 @@ function superviseVideoEncoder(options) {
4232
4258
  });
4233
4259
  return {
4234
4260
  ...encoder,
4261
+ /** Native capture takes keyframe, quality and frame-rate changes over stdin; ffmpeg needs a replacement for each. */
4262
+ controllable: () => nativeCapture,
4235
4263
  capture: () => ({
4236
4264
  captureBackend: (encoder.command() ?? (nativeCapture ? captureBinary : "ffmpeg")) === captureBinary ? "native" : "ffmpeg",
4237
4265
  captureMetrics: captureMetrics?.generation === encoder.generation() && Date.now() - captureMetrics.at <= 2500 ? captureMetrics : void 0
@@ -4445,6 +4473,8 @@ var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP
4445
4473
  var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
4446
4474
  var basePreset = availableParallelism2() >= 4 ? "superfast" : "ultrafast";
4447
4475
  var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "", 10) || 60;
4476
+ var standbyFps = 5;
4477
+ var standbyRung = 5;
4448
4478
  var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
4449
4479
  var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
4450
4480
  var webrtcEnabled = process.env.REPLICAS_DESKTOP_STREAM_WEBRTC !== "0";
@@ -4475,6 +4505,7 @@ var qualityLadder = (profile) => rungCaps.map((kbps, index) => ({ kbps, crf: pro
4475
4505
  var fullChromaMinKbps = 8e3;
4476
4506
  var fallbackKeyframeMs = 250;
4477
4507
  var webrtcStartupRung = 2;
4508
+ var descentMemoryMs = 18e4;
4478
4509
  var targetBacklogMs = 120;
4479
4510
  var congestedBacklogMs = 250;
4480
4511
  var congestedDecodeQueue = 3;
@@ -4554,7 +4585,8 @@ async function runMediaServer() {
4554
4585
  let activeProfile = "balanced";
4555
4586
  let adaptive = true;
4556
4587
  let rung = 0;
4557
- let descents = 0;
4588
+ let descents = [];
4589
+ let failedRung = -1;
4558
4590
  let congestedSeconds = 0;
4559
4591
  let comfortableSeconds = 0;
4560
4592
  let cleanTransportSeconds = 0;
@@ -4573,6 +4605,8 @@ async function runMediaServer() {
4573
4605
  let encoderFps = 0;
4574
4606
  let videoWanted = false;
4575
4607
  let encoderSignature = "";
4608
+ let encoderDescription = "";
4609
+ let encoderLive = { crf: 0, kbps: 0, fps: 0 };
4576
4610
  let encoderStop;
4577
4611
  let joinKeyframe;
4578
4612
  let lastVideoAt = 0;
@@ -4601,13 +4635,19 @@ async function runMediaServer() {
4601
4635
  if (accepted.length === 0) return "main";
4602
4636
  return PROFILE_ORDER.find((candidate) => (!requires420 || candidate !== "high444") && accepted.every((set) => set.has(candidate))) ?? "baseline";
4603
4637
  };
4638
+ const allStandby = () => viewers.size > 0 && [...viewers.values()].every((viewer) => viewer.standby);
4639
+ const streamFps = () => allStandby() ? Math.min(fps, standbyFps) : fps;
4604
4640
  const encoderParams = () => {
4605
- const quality = ladder()[rung];
4641
+ const quality = ladder()[allStandby() ? Math.max(rung, standbyRung) : rung];
4642
+ const encodeFps = streamFps();
4606
4643
  const sockets = socketViewers().length > 0;
4607
- const keyframeMs = sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
4644
+ const onDemand = videoEncoder.controllable();
4645
+ const keyframeMs = onDemand ? 0 : sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
4608
4646
  const videoProfile = profile();
4609
- const intraRefresh = !sockets;
4647
+ const intraRefresh = !onDemand && !sockets;
4610
4648
  const pixelFormat = videoProfile === "high444" ? "yuv444p" : "yuv420p";
4649
+ const live = `rung ${rung} \xB7 crf ${quality.crf} \xB7 ${encodeFps} fps`;
4650
+ const restart = `${activeProfile} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs ? `${keyframeMs}ms${intraRefresh ? " refresh" : ""}` : "on demand"}`;
4611
4651
  return {
4612
4652
  quality,
4613
4653
  preset,
@@ -4615,8 +4655,10 @@ async function runMediaServer() {
4615
4655
  keyframeMs,
4616
4656
  intraRefresh,
4617
4657
  pixelFormat,
4658
+ fps: encodeFps,
4618
4659
  chromaQpOffset: profiles[activeProfile].chromaQpOffset,
4619
- signature: `rung ${rung} \xB7 ${activeProfile} crf ${quality.crf} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs}ms${intraRefresh ? " refresh" : ""}`
4660
+ signature: onDemand ? restart : `${live} \xB7 ${restart}`,
4661
+ description: `${live} \xB7 ${restart}`
4620
4662
  };
4621
4663
  };
4622
4664
  const budgetBps = () => ladder()[rung].kbps * 1e3 * budgetFactor;
@@ -4637,6 +4679,7 @@ async function runMediaServer() {
4637
4679
  if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
4638
4680
  viewer.waitingForKeyframe = true;
4639
4681
  viewer.withheldFrames += 1;
4682
+ if (videoEncoder.controllable() && queueMs(viewer) <= targetBacklogMs && socket.bufferedAmount <= fallbackBufferedBytes) requestKeyframe("socket viewer caught up", void 0, viewer);
4640
4683
  continue;
4641
4684
  }
4642
4685
  if (keyframe2) viewer.waitingForKeyframe = false;
@@ -4663,12 +4706,12 @@ async function runMediaServer() {
4663
4706
  }
4664
4707
  }
4665
4708
  const last = Boolean(packet[1] & 128);
4666
- videoSplicer.splice(packet, generation, Math.round(9e4 / fps));
4709
+ videoSplicer.splice(packet, generation, Math.round(9e4 / streamFps()));
4667
4710
  let rtp;
4668
4711
  let sockets = false;
4669
4712
  for (const viewer of viewers.values()) {
4670
4713
  if (viewer.fallback) sockets = true;
4671
- else viewer.rtc?.enqueueVideo(rtp ??= withPlayoutDelay(packet, PLAYOUT_DELAY_ID), frameId, keyframe, last);
4714
+ viewer.rtc?.enqueueVideo(rtp ??= withPlayoutDelay(packet, PLAYOUT_DELAY_ID), frameId, keyframe, last);
4672
4715
  }
4673
4716
  frameStart = last;
4674
4717
  if (last) {
@@ -4677,7 +4720,12 @@ async function runMediaServer() {
4677
4720
  }
4678
4721
  if (!sockets) return;
4679
4722
  const parsed = parseRtpPacket(packet);
4723
+ const discarded = videoAssembler.discarded;
4680
4724
  const frame = parsed && videoAssembler.push(parsed);
4725
+ if (videoAssembler.discarded !== discarded) {
4726
+ for (const viewer of socketViewers()) viewer.waitingForKeyframe = true;
4727
+ if (videoEncoder.controllable()) requestKeyframe("socket frame lost");
4728
+ }
4681
4729
  if (frame) broadcast(mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data), frame.key);
4682
4730
  };
4683
4731
  const onAudioPacket = (packet, generation) => {
@@ -4688,7 +4736,7 @@ async function runMediaServer() {
4688
4736
  let sockets = false;
4689
4737
  for (const viewer of viewers.values()) {
4690
4738
  if (viewer.fallback) sockets = true;
4691
- else viewer.rtc?.sendAudio(packet);
4739
+ viewer.rtc?.sendAudio(packet);
4692
4740
  }
4693
4741
  if (!sockets) return;
4694
4742
  const parsed = parseRtpPacket(packet);
@@ -4699,13 +4747,12 @@ async function runMediaServer() {
4699
4747
  display,
4700
4748
  width,
4701
4749
  height,
4702
- fps,
4703
4750
  colorMode,
4704
4751
  params: encoderParams,
4705
4752
  onPacket: onVideoPacket,
4706
4753
  record
4707
4754
  });
4708
- const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(fps, encoderFps)) : fps;
4755
+ const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(streamFps(), encoderFps)) : streamFps();
4709
4756
  const audioEncoder = superviseEncoder({
4710
4757
  label: "audio",
4711
4758
  log: (message) => record("audio encoder", message),
@@ -4763,20 +4810,30 @@ async function runMediaServer() {
4763
4810
  const ensureEncoders = () => {
4764
4811
  clearTimeout(encoderStop);
4765
4812
  void audioEncoder.start();
4766
- const { signature } = encoderParams();
4813
+ const { signature, description, quality, fps: encodeFps } = encoderParams();
4814
+ const live = { crf: quality.crf, kbps: quality.kbps, fps: encodeFps };
4767
4815
  if (!videoWanted) {
4768
4816
  videoWanted = true;
4769
4817
  encoderSignature = signature;
4770
- record("encoder", signature);
4818
+ encoderDescription = description;
4819
+ encoderLive = live;
4820
+ record("encoder", description);
4771
4821
  void videoEncoder.start();
4772
4822
  return false;
4773
4823
  }
4774
- if (signature === encoderSignature) return false;
4775
- encoderSignature = signature;
4776
- lastSwapAt = Date.now();
4777
- record("encoder", signature);
4778
- void videoEncoder.replace();
4779
- return true;
4824
+ if (description !== encoderDescription) record("encoder", description);
4825
+ encoderDescription = description;
4826
+ if (signature !== encoderSignature) {
4827
+ encoderSignature = signature;
4828
+ encoderLive = live;
4829
+ lastSwapAt = Date.now();
4830
+ void videoEncoder.replace();
4831
+ return true;
4832
+ }
4833
+ if (live.crf !== encoderLive.crf || live.kbps !== encoderLive.kbps) videoEncoder.control(`quality ${live.crf} ${live.kbps}`);
4834
+ if (live.fps !== encoderLive.fps) videoEncoder.control(`fps ${live.fps}`);
4835
+ encoderLive = live;
4836
+ return false;
4780
4837
  };
4781
4838
  const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
4782
4839
  const stopEncodersSoon = () => {
@@ -4794,8 +4851,9 @@ async function runMediaServer() {
4794
4851
  const quality = ladder()[next];
4795
4852
  record(next > rung ? "quality down" : "quality up", `rung ${rung} \u2192 ${next} (${quality.kbps} kbps cap, crf ${quality.crf}): ${reason}`);
4796
4853
  if (next > rung) {
4797
- if (now - lastDescentAt > 1e4) descents += 1;
4854
+ if (now - lastDescentAt > 1e4) descents.push(now);
4798
4855
  lastDescentAt = now;
4856
+ failedRung = rung;
4799
4857
  if (now - lastAscentAt < 15e3) {
4800
4858
  blockedRung = rung;
4801
4859
  blockedUntil = now + 12e4;
@@ -4811,7 +4869,8 @@ async function runMediaServer() {
4811
4869
  const resetLadder = (request) => {
4812
4870
  rung = request.transport === "webrtc" && request.adaptive ? webrtcStartupRung : 0;
4813
4871
  cleanTransportSeconds = 0;
4814
- descents = 0;
4872
+ descents = [];
4873
+ failedRung = -1;
4815
4874
  lastDescentAt = 0;
4816
4875
  lastAscentAt = 0;
4817
4876
  congestedSeconds = 0;
@@ -4831,6 +4890,7 @@ async function runMediaServer() {
4831
4890
  const sent = Math.min(...recentVideoKbps);
4832
4891
  return remb !== void 0 && earlier !== void 0 && remb <= earlier.kbps && sent > 500 && remb < sent * 0.9;
4833
4892
  };
4893
+ const loaded = () => Math.min(...recentVideoKbps) > 500;
4834
4894
  const signals = (viewer, stats) => {
4835
4895
  const now = Date.now();
4836
4896
  if (viewer.fallback) {
@@ -4841,22 +4901,24 @@ async function runMediaServer() {
4841
4901
  }
4842
4902
  const stableSince = stableRttWindows.get(viewer)?.since;
4843
4903
  return [
4844
- ...viewer.rttFloorMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || now - stableSince < 2e3) ? [`rtt floor ${Math.round(viewer.rttFloorMs)} ms over ${Math.round(viewer.rtcBaselineMs)} ms baseline`] : [],
4904
+ ...viewer.rttFloorMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || now - stableSince < 2e3) && loaded() ? [`rtt floor ${Math.round(viewer.rttFloorMs)} ms over ${Math.round(viewer.rtcBaselineMs)} ms baseline`] : [],
4845
4905
  ...stats.videoFps < expectedFps() * 0.6 && viewer.lowFpsReports >= 2 ? [`decoded ${stats.videoFps.toFixed(0)} fps`] : [],
4846
- ...(stats.lossPercent ?? 0) > 3 ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
4847
- ...(stats.jitterBufferMs ?? 0) > 80 ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
4906
+ ...(stats.lossPercent ?? 0) > 3 && loaded() ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
4907
+ ...(stats.jitterBufferMs ?? 0) > 80 && loaded() ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
4848
4908
  ];
4849
4909
  };
4850
4910
  const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
4851
- const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 || stats.videoFps < expectedFps() * 0.25 && viewer.lowFpsReports >= 2);
4911
+ const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 && loaded() || stats.videoFps < expectedFps() * 0.25 && viewer.lowFpsReports >= 2);
4852
4912
  let lastSevereDescentAt = 0;
4853
4913
  const descendSevere = (now, trigger) => {
4854
4914
  comfortableSeconds = 0;
4855
4915
  congestedSeconds = 0;
4856
4916
  if (!adaptive || now - lastSevereDescentAt < 1e3) return;
4857
4917
  lastSevereDescentAt = now;
4918
+ const sent = Math.min(...recentVideoKbps);
4858
4919
  const estimates = [...viewers.values()].flatMap((viewer) => {
4859
- const reported = viewer.stats && now - viewer.stats.at < 750 ? viewer.stats.availableKbps ?? 0 : 0;
4920
+ const available = viewer.stats && now - viewer.stats.at < 750 ? viewer.stats.availableKbps ?? 0 : 0;
4921
+ const reported = sent > 500 && available < sent * 0.9 ? available : 0;
4860
4922
  const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
4861
4923
  return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
4862
4924
  });
@@ -4873,10 +4935,13 @@ async function runMediaServer() {
4873
4935
  videoKbps = Math.round((videoBytes - bytesAtTick) * 8 / 1e3);
4874
4936
  bytesAtTick = videoBytes;
4875
4937
  if (recentVideoKbps.push(videoKbps) > 3) recentVideoKbps.shift();
4876
- if (viewers.size === 0) return;
4938
+ if (viewers.size === 0) {
4939
+ starvedSeconds = 0;
4940
+ return;
4941
+ }
4877
4942
  const { captureBackend, captureMetrics } = videoEncoder.capture();
4878
- const captureBusy = captureBackend !== "native" || !captureMetrics || captureMetrics.busyMs / captureMetrics.elapsedMs > 0.8;
4879
- if (videoEncoder.running() && encoderFps < fps * 0.9 && captureBusy) {
4943
+ const captureBusy = captureBackend !== "native" || (captureMetrics ? captureMetrics.busyMs / captureMetrics.elapsedMs > 0.8 : videoEncoder.activeForMs() > 2500);
4944
+ if (videoEncoder.running() && encoderFps < streamFps() * 0.9 && captureBusy) {
4880
4945
  cleanTransportSeconds = 0;
4881
4946
  congestedSeconds = 0;
4882
4947
  comfortableSeconds = 0;
@@ -4924,15 +4989,16 @@ async function runMediaServer() {
4924
4989
  return;
4925
4990
  }
4926
4991
  congestedSeconds = Math.max(0, congestedSeconds - 0.5);
4927
- if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.rttFloorMs > viewer.rtcBaselineMs + 20 || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
4992
+ descents = descents.filter((at) => now - at < descentMemoryMs);
4993
+ if (descents.length === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.rttFloorMs > viewer.rtcBaselineMs + 20 && loaded() || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
4928
4994
  comfortableSeconds = 0;
4929
4995
  return;
4930
4996
  }
4931
4997
  comfortableSeconds += 1;
4932
- const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
4933
- const recoverySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents, 2));
4934
- if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
4935
4998
  const target = Math.max(0, rung - 1);
4999
+ const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
5000
+ const retrySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents.length - 1, 2));
5001
+ if (comfortableSeconds < (descents.length > 0 && target <= failedRung ? retrySeconds : startupSeconds)) return;
4936
5002
  if (target === blockedRung && now < blockedUntil) return;
4937
5003
  setRung(target, `${comfortableSeconds}s comfortable, ${cleanTransportSeconds}s clean transport`);
4938
5004
  }, 1e3);
@@ -5012,9 +5078,18 @@ async function runMediaServer() {
5012
5078
  `);
5013
5079
  }, telemetryIntervalMs);
5014
5080
  telemetry.unref();
5081
+ let lastKeyframeRequestAt = 0;
5015
5082
  const requestKeyframe = (reason, joinedAt, requestingViewer) => {
5016
5083
  const now = Date.now();
5017
5084
  if (!videoWanted || viewers.size === 0 || videoEncoder.replacing() || lastKeyframeAt < now - videoEncoder.activeForMs() || joinedAt !== void 0 && lastKeyframeAt > joinedAt) return;
5085
+ if (videoEncoder.controllable()) {
5086
+ const rttMs = requestingViewer?.rtc?.rttMs ?? requestingViewer?.stats?.rttMs ?? 0;
5087
+ if (joinedAt === void 0 && now - Math.max(lastKeyframeAt, lastKeyframeRequestAt) < Math.max(300, rttMs + 150)) return;
5088
+ if (!videoEncoder.control("keyframe")) return;
5089
+ lastKeyframeRequestAt = now;
5090
+ record("keyframe", reason);
5091
+ return;
5092
+ }
5018
5093
  const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
5019
5094
  const wait = (joinedAt === void 0 ? startup ? 500 : 2e3 : 350) - (now - lastSwapAt);
5020
5095
  if (wait > 0) {
@@ -5024,7 +5099,9 @@ async function runMediaServer() {
5024
5099
  }
5025
5100
  return;
5026
5101
  }
5027
- if (!startup && encoderParams().keyframeMs - (now - lastKeyframeAt) <= 350) return;
5102
+ const { keyframeMs, intraRefresh } = encoderParams();
5103
+ const untilPeriodic = keyframeMs - (now - lastKeyframeAt);
5104
+ if (!startup && !intraRefresh && untilPeriodic >= 0 && untilPeriodic <= 350) return;
5028
5105
  lastSwapAt = now;
5029
5106
  record("keyframe", reason);
5030
5107
  void videoEncoder.replace();
@@ -5037,16 +5114,51 @@ async function runMediaServer() {
5037
5114
  process.stderr.write(`[desktop-stream] webrtc fallback diagnostics: ${JSON.stringify(entry)}
5038
5115
  `);
5039
5116
  }
5040
- if (viewer.fallback) return;
5117
+ const retried = viewer.rtc;
5118
+ viewer.rtc?.close();
5119
+ viewer.rtc = void 0;
5120
+ if (viewer.fallback) {
5121
+ if (retried) record("webrtc retry failed", reason);
5122
+ return;
5123
+ }
5041
5124
  viewer.fallback = true;
5042
5125
  viewer.waitingForKeyframe = true;
5043
5126
  viewer.input.release();
5044
- viewer.rtc?.close();
5045
- viewer.rtc = void 0;
5046
5127
  record("fallback to socket", reason);
5047
5128
  if (!syncEncoder()) requestKeyframe("socket fallback");
5048
5129
  };
5049
5130
  const optional = (value, max) => value === void 0 ? void 0 : clamp(value, 0, max);
5131
+ const openWebRtc = (viewer, reply, iceServers) => {
5132
+ try {
5133
+ viewer.rtc = new WebRtcViewer({ iceServers, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
5134
+ offer: (sdp) => reply({ type: "offer", sdp }),
5135
+ candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
5136
+ message: (message, answer) => handleMessage(viewer, message, answer),
5137
+ keyframe: (reason) => {
5138
+ if (reason === "stale" && Date.now() - lastDescentAt > 1e3) descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
5139
+ requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
5140
+ },
5141
+ remb: (bitsPerSecond) => {
5142
+ if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
5143
+ },
5144
+ connected: () => {
5145
+ record("webrtc connected", viewer.rtc?.path ?? void 0);
5146
+ requestKeyframe("viewer joined", Date.now());
5147
+ },
5148
+ // A viewer that loses its peer cannot send the key-ups for whatever it
5149
+ // was holding, and its socket may stay open for a long time yet. A socket
5150
+ // viewer trying WebRTC again still sends its input over the socket.
5151
+ disconnected: (state) => {
5152
+ record(`webrtc ${state}`);
5153
+ if (!viewer.fallback) viewer.input.release();
5154
+ if (state === "failed") reply({ type: "webrtc-failed" });
5155
+ }
5156
+ });
5157
+ } catch (error) {
5158
+ record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
5159
+ reply({ type: "webrtc-failed" });
5160
+ }
5161
+ };
5050
5162
  const handleMessage = (viewer, message, reply) => {
5051
5163
  switch (message.type) {
5052
5164
  case "ping":
@@ -5124,7 +5236,7 @@ async function runMediaServer() {
5124
5236
  }
5125
5237
  if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
5126
5238
  if (viewer.stats.videoFps >= expectedFps() * 0.8) viewer.everHealthy = true;
5127
- if (encoderHealthyTicks >= 3 && videoEncoder.running() && [...viewers.values()].every((v) => !v.fallback && v.stats && now - v.stats.at < 750 && now - v.connectedAt > 2500 && (v.stats.videoPacketsPerSecond ?? 0) > 0 && (v.stats.lossPercent ?? 0) > 10)) descendSevere(now, "every viewer reports over 10% loss");
5239
+ if (encoderHealthyTicks >= 3 && videoEncoder.running() && loaded() && [...viewers.values()].every((v) => !v.fallback && v.stats && now - v.stats.at < 750 && now - v.connectedAt > 2500 && (v.stats.videoPacketsPerSecond ?? 0) > 0 && (v.stats.lossPercent ?? 0) > 10)) descendSevere(now, "every viewer reports over 10% loss");
5128
5240
  return;
5129
5241
  }
5130
5242
  case "answer":
@@ -5137,12 +5249,35 @@ async function runMediaServer() {
5137
5249
  case "fallback":
5138
5250
  enterFallback(viewer, message.reason, message.failure);
5139
5251
  return;
5252
+ // A socket viewer whose WebRTC attempt failed tries again in the background;
5253
+ // the socket keeps carrying its media until the new peer shows video.
5254
+ case "retry-webrtc": {
5255
+ const ice = webrtc && viewer.fallback && !viewer.rtc && viewer.request.transport === "webrtc" ? currentIceServers() : void 0;
5256
+ if (!ice) return;
5257
+ record("retrying webrtc", "socket media continues meanwhile");
5258
+ openWebRtc(viewer, reply, ice.native);
5259
+ return;
5260
+ }
5261
+ case "webrtc-ready":
5262
+ if (!viewer.fallback || !viewer.rtc?.connected) return;
5263
+ viewer.fallback = false;
5264
+ viewer.withheldFrames = 0;
5265
+ record("back on webrtc", viewer.rtc.path ?? void 0);
5266
+ syncEncoder();
5267
+ return;
5140
5268
  case "keyframe":
5141
- if (!viewer.fallback) requestKeyframe("viewer watchdog", void 0, viewer);
5269
+ if (viewer.fallback) viewer.waitingForKeyframe = true;
5270
+ if (!viewer.fallback || videoEncoder.controllable()) requestKeyframe(viewer.fallback ? "socket viewer decoder reset" : "viewer watchdog", void 0, viewer);
5142
5271
  return;
5143
5272
  case "theme":
5144
5273
  applyDesktopTheme(message.theme);
5145
5274
  return;
5275
+ case "standby":
5276
+ if (viewer.standby === message.standby) return;
5277
+ viewer.standby = message.standby;
5278
+ record(message.standby ? "viewer standby" : "viewer active", `${[...viewers.values()].filter((v) => v.standby).length} of ${viewers.size} in standby`);
5279
+ syncEncoder();
5280
+ return;
5146
5281
  default:
5147
5282
  viewer.input.handle(message);
5148
5283
  }
@@ -5185,6 +5320,8 @@ async function runMediaServer() {
5185
5320
  audioBytes,
5186
5321
  videoKbps,
5187
5322
  encoderFps,
5323
+ streamFps: streamFps(),
5324
+ standbyViewers: all.filter((viewer) => viewer.standby).length,
5188
5325
  ...videoEncoder.capture(),
5189
5326
  cpu,
5190
5327
  clientStats: all.flatMap((viewer) => viewer.stats ? [viewer.stats] : []),
@@ -5238,13 +5375,14 @@ async function runMediaServer() {
5238
5375
  socket.destroy();
5239
5376
  return;
5240
5377
  }
5241
- webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest)));
5378
+ webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest), url.searchParams.get("standby") === "1"));
5242
5379
  });
5243
- webSocketServer.on("connection", (socket, request) => {
5380
+ webSocketServer.on("connection", (socket, request, standby) => {
5244
5381
  const viewer = {
5245
5382
  socket,
5246
5383
  input: new InputBridge(display, width, height),
5247
5384
  request,
5385
+ standby,
5248
5386
  fallback: false,
5249
5387
  waitingForKeyframe: true,
5250
5388
  withheldFrames: 0,
@@ -5269,7 +5407,7 @@ async function runMediaServer() {
5269
5407
  if (!adaptive) rung = 0;
5270
5408
  }
5271
5409
  viewers.set(socket, viewer);
5272
- record("viewer connected", `${request.transport} requested, ${viewers.size} connected`);
5410
+ record("viewer connected", `${request.transport} requested${standby ? " in standby" : ""}, ${viewers.size} connected`);
5273
5411
  const reply = (message) => {
5274
5412
  if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
5275
5413
  };
@@ -5278,36 +5416,7 @@ async function runMediaServer() {
5278
5416
  const fallbackProfile = profile() === "high444" ? "main" : profile();
5279
5417
  const configMessage = { ...config, videoCodec: H264_PROFILES[fallbackProfile].codec, iceServers: ice?.browser, request };
5280
5418
  reply(configMessage);
5281
- if (ice) {
5282
- try {
5283
- viewer.rtc = new WebRtcViewer({ iceServers: ice.native, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
5284
- offer: (sdp) => reply({ type: "offer", sdp }),
5285
- candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
5286
- message: (message, answer) => handleMessage(viewer, message, answer),
5287
- keyframe: (reason) => {
5288
- if (reason === "stale") descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
5289
- requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
5290
- },
5291
- remb: (bitsPerSecond) => {
5292
- if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
5293
- },
5294
- connected: () => {
5295
- record("webrtc connected", viewer.rtc?.path ?? void 0);
5296
- requestKeyframe("viewer joined", Date.now());
5297
- },
5298
- // A viewer that loses its peer cannot send the key-ups for whatever it
5299
- // was holding, and its socket may stay open for a long time yet.
5300
- disconnected: (state) => {
5301
- record(`webrtc ${state}`);
5302
- viewer.input.release();
5303
- if (state === "failed") reply({ type: "webrtc-failed" });
5304
- }
5305
- });
5306
- } catch (error) {
5307
- record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
5308
- reply({ type: "webrtc-failed" });
5309
- }
5310
- }
5419
+ if (ice) openWebRtc(viewer, reply, ice.native);
5311
5420
  if (!viewer.rtc) enterFallback(viewer, request.transport === "webrtc" ? "webrtc unavailable" : "websocket requested");
5312
5421
  ensureEncoders();
5313
5422
  socket.on("message", (raw, binary) => {