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.
- package/README.md +6 -6
- package/assets/desktop/start-services.sh +14 -0
- package/dist/index.js +317 -162
- package/dist/stream-viewer.js +214 -63
- 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
|
@@ -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(
|
|
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)
|
|
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 =
|
|
481
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|