replicas-computer 0.3.17 → 0.3.18

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
@@ -23,15 +23,15 @@ Chrome disables GPU compositing when no `/dev/dri/renderD*` device is available.
23
23
 
24
24
  ## How the stream works
25
25
 
26
- The Go capture process listens for XDamage updates, copies the current screen with XShm, converts RGB to YUV, and encodes H.264 through libx264 directly into RTP on loopback. The libyuv path copies and converts the changed bounding rectangle, aligned to preserve 4:2:0 chroma samples, while reusing unchanged pixels. It captures up to 60fps when the desktop changes and sends 5fps keepalives while idle. It reads control lines on stdin between frames: `keyframe` encodes the next frame as an IDR at once, `quality <crf> <kbps>` changes the rate control through `x264_encoder_reconfig`, and `fps <n>` changes the capture rate, so none of them restarts the process. FFmpeg supplies Opus audio and remains the video fallback when the native binary is missing or fails. The stream uses the desktop resolution, 1920×1080 by default. 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.
26
+ The Go capture process listens for XDamage updates, copies the current screen with XShm, converts RGB to YUV, and encodes H.264 through libx264 directly into RTP on loopback. The libyuv path copies and converts the changed bounding rectangle, aligned to preserve 4:2:0 chroma samples, while reusing unchanged pixels. It captures up to 60fps when the desktop changes and sends 5fps keepalives while idle. It reads control lines on stdin between frames: `keyframe` encodes the next frame as an IDR at once, `quality <crf> <kbps>` changes the rate control through `x264_encoder_reconfig`, and `fps <n>` changes the capture rate, so none of them restarts the process. x264 keeps the frame rate it opened with and sizes its rate cap by it, so the encoder always opens at the stream's full rate and `--capture-fps` sets the rate capture starts at. FFmpeg supplies Opus audio and remains the video fallback when the native binary is missing or fails. The stream uses the desktop resolution, 1920×1080 by default. 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.
27
27
 
28
28
  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 (at least three after the connection completes, so a slow network is not abandoned just as it connects) switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser. A viewer that fell back after a WebRTC failure keeps trying WebRTC in the background, 15 seconds after the fallback and then at doubling intervals up to five minutes: the server opens a new peer while the socket keeps carrying media, the new peer plays into an unseen element without offering 4:4:4, and once it decodes video the viewer moves its picture, audio and input to it and tells the server to stop the socket media (`retry-webrtc`, then `webrtc-ready`). The dashboard records each return as `desktop_stream_webrtc_recovered` with the time spent on the socket.
29
29
 
30
30
  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 bitrate ladder down to 750 kbps that keeps resolution and frame rate and adds two CRF per rung under the default 20 Mbps cap; `?adapt=0` pins the top rung.
31
31
 
32
- 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. Loss, RTT growth and jitter count only while the encoder has sent over 500 kbps for three seconds: on a quiet screen they belong to the network, and no rung fixes them. Climbing back to the rung below the one a descent left takes a few clean seconds; retrying that rung waits 15, 30 and then 60 seconds as episodes repeat, and an episode is forgotten after three minutes. 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 and the browser's available-bitrate estimate only bound how far a severe descent (heavy loss or a starved decoder) steps, and only while the stream has been sending more than they report. `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.
32
+ 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. Loss, RTT growth and jitter count only while the encoder has sent over 500 kbps for three seconds: on a quiet screen they belong to the network, and no rung fixes them. Climbing back to the rung below the one a descent left takes a few clean seconds; retrying that rung waits 15, 30 and then 60 seconds as episodes repeat, and an episode is forgotten after three minutes. 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, a starved decoder or a cut send queue) steps, and only once it has stopped climbing below what the stream sends. The browser's available-bitrate stat is the same estimate and is reported but never used. `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.
33
33
 
34
- The superfast preset uses integer-pixel motion search to reduce encoder CPU while retaining CABAC compression; ultrafast is unchanged. Encoding uses at most four threads and one queued capture frame and skips rapid catch-up captures after CPU stalls. Native capture sends no periodic keyframes or refresh wave: an IDR follows within a frame when a viewer joins, a receiver sends PLI, a send queue is cut, the viewer's watchdog sees no video, or a socket viewer whose frames were withheld has drained its backlog or reset its decoder. Requests within a round trip of the last keyframe share it. Browsers join only at an IDR, so the SPS that intra refresh puts before a recovery point is not treated as a keyframe. Rung and standby changes are applied in place; a change of H.264 profile, chroma or preset starts a replacement, which discards two initial frames, emits a forced keyframe and then takes over. The FFmpeg fallback cannot be told to make a keyframe, so it keeps periodic intra refresh for WebRTC viewers, switches to 250 ms IDR intervals for socket viewers, and makes requested keyframes by replacement. Every new encoder has four seconds to produce joinable media. Native startup or replacement failures switch subsequent launches to FFmpeg; the active encoder continues until its replacement is ready.
34
+ The superfast preset uses integer-pixel motion search to reduce encoder CPU while retaining CABAC compression; ultrafast is unchanged. Encoding uses at most four threads and one queued capture frame and skips rapid catch-up captures after CPU stalls. Native capture sends no periodic keyframes or refresh wave: an IDR follows within a frame when a viewer joins, a receiver sends PLI, a send queue is cut, the viewer's watchdog sees no video, or a socket viewer whose frames were withheld has drained its backlog or reset its decoder. Requests within a round trip of the last keyframe share it. Browsers join only at an IDR, so the SPS that intra refresh puts before a recovery point is not treated as a keyframe. Rung and standby changes are applied in place; a change of H.264 profile, chroma or preset starts a replacement, which discards two initial frames, emits a forced keyframe and then takes over. Until then the outgoing encoder takes the new rate, and keyframe requests wait for the replacement's. The FFmpeg fallback cannot be told to make a keyframe, so it keeps periodic intra refresh for WebRTC viewers, switches to 250 ms IDR intervals for socket viewers, and makes requested keyframes by replacement. Every new encoder has four seconds to produce joinable media. Native startup or replacement failures switch subsequent launches to FFmpeg; the active encoder continues until its replacement is ready.
35
35
 
36
36
  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.
37
37
 
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.17";
2940
+ var COMPUTER_USE_VERSION = "0.3.18";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3478,13 +3478,21 @@ function superviseEncoder(options) {
3478
3478
  if (active || candidate) return;
3479
3479
  await spawnCandidate();
3480
3480
  });
3481
- const replace = () => enqueue(async () => {
3482
- if (!wanted) return;
3483
- clearTimeout(restart);
3484
- clearTimeout(candidateDeadline);
3485
- retire(candidate);
3486
- await spawnCandidate();
3487
- });
3481
+ let queuedReplacements = 0;
3482
+ const replace = () => {
3483
+ queuedReplacements += 1;
3484
+ return enqueue(async () => {
3485
+ try {
3486
+ if (!wanted) return;
3487
+ clearTimeout(restart);
3488
+ clearTimeout(candidateDeadline);
3489
+ retire(candidate);
3490
+ await spawnCandidate();
3491
+ } finally {
3492
+ queuedReplacements -= 1;
3493
+ }
3494
+ });
3495
+ };
3488
3496
  const stop = () => enqueue(async () => {
3489
3497
  wanted = false;
3490
3498
  clearTimeout(restart);
@@ -3507,7 +3515,7 @@ function superviseEncoder(options) {
3507
3515
  stop,
3508
3516
  control,
3509
3517
  running: () => Boolean(active && alive(active.child)),
3510
- replacing: () => Boolean(candidate),
3518
+ replacing: () => Boolean(candidate) || queuedReplacements > 0,
3511
3519
  generation: () => active?.id ?? 0,
3512
3520
  pid: () => active?.child.pid ?? 0,
3513
3521
  command: () => active?.child.spawnfile,
@@ -4200,6 +4208,8 @@ function superviseVideoEncoder(options) {
4200
4208
  "--height",
4201
4209
  String(height2),
4202
4210
  "--fps",
4211
+ String(options.maxFps),
4212
+ "--capture-fps",
4203
4213
  String(fps2),
4204
4214
  "--threads",
4205
4215
  String(Math.min(4, availableParallelism())),
@@ -4807,6 +4817,7 @@ async function runMediaServer() {
4807
4817
  width,
4808
4818
  height,
4809
4819
  colorMode,
4820
+ maxFps: fps,
4810
4821
  params: encoderParams,
4811
4822
  onPacket: onVideoPacket,
4812
4823
  record
@@ -4882,17 +4893,16 @@ async function runMediaServer() {
4882
4893
  }
4883
4894
  if (description !== encoderDescription) record("encoder", description);
4884
4895
  encoderDescription = description;
4885
- if (signature !== encoderSignature) {
4896
+ const replacing = signature !== encoderSignature;
4897
+ if (replacing) {
4886
4898
  encoderSignature = signature;
4887
- encoderLive = live;
4888
4899
  lastSwapAt = Date.now();
4889
4900
  void videoEncoder.replace();
4890
- return true;
4891
4901
  }
4892
4902
  if (live.crf !== encoderLive.crf || live.kbps !== encoderLive.kbps) videoEncoder.control(`quality ${live.crf} ${live.kbps}`);
4893
4903
  if (live.fps !== encoderLive.fps) videoEncoder.control(`fps ${live.fps}`);
4894
4904
  encoderLive = live;
4895
- return false;
4905
+ return replacing;
4896
4906
  };
4897
4907
  const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
4898
4908
  const stopEncodersSoon = () => {
@@ -4974,12 +4984,9 @@ async function runMediaServer() {
4974
4984
  congestedSeconds = 0;
4975
4985
  if (!adaptive || now - lastSevereDescentAt < 1e3) return;
4976
4986
  lastSevereDescentAt = now;
4977
- const sent = Math.min(...recentVideoKbps);
4978
4987
  const estimates = [...viewers.values()].flatMap((viewer) => {
4979
- const available = viewer.stats && now - viewer.stats.at < 750 ? viewer.stats.availableKbps ?? 0 : 0;
4980
- const reported = sent > 500 && available < sent * 0.9 ? available : 0;
4981
- const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
4982
- return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
4988
+ const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) : void 0;
4989
+ return remb ? [remb] : [];
4983
4990
  });
4984
4991
  const rungs = ladder();
4985
4992
  const estimatedRung = estimates.length ? rungs.findIndex((quality) => quality.kbps <= Math.min(...estimates) * 0.8) : rung + 2;
@@ -1,7 +1,7 @@
1
1
  package capture
2
2
 
3
3
  type Options struct {
4
- Width, Height, FPS, Threads, CRF, Kbps int
4
+ Width, Height, FPS, CaptureFPS, Threads, CRF, Kbps int
5
5
  KeyframeMS, IntraRefresh, ChromaQP, Warmup, Payload, BT709 int
6
6
  Preset, Profile, URL, SSRC string
7
7
  }
package/native/main.go CHANGED
@@ -2,6 +2,7 @@ package main
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "cmp"
5
6
  "fmt"
6
7
  "os"
7
8
  "os/signal"
@@ -59,7 +60,10 @@ func run(o capture.Options) error {
59
60
  }
60
61
  started := capture.NowMicros()
61
62
  last, lastKeyframe, reportAt := started-1000000, started-1000000, started
62
- interval := int64((1000000 + o.FPS - 1) / o.FPS)
63
+ // x264 keeps the frame rate it opened with and sizes its rate cap by it, so
64
+ // --fps is the stream's full rate and --capture-fps the rate capture starts at.
65
+ captureFPS := cmp.Or(o.CaptureFPS, o.FPS)
66
+ interval := int64((1000000 + captureFPS - 1) / captureFPS)
63
67
  busyUS, captureUS, scaleUS, encodeUS, previousPTS := int64(0), int64(0), int64(0), int64(0), int64(-1)
64
68
  dirty, forceKeyframe := true, false
65
69
  number, changedFrames, sentFrames := 0, 0, 0
package/native/options.go CHANGED
@@ -18,7 +18,7 @@ func parseOptions() (capture.Options, error) {
18
18
  initial, low, high int
19
19
  }{
20
20
  {"width", &o.Width, 0, 2, 8192}, {"height", &o.Height, 0, 2, 8192},
21
- {"fps", &o.FPS, 60, 1, 240}, {"threads", &o.Threads, 4, 1, 16},
21
+ {"fps", &o.FPS, 60, 1, 240}, {"capture-fps", &o.CaptureFPS, 0, 1, 240}, {"threads", &o.Threads, 4, 1, 16},
22
22
  {"crf", &o.CRF, 14, 0, 51}, {"kbps", &o.Kbps, 20000, 1, 1000000},
23
23
  {"keyframe-ms", &o.KeyframeMS, 1000, 0, 10000}, {"intra-refresh", &o.IntraRefresh, 1, 0, 1},
24
24
  {"chroma-qp", &o.ChromaQP, 0, -12, 12}, {"warmup", &o.Warmup, 0, 0, 60},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",