replicas-computer 0.3.14 → 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 +159 -66
- package/dist/stream-viewer.js +210 -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/README.md
CHANGED
|
@@ -17,21 +17,21 @@ The versioned computer-use runtime for Replicas workspace images.
|
|
|
17
17
|
- `xsettingsd` broadcasts theme changes to running GTK apps and Chrome. Chrome follows the dashboard theme without reopening tabs; launch it without a `GTK_THEME` override.
|
|
18
18
|
- `assets/desktop/` owns the desktop theme: `brand-wallpaper.svg` (regenerate with `bun scripts/render-wallpaper.ts`), `tint2rc` dock, `openbox-theme/`, `gtk-settings.ini`, the `replicas-terminal` wrapper, `themes/` palettes that `start-services.sh` renders into those templates (`--theme light|dark` switches a running desktop), and the bundled fonts (static TTF instances of General Sans and Geist Mono; variable and WOFF2 builds render with wrong metrics in Pango and VTE).
|
|
19
19
|
|
|
20
|
-
The viewer and agent command paths are independent. One on-demand encoder pair is shared by all viewers, stops when the last viewer disconnects, and restarts without reloading the page. A viewer out of sight (the dashboard keeps one connected while the Desktop tab is hidden) reports `standby`, detected with an `IntersectionObserver` because a hidden iframe keeps its last layout size; while every viewer is in standby the encoder runs at 5 fps on the 1.5 Mbps rung, and the first viewer to become visible
|
|
20
|
+
The viewer and agent command paths are independent. `start-services.sh` runs Xvfb, the compositor, PulseAudio and the stream server (and so the encoders it spawns) at nice −10 when `sudo` is available, so CPU load from the apps on the desktop slows those apps instead of stalling the picture of them. One on-demand encoder pair is shared by all viewers, stops when the last viewer disconnects, and restarts without reloading the page. A viewer out of sight (the dashboard keeps one connected while the Desktop tab is hidden) reports `standby`, detected with an `IntersectionObserver` because a hidden iframe keeps its last layout size; while every viewer is in standby the encoder runs at 5 fps on the 1.5 Mbps rung, and the first viewer to become visible returns it to full rate in place.
|
|
21
21
|
|
|
22
22
|
Chrome disables GPU compositing when no `/dev/dri/renderD*` device is available. This avoids Mesa's CPU-rendered compositor overhead before capture while retaining WebGL support. Desktops with a hardware render device keep Chrome's normal compositing path.
|
|
23
23
|
|
|
24
24
|
## How the stream works
|
|
25
25
|
|
|
26
|
-
The Go capture process listens for XDamage updates, copies the current screen with XShm, converts RGB to YUV, and encodes H.264 through libx264 directly into RTP on loopback. The libyuv path copies and converts the changed bounding rectangle, aligned to preserve 4:2:0 chroma samples, while reusing unchanged pixels. It captures up to 60fps when the desktop changes and sends 5fps keepalives while idle. FFmpeg supplies Opus audio and remains the video fallback when the native binary is missing or fails. The stream uses the desktop resolution, 1920×1080 by default. RGB capture is converted with the BT.709 matrix in limited range and the same matrix is signalled in the bitstream, so browsers reconstruct the colours the desktop drew (`REPLICAS_DESKTOP_STREAM_COLOR=legacy` restores the untagged BT.601 conversion). Frames are forwarded unchanged apart from a sequence/timestamp splice and a playout-delay header extension.
|
|
26
|
+
The Go capture process listens for XDamage updates, copies the current screen with XShm, converts RGB to YUV, and encodes H.264 through libx264 directly into RTP on loopback. The libyuv path copies and converts the changed bounding rectangle, aligned to preserve 4:2:0 chroma samples, while reusing unchanged pixels. It captures up to 60fps when the desktop changes and sends 5fps keepalives while idle. It reads control lines on stdin between frames: `keyframe` encodes the next frame as an IDR at once, `quality <crf> <kbps>` changes the rate control through `x264_encoder_reconfig`, and `fps <n>` changes the capture rate, so none of them restarts the process. FFmpeg supplies Opus audio and remains the video fallback when the native binary is missing or fails. The stream uses the desktop resolution, 1920×1080 by default. RGB capture is converted with the BT.709 matrix in limited range and the same matrix is signalled in the bitstream, so browsers reconstruct the colours the desktop drew (`REPLICAS_DESKTOP_STREAM_COLOR=legacy` restores the untagged BT.601 conversion). Frames are forwarded unchanged apart from a sequence/timestamp splice and a playout-delay header extension.
|
|
27
27
|
|
|
28
|
-
WebRTC is the default transport, with automatic WebSocket fallback. A viewer can choose `?transport=websocket`; `REPLICAS_DESKTOP_STREAM_TRANSPORT=websocket` changes the server default for viewers that do not choose. Dashboard URLs carry the selected transport explicitly, and saved transport preferences are preserved. The viewer's WebSocket to `/stream` is the control plane either way: it carries the server's SDP offer, the viewer's answer, trickled ICE candidates, keepalives and telemetry. Pointer moves ride an unreliable data channel. When the server advertises `inputRace`, clicks, keys, wheel events and held-input release race a direct copy against ordered WebSocket delivery. The input bridge accepts only the next action ID, ignoring duplicates and out-of-order direct copies; the socket fills gaps without SCTP retransmission delays or a reorder queue. Older peers keep their existing input path. The watchdog tracks decoded frames so CSS-hidden viewers do not fall back merely because the browser stops presenting video. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without decoded video switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser.
|
|
28
|
+
WebRTC is the default transport, with automatic WebSocket fallback. A viewer can choose `?transport=websocket`; `REPLICAS_DESKTOP_STREAM_TRANSPORT=websocket` changes the server default for viewers that do not choose. Dashboard URLs carry the selected transport explicitly, and saved transport preferences are preserved. The viewer's WebSocket to `/stream` is the control plane either way: it carries the server's SDP offer, the viewer's answer, trickled ICE candidates, keepalives and telemetry. Pointer moves ride an unreliable data channel. When the server advertises `inputRace`, clicks, keys, wheel events and held-input release race a direct copy against ordered WebSocket delivery. The input bridge accepts only the next action ID, ignoring duplicates and out-of-order direct copies; the socket fills gaps without SCTP retransmission delays or a reorder queue. Older peers keep their existing input path. The watchdog tracks decoded frames so CSS-hidden viewers do not fall back merely because the browser stops presenting video. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without decoded video (at least three after the connection completes, so a slow network is not abandoned just as it connects) switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser. A viewer that fell back after a WebRTC failure keeps trying WebRTC in the background, 15 seconds after the fallback and then at doubling intervals up to five minutes: the server opens a new peer while the socket keeps carrying media, the new peer plays into an unseen element without offering 4:4:4, and once it decodes video the viewer moves its picture, audio and input to it and tells the server to stop the socket media (`retry-webrtc`, then `webrtc-ready`). The dashboard records each return as `desktop_stream_webrtc_recovered` with the time spent on the socket.
|
|
29
29
|
|
|
30
30
|
The server makes the offer, so payload types and SSRCs are fixed and ffmpeg emits them directly. Four H.264 payload types are offered (High 4:4:4 Predictive, High, Main, Constrained Baseline); the encoder runs the best profile every connected viewer accepts, so one viewer without 4:4:4 support moves everyone to 4:2:0, and socket viewers or caps below 8 Mbps do the same. Two profiles select the encoder tuning: `balanced` (CRF 14, default chroma quantisation) and `text` (CRF 10, chroma QP offset −6); `?profile=` picks one and the most recent viewer's choice applies to the shared encoder. Quality adapts on a bitrate ladder down to 750 kbps that keeps resolution and frame rate and adds two CRF per rung under the default 20 Mbps cap; `?adapt=0` pins the top rung.
|
|
31
31
|
|
|
32
|
-
Every video byte for a WebRTC viewer passes through `PacedSender`. libdatachannel's own pacer and NACK responder are not used because repairs bypassed the pacer and neither accounted for duplicates, so recovery traffic could exceed the link while fresh frames queued for seconds. The budget is 1.25× the rung's encoder cap and covers media, repairs and protective copies together: repairs take at most 30% of it over a rolling second, copies are sent only from idle budget while loss is observed and never exceed the original media bytes, and a queue that grows past 150 ms is cut back to whole frames a decoder can still follow (unsent delta frames go, the in-flight frame completes, a keyframe is requested and further deltas are dropped until it arrives). Sequence numbers are assigned as packets leave, so dropped frames never read as loss. Receiver PLI, NACK and REMB are parsed from RTCP. RTT growth (the lowest probe of the last second against the session's baseline, so a single late probe is not a queue), loss, jitter-buffer growth and stale-queue cuts drive the ladder down, and clean seconds drive it up. REMB is not a congestion vote: the receiver's estimate is bounded by what recently arrived and climbs by tens of kbps per second, so it trails every burst of motion on a quiet screen for a minute or more, and its delay detector reads the sender's pacing bursts on a quiet screen as overuse. It only
|
|
32
|
+
Every video byte for a WebRTC viewer passes through `PacedSender`. libdatachannel's own pacer and NACK responder are not used because repairs bypassed the pacer and neither accounted for duplicates, so recovery traffic could exceed the link while fresh frames queued for seconds. The budget is 1.25× the rung's encoder cap and covers media, repairs and protective copies together: repairs take at most 30% of it over a rolling second, copies are sent only from idle budget while loss is observed and never exceed the original media bytes, and a queue that grows past 150 ms is cut back to whole frames a decoder can still follow (unsent delta frames go, the in-flight frame completes, a keyframe is requested and further deltas are dropped until it arrives). Sequence numbers are assigned as packets leave, so dropped frames never read as loss. Receiver PLI, NACK and REMB are parsed from RTCP. RTT growth (the lowest probe of the last second against the session's baseline, so a single late probe is not a queue), loss, jitter-buffer growth and stale-queue cuts drive the ladder down, and clean seconds drive it up. Loss, RTT growth and jitter count only while the encoder has sent over 500 kbps for three seconds: on a quiet screen they belong to the network, and no rung fixes them. Climbing back to the rung below the one a descent left takes a few clean seconds; retrying that rung waits 15, 30 and then 60 seconds as episodes repeat, and an episode is forgotten after three minutes. REMB is not a congestion vote: the receiver's estimate is bounded by what recently arrived and climbs by tens of kbps per second, so it trails every burst of motion on a quiet screen for a minute or more, and its delay detector reads the sender's pacing bursts on a quiet screen as overuse. It and the browser's available-bitrate estimate only bound how far a severe descent (heavy loss or a starved decoder) steps, and only while the stream has been sending more than they report. `scripts/probe-viewer.ts` drives the viewer page in a Chrome with remote debugging and prints what it measures and what the server decides each second; `scripts/probe-rtt-jitter.ts` feeds the controller a synthetic WebRTC viewer with jittery RTT probes.
|
|
33
33
|
|
|
34
|
-
The superfast preset uses integer-pixel motion search to reduce encoder CPU while retaining CABAC compression; ultrafast is unchanged. Encoding uses at most four threads and one queued capture frame
|
|
34
|
+
The superfast preset uses integer-pixel motion search to reduce encoder CPU while retaining CABAC compression; ultrafast is unchanged. Encoding uses at most four threads and one queued capture frame and skips rapid catch-up captures after CPU stalls. Native capture sends no periodic keyframes or refresh wave: an IDR follows within a frame when a viewer joins, a receiver sends PLI, a send queue is cut, the viewer's watchdog sees no video, or a socket viewer whose frames were withheld has drained its backlog or reset its decoder. Requests within a round trip of the last keyframe share it. Browsers join only at an IDR, so the SPS that intra refresh puts before a recovery point is not treated as a keyframe. Rung and standby changes are applied in place; a change of H.264 profile, chroma or preset starts a replacement, which discards two initial frames, emits a forced keyframe and then takes over. The FFmpeg fallback cannot be told to make a keyframe, so it keeps periodic intra refresh for WebRTC viewers, switches to 250 ms IDR intervals for socket viewers, and makes requested keyframes by replacement. Every new encoder has four seconds to produce joinable media. Native startup or replacement failures switch subsequent launches to FFmpeg; the active encoder continues until its replacement is ready.
|
|
35
35
|
|
|
36
36
|
The viewer exposes `?view=fit|native|pixel` (letterboxed, one source pixel per CSS pixel, one source pixel per device pixel) and `?diagnostics=1`, which shows transport and ICE route, negotiated codec profile, source/decoded/CSS/device dimensions, displayed and decoded frame rates, bitrate, RTT, loss, jitter buffer, the server's encoder settings, keyframe interval, CPU, pacer budget, queue, stale cuts, repairs and copies, the socket fallback's unsent bytes, withheld delta frames and decoder resets, and the latest server and viewer events. `globalThis.__replicasDesktop` carries the same state for tooling.
|
|
37
37
|
|
|
@@ -82,6 +82,16 @@ start_bg() {
|
|
|
82
82
|
echo $! > "$pid_file"
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
# The stream, the X server it reads, the compositor that draws into it and the
|
|
86
|
+
# audio server outrank the apps they show, so CPU load from those apps slows
|
|
87
|
+
# the apps rather than stalling the picture of them. The encoders the stream
|
|
88
|
+
# server spawns inherit its priority. Without sudo they run at normal priority.
|
|
89
|
+
prioritize() {
|
|
90
|
+
local pid
|
|
91
|
+
pid="$(cat "$STATE_DIR/$1.pid" 2>/dev/null)"
|
|
92
|
+
[ -n "$pid" ] && sudo -n renice -n -10 -p "$pid" >/dev/null 2>&1 || true
|
|
93
|
+
}
|
|
94
|
+
|
|
85
95
|
count_lines() {
|
|
86
96
|
awk 'NF { count++ } END { print count + 0 }'
|
|
87
97
|
}
|
|
@@ -346,6 +356,7 @@ EOF
|
|
|
346
356
|
start_bg xvfb \
|
|
347
357
|
"pgrep -af 'Xvfb :$DISPLAY_NUM '" \
|
|
348
358
|
"exec Xvfb :$DISPLAY_NUM -screen 0 ${WIDTH}x${HEIGHT}x${DEPTH} -ac +extension RANDR +extension GLX"
|
|
359
|
+
prioritize xvfb
|
|
349
360
|
|
|
350
361
|
for _ in $(seq 1 30); do
|
|
351
362
|
if DISPLAY=":$DISPLAY_NUM" xdpyinfo >/dev/null 2>&1; then break; fi
|
|
@@ -363,6 +374,7 @@ start_bg xsettingsd \
|
|
|
363
374
|
start_bg pulseaudio \
|
|
364
375
|
"pactl info" \
|
|
365
376
|
"exec pulseaudio --daemonize=no --exit-idle-time=-1 --log-target=stderr"
|
|
377
|
+
prioritize pulseaudio
|
|
366
378
|
|
|
367
379
|
# Paint twice because some themes reset root pixmap during startup.
|
|
368
380
|
paint_wallpaper
|
|
@@ -388,6 +400,7 @@ if [ "$COMPOSITOR" = "1" ]; then
|
|
|
388
400
|
start_bg picom \
|
|
389
401
|
"false" \
|
|
390
402
|
"DISPLAY=:$DISPLAY_NUM exec picom --config $HOME/.config/picom/picom.conf --no-fading-openclose"
|
|
403
|
+
prioritize picom
|
|
391
404
|
fi
|
|
392
405
|
|
|
393
406
|
if [ "$ENABLE_NOVNC_FALLBACK" = "1" ]; then
|
|
@@ -426,6 +439,7 @@ sudo sysctl -q -w net.core.rmem_max=8388608 net.core.wmem_max=8388608 >/dev/null
|
|
|
426
439
|
start_bg stream \
|
|
427
440
|
"has_stream" \
|
|
428
441
|
"exec env REPLICAS_DESKTOP_DISPLAY=:$DISPLAY_NUM REPLICAS_DESKTOP_WIDTH=$WIDTH REPLICAS_DESKTOP_HEIGHT=$HEIGHT ${REPLICAS_DESKTOP_STREAM_FPS:+REPLICAS_DESKTOP_STREAM_FPS=$REPLICAS_DESKTOP_STREAM_FPS }REPLICAS_DESKTOP_STREAM_PORT=$STREAM_PORT REPLICAS_DESKTOP_STREAM_WEBROOT=${REPLICAS_DESKTOP_STREAM_WEBROOT:-$REPLICAS_DESKTOP_TEMPLATES/stream} replicas-computer stream"
|
|
442
|
+
prioritize stream
|
|
429
443
|
|
|
430
444
|
# Takes Chrome's cold-start cost at boot instead of on the user's first click;
|
|
431
445
|
# later launches join this instance through the profile singleton.
|
package/dist/index.js
CHANGED
|
@@ -2937,7 +2937,7 @@ async function computerStatusCommand() {
|
|
|
2937
2937
|
}
|
|
2938
2938
|
|
|
2939
2939
|
// src/version.ts
|
|
2940
|
-
var COMPUTER_USE_VERSION = "0.3.
|
|
2940
|
+
var COMPUTER_USE_VERSION = "0.3.15";
|
|
2941
2941
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
2942
2942
|
|
|
2943
2943
|
// src/program.ts
|
|
@@ -3251,13 +3251,17 @@ import { createInterface } from "readline";
|
|
|
3251
3251
|
|
|
3252
3252
|
// src/rtp.ts
|
|
3253
3253
|
var RTP_HEADER_BYTES = 12;
|
|
3254
|
+
var NAL_SLICE = 1;
|
|
3254
3255
|
var NAL_IDR = 5;
|
|
3256
|
+
var NAL_SEI = 6;
|
|
3255
3257
|
var NAL_SPS = 7;
|
|
3258
|
+
var SEI_RECOVERY_POINT = 6;
|
|
3256
3259
|
var NAL_STAP_A = 24;
|
|
3257
3260
|
var NAL_FU_A = 28;
|
|
3258
3261
|
var nalType = (byte) => byte & 31;
|
|
3259
3262
|
var headerBytesOf = (packet) => RTP_HEADER_BYTES + (packet[0] & 15) * 4;
|
|
3260
3263
|
var joinable = (nal) => nal === NAL_IDR || nal === NAL_SPS;
|
|
3264
|
+
var refreshFrame = (nal) => nalType(nal[0]) === NAL_SLICE || nalType(nal[0]) === NAL_SEI && nal[1] === SEI_RECOVERY_POINT;
|
|
3261
3265
|
var isRtp = (data) => data.length >= RTP_HEADER_BYTES && data[0] >> 6 === 2 && (data[1] < 192 || data[1] > 223);
|
|
3262
3266
|
var rtpPayload = (packet) => packet.subarray(headerBytesOf(packet));
|
|
3263
3267
|
function aggregatedNals(payload) {
|
|
@@ -3286,7 +3290,10 @@ function isKeyframeStart(payload) {
|
|
|
3286
3290
|
const fragment = fragmentHeader(payload);
|
|
3287
3291
|
return fragment.start && joinable(fragment.type);
|
|
3288
3292
|
}
|
|
3289
|
-
if (type === NAL_STAP_A)
|
|
3293
|
+
if (type === NAL_STAP_A) {
|
|
3294
|
+
const { nals } = aggregatedNals(payload);
|
|
3295
|
+
return nals.some((nal) => joinable(nalType(nal[0]))) && !nals.some(refreshFrame);
|
|
3296
|
+
}
|
|
3290
3297
|
return joinable(type);
|
|
3291
3298
|
}
|
|
3292
3299
|
var RtpSplicer = class {
|
|
@@ -3363,6 +3370,8 @@ var RtpClock = class {
|
|
|
3363
3370
|
};
|
|
3364
3371
|
var startCode = new Uint8Array([0, 0, 0, 1]);
|
|
3365
3372
|
var H264RtpAssembler = class {
|
|
3373
|
+
/** Frames abandoned for a missing or out-of-order packet; every delta after one is undecodable until a keyframe. */
|
|
3374
|
+
discarded = 0;
|
|
3366
3375
|
clock = new RtpClock(9e4);
|
|
3367
3376
|
chunks = [];
|
|
3368
3377
|
key = false;
|
|
@@ -3380,6 +3389,7 @@ var H264RtpAssembler = class {
|
|
|
3380
3389
|
}
|
|
3381
3390
|
push(packet) {
|
|
3382
3391
|
if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
|
|
3392
|
+
if (this.chunks.length > 0 || this.damaged) this.discarded += 1;
|
|
3383
3393
|
this.clearFrame();
|
|
3384
3394
|
}
|
|
3385
3395
|
this.timestamp ??= packet.timestamp;
|
|
@@ -3412,6 +3422,7 @@ var H264RtpAssembler = class {
|
|
|
3412
3422
|
} else this.damaged = true;
|
|
3413
3423
|
if (!packet.marker) return null;
|
|
3414
3424
|
if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
|
|
3425
|
+
this.discarded += 1;
|
|
3415
3426
|
this.clearFrame();
|
|
3416
3427
|
return null;
|
|
3417
3428
|
}
|
|
@@ -3469,7 +3480,9 @@ function superviseEncoder(options) {
|
|
|
3469
3480
|
const warmupFrames = active ? options.warmupFrames ?? 0 : 0;
|
|
3470
3481
|
const command = options.command?.() ?? "ffmpeg";
|
|
3471
3482
|
const args = options.args(socket.address().port, warmupFrames);
|
|
3472
|
-
const child = spawn3(command, args, { env: options.env, stdio: ["ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
|
|
3483
|
+
const child = spawn3(command, args, { env: options.env, stdio: [options.controllable?.(command) ? "pipe" : "ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
|
|
3484
|
+
child.stdin?.on("error", () => {
|
|
3485
|
+
});
|
|
3473
3486
|
child.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
|
|
3474
3487
|
const generation = { id: ++nextId, child, socket, live: true, startedAt: Date.now(), warmupFrames };
|
|
3475
3488
|
candidate = generation;
|
|
@@ -3539,10 +3552,18 @@ function superviseEncoder(options) {
|
|
|
3539
3552
|
candidate = void 0;
|
|
3540
3553
|
active = void 0;
|
|
3541
3554
|
});
|
|
3555
|
+
const control = (line) => {
|
|
3556
|
+
for (const generation of [candidate, active]) {
|
|
3557
|
+
if (generation?.live && generation.child.stdin?.writable) generation.child.stdin.write(`${line}
|
|
3558
|
+
`);
|
|
3559
|
+
}
|
|
3560
|
+
return Boolean(active?.live && active.child.stdin?.writable);
|
|
3561
|
+
};
|
|
3542
3562
|
return {
|
|
3543
3563
|
start,
|
|
3544
3564
|
replace,
|
|
3545
3565
|
stop,
|
|
3566
|
+
control,
|
|
3546
3567
|
running: () => Boolean(active && alive(active.child)),
|
|
3547
3568
|
replacing: () => Boolean(candidate),
|
|
3548
3569
|
generation: () => active?.id ?? 0,
|
|
@@ -3625,6 +3646,8 @@ function validInput(value) {
|
|
|
3625
3646
|
switch (value.type) {
|
|
3626
3647
|
case "release":
|
|
3627
3648
|
case "keyframe":
|
|
3649
|
+
case "retry-webrtc":
|
|
3650
|
+
case "webrtc-ready":
|
|
3628
3651
|
return true;
|
|
3629
3652
|
case "theme":
|
|
3630
3653
|
return value.theme === "light" || value.theme === "dark";
|
|
@@ -4078,6 +4101,7 @@ function superviseVideoEncoder(options) {
|
|
|
4078
4101
|
const encoder = superviseEncoder({
|
|
4079
4102
|
label: "video",
|
|
4080
4103
|
command: () => nativeCapture ? captureBinary : "ffmpeg",
|
|
4104
|
+
controllable: (command) => command === captureBinary,
|
|
4081
4105
|
onFailure: (command) => {
|
|
4082
4106
|
if (command !== captureBinary) return;
|
|
4083
4107
|
nativeCapture = false;
|
|
@@ -4234,6 +4258,8 @@ function superviseVideoEncoder(options) {
|
|
|
4234
4258
|
});
|
|
4235
4259
|
return {
|
|
4236
4260
|
...encoder,
|
|
4261
|
+
/** Native capture takes keyframe, quality and frame-rate changes over stdin; ffmpeg needs a replacement for each. */
|
|
4262
|
+
controllable: () => nativeCapture,
|
|
4237
4263
|
capture: () => ({
|
|
4238
4264
|
captureBackend: (encoder.command() ?? (nativeCapture ? captureBinary : "ffmpeg")) === captureBinary ? "native" : "ffmpeg",
|
|
4239
4265
|
captureMetrics: captureMetrics?.generation === encoder.generation() && Date.now() - captureMetrics.at <= 2500 ? captureMetrics : void 0
|
|
@@ -4479,6 +4505,7 @@ var qualityLadder = (profile) => rungCaps.map((kbps, index) => ({ kbps, crf: pro
|
|
|
4479
4505
|
var fullChromaMinKbps = 8e3;
|
|
4480
4506
|
var fallbackKeyframeMs = 250;
|
|
4481
4507
|
var webrtcStartupRung = 2;
|
|
4508
|
+
var descentMemoryMs = 18e4;
|
|
4482
4509
|
var targetBacklogMs = 120;
|
|
4483
4510
|
var congestedBacklogMs = 250;
|
|
4484
4511
|
var congestedDecodeQueue = 3;
|
|
@@ -4558,7 +4585,8 @@ async function runMediaServer() {
|
|
|
4558
4585
|
let activeProfile = "balanced";
|
|
4559
4586
|
let adaptive = true;
|
|
4560
4587
|
let rung = 0;
|
|
4561
|
-
let descents =
|
|
4588
|
+
let descents = [];
|
|
4589
|
+
let failedRung = -1;
|
|
4562
4590
|
let congestedSeconds = 0;
|
|
4563
4591
|
let comfortableSeconds = 0;
|
|
4564
4592
|
let cleanTransportSeconds = 0;
|
|
@@ -4577,6 +4605,8 @@ async function runMediaServer() {
|
|
|
4577
4605
|
let encoderFps = 0;
|
|
4578
4606
|
let videoWanted = false;
|
|
4579
4607
|
let encoderSignature = "";
|
|
4608
|
+
let encoderDescription = "";
|
|
4609
|
+
let encoderLive = { crf: 0, kbps: 0, fps: 0 };
|
|
4580
4610
|
let encoderStop;
|
|
4581
4611
|
let joinKeyframe;
|
|
4582
4612
|
let lastVideoAt = 0;
|
|
@@ -4611,10 +4641,13 @@ async function runMediaServer() {
|
|
|
4611
4641
|
const quality = ladder()[allStandby() ? Math.max(rung, standbyRung) : rung];
|
|
4612
4642
|
const encodeFps = streamFps();
|
|
4613
4643
|
const sockets = socketViewers().length > 0;
|
|
4614
|
-
const
|
|
4644
|
+
const onDemand = videoEncoder.controllable();
|
|
4645
|
+
const keyframeMs = onDemand ? 0 : sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
|
|
4615
4646
|
const videoProfile = profile();
|
|
4616
|
-
const intraRefresh = !sockets;
|
|
4647
|
+
const intraRefresh = !onDemand && !sockets;
|
|
4617
4648
|
const pixelFormat = videoProfile === "high444" ? "yuv444p" : "yuv420p";
|
|
4649
|
+
const live = `rung ${rung} \xB7 crf ${quality.crf} \xB7 ${encodeFps} fps`;
|
|
4650
|
+
const restart = `${activeProfile} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs ? `${keyframeMs}ms${intraRefresh ? " refresh" : ""}` : "on demand"}`;
|
|
4618
4651
|
return {
|
|
4619
4652
|
quality,
|
|
4620
4653
|
preset,
|
|
@@ -4624,7 +4657,8 @@ async function runMediaServer() {
|
|
|
4624
4657
|
pixelFormat,
|
|
4625
4658
|
fps: encodeFps,
|
|
4626
4659
|
chromaQpOffset: profiles[activeProfile].chromaQpOffset,
|
|
4627
|
-
signature:
|
|
4660
|
+
signature: onDemand ? restart : `${live} \xB7 ${restart}`,
|
|
4661
|
+
description: `${live} \xB7 ${restart}`
|
|
4628
4662
|
};
|
|
4629
4663
|
};
|
|
4630
4664
|
const budgetBps = () => ladder()[rung].kbps * 1e3 * budgetFactor;
|
|
@@ -4645,6 +4679,7 @@ async function runMediaServer() {
|
|
|
4645
4679
|
if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
|
|
4646
4680
|
viewer.waitingForKeyframe = true;
|
|
4647
4681
|
viewer.withheldFrames += 1;
|
|
4682
|
+
if (videoEncoder.controllable() && queueMs(viewer) <= targetBacklogMs && socket.bufferedAmount <= fallbackBufferedBytes) requestKeyframe("socket viewer caught up", void 0, viewer);
|
|
4648
4683
|
continue;
|
|
4649
4684
|
}
|
|
4650
4685
|
if (keyframe2) viewer.waitingForKeyframe = false;
|
|
@@ -4676,7 +4711,7 @@ async function runMediaServer() {
|
|
|
4676
4711
|
let sockets = false;
|
|
4677
4712
|
for (const viewer of viewers.values()) {
|
|
4678
4713
|
if (viewer.fallback) sockets = true;
|
|
4679
|
-
|
|
4714
|
+
viewer.rtc?.enqueueVideo(rtp ??= withPlayoutDelay(packet, PLAYOUT_DELAY_ID), frameId, keyframe, last);
|
|
4680
4715
|
}
|
|
4681
4716
|
frameStart = last;
|
|
4682
4717
|
if (last) {
|
|
@@ -4685,7 +4720,12 @@ async function runMediaServer() {
|
|
|
4685
4720
|
}
|
|
4686
4721
|
if (!sockets) return;
|
|
4687
4722
|
const parsed = parseRtpPacket(packet);
|
|
4723
|
+
const discarded = videoAssembler.discarded;
|
|
4688
4724
|
const frame = parsed && videoAssembler.push(parsed);
|
|
4725
|
+
if (videoAssembler.discarded !== discarded) {
|
|
4726
|
+
for (const viewer of socketViewers()) viewer.waitingForKeyframe = true;
|
|
4727
|
+
if (videoEncoder.controllable()) requestKeyframe("socket frame lost");
|
|
4728
|
+
}
|
|
4689
4729
|
if (frame) broadcast(mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data), frame.key);
|
|
4690
4730
|
};
|
|
4691
4731
|
const onAudioPacket = (packet, generation) => {
|
|
@@ -4696,7 +4736,7 @@ async function runMediaServer() {
|
|
|
4696
4736
|
let sockets = false;
|
|
4697
4737
|
for (const viewer of viewers.values()) {
|
|
4698
4738
|
if (viewer.fallback) sockets = true;
|
|
4699
|
-
|
|
4739
|
+
viewer.rtc?.sendAudio(packet);
|
|
4700
4740
|
}
|
|
4701
4741
|
if (!sockets) return;
|
|
4702
4742
|
const parsed = parseRtpPacket(packet);
|
|
@@ -4770,20 +4810,30 @@ async function runMediaServer() {
|
|
|
4770
4810
|
const ensureEncoders = () => {
|
|
4771
4811
|
clearTimeout(encoderStop);
|
|
4772
4812
|
void audioEncoder.start();
|
|
4773
|
-
const { signature } = encoderParams();
|
|
4813
|
+
const { signature, description, quality, fps: encodeFps } = encoderParams();
|
|
4814
|
+
const live = { crf: quality.crf, kbps: quality.kbps, fps: encodeFps };
|
|
4774
4815
|
if (!videoWanted) {
|
|
4775
4816
|
videoWanted = true;
|
|
4776
4817
|
encoderSignature = signature;
|
|
4777
|
-
|
|
4818
|
+
encoderDescription = description;
|
|
4819
|
+
encoderLive = live;
|
|
4820
|
+
record("encoder", description);
|
|
4778
4821
|
void videoEncoder.start();
|
|
4779
4822
|
return false;
|
|
4780
4823
|
}
|
|
4781
|
-
if (
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4824
|
+
if (description !== encoderDescription) record("encoder", description);
|
|
4825
|
+
encoderDescription = description;
|
|
4826
|
+
if (signature !== encoderSignature) {
|
|
4827
|
+
encoderSignature = signature;
|
|
4828
|
+
encoderLive = live;
|
|
4829
|
+
lastSwapAt = Date.now();
|
|
4830
|
+
void videoEncoder.replace();
|
|
4831
|
+
return true;
|
|
4832
|
+
}
|
|
4833
|
+
if (live.crf !== encoderLive.crf || live.kbps !== encoderLive.kbps) videoEncoder.control(`quality ${live.crf} ${live.kbps}`);
|
|
4834
|
+
if (live.fps !== encoderLive.fps) videoEncoder.control(`fps ${live.fps}`);
|
|
4835
|
+
encoderLive = live;
|
|
4836
|
+
return false;
|
|
4787
4837
|
};
|
|
4788
4838
|
const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
|
|
4789
4839
|
const stopEncodersSoon = () => {
|
|
@@ -4801,8 +4851,9 @@ async function runMediaServer() {
|
|
|
4801
4851
|
const quality = ladder()[next];
|
|
4802
4852
|
record(next > rung ? "quality down" : "quality up", `rung ${rung} \u2192 ${next} (${quality.kbps} kbps cap, crf ${quality.crf}): ${reason}`);
|
|
4803
4853
|
if (next > rung) {
|
|
4804
|
-
if (now - lastDescentAt > 1e4) descents
|
|
4854
|
+
if (now - lastDescentAt > 1e4) descents.push(now);
|
|
4805
4855
|
lastDescentAt = now;
|
|
4856
|
+
failedRung = rung;
|
|
4806
4857
|
if (now - lastAscentAt < 15e3) {
|
|
4807
4858
|
blockedRung = rung;
|
|
4808
4859
|
blockedUntil = now + 12e4;
|
|
@@ -4818,7 +4869,8 @@ async function runMediaServer() {
|
|
|
4818
4869
|
const resetLadder = (request) => {
|
|
4819
4870
|
rung = request.transport === "webrtc" && request.adaptive ? webrtcStartupRung : 0;
|
|
4820
4871
|
cleanTransportSeconds = 0;
|
|
4821
|
-
descents =
|
|
4872
|
+
descents = [];
|
|
4873
|
+
failedRung = -1;
|
|
4822
4874
|
lastDescentAt = 0;
|
|
4823
4875
|
lastAscentAt = 0;
|
|
4824
4876
|
congestedSeconds = 0;
|
|
@@ -4838,6 +4890,7 @@ async function runMediaServer() {
|
|
|
4838
4890
|
const sent = Math.min(...recentVideoKbps);
|
|
4839
4891
|
return remb !== void 0 && earlier !== void 0 && remb <= earlier.kbps && sent > 500 && remb < sent * 0.9;
|
|
4840
4892
|
};
|
|
4893
|
+
const loaded = () => Math.min(...recentVideoKbps) > 500;
|
|
4841
4894
|
const signals = (viewer, stats) => {
|
|
4842
4895
|
const now = Date.now();
|
|
4843
4896
|
if (viewer.fallback) {
|
|
@@ -4848,22 +4901,24 @@ async function runMediaServer() {
|
|
|
4848
4901
|
}
|
|
4849
4902
|
const stableSince = stableRttWindows.get(viewer)?.since;
|
|
4850
4903
|
return [
|
|
4851
|
-
...viewer.rttFloorMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || now - stableSince < 2e3) ? [`rtt floor ${Math.round(viewer.rttFloorMs)} ms over ${Math.round(viewer.rtcBaselineMs)} ms baseline`] : [],
|
|
4904
|
+
...viewer.rttFloorMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || now - stableSince < 2e3) && loaded() ? [`rtt floor ${Math.round(viewer.rttFloorMs)} ms over ${Math.round(viewer.rtcBaselineMs)} ms baseline`] : [],
|
|
4852
4905
|
...stats.videoFps < expectedFps() * 0.6 && viewer.lowFpsReports >= 2 ? [`decoded ${stats.videoFps.toFixed(0)} fps`] : [],
|
|
4853
|
-
...(stats.lossPercent ?? 0) > 3 ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
|
|
4854
|
-
...(stats.jitterBufferMs ?? 0) > 80 ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
|
|
4906
|
+
...(stats.lossPercent ?? 0) > 3 && loaded() ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
|
|
4907
|
+
...(stats.jitterBufferMs ?? 0) > 80 && loaded() ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
|
|
4855
4908
|
];
|
|
4856
4909
|
};
|
|
4857
4910
|
const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
|
|
4858
|
-
const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 || stats.videoFps < expectedFps() * 0.25 && viewer.lowFpsReports >= 2);
|
|
4911
|
+
const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 && loaded() || stats.videoFps < expectedFps() * 0.25 && viewer.lowFpsReports >= 2);
|
|
4859
4912
|
let lastSevereDescentAt = 0;
|
|
4860
4913
|
const descendSevere = (now, trigger) => {
|
|
4861
4914
|
comfortableSeconds = 0;
|
|
4862
4915
|
congestedSeconds = 0;
|
|
4863
4916
|
if (!adaptive || now - lastSevereDescentAt < 1e3) return;
|
|
4864
4917
|
lastSevereDescentAt = now;
|
|
4918
|
+
const sent = Math.min(...recentVideoKbps);
|
|
4865
4919
|
const estimates = [...viewers.values()].flatMap((viewer) => {
|
|
4866
|
-
const
|
|
4920
|
+
const available = viewer.stats && now - viewer.stats.at < 750 ? viewer.stats.availableKbps ?? 0 : 0;
|
|
4921
|
+
const reported = sent > 500 && available < sent * 0.9 ? available : 0;
|
|
4867
4922
|
const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
|
|
4868
4923
|
return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
|
|
4869
4924
|
});
|
|
@@ -4880,9 +4935,12 @@ async function runMediaServer() {
|
|
|
4880
4935
|
videoKbps = Math.round((videoBytes - bytesAtTick) * 8 / 1e3);
|
|
4881
4936
|
bytesAtTick = videoBytes;
|
|
4882
4937
|
if (recentVideoKbps.push(videoKbps) > 3) recentVideoKbps.shift();
|
|
4883
|
-
if (viewers.size === 0)
|
|
4938
|
+
if (viewers.size === 0) {
|
|
4939
|
+
starvedSeconds = 0;
|
|
4940
|
+
return;
|
|
4941
|
+
}
|
|
4884
4942
|
const { captureBackend, captureMetrics } = videoEncoder.capture();
|
|
4885
|
-
const captureBusy = captureBackend !== "native" ||
|
|
4943
|
+
const captureBusy = captureBackend !== "native" || (captureMetrics ? captureMetrics.busyMs / captureMetrics.elapsedMs > 0.8 : videoEncoder.activeForMs() > 2500);
|
|
4886
4944
|
if (videoEncoder.running() && encoderFps < streamFps() * 0.9 && captureBusy) {
|
|
4887
4945
|
cleanTransportSeconds = 0;
|
|
4888
4946
|
congestedSeconds = 0;
|
|
@@ -4931,15 +4989,16 @@ async function runMediaServer() {
|
|
|
4931
4989
|
return;
|
|
4932
4990
|
}
|
|
4933
4991
|
congestedSeconds = Math.max(0, congestedSeconds - 0.5);
|
|
4934
|
-
|
|
4992
|
+
descents = descents.filter((at) => now - at < descentMemoryMs);
|
|
4993
|
+
if (descents.length === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.rttFloorMs > viewer.rtcBaselineMs + 20 && loaded() || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
|
|
4935
4994
|
comfortableSeconds = 0;
|
|
4936
4995
|
return;
|
|
4937
4996
|
}
|
|
4938
4997
|
comfortableSeconds += 1;
|
|
4939
|
-
const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
|
|
4940
|
-
const recoverySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents, 2));
|
|
4941
|
-
if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
|
|
4942
4998
|
const target = Math.max(0, rung - 1);
|
|
4999
|
+
const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
|
|
5000
|
+
const retrySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents.length - 1, 2));
|
|
5001
|
+
if (comfortableSeconds < (descents.length > 0 && target <= failedRung ? retrySeconds : startupSeconds)) return;
|
|
4943
5002
|
if (target === blockedRung && now < blockedUntil) return;
|
|
4944
5003
|
setRung(target, `${comfortableSeconds}s comfortable, ${cleanTransportSeconds}s clean transport`);
|
|
4945
5004
|
}, 1e3);
|
|
@@ -5019,9 +5078,18 @@ async function runMediaServer() {
|
|
|
5019
5078
|
`);
|
|
5020
5079
|
}, telemetryIntervalMs);
|
|
5021
5080
|
telemetry.unref();
|
|
5081
|
+
let lastKeyframeRequestAt = 0;
|
|
5022
5082
|
const requestKeyframe = (reason, joinedAt, requestingViewer) => {
|
|
5023
5083
|
const now = Date.now();
|
|
5024
5084
|
if (!videoWanted || viewers.size === 0 || videoEncoder.replacing() || lastKeyframeAt < now - videoEncoder.activeForMs() || joinedAt !== void 0 && lastKeyframeAt > joinedAt) return;
|
|
5085
|
+
if (videoEncoder.controllable()) {
|
|
5086
|
+
const rttMs = requestingViewer?.rtc?.rttMs ?? requestingViewer?.stats?.rttMs ?? 0;
|
|
5087
|
+
if (joinedAt === void 0 && now - Math.max(lastKeyframeAt, lastKeyframeRequestAt) < Math.max(300, rttMs + 150)) return;
|
|
5088
|
+
if (!videoEncoder.control("keyframe")) return;
|
|
5089
|
+
lastKeyframeRequestAt = now;
|
|
5090
|
+
record("keyframe", reason);
|
|
5091
|
+
return;
|
|
5092
|
+
}
|
|
5025
5093
|
const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
|
|
5026
5094
|
const wait = (joinedAt === void 0 ? startup ? 500 : 2e3 : 350) - (now - lastSwapAt);
|
|
5027
5095
|
if (wait > 0) {
|
|
@@ -5031,7 +5099,9 @@ async function runMediaServer() {
|
|
|
5031
5099
|
}
|
|
5032
5100
|
return;
|
|
5033
5101
|
}
|
|
5034
|
-
|
|
5102
|
+
const { keyframeMs, intraRefresh } = encoderParams();
|
|
5103
|
+
const untilPeriodic = keyframeMs - (now - lastKeyframeAt);
|
|
5104
|
+
if (!startup && !intraRefresh && untilPeriodic >= 0 && untilPeriodic <= 350) return;
|
|
5035
5105
|
lastSwapAt = now;
|
|
5036
5106
|
record("keyframe", reason);
|
|
5037
5107
|
void videoEncoder.replace();
|
|
@@ -5044,16 +5114,51 @@ async function runMediaServer() {
|
|
|
5044
5114
|
process.stderr.write(`[desktop-stream] webrtc fallback diagnostics: ${JSON.stringify(entry)}
|
|
5045
5115
|
`);
|
|
5046
5116
|
}
|
|
5047
|
-
|
|
5117
|
+
const retried = viewer.rtc;
|
|
5118
|
+
viewer.rtc?.close();
|
|
5119
|
+
viewer.rtc = void 0;
|
|
5120
|
+
if (viewer.fallback) {
|
|
5121
|
+
if (retried) record("webrtc retry failed", reason);
|
|
5122
|
+
return;
|
|
5123
|
+
}
|
|
5048
5124
|
viewer.fallback = true;
|
|
5049
5125
|
viewer.waitingForKeyframe = true;
|
|
5050
5126
|
viewer.input.release();
|
|
5051
|
-
viewer.rtc?.close();
|
|
5052
|
-
viewer.rtc = void 0;
|
|
5053
5127
|
record("fallback to socket", reason);
|
|
5054
5128
|
if (!syncEncoder()) requestKeyframe("socket fallback");
|
|
5055
5129
|
};
|
|
5056
5130
|
const optional = (value, max) => value === void 0 ? void 0 : clamp(value, 0, max);
|
|
5131
|
+
const openWebRtc = (viewer, reply, iceServers) => {
|
|
5132
|
+
try {
|
|
5133
|
+
viewer.rtc = new WebRtcViewer({ iceServers, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
|
|
5134
|
+
offer: (sdp) => reply({ type: "offer", sdp }),
|
|
5135
|
+
candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
|
|
5136
|
+
message: (message, answer) => handleMessage(viewer, message, answer),
|
|
5137
|
+
keyframe: (reason) => {
|
|
5138
|
+
if (reason === "stale" && Date.now() - lastDescentAt > 1e3) descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
|
|
5139
|
+
requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
|
|
5140
|
+
},
|
|
5141
|
+
remb: (bitsPerSecond) => {
|
|
5142
|
+
if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
|
|
5143
|
+
},
|
|
5144
|
+
connected: () => {
|
|
5145
|
+
record("webrtc connected", viewer.rtc?.path ?? void 0);
|
|
5146
|
+
requestKeyframe("viewer joined", Date.now());
|
|
5147
|
+
},
|
|
5148
|
+
// A viewer that loses its peer cannot send the key-ups for whatever it
|
|
5149
|
+
// was holding, and its socket may stay open for a long time yet. A socket
|
|
5150
|
+
// viewer trying WebRTC again still sends its input over the socket.
|
|
5151
|
+
disconnected: (state) => {
|
|
5152
|
+
record(`webrtc ${state}`);
|
|
5153
|
+
if (!viewer.fallback) viewer.input.release();
|
|
5154
|
+
if (state === "failed") reply({ type: "webrtc-failed" });
|
|
5155
|
+
}
|
|
5156
|
+
});
|
|
5157
|
+
} catch (error) {
|
|
5158
|
+
record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
5159
|
+
reply({ type: "webrtc-failed" });
|
|
5160
|
+
}
|
|
5161
|
+
};
|
|
5057
5162
|
const handleMessage = (viewer, message, reply) => {
|
|
5058
5163
|
switch (message.type) {
|
|
5059
5164
|
case "ping":
|
|
@@ -5131,7 +5236,7 @@ async function runMediaServer() {
|
|
|
5131
5236
|
}
|
|
5132
5237
|
if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
|
|
5133
5238
|
if (viewer.stats.videoFps >= expectedFps() * 0.8) viewer.everHealthy = true;
|
|
5134
|
-
if (encoderHealthyTicks >= 3 && videoEncoder.running() && [...viewers.values()].every((v) => !v.fallback && v.stats && now - v.stats.at < 750 && now - v.connectedAt > 2500 && (v.stats.videoPacketsPerSecond ?? 0) > 0 && (v.stats.lossPercent ?? 0) > 10)) descendSevere(now, "every viewer reports over 10% loss");
|
|
5239
|
+
if (encoderHealthyTicks >= 3 && videoEncoder.running() && loaded() && [...viewers.values()].every((v) => !v.fallback && v.stats && now - v.stats.at < 750 && now - v.connectedAt > 2500 && (v.stats.videoPacketsPerSecond ?? 0) > 0 && (v.stats.lossPercent ?? 0) > 10)) descendSevere(now, "every viewer reports over 10% loss");
|
|
5135
5240
|
return;
|
|
5136
5241
|
}
|
|
5137
5242
|
case "answer":
|
|
@@ -5144,8 +5249,25 @@ async function runMediaServer() {
|
|
|
5144
5249
|
case "fallback":
|
|
5145
5250
|
enterFallback(viewer, message.reason, message.failure);
|
|
5146
5251
|
return;
|
|
5252
|
+
// A socket viewer whose WebRTC attempt failed tries again in the background;
|
|
5253
|
+
// the socket keeps carrying its media until the new peer shows video.
|
|
5254
|
+
case "retry-webrtc": {
|
|
5255
|
+
const ice = webrtc && viewer.fallback && !viewer.rtc && viewer.request.transport === "webrtc" ? currentIceServers() : void 0;
|
|
5256
|
+
if (!ice) return;
|
|
5257
|
+
record("retrying webrtc", "socket media continues meanwhile");
|
|
5258
|
+
openWebRtc(viewer, reply, ice.native);
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
5261
|
+
case "webrtc-ready":
|
|
5262
|
+
if (!viewer.fallback || !viewer.rtc?.connected) return;
|
|
5263
|
+
viewer.fallback = false;
|
|
5264
|
+
viewer.withheldFrames = 0;
|
|
5265
|
+
record("back on webrtc", viewer.rtc.path ?? void 0);
|
|
5266
|
+
syncEncoder();
|
|
5267
|
+
return;
|
|
5147
5268
|
case "keyframe":
|
|
5148
|
-
if (
|
|
5269
|
+
if (viewer.fallback) viewer.waitingForKeyframe = true;
|
|
5270
|
+
if (!viewer.fallback || videoEncoder.controllable()) requestKeyframe(viewer.fallback ? "socket viewer decoder reset" : "viewer watchdog", void 0, viewer);
|
|
5149
5271
|
return;
|
|
5150
5272
|
case "theme":
|
|
5151
5273
|
applyDesktopTheme(message.theme);
|
|
@@ -5294,36 +5416,7 @@ async function runMediaServer() {
|
|
|
5294
5416
|
const fallbackProfile = profile() === "high444" ? "main" : profile();
|
|
5295
5417
|
const configMessage = { ...config, videoCodec: H264_PROFILES[fallbackProfile].codec, iceServers: ice?.browser, request };
|
|
5296
5418
|
reply(configMessage);
|
|
5297
|
-
if (ice)
|
|
5298
|
-
try {
|
|
5299
|
-
viewer.rtc = new WebRtcViewer({ iceServers: ice.native, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
|
|
5300
|
-
offer: (sdp) => reply({ type: "offer", sdp }),
|
|
5301
|
-
candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
|
|
5302
|
-
message: (message, answer) => handleMessage(viewer, message, answer),
|
|
5303
|
-
keyframe: (reason) => {
|
|
5304
|
-
if (reason === "stale") descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
|
|
5305
|
-
requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
|
|
5306
|
-
},
|
|
5307
|
-
remb: (bitsPerSecond) => {
|
|
5308
|
-
if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
|
|
5309
|
-
},
|
|
5310
|
-
connected: () => {
|
|
5311
|
-
record("webrtc connected", viewer.rtc?.path ?? void 0);
|
|
5312
|
-
requestKeyframe("viewer joined", Date.now());
|
|
5313
|
-
},
|
|
5314
|
-
// A viewer that loses its peer cannot send the key-ups for whatever it
|
|
5315
|
-
// was holding, and its socket may stay open for a long time yet.
|
|
5316
|
-
disconnected: (state) => {
|
|
5317
|
-
record(`webrtc ${state}`);
|
|
5318
|
-
viewer.input.release();
|
|
5319
|
-
if (state === "failed") reply({ type: "webrtc-failed" });
|
|
5320
|
-
}
|
|
5321
|
-
});
|
|
5322
|
-
} catch (error) {
|
|
5323
|
-
record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
5324
|
-
reply({ type: "webrtc-failed" });
|
|
5325
|
-
}
|
|
5326
|
-
}
|
|
5419
|
+
if (ice) openWebRtc(viewer, reply, ice.native);
|
|
5327
5420
|
if (!viewer.rtc) enterFallback(viewer, request.transport === "webrtc" ? "webrtc unavailable" : "websocket requested");
|
|
5328
5421
|
ensureEncoders();
|
|
5329
5422
|
socket.on("message", (raw, binary) => {
|
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;
|
|
@@ -119,6 +123,7 @@ var peer;
|
|
|
119
123
|
var inputChannel;
|
|
120
124
|
var pointerChannel;
|
|
121
125
|
var webrtcDeadline;
|
|
126
|
+
var webrtcDeadlineAt = 0;
|
|
122
127
|
var webrtcSkips = 0;
|
|
123
128
|
var videoTrack;
|
|
124
129
|
var videoWriter;
|
|
@@ -174,6 +179,7 @@ state.events = viewerEvents;
|
|
|
174
179
|
var decoderResets = 0;
|
|
175
180
|
var connectionStartedAt = performance.now();
|
|
176
181
|
var failure;
|
|
182
|
+
var recovery;
|
|
177
183
|
var localCandidateTypes = /* @__PURE__ */ new Set();
|
|
178
184
|
var remoteCandidateTypes = /* @__PURE__ */ new Set();
|
|
179
185
|
var iceErrorCodes = /* @__PURE__ */ new Set();
|
|
@@ -250,9 +256,10 @@ var sendInput = (message, unreliable = false) => {
|
|
|
250
256
|
};
|
|
251
257
|
var announceTransport = () => {
|
|
252
258
|
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 }, "*");
|
|
259
|
+
window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure, recovery }, "*");
|
|
254
260
|
};
|
|
255
261
|
var closePeer = (preserveDeadline = false) => {
|
|
262
|
+
cancelRetry();
|
|
256
263
|
if (!preserveDeadline) {
|
|
257
264
|
clearTimeout(webrtcDeadline);
|
|
258
265
|
webrtcDeadline = void 0;
|
|
@@ -394,7 +401,14 @@ var handleBinary = (data) => {
|
|
|
394
401
|
waitingForKeyframe = true;
|
|
395
402
|
state.decoderResets = ++decoderResets;
|
|
396
403
|
}
|
|
397
|
-
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
|
+
}
|
|
398
412
|
waitingForKeyframe = false;
|
|
399
413
|
try {
|
|
400
414
|
videoDecoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data: payload }));
|
|
@@ -465,6 +479,9 @@ var useFallback = (reasonCode, failed = false) => {
|
|
|
465
479
|
route = { ...route, path: "proxy", codec: config.videoCodec };
|
|
466
480
|
announceTransport();
|
|
467
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();
|
|
468
485
|
if (!TrackGenerator) {
|
|
469
486
|
showStatus("This browser cannot render the desktop stream");
|
|
470
487
|
return;
|
|
@@ -477,35 +494,15 @@ var useFallback = (reasonCode, failed = false) => {
|
|
|
477
494
|
var negotiate = async (sdp) => {
|
|
478
495
|
if (!config?.iceServers || transport === "websocket") return;
|
|
479
496
|
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]);
|
|
497
|
+
const pc = openPeer(config, () => peer === pc, (stream) => {
|
|
498
|
+
if (screen.srcObject !== stream) screen.srcObject = stream;
|
|
493
499
|
void screen.play().catch(() => {
|
|
494
500
|
});
|
|
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
|
-
};
|
|
501
|
+
}, (channel) => {
|
|
502
|
+
if (channel.label === "pointer") pointerChannel = channel;
|
|
503
|
+
else inputChannel = channel;
|
|
504
|
+
}, (type) => localCandidateTypes.add(type));
|
|
505
|
+
peer = pc;
|
|
509
506
|
pc.onicecandidateerror = (event) => {
|
|
510
507
|
if (peer !== pc) return;
|
|
511
508
|
iceErrorCodes.add(event.errorCode);
|
|
@@ -522,6 +519,7 @@ var negotiate = async (sdp) => {
|
|
|
522
519
|
transportReason = "";
|
|
523
520
|
webrtcSkips = 0;
|
|
524
521
|
lastFrameAt = performance.now();
|
|
522
|
+
if (webrtcDeadline && webrtcDeadlineAt - lastFrameAt < webrtcFirstFrameMs) armWebrtcDeadline(webrtcFirstFrameMs);
|
|
525
523
|
announceTransport();
|
|
526
524
|
unlockAudio();
|
|
527
525
|
} else if (pc.connectionState === "failed") {
|
|
@@ -530,26 +528,166 @@ var negotiate = async (sdp) => {
|
|
|
530
528
|
}
|
|
531
529
|
};
|
|
532
530
|
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 });
|
|
531
|
+
if (!await answerOffer(pc, sdp, skipFullChroma, () => peer === pc)) useFallback("codec_unsupported", true);
|
|
549
532
|
} catch (error) {
|
|
550
533
|
if (peer === pc) throw error;
|
|
551
534
|
}
|
|
552
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
|
+
};
|
|
553
691
|
var handleServerMessage = (message, viaSocket) => {
|
|
554
692
|
if (message.type === "pong") {
|
|
555
693
|
const elapsed = performance.now() - message.sentAt;
|
|
@@ -570,10 +708,15 @@ var handleServerMessage = (message, viaSocket) => {
|
|
|
570
708
|
return;
|
|
571
709
|
}
|
|
572
710
|
if (message.type === "webrtc-failed") {
|
|
573
|
-
|
|
711
|
+
if (retryRequested) failRetry("server connection failed");
|
|
712
|
+
else useFallback("server_connection_failed", true);
|
|
574
713
|
return;
|
|
575
714
|
}
|
|
576
715
|
if (message.type === "offer") {
|
|
716
|
+
if (retryRequested) {
|
|
717
|
+
void answerRetry(message.sdp);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
577
720
|
void negotiate(message.sdp).catch((error) => {
|
|
578
721
|
negotiationErrorName = error instanceof Error ? error.name : "unknown";
|
|
579
722
|
useFallback("negotiation_failed", true);
|
|
@@ -583,7 +726,7 @@ var handleServerMessage = (message, viaSocket) => {
|
|
|
583
726
|
if (message.type === "candidate") {
|
|
584
727
|
const candidateType = message.candidate.match(/\btyp (host|srflx|prflx|relay)\b/)?.[1];
|
|
585
728
|
if (candidateType) remoteCandidateTypes.add(candidateType);
|
|
586
|
-
const pc = peer;
|
|
729
|
+
const pc = retry?.pc ?? peer;
|
|
587
730
|
void pc?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
|
|
588
731
|
if (peer === pc) candidateErrors += 1;
|
|
589
732
|
});
|
|
@@ -605,18 +748,24 @@ var handleServerMessage = (message, viaSocket) => {
|
|
|
605
748
|
useFallback("recent_failure");
|
|
606
749
|
} else if (!transport) {
|
|
607
750
|
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);
|
|
751
|
+
armWebrtcDeadline(webrtcFrameTimeoutMs);
|
|
618
752
|
}
|
|
619
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
|
+
};
|
|
620
769
|
var handleServerText = (data, viaSocket) => {
|
|
621
770
|
let parsed;
|
|
622
771
|
try {
|
|
@@ -633,6 +782,7 @@ var connect = () => {
|
|
|
633
782
|
transport = void 0;
|
|
634
783
|
transportReason = "";
|
|
635
784
|
failure = void 0;
|
|
785
|
+
recovery = void 0;
|
|
636
786
|
config = void 0;
|
|
637
787
|
telemetry = void 0;
|
|
638
788
|
connectionStartedAt = performance.now();
|
|
@@ -807,7 +957,7 @@ var updateDiagnostics = (stats) => {
|
|
|
807
957
|
`source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
|
|
808
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}`,
|
|
809
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)` : "",
|
|
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}` : "",
|
|
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}` : "",
|
|
811
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"}` : "",
|
|
812
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` : "",
|
|
813
963
|
telemetry?.events.length ? `server events: ${describeEvents(telemetry.events)}` : "",
|
|
@@ -995,6 +1145,8 @@ setInterval(() => {
|
|
|
995
1145
|
renderedFrames = 0;
|
|
996
1146
|
decodedAudioPackets = 0;
|
|
997
1147
|
renderedWindowStartedAt = now;
|
|
1148
|
+
if (retry) void checkRetry(retry).catch(() => {
|
|
1149
|
+
});
|
|
998
1150
|
if (peer) void reportWebRtcStats(peer, stats).catch(() => {
|
|
999
1151
|
});
|
|
1000
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
|
}
|