replicas-computer 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -21,15 +21,17 @@ The viewer and agent command paths are independent. One on-demand encoder pair i
21
21
 
22
22
  ffmpeg captures the display and encodes 1920×1080 H.264 (and Opus from PulseAudio) as RTP onto loopback. RGB capture is converted with the BT.709 matrix in limited range and the same matrix is signalled in the bitstream, so browsers reconstruct the colours the desktop drew (`REPLICAS_DESKTOP_STREAM_COLOR=legacy` restores the untagged BT.601 conversion). Frames are forwarded unchanged apart from a sequence/timestamp splice and a playout-delay header extension.
23
23
 
24
- WebRTC is the default transport, with automatic WebSocket fallback. A viewer can choose `?transport=websocket`; `REPLICAS_DESKTOP_STREAM_TRANSPORT=websocket` changes the server default for viewers that do not choose. Dashboard URLs carry the selected transport explicitly, and saved transport preferences are preserved. The viewer's WebSocket to `/stream` is the control plane either way: it carries the server's SDP offer, the viewer's answer, trickled ICE candidates, keepalives and telemetry. Pointer moves ride an unreliable data channel. When the server advertises `inputRace`, clicks, keys, wheel events and held-input release race a direct copy against ordered WebSocket delivery. The input bridge accepts only the next action ID, ignoring duplicates and out-of-order direct copies; the socket fills gaps without SCTP retransmission delays or a reorder queue. Older peers keep their existing input path. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without a displayed frame switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser.
24
+ WebRTC is the default transport, with automatic WebSocket fallback. A viewer can choose `?transport=websocket`; `REPLICAS_DESKTOP_STREAM_TRANSPORT=websocket` changes the server default for viewers that do not choose. Dashboard URLs carry the selected transport explicitly, and saved transport preferences are preserved. The viewer's WebSocket to `/stream` is the control plane either way: it carries the server's SDP offer, the viewer's answer, trickled ICE candidates, keepalives and telemetry. Pointer moves ride an unreliable data channel. When the server advertises `inputRace`, clicks, keys, wheel events and held-input release race a direct copy against ordered WebSocket delivery. The input bridge accepts only the next action ID, ignoring duplicates and out-of-order direct copies; the socket fills gaps without SCTP retransmission delays or a reorder queue. Older peers keep their existing input path. The watchdog tracks decoded frames so CSS-hidden viewers do not fall back merely because the browser stops presenting video. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without decoded video switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser.
25
25
 
26
26
  The server makes the offer, so payload types and SSRCs are fixed and ffmpeg emits them directly. Four H.264 payload types are offered (High 4:4:4 Predictive, High, Main, Constrained Baseline); the encoder runs the best profile every connected viewer accepts, so one viewer without 4:4:4 support moves everyone to 4:2:0, and socket viewers or caps below 8 Mbps do the same. Two profiles select the encoder tuning: `balanced` (CRF 14, default chroma quantisation) and `text` (CRF 10, chroma QP offset −6); `?profile=` picks one and the most recent viewer's choice applies to the shared encoder. Quality adapts on a six-rung bitrate ladder that keeps resolution and frame rate and adds two CRF per rung under the 20 Mbps cap; `?adapt=0` pins the top rung.
27
27
 
28
- Every video byte for a WebRTC viewer passes through `PacedSender`. libdatachannel's own pacer and NACK responder are not used because repairs bypassed the pacer and neither accounted for duplicates, so recovery traffic could exceed the link while fresh frames queued for seconds. The budget is 1.25× the rung's encoder cap and covers media, repairs and protective copies together: repairs take at most 30% of it over a rolling second, copies are sent only from idle budget while loss is observed and never exceed the original media bytes, and a queue that grows past 150 ms is cut back to whole frames a decoder can still follow (unsent delta frames go, the in-flight frame completes, a keyframe is requested and further deltas are dropped until it arrives). Sequence numbers are assigned as packets leave, so dropped frames never read as loss. Receiver PLI, NACK and REMB are parsed from RTCP; a REMB estimate below the sent rate, RTT growth, loss, jitter-buffer growth and stale-queue cuts all drive the ladder down, and clean seconds drive it up.
28
+ Every video byte for a WebRTC viewer passes through `PacedSender`. libdatachannel's own pacer and NACK responder are not used because repairs bypassed the pacer and neither accounted for duplicates, so recovery traffic could exceed the link while fresh frames queued for seconds. The budget is 1.25× the rung's encoder cap and covers media, repairs and protective copies together: repairs take at most 30% of it over a rolling second, copies are sent only from idle budget while loss is observed and never exceed the original media bytes, and a queue that grows past 150 ms is cut back to whole frames a decoder can still follow (unsent delta frames go, the in-flight frame completes, a keyframe is requested and further deltas are dropped until it arrives). Sequence numbers are assigned as packets leave, so dropped frames never read as loss. Receiver PLI, NACK and REMB are parsed from RTCP. RTT growth (the lowest probe of the last second against the session's baseline, so a single late probe is not a queue), loss, jitter-buffer growth and stale-queue cuts drive the ladder down, and clean seconds drive it up. REMB is not a congestion vote: the receiver's estimate is bounded by what recently arrived and climbs by tens of kbps per second, so it trails every burst of motion on a quiet screen for a minute or more, and its delay detector reads the sender's pacing bursts on a quiet screen as overuse. It only bounds how far a severe descent (heavy loss or a starved decoder) steps, and only while it has stopped climbing below the sent rate. `scripts/probe-viewer.ts` drives the viewer page in a Chrome with remote debugging and prints what it measures and what the server decides each second; `scripts/probe-rtt-jitter.ts` feeds the controller a synthetic WebRTC viewer with jittery RTT probes.
29
29
 
30
30
  Encoding uses at most four threads and one queued capture frame, skips rapid catch-up captures after CPU stalls, and uses periodic intra refresh for WebRTC viewers; socket viewers switch it to 250 ms IDR intervals because they resynchronise only on IDR frames. When encoder settings change, a replacement discards two initial frames, emits a forced keyframe and then takes over. The active encoder continues if replacement fails.
31
31
 
32
- The viewer exposes `?view=fit|native|pixel` (letterboxed, one source pixel per CSS pixel, one source pixel per device pixel) and `?diagnostics=1`, which shows transport and ICE route, negotiated codec profile, source/decoded/CSS/device dimensions, displayed and decoded frame rates, bitrate, RTT, loss, jitter buffer, and the server's encoder settings, CPU, pacer budget, queue, stale cuts, repairs and copies. `globalThis.__replicasDesktop` carries the same state for tooling.
32
+ The viewer exposes `?view=fit|native|pixel` (letterboxed, one source pixel per CSS pixel, one source pixel per device pixel) and `?diagnostics=1`, which shows transport and ICE route, negotiated codec profile, source/decoded/CSS/device dimensions, displayed and decoded frame rates, bitrate, RTT, loss, jitter buffer, the server's encoder settings, keyframe interval, CPU, pacer budget, queue, stale cuts, repairs and copies, the socket fallback's unsent bytes, withheld delta frames and decoder resets, and the latest server and viewer events. `globalThis.__replicasDesktop` carries the same state for tooling.
33
+
34
+ The server records every decision that changes the picture (viewer connects and transport fallbacks with their reason, WebRTC state changes, quality rung moves with the signals that voted for them, encoder swaps, preset changes and keyframe requests) and writes each to the stream log (`/tmp/replicas-computer/stream.log`). `GET /telemetry` returns the `/health` snapshot plus the last 200 events and five minutes of one-second samples (encoder rung, CRF, cap, keyframe interval, preset, fps and kbps; CPU; per-viewer transport, ICE path, pacer counters, socket backlog and the viewer's own reported stats). Fetch it inside the sandbox with `curl -s localhost:6080/telemetry` or through the authenticated preview URL; the dashboard's stream settings menu has **Open stream telemetry**. `REPLICAS_DESKTOP_STREAM_TELEMETRY=1` also writes every sample to the stream log for sessions longer than the in-memory history.
33
35
 
34
36
  | Variable | Default | Meaning |
35
37
  |---|---|---|
@@ -42,6 +44,7 @@ The viewer exposes `?view=fit|native|pixel` (letterboxed, one source pixel per C
42
44
  | `REPLICAS_DESKTOP_STREAM_COLOR` | `bt709` | `legacy` disables the explicit BT.709 conversion and signalling |
43
45
  | `REPLICAS_DESKTOP_STREAM_VIDEO_BITRATE` | `20M` | Encoder bitrate cap across all quality rungs |
44
46
  | `REPLICAS_DESKTOP_STREAM_FPS` | `60` | Capture and encode rate |
47
+ | `REPLICAS_DESKTOP_STREAM_TELEMETRY` | unset | `1` writes one-second telemetry samples to the stream log while a viewer is connected |
45
48
 
46
49
  ```bash
47
50
  replicas-computer setup --check
@@ -38,6 +38,8 @@ html[data-view="native"] body, html[data-view="pixel"] body { width: max-content
38
38
  right: 12px;
39
39
  display: none;
40
40
  max-width: calc(100vw - 24px);
41
+ max-height: calc(100vh - 24px);
42
+ overflow: hidden;
41
43
  padding: 6px 10px;
42
44
  border: 1px solid var(--replicas-hairline);
43
45
  color: var(--replicas-ink);
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.2";
2940
+ var COMPUTER_USE_VERSION = "0.3.4";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3446,8 +3446,8 @@ function superviseEncoder(options) {
3446
3446
  let restart;
3447
3447
  let candidateDeadline;
3448
3448
  let chain = Promise.resolve();
3449
- const log = (message) => process.stderr.write(`[desktop-stream] ${options.label} ${message}
3450
- `);
3449
+ const log = options.log ?? ((message) => process.stderr.write(`[desktop-stream] ${options.label} ${message}
3450
+ `));
3451
3451
  const retire = (generation) => {
3452
3452
  if (!generation) return;
3453
3453
  generation.live = false;
@@ -4253,6 +4253,9 @@ var fallbackBufferedBytes = 512 * 1024;
4253
4253
  var maximumBufferedBytes = 4 * 1024 * 1024;
4254
4254
  var probeIntervalMs = 250;
4255
4255
  var telemetryIntervalMs = 500;
4256
+ var eventHistory = 200;
4257
+ var sampleHistory = 300;
4258
+ var telemetryLog = process.env.REPLICAS_DESKTOP_STREAM_TELEMETRY === "1";
4256
4259
  var budgetFactor = 1.25;
4257
4260
  var protectMs = 1e4;
4258
4261
  var packageRoot = resolve2(dirname6(fileURLToPath(import.meta.url)), "..");
@@ -4352,6 +4355,14 @@ async function runMediaServer() {
4352
4355
  let audioPackets = 0;
4353
4356
  let videoBytes = 0;
4354
4357
  let audioBytes = 0;
4358
+ const events = [];
4359
+ const samples = [];
4360
+ const record = (event, detail) => {
4361
+ if (events.push({ at: Date.now(), event, detail }) > eventHistory) events.shift();
4362
+ process.stderr.write(`[desktop-stream] ${event}${detail ? `: ${detail}` : ""}
4363
+ `);
4364
+ };
4365
+ const transportOf = (viewer) => viewer.fallback ? "websocket" : viewer.rtc?.connected ? "webrtc" : "negotiating";
4355
4366
  const ladder = () => qualityLadder(activeProfile);
4356
4367
  const socketViewers = () => [...viewers.values()].filter((viewer) => viewer.fallback);
4357
4368
  const profile = () => {
@@ -4389,11 +4400,13 @@ async function runMediaServer() {
4389
4400
  const { socket } = viewer;
4390
4401
  if (!viewer.fallback || socket.readyState !== WebSocket2.OPEN) continue;
4391
4402
  if (socket.bufferedAmount > maximumBufferedBytes) {
4403
+ record("viewer dropped", `socket holds ${Math.round(socket.bufferedAmount / 1024)} KiB unsent`);
4392
4404
  socket.close(1013, "stream client is too slow");
4393
4405
  continue;
4394
4406
  }
4395
4407
  if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
4396
4408
  viewer.waitingForKeyframe = true;
4409
+ viewer.withheldFrames += 1;
4397
4410
  continue;
4398
4411
  }
4399
4412
  if (keyframe2) viewer.waitingForKeyframe = false;
@@ -4454,6 +4467,7 @@ async function runMediaServer() {
4454
4467
  const env = { ...process.env, DISPLAY: display };
4455
4468
  const videoEncoder = superviseEncoder({
4456
4469
  label: "video",
4470
+ log: (message) => record("video encoder", message),
4457
4471
  // Initial encoder work can leave its first image older than the outgoing stream.
4458
4472
  warmupFrames: 2,
4459
4473
  env,
@@ -4556,6 +4570,7 @@ async function runMediaServer() {
4556
4570
  });
4557
4571
  const audioEncoder = superviseEncoder({
4558
4572
  label: "audio",
4573
+ log: (message) => record("audio encoder", message),
4559
4574
  env,
4560
4575
  onPacket: onAudioPacket,
4561
4576
  joinable: () => true,
@@ -4614,14 +4629,14 @@ async function runMediaServer() {
4614
4629
  if (!videoWanted) {
4615
4630
  videoWanted = true;
4616
4631
  encoderSignature = signature;
4632
+ record("encoder", signature);
4617
4633
  void videoEncoder.start();
4618
4634
  return false;
4619
4635
  }
4620
4636
  if (signature === encoderSignature) return false;
4621
4637
  encoderSignature = signature;
4622
4638
  lastSwapAt = Date.now();
4623
- process.stderr.write(`[desktop-stream] encoder \u2192 ${signature}
4624
- `);
4639
+ record("encoder", signature);
4625
4640
  void videoEncoder.replace();
4626
4641
  return true;
4627
4642
  };
@@ -4635,9 +4650,11 @@ async function runMediaServer() {
4635
4650
  void audioEncoder.stop();
4636
4651
  }, 5e3);
4637
4652
  };
4638
- const setRung = (next) => {
4653
+ const setRung = (next, reason) => {
4639
4654
  if (next === rung) return;
4640
4655
  const now = Date.now();
4656
+ const quality = ladder()[next];
4657
+ record(next > rung ? "quality down" : "quality up", `rung ${rung} \u2192 ${next} (${quality.kbps} kbps cap, crf ${quality.crf}): ${reason}`);
4641
4658
  if (next > rung) {
4642
4659
  if (now - lastDescentAt > 1e4) descents += 1;
4643
4660
  lastDescentAt = now;
@@ -4665,20 +4682,37 @@ async function runMediaServer() {
4665
4682
  blockedUntil = 0;
4666
4683
  };
4667
4684
  const stableRttWindows = /* @__PURE__ */ new WeakMap();
4668
- const freshRemb = (viewer, now) => viewer.remb && now - viewer.remb.at < 2e3 ? viewer.remb.kbps : void 0;
4685
+ const latestRemb = (viewer) => viewer.rembs[viewer.rembs.length - 1];
4686
+ const freshRemb = (viewer, now) => {
4687
+ const remb = latestRemb(viewer);
4688
+ return remb && now - remb.at < 2e3 ? remb.kbps : void 0;
4689
+ };
4669
4690
  const rembBelowSent = (viewer, now) => {
4670
4691
  const remb = freshRemb(viewer, now);
4692
+ const earlier = [...viewer.rembs].reverse().find((sample) => sample.at <= now - 2e3);
4671
4693
  const sent = Math.min(...recentVideoKbps);
4672
- return remb !== void 0 && sent > 500 && remb < sent * 0.9;
4694
+ return remb !== void 0 && earlier !== void 0 && remb <= earlier.kbps && sent > 500 && remb < sent * 0.9;
4673
4695
  };
4674
- const struggling = (viewer, stats) => {
4696
+ const signals = (viewer, stats) => {
4697
+ const now = Date.now();
4698
+ if (viewer.fallback) {
4699
+ return [
4700
+ ...queueMs(viewer) > congestedBacklogMs ? [`probe queue ${Math.round(queueMs(viewer))} ms`] : [],
4701
+ ...stats.decodeQueue > congestedDecodeQueue ? [`decode queue ${stats.decodeQueue}`] : []
4702
+ ];
4703
+ }
4675
4704
  const stableSince = stableRttWindows.get(viewer)?.since;
4676
- return viewer.fallback ? queueMs(viewer) > congestedBacklogMs || stats.decodeQueue > congestedDecodeQueue : stats.rttMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || Date.now() - stableSince < 2e3) || stats.videoFps < fps * 0.6 && viewer.lowFpsReports >= 2 || (stats.lossPercent ?? 0) > 3 || (stats.jitterBufferMs ?? 0) > 80 || rembBelowSent(viewer, Date.now());
4705
+ return [
4706
+ ...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`] : [],
4707
+ ...stats.videoFps < fps * 0.6 && viewer.lowFpsReports >= 2 ? [`decoded ${stats.videoFps.toFixed(0)} fps`] : [],
4708
+ ...(stats.lossPercent ?? 0) > 3 ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
4709
+ ...(stats.jitterBufferMs ?? 0) > 80 ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
4710
+ ];
4677
4711
  };
4678
4712
  const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
4679
4713
  const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 || stats.videoFps < fps * 0.25 && viewer.lowFpsReports >= 2);
4680
4714
  let lastSevereDescentAt = 0;
4681
- const descendSevere = (now) => {
4715
+ const descendSevere = (now, trigger) => {
4682
4716
  comfortableSeconds = 0;
4683
4717
  congestedSeconds = 0;
4684
4718
  if (!adaptive || now - lastSevereDescentAt < 1e3) return;
@@ -4690,7 +4724,10 @@ async function runMediaServer() {
4690
4724
  });
4691
4725
  const rungs = ladder();
4692
4726
  const estimatedRung = estimates.length ? rungs.findIndex((quality) => quality.kbps <= Math.min(...estimates) * 0.8) : rung + 2;
4693
- setRung(Math.min(rungs.length - 1, Math.max(rung + 2, estimatedRung < 0 ? rungs.length - 1 : estimatedRung)));
4727
+ setRung(
4728
+ Math.min(rungs.length - 1, Math.max(rung + 2, estimatedRung < 0 ? rungs.length - 1 : estimatedRung)),
4729
+ `severe: ${trigger}; link estimates ${estimates.length ? `${estimates.map(Math.round).join("/")} kbps` : "none"}`
4730
+ );
4694
4731
  };
4695
4732
  const control = setInterval(() => {
4696
4733
  encoderFps = videoFrames - framesAtTick;
@@ -4708,6 +4745,7 @@ async function runMediaServer() {
4708
4745
  starvedSeconds += 1;
4709
4746
  if (starvedSeconds >= 5 && preset !== "ultrafast") {
4710
4747
  preset = "ultrafast";
4748
+ record("encoder starved", `${encoderFps} fps for ${starvedSeconds}s; preset \u2192 ultrafast`);
4711
4749
  syncEncoder();
4712
4750
  }
4713
4751
  return;
@@ -4717,26 +4755,27 @@ async function runMediaServer() {
4717
4755
  if (encoderHealthyTicks < 3) return;
4718
4756
  if (preset !== basePreset && ++presetHealthySeconds >= 120) {
4719
4757
  preset = basePreset;
4758
+ record("encoder recovered", `preset \u2192 ${basePreset}`);
4720
4759
  syncEncoder();
4721
4760
  }
4722
4761
  if (!adaptive) {
4723
- if (rung !== 0) setRung(0);
4762
+ if (rung !== 0) setRung(0, "quality pinned");
4724
4763
  return;
4725
4764
  }
4726
4765
  const now = Date.now();
4727
4766
  const reporting = [...viewers.values()].filter((viewer) => viewer.stats !== void 0 && viewer.stats.videoPacketsPerSecond !== 0 && now - viewer.stats.at < 3e3 && now - viewer.connectedAt > 2500);
4728
- cleanTransportSeconds = reporting.length === viewers.size && reporting.length > 0 && reporting.every((viewer) => !viewer.fallback && viewer.stats.rttMs <= viewer.rtcBaselineMs + 20 && (viewer.stats.lossPercent ?? 0) === 0 && (viewer.stats.nacks ?? 0) === 0 && (viewer.stats.jitterBufferMs ?? 0) <= 20 && viewer.stats.videoFps >= fps * 0.95) ? cleanTransportSeconds + 1 : 0;
4767
+ cleanTransportSeconds = reporting.length === viewers.size && reporting.length > 0 && reporting.every((viewer) => !viewer.fallback && viewer.rttFloorMs <= viewer.rtcBaselineMs + 20 && (viewer.stats.lossPercent ?? 0) === 0 && (viewer.stats.nacks ?? 0) === 0 && (viewer.stats.jitterBufferMs ?? 0) <= 20 && viewer.stats.videoFps >= fps * 0.95) ? cleanTransportSeconds + 1 : 0;
4729
4768
  if (reporting.length > 0 && reporting.every((viewer) => severe(viewer, viewer.stats))) {
4730
- descendSevere(now);
4769
+ descendSevere(now, reporting.map((viewer) => `${(viewer.stats.lossPercent ?? 0).toFixed(1)}% loss, ${viewer.stats.videoFps.toFixed(0)} fps decoded`).join(" | "));
4731
4770
  return;
4732
4771
  }
4733
- const votes = reporting.flatMap((viewer) => viewer.everHealthy || now - viewer.connectedAt > 15e3 ? [struggling(viewer, viewer.stats)] : []);
4772
+ const votes = reporting.flatMap((viewer) => viewer.everHealthy || now - viewer.connectedAt > 15e3 ? [signals(viewer, viewer.stats)] : []);
4734
4773
  if (votes.length === 0) return;
4735
- const strugglers = votes.filter(Boolean).length;
4774
+ const strugglers = votes.filter((vote) => vote.length > 0).length;
4736
4775
  if (strugglers === votes.length) {
4737
4776
  comfortableSeconds = 0;
4738
4777
  congestedSeconds += 1;
4739
- if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1));
4778
+ if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1), votes.map((vote) => vote.join(", ")).join(" | "));
4740
4779
  return;
4741
4780
  }
4742
4781
  if (strugglers > 0) {
@@ -4745,7 +4784,7 @@ async function runMediaServer() {
4745
4784
  return;
4746
4785
  }
4747
4786
  congestedSeconds = Math.max(0, congestedSeconds - 0.5);
4748
- if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.stats.rttMs > viewer.rtcBaselineMs + 20 || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
4787
+ if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.rttFloorMs > viewer.rtcBaselineMs + 20 || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
4749
4788
  comfortableSeconds = 0;
4750
4789
  return;
4751
4790
  }
@@ -4755,7 +4794,7 @@ async function runMediaServer() {
4755
4794
  if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
4756
4795
  const target = Math.max(0, rung - 1);
4757
4796
  if (target === blockedRung && now < blockedUntil) return;
4758
- setRung(target);
4797
+ setRung(target, `${comfortableSeconds}s comfortable, ${cleanTransportSeconds}s clean transport`);
4759
4798
  }, 1e3);
4760
4799
  control.unref();
4761
4800
  const probe = setInterval(() => {
@@ -4779,46 +4818,61 @@ async function runMediaServer() {
4779
4818
  encoderPercent: Math.max(0, Math.round((encoder - cpuSample.encoder) * 100 / elapsed))
4780
4819
  };
4781
4820
  cpuSample = { at: now, server, encoder };
4782
- const { quality, profile: videoProfile, pixelFormat } = encoderParams();
4821
+ const { quality, profile: videoProfile, pixelFormat, keyframeMs } = encoderParams();
4822
+ const encoderState = {
4823
+ profile: activeProfile,
4824
+ codecProfile: videoProfile,
4825
+ pixelFormat,
4826
+ color: colorMode,
4827
+ rung,
4828
+ rungs: ladder().length,
4829
+ kbpsCap: quality.kbps,
4830
+ crf: quality.crf,
4831
+ preset,
4832
+ keyframeMs,
4833
+ fps: encoderFps,
4834
+ kbps: videoKbps,
4835
+ adaptive
4836
+ };
4837
+ const cpuState = { ...cpu, load: Math.round(loadavg()[0] * 100) / 100, cores: availableParallelism() };
4838
+ const sample = {
4839
+ at: now,
4840
+ encoder: { rung, crf: quality.crf, kbpsCap: quality.kbps, keyframeMs, preset, fps: encoderFps, kbps: videoKbps },
4841
+ cpu: cpuState,
4842
+ viewers: []
4843
+ };
4783
4844
  for (const viewer of viewers.values()) {
4784
4845
  if (viewer.socket.readyState !== WebSocket2.OPEN) continue;
4785
4846
  const pacer = viewer.rtc?.snapshot();
4786
- const message = {
4787
- type: "telemetry",
4788
- encoder: {
4789
- profile: activeProfile,
4790
- codecProfile: videoProfile,
4791
- pixelFormat,
4792
- color: colorMode,
4793
- rung,
4794
- rungs: ladder().length,
4795
- kbpsCap: quality.kbps,
4796
- crf: quality.crf,
4797
- preset,
4798
- fps: encoderFps,
4799
- kbps: videoKbps,
4800
- adaptive
4801
- },
4802
- cpu: { ...cpu, load: Math.round(loadavg()[0] * 100) / 100, cores: availableParallelism() },
4803
- viewer: {
4804
- budgetKbps: pacer?.budgetKbps ?? 0,
4805
- queueMs: pacer?.queueMs ?? Math.round(queueMs(viewer)),
4806
- queuedPackets: pacer?.queuedPackets ?? 0,
4807
- staleEvents: pacer?.staleEvents ?? 0,
4808
- droppedFrames: pacer?.droppedFrames ?? 0,
4809
- repairsSent: pacer?.repairsSent ?? 0,
4810
- repairsSkipped: pacer?.repairsSkipped ?? 0,
4811
- duplicatesSent: pacer?.duplicatesSent ?? 0,
4812
- protecting: pacer?.protecting ?? false,
4813
- rembKbps: pacer?.rembKbps ?? null,
4814
- sentKbps: pacer?.sentKbps ?? 0
4815
- }
4847
+ const viewerState = {
4848
+ budgetKbps: pacer?.budgetKbps ?? 0,
4849
+ queueMs: pacer?.queueMs ?? Math.round(queueMs(viewer)),
4850
+ queuedPackets: pacer?.queuedPackets ?? 0,
4851
+ staleEvents: pacer?.staleEvents ?? 0,
4852
+ droppedFrames: pacer?.droppedFrames ?? 0,
4853
+ repairsSent: pacer?.repairsSent ?? 0,
4854
+ repairsSkipped: pacer?.repairsSkipped ?? 0,
4855
+ duplicatesSent: pacer?.duplicatesSent ?? 0,
4856
+ protecting: pacer?.protecting ?? false,
4857
+ rembKbps: pacer?.rembKbps ?? null,
4858
+ sentKbps: pacer?.sentKbps ?? 0,
4859
+ transport: transportOf(viewer),
4860
+ bufferedBytes: viewer.socket.bufferedAmount,
4861
+ withheldFrames: viewer.withheldFrames,
4862
+ waitingForKeyframe: viewer.fallback && viewer.waitingForKeyframe
4816
4863
  };
4864
+ sample.viewers.push({ ...viewerState, sentKbps: Math.round((viewerState.sentKbps + (viewer.lastSentKbps ?? viewerState.sentKbps)) / 2), path: viewer.rtc?.path ?? null, stats: viewer.stats ?? null });
4865
+ viewer.lastSentKbps = viewerState.sentKbps;
4866
+ const message = { type: "telemetry", encoder: encoderState, cpu: cpuState, viewer: viewerState, events: events.slice(-5) };
4817
4867
  viewer.socket.send(JSON.stringify(message));
4818
4868
  }
4869
+ if (now - (samples[samples.length - 1]?.at ?? 0) < 900) return;
4870
+ if (samples.push(sample) > sampleHistory) samples.shift();
4871
+ if (telemetryLog) process.stderr.write(`[desktop-stream] sample ${JSON.stringify(sample)}
4872
+ `);
4819
4873
  }, telemetryIntervalMs);
4820
4874
  telemetry.unref();
4821
- const requestKeyframe = (joinedAt, requestingViewer) => {
4875
+ const requestKeyframe = (reason, joinedAt, requestingViewer) => {
4822
4876
  const now = Date.now();
4823
4877
  if (!videoWanted || viewers.size === 0 || videoEncoder.replacing() || lastKeyframeAt < now - videoEncoder.activeForMs() || joinedAt !== void 0 && lastKeyframeAt > joinedAt) return;
4824
4878
  const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
@@ -4826,12 +4880,13 @@ async function runMediaServer() {
4826
4880
  if (wait > 0) {
4827
4881
  if (joinedAt !== void 0) {
4828
4882
  clearTimeout(joinKeyframe);
4829
- joinKeyframe = setTimeout(() => requestKeyframe(joinedAt), wait);
4883
+ joinKeyframe = setTimeout(() => requestKeyframe(reason, joinedAt), wait);
4830
4884
  }
4831
4885
  return;
4832
4886
  }
4833
4887
  if (!startup && encoderParams().keyframeMs - (now - lastKeyframeAt) <= 350) return;
4834
4888
  lastSwapAt = now;
4889
+ record("keyframe", reason);
4835
4890
  void videoEncoder.replace();
4836
4891
  };
4837
4892
  const enterFallback = (viewer, reason) => {
@@ -4841,9 +4896,8 @@ async function runMediaServer() {
4841
4896
  viewer.input.release();
4842
4897
  viewer.rtc?.close();
4843
4898
  viewer.rtc = void 0;
4844
- process.stderr.write(`[desktop-stream] viewer streaming over the socket: ${reason}
4845
- `);
4846
- if (!syncEncoder()) requestKeyframe();
4899
+ record("fallback to socket", reason);
4900
+ if (!syncEncoder()) requestKeyframe("socket fallback");
4847
4901
  };
4848
4902
  const optional = (value, max) => value === void 0 ? void 0 : clamp(value, 0, max);
4849
4903
  const handleMessage = (viewer, message, reply) => {
@@ -4883,8 +4937,10 @@ async function runMediaServer() {
4883
4937
  viewer.lowFpsReports = Math.min(2, viewer.lowFpsReports + 1);
4884
4938
  lowFpsObservationAt.set(viewer, now);
4885
4939
  }
4886
- if (!viewer.fallback && viewer.stats.rttMs > 0) viewer.rtcBaselineMs = Math.min(viewer.rtcBaselineMs, viewer.stats.rttMs);
4887
- const rtt = viewer.stats.rttMs;
4940
+ viewer.rttSamples = [...viewer.rttSamples.filter((sample) => now - sample.at < 1e3), { at: now, ms: viewer.stats.rttMs }];
4941
+ viewer.rttFloorMs = Math.min(...viewer.rttSamples.map((sample) => sample.ms).filter((ms) => ms > 0), viewer.stats.rttMs);
4942
+ if (!viewer.fallback && viewer.rttFloorMs > 0) viewer.rtcBaselineMs = Math.min(viewer.rtcBaselineMs, viewer.rttFloorMs);
4943
+ const rtt = viewer.rttFloorMs;
4888
4944
  let window = stableRttWindows.get(viewer);
4889
4945
  const stableNetwork = !viewer.fallback && rtt > viewer.rtcBaselineMs + 20 && (viewer.stats.videoPacketsPerSecond ?? 0) > 0 && (viewer.stats.lossPercent ?? 0) <= 1 && (viewer.stats.jitterBufferMs ?? 0) <= 80;
4890
4946
  if (stableNetwork && viewer.stats.videoFps >= fps * 0.9) {
@@ -4919,7 +4975,7 @@ async function runMediaServer() {
4919
4975
  }
4920
4976
  if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
4921
4977
  if (viewer.stats.videoFps >= fps * 0.8) viewer.everHealthy = true;
4922
- 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);
4978
+ 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");
4923
4979
  return;
4924
4980
  }
4925
4981
  case "answer":
@@ -4933,7 +4989,7 @@ async function runMediaServer() {
4933
4989
  enterFallback(viewer, message.reason);
4934
4990
  return;
4935
4991
  case "keyframe":
4936
- if (!viewer.fallback) requestKeyframe(void 0, viewer);
4992
+ if (!viewer.fallback) requestKeyframe("viewer watchdog", void 0, viewer);
4937
4993
  return;
4938
4994
  case "theme":
4939
4995
  applyDesktopTheme(message.theme);
@@ -4955,7 +5011,7 @@ async function runMediaServer() {
4955
5011
  };
4956
5012
  const httpServer = createServer2((request, response) => {
4957
5013
  const url = new URL(request.url || "/", "http://localhost");
4958
- if (url.pathname === "/health") {
5014
+ if (url.pathname === "/health" || url.pathname === "/telemetry") {
4959
5015
  const all = [...viewers.values()];
4960
5016
  const memory = process.memoryUsage();
4961
5017
  const { keyframeMs, profile: videoProfile, pixelFormat, quality } = encoderParams();
@@ -4964,7 +5020,7 @@ async function runMediaServer() {
4964
5020
  version: COMPUTER_USE_VERSION,
4965
5021
  clients: viewers.size,
4966
5022
  webrtc,
4967
- transports: all.map((viewer) => viewer.fallback ? "websocket" : viewer.rtc?.connected ? "webrtc" : "negotiating"),
5023
+ transports: all.map(transportOf),
4968
5024
  requests: all.map((viewer) => viewer.request),
4969
5025
  encoder: videoEncoder.running(),
4970
5026
  audioEncoder: audioEncoder.running(),
@@ -4998,7 +5054,8 @@ async function runMediaServer() {
4998
5054
  heapUsed: memory.heapUsed,
4999
5055
  width,
5000
5056
  height,
5001
- fps
5057
+ fps,
5058
+ ...url.pathname === "/telemetry" ? { events, samples } : {}
5002
5059
  }));
5003
5060
  return;
5004
5061
  }
@@ -5038,6 +5095,7 @@ async function runMediaServer() {
5038
5095
  request,
5039
5096
  fallback: false,
5040
5097
  waitingForKeyframe: true,
5098
+ withheldFrames: 0,
5041
5099
  everHealthy: false,
5042
5100
  lowFpsReports: 0,
5043
5101
  connectedAt: Date.now(),
@@ -5046,17 +5104,20 @@ async function runMediaServer() {
5046
5104
  probeAnswered: false,
5047
5105
  baselineMs: Number.POSITIVE_INFINITY,
5048
5106
  rtcBaselineMs: Number.POSITIVE_INFINITY,
5049
- protectBlockedUntil: 0
5107
+ protectBlockedUntil: 0,
5108
+ rembs: [],
5109
+ rttFloorMs: 0,
5110
+ rttSamples: []
5050
5111
  };
5051
5112
  if (viewers.size === 0) resetLadder(request);
5052
5113
  if (request.profile !== activeProfile || request.adaptive !== adaptive) {
5053
- process.stderr.write(`[desktop-stream] viewer requests ${request.profile} profile, ${request.adaptive ? "adaptive" : "pinned"} quality
5054
- `);
5114
+ record("viewer request", `${request.profile} profile, ${request.adaptive ? "adaptive" : "pinned"} quality`);
5055
5115
  activeProfile = request.profile;
5056
5116
  adaptive = request.adaptive;
5057
5117
  if (!adaptive) rung = 0;
5058
5118
  }
5059
5119
  viewers.set(socket, viewer);
5120
+ record("viewer connected", `${request.transport} requested, ${viewers.size} connected`);
5060
5121
  const reply = (message) => {
5061
5122
  if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
5062
5123
  };
@@ -5072,23 +5133,26 @@ async function runMediaServer() {
5072
5133
  candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
5073
5134
  message: (message, answer) => handleMessage(viewer, message, answer),
5074
5135
  keyframe: (reason) => {
5075
- if (reason === "stale") descendSevere(Date.now());
5076
- requestKeyframe(void 0, viewer);
5136
+ if (reason === "stale") descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
5137
+ requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
5077
5138
  },
5078
5139
  remb: (bitsPerSecond) => {
5079
- viewer.remb = { kbps: bitsPerSecond / 1e3, at: Date.now() };
5140
+ if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
5141
+ },
5142
+ connected: () => {
5143
+ record("webrtc connected", viewer.rtc?.path ?? void 0);
5144
+ requestKeyframe("viewer joined", Date.now());
5080
5145
  },
5081
- connected: () => requestKeyframe(Date.now()),
5082
5146
  // A viewer that loses its peer cannot send the key-ups for whatever it
5083
5147
  // was holding, and its socket may stay open for a long time yet.
5084
5148
  disconnected: (state) => {
5149
+ record(`webrtc ${state}`);
5085
5150
  viewer.input.release();
5086
5151
  if (state === "failed") reply({ type: "webrtc-failed" });
5087
5152
  }
5088
5153
  });
5089
5154
  } catch (error) {
5090
- process.stderr.write(`[desktop-stream] could not create a peer connection: ${error instanceof Error ? error.message : String(error)}
5091
- `);
5155
+ record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
5092
5156
  }
5093
5157
  }
5094
5158
  if (!viewer.rtc) enterFallback(viewer, offerWebRtc ? "webrtc unavailable" : "websocket requested");
@@ -5102,6 +5166,7 @@ async function runMediaServer() {
5102
5166
  viewer.rtc?.close();
5103
5167
  viewer.input.close();
5104
5168
  viewers.delete(socket);
5169
+ record("viewer disconnected", `${viewers.size} connected`);
5105
5170
  if (viewers.size > 0) {
5106
5171
  syncEncoder();
5107
5172
  return;
@@ -24,7 +24,7 @@ function validServerMessage(value) {
24
24
  case "config":
25
25
  return finite(value.protocolVersion) && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
26
26
  case "telemetry":
27
- return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer);
27
+ return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
28
28
  default:
29
29
  return false;
30
30
  }
@@ -129,6 +129,13 @@ var telemetry;
129
129
  var route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
130
130
  var state = {};
131
131
  globalThis.__replicasDesktop = state;
132
+ var viewerEvents = [];
133
+ state.events = viewerEvents;
134
+ var decoderResets = 0;
135
+ var note = (event, detail) => {
136
+ if (viewerEvents.push({ at: Date.now(), event, detail }) > 50) viewerEvents.shift();
137
+ };
138
+ var describeEvents = (list) => list.map((entry) => `${new Date(entry.at).toLocaleTimeString([], { hour12: false })} ${entry.event}${entry.detail ? ` (${entry.detail})` : ""}`).join(" \xB7 ");
132
139
  var showStatus = (message) => {
133
140
  statusText.textContent = message;
134
141
  status.classList.add("visible");
@@ -267,6 +274,7 @@ var configureVideo = async (next, preferHardware = true) => {
267
274
  if (videoDecoder !== decoder) return;
268
275
  videoDecoder = void 0;
269
276
  waitingForKeyframe = true;
277
+ note("decoder error", error.message);
270
278
  showStatus(`recovering video: ${error.message}`);
271
279
  if (config) void configureVideo(config, false).catch((reason) => showStatus(describe(reason)));
272
280
  }
@@ -335,6 +343,7 @@ var handleBinary = (data) => {
335
343
  videoDecoder.reset();
336
344
  videoDecoder.configure(activeVideoConfig);
337
345
  waitingForKeyframe = true;
346
+ state.decoderResets = ++decoderResets;
338
347
  }
339
348
  if (waitingForKeyframe && !key) return;
340
349
  waitingForKeyframe = false;
@@ -344,6 +353,7 @@ var handleBinary = (data) => {
344
353
  videoDecoder.reset();
345
354
  videoDecoder.configure(activeVideoConfig);
346
355
  waitingForKeyframe = true;
356
+ state.decoderResets = ++decoderResets;
347
357
  }
348
358
  };
349
359
  var unlockAudio = () => {
@@ -367,6 +377,7 @@ var useFallback = (reason, failed = false) => {
367
377
  if (transport === "websocket" || !config) return;
368
378
  closePeer();
369
379
  if (failed) webrtcSkips = webrtcSkipsAfterFailure;
380
+ note("fallback to socket", reason);
370
381
  transport = "websocket";
371
382
  transportReason = reason;
372
383
  route = { ...route, path: "proxy", codec: config.videoCodec };
@@ -412,8 +423,12 @@ var negotiate = async (sdp) => {
412
423
  if (peer !== pc) return;
413
424
  if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
414
425
  };
426
+ pc.oniceconnectionstatechange = () => {
427
+ if (peer === pc) note("ice", pc.iceConnectionState);
428
+ };
415
429
  pc.onconnectionstatechange = () => {
416
430
  if (peer !== pc) return;
431
+ note("webrtc", pc.connectionState);
417
432
  if (pc.connectionState === "connected") {
418
433
  transport = "webrtc";
419
434
  transportReason = "";
@@ -491,7 +506,7 @@ var handleServerMessage = (message, viaSocket) => {
491
506
  useFallback("recent WebRTC failure");
492
507
  } else if (!transport) {
493
508
  showStatus("Establishing connection\u2026");
494
- webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not display video in time", true), webrtcFrameTimeoutMs);
509
+ webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not decode video in time", true), webrtcFrameTimeoutMs);
495
510
  }
496
511
  };
497
512
  var handleServerText = (data, viaSocket) => {
@@ -516,6 +531,7 @@ var connect = () => {
516
531
  socket = nextSocket;
517
532
  nextSocket.addEventListener("open", () => {
518
533
  if (socket !== nextSocket) return;
534
+ note("socket open");
519
535
  actionId = 0;
520
536
  reconnectAttempt = 0;
521
537
  sendSocket({ type: "theme", theme: document.documentElement.dataset.theme === "light" ? "light" : "dark" });
@@ -534,8 +550,9 @@ var connect = () => {
534
550
  transport = void 0;
535
551
  announceTransport();
536
552
  reconnectAttempt += 1;
537
- const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5));
538
- reconnectTimer = setTimeout(connect, delay * (0.85 + Math.random() * 0.3));
553
+ const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5)) * (0.85 + Math.random() * 0.3);
554
+ note("socket closed", `reconnect in ${Math.round(delay)} ms`);
555
+ reconnectTimer = setTimeout(connect, delay);
539
556
  });
540
557
  nextSocket.addEventListener("error", () => nextSocket.close());
541
558
  };
@@ -561,11 +578,6 @@ var flushPointer = () => {
561
578
  var frameCallback = 0;
562
579
  var observeFrame = () => {
563
580
  frameCallback = screen.requestVideoFrameCallback((_now, metadata) => {
564
- const stream = screen.srcObject;
565
- if (webrtcDeadline && transport === "webrtc" && stream instanceof MediaStream && peer?.getReceivers().some(({ track }) => stream.getVideoTracks().includes(track))) {
566
- clearTimeout(webrtcDeadline);
567
- webrtcDeadline = void 0;
568
- }
569
581
  renderedFrames += 1;
570
582
  presentedFrames += lastPresentedCount ? Math.max(0, metadata.presentedFrames - lastPresentedCount) : 1;
571
583
  lastPresentedCount = metadata.presentedFrames;
@@ -654,8 +666,11 @@ var updateDiagnostics = (stats) => {
654
666
  `${transport ?? "connecting"}${transportReason ? ` (${transportReason})` : ""} \xB7 ${route.path || "\u2014"} \xB7 ${route.codec || config?.videoCodec || ""} \xB7 ${view} view`,
655
667
  `source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
656
668
  `${route.displayedFps.toFixed(1)} displayed/s \xB7 ${stats.videoFps.toFixed(1)} decoded/s \xB7 ${route.kbps.toFixed(0)} kbps \xB7 ${stats.rttMs.toFixed(0)} ms rtt \xB7 ${socketRtt.toFixed(0)} ms via proxy \xB7 ${network}`,
657
- encoder ? `encoder ${encoder.profile} crf ${encoder.crf} \xB7 ${encoder.codecProfile} ${encoder.pixelFormat} ${encoder.color} \xB7 rung ${encoder.rung}/${encoder.rungs - 1} cap ${encoder.kbpsCap} kbps${encoder.adaptive ? "" : " pinned"} \xB7 ${encoder.fps} fps ${encoder.kbps} kbps \xB7 ${encoder.preset}` : "",
658
- telemetry && server ? `server cpu: encoder ${telemetry.cpu.encoderPercent}% \xB7 stream ${telemetry.cpu.serverPercent}% \xB7 load ${telemetry.cpu.load} on ${telemetry.cpu.cores} cores \xB7 budget ${server.budgetKbps} kbps sent ${server.sentKbps} \xB7 queue ${server.queueMs} ms ${server.queuedPackets} pkt \xB7 stale ${server.staleEvents} dropped ${server.droppedFrames} \xB7 repairs ${server.repairsSent}/${server.repairsSkipped} skipped \xB7 copies ${server.duplicatesSent}${server.protecting ? " on" : ""} \xB7 remb ${server.rembKbps ?? "\u2014"}` : ""
669
+ encoder ? `encoder ${encoder.profile} crf ${encoder.crf} \xB7 ${encoder.codecProfile} ${encoder.pixelFormat} ${encoder.color} \xB7 rung ${encoder.rung}/${encoder.rungs - 1} cap ${encoder.kbpsCap} kbps${encoder.adaptive ? "" : " pinned"} \xB7 keyframe every ${encoder.keyframeMs} ms \xB7 ${encoder.fps} fps ${encoder.kbps} kbps \xB7 ${encoder.preset}` : "",
670
+ telemetry && server ? `server cpu: encoder ${telemetry.cpu.encoderPercent}% \xB7 stream ${telemetry.cpu.serverPercent}% \xB7 load ${telemetry.cpu.load} on ${telemetry.cpu.cores} cores \xB7 budget ${server.budgetKbps} kbps sent ${server.sentKbps} \xB7 queue ${server.queueMs} ms ${server.queuedPackets} pkt \xB7 stale ${server.staleEvents} dropped ${server.droppedFrames} \xB7 repairs ${server.repairsSent}/${server.repairsSkipped} skipped \xB7 copies ${server.duplicatesSent}${server.protecting ? " on" : ""} \xB7 remb ${server.rembKbps ?? "\u2014"}` : "",
671
+ transport === "websocket" && server ? `socket: ${Math.round(server.bufferedBytes / 1024)} KiB unsent \xB7 ${server.withheldFrames} deltas withheld${server.waitingForKeyframe ? " \xB7 awaiting keyframe" : ""} \xB7 ${decoderResets} decoder resets` : "",
672
+ telemetry?.events.length ? `server events: ${describeEvents(telemetry.events)}` : "",
673
+ viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
659
674
  ].filter(Boolean).join("\n");
660
675
  };
661
676
  var inboundOf = (kind) => (stat) => stat.type === "inbound-rtp" && "kind" in stat && stat.kind === kind;
@@ -691,6 +706,11 @@ var reportWebRtcStats = async (pc, stats) => {
691
706
  jitterBufferEmittedCount: video?.jitterBufferEmittedCount ?? 0,
692
707
  audioPackets: audio?.packetsReceived ?? 0
693
708
  };
709
+ if (counters.framesDecoded > (inbound?.framesDecoded ?? 0)) {
710
+ lastFrameAt = now;
711
+ clearTimeout(webrtcDeadline);
712
+ webrtcDeadline = void 0;
713
+ }
694
714
  if (!inbound) inboundHistory = [];
695
715
  while (inboundHistory.length > 1 && inboundHistory[1].at <= now - 1e3) inboundHistory.shift();
696
716
  const oldest = inboundHistory[0];
@@ -723,8 +743,6 @@ var reportWebRtcStats = async (pc, stats) => {
723
743
  updateDiagnostics(stats);
724
744
  state.framesDropped = video?.framesDropped;
725
745
  };
726
- var watchdogPlaybackFrames = 0;
727
- var watchdogPlaybackAt = 0;
728
746
  setInterval(() => {
729
747
  if (transport !== "webrtc") return;
730
748
  if (screen.paused && screen.srcObject instanceof MediaStream && screen.srcObject.getVideoTracks().some((track) => track.readyState === "live")) return;
@@ -733,15 +751,10 @@ setInterval(() => {
733
751
  useFallback("WebRTC video stopped arriving", true);
734
752
  return;
735
753
  }
736
- const quality = screen.getVideoPlaybackQuality();
737
- const playbackFrames = quality.totalVideoFrames - quality.droppedVideoFrames;
738
- if (playbackFrames !== watchdogPlaybackFrames) {
739
- watchdogPlaybackFrames = playbackFrames;
740
- watchdogPlaybackAt = now;
741
- }
742
754
  if (inputChannel?.readyState !== "open") return;
743
- if (now - watchdogPlaybackAt < 300 || now - lastFrameAt < 300 || now - lastKeyframeRequestAt < 1e3) return;
755
+ if (now - lastFrameAt < 2500 || now - lastKeyframeRequestAt < 1e3) return;
744
756
  lastKeyframeRequestAt = now;
757
+ note("keyframe requested", `no frame for ${Math.round(now - lastFrameAt)} ms`);
745
758
  sendInput({ type: "keyframe" });
746
759
  }, 100);
747
760
  setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",