replicas-computer 0.3.16 → 0.3.18
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 +3 -3
- package/dist/index.js +25 -18
- package/dist/stream-viewer.js +100 -7
- package/native/internal/capture/options.go +1 -1
- package/native/main.go +5 -1
- package/native/options.go +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,15 +23,15 @@ Chrome disables GPU compositing when no `/dev/dri/renderD*` device is available.
|
|
|
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. 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.
|
|
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. x264 keeps the frame rate it opened with and sizes its rate cap by it, so the encoder always opens at the stream's full rate and `--capture-fps` sets the rate capture starts at. 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
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. 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
|
|
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 only bounds how far a severe descent (heavy loss, a starved decoder or a cut send queue) steps, and only once it has stopped climbing below what the stream sends. The browser's available-bitrate stat is the same estimate and is reported but never used. `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 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.
|
|
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. Until then the outgoing encoder takes the new rate, and keyframe requests wait for the replacement's. 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
|
|
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.18";
|
|
2941
2941
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
2942
2942
|
|
|
2943
2943
|
// src/program.ts
|
|
@@ -3478,13 +3478,21 @@ function superviseEncoder(options) {
|
|
|
3478
3478
|
if (active || candidate) return;
|
|
3479
3479
|
await spawnCandidate();
|
|
3480
3480
|
});
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3481
|
+
let queuedReplacements = 0;
|
|
3482
|
+
const replace = () => {
|
|
3483
|
+
queuedReplacements += 1;
|
|
3484
|
+
return enqueue(async () => {
|
|
3485
|
+
try {
|
|
3486
|
+
if (!wanted) return;
|
|
3487
|
+
clearTimeout(restart);
|
|
3488
|
+
clearTimeout(candidateDeadline);
|
|
3489
|
+
retire(candidate);
|
|
3490
|
+
await spawnCandidate();
|
|
3491
|
+
} finally {
|
|
3492
|
+
queuedReplacements -= 1;
|
|
3493
|
+
}
|
|
3494
|
+
});
|
|
3495
|
+
};
|
|
3488
3496
|
const stop = () => enqueue(async () => {
|
|
3489
3497
|
wanted = false;
|
|
3490
3498
|
clearTimeout(restart);
|
|
@@ -3507,7 +3515,7 @@ function superviseEncoder(options) {
|
|
|
3507
3515
|
stop,
|
|
3508
3516
|
control,
|
|
3509
3517
|
running: () => Boolean(active && alive(active.child)),
|
|
3510
|
-
replacing: () => Boolean(candidate),
|
|
3518
|
+
replacing: () => Boolean(candidate) || queuedReplacements > 0,
|
|
3511
3519
|
generation: () => active?.id ?? 0,
|
|
3512
3520
|
pid: () => active?.child.pid ?? 0,
|
|
3513
3521
|
command: () => active?.child.spawnfile,
|
|
@@ -4200,6 +4208,8 @@ function superviseVideoEncoder(options) {
|
|
|
4200
4208
|
"--height",
|
|
4201
4209
|
String(height2),
|
|
4202
4210
|
"--fps",
|
|
4211
|
+
String(options.maxFps),
|
|
4212
|
+
"--capture-fps",
|
|
4203
4213
|
String(fps2),
|
|
4204
4214
|
"--threads",
|
|
4205
4215
|
String(Math.min(4, availableParallelism())),
|
|
@@ -4807,6 +4817,7 @@ async function runMediaServer() {
|
|
|
4807
4817
|
width,
|
|
4808
4818
|
height,
|
|
4809
4819
|
colorMode,
|
|
4820
|
+
maxFps: fps,
|
|
4810
4821
|
params: encoderParams,
|
|
4811
4822
|
onPacket: onVideoPacket,
|
|
4812
4823
|
record
|
|
@@ -4882,17 +4893,16 @@ async function runMediaServer() {
|
|
|
4882
4893
|
}
|
|
4883
4894
|
if (description !== encoderDescription) record("encoder", description);
|
|
4884
4895
|
encoderDescription = description;
|
|
4885
|
-
|
|
4896
|
+
const replacing = signature !== encoderSignature;
|
|
4897
|
+
if (replacing) {
|
|
4886
4898
|
encoderSignature = signature;
|
|
4887
|
-
encoderLive = live;
|
|
4888
4899
|
lastSwapAt = Date.now();
|
|
4889
4900
|
void videoEncoder.replace();
|
|
4890
|
-
return true;
|
|
4891
4901
|
}
|
|
4892
4902
|
if (live.crf !== encoderLive.crf || live.kbps !== encoderLive.kbps) videoEncoder.control(`quality ${live.crf} ${live.kbps}`);
|
|
4893
4903
|
if (live.fps !== encoderLive.fps) videoEncoder.control(`fps ${live.fps}`);
|
|
4894
4904
|
encoderLive = live;
|
|
4895
|
-
return
|
|
4905
|
+
return replacing;
|
|
4896
4906
|
};
|
|
4897
4907
|
const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
|
|
4898
4908
|
const stopEncodersSoon = () => {
|
|
@@ -4974,12 +4984,9 @@ async function runMediaServer() {
|
|
|
4974
4984
|
congestedSeconds = 0;
|
|
4975
4985
|
if (!adaptive || now - lastSevereDescentAt < 1e3) return;
|
|
4976
4986
|
lastSevereDescentAt = now;
|
|
4977
|
-
const sent = Math.min(...recentVideoKbps);
|
|
4978
4987
|
const estimates = [...viewers.values()].flatMap((viewer) => {
|
|
4979
|
-
const
|
|
4980
|
-
|
|
4981
|
-
const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
|
|
4982
|
-
return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
|
|
4988
|
+
const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) : void 0;
|
|
4989
|
+
return remb ? [remb] : [];
|
|
4983
4990
|
});
|
|
4984
4991
|
const rungs = ladder();
|
|
4985
4992
|
const estimatedRung = estimates.length ? rungs.findIndex((quality) => quality.kbps <= Math.min(...estimates) * 0.8) : rung + 2;
|
package/dist/stream-viewer.js
CHANGED
|
@@ -49,6 +49,7 @@ var DESKTOP_WEBRTC_FAILURE_REASONS = {
|
|
|
49
49
|
full_chroma_undecodable: "WebRTC could not decode H.264 High 4:4:4"
|
|
50
50
|
};
|
|
51
51
|
var DESKTOP_QUALITY_MESSAGE = "replicas-desktop-quality";
|
|
52
|
+
var DESKTOP_CONNECTION_MESSAGE = "replicas-desktop-connection";
|
|
52
53
|
|
|
53
54
|
// src/input-validation.ts
|
|
54
55
|
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
@@ -102,6 +103,7 @@ var webrtcFirstFrameMs = 3e3;
|
|
|
102
103
|
var webrtcRetryFirstMs = 15e3;
|
|
103
104
|
var webrtcRetryMaxMs = 3e5;
|
|
104
105
|
var webrtcRetryTimeoutMs = 1e4;
|
|
106
|
+
var hiddenDisconnectMs = 6e4;
|
|
105
107
|
var webrtcSkipsAfterFailure = 3;
|
|
106
108
|
var pointerIntervalMs = 4;
|
|
107
109
|
var qualityWindowMs = 3e4;
|
|
@@ -113,8 +115,10 @@ try {
|
|
|
113
115
|
} catch {
|
|
114
116
|
}
|
|
115
117
|
var socket;
|
|
116
|
-
var
|
|
118
|
+
var intersecting = innerWidth > 0 && innerHeight > 0;
|
|
119
|
+
var standby = !intersecting;
|
|
117
120
|
var standbyTimer;
|
|
121
|
+
var hiddenTimer;
|
|
118
122
|
var config;
|
|
119
123
|
var transport;
|
|
120
124
|
var transportReason = "";
|
|
@@ -171,6 +175,24 @@ var restartQuality = () => {
|
|
|
171
175
|
qualitySequence = 0;
|
|
172
176
|
presentedAt = 0;
|
|
173
177
|
};
|
|
178
|
+
var newConnectionTotals = () => ({
|
|
179
|
+
sampledAt: 0,
|
|
180
|
+
presented: 0,
|
|
181
|
+
shownMs: 0,
|
|
182
|
+
frames: 0,
|
|
183
|
+
samples: 0,
|
|
184
|
+
rung: 0,
|
|
185
|
+
worstRung: 0,
|
|
186
|
+
cpu: 0,
|
|
187
|
+
rtt: 0,
|
|
188
|
+
rttSamples: 0,
|
|
189
|
+
worstRtt: 0,
|
|
190
|
+
loss: 0,
|
|
191
|
+
lossSamples: 0,
|
|
192
|
+
longestGapMs: 0,
|
|
193
|
+
reportedAt: 0
|
|
194
|
+
});
|
|
195
|
+
var connectionTotals = newConnectionTotals();
|
|
174
196
|
var state = {};
|
|
175
197
|
globalThis.__replicasDesktop = state;
|
|
176
198
|
var viewerEvents = [];
|
|
@@ -257,6 +279,31 @@ var announceTransport = () => {
|
|
|
257
279
|
if (window.parent === window) return;
|
|
258
280
|
window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure, recovery }, "*");
|
|
259
281
|
};
|
|
282
|
+
var average = (sum, count) => count ? Math.round(sum * 10 / count) / 10 : void 0;
|
|
283
|
+
var reportConnection = (ended) => {
|
|
284
|
+
if (window.parent === window || !config) return;
|
|
285
|
+
const totals = connectionTotals;
|
|
286
|
+
totals.reportedAt = performance.now();
|
|
287
|
+
window.parent.postMessage({
|
|
288
|
+
type: DESKTOP_CONNECTION_MESSAGE,
|
|
289
|
+
serverVersion: config.serverVersion ?? "unknown",
|
|
290
|
+
firstFrameMs: totals.firstFrameAt === void 0 ? void 0 : Math.round(totals.firstFrameAt - connectionStartedAt),
|
|
291
|
+
shownMs: Math.round(totals.shownMs),
|
|
292
|
+
displayedFps: totals.shownMs >= 1e3 ? Math.round(totals.frames * 1e4 / totals.shownMs) / 10 : void 0,
|
|
293
|
+
inputRttMs: average(totals.rtt, totals.rttSamples),
|
|
294
|
+
worstInputRttMs: totals.rttSamples ? Math.round(totals.worstRtt) : void 0,
|
|
295
|
+
qualityRung: average(totals.rung, totals.samples),
|
|
296
|
+
worstQualityRung: totals.samples ? totals.worstRung : void 0,
|
|
297
|
+
longestFrameGapMs: totals.samples ? Math.round(totals.longestGapMs) : void 0,
|
|
298
|
+
lossPercent: average(totals.loss, totals.lossSamples),
|
|
299
|
+
cpuLoadPercent: average(totals.cpu, totals.samples),
|
|
300
|
+
ended
|
|
301
|
+
}, "*");
|
|
302
|
+
};
|
|
303
|
+
var endConnection = (ended) => {
|
|
304
|
+
if (ended || connectionTotals.sampledAt) reportConnection(ended);
|
|
305
|
+
connectionTotals = newConnectionTotals();
|
|
306
|
+
};
|
|
260
307
|
var closePeer = (preserveDeadline = false) => {
|
|
261
308
|
cancelRetry();
|
|
262
309
|
if (!preserveDeadline) {
|
|
@@ -310,9 +357,11 @@ var configureVideo = async (next, preferHardware = true) => {
|
|
|
310
357
|
if (configuration !== videoConfiguration) return;
|
|
311
358
|
const selectedConfig = support.supported && support.config ? support.config : decoderConfig(next);
|
|
312
359
|
activeVideoConfig = selectedConfig;
|
|
360
|
+
const totals = connectionTotals;
|
|
313
361
|
const decoder = new VideoDecoder({
|
|
314
362
|
output: (frame) => {
|
|
315
363
|
socketDecodedFrames += 1;
|
|
364
|
+
totals.firstFrameAt ??= performance.now();
|
|
316
365
|
const writer = videoWriter;
|
|
317
366
|
if (!writer || (writer.desiredSize ?? 1) <= 0) {
|
|
318
367
|
frame.close();
|
|
@@ -776,6 +825,7 @@ var handleServerText = (data, viaSocket) => {
|
|
|
776
825
|
};
|
|
777
826
|
var connect = () => {
|
|
778
827
|
if (document.visibilityState !== "visible") return;
|
|
828
|
+
endConnection();
|
|
779
829
|
clearTimeout(reconnectTimer);
|
|
780
830
|
closePeer();
|
|
781
831
|
transport = void 0;
|
|
@@ -818,9 +868,12 @@ var connect = () => {
|
|
|
818
868
|
});
|
|
819
869
|
nextSocket.addEventListener("close", () => {
|
|
820
870
|
if (socket !== nextSocket) return;
|
|
871
|
+
endConnection();
|
|
821
872
|
closePeer();
|
|
822
|
-
transport
|
|
823
|
-
|
|
873
|
+
if (transport) {
|
|
874
|
+
transport = void 0;
|
|
875
|
+
announceTransport();
|
|
876
|
+
}
|
|
824
877
|
reconnectAttempt += 1;
|
|
825
878
|
const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5)) * (0.85 + Math.random() * 0.3);
|
|
826
879
|
note("socket closed", `reconnect in ${Math.round(delay)} ms`);
|
|
@@ -855,6 +908,8 @@ var observeFrame = () => {
|
|
|
855
908
|
lastPresentedCount = metadata.presentedFrames;
|
|
856
909
|
lastFrameAt = performance.now();
|
|
857
910
|
if (presentedAt && qualityWindow) qualityWindow.longestGapMs = Math.max(qualityWindow.longestGapMs, lastFrameAt - Math.max(presentedAt, qualityWindow.at));
|
|
911
|
+
const { shownSince } = connectionTotals;
|
|
912
|
+
if (presentedAt && shownSince !== void 0) connectionTotals.longestGapMs = Math.max(connectionTotals.longestGapMs, lastFrameAt - Math.max(presentedAt, shownSince));
|
|
858
913
|
presentedAt = lastFrameAt;
|
|
859
914
|
status.classList.remove("visible");
|
|
860
915
|
observeFrame();
|
|
@@ -909,22 +964,32 @@ var setStandby = (next) => {
|
|
|
909
964
|
sendSocket({ type: "standby", standby });
|
|
910
965
|
if (!standby && !audioPrompt.hidden) unlockAudio();
|
|
911
966
|
};
|
|
912
|
-
|
|
913
|
-
if (
|
|
967
|
+
var syncStandby = () => {
|
|
968
|
+
if (intersecting && document.visibilityState === "visible") setStandby(false);
|
|
914
969
|
else if (!standby) {
|
|
915
970
|
clearTimeout(standbyTimer);
|
|
916
971
|
standbyTimer = setTimeout(() => setStandby(true), 1e3);
|
|
917
972
|
}
|
|
973
|
+
};
|
|
974
|
+
new IntersectionObserver(([entry]) => {
|
|
975
|
+
intersecting = entry.isIntersecting;
|
|
976
|
+
syncStandby();
|
|
918
977
|
}).observe(document.documentElement);
|
|
919
978
|
window.addEventListener("online", connect);
|
|
920
979
|
document.addEventListener("visibilitychange", () => {
|
|
980
|
+
clearTimeout(hiddenTimer);
|
|
981
|
+
syncStandby();
|
|
921
982
|
if (document.visibilityState === "hidden") {
|
|
922
|
-
|
|
983
|
+
hiddenTimer = setTimeout(() => {
|
|
984
|
+
endConnection("hidden");
|
|
985
|
+
disconnect();
|
|
986
|
+
}, hiddenDisconnectMs);
|
|
923
987
|
return;
|
|
924
988
|
}
|
|
925
|
-
connect();
|
|
989
|
+
if (!socket || socket.readyState >= WebSocket.CLOSING) connect();
|
|
926
990
|
});
|
|
927
991
|
window.addEventListener("pagehide", () => {
|
|
992
|
+
endConnection();
|
|
928
993
|
disconnect();
|
|
929
994
|
screen.cancelVideoFrameCallback(frameCallback);
|
|
930
995
|
videoDecoder?.close();
|
|
@@ -963,6 +1028,32 @@ var updateDiagnostics = (stats) => {
|
|
|
963
1028
|
viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
|
|
964
1029
|
].filter(Boolean).join("\n");
|
|
965
1030
|
};
|
|
1031
|
+
var sampleConnection = (stats, current, now) => {
|
|
1032
|
+
const totals = connectionTotals;
|
|
1033
|
+
const shown = !standby && document.visibilityState === "visible" && qualityUnseen.samples < 4;
|
|
1034
|
+
if (shown && totals.sampledAt) {
|
|
1035
|
+
totals.shownSince ??= totals.sampledAt;
|
|
1036
|
+
totals.shownMs += now - totals.sampledAt;
|
|
1037
|
+
totals.frames += presentedFrames - totals.presented;
|
|
1038
|
+
totals.samples += 1;
|
|
1039
|
+
totals.rung += current.encoder.rung;
|
|
1040
|
+
totals.worstRung = Math.max(totals.worstRung, current.encoder.rung);
|
|
1041
|
+
totals.cpu += current.cpu.load * 100 / current.cpu.cores;
|
|
1042
|
+
if (stats.rttMs > 0) {
|
|
1043
|
+
totals.rtt += stats.rttMs;
|
|
1044
|
+
totals.rttSamples += 1;
|
|
1045
|
+
totals.worstRtt = Math.max(totals.worstRtt, stats.rttMs);
|
|
1046
|
+
}
|
|
1047
|
+
if (stats.lossPercent !== void 0) {
|
|
1048
|
+
totals.loss += stats.lossPercent;
|
|
1049
|
+
totals.lossSamples += 1;
|
|
1050
|
+
}
|
|
1051
|
+
totals.longestGapMs = Math.max(totals.longestGapMs, now - Math.max(presentedAt, totals.shownSince));
|
|
1052
|
+
} else if (!shown) totals.shownSince = void 0;
|
|
1053
|
+
totals.sampledAt = now;
|
|
1054
|
+
totals.presented = presentedFrames;
|
|
1055
|
+
if (now - totals.reportedAt >= 2e3) reportConnection();
|
|
1056
|
+
};
|
|
966
1057
|
var reportQuality = (stats) => {
|
|
967
1058
|
if (window.parent === window || !transport || !config || !telemetry) return;
|
|
968
1059
|
const now = performance.now();
|
|
@@ -970,6 +1061,7 @@ var reportQuality = (stats) => {
|
|
|
970
1061
|
const decoded = transport === "webrtc" ? inbound?.framesDecoded ?? 0 : socketDecodedFrames;
|
|
971
1062
|
qualityUnseen = { decoded, presented: presentedFrames, samples: decoded > qualityUnseen.decoded && presentedFrames === qualityUnseen.presented ? qualityUnseen.samples + 1 : 0 };
|
|
972
1063
|
if (qualityUnseen.samples >= 4) current.unseen = true;
|
|
1064
|
+
sampleConnection(stats, telemetry, now);
|
|
973
1065
|
qualitySamples.push({ stats, telemetry });
|
|
974
1066
|
const windowMs = now - current.at;
|
|
975
1067
|
if (windowMs < qualityWindowMs) return;
|
|
@@ -1048,6 +1140,7 @@ var reportWebRtcStats = async (pc, stats) => {
|
|
|
1048
1140
|
};
|
|
1049
1141
|
if (counters.framesDecoded > (inbound?.framesDecoded ?? 0)) {
|
|
1050
1142
|
lastFrameAt = now;
|
|
1143
|
+
connectionTotals.firstFrameAt ??= now;
|
|
1051
1144
|
clearTimeout(webrtcDeadline);
|
|
1052
1145
|
webrtcDeadline = void 0;
|
|
1053
1146
|
if (fullChromaFailure) {
|
package/native/main.go
CHANGED
|
@@ -2,6 +2,7 @@ package main
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"cmp"
|
|
5
6
|
"fmt"
|
|
6
7
|
"os"
|
|
7
8
|
"os/signal"
|
|
@@ -59,7 +60,10 @@ func run(o capture.Options) error {
|
|
|
59
60
|
}
|
|
60
61
|
started := capture.NowMicros()
|
|
61
62
|
last, lastKeyframe, reportAt := started-1000000, started-1000000, started
|
|
62
|
-
|
|
63
|
+
// x264 keeps the frame rate it opened with and sizes its rate cap by it, so
|
|
64
|
+
// --fps is the stream's full rate and --capture-fps the rate capture starts at.
|
|
65
|
+
captureFPS := cmp.Or(o.CaptureFPS, o.FPS)
|
|
66
|
+
interval := int64((1000000 + captureFPS - 1) / captureFPS)
|
|
63
67
|
busyUS, captureUS, scaleUS, encodeUS, previousPTS := int64(0), int64(0), int64(0), int64(0), int64(-1)
|
|
64
68
|
dirty, forceKeyframe := true, false
|
|
65
69
|
number, changedFrames, sentFrames := 0, 0, 0
|
package/native/options.go
CHANGED
|
@@ -18,7 +18,7 @@ func parseOptions() (capture.Options, error) {
|
|
|
18
18
|
initial, low, high int
|
|
19
19
|
}{
|
|
20
20
|
{"width", &o.Width, 0, 2, 8192}, {"height", &o.Height, 0, 2, 8192},
|
|
21
|
-
{"fps", &o.FPS, 60, 1, 240}, {"threads", &o.Threads, 4, 1, 16},
|
|
21
|
+
{"fps", &o.FPS, 60, 1, 240}, {"capture-fps", &o.CaptureFPS, 0, 1, 240}, {"threads", &o.Threads, 4, 1, 16},
|
|
22
22
|
{"crf", &o.CRF, 14, 0, 51}, {"kbps", &o.Kbps, 20000, 1, 1000000},
|
|
23
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},
|