replicas-computer 0.3.12 → 0.3.14

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
@@ -17,7 +17,7 @@ The versioned computer-use runtime for Replicas workspace images.
17
17
  - `xsettingsd` broadcasts theme changes to running GTK apps and Chrome. Chrome follows the dashboard theme without reopening tabs; launch it without a `GTK_THEME` override.
18
18
  - `assets/desktop/` owns the desktop theme: `brand-wallpaper.svg` (regenerate with `bun scripts/render-wallpaper.ts`), `tint2rc` dock, `openbox-theme/`, `gtk-settings.ini`, the `replicas-terminal` wrapper, `themes/` palettes that `start-services.sh` renders into those templates (`--theme light|dark` switches a running desktop), and the bundled fonts (static TTF instances of General Sans and Geist Mono; variable and WOFF2 builds render with wrong metrics in Pango and VTE).
19
19
 
20
- The viewer and agent command paths are independent. One on-demand encoder pair is shared by all viewers, stops when the last viewer disconnects, and restarts without reloading the page.
20
+ The viewer and agent command paths are independent. One on-demand encoder pair is shared by all viewers, stops when the last viewer disconnects, and restarts without reloading the page. A viewer out of sight (the dashboard keeps one connected while the Desktop tab is hidden) reports `standby`, detected with an `IntersectionObserver` because a hidden iframe keeps its last layout size; while every viewer is in standby the encoder runs at 5 fps on the 1.5 Mbps rung, and the first viewer to become visible swaps it back to full rate without a gap.
21
21
 
22
22
  Chrome disables GPU compositing when no `/dev/dri/renderD*` device is available. This avoids Mesa's CPU-rendered compositor overhead before capture while retaining WebGL support. Desktops with a hardware render device keep Chrome's normal compositing path.
23
23
 
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.12";
2940
+ var COMPUTER_USE_VERSION = "0.3.14";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3628,6 +3628,8 @@ function validInput(value) {
3628
3628
  return true;
3629
3629
  case "theme":
3630
3630
  return value.theme === "light" || value.theme === "dark";
3631
+ case "standby":
3632
+ return typeof value.standby === "boolean";
3631
3633
  case "ping":
3632
3634
  case "pong":
3633
3635
  return finite(value.sentAt);
@@ -4069,7 +4071,7 @@ var WebRtcViewer = class {
4069
4071
  // src/video-encoder.ts
4070
4072
  var captureBinary = resolve2(dirname6(fileURLToPath(import.meta.url)), "..", "dist", "replicas-capture");
4071
4073
  function superviseVideoEncoder(options) {
4072
- const { display: display2, width: width2, height: height2, fps: fps2, colorMode: colorMode2 } = options;
4074
+ const { display: display2, width: width2, height: height2, colorMode: colorMode2 } = options;
4073
4075
  const env = { ...process.env, DISPLAY: display2 };
4074
4076
  let nativeCapture = process.env.REPLICAS_DESKTOP_STREAM_CAPTURE !== "ffmpeg" && existsSync6(captureBinary);
4075
4077
  let captureMetrics;
@@ -4098,7 +4100,7 @@ function superviseVideoEncoder(options) {
4098
4100
  onPacket: options.onPacket,
4099
4101
  joinable: (packet) => isKeyframeStart(rtpPayload(packet)),
4100
4102
  args: (udpPort, warmupFrames) => {
4101
- const { quality, keyframeMs, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
4103
+ const { quality, keyframeMs, fps: fps2, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
4102
4104
  const keyframeInterval = Math.max(1, Math.round(fps2 * keyframeMs / 1e3));
4103
4105
  if (nativeCapture) return [
4104
4106
  "--width",
@@ -4445,6 +4447,8 @@ var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP
4445
4447
  var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
4446
4448
  var basePreset = availableParallelism2() >= 4 ? "superfast" : "ultrafast";
4447
4449
  var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "", 10) || 60;
4450
+ var standbyFps = 5;
4451
+ var standbyRung = 5;
4448
4452
  var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
4449
4453
  var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
4450
4454
  var webrtcEnabled = process.env.REPLICAS_DESKTOP_STREAM_WEBRTC !== "0";
@@ -4601,8 +4605,11 @@ async function runMediaServer() {
4601
4605
  if (accepted.length === 0) return "main";
4602
4606
  return PROFILE_ORDER.find((candidate) => (!requires420 || candidate !== "high444") && accepted.every((set) => set.has(candidate))) ?? "baseline";
4603
4607
  };
4608
+ const allStandby = () => viewers.size > 0 && [...viewers.values()].every((viewer) => viewer.standby);
4609
+ const streamFps = () => allStandby() ? Math.min(fps, standbyFps) : fps;
4604
4610
  const encoderParams = () => {
4605
- const quality = ladder()[rung];
4611
+ const quality = ladder()[allStandby() ? Math.max(rung, standbyRung) : rung];
4612
+ const encodeFps = streamFps();
4606
4613
  const sockets = socketViewers().length > 0;
4607
4614
  const keyframeMs = sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
4608
4615
  const videoProfile = profile();
@@ -4615,8 +4622,9 @@ async function runMediaServer() {
4615
4622
  keyframeMs,
4616
4623
  intraRefresh,
4617
4624
  pixelFormat,
4625
+ fps: encodeFps,
4618
4626
  chromaQpOffset: profiles[activeProfile].chromaQpOffset,
4619
- signature: `rung ${rung} \xB7 ${activeProfile} crf ${quality.crf} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs}ms${intraRefresh ? " refresh" : ""}`
4627
+ signature: `rung ${rung} \xB7 ${activeProfile} crf ${quality.crf} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs}ms${intraRefresh ? " refresh" : ""} \xB7 ${encodeFps} fps`
4620
4628
  };
4621
4629
  };
4622
4630
  const budgetBps = () => ladder()[rung].kbps * 1e3 * budgetFactor;
@@ -4663,7 +4671,7 @@ async function runMediaServer() {
4663
4671
  }
4664
4672
  }
4665
4673
  const last = Boolean(packet[1] & 128);
4666
- videoSplicer.splice(packet, generation, Math.round(9e4 / fps));
4674
+ videoSplicer.splice(packet, generation, Math.round(9e4 / streamFps()));
4667
4675
  let rtp;
4668
4676
  let sockets = false;
4669
4677
  for (const viewer of viewers.values()) {
@@ -4699,13 +4707,12 @@ async function runMediaServer() {
4699
4707
  display,
4700
4708
  width,
4701
4709
  height,
4702
- fps,
4703
4710
  colorMode,
4704
4711
  params: encoderParams,
4705
4712
  onPacket: onVideoPacket,
4706
4713
  record
4707
4714
  });
4708
- const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(fps, encoderFps)) : fps;
4715
+ const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(streamFps(), encoderFps)) : streamFps();
4709
4716
  const audioEncoder = superviseEncoder({
4710
4717
  label: "audio",
4711
4718
  log: (message) => record("audio encoder", message),
@@ -4876,7 +4883,7 @@ async function runMediaServer() {
4876
4883
  if (viewers.size === 0) return;
4877
4884
  const { captureBackend, captureMetrics } = videoEncoder.capture();
4878
4885
  const captureBusy = captureBackend !== "native" || !captureMetrics || captureMetrics.busyMs / captureMetrics.elapsedMs > 0.8;
4879
- if (videoEncoder.running() && encoderFps < fps * 0.9 && captureBusy) {
4886
+ if (videoEncoder.running() && encoderFps < streamFps() * 0.9 && captureBusy) {
4880
4887
  cleanTransportSeconds = 0;
4881
4888
  congestedSeconds = 0;
4882
4889
  comfortableSeconds = 0;
@@ -5143,6 +5150,12 @@ async function runMediaServer() {
5143
5150
  case "theme":
5144
5151
  applyDesktopTheme(message.theme);
5145
5152
  return;
5153
+ case "standby":
5154
+ if (viewer.standby === message.standby) return;
5155
+ viewer.standby = message.standby;
5156
+ record(message.standby ? "viewer standby" : "viewer active", `${[...viewers.values()].filter((v) => v.standby).length} of ${viewers.size} in standby`);
5157
+ syncEncoder();
5158
+ return;
5146
5159
  default:
5147
5160
  viewer.input.handle(message);
5148
5161
  }
@@ -5185,6 +5198,8 @@ async function runMediaServer() {
5185
5198
  audioBytes,
5186
5199
  videoKbps,
5187
5200
  encoderFps,
5201
+ streamFps: streamFps(),
5202
+ standbyViewers: all.filter((viewer) => viewer.standby).length,
5188
5203
  ...videoEncoder.capture(),
5189
5204
  cpu,
5190
5205
  clientStats: all.flatMap((viewer) => viewer.stats ? [viewer.stats] : []),
@@ -5238,13 +5253,14 @@ async function runMediaServer() {
5238
5253
  socket.destroy();
5239
5254
  return;
5240
5255
  }
5241
- webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest)));
5256
+ webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest), url.searchParams.get("standby") === "1"));
5242
5257
  });
5243
- webSocketServer.on("connection", (socket, request) => {
5258
+ webSocketServer.on("connection", (socket, request, standby) => {
5244
5259
  const viewer = {
5245
5260
  socket,
5246
5261
  input: new InputBridge(display, width, height),
5247
5262
  request,
5263
+ standby,
5248
5264
  fallback: false,
5249
5265
  waitingForKeyframe: true,
5250
5266
  withheldFrames: 0,
@@ -5269,7 +5285,7 @@ async function runMediaServer() {
5269
5285
  if (!adaptive) rung = 0;
5270
5286
  }
5271
5287
  viewers.set(socket, viewer);
5272
- record("viewer connected", `${request.transport} requested, ${viewers.size} connected`);
5288
+ record("viewer connected", `${request.transport} requested${standby ? " in standby" : ""}, ${viewers.size} connected`);
5273
5289
  const reply = (message) => {
5274
5290
  if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
5275
5291
  };
@@ -45,6 +45,7 @@ var DESKTOP_WEBRTC_FAILURE_REASONS = {
45
45
  video_stalled: "WebRTC video stopped arriving",
46
46
  full_chroma_undecodable: "WebRTC could not decode H.264 High 4:4:4"
47
47
  };
48
+ var DESKTOP_QUALITY_MESSAGE = "replicas-desktop-quality";
48
49
 
49
50
  // src/input-validation.ts
50
51
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
@@ -100,6 +101,7 @@ diagnostics.classList.toggle("visible", showDiagnostics);
100
101
  var webrtcFrameTimeoutMs = 6e3;
101
102
  var webrtcSkipsAfterFailure = 3;
102
103
  var pointerIntervalMs = 4;
104
+ var qualityWindowMs = 3e4;
103
105
  var fullChromaFailureKey = "replicas-desktop-4:4:4-failed";
104
106
  var skipFullChroma = false;
105
107
  var fullChromaFailure;
@@ -108,6 +110,8 @@ try {
108
110
  } catch {
109
111
  }
110
112
  var socket;
113
+ var standby = innerWidth === 0 || innerHeight === 0;
114
+ var standbyTimer;
111
115
  var config;
112
116
  var transport;
113
117
  var transportReason = "";
@@ -151,6 +155,18 @@ var inboundAt = 0;
151
155
  var inboundHistory = [];
152
156
  var telemetry;
153
157
  var route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
158
+ var qualitySamples = [];
159
+ var qualityWindow;
160
+ var qualityUnseen = { decoded: 0, presented: 0, samples: 0 };
161
+ var qualitySequence = 0;
162
+ var presentedAt = 0;
163
+ var socketDecodedFrames = 0;
164
+ var restartQuality = () => {
165
+ qualitySamples = [];
166
+ qualityWindow = void 0;
167
+ qualitySequence = 0;
168
+ presentedAt = 0;
169
+ };
154
170
  var state = {};
155
171
  globalThis.__replicasDesktop = state;
156
172
  var viewerEvents = [];
@@ -290,6 +306,7 @@ var configureVideo = async (next, preferHardware = true) => {
290
306
  activeVideoConfig = selectedConfig;
291
307
  const decoder = new VideoDecoder({
292
308
  output: (frame) => {
309
+ socketDecodedFrames += 1;
293
310
  const writer = videoWriter;
294
311
  if (!writer || (writer.desiredSize ?? 1) <= 0) {
295
312
  frame.close();
@@ -443,6 +460,7 @@ var useFallback = (reasonCode, failed = false) => {
443
460
  if (failed) webrtcSkips = webrtcSkipsAfterFailure;
444
461
  note("fallback to socket", reason);
445
462
  transport = "websocket";
463
+ restartQuality();
446
464
  transportReason = reason;
447
465
  route = { ...route, path: "proxy", codec: config.videoCodec };
448
466
  announceTransport();
@@ -624,11 +642,13 @@ var connect = () => {
624
642
  candidateErrors = 0;
625
643
  negotiationErrorName = "";
626
644
  lastFrameAt = 0;
645
+ restartQuality();
627
646
  route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
628
647
  announceTransport();
629
648
  socket?.close();
630
649
  showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026", true);
631
650
  const query = new URLSearchParams([...params].filter(([key]) => ["transport", "profile", "adapt", "ice"].includes(key)));
651
+ if (standby) query.set("standby", "1");
632
652
  const nextSocket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/stream?${query}`);
633
653
  nextSocket.binaryType = "arraybuffer";
634
654
  socket = nextSocket;
@@ -685,6 +705,8 @@ var observeFrame = () => {
685
705
  presentedFrames += lastPresentedCount ? Math.max(0, metadata.presentedFrames - lastPresentedCount) : 1;
686
706
  lastPresentedCount = metadata.presentedFrames;
687
707
  lastFrameAt = performance.now();
708
+ if (presentedAt && qualityWindow) qualityWindow.longestGapMs = Math.max(qualityWindow.longestGapMs, lastFrameAt - Math.max(presentedAt, qualityWindow.at));
709
+ presentedAt = lastFrameAt;
688
710
  status.classList.remove("visible");
689
711
  observeFrame();
690
712
  });
@@ -730,6 +752,21 @@ window.addEventListener("keyup", (event) => {
730
752
  }, { capture: true });
731
753
  window.addEventListener("blur", () => send({ type: "release" }));
732
754
  window.addEventListener("resize", fitScreen);
755
+ var setStandby = (next) => {
756
+ clearTimeout(standbyTimer);
757
+ if (next === standby) return;
758
+ standby = next;
759
+ note(standby ? "standby" : "active");
760
+ sendSocket({ type: "standby", standby });
761
+ if (!standby && !audioPrompt.hidden) unlockAudio();
762
+ };
763
+ new IntersectionObserver(([entry]) => {
764
+ if (entry.isIntersecting) setStandby(false);
765
+ else if (!standby) {
766
+ clearTimeout(standbyTimer);
767
+ standbyTimer = setTimeout(() => setStandby(true), 1e3);
768
+ }
769
+ }).observe(document.documentElement);
733
770
  window.addEventListener("online", connect);
734
771
  document.addEventListener("visibilitychange", () => {
735
772
  if (document.visibilityState === "hidden") {
@@ -777,6 +814,53 @@ var updateDiagnostics = (stats) => {
777
814
  viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
778
815
  ].filter(Boolean).join("\n");
779
816
  };
817
+ var reportQuality = (stats) => {
818
+ if (window.parent === window || !transport || !config || !telemetry) return;
819
+ const now = performance.now();
820
+ const current = qualityWindow ??= { at: now, presented: presentedFrames, freezes: inbound?.freezeCount ?? 0, longestGapMs: 0 };
821
+ const decoded = transport === "webrtc" ? inbound?.framesDecoded ?? 0 : socketDecodedFrames;
822
+ qualityUnseen = { decoded, presented: presentedFrames, samples: decoded > qualityUnseen.decoded && presentedFrames === qualityUnseen.presented ? qualityUnseen.samples + 1 : 0 };
823
+ if (qualityUnseen.samples >= 4) current.unseen = true;
824
+ qualitySamples.push({ stats, telemetry });
825
+ const windowMs = now - current.at;
826
+ if (windowMs < qualityWindowMs) return;
827
+ const samples = qualitySamples;
828
+ const rungs = samples.map((sample) => sample.telemetry.encoder.rung);
829
+ qualitySamples = [];
830
+ qualityWindow = { at: now, presented: presentedFrames, freezes: inbound?.freezeCount ?? 0, longestGapMs: 0, rung: rungs.at(-1) };
831
+ if (current.unseen) return;
832
+ const mean = (values) => {
833
+ const known = values.filter((value) => value !== void 0);
834
+ return known.length ? Math.round(known.reduce((sum, value) => sum + value, 0) * 10 / known.length) / 10 : void 0;
835
+ };
836
+ const displayedFps = Math.round((presentedFrames - current.presented) * 1e4 / windowMs) / 10;
837
+ const decodedFps = transport === "webrtc" ? mean(samples.map((sample) => sample.stats.videoFps)) : void 0;
838
+ const rtts = samples.map((sample) => sample.stats.rttMs);
839
+ window.parent.postMessage({
840
+ type: DESKTOP_QUALITY_MESSAGE,
841
+ transport,
842
+ path: route.path,
843
+ codec: route.codec,
844
+ profile: telemetry.encoder.profile,
845
+ serverVersion: config.serverVersion ?? "unknown",
846
+ sequence: ++qualitySequence,
847
+ windowMs: Math.round(windowMs),
848
+ displayedFps,
849
+ encoderFps: mean(samples.map((sample) => sample.telemetry.encoder.fps)) ?? 0,
850
+ longestFrameGapMs: Math.round(Math.max(current.longestGapMs, now - Math.max(presentedAt, current.at))),
851
+ qualityRung: mean(rungs) ?? 0,
852
+ worstQualityRung: Math.max(...rungs),
853
+ qualityDrops: rungs.filter((rung, index) => rung > (index ? rungs[index - 1] : current.rung ?? rung)).length,
854
+ decodedHeight: screen.videoHeight,
855
+ inputRttMs: mean(rtts) ?? 0,
856
+ worstInputRttMs: Math.round(Math.max(...rtts)),
857
+ cpuLoadPercent: mean(samples.map((sample) => sample.telemetry.cpu.load * 100 / sample.telemetry.cpu.cores)) ?? 0,
858
+ decodedFps,
859
+ receiveToDecodeMs: mean(samples.map((sample) => sample.stats.receiverProcessingMs)),
860
+ lossPercent: mean(samples.map((sample) => sample.stats.lossPercent)),
861
+ freezes: inbound ? Math.max(0, inbound.freezeCount - current.freezes) : void 0
862
+ }, "*");
863
+ };
780
864
  var inboundOf = (kind) => (stat) => stat.type === "inbound-rtp" && "kind" in stat && stat.kind === kind;
781
865
  var reportWebRtcStats = async (pc, stats) => {
782
866
  const entries = [...(await pc.getStats()).values()];
@@ -868,6 +952,7 @@ var reportWebRtcStats = async (pc, stats) => {
868
952
  if (pair?.availableIncomingBitrate) stats.availableKbps = pair.availableIncomingBitrate / 1e3;
869
953
  sendInput({ type: "stats", ...stats }, true);
870
954
  updateDiagnostics(stats);
955
+ reportQuality(stats);
871
956
  state.framesDropped = video?.framesDropped;
872
957
  };
873
958
  setInterval(() => {
@@ -915,6 +1000,7 @@ setInterval(() => {
915
1000
  else if (transport === "websocket") {
916
1001
  sendInput({ type: "stats", ...stats });
917
1002
  updateDiagnostics(stats);
1003
+ reportQuality(stats);
918
1004
  route.kbps = 0;
919
1005
  }
920
1006
  }, 250);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",