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.
- package/README.md +5 -5
- package/assets/desktop/start-services.sh +14 -0
- package/dist/index.js +185 -76
- package/dist/stream-viewer.js +228 -58
- package/native/internal/capture/native.go +40 -11
- package/native/main.go +69 -4
- package/native/options.go +1 -1
- package/package.json +1 -1
package/dist/stream-viewer.js
CHANGED
|
@@ -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)
|
|
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 =
|
|
479
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(&
|
|
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
|
|
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
|
-
|
|
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 *
|
|
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=%
|
|
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
|
|
446
|
-
func (s *Stream) Changed() (bool, error)
|
|
447
|
-
func (s *Stream) Wait(micros int64) error
|
|
448
|
-
func (s *Stream) Send(pts int64, keyframe bool) error
|
|
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
|
-
|
|
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
|
|
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) || (
|
|
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,
|
|
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
|
}
|