replicas-computer 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,6 +99,10 @@ var view = params.get("view") === "native" ? "native" : params.get("view") === "
99
99
  document.documentElement.dataset.view = view;
100
100
  diagnostics.classList.toggle("visible", showDiagnostics);
101
101
  var webrtcFrameTimeoutMs = 6e3;
102
+ var webrtcFirstFrameMs = 3e3;
103
+ var webrtcRetryFirstMs = 15e3;
104
+ var webrtcRetryMaxMs = 3e5;
105
+ var webrtcRetryTimeoutMs = 1e4;
102
106
  var webrtcSkipsAfterFailure = 3;
103
107
  var pointerIntervalMs = 4;
104
108
  var qualityWindowMs = 3e4;
@@ -110,6 +114,8 @@ try {
110
114
  } catch {
111
115
  }
112
116
  var socket;
117
+ var standby = innerWidth === 0 || innerHeight === 0;
118
+ var standbyTimer;
113
119
  var config;
114
120
  var transport;
115
121
  var transportReason = "";
@@ -117,6 +123,7 @@ var peer;
117
123
  var inputChannel;
118
124
  var pointerChannel;
119
125
  var webrtcDeadline;
126
+ var webrtcDeadlineAt = 0;
120
127
  var webrtcSkips = 0;
121
128
  var videoTrack;
122
129
  var videoWriter;
@@ -172,6 +179,7 @@ state.events = viewerEvents;
172
179
  var decoderResets = 0;
173
180
  var connectionStartedAt = performance.now();
174
181
  var failure;
182
+ var recovery;
175
183
  var localCandidateTypes = /* @__PURE__ */ new Set();
176
184
  var remoteCandidateTypes = /* @__PURE__ */ new Set();
177
185
  var iceErrorCodes = /* @__PURE__ */ new Set();
@@ -248,9 +256,10 @@ var sendInput = (message, unreliable = false) => {
248
256
  };
249
257
  var announceTransport = () => {
250
258
  if (window.parent === window) return;
251
- window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure }, "*");
259
+ window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure, recovery }, "*");
252
260
  };
253
261
  var closePeer = (preserveDeadline = false) => {
262
+ cancelRetry();
254
263
  if (!preserveDeadline) {
255
264
  clearTimeout(webrtcDeadline);
256
265
  webrtcDeadline = void 0;
@@ -392,7 +401,14 @@ var handleBinary = (data) => {
392
401
  waitingForKeyframe = true;
393
402
  state.decoderResets = ++decoderResets;
394
403
  }
395
- if (waitingForKeyframe && !key) return;
404
+ if (waitingForKeyframe && !key) {
405
+ const now = performance.now();
406
+ if (now - lastKeyframeRequestAt >= 1e3) {
407
+ lastKeyframeRequestAt = now;
408
+ sendSocket({ type: "keyframe" });
409
+ }
410
+ return;
411
+ }
396
412
  waitingForKeyframe = false;
397
413
  try {
398
414
  videoDecoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data: payload }));
@@ -463,6 +479,9 @@ var useFallback = (reasonCode, failed = false) => {
463
479
  route = { ...route, path: "proxy", codec: config.videoCodec };
464
480
  announceTransport();
465
481
  sendSocket({ type: "fallback", reason, failure });
482
+ socketSince = performance.now();
483
+ retryAttempts = 0;
484
+ if (reasonCode !== "websocket_selected" && reasonCode !== "server_unavailable" && reasonCode !== "browser_unavailable" && reasonCode !== "codec_unsupported") scheduleRetry();
466
485
  if (!TrackGenerator) {
467
486
  showStatus("This browser cannot render the desktop stream");
468
487
  return;
@@ -475,35 +494,15 @@ var useFallback = (reasonCode, failed = false) => {
475
494
  var negotiate = async (sdp) => {
476
495
  if (!config?.iceServers || transport === "websocket") return;
477
496
  closePeer(true);
478
- const pc = new RTCPeerConnection({ iceServers: config.iceServers, bundlePolicy: "max-bundle", iceTransportPolicy: config.request.iceTransportPolicy });
479
- peer = pc;
480
- pc.ontrack = (event) => {
481
- if (peer !== pc) return;
482
- try {
483
- event.receiver.jitterBufferTarget = 0;
484
- } catch {
485
- }
486
- try {
487
- event.receiver.playoutDelayHint = 0;
488
- } catch {
489
- }
490
- if (screen.srcObject !== event.streams[0]) screen.srcObject = event.streams[0] ?? new MediaStream([event.track]);
497
+ const pc = openPeer(config, () => peer === pc, (stream) => {
498
+ if (screen.srcObject !== stream) screen.srcObject = stream;
491
499
  void screen.play().catch(() => {
492
500
  });
493
- };
494
- pc.ondatachannel = (event) => {
495
- if (peer !== pc) return;
496
- if (event.channel.label === "pointer") pointerChannel = event.channel;
497
- else inputChannel = event.channel;
498
- event.channel.onmessage = (message) => {
499
- if (typeof message.data === "string") handleServerText(message.data, false);
500
- };
501
- };
502
- pc.onicecandidate = (event) => {
503
- if (peer !== pc) return;
504
- if (event.candidate?.type) localCandidateTypes.add(event.candidate.type);
505
- if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
506
- };
501
+ }, (channel) => {
502
+ if (channel.label === "pointer") pointerChannel = channel;
503
+ else inputChannel = channel;
504
+ }, (type) => localCandidateTypes.add(type));
505
+ peer = pc;
507
506
  pc.onicecandidateerror = (event) => {
508
507
  if (peer !== pc) return;
509
508
  iceErrorCodes.add(event.errorCode);
@@ -520,6 +519,7 @@ var negotiate = async (sdp) => {
520
519
  transportReason = "";
521
520
  webrtcSkips = 0;
522
521
  lastFrameAt = performance.now();
522
+ if (webrtcDeadline && webrtcDeadlineAt - lastFrameAt < webrtcFirstFrameMs) armWebrtcDeadline(webrtcFirstFrameMs);
523
523
  announceTransport();
524
524
  unlockAudio();
525
525
  } else if (pc.connectionState === "failed") {
@@ -528,26 +528,166 @@ var negotiate = async (sdp) => {
528
528
  }
529
529
  };
530
530
  try {
531
- await pc.setRemoteDescription({ type: "offer", sdp });
532
- if (peer !== pc) return;
533
- if (skipFullChroma) {
534
- const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => h264ProfileLabel(codec.sdpFmtpLine ?? "") !== H264_PROFILES.high444.label);
535
- if (codecs) pc.getTransceivers().find((transceiver) => transceiver.receiver.track.kind === "video")?.setCodecPreferences(codecs);
536
- }
537
- const answer = await pc.createAnswer();
538
- if (peer !== pc) return;
539
- await pc.setLocalDescription(answer);
540
- if (peer !== pc || !answer.sdp) return;
541
- const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
542
- if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) {
543
- useFallback("codec_unsupported", true);
544
- return;
545
- }
546
- sendSocket({ type: "answer", sdp: answer.sdp });
531
+ if (!await answerOffer(pc, sdp, skipFullChroma, () => peer === pc)) useFallback("codec_unsupported", true);
547
532
  } catch (error) {
548
533
  if (peer === pc) throw error;
549
534
  }
550
535
  };
536
+ var renderOnArrival = (receiver) => {
537
+ try {
538
+ receiver.jitterBufferTarget = 0;
539
+ } catch {
540
+ }
541
+ try {
542
+ receiver.playoutDelayHint = 0;
543
+ } catch {
544
+ }
545
+ };
546
+ var openPeer = ({ iceServers, request: { iceTransportPolicy } }, current, play, attach, candidateType) => {
547
+ const pc = new RTCPeerConnection({ iceServers, bundlePolicy: "max-bundle", iceTransportPolicy });
548
+ pc.ontrack = (event) => {
549
+ if (!current()) return;
550
+ renderOnArrival(event.receiver);
551
+ play(event.streams[0] ?? new MediaStream([event.track]));
552
+ };
553
+ pc.ondatachannel = (event) => {
554
+ if (!current()) return;
555
+ attach(event.channel);
556
+ event.channel.onmessage = (message) => {
557
+ if (typeof message.data === "string") handleServerText(message.data, false);
558
+ };
559
+ };
560
+ pc.onicecandidate = (event) => {
561
+ if (!current()) return;
562
+ if (event.candidate?.type) candidateType?.(event.candidate.type);
563
+ if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
564
+ };
565
+ return pc;
566
+ };
567
+ var answerOffer = async (pc, sdp, withoutFullChroma, current) => {
568
+ await pc.setRemoteDescription({ type: "offer", sdp });
569
+ if (!current()) return true;
570
+ if (withoutFullChroma) {
571
+ const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => h264ProfileLabel(codec.sdpFmtpLine ?? "") !== H264_PROFILES.high444.label);
572
+ if (codecs) pc.getTransceivers().find((transceiver) => transceiver.receiver.track.kind === "video")?.setCodecPreferences(codecs);
573
+ }
574
+ const answer = await pc.createAnswer();
575
+ if (!current()) return true;
576
+ await pc.setLocalDescription(answer);
577
+ if (!current() || !answer.sdp) return true;
578
+ const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
579
+ if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) return false;
580
+ sendSocket({ type: "answer", sdp: answer.sdp });
581
+ return true;
582
+ };
583
+ var retry;
584
+ var retryRequested = false;
585
+ var retryTimer;
586
+ var retryDeadline;
587
+ var retryDelayMs = webrtcRetryFirstMs;
588
+ var retryAttempts = 0;
589
+ var socketSince = 0;
590
+ var retryScreen = document.createElement("video");
591
+ retryScreen.muted = true;
592
+ retryScreen.playsInline = true;
593
+ retryScreen.setAttribute("aria-hidden", "true");
594
+ retryScreen.style.cssText = "position:fixed;left:0;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
595
+ document.body.append(retryScreen);
596
+ var cancelRetry = () => {
597
+ clearTimeout(retryTimer);
598
+ clearTimeout(retryDeadline);
599
+ retryTimer = void 0;
600
+ retryRequested = false;
601
+ retry?.pc.close();
602
+ retry = void 0;
603
+ retryScreen.srcObject = null;
604
+ };
605
+ var scheduleRetry = () => {
606
+ if (retryTimer || retryRequested || request.transport !== "webrtc" || !config?.iceServers || typeof RTCPeerConnection !== "function") return;
607
+ retryTimer = setTimeout(() => {
608
+ retryTimer = void 0;
609
+ if (transport !== "websocket" || socket?.readyState !== WebSocket.OPEN) return;
610
+ retryRequested = true;
611
+ retryAttempts += 1;
612
+ note("retrying WebRTC", `attempt ${retryAttempts}`);
613
+ sendSocket({ type: "retry-webrtc" });
614
+ retryDeadline = setTimeout(() => failRetry("no video in time"), webrtcRetryTimeoutMs);
615
+ }, retryDelayMs);
616
+ retryDelayMs = Math.min(webrtcRetryMaxMs, retryDelayMs * 2);
617
+ };
618
+ var failRetry = (reason) => {
619
+ if (!retryRequested) return;
620
+ cancelRetry();
621
+ note("WebRTC retry failed", reason);
622
+ sendSocket({ type: "fallback", reason: `WebRTC retry: ${reason}` });
623
+ scheduleRetry();
624
+ };
625
+ var answerRetry = async (sdp) => {
626
+ if (!retryRequested || retry || !config?.iceServers) return;
627
+ const pc = openPeer(config, () => retry === attempt, (stream) => {
628
+ attempt.stream = stream;
629
+ retryScreen.srcObject = stream;
630
+ void retryScreen.play().catch(() => {
631
+ });
632
+ }, (channel) => {
633
+ if (channel.label === "pointer") attempt.pointer = channel;
634
+ else attempt.input = channel;
635
+ });
636
+ const attempt = { pc };
637
+ retry = attempt;
638
+ pc.onconnectionstatechange = () => {
639
+ if (peer === pc) {
640
+ note("webrtc", pc.connectionState);
641
+ if (pc.connectionState === "failed") socket?.close();
642
+ } else if (retry === attempt && pc.connectionState === "failed") failRetry("connection failed");
643
+ };
644
+ try {
645
+ if (!await answerOffer(pc, sdp, true, () => retry === attempt)) failRetry("codec unsupported");
646
+ } catch (error) {
647
+ if (retry === attempt) failRetry(describe(error));
648
+ }
649
+ };
650
+ var checkRetry = async (attempt) => {
651
+ const video = [...(await attempt.pc.getStats()).values()].find(inboundOf("video"));
652
+ if (retry === attempt && (video?.framesDecoded ?? 0) > 0) completeRetry(attempt);
653
+ };
654
+ var completeRetry = (attempt) => {
655
+ clearTimeout(retryDeadline);
656
+ retry = void 0;
657
+ retryRequested = false;
658
+ videoConfiguration += 1;
659
+ if (videoDecoder?.state !== "closed") videoDecoder?.close();
660
+ videoDecoder = void 0;
661
+ activeVideoConfig = void 0;
662
+ if (audioDecoder?.state !== "closed") audioDecoder?.close();
663
+ audioDecoder = void 0;
664
+ void videoWriter?.close().catch(() => {
665
+ });
666
+ videoTrack?.stop();
667
+ videoTrack = void 0;
668
+ videoWriter = void 0;
669
+ peer = attempt.pc;
670
+ inputChannel = attempt.input;
671
+ pointerChannel = attempt.pointer;
672
+ inbound = void 0;
673
+ inboundHistory = [];
674
+ screen.srcObject = attempt.stream ?? null;
675
+ retryScreen.srcObject = null;
676
+ recovery = { id: crypto.randomUUID?.() ?? `${Date.now()}`, afterMs: Math.round(performance.now() - socketSince), attempts: retryAttempts };
677
+ note("back on WebRTC", `${Math.round(recovery.afterMs / 1e3)} s and ${retryAttempts} attempts on the socket`);
678
+ transport = "webrtc";
679
+ transportReason = "";
680
+ failure = void 0;
681
+ webrtcSkips = 0;
682
+ retryDelayMs = webrtcRetryFirstMs;
683
+ retryAttempts = 0;
684
+ lastFrameAt = performance.now();
685
+ restartQuality();
686
+ route = { ...route, path: "", codec: "" };
687
+ sendSocket({ type: "webrtc-ready" });
688
+ announceTransport();
689
+ unlockAudio();
690
+ };
551
691
  var handleServerMessage = (message, viaSocket) => {
552
692
  if (message.type === "pong") {
553
693
  const elapsed = performance.now() - message.sentAt;
@@ -568,10 +708,15 @@ var handleServerMessage = (message, viaSocket) => {
568
708
  return;
569
709
  }
570
710
  if (message.type === "webrtc-failed") {
571
- useFallback("server_connection_failed", true);
711
+ if (retryRequested) failRetry("server connection failed");
712
+ else useFallback("server_connection_failed", true);
572
713
  return;
573
714
  }
574
715
  if (message.type === "offer") {
716
+ if (retryRequested) {
717
+ void answerRetry(message.sdp);
718
+ return;
719
+ }
575
720
  void negotiate(message.sdp).catch((error) => {
576
721
  negotiationErrorName = error instanceof Error ? error.name : "unknown";
577
722
  useFallback("negotiation_failed", true);
@@ -581,7 +726,7 @@ var handleServerMessage = (message, viaSocket) => {
581
726
  if (message.type === "candidate") {
582
727
  const candidateType = message.candidate.match(/\btyp (host|srflx|prflx|relay)\b/)?.[1];
583
728
  if (candidateType) remoteCandidateTypes.add(candidateType);
584
- const pc = peer;
729
+ const pc = retry?.pc ?? peer;
585
730
  void pc?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
586
731
  if (peer === pc) candidateErrors += 1;
587
732
  });
@@ -603,18 +748,24 @@ var handleServerMessage = (message, viaSocket) => {
603
748
  useFallback("recent_failure");
604
749
  } else if (!transport) {
605
750
  showStatus("Establishing connection\u2026", true);
606
- webrtcDeadline ??= setTimeout(() => {
607
- if (skipFullChroma || !inbound?.packetsReceived || !route.codec.endsWith(H264_PROFILES.high444.label)) {
608
- useFallback("video_timeout", true);
609
- return;
610
- }
611
- skipFullChroma = true;
612
- if (config) fullChromaFailure = captureFailure("full_chroma_undecodable", config);
613
- note("retrying WebRTC without 4:4:4");
614
- socket?.close();
615
- }, webrtcFrameTimeoutMs);
751
+ armWebrtcDeadline(webrtcFrameTimeoutMs);
616
752
  }
617
753
  };
754
+ var armWebrtcDeadline = (ms) => {
755
+ clearTimeout(webrtcDeadline);
756
+ webrtcDeadlineAt = performance.now() + ms;
757
+ webrtcDeadline = setTimeout(() => {
758
+ webrtcDeadline = void 0;
759
+ if (skipFullChroma || !inbound?.packetsReceived || !route.codec.endsWith(H264_PROFILES.high444.label)) {
760
+ useFallback("video_timeout", true);
761
+ return;
762
+ }
763
+ skipFullChroma = true;
764
+ if (config) fullChromaFailure = captureFailure("full_chroma_undecodable", config);
765
+ note("retrying WebRTC without 4:4:4");
766
+ connect();
767
+ }, ms);
768
+ };
618
769
  var handleServerText = (data, viaSocket) => {
619
770
  let parsed;
620
771
  try {
@@ -631,6 +782,7 @@ var connect = () => {
631
782
  transport = void 0;
632
783
  transportReason = "";
633
784
  failure = void 0;
785
+ recovery = void 0;
634
786
  config = void 0;
635
787
  telemetry = void 0;
636
788
  connectionStartedAt = performance.now();
@@ -646,6 +798,7 @@ var connect = () => {
646
798
  socket?.close();
647
799
  showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026", true);
648
800
  const query = new URLSearchParams([...params].filter(([key]) => ["transport", "profile", "adapt", "ice"].includes(key)));
801
+ if (standby) query.set("standby", "1");
649
802
  const nextSocket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/stream?${query}`);
650
803
  nextSocket.binaryType = "arraybuffer";
651
804
  socket = nextSocket;
@@ -749,6 +902,21 @@ window.addEventListener("keyup", (event) => {
749
902
  }, { capture: true });
750
903
  window.addEventListener("blur", () => send({ type: "release" }));
751
904
  window.addEventListener("resize", fitScreen);
905
+ var setStandby = (next) => {
906
+ clearTimeout(standbyTimer);
907
+ if (next === standby) return;
908
+ standby = next;
909
+ note(standby ? "standby" : "active");
910
+ sendSocket({ type: "standby", standby });
911
+ if (!standby && !audioPrompt.hidden) unlockAudio();
912
+ };
913
+ new IntersectionObserver(([entry]) => {
914
+ if (entry.isIntersecting) setStandby(false);
915
+ else if (!standby) {
916
+ clearTimeout(standbyTimer);
917
+ standbyTimer = setTimeout(() => setStandby(true), 1e3);
918
+ }
919
+ }).observe(document.documentElement);
752
920
  window.addEventListener("online", connect);
753
921
  document.addEventListener("visibilitychange", () => {
754
922
  if (document.visibilityState === "hidden") {
@@ -789,7 +957,7 @@ var updateDiagnostics = (stats) => {
789
957
  `source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
790
958
  `${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}`,
791
959
  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)` : "",
792
- 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}` : "",
960
+ 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}` : "",
793
961
  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"}` : "",
794
962
  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` : "",
795
963
  telemetry?.events.length ? `server events: ${describeEvents(telemetry.events)}` : "",
@@ -977,6 +1145,8 @@ setInterval(() => {
977
1145
  renderedFrames = 0;
978
1146
  decodedAudioPackets = 0;
979
1147
  renderedWindowStartedAt = now;
1148
+ if (retry) void checkRetry(retry).catch(() => {
1149
+ });
980
1150
  if (peer) void reportWebRtcStats(peer, stats).catch(() => {
981
1151
  });
982
1152
  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.13",
3
+ "version": "0.3.15",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",