replicas-computer 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/assets/desktop/stream-viewer.css +2 -0
- package/dist/index.js +137 -72
- package/dist/stream-viewer.js +33 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,15 +21,17 @@ The viewer and agent command paths are independent. One on-demand encoder pair i
|
|
|
21
21
|
|
|
22
22
|
ffmpeg captures the display and encodes 1920×1080 H.264 (and Opus from PulseAudio) as RTP onto loopback. 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.
|
|
23
23
|
|
|
24
|
-
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. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without
|
|
24
|
+
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.
|
|
25
25
|
|
|
26
26
|
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 six-rung bitrate ladder that keeps resolution and frame rate and adds two CRF per rung under the 20 Mbps cap; `?adapt=0` pins the top rung.
|
|
27
27
|
|
|
28
|
-
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
|
|
28
|
+
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 bounds how far a severe descent (heavy loss or a starved decoder) steps, and only while it has stopped climbing below the sent rate. `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.
|
|
29
29
|
|
|
30
30
|
Encoding uses at most four threads and one queued capture frame, skips rapid catch-up captures after CPU stalls, and uses periodic intra refresh for WebRTC viewers; socket viewers switch it to 250 ms IDR intervals because they resynchronise only on IDR frames. When encoder settings change, a replacement discards two initial frames, emits a forced keyframe and then takes over. The active encoder continues if replacement fails.
|
|
31
31
|
|
|
32
|
-
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,
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
The server records every decision that changes the picture (viewer connects and transport fallbacks with their reason, WebRTC state changes, quality rung moves with the signals that voted for them, encoder swaps, preset changes and keyframe requests) and writes each to the stream log (`/tmp/replicas-computer/stream.log`). `GET /telemetry` returns the `/health` snapshot plus the last 200 events and five minutes of one-second samples (encoder rung, CRF, cap, keyframe interval, preset, fps and kbps; CPU; per-viewer transport, ICE path, pacer counters, socket backlog and the viewer's own reported stats). Fetch it inside the sandbox with `curl -s localhost:6080/telemetry` or through the authenticated preview URL; the dashboard's stream settings menu has **Open stream telemetry**. `REPLICAS_DESKTOP_STREAM_TELEMETRY=1` also writes every sample to the stream log for sessions longer than the in-memory history.
|
|
33
35
|
|
|
34
36
|
| Variable | Default | Meaning |
|
|
35
37
|
|---|---|---|
|
|
@@ -42,6 +44,7 @@ The viewer exposes `?view=fit|native|pixel` (letterboxed, one source pixel per C
|
|
|
42
44
|
| `REPLICAS_DESKTOP_STREAM_COLOR` | `bt709` | `legacy` disables the explicit BT.709 conversion and signalling |
|
|
43
45
|
| `REPLICAS_DESKTOP_STREAM_VIDEO_BITRATE` | `20M` | Encoder bitrate cap across all quality rungs |
|
|
44
46
|
| `REPLICAS_DESKTOP_STREAM_FPS` | `60` | Capture and encode rate |
|
|
47
|
+
| `REPLICAS_DESKTOP_STREAM_TELEMETRY` | unset | `1` writes one-second telemetry samples to the stream log while a viewer is connected |
|
|
45
48
|
|
|
46
49
|
```bash
|
|
47
50
|
replicas-computer setup --check
|
|
@@ -38,6 +38,8 @@ html[data-view="native"] body, html[data-view="pixel"] body { width: max-content
|
|
|
38
38
|
right: 12px;
|
|
39
39
|
display: none;
|
|
40
40
|
max-width: calc(100vw - 24px);
|
|
41
|
+
max-height: calc(100vh - 24px);
|
|
42
|
+
overflow: hidden;
|
|
41
43
|
padding: 6px 10px;
|
|
42
44
|
border: 1px solid var(--replicas-hairline);
|
|
43
45
|
color: var(--replicas-ink);
|
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.4";
|
|
2941
2941
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
2942
2942
|
|
|
2943
2943
|
// src/program.ts
|
|
@@ -3446,8 +3446,8 @@ function superviseEncoder(options) {
|
|
|
3446
3446
|
let restart;
|
|
3447
3447
|
let candidateDeadline;
|
|
3448
3448
|
let chain = Promise.resolve();
|
|
3449
|
-
const log = (message) => process.stderr.write(`[desktop-stream] ${options.label} ${message}
|
|
3450
|
-
`);
|
|
3449
|
+
const log = options.log ?? ((message) => process.stderr.write(`[desktop-stream] ${options.label} ${message}
|
|
3450
|
+
`));
|
|
3451
3451
|
const retire = (generation) => {
|
|
3452
3452
|
if (!generation) return;
|
|
3453
3453
|
generation.live = false;
|
|
@@ -4253,6 +4253,9 @@ var fallbackBufferedBytes = 512 * 1024;
|
|
|
4253
4253
|
var maximumBufferedBytes = 4 * 1024 * 1024;
|
|
4254
4254
|
var probeIntervalMs = 250;
|
|
4255
4255
|
var telemetryIntervalMs = 500;
|
|
4256
|
+
var eventHistory = 200;
|
|
4257
|
+
var sampleHistory = 300;
|
|
4258
|
+
var telemetryLog = process.env.REPLICAS_DESKTOP_STREAM_TELEMETRY === "1";
|
|
4256
4259
|
var budgetFactor = 1.25;
|
|
4257
4260
|
var protectMs = 1e4;
|
|
4258
4261
|
var packageRoot = resolve2(dirname6(fileURLToPath(import.meta.url)), "..");
|
|
@@ -4352,6 +4355,14 @@ async function runMediaServer() {
|
|
|
4352
4355
|
let audioPackets = 0;
|
|
4353
4356
|
let videoBytes = 0;
|
|
4354
4357
|
let audioBytes = 0;
|
|
4358
|
+
const events = [];
|
|
4359
|
+
const samples = [];
|
|
4360
|
+
const record = (event, detail) => {
|
|
4361
|
+
if (events.push({ at: Date.now(), event, detail }) > eventHistory) events.shift();
|
|
4362
|
+
process.stderr.write(`[desktop-stream] ${event}${detail ? `: ${detail}` : ""}
|
|
4363
|
+
`);
|
|
4364
|
+
};
|
|
4365
|
+
const transportOf = (viewer) => viewer.fallback ? "websocket" : viewer.rtc?.connected ? "webrtc" : "negotiating";
|
|
4355
4366
|
const ladder = () => qualityLadder(activeProfile);
|
|
4356
4367
|
const socketViewers = () => [...viewers.values()].filter((viewer) => viewer.fallback);
|
|
4357
4368
|
const profile = () => {
|
|
@@ -4389,11 +4400,13 @@ async function runMediaServer() {
|
|
|
4389
4400
|
const { socket } = viewer;
|
|
4390
4401
|
if (!viewer.fallback || socket.readyState !== WebSocket2.OPEN) continue;
|
|
4391
4402
|
if (socket.bufferedAmount > maximumBufferedBytes) {
|
|
4403
|
+
record("viewer dropped", `socket holds ${Math.round(socket.bufferedAmount / 1024)} KiB unsent`);
|
|
4392
4404
|
socket.close(1013, "stream client is too slow");
|
|
4393
4405
|
continue;
|
|
4394
4406
|
}
|
|
4395
4407
|
if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
|
|
4396
4408
|
viewer.waitingForKeyframe = true;
|
|
4409
|
+
viewer.withheldFrames += 1;
|
|
4397
4410
|
continue;
|
|
4398
4411
|
}
|
|
4399
4412
|
if (keyframe2) viewer.waitingForKeyframe = false;
|
|
@@ -4454,6 +4467,7 @@ async function runMediaServer() {
|
|
|
4454
4467
|
const env = { ...process.env, DISPLAY: display };
|
|
4455
4468
|
const videoEncoder = superviseEncoder({
|
|
4456
4469
|
label: "video",
|
|
4470
|
+
log: (message) => record("video encoder", message),
|
|
4457
4471
|
// Initial encoder work can leave its first image older than the outgoing stream.
|
|
4458
4472
|
warmupFrames: 2,
|
|
4459
4473
|
env,
|
|
@@ -4556,6 +4570,7 @@ async function runMediaServer() {
|
|
|
4556
4570
|
});
|
|
4557
4571
|
const audioEncoder = superviseEncoder({
|
|
4558
4572
|
label: "audio",
|
|
4573
|
+
log: (message) => record("audio encoder", message),
|
|
4559
4574
|
env,
|
|
4560
4575
|
onPacket: onAudioPacket,
|
|
4561
4576
|
joinable: () => true,
|
|
@@ -4614,14 +4629,14 @@ async function runMediaServer() {
|
|
|
4614
4629
|
if (!videoWanted) {
|
|
4615
4630
|
videoWanted = true;
|
|
4616
4631
|
encoderSignature = signature;
|
|
4632
|
+
record("encoder", signature);
|
|
4617
4633
|
void videoEncoder.start();
|
|
4618
4634
|
return false;
|
|
4619
4635
|
}
|
|
4620
4636
|
if (signature === encoderSignature) return false;
|
|
4621
4637
|
encoderSignature = signature;
|
|
4622
4638
|
lastSwapAt = Date.now();
|
|
4623
|
-
|
|
4624
|
-
`);
|
|
4639
|
+
record("encoder", signature);
|
|
4625
4640
|
void videoEncoder.replace();
|
|
4626
4641
|
return true;
|
|
4627
4642
|
};
|
|
@@ -4635,9 +4650,11 @@ async function runMediaServer() {
|
|
|
4635
4650
|
void audioEncoder.stop();
|
|
4636
4651
|
}, 5e3);
|
|
4637
4652
|
};
|
|
4638
|
-
const setRung = (next) => {
|
|
4653
|
+
const setRung = (next, reason) => {
|
|
4639
4654
|
if (next === rung) return;
|
|
4640
4655
|
const now = Date.now();
|
|
4656
|
+
const quality = ladder()[next];
|
|
4657
|
+
record(next > rung ? "quality down" : "quality up", `rung ${rung} \u2192 ${next} (${quality.kbps} kbps cap, crf ${quality.crf}): ${reason}`);
|
|
4641
4658
|
if (next > rung) {
|
|
4642
4659
|
if (now - lastDescentAt > 1e4) descents += 1;
|
|
4643
4660
|
lastDescentAt = now;
|
|
@@ -4665,20 +4682,37 @@ async function runMediaServer() {
|
|
|
4665
4682
|
blockedUntil = 0;
|
|
4666
4683
|
};
|
|
4667
4684
|
const stableRttWindows = /* @__PURE__ */ new WeakMap();
|
|
4668
|
-
const
|
|
4685
|
+
const latestRemb = (viewer) => viewer.rembs[viewer.rembs.length - 1];
|
|
4686
|
+
const freshRemb = (viewer, now) => {
|
|
4687
|
+
const remb = latestRemb(viewer);
|
|
4688
|
+
return remb && now - remb.at < 2e3 ? remb.kbps : void 0;
|
|
4689
|
+
};
|
|
4669
4690
|
const rembBelowSent = (viewer, now) => {
|
|
4670
4691
|
const remb = freshRemb(viewer, now);
|
|
4692
|
+
const earlier = [...viewer.rembs].reverse().find((sample) => sample.at <= now - 2e3);
|
|
4671
4693
|
const sent = Math.min(...recentVideoKbps);
|
|
4672
|
-
return remb !== void 0 && sent > 500 && remb < sent * 0.9;
|
|
4694
|
+
return remb !== void 0 && earlier !== void 0 && remb <= earlier.kbps && sent > 500 && remb < sent * 0.9;
|
|
4673
4695
|
};
|
|
4674
|
-
const
|
|
4696
|
+
const signals = (viewer, stats) => {
|
|
4697
|
+
const now = Date.now();
|
|
4698
|
+
if (viewer.fallback) {
|
|
4699
|
+
return [
|
|
4700
|
+
...queueMs(viewer) > congestedBacklogMs ? [`probe queue ${Math.round(queueMs(viewer))} ms`] : [],
|
|
4701
|
+
...stats.decodeQueue > congestedDecodeQueue ? [`decode queue ${stats.decodeQueue}`] : []
|
|
4702
|
+
];
|
|
4703
|
+
}
|
|
4675
4704
|
const stableSince = stableRttWindows.get(viewer)?.since;
|
|
4676
|
-
return
|
|
4705
|
+
return [
|
|
4706
|
+
...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`] : [],
|
|
4707
|
+
...stats.videoFps < fps * 0.6 && viewer.lowFpsReports >= 2 ? [`decoded ${stats.videoFps.toFixed(0)} fps`] : [],
|
|
4708
|
+
...(stats.lossPercent ?? 0) > 3 ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
|
|
4709
|
+
...(stats.jitterBufferMs ?? 0) > 80 ? [`jitter buffer ${(stats.jitterBufferMs ?? 0).toFixed(0)} ms`] : []
|
|
4710
|
+
];
|
|
4677
4711
|
};
|
|
4678
4712
|
const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
|
|
4679
4713
|
const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 || stats.videoFps < fps * 0.25 && viewer.lowFpsReports >= 2);
|
|
4680
4714
|
let lastSevereDescentAt = 0;
|
|
4681
|
-
const descendSevere = (now) => {
|
|
4715
|
+
const descendSevere = (now, trigger) => {
|
|
4682
4716
|
comfortableSeconds = 0;
|
|
4683
4717
|
congestedSeconds = 0;
|
|
4684
4718
|
if (!adaptive || now - lastSevereDescentAt < 1e3) return;
|
|
@@ -4690,7 +4724,10 @@ async function runMediaServer() {
|
|
|
4690
4724
|
});
|
|
4691
4725
|
const rungs = ladder();
|
|
4692
4726
|
const estimatedRung = estimates.length ? rungs.findIndex((quality) => quality.kbps <= Math.min(...estimates) * 0.8) : rung + 2;
|
|
4693
|
-
setRung(
|
|
4727
|
+
setRung(
|
|
4728
|
+
Math.min(rungs.length - 1, Math.max(rung + 2, estimatedRung < 0 ? rungs.length - 1 : estimatedRung)),
|
|
4729
|
+
`severe: ${trigger}; link estimates ${estimates.length ? `${estimates.map(Math.round).join("/")} kbps` : "none"}`
|
|
4730
|
+
);
|
|
4694
4731
|
};
|
|
4695
4732
|
const control = setInterval(() => {
|
|
4696
4733
|
encoderFps = videoFrames - framesAtTick;
|
|
@@ -4708,6 +4745,7 @@ async function runMediaServer() {
|
|
|
4708
4745
|
starvedSeconds += 1;
|
|
4709
4746
|
if (starvedSeconds >= 5 && preset !== "ultrafast") {
|
|
4710
4747
|
preset = "ultrafast";
|
|
4748
|
+
record("encoder starved", `${encoderFps} fps for ${starvedSeconds}s; preset \u2192 ultrafast`);
|
|
4711
4749
|
syncEncoder();
|
|
4712
4750
|
}
|
|
4713
4751
|
return;
|
|
@@ -4717,26 +4755,27 @@ async function runMediaServer() {
|
|
|
4717
4755
|
if (encoderHealthyTicks < 3) return;
|
|
4718
4756
|
if (preset !== basePreset && ++presetHealthySeconds >= 120) {
|
|
4719
4757
|
preset = basePreset;
|
|
4758
|
+
record("encoder recovered", `preset \u2192 ${basePreset}`);
|
|
4720
4759
|
syncEncoder();
|
|
4721
4760
|
}
|
|
4722
4761
|
if (!adaptive) {
|
|
4723
|
-
if (rung !== 0) setRung(0);
|
|
4762
|
+
if (rung !== 0) setRung(0, "quality pinned");
|
|
4724
4763
|
return;
|
|
4725
4764
|
}
|
|
4726
4765
|
const now = Date.now();
|
|
4727
4766
|
const reporting = [...viewers.values()].filter((viewer) => viewer.stats !== void 0 && viewer.stats.videoPacketsPerSecond !== 0 && now - viewer.stats.at < 3e3 && now - viewer.connectedAt > 2500);
|
|
4728
|
-
cleanTransportSeconds = reporting.length === viewers.size && reporting.length > 0 && reporting.every((viewer) => !viewer.fallback && viewer.
|
|
4767
|
+
cleanTransportSeconds = reporting.length === viewers.size && reporting.length > 0 && reporting.every((viewer) => !viewer.fallback && viewer.rttFloorMs <= viewer.rtcBaselineMs + 20 && (viewer.stats.lossPercent ?? 0) === 0 && (viewer.stats.nacks ?? 0) === 0 && (viewer.stats.jitterBufferMs ?? 0) <= 20 && viewer.stats.videoFps >= fps * 0.95) ? cleanTransportSeconds + 1 : 0;
|
|
4729
4768
|
if (reporting.length > 0 && reporting.every((viewer) => severe(viewer, viewer.stats))) {
|
|
4730
|
-
descendSevere(now);
|
|
4769
|
+
descendSevere(now, reporting.map((viewer) => `${(viewer.stats.lossPercent ?? 0).toFixed(1)}% loss, ${viewer.stats.videoFps.toFixed(0)} fps decoded`).join(" | "));
|
|
4731
4770
|
return;
|
|
4732
4771
|
}
|
|
4733
|
-
const votes = reporting.flatMap((viewer) => viewer.everHealthy || now - viewer.connectedAt > 15e3 ? [
|
|
4772
|
+
const votes = reporting.flatMap((viewer) => viewer.everHealthy || now - viewer.connectedAt > 15e3 ? [signals(viewer, viewer.stats)] : []);
|
|
4734
4773
|
if (votes.length === 0) return;
|
|
4735
|
-
const strugglers = votes.filter(
|
|
4774
|
+
const strugglers = votes.filter((vote) => vote.length > 0).length;
|
|
4736
4775
|
if (strugglers === votes.length) {
|
|
4737
4776
|
comfortableSeconds = 0;
|
|
4738
4777
|
congestedSeconds += 1;
|
|
4739
|
-
if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1));
|
|
4778
|
+
if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1), votes.map((vote) => vote.join(", ")).join(" | "));
|
|
4740
4779
|
return;
|
|
4741
4780
|
}
|
|
4742
4781
|
if (strugglers > 0) {
|
|
@@ -4745,7 +4784,7 @@ async function runMediaServer() {
|
|
|
4745
4784
|
return;
|
|
4746
4785
|
}
|
|
4747
4786
|
congestedSeconds = Math.max(0, congestedSeconds - 0.5);
|
|
4748
|
-
if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.
|
|
4787
|
+
if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.rttFloorMs > viewer.rtcBaselineMs + 20 || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
|
|
4749
4788
|
comfortableSeconds = 0;
|
|
4750
4789
|
return;
|
|
4751
4790
|
}
|
|
@@ -4755,7 +4794,7 @@ async function runMediaServer() {
|
|
|
4755
4794
|
if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
|
|
4756
4795
|
const target = Math.max(0, rung - 1);
|
|
4757
4796
|
if (target === blockedRung && now < blockedUntil) return;
|
|
4758
|
-
setRung(target);
|
|
4797
|
+
setRung(target, `${comfortableSeconds}s comfortable, ${cleanTransportSeconds}s clean transport`);
|
|
4759
4798
|
}, 1e3);
|
|
4760
4799
|
control.unref();
|
|
4761
4800
|
const probe = setInterval(() => {
|
|
@@ -4779,46 +4818,61 @@ async function runMediaServer() {
|
|
|
4779
4818
|
encoderPercent: Math.max(0, Math.round((encoder - cpuSample.encoder) * 100 / elapsed))
|
|
4780
4819
|
};
|
|
4781
4820
|
cpuSample = { at: now, server, encoder };
|
|
4782
|
-
const { quality, profile: videoProfile, pixelFormat } = encoderParams();
|
|
4821
|
+
const { quality, profile: videoProfile, pixelFormat, keyframeMs } = encoderParams();
|
|
4822
|
+
const encoderState = {
|
|
4823
|
+
profile: activeProfile,
|
|
4824
|
+
codecProfile: videoProfile,
|
|
4825
|
+
pixelFormat,
|
|
4826
|
+
color: colorMode,
|
|
4827
|
+
rung,
|
|
4828
|
+
rungs: ladder().length,
|
|
4829
|
+
kbpsCap: quality.kbps,
|
|
4830
|
+
crf: quality.crf,
|
|
4831
|
+
preset,
|
|
4832
|
+
keyframeMs,
|
|
4833
|
+
fps: encoderFps,
|
|
4834
|
+
kbps: videoKbps,
|
|
4835
|
+
adaptive
|
|
4836
|
+
};
|
|
4837
|
+
const cpuState = { ...cpu, load: Math.round(loadavg()[0] * 100) / 100, cores: availableParallelism() };
|
|
4838
|
+
const sample = {
|
|
4839
|
+
at: now,
|
|
4840
|
+
encoder: { rung, crf: quality.crf, kbpsCap: quality.kbps, keyframeMs, preset, fps: encoderFps, kbps: videoKbps },
|
|
4841
|
+
cpu: cpuState,
|
|
4842
|
+
viewers: []
|
|
4843
|
+
};
|
|
4783
4844
|
for (const viewer of viewers.values()) {
|
|
4784
4845
|
if (viewer.socket.readyState !== WebSocket2.OPEN) continue;
|
|
4785
4846
|
const pacer = viewer.rtc?.snapshot();
|
|
4786
|
-
const
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
cpu: { ...cpu, load: Math.round(loadavg()[0] * 100) / 100, cores: availableParallelism() },
|
|
4803
|
-
viewer: {
|
|
4804
|
-
budgetKbps: pacer?.budgetKbps ?? 0,
|
|
4805
|
-
queueMs: pacer?.queueMs ?? Math.round(queueMs(viewer)),
|
|
4806
|
-
queuedPackets: pacer?.queuedPackets ?? 0,
|
|
4807
|
-
staleEvents: pacer?.staleEvents ?? 0,
|
|
4808
|
-
droppedFrames: pacer?.droppedFrames ?? 0,
|
|
4809
|
-
repairsSent: pacer?.repairsSent ?? 0,
|
|
4810
|
-
repairsSkipped: pacer?.repairsSkipped ?? 0,
|
|
4811
|
-
duplicatesSent: pacer?.duplicatesSent ?? 0,
|
|
4812
|
-
protecting: pacer?.protecting ?? false,
|
|
4813
|
-
rembKbps: pacer?.rembKbps ?? null,
|
|
4814
|
-
sentKbps: pacer?.sentKbps ?? 0
|
|
4815
|
-
}
|
|
4847
|
+
const viewerState = {
|
|
4848
|
+
budgetKbps: pacer?.budgetKbps ?? 0,
|
|
4849
|
+
queueMs: pacer?.queueMs ?? Math.round(queueMs(viewer)),
|
|
4850
|
+
queuedPackets: pacer?.queuedPackets ?? 0,
|
|
4851
|
+
staleEvents: pacer?.staleEvents ?? 0,
|
|
4852
|
+
droppedFrames: pacer?.droppedFrames ?? 0,
|
|
4853
|
+
repairsSent: pacer?.repairsSent ?? 0,
|
|
4854
|
+
repairsSkipped: pacer?.repairsSkipped ?? 0,
|
|
4855
|
+
duplicatesSent: pacer?.duplicatesSent ?? 0,
|
|
4856
|
+
protecting: pacer?.protecting ?? false,
|
|
4857
|
+
rembKbps: pacer?.rembKbps ?? null,
|
|
4858
|
+
sentKbps: pacer?.sentKbps ?? 0,
|
|
4859
|
+
transport: transportOf(viewer),
|
|
4860
|
+
bufferedBytes: viewer.socket.bufferedAmount,
|
|
4861
|
+
withheldFrames: viewer.withheldFrames,
|
|
4862
|
+
waitingForKeyframe: viewer.fallback && viewer.waitingForKeyframe
|
|
4816
4863
|
};
|
|
4864
|
+
sample.viewers.push({ ...viewerState, sentKbps: Math.round((viewerState.sentKbps + (viewer.lastSentKbps ?? viewerState.sentKbps)) / 2), path: viewer.rtc?.path ?? null, stats: viewer.stats ?? null });
|
|
4865
|
+
viewer.lastSentKbps = viewerState.sentKbps;
|
|
4866
|
+
const message = { type: "telemetry", encoder: encoderState, cpu: cpuState, viewer: viewerState, events: events.slice(-5) };
|
|
4817
4867
|
viewer.socket.send(JSON.stringify(message));
|
|
4818
4868
|
}
|
|
4869
|
+
if (now - (samples[samples.length - 1]?.at ?? 0) < 900) return;
|
|
4870
|
+
if (samples.push(sample) > sampleHistory) samples.shift();
|
|
4871
|
+
if (telemetryLog) process.stderr.write(`[desktop-stream] sample ${JSON.stringify(sample)}
|
|
4872
|
+
`);
|
|
4819
4873
|
}, telemetryIntervalMs);
|
|
4820
4874
|
telemetry.unref();
|
|
4821
|
-
const requestKeyframe = (joinedAt, requestingViewer) => {
|
|
4875
|
+
const requestKeyframe = (reason, joinedAt, requestingViewer) => {
|
|
4822
4876
|
const now = Date.now();
|
|
4823
4877
|
if (!videoWanted || viewers.size === 0 || videoEncoder.replacing() || lastKeyframeAt < now - videoEncoder.activeForMs() || joinedAt !== void 0 && lastKeyframeAt > joinedAt) return;
|
|
4824
4878
|
const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
|
|
@@ -4826,12 +4880,13 @@ async function runMediaServer() {
|
|
|
4826
4880
|
if (wait > 0) {
|
|
4827
4881
|
if (joinedAt !== void 0) {
|
|
4828
4882
|
clearTimeout(joinKeyframe);
|
|
4829
|
-
joinKeyframe = setTimeout(() => requestKeyframe(joinedAt), wait);
|
|
4883
|
+
joinKeyframe = setTimeout(() => requestKeyframe(reason, joinedAt), wait);
|
|
4830
4884
|
}
|
|
4831
4885
|
return;
|
|
4832
4886
|
}
|
|
4833
4887
|
if (!startup && encoderParams().keyframeMs - (now - lastKeyframeAt) <= 350) return;
|
|
4834
4888
|
lastSwapAt = now;
|
|
4889
|
+
record("keyframe", reason);
|
|
4835
4890
|
void videoEncoder.replace();
|
|
4836
4891
|
};
|
|
4837
4892
|
const enterFallback = (viewer, reason) => {
|
|
@@ -4841,9 +4896,8 @@ async function runMediaServer() {
|
|
|
4841
4896
|
viewer.input.release();
|
|
4842
4897
|
viewer.rtc?.close();
|
|
4843
4898
|
viewer.rtc = void 0;
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
if (!syncEncoder()) requestKeyframe();
|
|
4899
|
+
record("fallback to socket", reason);
|
|
4900
|
+
if (!syncEncoder()) requestKeyframe("socket fallback");
|
|
4847
4901
|
};
|
|
4848
4902
|
const optional = (value, max) => value === void 0 ? void 0 : clamp(value, 0, max);
|
|
4849
4903
|
const handleMessage = (viewer, message, reply) => {
|
|
@@ -4883,8 +4937,10 @@ async function runMediaServer() {
|
|
|
4883
4937
|
viewer.lowFpsReports = Math.min(2, viewer.lowFpsReports + 1);
|
|
4884
4938
|
lowFpsObservationAt.set(viewer, now);
|
|
4885
4939
|
}
|
|
4886
|
-
|
|
4887
|
-
|
|
4940
|
+
viewer.rttSamples = [...viewer.rttSamples.filter((sample) => now - sample.at < 1e3), { at: now, ms: viewer.stats.rttMs }];
|
|
4941
|
+
viewer.rttFloorMs = Math.min(...viewer.rttSamples.map((sample) => sample.ms).filter((ms) => ms > 0), viewer.stats.rttMs);
|
|
4942
|
+
if (!viewer.fallback && viewer.rttFloorMs > 0) viewer.rtcBaselineMs = Math.min(viewer.rtcBaselineMs, viewer.rttFloorMs);
|
|
4943
|
+
const rtt = viewer.rttFloorMs;
|
|
4888
4944
|
let window = stableRttWindows.get(viewer);
|
|
4889
4945
|
const stableNetwork = !viewer.fallback && rtt > viewer.rtcBaselineMs + 20 && (viewer.stats.videoPacketsPerSecond ?? 0) > 0 && (viewer.stats.lossPercent ?? 0) <= 1 && (viewer.stats.jitterBufferMs ?? 0) <= 80;
|
|
4890
4946
|
if (stableNetwork && viewer.stats.videoFps >= fps * 0.9) {
|
|
@@ -4919,7 +4975,7 @@ async function runMediaServer() {
|
|
|
4919
4975
|
}
|
|
4920
4976
|
if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
|
|
4921
4977
|
if (viewer.stats.videoFps >= fps * 0.8) viewer.everHealthy = true;
|
|
4922
|
-
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);
|
|
4978
|
+
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");
|
|
4923
4979
|
return;
|
|
4924
4980
|
}
|
|
4925
4981
|
case "answer":
|
|
@@ -4933,7 +4989,7 @@ async function runMediaServer() {
|
|
|
4933
4989
|
enterFallback(viewer, message.reason);
|
|
4934
4990
|
return;
|
|
4935
4991
|
case "keyframe":
|
|
4936
|
-
if (!viewer.fallback) requestKeyframe(void 0, viewer);
|
|
4992
|
+
if (!viewer.fallback) requestKeyframe("viewer watchdog", void 0, viewer);
|
|
4937
4993
|
return;
|
|
4938
4994
|
case "theme":
|
|
4939
4995
|
applyDesktopTheme(message.theme);
|
|
@@ -4955,7 +5011,7 @@ async function runMediaServer() {
|
|
|
4955
5011
|
};
|
|
4956
5012
|
const httpServer = createServer2((request, response) => {
|
|
4957
5013
|
const url = new URL(request.url || "/", "http://localhost");
|
|
4958
|
-
if (url.pathname === "/health") {
|
|
5014
|
+
if (url.pathname === "/health" || url.pathname === "/telemetry") {
|
|
4959
5015
|
const all = [...viewers.values()];
|
|
4960
5016
|
const memory = process.memoryUsage();
|
|
4961
5017
|
const { keyframeMs, profile: videoProfile, pixelFormat, quality } = encoderParams();
|
|
@@ -4964,7 +5020,7 @@ async function runMediaServer() {
|
|
|
4964
5020
|
version: COMPUTER_USE_VERSION,
|
|
4965
5021
|
clients: viewers.size,
|
|
4966
5022
|
webrtc,
|
|
4967
|
-
transports: all.map(
|
|
5023
|
+
transports: all.map(transportOf),
|
|
4968
5024
|
requests: all.map((viewer) => viewer.request),
|
|
4969
5025
|
encoder: videoEncoder.running(),
|
|
4970
5026
|
audioEncoder: audioEncoder.running(),
|
|
@@ -4998,7 +5054,8 @@ async function runMediaServer() {
|
|
|
4998
5054
|
heapUsed: memory.heapUsed,
|
|
4999
5055
|
width,
|
|
5000
5056
|
height,
|
|
5001
|
-
fps
|
|
5057
|
+
fps,
|
|
5058
|
+
...url.pathname === "/telemetry" ? { events, samples } : {}
|
|
5002
5059
|
}));
|
|
5003
5060
|
return;
|
|
5004
5061
|
}
|
|
@@ -5038,6 +5095,7 @@ async function runMediaServer() {
|
|
|
5038
5095
|
request,
|
|
5039
5096
|
fallback: false,
|
|
5040
5097
|
waitingForKeyframe: true,
|
|
5098
|
+
withheldFrames: 0,
|
|
5041
5099
|
everHealthy: false,
|
|
5042
5100
|
lowFpsReports: 0,
|
|
5043
5101
|
connectedAt: Date.now(),
|
|
@@ -5046,17 +5104,20 @@ async function runMediaServer() {
|
|
|
5046
5104
|
probeAnswered: false,
|
|
5047
5105
|
baselineMs: Number.POSITIVE_INFINITY,
|
|
5048
5106
|
rtcBaselineMs: Number.POSITIVE_INFINITY,
|
|
5049
|
-
protectBlockedUntil: 0
|
|
5107
|
+
protectBlockedUntil: 0,
|
|
5108
|
+
rembs: [],
|
|
5109
|
+
rttFloorMs: 0,
|
|
5110
|
+
rttSamples: []
|
|
5050
5111
|
};
|
|
5051
5112
|
if (viewers.size === 0) resetLadder(request);
|
|
5052
5113
|
if (request.profile !== activeProfile || request.adaptive !== adaptive) {
|
|
5053
|
-
|
|
5054
|
-
`);
|
|
5114
|
+
record("viewer request", `${request.profile} profile, ${request.adaptive ? "adaptive" : "pinned"} quality`);
|
|
5055
5115
|
activeProfile = request.profile;
|
|
5056
5116
|
adaptive = request.adaptive;
|
|
5057
5117
|
if (!adaptive) rung = 0;
|
|
5058
5118
|
}
|
|
5059
5119
|
viewers.set(socket, viewer);
|
|
5120
|
+
record("viewer connected", `${request.transport} requested, ${viewers.size} connected`);
|
|
5060
5121
|
const reply = (message) => {
|
|
5061
5122
|
if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
|
|
5062
5123
|
};
|
|
@@ -5072,23 +5133,26 @@ async function runMediaServer() {
|
|
|
5072
5133
|
candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
|
|
5073
5134
|
message: (message, answer) => handleMessage(viewer, message, answer),
|
|
5074
5135
|
keyframe: (reason) => {
|
|
5075
|
-
if (reason === "stale") descendSevere(Date.now());
|
|
5076
|
-
requestKeyframe(void 0, viewer);
|
|
5136
|
+
if (reason === "stale") descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
|
|
5137
|
+
requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
|
|
5077
5138
|
},
|
|
5078
5139
|
remb: (bitsPerSecond) => {
|
|
5079
|
-
viewer.
|
|
5140
|
+
if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
|
|
5141
|
+
},
|
|
5142
|
+
connected: () => {
|
|
5143
|
+
record("webrtc connected", viewer.rtc?.path ?? void 0);
|
|
5144
|
+
requestKeyframe("viewer joined", Date.now());
|
|
5080
5145
|
},
|
|
5081
|
-
connected: () => requestKeyframe(Date.now()),
|
|
5082
5146
|
// A viewer that loses its peer cannot send the key-ups for whatever it
|
|
5083
5147
|
// was holding, and its socket may stay open for a long time yet.
|
|
5084
5148
|
disconnected: (state) => {
|
|
5149
|
+
record(`webrtc ${state}`);
|
|
5085
5150
|
viewer.input.release();
|
|
5086
5151
|
if (state === "failed") reply({ type: "webrtc-failed" });
|
|
5087
5152
|
}
|
|
5088
5153
|
});
|
|
5089
5154
|
} catch (error) {
|
|
5090
|
-
|
|
5091
|
-
`);
|
|
5155
|
+
record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
5092
5156
|
}
|
|
5093
5157
|
}
|
|
5094
5158
|
if (!viewer.rtc) enterFallback(viewer, offerWebRtc ? "webrtc unavailable" : "websocket requested");
|
|
@@ -5102,6 +5166,7 @@ async function runMediaServer() {
|
|
|
5102
5166
|
viewer.rtc?.close();
|
|
5103
5167
|
viewer.input.close();
|
|
5104
5168
|
viewers.delete(socket);
|
|
5169
|
+
record("viewer disconnected", `${viewers.size} connected`);
|
|
5105
5170
|
if (viewers.size > 0) {
|
|
5106
5171
|
syncEncoder();
|
|
5107
5172
|
return;
|
package/dist/stream-viewer.js
CHANGED
|
@@ -24,7 +24,7 @@ function validServerMessage(value) {
|
|
|
24
24
|
case "config":
|
|
25
25
|
return finite(value.protocolVersion) && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
|
|
26
26
|
case "telemetry":
|
|
27
|
-
return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer);
|
|
27
|
+
return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
|
|
28
28
|
default:
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
@@ -129,6 +129,13 @@ var telemetry;
|
|
|
129
129
|
var route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
|
|
130
130
|
var state = {};
|
|
131
131
|
globalThis.__replicasDesktop = state;
|
|
132
|
+
var viewerEvents = [];
|
|
133
|
+
state.events = viewerEvents;
|
|
134
|
+
var decoderResets = 0;
|
|
135
|
+
var note = (event, detail) => {
|
|
136
|
+
if (viewerEvents.push({ at: Date.now(), event, detail }) > 50) viewerEvents.shift();
|
|
137
|
+
};
|
|
138
|
+
var describeEvents = (list) => list.map((entry) => `${new Date(entry.at).toLocaleTimeString([], { hour12: false })} ${entry.event}${entry.detail ? ` (${entry.detail})` : ""}`).join(" \xB7 ");
|
|
132
139
|
var showStatus = (message) => {
|
|
133
140
|
statusText.textContent = message;
|
|
134
141
|
status.classList.add("visible");
|
|
@@ -267,6 +274,7 @@ var configureVideo = async (next, preferHardware = true) => {
|
|
|
267
274
|
if (videoDecoder !== decoder) return;
|
|
268
275
|
videoDecoder = void 0;
|
|
269
276
|
waitingForKeyframe = true;
|
|
277
|
+
note("decoder error", error.message);
|
|
270
278
|
showStatus(`recovering video: ${error.message}`);
|
|
271
279
|
if (config) void configureVideo(config, false).catch((reason) => showStatus(describe(reason)));
|
|
272
280
|
}
|
|
@@ -335,6 +343,7 @@ var handleBinary = (data) => {
|
|
|
335
343
|
videoDecoder.reset();
|
|
336
344
|
videoDecoder.configure(activeVideoConfig);
|
|
337
345
|
waitingForKeyframe = true;
|
|
346
|
+
state.decoderResets = ++decoderResets;
|
|
338
347
|
}
|
|
339
348
|
if (waitingForKeyframe && !key) return;
|
|
340
349
|
waitingForKeyframe = false;
|
|
@@ -344,6 +353,7 @@ var handleBinary = (data) => {
|
|
|
344
353
|
videoDecoder.reset();
|
|
345
354
|
videoDecoder.configure(activeVideoConfig);
|
|
346
355
|
waitingForKeyframe = true;
|
|
356
|
+
state.decoderResets = ++decoderResets;
|
|
347
357
|
}
|
|
348
358
|
};
|
|
349
359
|
var unlockAudio = () => {
|
|
@@ -367,6 +377,7 @@ var useFallback = (reason, failed = false) => {
|
|
|
367
377
|
if (transport === "websocket" || !config) return;
|
|
368
378
|
closePeer();
|
|
369
379
|
if (failed) webrtcSkips = webrtcSkipsAfterFailure;
|
|
380
|
+
note("fallback to socket", reason);
|
|
370
381
|
transport = "websocket";
|
|
371
382
|
transportReason = reason;
|
|
372
383
|
route = { ...route, path: "proxy", codec: config.videoCodec };
|
|
@@ -412,8 +423,12 @@ var negotiate = async (sdp) => {
|
|
|
412
423
|
if (peer !== pc) return;
|
|
413
424
|
if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
|
|
414
425
|
};
|
|
426
|
+
pc.oniceconnectionstatechange = () => {
|
|
427
|
+
if (peer === pc) note("ice", pc.iceConnectionState);
|
|
428
|
+
};
|
|
415
429
|
pc.onconnectionstatechange = () => {
|
|
416
430
|
if (peer !== pc) return;
|
|
431
|
+
note("webrtc", pc.connectionState);
|
|
417
432
|
if (pc.connectionState === "connected") {
|
|
418
433
|
transport = "webrtc";
|
|
419
434
|
transportReason = "";
|
|
@@ -491,7 +506,7 @@ var handleServerMessage = (message, viaSocket) => {
|
|
|
491
506
|
useFallback("recent WebRTC failure");
|
|
492
507
|
} else if (!transport) {
|
|
493
508
|
showStatus("Establishing connection\u2026");
|
|
494
|
-
webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not
|
|
509
|
+
webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not decode video in time", true), webrtcFrameTimeoutMs);
|
|
495
510
|
}
|
|
496
511
|
};
|
|
497
512
|
var handleServerText = (data, viaSocket) => {
|
|
@@ -516,6 +531,7 @@ var connect = () => {
|
|
|
516
531
|
socket = nextSocket;
|
|
517
532
|
nextSocket.addEventListener("open", () => {
|
|
518
533
|
if (socket !== nextSocket) return;
|
|
534
|
+
note("socket open");
|
|
519
535
|
actionId = 0;
|
|
520
536
|
reconnectAttempt = 0;
|
|
521
537
|
sendSocket({ type: "theme", theme: document.documentElement.dataset.theme === "light" ? "light" : "dark" });
|
|
@@ -534,8 +550,9 @@ var connect = () => {
|
|
|
534
550
|
transport = void 0;
|
|
535
551
|
announceTransport();
|
|
536
552
|
reconnectAttempt += 1;
|
|
537
|
-
const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5));
|
|
538
|
-
|
|
553
|
+
const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5)) * (0.85 + Math.random() * 0.3);
|
|
554
|
+
note("socket closed", `reconnect in ${Math.round(delay)} ms`);
|
|
555
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
539
556
|
});
|
|
540
557
|
nextSocket.addEventListener("error", () => nextSocket.close());
|
|
541
558
|
};
|
|
@@ -561,11 +578,6 @@ var flushPointer = () => {
|
|
|
561
578
|
var frameCallback = 0;
|
|
562
579
|
var observeFrame = () => {
|
|
563
580
|
frameCallback = screen.requestVideoFrameCallback((_now, metadata) => {
|
|
564
|
-
const stream = screen.srcObject;
|
|
565
|
-
if (webrtcDeadline && transport === "webrtc" && stream instanceof MediaStream && peer?.getReceivers().some(({ track }) => stream.getVideoTracks().includes(track))) {
|
|
566
|
-
clearTimeout(webrtcDeadline);
|
|
567
|
-
webrtcDeadline = void 0;
|
|
568
|
-
}
|
|
569
581
|
renderedFrames += 1;
|
|
570
582
|
presentedFrames += lastPresentedCount ? Math.max(0, metadata.presentedFrames - lastPresentedCount) : 1;
|
|
571
583
|
lastPresentedCount = metadata.presentedFrames;
|
|
@@ -654,8 +666,11 @@ var updateDiagnostics = (stats) => {
|
|
|
654
666
|
`${transport ?? "connecting"}${transportReason ? ` (${transportReason})` : ""} \xB7 ${route.path || "\u2014"} \xB7 ${route.codec || config?.videoCodec || ""} \xB7 ${view} view`,
|
|
655
667
|
`source ${dims.source} \xB7 decoded ${dims.decoded || "\u2014"} \xB7 css ${dims.css} \xB7 device ${dims.physical}`,
|
|
656
668
|
`${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}`,
|
|
657
|
-
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 ${encoder.fps} fps ${encoder.kbps} kbps \xB7 ${encoder.preset}` : "",
|
|
658
|
-
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"}` : ""
|
|
669
|
+
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}` : "",
|
|
670
|
+
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"}` : "",
|
|
671
|
+
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` : "",
|
|
672
|
+
telemetry?.events.length ? `server events: ${describeEvents(telemetry.events)}` : "",
|
|
673
|
+
viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
|
|
659
674
|
].filter(Boolean).join("\n");
|
|
660
675
|
};
|
|
661
676
|
var inboundOf = (kind) => (stat) => stat.type === "inbound-rtp" && "kind" in stat && stat.kind === kind;
|
|
@@ -691,6 +706,11 @@ var reportWebRtcStats = async (pc, stats) => {
|
|
|
691
706
|
jitterBufferEmittedCount: video?.jitterBufferEmittedCount ?? 0,
|
|
692
707
|
audioPackets: audio?.packetsReceived ?? 0
|
|
693
708
|
};
|
|
709
|
+
if (counters.framesDecoded > (inbound?.framesDecoded ?? 0)) {
|
|
710
|
+
lastFrameAt = now;
|
|
711
|
+
clearTimeout(webrtcDeadline);
|
|
712
|
+
webrtcDeadline = void 0;
|
|
713
|
+
}
|
|
694
714
|
if (!inbound) inboundHistory = [];
|
|
695
715
|
while (inboundHistory.length > 1 && inboundHistory[1].at <= now - 1e3) inboundHistory.shift();
|
|
696
716
|
const oldest = inboundHistory[0];
|
|
@@ -723,8 +743,6 @@ var reportWebRtcStats = async (pc, stats) => {
|
|
|
723
743
|
updateDiagnostics(stats);
|
|
724
744
|
state.framesDropped = video?.framesDropped;
|
|
725
745
|
};
|
|
726
|
-
var watchdogPlaybackFrames = 0;
|
|
727
|
-
var watchdogPlaybackAt = 0;
|
|
728
746
|
setInterval(() => {
|
|
729
747
|
if (transport !== "webrtc") return;
|
|
730
748
|
if (screen.paused && screen.srcObject instanceof MediaStream && screen.srcObject.getVideoTracks().some((track) => track.readyState === "live")) return;
|
|
@@ -733,15 +751,10 @@ setInterval(() => {
|
|
|
733
751
|
useFallback("WebRTC video stopped arriving", true);
|
|
734
752
|
return;
|
|
735
753
|
}
|
|
736
|
-
const quality = screen.getVideoPlaybackQuality();
|
|
737
|
-
const playbackFrames = quality.totalVideoFrames - quality.droppedVideoFrames;
|
|
738
|
-
if (playbackFrames !== watchdogPlaybackFrames) {
|
|
739
|
-
watchdogPlaybackFrames = playbackFrames;
|
|
740
|
-
watchdogPlaybackAt = now;
|
|
741
|
-
}
|
|
742
754
|
if (inputChannel?.readyState !== "open") return;
|
|
743
|
-
if (now -
|
|
755
|
+
if (now - lastFrameAt < 2500 || now - lastKeyframeRequestAt < 1e3) return;
|
|
744
756
|
lastKeyframeRequestAt = now;
|
|
757
|
+
note("keyframe requested", `no frame for ${Math.round(now - lastFrameAt)} ms`);
|
|
745
758
|
sendInput({ type: "keyframe" });
|
|
746
759
|
}, 100);
|
|
747
760
|
setInterval(() => {
|