replicas-computer 0.3.14 → 0.3.16

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.
@@ -4,6 +4,9 @@ function isRecord(value) {
4
4
  }
5
5
 
6
6
  // ../shared/src/desktop-stream.ts
7
+ function isStreamIceServer(value) {
8
+ return isRecord(value) && typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
9
+ }
7
10
  var DEFAULT_STREAM_REQUEST = {
8
11
  transport: "webrtc",
9
12
  profile: "balanced",
@@ -49,10 +52,6 @@ var DESKTOP_QUALITY_MESSAGE = "replicas-desktop-quality";
49
52
 
50
53
  // src/input-validation.ts
51
54
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
52
- var iceServer = (value) => {
53
- if (!isRecord(value)) return false;
54
- return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
55
- };
56
55
  function validServerMessage(value) {
57
56
  if (!isRecord(value) || typeof value.type !== "string") return false;
58
57
  switch (value.type) {
@@ -66,7 +65,7 @@ function validServerMessage(value) {
66
65
  case "candidate":
67
66
  return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
68
67
  case "config":
69
- return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (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));
68
+ return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (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(isStreamIceServer));
70
69
  case "telemetry":
71
70
  return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
72
71
  default:
@@ -99,6 +98,10 @@ var view = params.get("view") === "native" ? "native" : params.get("view") === "
99
98
  document.documentElement.dataset.view = view;
100
99
  diagnostics.classList.toggle("visible", showDiagnostics);
101
100
  var webrtcFrameTimeoutMs = 6e3;
101
+ var webrtcFirstFrameMs = 3e3;
102
+ var webrtcRetryFirstMs = 15e3;
103
+ var webrtcRetryMaxMs = 3e5;
104
+ var webrtcRetryTimeoutMs = 1e4;
102
105
  var webrtcSkipsAfterFailure = 3;
103
106
  var pointerIntervalMs = 4;
104
107
  var qualityWindowMs = 3e4;
@@ -119,6 +122,7 @@ var peer;
119
122
  var inputChannel;
120
123
  var pointerChannel;
121
124
  var webrtcDeadline;
125
+ var webrtcDeadlineAt = 0;
122
126
  var webrtcSkips = 0;
123
127
  var videoTrack;
124
128
  var videoWriter;
@@ -174,6 +178,7 @@ state.events = viewerEvents;
174
178
  var decoderResets = 0;
175
179
  var connectionStartedAt = performance.now();
176
180
  var failure;
181
+ var recovery;
177
182
  var localCandidateTypes = /* @__PURE__ */ new Set();
178
183
  var remoteCandidateTypes = /* @__PURE__ */ new Set();
179
184
  var iceErrorCodes = /* @__PURE__ */ new Set();
@@ -250,9 +255,10 @@ var sendInput = (message, unreliable = false) => {
250
255
  };
251
256
  var announceTransport = () => {
252
257
  if (window.parent === window) return;
253
- window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure }, "*");
258
+ window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure, recovery }, "*");
254
259
  };
255
260
  var closePeer = (preserveDeadline = false) => {
261
+ cancelRetry();
256
262
  if (!preserveDeadline) {
257
263
  clearTimeout(webrtcDeadline);
258
264
  webrtcDeadline = void 0;
@@ -394,7 +400,14 @@ var handleBinary = (data) => {
394
400
  waitingForKeyframe = true;
395
401
  state.decoderResets = ++decoderResets;
396
402
  }
397
- if (waitingForKeyframe && !key) return;
403
+ if (waitingForKeyframe && !key) {
404
+ const now = performance.now();
405
+ if (now - lastKeyframeRequestAt >= 1e3) {
406
+ lastKeyframeRequestAt = now;
407
+ sendSocket({ type: "keyframe" });
408
+ }
409
+ return;
410
+ }
398
411
  waitingForKeyframe = false;
399
412
  try {
400
413
  videoDecoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data: payload }));
@@ -465,6 +478,9 @@ var useFallback = (reasonCode, failed = false) => {
465
478
  route = { ...route, path: "proxy", codec: config.videoCodec };
466
479
  announceTransport();
467
480
  sendSocket({ type: "fallback", reason, failure });
481
+ socketSince = performance.now();
482
+ retryAttempts = 0;
483
+ if (reasonCode !== "websocket_selected" && reasonCode !== "server_unavailable" && reasonCode !== "browser_unavailable" && reasonCode !== "codec_unsupported") scheduleRetry();
468
484
  if (!TrackGenerator) {
469
485
  showStatus("This browser cannot render the desktop stream");
470
486
  return;
@@ -477,35 +493,15 @@ var useFallback = (reasonCode, failed = false) => {
477
493
  var negotiate = async (sdp) => {
478
494
  if (!config?.iceServers || transport === "websocket") return;
479
495
  closePeer(true);
480
- const pc = new RTCPeerConnection({ iceServers: config.iceServers, bundlePolicy: "max-bundle", iceTransportPolicy: config.request.iceTransportPolicy });
481
- peer = pc;
482
- pc.ontrack = (event) => {
483
- if (peer !== pc) return;
484
- try {
485
- event.receiver.jitterBufferTarget = 0;
486
- } catch {
487
- }
488
- try {
489
- event.receiver.playoutDelayHint = 0;
490
- } catch {
491
- }
492
- if (screen.srcObject !== event.streams[0]) screen.srcObject = event.streams[0] ?? new MediaStream([event.track]);
496
+ const pc = openPeer(config, () => peer === pc, (stream) => {
497
+ if (screen.srcObject !== stream) screen.srcObject = stream;
493
498
  void screen.play().catch(() => {
494
499
  });
495
- };
496
- pc.ondatachannel = (event) => {
497
- if (peer !== pc) return;
498
- if (event.channel.label === "pointer") pointerChannel = event.channel;
499
- else inputChannel = event.channel;
500
- event.channel.onmessage = (message) => {
501
- if (typeof message.data === "string") handleServerText(message.data, false);
502
- };
503
- };
504
- pc.onicecandidate = (event) => {
505
- if (peer !== pc) return;
506
- if (event.candidate?.type) localCandidateTypes.add(event.candidate.type);
507
- if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
508
- };
500
+ }, (channel) => {
501
+ if (channel.label === "pointer") pointerChannel = channel;
502
+ else inputChannel = channel;
503
+ }, (type) => localCandidateTypes.add(type));
504
+ peer = pc;
509
505
  pc.onicecandidateerror = (event) => {
510
506
  if (peer !== pc) return;
511
507
  iceErrorCodes.add(event.errorCode);
@@ -522,6 +518,7 @@ var negotiate = async (sdp) => {
522
518
  transportReason = "";
523
519
  webrtcSkips = 0;
524
520
  lastFrameAt = performance.now();
521
+ if (webrtcDeadline && webrtcDeadlineAt - lastFrameAt < webrtcFirstFrameMs) armWebrtcDeadline(webrtcFirstFrameMs);
525
522
  announceTransport();
526
523
  unlockAudio();
527
524
  } else if (pc.connectionState === "failed") {
@@ -530,26 +527,166 @@ var negotiate = async (sdp) => {
530
527
  }
531
528
  };
532
529
  try {
533
- await pc.setRemoteDescription({ type: "offer", sdp });
534
- if (peer !== pc) return;
535
- if (skipFullChroma) {
536
- const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => h264ProfileLabel(codec.sdpFmtpLine ?? "") !== H264_PROFILES.high444.label);
537
- if (codecs) pc.getTransceivers().find((transceiver) => transceiver.receiver.track.kind === "video")?.setCodecPreferences(codecs);
538
- }
539
- const answer = await pc.createAnswer();
540
- if (peer !== pc) return;
541
- await pc.setLocalDescription(answer);
542
- if (peer !== pc || !answer.sdp) return;
543
- const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
544
- if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) {
545
- useFallback("codec_unsupported", true);
546
- return;
547
- }
548
- sendSocket({ type: "answer", sdp: answer.sdp });
530
+ if (!await answerOffer(pc, sdp, skipFullChroma, () => peer === pc)) useFallback("codec_unsupported", true);
549
531
  } catch (error) {
550
532
  if (peer === pc) throw error;
551
533
  }
552
534
  };
535
+ var renderOnArrival = (receiver) => {
536
+ try {
537
+ receiver.jitterBufferTarget = 0;
538
+ } catch {
539
+ }
540
+ try {
541
+ receiver.playoutDelayHint = 0;
542
+ } catch {
543
+ }
544
+ };
545
+ var openPeer = ({ iceServers, request: { iceTransportPolicy } }, current, play, attach, candidateType) => {
546
+ const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle", iceTransportPolicy });
547
+ pc.ontrack = (event) => {
548
+ if (!current()) return;
549
+ renderOnArrival(event.receiver);
550
+ play(event.streams[0] ?? new MediaStream([event.track]));
551
+ };
552
+ pc.ondatachannel = (event) => {
553
+ if (!current()) return;
554
+ attach(event.channel);
555
+ event.channel.onmessage = (message) => {
556
+ if (typeof message.data === "string") handleServerText(message.data, false);
557
+ };
558
+ };
559
+ pc.onicecandidate = (event) => {
560
+ if (!current()) return;
561
+ if (event.candidate?.type) candidateType?.(event.candidate.type);
562
+ if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
563
+ };
564
+ return pc;
565
+ };
566
+ var answerOffer = async (pc, sdp, withoutFullChroma, current) => {
567
+ await pc.setRemoteDescription({ type: "offer", sdp });
568
+ if (!current()) return true;
569
+ if (withoutFullChroma) {
570
+ const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => h264ProfileLabel(codec.sdpFmtpLine ?? "") !== H264_PROFILES.high444.label);
571
+ if (codecs) pc.getTransceivers().find((transceiver) => transceiver.receiver.track.kind === "video")?.setCodecPreferences(codecs);
572
+ }
573
+ const answer = await pc.createAnswer();
574
+ if (!current()) return true;
575
+ await pc.setLocalDescription(answer);
576
+ if (!current() || !answer.sdp) return true;
577
+ const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
578
+ if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) return false;
579
+ sendSocket({ type: "answer", sdp: answer.sdp });
580
+ return true;
581
+ };
582
+ var retry;
583
+ var retryRequested = false;
584
+ var retryTimer;
585
+ var retryDeadline;
586
+ var retryDelayMs = webrtcRetryFirstMs;
587
+ var retryAttempts = 0;
588
+ var socketSince = 0;
589
+ var retryScreen = document.createElement("video");
590
+ retryScreen.muted = true;
591
+ retryScreen.playsInline = true;
592
+ retryScreen.setAttribute("aria-hidden", "true");
593
+ retryScreen.style.cssText = "position:fixed;left:0;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
594
+ document.body.append(retryScreen);
595
+ var cancelRetry = () => {
596
+ clearTimeout(retryTimer);
597
+ clearTimeout(retryDeadline);
598
+ retryTimer = void 0;
599
+ retryRequested = false;
600
+ retry?.pc.close();
601
+ retry = void 0;
602
+ retryScreen.srcObject = null;
603
+ };
604
+ var scheduleRetry = () => {
605
+ if (retryTimer || retryRequested || request.transport !== "webrtc" || !config?.iceServers || typeof RTCPeerConnection !== "function") return;
606
+ retryTimer = setTimeout(() => {
607
+ retryTimer = void 0;
608
+ if (transport !== "websocket" || socket?.readyState !== WebSocket.OPEN) return;
609
+ retryRequested = true;
610
+ retryAttempts += 1;
611
+ note("retrying WebRTC", `attempt ${retryAttempts}`);
612
+ sendSocket({ type: "retry-webrtc" });
613
+ retryDeadline = setTimeout(() => failRetry("no video in time"), webrtcRetryTimeoutMs);
614
+ }, retryDelayMs);
615
+ retryDelayMs = Math.min(webrtcRetryMaxMs, retryDelayMs * 2);
616
+ };
617
+ var failRetry = (reason) => {
618
+ if (!retryRequested) return;
619
+ cancelRetry();
620
+ note("WebRTC retry failed", reason);
621
+ sendSocket({ type: "fallback", reason: `WebRTC retry: ${reason}` });
622
+ scheduleRetry();
623
+ };
624
+ var answerRetry = async (sdp) => {
625
+ if (!retryRequested || retry || !config?.iceServers) return;
626
+ const pc = openPeer(config, () => retry === attempt, (stream) => {
627
+ attempt.stream = stream;
628
+ retryScreen.srcObject = stream;
629
+ void retryScreen.play().catch(() => {
630
+ });
631
+ }, (channel) => {
632
+ if (channel.label === "pointer") attempt.pointer = channel;
633
+ else attempt.input = channel;
634
+ });
635
+ const attempt = { pc };
636
+ retry = attempt;
637
+ pc.onconnectionstatechange = () => {
638
+ if (peer === pc) {
639
+ note("webrtc", pc.connectionState);
640
+ if (pc.connectionState === "failed") socket?.close();
641
+ } else if (retry === attempt && pc.connectionState === "failed") failRetry("connection failed");
642
+ };
643
+ try {
644
+ if (!await answerOffer(pc, sdp, true, () => retry === attempt)) failRetry("codec unsupported");
645
+ } catch (error) {
646
+ if (retry === attempt) failRetry(describe(error));
647
+ }
648
+ };
649
+ var checkRetry = async (attempt) => {
650
+ const video = [...(await attempt.pc.getStats()).values()].find(inboundOf("video"));
651
+ if (retry === attempt && (video?.framesDecoded ?? 0) > 0) completeRetry(attempt);
652
+ };
653
+ var completeRetry = (attempt) => {
654
+ clearTimeout(retryDeadline);
655
+ retry = void 0;
656
+ retryRequested = false;
657
+ videoConfiguration += 1;
658
+ if (videoDecoder?.state !== "closed") videoDecoder?.close();
659
+ videoDecoder = void 0;
660
+ activeVideoConfig = void 0;
661
+ if (audioDecoder?.state !== "closed") audioDecoder?.close();
662
+ audioDecoder = void 0;
663
+ void videoWriter?.close().catch(() => {
664
+ });
665
+ videoTrack?.stop();
666
+ videoTrack = void 0;
667
+ videoWriter = void 0;
668
+ peer = attempt.pc;
669
+ inputChannel = attempt.input;
670
+ pointerChannel = attempt.pointer;
671
+ inbound = void 0;
672
+ inboundHistory = [];
673
+ screen.srcObject = attempt.stream ?? null;
674
+ retryScreen.srcObject = null;
675
+ recovery = { id: crypto.randomUUID?.() ?? `${Date.now()}`, afterMs: Math.round(performance.now() - socketSince), attempts: retryAttempts };
676
+ note("back on WebRTC", `${Math.round(recovery.afterMs / 1e3)} s and ${retryAttempts} attempts on the socket`);
677
+ transport = "webrtc";
678
+ transportReason = "";
679
+ failure = void 0;
680
+ webrtcSkips = 0;
681
+ retryDelayMs = webrtcRetryFirstMs;
682
+ retryAttempts = 0;
683
+ lastFrameAt = performance.now();
684
+ restartQuality();
685
+ route = { ...route, path: "", codec: "" };
686
+ sendSocket({ type: "webrtc-ready" });
687
+ announceTransport();
688
+ unlockAudio();
689
+ };
553
690
  var handleServerMessage = (message, viaSocket) => {
554
691
  if (message.type === "pong") {
555
692
  const elapsed = performance.now() - message.sentAt;
@@ -570,10 +707,15 @@ var handleServerMessage = (message, viaSocket) => {
570
707
  return;
571
708
  }
572
709
  if (message.type === "webrtc-failed") {
573
- useFallback("server_connection_failed", true);
710
+ if (retryRequested) failRetry("server connection failed");
711
+ else useFallback("server_connection_failed", true);
574
712
  return;
575
713
  }
576
714
  if (message.type === "offer") {
715
+ if (retryRequested) {
716
+ void answerRetry(message.sdp);
717
+ return;
718
+ }
577
719
  void negotiate(message.sdp).catch((error) => {
578
720
  negotiationErrorName = error instanceof Error ? error.name : "unknown";
579
721
  useFallback("negotiation_failed", true);
@@ -583,7 +725,7 @@ var handleServerMessage = (message, viaSocket) => {
583
725
  if (message.type === "candidate") {
584
726
  const candidateType = message.candidate.match(/\btyp (host|srflx|prflx|relay)\b/)?.[1];
585
727
  if (candidateType) remoteCandidateTypes.add(candidateType);
586
- const pc = peer;
728
+ const pc = retry?.pc ?? peer;
587
729
  void pc?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
588
730
  if (peer === pc) candidateErrors += 1;
589
731
  });
@@ -605,18 +747,24 @@ var handleServerMessage = (message, viaSocket) => {
605
747
  useFallback("recent_failure");
606
748
  } else if (!transport) {
607
749
  showStatus("Establishing connection\u2026", true);
608
- webrtcDeadline ??= setTimeout(() => {
609
- if (skipFullChroma || !inbound?.packetsReceived || !route.codec.endsWith(H264_PROFILES.high444.label)) {
610
- useFallback("video_timeout", true);
611
- return;
612
- }
613
- skipFullChroma = true;
614
- if (config) fullChromaFailure = captureFailure("full_chroma_undecodable", config);
615
- note("retrying WebRTC without 4:4:4");
616
- socket?.close();
617
- }, webrtcFrameTimeoutMs);
750
+ armWebrtcDeadline(webrtcFrameTimeoutMs);
618
751
  }
619
752
  };
753
+ var armWebrtcDeadline = (ms) => {
754
+ clearTimeout(webrtcDeadline);
755
+ webrtcDeadlineAt = performance.now() + ms;
756
+ webrtcDeadline = setTimeout(() => {
757
+ webrtcDeadline = void 0;
758
+ if (skipFullChroma || !inbound?.packetsReceived || !route.codec.endsWith(H264_PROFILES.high444.label)) {
759
+ useFallback("video_timeout", true);
760
+ return;
761
+ }
762
+ skipFullChroma = true;
763
+ if (config) fullChromaFailure = captureFailure("full_chroma_undecodable", config);
764
+ note("retrying WebRTC without 4:4:4");
765
+ connect();
766
+ }, ms);
767
+ };
620
768
  var handleServerText = (data, viaSocket) => {
621
769
  let parsed;
622
770
  try {
@@ -633,6 +781,7 @@ var connect = () => {
633
781
  transport = void 0;
634
782
  transportReason = "";
635
783
  failure = void 0;
784
+ recovery = void 0;
636
785
  config = void 0;
637
786
  telemetry = void 0;
638
787
  connectionStartedAt = performance.now();
@@ -807,7 +956,7 @@ var updateDiagnostics = (stats) => {
807
956
  `source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
808
957
  `${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}`,
809
958
  transport === "webrtc" ? `receiver: ${stats.decodeMs?.toFixed(2) ?? "\u2014"} ms decode \xB7 ${stats.receiverProcessingMs?.toFixed(2) ?? "\u2014"} ms receive \u2192 decoded (includes buffer + decode; excludes display)` : "",
810
- 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}` : "",
959
+ 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 ${encoder.keyframeMs ? `every ${encoder.keyframeMs} ms` : "on demand"} \xB7 ${encoder.fps} fps ${encoder.kbps} kbps \xB7 ${encoder.preset}` : "",
811
960
  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"}` : "",
812
961
  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` : "",
813
962
  telemetry?.events.length ? `server events: ${describeEvents(telemetry.events)}` : "",
@@ -995,6 +1144,8 @@ setInterval(() => {
995
1144
  renderedFrames = 0;
996
1145
  decodedAudioPackets = 0;
997
1146
  renderedWindowStartedAt = now;
1147
+ if (retry) void checkRetry(retry).catch(() => {
1148
+ });
998
1149
  if (peer) void reportWebRtcStats(peer, stats).catch(() => {
999
1150
  });
1000
1151
  else if (transport === "websocket") {
@@ -152,15 +152,19 @@ func (d *desktop) changed() (bool, error) {
152
152
  return changed, nil
153
153
  }
154
154
 
155
- func (d *desktop) wait(micros int64) error {
155
+ func (d *desktop) wait(micros int64, control int) error {
156
156
  C.XFlush(d.display)
157
- fd := C.struct_pollfd{fd: C.XConnectionNumber(d.display), events: C.POLLIN}
157
+ fds := [2]C.struct_pollfd{{fd: C.XConnectionNumber(d.display), events: C.POLLIN}, {fd: C.int(control), events: C.POLLIN}}
158
+ count := C.nfds_t(1)
159
+ if control >= 0 {
160
+ count = 2
161
+ }
158
162
  timeout := C.struct_timespec{tv_sec: C.time_t(micros / 1000000), tv_nsec: C.long(micros % 1000000 * 1000)}
159
- ready, err := C.ppoll(&fd, 1, &timeout, nil)
163
+ ready, err := C.ppoll(&fds[0], count, &timeout, nil)
160
164
  if ready < 0 && err != syscall.EINTR {
161
165
  return fmt.Errorf("display poll failed: %w", err)
162
166
  }
163
- if fd.revents&(C.POLLERR|C.POLLHUP|C.POLLNVAL) != 0 {
167
+ if fds[0].revents&(C.POLLERR|C.POLLHUP|C.POLLNVAL) != 0 {
164
168
  return errors.New("display disconnected")
165
169
  }
166
170
  return nil
@@ -217,6 +221,7 @@ type encoder struct {
217
221
  packet *C.AVPacket
218
222
  scale *C.struct_SwsContext
219
223
  matrix colorMatrix
224
+ vbvMS int
220
225
  headerWritten bool
221
226
  }
222
227
 
@@ -251,10 +256,19 @@ func newEncoder(o Options) (e *encoder, err error) {
251
256
  }
252
257
  c.time_base = C.AVRational{num: 1, den: 90000}
253
258
  c.framerate = C.AVRational{num: C.int(o.FPS), den: 1}
254
- c.gop_size = C.int(max(1, (o.FPS*o.KeyframeMS+500)/1000))
259
+ keyint := "infinite"
260
+ c.gop_size = C.int(1 << 30)
261
+ if o.KeyframeMS > 0 {
262
+ c.gop_size = C.int(max(1, (o.FPS*o.KeyframeMS+500)/1000))
263
+ keyint = fmt.Sprintf("%d:min-keyint=%d", c.gop_size, c.gop_size)
264
+ }
255
265
  c.max_b_frames, c.thread_count = 0, C.int(o.Threads)
266
+ e.vbvMS = 100
267
+ if o.KeyframeMS > 0 {
268
+ e.vbvMS = min(o.KeyframeMS, 100)
269
+ }
256
270
  c.rc_max_rate = C.int64_t(o.Kbps) * 1000
257
- c.rc_buffer_size = C.int(o.Kbps * min(o.KeyframeMS, 100))
271
+ c.rc_buffer_size = C.int(o.Kbps * e.vbvMS)
258
272
  c.flags |= C.AV_CODEC_FLAG_LOW_DELAY
259
273
  if o.BT709 != 0 {
260
274
  c.color_primaries = C.AVCOL_PRI_BT709
@@ -264,7 +278,7 @@ func newEncoder(o Options) (e *encoder, err error) {
264
278
  }
265
279
  dict := dictionary{}
266
280
  defer dict.close()
267
- parameters := fmt.Sprintf("fps=%d/1:repeat-headers=1:sliced-threads=1:slices=4:keyint=%d:min-keyint=%d:scenecut=0:chroma-qp-offset=%d:intra-refresh=%d", o.FPS, c.gop_size, c.gop_size, o.ChromaQP, o.IntraRefresh)
281
+ parameters := fmt.Sprintf("fps=%d/1:repeat-headers=1:sliced-threads=1:slices=4:keyint=%s:scenecut=0:chroma-qp-offset=%d:intra-refresh=%d", o.FPS, keyint, o.ChromaQP, o.IntraRefresh)
268
282
  if o.Preset == "superfast" {
269
283
  parameters += ":subme=0"
270
284
  }
@@ -366,6 +380,20 @@ func (e *encoder) close() {
366
380
  e.matrix.close()
367
381
  }
368
382
 
383
+ // reconfigure changes the rate control between frames; libavcodec's x264
384
+ // wrapper applies a changed CRF or VBV with x264_encoder_reconfig before the
385
+ // next frame, so the stream continues without a keyframe.
386
+ func (e *encoder) reconfigure(crf, kbps int) error {
387
+ name := C.CString("crf")
388
+ defer C.free(unsafe.Pointer(name))
389
+ if err := avCheck(C.av_opt_set_double(e.codec.priv_data, name, C.double(crf), 0)); err != nil {
390
+ return err
391
+ }
392
+ e.codec.rc_max_rate = C.int64_t(kbps) * 1000
393
+ e.codec.rc_buffer_size = C.int(kbps * e.vbvMS)
394
+ return nil
395
+ }
396
+
369
397
  func (e *encoder) convert(d *desktop, x, y int) error {
370
398
  im := d.image
371
399
  if e.scale != nil {
@@ -442,10 +470,11 @@ func (s *Stream) Close() {
442
470
  runtime.UnlockOSThread()
443
471
  }
444
472
 
445
- func NowMicros() int64 { return int64(C.av_gettime_relative()) }
446
- func (s *Stream) Changed() (bool, error) { return s.desktop.changed() }
447
- func (s *Stream) Wait(micros int64) error { return s.desktop.wait(micros) }
448
- func (s *Stream) Send(pts int64, keyframe bool) error { return s.encoder.send(pts, keyframe) }
473
+ func NowMicros() int64 { return int64(C.av_gettime_relative()) }
474
+ func (s *Stream) Changed() (bool, error) { return s.desktop.changed() }
475
+ func (s *Stream) Wait(micros int64, control int) error { return s.desktop.wait(micros, control) }
476
+ func (s *Stream) Send(pts int64, keyframe bool) error { return s.encoder.send(pts, keyframe) }
477
+ func (s *Stream) Reconfigure(crf, kbps int) error { return s.encoder.reconfigure(crf, kbps) }
449
478
 
450
479
  func (s *Stream) Capture(first bool) (copied, converted int64, err error) {
451
480
  if err = avCheck(C.av_frame_make_writable(s.encoder.frame)); err != nil {
package/native/main.go CHANGED
@@ -1,14 +1,49 @@
1
1
  package main
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "fmt"
5
6
  "os"
6
7
  "os/signal"
8
+ "strconv"
9
+ "strings"
7
10
  "syscall"
8
11
 
9
12
  "github.com/replicas-group/replicas/computer-use/native/internal/capture"
10
13
  )
11
14
 
15
+ // controls reads the media server's commands from stdin, one per line. They
16
+ // change the running encoder between frames, where a restart would cost a
17
+ // new process and a keyframe. A closed stdin ends control, not capture.
18
+ type controls struct {
19
+ fd int
20
+ pending []byte
21
+ }
22
+
23
+ func (c *controls) read() []string {
24
+ var buffer [256]byte
25
+ for c.fd >= 0 {
26
+ n, err := syscall.Read(c.fd, buffer[:])
27
+ if n > 0 {
28
+ c.pending = append(c.pending, buffer[:n]...)
29
+ continue
30
+ }
31
+ if err == syscall.EINTR {
32
+ continue
33
+ }
34
+ if err != syscall.EAGAIN {
35
+ c.fd = -1
36
+ }
37
+ break
38
+ }
39
+ var lines []string
40
+ for index := bytes.IndexByte(c.pending, '\n'); index >= 0; index = bytes.IndexByte(c.pending, '\n') {
41
+ lines = append(lines, string(c.pending[:index]))
42
+ c.pending = c.pending[index+1:]
43
+ }
44
+ return lines
45
+ }
46
+
12
47
  func run(o capture.Options) error {
13
48
  stop := make(chan os.Signal, 1)
14
49
  signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
@@ -18,11 +53,15 @@ func run(o capture.Options) error {
18
53
  return err
19
54
  }
20
55
  defer stream.Close()
56
+ control := &controls{fd: 0}
57
+ if syscall.SetNonblock(control.fd, true) != nil {
58
+ control.fd = -1
59
+ }
21
60
  started := capture.NowMicros()
22
61
  last, lastKeyframe, reportAt := started-1000000, started-1000000, started
23
62
  interval := int64((1000000 + o.FPS - 1) / o.FPS)
24
63
  busyUS, captureUS, scaleUS, encodeUS, previousPTS := int64(0), int64(0), int64(0), int64(0), int64(-1)
25
- dirty := true
64
+ dirty, forceKeyframe := true, false
26
65
  number, changedFrames, sentFrames := 0, 0, 0
27
66
  for {
28
67
  select {
@@ -30,6 +69,27 @@ func run(o capture.Options) error {
30
69
  return nil
31
70
  default:
32
71
  }
72
+ for _, line := range control.read() {
73
+ fields := strings.Fields(line)
74
+ values := make([]int, len(fields))
75
+ for index := 1; index < len(fields); index++ {
76
+ if values[index], err = strconv.Atoi(fields[index]); err != nil {
77
+ values[index] = -1
78
+ }
79
+ }
80
+ switch {
81
+ case len(fields) == 1 && fields[0] == "keyframe":
82
+ forceKeyframe = true
83
+ case len(fields) == 3 && fields[0] == "quality" && values[1] >= 0 && values[1] <= 51 && values[2] > 0:
84
+ if err = stream.Reconfigure(values[1], values[2]); err != nil {
85
+ return err
86
+ }
87
+ case len(fields) == 2 && fields[0] == "fps" && values[1] > 0 && values[1] <= 240:
88
+ interval = int64((1000000 + values[1] - 1) / values[1])
89
+ default:
90
+ fmt.Fprintf(os.Stderr, "capture: ignoring control %q\n", line)
91
+ }
92
+ }
33
93
  changed, err := stream.Changed()
34
94
  if err != nil {
35
95
  return err
@@ -37,15 +97,19 @@ func run(o capture.Options) error {
37
97
  dirty = dirty || changed
38
98
  now := capture.NowMicros()
39
99
  idleDue := last + max(interval, 200000)
40
- if o.IntraRefresh == 0 {
100
+ periodic := o.IntraRefresh == 0 && o.KeyframeMS > 0
101
+ if periodic {
41
102
  idleDue = min(idleDue, lastKeyframe+int64(o.KeyframeMS)*1000)
42
103
  }
43
104
  due := max(last+interval, idleDue)
44
105
  if dirty || number <= o.Warmup || number < 3 {
45
106
  due = last + interval
46
107
  }
108
+ if forceKeyframe {
109
+ due = now
110
+ }
47
111
  if now < due {
48
- if err = stream.Wait(due - now); err != nil {
112
+ if err = stream.Wait(due-now, control.fd); err != nil {
49
113
  return err
50
114
  }
51
115
  continue
@@ -61,7 +125,8 @@ func run(o capture.Options) error {
61
125
  }
62
126
  pts := max(previousPTS+1, (now-started)*90/1000)
63
127
  previousPTS = pts
64
- keyframe := number == 0 || (o.Warmup > 0 && number == o.Warmup) || (o.IntraRefresh == 0 && now-lastKeyframe >= int64(o.KeyframeMS)*1000)
128
+ keyframe := forceKeyframe || number == 0 || (o.Warmup > 0 && number == o.Warmup) || (periodic && now-lastKeyframe >= int64(o.KeyframeMS)*1000)
129
+ forceKeyframe = false
65
130
  if keyframe {
66
131
  lastKeyframe = now
67
132
  }
package/native/options.go CHANGED
@@ -20,7 +20,7 @@ func parseOptions() (capture.Options, error) {
20
20
  {"width", &o.Width, 0, 2, 8192}, {"height", &o.Height, 0, 2, 8192},
21
21
  {"fps", &o.FPS, 60, 1, 240}, {"threads", &o.Threads, 4, 1, 16},
22
22
  {"crf", &o.CRF, 14, 0, 51}, {"kbps", &o.Kbps, 20000, 1, 1000000},
23
- {"keyframe-ms", &o.KeyframeMS, 1000, 1, 10000}, {"intra-refresh", &o.IntraRefresh, 1, 0, 1},
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},
25
25
  {"payload", &o.Payload, 96, 0, 127}, {"bt709", &o.BT709, 1, 0, 1},
26
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",