replicas-computer 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/assets/desktop/start-services.sh +14 -0
- package/dist/index.js +185 -76
- package/dist/stream-viewer.js +228 -58
- package/native/internal/capture/native.go +40 -11
- package/native/main.go +69 -4
- package/native/options.go +1 -1
- package/package.json +1 -1
package/dist/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,9 +3646,13 @@ 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";
|
|
3654
|
+
case "standby":
|
|
3655
|
+
return typeof value.standby === "boolean";
|
|
3631
3656
|
case "ping":
|
|
3632
3657
|
case "pong":
|
|
3633
3658
|
return finite(value.sentAt);
|
|
@@ -4069,13 +4094,14 @@ var WebRtcViewer = class {
|
|
|
4069
4094
|
// src/video-encoder.ts
|
|
4070
4095
|
var captureBinary = resolve2(dirname6(fileURLToPath(import.meta.url)), "..", "dist", "replicas-capture");
|
|
4071
4096
|
function superviseVideoEncoder(options) {
|
|
4072
|
-
const { display: display2, width: width2, height: height2,
|
|
4097
|
+
const { display: display2, width: width2, height: height2, colorMode: colorMode2 } = options;
|
|
4073
4098
|
const env = { ...process.env, DISPLAY: display2 };
|
|
4074
4099
|
let nativeCapture = process.env.REPLICAS_DESKTOP_STREAM_CAPTURE !== "ffmpeg" && existsSync6(captureBinary);
|
|
4075
4100
|
let captureMetrics;
|
|
4076
4101
|
const encoder = superviseEncoder({
|
|
4077
4102
|
label: "video",
|
|
4078
4103
|
command: () => nativeCapture ? captureBinary : "ffmpeg",
|
|
4104
|
+
controllable: (command) => command === captureBinary,
|
|
4079
4105
|
onFailure: (command) => {
|
|
4080
4106
|
if (command !== captureBinary) return;
|
|
4081
4107
|
nativeCapture = false;
|
|
@@ -4098,7 +4124,7 @@ function superviseVideoEncoder(options) {
|
|
|
4098
4124
|
onPacket: options.onPacket,
|
|
4099
4125
|
joinable: (packet) => isKeyframeStart(rtpPayload(packet)),
|
|
4100
4126
|
args: (udpPort, warmupFrames) => {
|
|
4101
|
-
const { quality, keyframeMs, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
|
|
4127
|
+
const { quality, keyframeMs, fps: fps2, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset, preset } = options.params();
|
|
4102
4128
|
const keyframeInterval = Math.max(1, Math.round(fps2 * keyframeMs / 1e3));
|
|
4103
4129
|
if (nativeCapture) return [
|
|
4104
4130
|
"--width",
|
|
@@ -4232,6 +4258,8 @@ function superviseVideoEncoder(options) {
|
|
|
4232
4258
|
});
|
|
4233
4259
|
return {
|
|
4234
4260
|
...encoder,
|
|
4261
|
+
/** Native capture takes keyframe, quality and frame-rate changes over stdin; ffmpeg needs a replacement for each. */
|
|
4262
|
+
controllable: () => nativeCapture,
|
|
4235
4263
|
capture: () => ({
|
|
4236
4264
|
captureBackend: (encoder.command() ?? (nativeCapture ? captureBinary : "ffmpeg")) === captureBinary ? "native" : "ffmpeg",
|
|
4237
4265
|
captureMetrics: captureMetrics?.generation === encoder.generation() && Date.now() - captureMetrics.at <= 2500 ? captureMetrics : void 0
|
|
@@ -4445,6 +4473,8 @@ var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP
|
|
|
4445
4473
|
var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
|
|
4446
4474
|
var basePreset = availableParallelism2() >= 4 ? "superfast" : "ultrafast";
|
|
4447
4475
|
var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "", 10) || 60;
|
|
4476
|
+
var standbyFps = 5;
|
|
4477
|
+
var standbyRung = 5;
|
|
4448
4478
|
var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
|
|
4449
4479
|
var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
|
|
4450
4480
|
var webrtcEnabled = process.env.REPLICAS_DESKTOP_STREAM_WEBRTC !== "0";
|
|
@@ -4475,6 +4505,7 @@ var qualityLadder = (profile) => rungCaps.map((kbps, index) => ({ kbps, crf: pro
|
|
|
4475
4505
|
var fullChromaMinKbps = 8e3;
|
|
4476
4506
|
var fallbackKeyframeMs = 250;
|
|
4477
4507
|
var webrtcStartupRung = 2;
|
|
4508
|
+
var descentMemoryMs = 18e4;
|
|
4478
4509
|
var targetBacklogMs = 120;
|
|
4479
4510
|
var congestedBacklogMs = 250;
|
|
4480
4511
|
var congestedDecodeQueue = 3;
|
|
@@ -4554,7 +4585,8 @@ async function runMediaServer() {
|
|
|
4554
4585
|
let activeProfile = "balanced";
|
|
4555
4586
|
let adaptive = true;
|
|
4556
4587
|
let rung = 0;
|
|
4557
|
-
let descents =
|
|
4588
|
+
let descents = [];
|
|
4589
|
+
let failedRung = -1;
|
|
4558
4590
|
let congestedSeconds = 0;
|
|
4559
4591
|
let comfortableSeconds = 0;
|
|
4560
4592
|
let cleanTransportSeconds = 0;
|
|
@@ -4573,6 +4605,8 @@ async function runMediaServer() {
|
|
|
4573
4605
|
let encoderFps = 0;
|
|
4574
4606
|
let videoWanted = false;
|
|
4575
4607
|
let encoderSignature = "";
|
|
4608
|
+
let encoderDescription = "";
|
|
4609
|
+
let encoderLive = { crf: 0, kbps: 0, fps: 0 };
|
|
4576
4610
|
let encoderStop;
|
|
4577
4611
|
let joinKeyframe;
|
|
4578
4612
|
let lastVideoAt = 0;
|
|
@@ -4601,13 +4635,19 @@ async function runMediaServer() {
|
|
|
4601
4635
|
if (accepted.length === 0) return "main";
|
|
4602
4636
|
return PROFILE_ORDER.find((candidate) => (!requires420 || candidate !== "high444") && accepted.every((set) => set.has(candidate))) ?? "baseline";
|
|
4603
4637
|
};
|
|
4638
|
+
const allStandby = () => viewers.size > 0 && [...viewers.values()].every((viewer) => viewer.standby);
|
|
4639
|
+
const streamFps = () => allStandby() ? Math.min(fps, standbyFps) : fps;
|
|
4604
4640
|
const encoderParams = () => {
|
|
4605
|
-
const quality = ladder()[rung];
|
|
4641
|
+
const quality = ladder()[allStandby() ? Math.max(rung, standbyRung) : rung];
|
|
4642
|
+
const encodeFps = streamFps();
|
|
4606
4643
|
const sockets = socketViewers().length > 0;
|
|
4607
|
-
const
|
|
4644
|
+
const onDemand = videoEncoder.controllable();
|
|
4645
|
+
const keyframeMs = onDemand ? 0 : sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
|
|
4608
4646
|
const videoProfile = profile();
|
|
4609
|
-
const intraRefresh = !sockets;
|
|
4647
|
+
const intraRefresh = !onDemand && !sockets;
|
|
4610
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"}`;
|
|
4611
4651
|
return {
|
|
4612
4652
|
quality,
|
|
4613
4653
|
preset,
|
|
@@ -4615,8 +4655,10 @@ async function runMediaServer() {
|
|
|
4615
4655
|
keyframeMs,
|
|
4616
4656
|
intraRefresh,
|
|
4617
4657
|
pixelFormat,
|
|
4658
|
+
fps: encodeFps,
|
|
4618
4659
|
chromaQpOffset: profiles[activeProfile].chromaQpOffset,
|
|
4619
|
-
signature:
|
|
4660
|
+
signature: onDemand ? restart : `${live} \xB7 ${restart}`,
|
|
4661
|
+
description: `${live} \xB7 ${restart}`
|
|
4620
4662
|
};
|
|
4621
4663
|
};
|
|
4622
4664
|
const budgetBps = () => ladder()[rung].kbps * 1e3 * budgetFactor;
|
|
@@ -4637,6 +4679,7 @@ async function runMediaServer() {
|
|
|
4637
4679
|
if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
|
|
4638
4680
|
viewer.waitingForKeyframe = true;
|
|
4639
4681
|
viewer.withheldFrames += 1;
|
|
4682
|
+
if (videoEncoder.controllable() && queueMs(viewer) <= targetBacklogMs && socket.bufferedAmount <= fallbackBufferedBytes) requestKeyframe("socket viewer caught up", void 0, viewer);
|
|
4640
4683
|
continue;
|
|
4641
4684
|
}
|
|
4642
4685
|
if (keyframe2) viewer.waitingForKeyframe = false;
|
|
@@ -4663,12 +4706,12 @@ async function runMediaServer() {
|
|
|
4663
4706
|
}
|
|
4664
4707
|
}
|
|
4665
4708
|
const last = Boolean(packet[1] & 128);
|
|
4666
|
-
videoSplicer.splice(packet, generation, Math.round(9e4 /
|
|
4709
|
+
videoSplicer.splice(packet, generation, Math.round(9e4 / streamFps()));
|
|
4667
4710
|
let rtp;
|
|
4668
4711
|
let sockets = false;
|
|
4669
4712
|
for (const viewer of viewers.values()) {
|
|
4670
4713
|
if (viewer.fallback) sockets = true;
|
|
4671
|
-
|
|
4714
|
+
viewer.rtc?.enqueueVideo(rtp ??= withPlayoutDelay(packet, PLAYOUT_DELAY_ID), frameId, keyframe, last);
|
|
4672
4715
|
}
|
|
4673
4716
|
frameStart = last;
|
|
4674
4717
|
if (last) {
|
|
@@ -4677,7 +4720,12 @@ async function runMediaServer() {
|
|
|
4677
4720
|
}
|
|
4678
4721
|
if (!sockets) return;
|
|
4679
4722
|
const parsed = parseRtpPacket(packet);
|
|
4723
|
+
const discarded = videoAssembler.discarded;
|
|
4680
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
|
+
}
|
|
4681
4729
|
if (frame) broadcast(mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data), frame.key);
|
|
4682
4730
|
};
|
|
4683
4731
|
const onAudioPacket = (packet, generation) => {
|
|
@@ -4688,7 +4736,7 @@ async function runMediaServer() {
|
|
|
4688
4736
|
let sockets = false;
|
|
4689
4737
|
for (const viewer of viewers.values()) {
|
|
4690
4738
|
if (viewer.fallback) sockets = true;
|
|
4691
|
-
|
|
4739
|
+
viewer.rtc?.sendAudio(packet);
|
|
4692
4740
|
}
|
|
4693
4741
|
if (!sockets) return;
|
|
4694
4742
|
const parsed = parseRtpPacket(packet);
|
|
@@ -4699,13 +4747,12 @@ async function runMediaServer() {
|
|
|
4699
4747
|
display,
|
|
4700
4748
|
width,
|
|
4701
4749
|
height,
|
|
4702
|
-
fps,
|
|
4703
4750
|
colorMode,
|
|
4704
4751
|
params: encoderParams,
|
|
4705
4752
|
onPacket: onVideoPacket,
|
|
4706
4753
|
record
|
|
4707
4754
|
});
|
|
4708
|
-
const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(
|
|
4755
|
+
const expectedFps = () => videoEncoder.capture().captureBackend === "native" ? Math.max(1, Math.min(streamFps(), encoderFps)) : streamFps();
|
|
4709
4756
|
const audioEncoder = superviseEncoder({
|
|
4710
4757
|
label: "audio",
|
|
4711
4758
|
log: (message) => record("audio encoder", message),
|
|
@@ -4763,20 +4810,30 @@ async function runMediaServer() {
|
|
|
4763
4810
|
const ensureEncoders = () => {
|
|
4764
4811
|
clearTimeout(encoderStop);
|
|
4765
4812
|
void audioEncoder.start();
|
|
4766
|
-
const { signature } = encoderParams();
|
|
4813
|
+
const { signature, description, quality, fps: encodeFps } = encoderParams();
|
|
4814
|
+
const live = { crf: quality.crf, kbps: quality.kbps, fps: encodeFps };
|
|
4767
4815
|
if (!videoWanted) {
|
|
4768
4816
|
videoWanted = true;
|
|
4769
4817
|
encoderSignature = signature;
|
|
4770
|
-
|
|
4818
|
+
encoderDescription = description;
|
|
4819
|
+
encoderLive = live;
|
|
4820
|
+
record("encoder", description);
|
|
4771
4821
|
void videoEncoder.start();
|
|
4772
4822
|
return false;
|
|
4773
4823
|
}
|
|
4774
|
-
if (
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
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;
|
|
4780
4837
|
};
|
|
4781
4838
|
const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
|
|
4782
4839
|
const stopEncodersSoon = () => {
|
|
@@ -4794,8 +4851,9 @@ async function runMediaServer() {
|
|
|
4794
4851
|
const quality = ladder()[next];
|
|
4795
4852
|
record(next > rung ? "quality down" : "quality up", `rung ${rung} \u2192 ${next} (${quality.kbps} kbps cap, crf ${quality.crf}): ${reason}`);
|
|
4796
4853
|
if (next > rung) {
|
|
4797
|
-
if (now - lastDescentAt > 1e4) descents
|
|
4854
|
+
if (now - lastDescentAt > 1e4) descents.push(now);
|
|
4798
4855
|
lastDescentAt = now;
|
|
4856
|
+
failedRung = rung;
|
|
4799
4857
|
if (now - lastAscentAt < 15e3) {
|
|
4800
4858
|
blockedRung = rung;
|
|
4801
4859
|
blockedUntil = now + 12e4;
|
|
@@ -4811,7 +4869,8 @@ async function runMediaServer() {
|
|
|
4811
4869
|
const resetLadder = (request) => {
|
|
4812
4870
|
rung = request.transport === "webrtc" && request.adaptive ? webrtcStartupRung : 0;
|
|
4813
4871
|
cleanTransportSeconds = 0;
|
|
4814
|
-
descents =
|
|
4872
|
+
descents = [];
|
|
4873
|
+
failedRung = -1;
|
|
4815
4874
|
lastDescentAt = 0;
|
|
4816
4875
|
lastAscentAt = 0;
|
|
4817
4876
|
congestedSeconds = 0;
|
|
@@ -4831,6 +4890,7 @@ async function runMediaServer() {
|
|
|
4831
4890
|
const sent = Math.min(...recentVideoKbps);
|
|
4832
4891
|
return remb !== void 0 && earlier !== void 0 && remb <= earlier.kbps && sent > 500 && remb < sent * 0.9;
|
|
4833
4892
|
};
|
|
4893
|
+
const loaded = () => Math.min(...recentVideoKbps) > 500;
|
|
4834
4894
|
const signals = (viewer, stats) => {
|
|
4835
4895
|
const now = Date.now();
|
|
4836
4896
|
if (viewer.fallback) {
|
|
@@ -4841,22 +4901,24 @@ async function runMediaServer() {
|
|
|
4841
4901
|
}
|
|
4842
4902
|
const stableSince = stableRttWindows.get(viewer)?.since;
|
|
4843
4903
|
return [
|
|
4844
|
-
...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`] : [],
|
|
4845
4905
|
...stats.videoFps < expectedFps() * 0.6 && viewer.lowFpsReports >= 2 ? [`decoded ${stats.videoFps.toFixed(0)} fps`] : [],
|
|
4846
|
-
...(stats.lossPercent ?? 0) > 3 ? [`loss ${(stats.lossPercent ?? 0).toFixed(1)}%`] : [],
|
|
4847
|
-
...(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`] : []
|
|
4848
4908
|
];
|
|
4849
4909
|
};
|
|
4850
4910
|
const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
|
|
4851
|
-
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);
|
|
4852
4912
|
let lastSevereDescentAt = 0;
|
|
4853
4913
|
const descendSevere = (now, trigger) => {
|
|
4854
4914
|
comfortableSeconds = 0;
|
|
4855
4915
|
congestedSeconds = 0;
|
|
4856
4916
|
if (!adaptive || now - lastSevereDescentAt < 1e3) return;
|
|
4857
4917
|
lastSevereDescentAt = now;
|
|
4918
|
+
const sent = Math.min(...recentVideoKbps);
|
|
4858
4919
|
const estimates = [...viewers.values()].flatMap((viewer) => {
|
|
4859
|
-
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;
|
|
4860
4922
|
const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
|
|
4861
4923
|
return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
|
|
4862
4924
|
});
|
|
@@ -4873,10 +4935,13 @@ async function runMediaServer() {
|
|
|
4873
4935
|
videoKbps = Math.round((videoBytes - bytesAtTick) * 8 / 1e3);
|
|
4874
4936
|
bytesAtTick = videoBytes;
|
|
4875
4937
|
if (recentVideoKbps.push(videoKbps) > 3) recentVideoKbps.shift();
|
|
4876
|
-
if (viewers.size === 0)
|
|
4938
|
+
if (viewers.size === 0) {
|
|
4939
|
+
starvedSeconds = 0;
|
|
4940
|
+
return;
|
|
4941
|
+
}
|
|
4877
4942
|
const { captureBackend, captureMetrics } = videoEncoder.capture();
|
|
4878
|
-
const captureBusy = captureBackend !== "native" ||
|
|
4879
|
-
if (videoEncoder.running() && encoderFps <
|
|
4943
|
+
const captureBusy = captureBackend !== "native" || (captureMetrics ? captureMetrics.busyMs / captureMetrics.elapsedMs > 0.8 : videoEncoder.activeForMs() > 2500);
|
|
4944
|
+
if (videoEncoder.running() && encoderFps < streamFps() * 0.9 && captureBusy) {
|
|
4880
4945
|
cleanTransportSeconds = 0;
|
|
4881
4946
|
congestedSeconds = 0;
|
|
4882
4947
|
comfortableSeconds = 0;
|
|
@@ -4924,15 +4989,16 @@ async function runMediaServer() {
|
|
|
4924
4989
|
return;
|
|
4925
4990
|
}
|
|
4926
4991
|
congestedSeconds = Math.max(0, congestedSeconds - 0.5);
|
|
4927
|
-
|
|
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))) {
|
|
4928
4994
|
comfortableSeconds = 0;
|
|
4929
4995
|
return;
|
|
4930
4996
|
}
|
|
4931
4997
|
comfortableSeconds += 1;
|
|
4932
|
-
const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
|
|
4933
|
-
const recoverySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents, 2));
|
|
4934
|
-
if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
|
|
4935
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;
|
|
4936
5002
|
if (target === blockedRung && now < blockedUntil) return;
|
|
4937
5003
|
setRung(target, `${comfortableSeconds}s comfortable, ${cleanTransportSeconds}s clean transport`);
|
|
4938
5004
|
}, 1e3);
|
|
@@ -5012,9 +5078,18 @@ async function runMediaServer() {
|
|
|
5012
5078
|
`);
|
|
5013
5079
|
}, telemetryIntervalMs);
|
|
5014
5080
|
telemetry.unref();
|
|
5081
|
+
let lastKeyframeRequestAt = 0;
|
|
5015
5082
|
const requestKeyframe = (reason, joinedAt, requestingViewer) => {
|
|
5016
5083
|
const now = Date.now();
|
|
5017
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
|
+
}
|
|
5018
5093
|
const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
|
|
5019
5094
|
const wait = (joinedAt === void 0 ? startup ? 500 : 2e3 : 350) - (now - lastSwapAt);
|
|
5020
5095
|
if (wait > 0) {
|
|
@@ -5024,7 +5099,9 @@ async function runMediaServer() {
|
|
|
5024
5099
|
}
|
|
5025
5100
|
return;
|
|
5026
5101
|
}
|
|
5027
|
-
|
|
5102
|
+
const { keyframeMs, intraRefresh } = encoderParams();
|
|
5103
|
+
const untilPeriodic = keyframeMs - (now - lastKeyframeAt);
|
|
5104
|
+
if (!startup && !intraRefresh && untilPeriodic >= 0 && untilPeriodic <= 350) return;
|
|
5028
5105
|
lastSwapAt = now;
|
|
5029
5106
|
record("keyframe", reason);
|
|
5030
5107
|
void videoEncoder.replace();
|
|
@@ -5037,16 +5114,51 @@ async function runMediaServer() {
|
|
|
5037
5114
|
process.stderr.write(`[desktop-stream] webrtc fallback diagnostics: ${JSON.stringify(entry)}
|
|
5038
5115
|
`);
|
|
5039
5116
|
}
|
|
5040
|
-
|
|
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
|
+
}
|
|
5041
5124
|
viewer.fallback = true;
|
|
5042
5125
|
viewer.waitingForKeyframe = true;
|
|
5043
5126
|
viewer.input.release();
|
|
5044
|
-
viewer.rtc?.close();
|
|
5045
|
-
viewer.rtc = void 0;
|
|
5046
5127
|
record("fallback to socket", reason);
|
|
5047
5128
|
if (!syncEncoder()) requestKeyframe("socket fallback");
|
|
5048
5129
|
};
|
|
5049
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
|
+
};
|
|
5050
5162
|
const handleMessage = (viewer, message, reply) => {
|
|
5051
5163
|
switch (message.type) {
|
|
5052
5164
|
case "ping":
|
|
@@ -5124,7 +5236,7 @@ async function runMediaServer() {
|
|
|
5124
5236
|
}
|
|
5125
5237
|
if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
|
|
5126
5238
|
if (viewer.stats.videoFps >= expectedFps() * 0.8) viewer.everHealthy = true;
|
|
5127
|
-
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");
|
|
5128
5240
|
return;
|
|
5129
5241
|
}
|
|
5130
5242
|
case "answer":
|
|
@@ -5137,12 +5249,35 @@ async function runMediaServer() {
|
|
|
5137
5249
|
case "fallback":
|
|
5138
5250
|
enterFallback(viewer, message.reason, message.failure);
|
|
5139
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;
|
|
5140
5268
|
case "keyframe":
|
|
5141
|
-
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);
|
|
5142
5271
|
return;
|
|
5143
5272
|
case "theme":
|
|
5144
5273
|
applyDesktopTheme(message.theme);
|
|
5145
5274
|
return;
|
|
5275
|
+
case "standby":
|
|
5276
|
+
if (viewer.standby === message.standby) return;
|
|
5277
|
+
viewer.standby = message.standby;
|
|
5278
|
+
record(message.standby ? "viewer standby" : "viewer active", `${[...viewers.values()].filter((v) => v.standby).length} of ${viewers.size} in standby`);
|
|
5279
|
+
syncEncoder();
|
|
5280
|
+
return;
|
|
5146
5281
|
default:
|
|
5147
5282
|
viewer.input.handle(message);
|
|
5148
5283
|
}
|
|
@@ -5185,6 +5320,8 @@ async function runMediaServer() {
|
|
|
5185
5320
|
audioBytes,
|
|
5186
5321
|
videoKbps,
|
|
5187
5322
|
encoderFps,
|
|
5323
|
+
streamFps: streamFps(),
|
|
5324
|
+
standbyViewers: all.filter((viewer) => viewer.standby).length,
|
|
5188
5325
|
...videoEncoder.capture(),
|
|
5189
5326
|
cpu,
|
|
5190
5327
|
clientStats: all.flatMap((viewer) => viewer.stats ? [viewer.stats] : []),
|
|
@@ -5238,13 +5375,14 @@ async function runMediaServer() {
|
|
|
5238
5375
|
socket.destroy();
|
|
5239
5376
|
return;
|
|
5240
5377
|
}
|
|
5241
|
-
webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest)));
|
|
5378
|
+
webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest), url.searchParams.get("standby") === "1"));
|
|
5242
5379
|
});
|
|
5243
|
-
webSocketServer.on("connection", (socket, request) => {
|
|
5380
|
+
webSocketServer.on("connection", (socket, request, standby) => {
|
|
5244
5381
|
const viewer = {
|
|
5245
5382
|
socket,
|
|
5246
5383
|
input: new InputBridge(display, width, height),
|
|
5247
5384
|
request,
|
|
5385
|
+
standby,
|
|
5248
5386
|
fallback: false,
|
|
5249
5387
|
waitingForKeyframe: true,
|
|
5250
5388
|
withheldFrames: 0,
|
|
@@ -5269,7 +5407,7 @@ async function runMediaServer() {
|
|
|
5269
5407
|
if (!adaptive) rung = 0;
|
|
5270
5408
|
}
|
|
5271
5409
|
viewers.set(socket, viewer);
|
|
5272
|
-
record("viewer connected", `${request.transport} requested, ${viewers.size} connected`);
|
|
5410
|
+
record("viewer connected", `${request.transport} requested${standby ? " in standby" : ""}, ${viewers.size} connected`);
|
|
5273
5411
|
const reply = (message) => {
|
|
5274
5412
|
if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
|
|
5275
5413
|
};
|
|
@@ -5278,36 +5416,7 @@ async function runMediaServer() {
|
|
|
5278
5416
|
const fallbackProfile = profile() === "high444" ? "main" : profile();
|
|
5279
5417
|
const configMessage = { ...config, videoCodec: H264_PROFILES[fallbackProfile].codec, iceServers: ice?.browser, request };
|
|
5280
5418
|
reply(configMessage);
|
|
5281
|
-
if (ice)
|
|
5282
|
-
try {
|
|
5283
|
-
viewer.rtc = new WebRtcViewer({ iceServers: ice.native, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
|
|
5284
|
-
offer: (sdp) => reply({ type: "offer", sdp }),
|
|
5285
|
-
candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
|
|
5286
|
-
message: (message, answer) => handleMessage(viewer, message, answer),
|
|
5287
|
-
keyframe: (reason) => {
|
|
5288
|
-
if (reason === "stale") descendSevere(Date.now(), "send queue went stale and was cut back to a keyframe");
|
|
5289
|
-
requestKeyframe(reason === "pli" ? "receiver pli" : "stale send queue", void 0, viewer);
|
|
5290
|
-
},
|
|
5291
|
-
remb: (bitsPerSecond) => {
|
|
5292
|
-
if (viewer.rembs.push({ kbps: bitsPerSecond / 1e3, at: Date.now() }) > 16) viewer.rembs.shift();
|
|
5293
|
-
},
|
|
5294
|
-
connected: () => {
|
|
5295
|
-
record("webrtc connected", viewer.rtc?.path ?? void 0);
|
|
5296
|
-
requestKeyframe("viewer joined", Date.now());
|
|
5297
|
-
},
|
|
5298
|
-
// A viewer that loses its peer cannot send the key-ups for whatever it
|
|
5299
|
-
// was holding, and its socket may stay open for a long time yet.
|
|
5300
|
-
disconnected: (state) => {
|
|
5301
|
-
record(`webrtc ${state}`);
|
|
5302
|
-
viewer.input.release();
|
|
5303
|
-
if (state === "failed") reply({ type: "webrtc-failed" });
|
|
5304
|
-
}
|
|
5305
|
-
});
|
|
5306
|
-
} catch (error) {
|
|
5307
|
-
record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
5308
|
-
reply({ type: "webrtc-failed" });
|
|
5309
|
-
}
|
|
5310
|
-
}
|
|
5419
|
+
if (ice) openWebRtc(viewer, reply, ice.native);
|
|
5311
5420
|
if (!viewer.rtc) enterFallback(viewer, request.transport === "webrtc" ? "webrtc unavailable" : "websocket requested");
|
|
5312
5421
|
ensureEncoders();
|
|
5313
5422
|
socket.on("message", (raw, binary) => {
|