replicas-computer 0.2.15 → 0.3.1
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 +36 -4
- package/assets/desktop/geist-mono-400.woff2 +0 -0
- package/assets/desktop/geist-mono-600.woff2 +0 -0
- package/assets/desktop/geist-mono-700.woff2 +0 -0
- package/assets/desktop/general-sans-300.woff2 +0 -0
- package/assets/desktop/start-services.sh +4 -0
- package/assets/desktop/stream-index.html +4 -4
- package/assets/desktop/stream-viewer.css +5 -0
- package/assets/desktop/viewer-theme.css +4 -4
- package/dist/index.js +1707 -549
- package/dist/stream-viewer.js +519 -67
- package/dist/viewer.js +0 -9
- package/package.json +6 -2
- package/patches/libdatachannel-dtls-bio.patch +20 -0
- package/patches/libdatachannel-nack-duplicates.patch +13 -0
- package/scripts/build-webrtc.sh +31 -0
package/dist/index.js
CHANGED
|
@@ -998,13 +998,13 @@ async function computerRecordStartCommand(path, options) {
|
|
|
998
998
|
if (existsSync3(RECORD_PID_FILE)) {
|
|
999
999
|
const pid = parseInt(readFileSync3(RECORD_PID_FILE, "utf8").trim(), 10);
|
|
1000
1000
|
if (Number.isFinite(pid)) {
|
|
1001
|
-
let
|
|
1001
|
+
let alive3 = false;
|
|
1002
1002
|
try {
|
|
1003
1003
|
process.kill(pid, 0);
|
|
1004
|
-
|
|
1004
|
+
alive3 = true;
|
|
1005
1005
|
} catch {
|
|
1006
1006
|
}
|
|
1007
|
-
if (
|
|
1007
|
+
if (alive3) fail(`recording already in progress (pid ${pid}). run \`replicas computer record stop\` first.`);
|
|
1008
1008
|
}
|
|
1009
1009
|
}
|
|
1010
1010
|
const target = resolvePath(path);
|
|
@@ -1064,13 +1064,13 @@ async function computerRecordStartCommand(path, options) {
|
|
|
1064
1064
|
}
|
|
1065
1065
|
await sleep(100);
|
|
1066
1066
|
}
|
|
1067
|
-
let
|
|
1067
|
+
let alive2 = false;
|
|
1068
1068
|
try {
|
|
1069
1069
|
process.kill(child.pid, 0);
|
|
1070
|
-
|
|
1070
|
+
alive2 = true;
|
|
1071
1071
|
} catch {
|
|
1072
1072
|
}
|
|
1073
|
-
if (
|
|
1073
|
+
if (alive2) fail("ffmpeg is running but did not produce recording output within 5 seconds; run `replicas computer record stop` to finalize or retry");
|
|
1074
1074
|
clearRecordingState();
|
|
1075
1075
|
rmSync3(rawTarget, { force: true });
|
|
1076
1076
|
fail("ffmpeg exited before screen recording became ready");
|
|
@@ -1084,17 +1084,17 @@ async function computerRecordStopCommand() {
|
|
|
1084
1084
|
process.kill(pid, "SIGINT");
|
|
1085
1085
|
} catch {
|
|
1086
1086
|
}
|
|
1087
|
-
let
|
|
1087
|
+
let alive2 = true;
|
|
1088
1088
|
for (let i = 0; i < 150; i++) {
|
|
1089
1089
|
try {
|
|
1090
1090
|
process.kill(pid, 0);
|
|
1091
1091
|
} catch {
|
|
1092
|
-
|
|
1092
|
+
alive2 = false;
|
|
1093
1093
|
break;
|
|
1094
1094
|
}
|
|
1095
1095
|
await sleep(200);
|
|
1096
1096
|
}
|
|
1097
|
-
if (
|
|
1097
|
+
if (alive2) fail(`ffmpeg did not finalize recording within 30 seconds (pid ${pid})`);
|
|
1098
1098
|
}
|
|
1099
1099
|
if (existsSync3(RECORD_PATH_FILE)) {
|
|
1100
1100
|
const target = readFileSync3(RECORD_PATH_FILE, "utf8").trim();
|
|
@@ -1116,6 +1116,82 @@ function isRecord(value) {
|
|
|
1116
1116
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1117
1117
|
}
|
|
1118
1118
|
|
|
1119
|
+
// src/lib/chrome-session.ts
|
|
1120
|
+
async function openChromeSession(webSocketDebuggerUrl) {
|
|
1121
|
+
const socket = new WebSocket(webSocketDebuggerUrl);
|
|
1122
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1123
|
+
let nextId = 1;
|
|
1124
|
+
let closed = false;
|
|
1125
|
+
const fail2 = (error) => {
|
|
1126
|
+
if (closed) return;
|
|
1127
|
+
closed = true;
|
|
1128
|
+
for (const request of pending.values()) {
|
|
1129
|
+
clearTimeout(request.timeout);
|
|
1130
|
+
request.reject(error);
|
|
1131
|
+
}
|
|
1132
|
+
pending.clear();
|
|
1133
|
+
socket.close();
|
|
1134
|
+
};
|
|
1135
|
+
socket.addEventListener("message", (event) => {
|
|
1136
|
+
const data = typeof event.data === "string" ? event.data : Buffer.isBuffer(event.data) ? event.data.toString("utf8") : Buffer.from(event.data).toString("utf8");
|
|
1137
|
+
let message;
|
|
1138
|
+
try {
|
|
1139
|
+
message = JSON.parse(data);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
fail2(new Error(`Chrome DevTools returned invalid WebSocket JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!isRecord(message)) return;
|
|
1145
|
+
if (typeof message.id !== "number") return;
|
|
1146
|
+
const request = pending.get(message.id);
|
|
1147
|
+
if (!request) return;
|
|
1148
|
+
clearTimeout(request.timeout);
|
|
1149
|
+
pending.delete(message.id);
|
|
1150
|
+
const detail = isRecord(message.error) && typeof message.error.message === "string" ? message.error.message : void 0;
|
|
1151
|
+
request.resolve(message.error ? { error: { message: detail } } : { result: isRecord(message.result) ? message.result : {} });
|
|
1152
|
+
});
|
|
1153
|
+
socket.addEventListener("error", () => fail2(new Error("Chrome DevTools WebSocket failed")));
|
|
1154
|
+
socket.addEventListener("close", () => fail2(new Error("Chrome DevTools WebSocket closed unexpectedly")));
|
|
1155
|
+
await new Promise((resolve4, reject) => {
|
|
1156
|
+
const timeout = setTimeout(() => {
|
|
1157
|
+
socket.close();
|
|
1158
|
+
reject(new Error("Chrome DevTools connection timed out"));
|
|
1159
|
+
}, 1e4);
|
|
1160
|
+
socket.addEventListener("open", () => {
|
|
1161
|
+
clearTimeout(timeout);
|
|
1162
|
+
resolve4();
|
|
1163
|
+
}, { once: true });
|
|
1164
|
+
socket.addEventListener("error", () => {
|
|
1165
|
+
clearTimeout(timeout);
|
|
1166
|
+
reject(new Error("Chrome DevTools connection failed"));
|
|
1167
|
+
}, { once: true });
|
|
1168
|
+
});
|
|
1169
|
+
return {
|
|
1170
|
+
send: ({ method, params }) => new Promise((resolve4, reject) => {
|
|
1171
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
1172
|
+
reject(new Error("Chrome DevTools connection is not open"));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const id = nextId++;
|
|
1176
|
+
const timeout = setTimeout(() => {
|
|
1177
|
+
pending.delete(id);
|
|
1178
|
+
reject(new Error(`Chrome DevTools ${method} timed out`));
|
|
1179
|
+
}, 1e4);
|
|
1180
|
+
pending.set(id, { resolve: resolve4, reject, timeout });
|
|
1181
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
1182
|
+
}),
|
|
1183
|
+
close: () => fail2(new Error("Chrome DevTools connection closed"))
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
async function withChromeSession(webSocketDebuggerUrl, callback) {
|
|
1187
|
+
const session = await openChromeSession(webSocketDebuggerUrl);
|
|
1188
|
+
try {
|
|
1189
|
+
return await callback(session);
|
|
1190
|
+
} finally {
|
|
1191
|
+
session.close();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1119
1195
|
// src/commands/browser.ts
|
|
1120
1196
|
async function fetchChromeJson(path) {
|
|
1121
1197
|
let res;
|
|
@@ -1174,72 +1250,6 @@ var VISIBLE_JS = `
|
|
|
1174
1250
|
async function sendChromeSessionCommand(session, method, params) {
|
|
1175
1251
|
return chromeResult(await session.send({ method, params }), method);
|
|
1176
1252
|
}
|
|
1177
|
-
async function withChromeSession(webSocketDebuggerUrl, callback) {
|
|
1178
|
-
return await new Promise((resolve4, reject) => {
|
|
1179
|
-
const ws = new WebSocket(webSocketDebuggerUrl);
|
|
1180
|
-
const pending = /* @__PURE__ */ new Map();
|
|
1181
|
-
let nextId = 1;
|
|
1182
|
-
let settled = false;
|
|
1183
|
-
const failSession = (error) => {
|
|
1184
|
-
if (settled) return;
|
|
1185
|
-
settled = true;
|
|
1186
|
-
for (const request of pending.values()) {
|
|
1187
|
-
clearTimeout(request.timeout);
|
|
1188
|
-
request.reject(error);
|
|
1189
|
-
}
|
|
1190
|
-
pending.clear();
|
|
1191
|
-
ws.close();
|
|
1192
|
-
reject(error);
|
|
1193
|
-
};
|
|
1194
|
-
const finishSession = (value) => {
|
|
1195
|
-
if (settled) return;
|
|
1196
|
-
settled = true;
|
|
1197
|
-
for (const request of pending.values()) clearTimeout(request.timeout);
|
|
1198
|
-
pending.clear();
|
|
1199
|
-
ws.close();
|
|
1200
|
-
resolve4(value);
|
|
1201
|
-
};
|
|
1202
|
-
const session = {
|
|
1203
|
-
send: ({ method, params }) => new Promise((resolveCommand, rejectCommand) => {
|
|
1204
|
-
const id = nextId++;
|
|
1205
|
-
const timeout = setTimeout(() => {
|
|
1206
|
-
pending.delete(id);
|
|
1207
|
-
rejectCommand(new Error(`Chrome DevTools ${method} timed out`));
|
|
1208
|
-
}, 1e4);
|
|
1209
|
-
pending.set(id, { method, resolve: resolveCommand, reject: rejectCommand, timeout });
|
|
1210
|
-
ws.send(JSON.stringify({ id, method, params }));
|
|
1211
|
-
})
|
|
1212
|
-
};
|
|
1213
|
-
ws.addEventListener("open", async () => {
|
|
1214
|
-
try {
|
|
1215
|
-
finishSession(await callback(session));
|
|
1216
|
-
} catch (error) {
|
|
1217
|
-
failSession(error instanceof Error ? error : new Error(String(error)));
|
|
1218
|
-
}
|
|
1219
|
-
});
|
|
1220
|
-
ws.addEventListener("message", (event) => {
|
|
1221
|
-
const data = typeof event.data === "string" ? event.data : Buffer.isBuffer(event.data) ? event.data.toString("utf8") : Buffer.from(event.data).toString("utf8");
|
|
1222
|
-
let message;
|
|
1223
|
-
try {
|
|
1224
|
-
message = JSON.parse(data);
|
|
1225
|
-
} catch (error) {
|
|
1226
|
-
failSession(new Error(`Chrome DevTools returned invalid WebSocket JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
if (!isRecord(message) || typeof message.id !== "number") return;
|
|
1230
|
-
const request = pending.get(message.id);
|
|
1231
|
-
if (!request) return;
|
|
1232
|
-
clearTimeout(request.timeout);
|
|
1233
|
-
pending.delete(message.id);
|
|
1234
|
-
const detail = isRecord(message.error) && typeof message.error.message === "string" ? message.error.message : void 0;
|
|
1235
|
-
request.resolve(message.error ? { error: { message: detail } } : { result: isRecord(message.result) ? message.result : {} });
|
|
1236
|
-
});
|
|
1237
|
-
ws.addEventListener("error", () => failSession(new Error("Chrome DevTools WebSocket failed")));
|
|
1238
|
-
ws.addEventListener("close", () => {
|
|
1239
|
-
if (!settled) failSession(new Error("Chrome DevTools WebSocket closed unexpectedly"));
|
|
1240
|
-
});
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1243
1253
|
async function sendChromeCommands(webSocketDebuggerUrl, commands) {
|
|
1244
1254
|
return await withChromeSession(webSocketDebuggerUrl, async (session) => await Promise.all(commands.map((command) => session.send(command))));
|
|
1245
1255
|
}
|
|
@@ -2927,7 +2937,7 @@ async function computerStatusCommand() {
|
|
|
2927
2937
|
}
|
|
2928
2938
|
|
|
2929
2939
|
// src/version.ts
|
|
2930
|
-
var COMPUTER_USE_VERSION = "0.
|
|
2940
|
+
var COMPUTER_USE_VERSION = "0.3.1";
|
|
2931
2941
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
2932
2942
|
|
|
2933
2943
|
// src/program.ts
|
|
@@ -3167,16 +3177,385 @@ async function runComputerClient(argv) {
|
|
|
3167
3177
|
}
|
|
3168
3178
|
|
|
3169
3179
|
// src/media-server.ts
|
|
3170
|
-
import { spawn as
|
|
3171
|
-
import {
|
|
3172
|
-
import { createReadStream, existsSync as existsSync6, statSync as statSync2 } from "fs";
|
|
3180
|
+
import { spawn as spawn5 } from "child_process";
|
|
3181
|
+
import { createHash as createHash3 } from "crypto";
|
|
3182
|
+
import { createReadStream, existsSync as existsSync6, readdirSync, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
|
|
3173
3183
|
import { createServer as createServer2 } from "http";
|
|
3174
|
-
import { availableParallelism } from "os";
|
|
3184
|
+
import { availableParallelism, loadavg } from "os";
|
|
3175
3185
|
import { dirname as dirname6, extname, resolve as resolve2 } from "path";
|
|
3176
3186
|
import { fileURLToPath } from "url";
|
|
3177
3187
|
|
|
3178
|
-
// src/
|
|
3188
|
+
// ../shared/src/runtime-env.ts
|
|
3189
|
+
function parsePosixEnvFile(content) {
|
|
3190
|
+
const result = {};
|
|
3191
|
+
let pos = 0;
|
|
3192
|
+
while (pos < content.length) {
|
|
3193
|
+
let lineStart;
|
|
3194
|
+
if (pos === 0 || content[pos - 1] === "\n") {
|
|
3195
|
+
lineStart = pos;
|
|
3196
|
+
} else {
|
|
3197
|
+
const nl2 = content.indexOf("\n", pos);
|
|
3198
|
+
if (nl2 === -1) break;
|
|
3199
|
+
lineStart = nl2 + 1;
|
|
3200
|
+
}
|
|
3201
|
+
if (!content.startsWith("export ", lineStart)) {
|
|
3202
|
+
const nl2 = content.indexOf("\n", lineStart);
|
|
3203
|
+
if (nl2 === -1) break;
|
|
3204
|
+
pos = nl2 + 1;
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
const nameStart = lineStart + "export ".length;
|
|
3208
|
+
const eqIdx = content.indexOf("=", nameStart);
|
|
3209
|
+
if (eqIdx === -1) break;
|
|
3210
|
+
const name = content.slice(nameStart, eqIdx).trim();
|
|
3211
|
+
let cursor = eqIdx + 1;
|
|
3212
|
+
if (content[cursor] !== "'") {
|
|
3213
|
+
const nl2 = content.indexOf("\n", cursor);
|
|
3214
|
+
pos = nl2 === -1 ? content.length : nl2 + 1;
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3217
|
+
cursor += 1;
|
|
3218
|
+
let value = "";
|
|
3219
|
+
let closed = false;
|
|
3220
|
+
while (cursor < content.length) {
|
|
3221
|
+
const ch = content[cursor];
|
|
3222
|
+
if (ch === "'") {
|
|
3223
|
+
if (content.startsWith("\\''", cursor + 1)) {
|
|
3224
|
+
value += "'";
|
|
3225
|
+
cursor += 4;
|
|
3226
|
+
continue;
|
|
3227
|
+
}
|
|
3228
|
+
cursor += 1;
|
|
3229
|
+
closed = true;
|
|
3230
|
+
break;
|
|
3231
|
+
}
|
|
3232
|
+
value += ch;
|
|
3233
|
+
cursor += 1;
|
|
3234
|
+
}
|
|
3235
|
+
if (closed && name) {
|
|
3236
|
+
result[name] = value;
|
|
3237
|
+
}
|
|
3238
|
+
const nl = content.indexOf("\n", cursor);
|
|
3239
|
+
pos = nl === -1 ? content.length : nl + 1;
|
|
3240
|
+
}
|
|
3241
|
+
return result;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// src/media-server.ts
|
|
3245
|
+
import WebSocket2, { WebSocketServer } from "ws";
|
|
3246
|
+
|
|
3247
|
+
// src/encoder.ts
|
|
3179
3248
|
import { spawn as spawn3 } from "child_process";
|
|
3249
|
+
import { createSocket } from "dgram";
|
|
3250
|
+
|
|
3251
|
+
// src/rtp.ts
|
|
3252
|
+
var RTP_HEADER_BYTES = 12;
|
|
3253
|
+
var NAL_IDR = 5;
|
|
3254
|
+
var NAL_SPS = 7;
|
|
3255
|
+
var NAL_STAP_A = 24;
|
|
3256
|
+
var NAL_FU_A = 28;
|
|
3257
|
+
var nalType = (byte) => byte & 31;
|
|
3258
|
+
var headerBytesOf = (packet) => RTP_HEADER_BYTES + (packet[0] & 15) * 4;
|
|
3259
|
+
var joinable = (nal) => nal === NAL_IDR || nal === NAL_SPS;
|
|
3260
|
+
var isRtp = (data) => data.length >= RTP_HEADER_BYTES && data[0] >> 6 === 2 && (data[1] < 192 || data[1] > 223);
|
|
3261
|
+
var rtpPayload = (packet) => packet.subarray(headerBytesOf(packet));
|
|
3262
|
+
function aggregatedNals(payload) {
|
|
3263
|
+
const nals = [];
|
|
3264
|
+
let offset = 1;
|
|
3265
|
+
while (offset < payload.length) {
|
|
3266
|
+
if (offset + 2 > payload.length) return { nals, damaged: true };
|
|
3267
|
+
const size = payload[offset] * 256 + payload[offset + 1];
|
|
3268
|
+
offset += 2;
|
|
3269
|
+
if (!size || offset + size > payload.length) return { nals, damaged: true };
|
|
3270
|
+
nals.push(payload.subarray(offset, offset + size));
|
|
3271
|
+
offset += size;
|
|
3272
|
+
}
|
|
3273
|
+
return { nals, damaged: nals.length === 0 };
|
|
3274
|
+
}
|
|
3275
|
+
var fragmentHeader = (payload) => ({
|
|
3276
|
+
type: nalType(payload[1]),
|
|
3277
|
+
start: Boolean(payload[1] & 128),
|
|
3278
|
+
end: Boolean(payload[1] & 64)
|
|
3279
|
+
});
|
|
3280
|
+
function isKeyframeStart(payload) {
|
|
3281
|
+
if (payload.length === 0) return false;
|
|
3282
|
+
const type = nalType(payload[0]);
|
|
3283
|
+
if (type === NAL_FU_A) {
|
|
3284
|
+
if (payload.length < 2) return false;
|
|
3285
|
+
const fragment = fragmentHeader(payload);
|
|
3286
|
+
return fragment.start && joinable(fragment.type);
|
|
3287
|
+
}
|
|
3288
|
+
if (type === NAL_STAP_A) return aggregatedNals(payload).nals.some((nal) => joinable(nalType(nal[0])));
|
|
3289
|
+
return joinable(type);
|
|
3290
|
+
}
|
|
3291
|
+
var RtpSplicer = class {
|
|
3292
|
+
generation = -1;
|
|
3293
|
+
seqOffset = 0;
|
|
3294
|
+
timestampOffset = 0;
|
|
3295
|
+
lastSeq = -1;
|
|
3296
|
+
lastTimestamp = 0;
|
|
3297
|
+
splice(packet, generation, timestampStep) {
|
|
3298
|
+
const seq = packet.readUInt16BE(2);
|
|
3299
|
+
const timestamp = packet.readUInt32BE(4);
|
|
3300
|
+
if (generation !== this.generation) {
|
|
3301
|
+
if (this.lastSeq >= 0) {
|
|
3302
|
+
this.seqOffset = this.lastSeq + 1 - seq & 65535;
|
|
3303
|
+
this.timestampOffset = this.lastTimestamp + timestampStep - timestamp >>> 0;
|
|
3304
|
+
}
|
|
3305
|
+
this.generation = generation;
|
|
3306
|
+
}
|
|
3307
|
+
this.lastSeq = seq + this.seqOffset & 65535;
|
|
3308
|
+
this.lastTimestamp = timestamp + this.timestampOffset >>> 0;
|
|
3309
|
+
packet.writeUInt16BE(this.lastSeq, 2);
|
|
3310
|
+
packet.writeUInt32BE(this.lastTimestamp, 4);
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
function withPlayoutDelay(packet, id) {
|
|
3314
|
+
if (packet[0] & 16) return packet;
|
|
3315
|
+
const header = headerBytesOf(packet);
|
|
3316
|
+
const out = Buffer.allocUnsafe(packet.length + 8);
|
|
3317
|
+
packet.copy(out, 0, 0, header);
|
|
3318
|
+
out[0] |= 16;
|
|
3319
|
+
out.writeUInt32BE(3202220033, header);
|
|
3320
|
+
out[header + 4] = id << 4 | 2;
|
|
3321
|
+
out[header + 5] = 0;
|
|
3322
|
+
out[header + 6] = 0;
|
|
3323
|
+
out[header + 7] = 0;
|
|
3324
|
+
packet.copy(out, header + 8, header);
|
|
3325
|
+
return out;
|
|
3326
|
+
}
|
|
3327
|
+
function parseRtpPacket(data) {
|
|
3328
|
+
if (!isRtp(data)) return null;
|
|
3329
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
3330
|
+
const padding = Boolean(data[0] & 32);
|
|
3331
|
+
const extension = Boolean(data[0] & 16);
|
|
3332
|
+
let offset = headerBytesOf(data);
|
|
3333
|
+
if (offset > data.length) return null;
|
|
3334
|
+
if (extension) {
|
|
3335
|
+
if (offset + 4 > data.length) return null;
|
|
3336
|
+
offset += 4 + view.getUint16(offset + 2) * 4;
|
|
3337
|
+
}
|
|
3338
|
+
const end = data.length - (padding ? data[data.length - 1] : 0);
|
|
3339
|
+
if (offset >= end) return null;
|
|
3340
|
+
return {
|
|
3341
|
+
marker: Boolean(data[1] & 128),
|
|
3342
|
+
payload: data.subarray(offset, end),
|
|
3343
|
+
sequence: view.getUint16(2),
|
|
3344
|
+
timestamp: view.getUint32(4)
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
var RtpClock = class {
|
|
3348
|
+
constructor(rate) {
|
|
3349
|
+
this.rate = rate;
|
|
3350
|
+
}
|
|
3351
|
+
rate;
|
|
3352
|
+
base;
|
|
3353
|
+
last;
|
|
3354
|
+
wraps = 0;
|
|
3355
|
+
microseconds(timestamp) {
|
|
3356
|
+
if (this.last !== void 0 && timestamp < this.last && this.last - timestamp > 2147483648) this.wraps += 1;
|
|
3357
|
+
this.last = timestamp;
|
|
3358
|
+
const extended = timestamp + this.wraps * 4294967296;
|
|
3359
|
+
this.base ??= extended;
|
|
3360
|
+
return Math.round((extended - this.base) * 1e6 / this.rate);
|
|
3361
|
+
}
|
|
3362
|
+
};
|
|
3363
|
+
var startCode = new Uint8Array([0, 0, 0, 1]);
|
|
3364
|
+
var H264RtpAssembler = class {
|
|
3365
|
+
clock = new RtpClock(9e4);
|
|
3366
|
+
chunks = [];
|
|
3367
|
+
key = false;
|
|
3368
|
+
timestamp;
|
|
3369
|
+
sequence;
|
|
3370
|
+
fragmentType;
|
|
3371
|
+
damaged = false;
|
|
3372
|
+
clearFrame() {
|
|
3373
|
+
this.chunks = [];
|
|
3374
|
+
this.key = false;
|
|
3375
|
+
this.timestamp = void 0;
|
|
3376
|
+
this.sequence = void 0;
|
|
3377
|
+
this.fragmentType = void 0;
|
|
3378
|
+
this.damaged = false;
|
|
3379
|
+
}
|
|
3380
|
+
push(packet) {
|
|
3381
|
+
if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
|
|
3382
|
+
this.clearFrame();
|
|
3383
|
+
}
|
|
3384
|
+
this.timestamp ??= packet.timestamp;
|
|
3385
|
+
if (this.sequence !== void 0 && packet.sequence !== (this.sequence + 1) % 65536) this.damaged = true;
|
|
3386
|
+
this.sequence = packet.sequence;
|
|
3387
|
+
const type = nalType(packet.payload[0]);
|
|
3388
|
+
if (this.damaged) {
|
|
3389
|
+
} else if (type > 0 && type < NAL_STAP_A && this.fragmentType === void 0) {
|
|
3390
|
+
this.chunks.push(startCode, packet.payload);
|
|
3391
|
+
this.key ||= type === NAL_IDR;
|
|
3392
|
+
} else if (type === NAL_STAP_A && this.fragmentType === void 0) {
|
|
3393
|
+
const { nals, damaged } = aggregatedNals(packet.payload);
|
|
3394
|
+
for (const nal of nals) {
|
|
3395
|
+
this.chunks.push(startCode, nal);
|
|
3396
|
+
this.key ||= nalType(nal[0]) === NAL_IDR;
|
|
3397
|
+
}
|
|
3398
|
+
this.damaged = damaged;
|
|
3399
|
+
} else if (type === NAL_FU_A && packet.payload.length >= 3) {
|
|
3400
|
+
const fragment = fragmentHeader(packet.payload);
|
|
3401
|
+
if (fragment.start && !fragment.end && this.fragmentType === void 0) {
|
|
3402
|
+
this.fragmentType = fragment.type;
|
|
3403
|
+
this.chunks.push(startCode, new Uint8Array([packet.payload[0] & 224 | fragment.type]));
|
|
3404
|
+
this.key ||= fragment.type === NAL_IDR;
|
|
3405
|
+
} else if (!fragment.start && this.fragmentType === fragment.type) {
|
|
3406
|
+
if (fragment.end) this.fragmentType = void 0;
|
|
3407
|
+
} else {
|
|
3408
|
+
this.damaged = true;
|
|
3409
|
+
}
|
|
3410
|
+
if (!this.damaged) this.chunks.push(packet.payload.subarray(2));
|
|
3411
|
+
} else this.damaged = true;
|
|
3412
|
+
if (!packet.marker) return null;
|
|
3413
|
+
if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
|
|
3414
|
+
this.clearFrame();
|
|
3415
|
+
return null;
|
|
3416
|
+
}
|
|
3417
|
+
const length = this.chunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
3418
|
+
const data = new Uint8Array(length);
|
|
3419
|
+
let offset = 0;
|
|
3420
|
+
for (const chunk of this.chunks) {
|
|
3421
|
+
data.set(chunk, offset);
|
|
3422
|
+
offset += chunk.length;
|
|
3423
|
+
}
|
|
3424
|
+
const frame = { data, key: this.key, timestamp: this.clock.microseconds(packet.timestamp) };
|
|
3425
|
+
this.clearFrame();
|
|
3426
|
+
return frame;
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
|
|
3430
|
+
// src/encoder.ts
|
|
3431
|
+
var alive = (child) => child.exitCode === null && child.signalCode === null;
|
|
3432
|
+
var bindUdp = () => new Promise((resolveSocket, reject) => {
|
|
3433
|
+
const socket = createSocket("udp4");
|
|
3434
|
+
socket.once("error", reject);
|
|
3435
|
+
socket.bind(0, "127.0.0.1", () => {
|
|
3436
|
+
socket.off("error", reject);
|
|
3437
|
+
socket.setRecvBufferSize(4 * 1024 * 1024);
|
|
3438
|
+
resolveSocket(socket);
|
|
3439
|
+
});
|
|
3440
|
+
});
|
|
3441
|
+
function superviseEncoder(options) {
|
|
3442
|
+
let active;
|
|
3443
|
+
let candidate;
|
|
3444
|
+
let nextId = 0;
|
|
3445
|
+
let wanted = false;
|
|
3446
|
+
let restart;
|
|
3447
|
+
let candidateDeadline;
|
|
3448
|
+
let chain = Promise.resolve();
|
|
3449
|
+
const log = (message) => process.stderr.write(`[desktop-stream] ${options.label} ${message}
|
|
3450
|
+
`);
|
|
3451
|
+
const retire = (generation) => {
|
|
3452
|
+
if (!generation) return;
|
|
3453
|
+
generation.live = false;
|
|
3454
|
+
if (!alive(generation.child)) {
|
|
3455
|
+
generation.socket.close();
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
generation.child.kill("SIGTERM");
|
|
3459
|
+
const force = setTimeout(() => alive(generation.child) && generation.child.kill("SIGKILL"), 2e3);
|
|
3460
|
+
force.unref();
|
|
3461
|
+
generation.child.once("exit", () => {
|
|
3462
|
+
clearTimeout(force);
|
|
3463
|
+
generation.socket.close();
|
|
3464
|
+
});
|
|
3465
|
+
};
|
|
3466
|
+
const spawnGeneration = async () => {
|
|
3467
|
+
const socket = await bindUdp();
|
|
3468
|
+
const warmupFrames = active ? options.warmupFrames ?? 0 : 0;
|
|
3469
|
+
const args = options.args(socket.address().port, warmupFrames);
|
|
3470
|
+
const child = spawn3("ffmpeg", args, { env: options.env, stdio: ["ignore", "ignore", "pipe"] });
|
|
3471
|
+
child.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
|
|
3472
|
+
const generation = { id: ++nextId, child, socket, live: true, startedAt: Date.now(), warmupFrames };
|
|
3473
|
+
socket.on("message", (data) => {
|
|
3474
|
+
if (!generation.live || !isRtp(data)) return;
|
|
3475
|
+
if (generation === candidate) {
|
|
3476
|
+
if (generation.warmupFrames > 0) {
|
|
3477
|
+
if (data[1] & 128) generation.warmupFrames -= 1;
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
if (!options.joinable(data)) return;
|
|
3481
|
+
clearTimeout(candidateDeadline);
|
|
3482
|
+
candidate = void 0;
|
|
3483
|
+
retire(active);
|
|
3484
|
+
active = generation;
|
|
3485
|
+
} else if (generation !== active) return;
|
|
3486
|
+
options.onPacket(data, generation.id);
|
|
3487
|
+
});
|
|
3488
|
+
child.once("exit", (code, signal) => {
|
|
3489
|
+
if (generation === candidate) {
|
|
3490
|
+
clearTimeout(candidateDeadline);
|
|
3491
|
+
candidate = void 0;
|
|
3492
|
+
retire(generation);
|
|
3493
|
+
log(`replacement exited (${signal || code}); keeping the current encoder`);
|
|
3494
|
+
if (!active && wanted) restart = setTimeout(() => void start(), 500);
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
if (generation !== active) return;
|
|
3498
|
+
retire(generation);
|
|
3499
|
+
active = void 0;
|
|
3500
|
+
if (!wanted) return;
|
|
3501
|
+
log(`encoder exited (${signal || code}); restarting`);
|
|
3502
|
+
restart = setTimeout(() => void start(), 500);
|
|
3503
|
+
});
|
|
3504
|
+
return generation;
|
|
3505
|
+
};
|
|
3506
|
+
const enqueue = (task) => {
|
|
3507
|
+
chain = chain.then(task).catch((error) => {
|
|
3508
|
+
log(error instanceof Error ? error.message : String(error));
|
|
3509
|
+
});
|
|
3510
|
+
return chain;
|
|
3511
|
+
};
|
|
3512
|
+
const start = () => enqueue(async () => {
|
|
3513
|
+
wanted = true;
|
|
3514
|
+
clearTimeout(restart);
|
|
3515
|
+
if (active || candidate) return;
|
|
3516
|
+
active = await spawnGeneration();
|
|
3517
|
+
});
|
|
3518
|
+
const replace = () => enqueue(async () => {
|
|
3519
|
+
if (!wanted) return;
|
|
3520
|
+
if (!active) {
|
|
3521
|
+
active = await spawnGeneration();
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
clearTimeout(candidateDeadline);
|
|
3525
|
+
retire(candidate);
|
|
3526
|
+
const started = await spawnGeneration();
|
|
3527
|
+
candidate = started;
|
|
3528
|
+
candidateDeadline = setTimeout(() => {
|
|
3529
|
+
if (candidate !== started) return;
|
|
3530
|
+
candidate = void 0;
|
|
3531
|
+
retire(started);
|
|
3532
|
+
log("replacement produced no keyframe; keeping the current encoder");
|
|
3533
|
+
if (!active && wanted) restart = setTimeout(() => void start(), 500);
|
|
3534
|
+
}, 4e3);
|
|
3535
|
+
});
|
|
3536
|
+
const stop = () => enqueue(async () => {
|
|
3537
|
+
wanted = false;
|
|
3538
|
+
clearTimeout(restart);
|
|
3539
|
+
clearTimeout(candidateDeadline);
|
|
3540
|
+
retire(candidate);
|
|
3541
|
+
retire(active);
|
|
3542
|
+
candidate = void 0;
|
|
3543
|
+
active = void 0;
|
|
3544
|
+
});
|
|
3545
|
+
return {
|
|
3546
|
+
start,
|
|
3547
|
+
replace,
|
|
3548
|
+
stop,
|
|
3549
|
+
running: () => Boolean(active && alive(active.child)),
|
|
3550
|
+
replacing: () => Boolean(candidate),
|
|
3551
|
+
generation: () => active?.id ?? 0,
|
|
3552
|
+
pid: () => active?.child.pid ?? 0,
|
|
3553
|
+
activeForMs: () => active ? Date.now() - active.startedAt : Number.POSITIVE_INFINITY
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
// src/input-bridge.ts
|
|
3558
|
+
import { spawn as spawn4 } from "child_process";
|
|
3180
3559
|
var namedKeys = {
|
|
3181
3560
|
AltLeft: "Alt_L",
|
|
3182
3561
|
AltRight: "Alt_R",
|
|
@@ -3232,15 +3611,45 @@ var InputBridge = class {
|
|
|
3232
3611
|
height;
|
|
3233
3612
|
process;
|
|
3234
3613
|
pointer;
|
|
3614
|
+
closed = false;
|
|
3235
3615
|
pointerFlush;
|
|
3616
|
+
positionSequence = -1;
|
|
3617
|
+
actionId = 0;
|
|
3618
|
+
latestPosition;
|
|
3236
3619
|
pressedButtons = /* @__PURE__ */ new Set();
|
|
3237
3620
|
pressedKeys = /* @__PURE__ */ new Set();
|
|
3238
3621
|
close() {
|
|
3622
|
+
if (this.closed) return;
|
|
3623
|
+
this.closed = true;
|
|
3239
3624
|
this.release();
|
|
3240
|
-
this.process
|
|
3625
|
+
const child = this.process;
|
|
3241
3626
|
this.process = void 0;
|
|
3627
|
+
if (!child) return;
|
|
3628
|
+
child.stdin.end();
|
|
3629
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
3630
|
+
timeout.unref();
|
|
3631
|
+
child.once("exit", () => clearTimeout(timeout));
|
|
3242
3632
|
}
|
|
3243
3633
|
handle(message) {
|
|
3634
|
+
if (this.closed) return;
|
|
3635
|
+
if ("actionId" in message && message.actionId !== void 0) {
|
|
3636
|
+
if (message.actionId !== this.actionId + 1) return;
|
|
3637
|
+
this.actionId = message.actionId;
|
|
3638
|
+
}
|
|
3639
|
+
const sequence = "sequence" in message ? message.sequence : void 0;
|
|
3640
|
+
const stale = sequence !== void 0 && sequence <= this.positionSequence;
|
|
3641
|
+
if (stale && message.type === "pointer") return;
|
|
3642
|
+
if (sequence !== void 0 && !stale) {
|
|
3643
|
+
this.positionSequence = sequence;
|
|
3644
|
+
if ("x" in message) this.latestPosition = this.coordinates(message.x, message.y);
|
|
3645
|
+
}
|
|
3646
|
+
this.apply(message);
|
|
3647
|
+
if (stale && (message.type === "button" || message.type === "wheel" || message.type === "release") && this.latestPosition) {
|
|
3648
|
+
this.pointer = this.latestPosition;
|
|
3649
|
+
this.flushPointer(true);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
apply(message) {
|
|
3244
3653
|
if (message.type === "pointer") {
|
|
3245
3654
|
this.pointer = this.coordinates(message.x, message.y);
|
|
3246
3655
|
this.pointerFlush ??= setImmediate(() => this.flushPointer());
|
|
@@ -3248,7 +3657,7 @@ var InputBridge = class {
|
|
|
3248
3657
|
}
|
|
3249
3658
|
if (message.type === "button") {
|
|
3250
3659
|
this.pointer = this.coordinates(message.x, message.y);
|
|
3251
|
-
this.flushPointer();
|
|
3660
|
+
this.flushPointer(true);
|
|
3252
3661
|
const button = message.button === 0 ? 1 : message.button === 1 ? 2 : message.button === 2 ? 3 : 0;
|
|
3253
3662
|
if (!button) return;
|
|
3254
3663
|
if (message.down) {
|
|
@@ -3263,7 +3672,7 @@ var InputBridge = class {
|
|
|
3263
3672
|
}
|
|
3264
3673
|
if (message.type === "wheel") {
|
|
3265
3674
|
this.pointer = this.coordinates(message.x, message.y);
|
|
3266
|
-
this.flushPointer();
|
|
3675
|
+
this.flushPointer(true);
|
|
3267
3676
|
const vertical = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaY) / 80)));
|
|
3268
3677
|
const horizontal = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaX) / 80)));
|
|
3269
3678
|
if (Math.abs(message.deltaY) >= Math.abs(message.deltaX) && message.deltaY) {
|
|
@@ -3303,16 +3712,16 @@ var InputBridge = class {
|
|
|
3303
3712
|
y: Math.round(Math.min(1, Math.max(0, y)) * (this.height - 1))
|
|
3304
3713
|
};
|
|
3305
3714
|
}
|
|
3306
|
-
flushPointer() {
|
|
3715
|
+
flushPointer(force = false) {
|
|
3307
3716
|
if (this.pointerFlush) clearImmediate(this.pointerFlush);
|
|
3308
3717
|
this.pointerFlush = void 0;
|
|
3309
|
-
if (!this.pointer) return;
|
|
3718
|
+
if (!this.pointer || !force && this.process?.stdin.writableNeedDrain) return;
|
|
3310
3719
|
this.write(`mousemove ${this.pointer.x} ${this.pointer.y}`);
|
|
3311
3720
|
this.pointer = void 0;
|
|
3312
3721
|
}
|
|
3313
3722
|
write(command) {
|
|
3314
3723
|
if (!this.process || this.process.exitCode !== null || this.process.stdin.destroyed) {
|
|
3315
|
-
const child =
|
|
3724
|
+
const child = spawn4("xte", [], {
|
|
3316
3725
|
env: { ...process.env, DISPLAY: this.display },
|
|
3317
3726
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3318
3727
|
});
|
|
@@ -3320,6 +3729,7 @@ var InputBridge = class {
|
|
|
3320
3729
|
child.stderr.resume();
|
|
3321
3730
|
child.stdin.on("error", () => {
|
|
3322
3731
|
});
|
|
3732
|
+
child.stdin.on("drain", () => this.flushPointer());
|
|
3323
3733
|
child.once("exit", () => {
|
|
3324
3734
|
if (this.process === child) this.process = void 0;
|
|
3325
3735
|
});
|
|
@@ -3330,158 +3740,495 @@ var InputBridge = class {
|
|
|
3330
3740
|
}
|
|
3331
3741
|
};
|
|
3332
3742
|
|
|
3333
|
-
// src/
|
|
3334
|
-
var
|
|
3335
|
-
var
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
if (
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3743
|
+
// src/input-validation.ts
|
|
3744
|
+
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
3745
|
+
var optionalFinite = (value) => value === void 0 || finite(value);
|
|
3746
|
+
function validInput(value) {
|
|
3747
|
+
if (!isRecord(value) || typeof value.type !== "string") return false;
|
|
3748
|
+
if (value.sequence !== void 0 && (!finite(value.sequence) || !Number.isSafeInteger(value.sequence) || value.sequence < 0)) return false;
|
|
3749
|
+
if (value.actionId !== void 0 && (!finite(value.actionId) || !Number.isSafeInteger(value.actionId) || value.actionId < 1)) return false;
|
|
3750
|
+
switch (value.type) {
|
|
3751
|
+
case "release":
|
|
3752
|
+
case "keyframe":
|
|
3753
|
+
return true;
|
|
3754
|
+
case "theme":
|
|
3755
|
+
return value.theme === "light" || value.theme === "dark";
|
|
3756
|
+
case "ping":
|
|
3757
|
+
case "pong":
|
|
3758
|
+
return finite(value.sentAt);
|
|
3759
|
+
case "stats":
|
|
3760
|
+
return finite(value.videoFps) && finite(value.audioPacketsPerSecond) && finite(value.decodeQueue) && finite(value.rttMs) && optionalFinite(value.lossPercent) && optionalFinite(value.nacks) && optionalFinite(value.freezes) && optionalFinite(value.videoPacketsPerSecond) && optionalFinite(value.jitterBufferMs) && optionalFinite(value.availableKbps) && optionalFinite(value.playbackDroppedPercent);
|
|
3761
|
+
case "key":
|
|
3762
|
+
return typeof value.code === "string" && value.code.length < 40 && typeof value.down === "boolean";
|
|
3763
|
+
case "answer":
|
|
3764
|
+
return typeof value.sdp === "string" && value.sdp.length < 65536;
|
|
3765
|
+
case "candidate":
|
|
3766
|
+
return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
|
|
3767
|
+
case "fallback":
|
|
3768
|
+
return typeof value.reason === "string" && value.reason.length < 200;
|
|
3769
|
+
case "pointer":
|
|
3770
|
+
return finite(value.x) && finite(value.y);
|
|
3771
|
+
case "button":
|
|
3772
|
+
return finite(value.x) && finite(value.y) && finite(value.button) && typeof value.down === "boolean";
|
|
3773
|
+
case "wheel":
|
|
3774
|
+
return finite(value.x) && finite(value.y) && finite(value.deltaX) && finite(value.deltaY);
|
|
3775
|
+
default:
|
|
3776
|
+
return false;
|
|
3349
3777
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3778
|
+
}
|
|
3779
|
+
function parseInput(raw) {
|
|
3780
|
+
let value;
|
|
3781
|
+
try {
|
|
3782
|
+
value = JSON.parse(raw);
|
|
3783
|
+
} catch {
|
|
3784
|
+
return void 0;
|
|
3785
|
+
}
|
|
3786
|
+
return validInput(value) ? value : void 0;
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
// ../shared/src/desktop-stream.ts
|
|
3790
|
+
var DEFAULT_STREAM_REQUEST = {
|
|
3791
|
+
transport: "webrtc",
|
|
3792
|
+
profile: "balanced",
|
|
3793
|
+
adaptive: true,
|
|
3794
|
+
iceTransportPolicy: "all"
|
|
3795
|
+
};
|
|
3796
|
+
function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
|
|
3797
|
+
const transport = query.get("transport");
|
|
3798
|
+
const profile = query.get("profile");
|
|
3799
|
+
const adapt = query.get("adapt");
|
|
3800
|
+
const ice = query.get("ice");
|
|
3352
3801
|
return {
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3802
|
+
transport: transport === "webrtc" || transport === "websocket" ? transport : defaults.transport,
|
|
3803
|
+
profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
|
|
3804
|
+
adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
|
|
3805
|
+
iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
|
|
3357
3806
|
};
|
|
3358
3807
|
}
|
|
3359
|
-
var
|
|
3360
|
-
|
|
3361
|
-
|
|
3808
|
+
var H264_PROFILES = {
|
|
3809
|
+
high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
|
|
3810
|
+
high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
|
|
3811
|
+
main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
|
|
3812
|
+
baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
|
|
3813
|
+
};
|
|
3814
|
+
var PROFILE_ORDER = ["high444", "high", "main", "baseline"];
|
|
3815
|
+
|
|
3816
|
+
// src/media-protocol.ts
|
|
3817
|
+
var STREAM_PROTOCOL_VERSION = 3;
|
|
3818
|
+
var MEDIA_PACKET_HEADER_BYTES = 12;
|
|
3819
|
+
|
|
3820
|
+
// src/pacer.ts
|
|
3821
|
+
import { performance } from "perf_hooks";
|
|
3822
|
+
var ringSize = 4096;
|
|
3823
|
+
var staleQueueMs = 150;
|
|
3824
|
+
var burstMs = 20;
|
|
3825
|
+
var repairShare = 0.3;
|
|
3826
|
+
var repairSpacingMs = 30;
|
|
3827
|
+
var repairMaxAgeMs = 1500;
|
|
3828
|
+
var repairMaxSequenceAge = 700;
|
|
3829
|
+
var sendMaxSequenceAge = 1e3;
|
|
3830
|
+
var duplicateMaxAgeMs = 40;
|
|
3831
|
+
var duplicateQueueLimit = 64;
|
|
3832
|
+
var duplicateShare = 1;
|
|
3833
|
+
var PacedSender = class {
|
|
3834
|
+
constructor(budgetBps, send, keyframe, now = () => performance.now()) {
|
|
3835
|
+
this.budgetBps = budgetBps;
|
|
3836
|
+
this.send = send;
|
|
3837
|
+
this.keyframe = keyframe;
|
|
3838
|
+
this.now = now;
|
|
3839
|
+
this.tokens = budgetBps / 8e3 * burstMs;
|
|
3840
|
+
this.lastDrainAt = now();
|
|
3841
|
+
this.sample = { at: this.lastDrainAt, sentBytes: 0 };
|
|
3842
|
+
}
|
|
3843
|
+
budgetBps;
|
|
3844
|
+
send;
|
|
3845
|
+
keyframe;
|
|
3846
|
+
now;
|
|
3847
|
+
tokens;
|
|
3848
|
+
lastDrainAt;
|
|
3849
|
+
timer;
|
|
3850
|
+
media = [];
|
|
3851
|
+
mediaBytes = 0;
|
|
3852
|
+
sendingFrame = -1;
|
|
3853
|
+
awaitingKeyframe = true;
|
|
3854
|
+
lastStaleAt = Number.NEGATIVE_INFINITY;
|
|
3855
|
+
repairs = [];
|
|
3856
|
+
repairWindow = [];
|
|
3857
|
+
duplicates = [];
|
|
3858
|
+
mediaWindow = [];
|
|
3859
|
+
duplicateWindow = [];
|
|
3860
|
+
ring = new Array(ringSize);
|
|
3861
|
+
seq = Math.floor(Math.random() * 65536);
|
|
3862
|
+
lastKeyframe = { frameId: -1, at: 0 };
|
|
3863
|
+
protectUntil = 0;
|
|
3864
|
+
open = true;
|
|
3865
|
+
counters = { staleEvents: 0, droppedFrames: 0, repairsSent: 0, repairsSkipped: 0, duplicatesSent: 0, sentBytes: 0 };
|
|
3866
|
+
sample;
|
|
3867
|
+
setBudget(bitsPerSecond) {
|
|
3868
|
+
this.budgetBps = bitsPerSecond;
|
|
3869
|
+
this.bound(this.now());
|
|
3870
|
+
}
|
|
3871
|
+
/** Duplicate protection is worth its bandwidth only while loss is hurting; it never displaces fresh media. */
|
|
3872
|
+
protect(forMs) {
|
|
3873
|
+
this.protectUntil = forMs > 0 ? this.now() + forMs : 0;
|
|
3874
|
+
if (!forMs) this.duplicates.length = 0;
|
|
3875
|
+
}
|
|
3876
|
+
enqueue(packet, frameId, keyframe, last) {
|
|
3877
|
+
if (!this.open) return;
|
|
3878
|
+
if (keyframe) this.awaitingKeyframe = false;
|
|
3879
|
+
else if (this.awaitingKeyframe) return;
|
|
3880
|
+
this.media.push({ packet, frameId, keyframe, last });
|
|
3881
|
+
this.mediaBytes += packet.length;
|
|
3882
|
+
this.bound(this.now());
|
|
3883
|
+
this.drain();
|
|
3362
3884
|
}
|
|
3363
|
-
rate;
|
|
3364
|
-
base;
|
|
3365
|
-
last;
|
|
3366
|
-
wraps = 0;
|
|
3367
|
-
origin = 0;
|
|
3368
|
-
emitted = 0;
|
|
3369
3885
|
/**
|
|
3370
|
-
*
|
|
3371
|
-
*
|
|
3372
|
-
*
|
|
3373
|
-
* monotonic presentation timestamps.
|
|
3886
|
+
* Answers a receiver NACK for the sequence numbers it reports missing. The
|
|
3887
|
+
* send itself is deferred: this runs inside the native feedback callback,
|
|
3888
|
+
* where a failed send would abort the process instead of throwing.
|
|
3374
3889
|
*/
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3890
|
+
repair(sequences, rttMs) {
|
|
3891
|
+
const now = this.now();
|
|
3892
|
+
for (const seq of sequences) {
|
|
3893
|
+
const sent = this.ring[seq % ringSize];
|
|
3894
|
+
if (!sent || sent.seq !== seq || now - sent.sentAt > repairMaxAgeMs || now - sent.repairedAt < repairSpacingMs || (this.seq - seq & 65535) > repairMaxSequenceAge || sent.frameId < this.lastKeyframe.frameId && now - this.lastKeyframe.at > rttMs + 100 || this.repairs.includes(sent)) {
|
|
3895
|
+
this.counters.repairsSkipped += 1;
|
|
3896
|
+
continue;
|
|
3897
|
+
}
|
|
3898
|
+
this.repairs.push(sent);
|
|
3899
|
+
}
|
|
3900
|
+
if (this.repairs.length > 0) this.schedule(0);
|
|
3380
3901
|
}
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
this.
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3902
|
+
snapshot() {
|
|
3903
|
+
const now = this.now();
|
|
3904
|
+
const sentKbps = Math.round((this.counters.sentBytes - this.sample.sentBytes) * 8 / Math.max(1, now - this.sample.at));
|
|
3905
|
+
this.sample = { at: now, sentBytes: this.counters.sentBytes };
|
|
3906
|
+
return {
|
|
3907
|
+
budgetKbps: Math.round(this.budgetBps / 1e3),
|
|
3908
|
+
queueMs: Math.round(this.mediaBytes * 8e3 / this.budgetBps),
|
|
3909
|
+
queuedPackets: this.media.length,
|
|
3910
|
+
staleEvents: this.counters.staleEvents,
|
|
3911
|
+
droppedFrames: this.counters.droppedFrames,
|
|
3912
|
+
repairsSent: this.counters.repairsSent,
|
|
3913
|
+
repairsSkipped: this.counters.repairsSkipped,
|
|
3914
|
+
duplicatesSent: this.counters.duplicatesSent,
|
|
3915
|
+
protecting: now < this.protectUntil,
|
|
3916
|
+
sentKbps
|
|
3917
|
+
};
|
|
3388
3918
|
}
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
timestamp;
|
|
3396
|
-
sequence;
|
|
3397
|
-
fragmentType;
|
|
3398
|
-
damaged = false;
|
|
3399
|
-
clearFrame() {
|
|
3400
|
-
this.chunks = [];
|
|
3401
|
-
this.key = false;
|
|
3402
|
-
this.timestamp = void 0;
|
|
3403
|
-
this.sequence = void 0;
|
|
3404
|
-
this.fragmentType = void 0;
|
|
3405
|
-
this.damaged = false;
|
|
3919
|
+
close() {
|
|
3920
|
+
this.open = false;
|
|
3921
|
+
clearTimeout(this.timer);
|
|
3922
|
+
this.media.length = 0;
|
|
3923
|
+
this.repairs.length = 0;
|
|
3924
|
+
this.duplicates.length = 0;
|
|
3406
3925
|
}
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3926
|
+
/**
|
|
3927
|
+
* Cuts a stale queue back to what a decoder can still use. Frames before the
|
|
3928
|
+
* first queued keyframe are worthless once one is coming; without one, every
|
|
3929
|
+
* unsent delta frame goes and deltas keep going until the encoder answers the
|
|
3930
|
+
* keyframe request. The frame being transmitted always completes.
|
|
3931
|
+
*/
|
|
3932
|
+
bound(now) {
|
|
3933
|
+
if (this.mediaBytes * 8e3 / this.budgetBps <= staleQueueMs) return;
|
|
3934
|
+
const keyframeAt = this.media.findIndex((entry) => entry.keyframe);
|
|
3935
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
3936
|
+
const kept = [];
|
|
3937
|
+
this.media.forEach((entry, index) => {
|
|
3938
|
+
if (entry.frameId === this.sendingFrame || (keyframeAt >= 0 ? index >= keyframeAt : entry.keyframe)) kept.push(entry);
|
|
3939
|
+
else dropped.add(entry.frameId);
|
|
3940
|
+
});
|
|
3941
|
+
if (dropped.size === 0) return;
|
|
3942
|
+
this.media.length = 0;
|
|
3943
|
+
this.media.push(...kept);
|
|
3944
|
+
this.mediaBytes = kept.reduce((total, entry) => total + entry.packet.length, 0);
|
|
3945
|
+
this.counters.droppedFrames += dropped.size;
|
|
3946
|
+
this.counters.staleEvents += 1;
|
|
3947
|
+
if (keyframeAt >= 0) return;
|
|
3948
|
+
this.awaitingKeyframe = true;
|
|
3949
|
+
if (now - this.lastStaleAt < 500) return;
|
|
3950
|
+
this.lastStaleAt = now;
|
|
3951
|
+
this.keyframe();
|
|
3410
3952
|
}
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
this.
|
|
3953
|
+
schedule(delayMs) {
|
|
3954
|
+
this.timer ??= setTimeout(() => {
|
|
3955
|
+
this.timer = void 0;
|
|
3956
|
+
this.drain();
|
|
3957
|
+
}, delayMs);
|
|
3958
|
+
}
|
|
3959
|
+
drain() {
|
|
3960
|
+
if (!this.open) return;
|
|
3961
|
+
const now = this.now();
|
|
3962
|
+
const rate = this.budgetBps / 8e3;
|
|
3963
|
+
this.tokens = Math.min(rate * burstMs, this.tokens + (now - this.lastDrainAt) * rate);
|
|
3964
|
+
this.lastDrainAt = now;
|
|
3965
|
+
for (let repair = this.repairs[0]; repair && this.tokens >= repair.packet.length; repair = this.repairs[0]) {
|
|
3966
|
+
this.repairs.shift();
|
|
3967
|
+
if (!this.repairAllowed(now, repair.packet.length) || (this.seq - repair.seq & 65535) > sendMaxSequenceAge) {
|
|
3968
|
+
this.counters.repairsSkipped += 1;
|
|
3969
|
+
continue;
|
|
3970
|
+
}
|
|
3971
|
+
repair.repairedAt = now;
|
|
3972
|
+
this.repairWindow.push({ at: now, bytes: repair.packet.length });
|
|
3973
|
+
this.transmit(repair.packet);
|
|
3974
|
+
this.counters.repairsSent += 1;
|
|
3414
3975
|
}
|
|
3415
|
-
this.
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
this.
|
|
3422
|
-
this.
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3976
|
+
for (let next = this.media[0]; next && this.tokens >= next.packet.length; next = this.media[0]) {
|
|
3977
|
+
this.media.shift();
|
|
3978
|
+
this.mediaBytes -= next.packet.length;
|
|
3979
|
+
this.dispatch(next, now);
|
|
3980
|
+
}
|
|
3981
|
+
while (this.media.length === 0) {
|
|
3982
|
+
const copy = this.duplicates[0];
|
|
3983
|
+
if (!copy || this.tokens < copy.packet.length) break;
|
|
3984
|
+
if (now - copy.sentAt <= duplicateMaxAgeMs && !this.duplicateAllowed(now, copy.packet.length)) break;
|
|
3985
|
+
this.duplicates.shift();
|
|
3986
|
+
if (now - copy.sentAt > duplicateMaxAgeMs || (this.seq - copy.seq & 65535) > sendMaxSequenceAge) continue;
|
|
3987
|
+
this.duplicateWindow.push({ at: now, bytes: copy.packet.length });
|
|
3988
|
+
this.transmit(copy.packet);
|
|
3989
|
+
this.counters.duplicatesSent += 1;
|
|
3990
|
+
}
|
|
3991
|
+
const head = this.repairs[0] ?? this.media[0] ?? this.duplicates[0];
|
|
3992
|
+
if (head) this.schedule(Math.max(1, Math.ceil((head.packet.length - this.tokens) / rate)));
|
|
3993
|
+
}
|
|
3994
|
+
dispatch(entry, now) {
|
|
3995
|
+
const packet = Buffer.from(entry.packet);
|
|
3996
|
+
const seq = this.seq;
|
|
3997
|
+
this.seq = seq + 1 & 65535;
|
|
3998
|
+
packet.writeUInt16BE(seq, 2);
|
|
3999
|
+
const sent = { seq, packet, frameId: entry.frameId, sentAt: now, repairedAt: Number.NEGATIVE_INFINITY };
|
|
4000
|
+
this.ring[seq % ringSize] = sent;
|
|
4001
|
+
this.sendingFrame = entry.last ? -1 : entry.frameId;
|
|
4002
|
+
if (entry.keyframe && this.lastKeyframe.frameId !== entry.frameId) this.lastKeyframe = { frameId: entry.frameId, at: now };
|
|
4003
|
+
this.transmit(packet);
|
|
4004
|
+
while (this.mediaWindow.length > 0 && now - this.mediaWindow[0].at > 1e3) this.mediaWindow.shift();
|
|
4005
|
+
this.mediaWindow.push({ at: now, bytes: packet.length });
|
|
4006
|
+
if (now >= this.protectUntil) return;
|
|
4007
|
+
if (this.duplicates.length >= duplicateQueueLimit) this.duplicates.shift();
|
|
4008
|
+
this.duplicates.push(sent);
|
|
4009
|
+
}
|
|
4010
|
+
duplicateAllowed(now, bytes) {
|
|
4011
|
+
for (const window of [this.mediaWindow, this.duplicateWindow]) while (window.length > 0 && now - window[0].at > 1e3) window.shift();
|
|
4012
|
+
const media = this.mediaWindow.reduce((total, entry) => total + entry.bytes, 0);
|
|
4013
|
+
return this.duplicateWindow.reduce((total, entry) => total + entry.bytes, bytes) <= media * duplicateShare;
|
|
4014
|
+
}
|
|
4015
|
+
repairAllowed(now, bytes) {
|
|
4016
|
+
while (this.repairWindow.length > 0 && now - this.repairWindow[0].at > 1e3) this.repairWindow.shift();
|
|
4017
|
+
return this.repairWindow.reduce((total, entry) => total + entry.bytes, bytes) <= this.budgetBps / 8 * repairShare;
|
|
4018
|
+
}
|
|
4019
|
+
transmit(packet) {
|
|
4020
|
+
this.tokens -= packet.length;
|
|
4021
|
+
this.counters.sentBytes += packet.length;
|
|
4022
|
+
this.send(packet);
|
|
4023
|
+
}
|
|
4024
|
+
};
|
|
4025
|
+
|
|
4026
|
+
// src/rtcp.ts
|
|
4027
|
+
var RTPFB = 205;
|
|
4028
|
+
var PSFB = 206;
|
|
4029
|
+
function parseRtcpFeedback(packet, mediaSsrc) {
|
|
4030
|
+
const feedback = { pli: false, nacks: [] };
|
|
4031
|
+
let offset = 0;
|
|
4032
|
+
while (offset + 4 <= packet.length) {
|
|
4033
|
+
const size = (packet.readUInt16BE(offset + 2) + 1) * 4;
|
|
4034
|
+
if (packet[offset] >> 6 !== 2 || offset + size > packet.length) return null;
|
|
4035
|
+
let end = offset + size;
|
|
4036
|
+
if (packet[offset] & 32) {
|
|
4037
|
+
const padding = packet[end - 1];
|
|
4038
|
+
if (!padding || padding > size - 4 || end !== packet.length) return null;
|
|
4039
|
+
end -= padding;
|
|
4040
|
+
}
|
|
4041
|
+
const format2 = packet[offset] & 31;
|
|
4042
|
+
const type = packet[offset + 1];
|
|
4043
|
+
if (type === PSFB && format2 === 1 && end - offset >= 12 && packet.readUInt32BE(offset + 8) === mediaSsrc) feedback.pli = true;
|
|
4044
|
+
else if (type === RTPFB && format2 === 1 && end - offset >= 16 && packet.readUInt32BE(offset + 8) === mediaSsrc) {
|
|
4045
|
+
for (let item = offset + 12; item + 4 <= end; item += 4) {
|
|
4046
|
+
const pid = packet.readUInt16BE(item);
|
|
4047
|
+
const blp = packet.readUInt16BE(item + 2);
|
|
4048
|
+
feedback.nacks.push(pid);
|
|
4049
|
+
for (let bit = 0; bit < 16; bit += 1) if (blp & 1 << bit) feedback.nacks.push(pid + bit + 1 & 65535);
|
|
3442
4050
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
4051
|
+
} else if (type === PSFB && format2 === 15 && end - offset >= 20 && packet.toString("latin1", offset + 12, offset + 16) === "REMB") {
|
|
4052
|
+
const exponent = packet[offset + 17] >> 2;
|
|
4053
|
+
const mantissa = (packet[offset + 17] & 3) << 16 | packet[offset + 18] << 8 | packet[offset + 19];
|
|
4054
|
+
feedback.rembBps = mantissa * 2 ** exponent;
|
|
4055
|
+
}
|
|
4056
|
+
offset += size;
|
|
4057
|
+
}
|
|
4058
|
+
return offset === packet.length ? feedback : null;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
// src/webrtc.ts
|
|
4062
|
+
var OPUS_PAYLOAD_TYPE = 111;
|
|
4063
|
+
var VIDEO_SSRC = 1382387713;
|
|
4064
|
+
var AUDIO_SSRC = 1382387714;
|
|
4065
|
+
var PLAYOUT_DELAY_ID = 6;
|
|
4066
|
+
var playoutDelayUri = "http://www.webrtc.org/experiments/rtp-hdrext/playout-delay";
|
|
4067
|
+
var ndc;
|
|
4068
|
+
async function loadWebRtc() {
|
|
4069
|
+
try {
|
|
4070
|
+
ndc = (await import("node-datachannel")).default;
|
|
4071
|
+
ndc.initLogger("Error", (level, message) => process.stderr.write(`[desktop-stream] webrtc ${level.toLowerCase()}: ${message}
|
|
4072
|
+
`));
|
|
4073
|
+
return true;
|
|
4074
|
+
} catch (error) {
|
|
4075
|
+
process.stderr.write(`[desktop-stream] WebRTC unavailable, streaming over the socket only: ${error instanceof Error ? error.message : String(error)}
|
|
4076
|
+
`);
|
|
4077
|
+
return false;
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
var cleanupWebRtc = () => ndc?.cleanup();
|
|
4081
|
+
function parseIceServers(spec) {
|
|
4082
|
+
const native = (spec ?? "stun:stun.l.google.com:19302").split(",").map((server) => server.trim()).filter(Boolean);
|
|
4083
|
+
const browser = native.map((url) => {
|
|
4084
|
+
const match = url.match(/^(turns?):([^:@]+):(.+)@([^@]+)$/);
|
|
4085
|
+
return match ? { urls: `${match[1]}:${match[4]}`, username: match[2], credential: match[3] } : { urls: url };
|
|
4086
|
+
});
|
|
4087
|
+
return { native, browser };
|
|
4088
|
+
}
|
|
4089
|
+
var WebRtcViewer = class {
|
|
4090
|
+
constructor(options, handlers) {
|
|
4091
|
+
this.handlers = handlers;
|
|
4092
|
+
if (!ndc) throw new Error("WebRTC is not loaded");
|
|
4093
|
+
this.peer = new ndc.PeerConnection("viewer", {
|
|
4094
|
+
iceServers: options.iceServers,
|
|
4095
|
+
iceTransportPolicy: options.iceTransportPolicy,
|
|
4096
|
+
disableAutoNegotiation: true,
|
|
4097
|
+
...options.portRange ? { portRangeBegin: options.portRange.begin, portRangeEnd: options.portRange.end } : {}
|
|
4098
|
+
});
|
|
4099
|
+
const video = new ndc.Video("0", "SendOnly");
|
|
4100
|
+
for (const { payloadType, profileLevelId } of Object.values(H264_PROFILES)) {
|
|
4101
|
+
video.addH264Codec(payloadType, `profile-level-id=${profileLevelId};packetization-mode=1;level-asymmetry-allowed=1`);
|
|
4102
|
+
}
|
|
4103
|
+
video.addSSRC(VIDEO_SSRC, "replicas-video", "replicas-desktop", "video");
|
|
4104
|
+
video.parseSdpLine(`a=extmap:${PLAYOUT_DELAY_ID} ${playoutDelayUri}`);
|
|
4105
|
+
this.video = this.peer.addTrack(video);
|
|
4106
|
+
this.pacer = new PacedSender(options.budgetBps, (packet) => this.transmit(this.video, packet), () => handlers.keyframe("stale"));
|
|
4107
|
+
this.video.onMessage((raw) => {
|
|
4108
|
+
if (this.connected && typeof raw !== "string") this.onFeedback(raw);
|
|
4109
|
+
});
|
|
4110
|
+
const audio = new ndc.Audio("1", "SendOnly");
|
|
4111
|
+
audio.addOpusCodec(OPUS_PAYLOAD_TYPE, "minptime=10;useinbandfec=1");
|
|
4112
|
+
audio.addSSRC(AUDIO_SSRC, "replicas-audio", "replicas-desktop", "audio");
|
|
4113
|
+
this.audio = this.peer.addTrack(audio);
|
|
4114
|
+
this.channels = [this.peer.createDataChannel("input"), this.peer.createDataChannel("pointer", { unordered: true, maxRetransmits: 0 })];
|
|
4115
|
+
for (const resource of [this.video, this.audio, ...this.channels]) resource.onClosed(() => {
|
|
4116
|
+
});
|
|
4117
|
+
for (const channel of this.channels) {
|
|
4118
|
+
channel.onMessage((raw) => {
|
|
4119
|
+
const message = typeof raw === "string" ? parseInput(raw) : void 0;
|
|
4120
|
+
if (message) handlers.message(message, (reply) => void (channel.isOpen() && channel.sendMessage(JSON.stringify(reply))));
|
|
4121
|
+
});
|
|
4122
|
+
}
|
|
4123
|
+
this.peer.onLocalDescription((sdp) => handlers.offer(sdp));
|
|
4124
|
+
this.peer.onLocalCandidate((candidate, mid) => handlers.candidate(candidate, mid));
|
|
4125
|
+
this.peer.onStateChange((state) => {
|
|
4126
|
+
this.connected = !this.closed && state === "connected";
|
|
4127
|
+
if (this.connected) handlers.connected();
|
|
4128
|
+
else if (!this.closed && (state === "disconnected" || state === "failed" || state === "closed")) handlers.disconnected(state);
|
|
4129
|
+
});
|
|
4130
|
+
this.peer.setLocalDescription();
|
|
4131
|
+
}
|
|
4132
|
+
handlers;
|
|
4133
|
+
peer;
|
|
4134
|
+
video;
|
|
4135
|
+
audio;
|
|
4136
|
+
channels;
|
|
4137
|
+
pacer;
|
|
4138
|
+
profiles = /* @__PURE__ */ new Set();
|
|
4139
|
+
connected = false;
|
|
4140
|
+
closed = false;
|
|
4141
|
+
remb;
|
|
4142
|
+
answer(sdp) {
|
|
4143
|
+
const section = sdp.split(/^m=/m).find((part) => part.startsWith("video")) ?? "";
|
|
4144
|
+
for (const profile of PROFILE_ORDER) {
|
|
4145
|
+
if (new RegExp(`^a=rtpmap:${H264_PROFILES[profile].payloadType} H264/90000`, "m").test(section)) this.profiles.add(profile);
|
|
4146
|
+
}
|
|
4147
|
+
this.peer.setRemoteDescription(sdp, "answer");
|
|
4148
|
+
}
|
|
4149
|
+
candidate(candidate, mid) {
|
|
4150
|
+
this.peer.addRemoteCandidate(candidate, mid);
|
|
4151
|
+
}
|
|
4152
|
+
setBudget(bitsPerSecond) {
|
|
4153
|
+
this.pacer.setBudget(bitsPerSecond);
|
|
4154
|
+
}
|
|
4155
|
+
protect(forMs) {
|
|
4156
|
+
this.pacer.protect(forMs);
|
|
4157
|
+
}
|
|
4158
|
+
enqueueVideo(packet, frameId, keyframe, last) {
|
|
4159
|
+
if (this.connected && this.video.isOpen()) this.pacer.enqueue(packet, frameId, keyframe, last);
|
|
4160
|
+
}
|
|
4161
|
+
sendAudio(packet) {
|
|
4162
|
+
if (this.connected && this.audio.isOpen()) this.transmit(this.audio, packet);
|
|
4163
|
+
}
|
|
4164
|
+
snapshot() {
|
|
4165
|
+
return { ...this.pacer.snapshot(), rembKbps: this.remb === void 0 ? null : Math.round(this.remb / 1e3) };
|
|
4166
|
+
}
|
|
4167
|
+
get rttMs() {
|
|
4168
|
+
const rtt = this.connected ? this.peer.rtt() : -1;
|
|
4169
|
+
return rtt > 0 ? rtt : null;
|
|
4170
|
+
}
|
|
4171
|
+
get path() {
|
|
4172
|
+
const pair = this.connected ? this.peer.getSelectedCandidatePair() : null;
|
|
4173
|
+
return pair ? `${pair.local.type}/${pair.remote.type} ${pair.remote.transportType.toLowerCase()}` : null;
|
|
4174
|
+
}
|
|
4175
|
+
close() {
|
|
4176
|
+
if (this.closed) return;
|
|
4177
|
+
this.closed = true;
|
|
4178
|
+
this.connected = false;
|
|
4179
|
+
this.pacer.close();
|
|
4180
|
+
for (const resource of [...this.channels, this.video, this.audio, this.peer]) {
|
|
4181
|
+
try {
|
|
4182
|
+
resource.close();
|
|
4183
|
+
} catch {
|
|
3456
4184
|
}
|
|
3457
|
-
if (!this.damaged) this.chunks.push(packet.payload.subarray(2));
|
|
3458
|
-
} else this.damaged = true;
|
|
3459
|
-
if (!packet.marker) return null;
|
|
3460
|
-
if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
|
|
3461
|
-
this.clearFrame();
|
|
3462
|
-
return null;
|
|
3463
4185
|
}
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
4186
|
+
}
|
|
4187
|
+
onFeedback(raw) {
|
|
4188
|
+
const feedback = parseRtcpFeedback(raw, VIDEO_SSRC);
|
|
4189
|
+
if (!feedback) return;
|
|
4190
|
+
if (feedback.pli) this.handlers.keyframe("pli");
|
|
4191
|
+
if (feedback.rembBps !== void 0) {
|
|
4192
|
+
this.remb = feedback.rembBps;
|
|
4193
|
+
this.handlers.remb(feedback.rembBps);
|
|
4194
|
+
}
|
|
4195
|
+
if (feedback.nacks.length > 0) this.pacer.repair(feedback.nacks, this.rttMs ?? 100);
|
|
4196
|
+
}
|
|
4197
|
+
sendFailures = 0;
|
|
4198
|
+
// Sends run from pacer timers, where a thrown error would take the whole
|
|
4199
|
+
// stream server down. Native closure racing the isOpen check is expected;
|
|
4200
|
+
// anything else is logged a few times and the packet is dropped.
|
|
4201
|
+
transmit(track, packet) {
|
|
4202
|
+
try {
|
|
4203
|
+
track.sendMessageBinary(packet);
|
|
4204
|
+
} catch (error) {
|
|
4205
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4206
|
+
if (/closed|not open/i.test(message) || this.sendFailures++ >= 5) return;
|
|
4207
|
+
process.stderr.write(`[desktop-stream] webrtc send failed: ${message}
|
|
4208
|
+
`);
|
|
3470
4209
|
}
|
|
3471
|
-
const frame = { data, key: this.key, timestamp: this.clock.microseconds(packet.timestamp) };
|
|
3472
|
-
this.clearFrame();
|
|
3473
|
-
return frame;
|
|
3474
4210
|
}
|
|
3475
4211
|
};
|
|
3476
4212
|
|
|
3477
4213
|
// src/media-server.ts
|
|
3478
|
-
import WebSocket2, { WebSocketServer } from "ws";
|
|
3479
4214
|
var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
|
|
3480
4215
|
var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
|
|
3481
|
-
var
|
|
4216
|
+
var basePreset = availableParallelism() >= 4 ? "superfast" : "ultrafast";
|
|
3482
4217
|
var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "", 10) || 60;
|
|
3483
|
-
var x264Preset = cores >= 4 ? "superfast" : "ultrafast";
|
|
3484
4218
|
var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
|
|
4219
|
+
var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
|
|
4220
|
+
var webrtcEnabled = process.env.REPLICAS_DESKTOP_STREAM_WEBRTC !== "0";
|
|
4221
|
+
var runtimeEnvFile = process.env.REPLICAS_RUNTIME_ENV_FILE || "/home/user/.replicas/runtime-env.sh";
|
|
4222
|
+
var colorMode = process.env.REPLICAS_DESKTOP_STREAM_COLOR === "legacy" ? "legacy" : "bt709";
|
|
4223
|
+
var defaultRequest = parseStreamRequest(new URLSearchParams({
|
|
4224
|
+
transport: process.env.REPLICAS_DESKTOP_STREAM_TRANSPORT ?? "",
|
|
4225
|
+
profile: process.env.REPLICAS_DESKTOP_STREAM_PROFILE ?? "",
|
|
4226
|
+
adapt: process.env.REPLICAS_DESKTOP_STREAM_ADAPTIVE ?? ""
|
|
4227
|
+
}));
|
|
4228
|
+
var icePorts = (() => {
|
|
4229
|
+
const match = process.env.REPLICAS_DESKTOP_STREAM_ICE_PORTS?.match(/^(\d+)-(\d+)$/);
|
|
4230
|
+
return match ? { begin: Number(match[1]), end: Number(match[2]) } : void 0;
|
|
4231
|
+
})();
|
|
3485
4232
|
var configuredTopKbps = (() => {
|
|
3486
4233
|
const raw = process.env.REPLICAS_DESKTOP_STREAM_VIDEO_BITRATE?.trim();
|
|
3487
4234
|
const match = raw?.match(/^(\d+(?:\.\d+)?)([MmKk]?)$/);
|
|
@@ -3489,32 +4236,25 @@ var configuredTopKbps = (() => {
|
|
|
3489
4236
|
const value = Number.parseFloat(match[1]);
|
|
3490
4237
|
return Math.max(1, Math.round(match[2].toLowerCase() === "m" ? value * 1e3 : value));
|
|
3491
4238
|
})();
|
|
3492
|
-
var
|
|
3493
|
-
{
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
];
|
|
3498
|
-
var
|
|
3499
|
-
var
|
|
4239
|
+
var profiles = {
|
|
4240
|
+
balanced: { crf: 14, chromaQpOffset: 0 },
|
|
4241
|
+
text: { crf: 10, chromaQpOffset: -6 }
|
|
4242
|
+
};
|
|
4243
|
+
var rungCaps = [configuredTopKbps, 12e3, 8e3, 5e3, 3e3, 1500].map((kbps) => Math.min(kbps, configuredTopKbps));
|
|
4244
|
+
var qualityLadder = (profile) => rungCaps.map((kbps, index) => ({ kbps, crf: profiles[profile].crf + 2 * index, keyframeMs: index >= 4 ? 2e3 : 1e3 }));
|
|
4245
|
+
var fullChromaMinKbps = 8e3;
|
|
4246
|
+
var fallbackKeyframeMs = 250;
|
|
4247
|
+
var webrtcStartupRung = 2;
|
|
4248
|
+
var vbvMs = 100;
|
|
3500
4249
|
var targetBacklogMs = 120;
|
|
3501
4250
|
var congestedBacklogMs = 250;
|
|
3502
4251
|
var congestedDecodeQueue = 3;
|
|
3503
4252
|
var fallbackBufferedBytes = 512 * 1024;
|
|
3504
4253
|
var maximumBufferedBytes = 4 * 1024 * 1024;
|
|
3505
|
-
var
|
|
3506
|
-
var
|
|
3507
|
-
var
|
|
3508
|
-
|
|
3509
|
-
protocolVersion: STREAM_PROTOCOL_VERSION,
|
|
3510
|
-
width,
|
|
3511
|
-
height,
|
|
3512
|
-
fps,
|
|
3513
|
-
videoCodec,
|
|
3514
|
-
audioCodec: "opus",
|
|
3515
|
-
audioSampleRate: 48e3,
|
|
3516
|
-
audioChannels: 2
|
|
3517
|
-
};
|
|
4254
|
+
var probeIntervalMs = 250;
|
|
4255
|
+
var telemetryIntervalMs = 500;
|
|
4256
|
+
var budgetFactor = 1.25;
|
|
4257
|
+
var protectMs = 1e4;
|
|
3518
4258
|
var packageRoot = resolve2(dirname6(fileURLToPath(import.meta.url)), "..");
|
|
3519
4259
|
var webroot = process.env.REPLICAS_DESKTOP_STREAM_WEBROOT || resolve2(packageRoot, "assets", "desktop");
|
|
3520
4260
|
var contentTypes = {
|
|
@@ -3524,17 +4264,6 @@ var contentTypes = {
|
|
|
3524
4264
|
".svg": "image/svg+xml",
|
|
3525
4265
|
".woff2": "font/woff2"
|
|
3526
4266
|
};
|
|
3527
|
-
function bindUdp() {
|
|
3528
|
-
return new Promise((resolveSocket, reject) => {
|
|
3529
|
-
const socket = createSocket("udp4");
|
|
3530
|
-
socket.once("error", reject);
|
|
3531
|
-
socket.bind(0, "127.0.0.1", () => {
|
|
3532
|
-
socket.off("error", reject);
|
|
3533
|
-
socket.setRecvBufferSize(4 * 1024 * 1024);
|
|
3534
|
-
resolveSocket(socket);
|
|
3535
|
-
});
|
|
3536
|
-
});
|
|
3537
|
-
}
|
|
3538
4267
|
function mediaPacket(kind, timestamp, payload) {
|
|
3539
4268
|
const header = Buffer.alloc(MEDIA_PACKET_HEADER_BYTES);
|
|
3540
4269
|
header[0] = kind;
|
|
@@ -3542,17 +4271,13 @@ function mediaPacket(kind, timestamp, payload) {
|
|
|
3542
4271
|
header.writeBigUInt64BE(BigInt(Math.max(0, timestamp)), 4);
|
|
3543
4272
|
return Buffer.concat([header, payload]);
|
|
3544
4273
|
}
|
|
3545
|
-
function
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
if (!("x" in value) || !("y" in value) || typeof value.x !== "number" || typeof value.y !== "number") return false;
|
|
3553
|
-
if (value.type === "pointer") return true;
|
|
3554
|
-
if (value.type === "button") return "button" in value && typeof value.button === "number" && "down" in value && typeof value.down === "boolean";
|
|
3555
|
-
return value.type === "wheel" && "deltaX" in value && typeof value.deltaX === "number" && "deltaY" in value && typeof value.deltaY === "number";
|
|
4274
|
+
function currentIceServers() {
|
|
4275
|
+
let spec = process.env.REPLICAS_DESKTOP_STREAM_ICE_SERVERS;
|
|
4276
|
+
try {
|
|
4277
|
+
spec = parsePosixEnvFile(readFileSync6(runtimeEnvFile, "utf8")).REPLICAS_DESKTOP_STREAM_ICE_SERVERS ?? spec;
|
|
4278
|
+
} catch {
|
|
4279
|
+
}
|
|
4280
|
+
return parseIceServers(spec);
|
|
3556
4281
|
}
|
|
3557
4282
|
var appliedTheme;
|
|
3558
4283
|
var pendingTheme;
|
|
@@ -3571,142 +4296,270 @@ function drainDesktopTheme() {
|
|
|
3571
4296
|
if (applied) appliedTheme = theme;
|
|
3572
4297
|
drainDesktopTheme();
|
|
3573
4298
|
};
|
|
3574
|
-
const child =
|
|
4299
|
+
const child = spawn5("bash", [SERVICES_SCRIPT, "--theme", theme], { detached: true, stdio: "ignore" });
|
|
3575
4300
|
child.unref();
|
|
3576
4301
|
child.once("exit", (code) => finish(code === 0));
|
|
3577
4302
|
child.once("error", () => finish(false));
|
|
3578
4303
|
}
|
|
4304
|
+
function processCpuMs(pid) {
|
|
4305
|
+
if (!pid) return 0;
|
|
4306
|
+
try {
|
|
4307
|
+
const stat = readFileSync6(`/proc/${pid}/stat`, "utf8");
|
|
4308
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
4309
|
+
return (Number(fields[11]) + Number(fields[12])) * 10;
|
|
4310
|
+
} catch {
|
|
4311
|
+
return 0;
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
3579
4314
|
async function runMediaServer() {
|
|
3580
4315
|
if (![width, height, fps, port].every((value) => Number.isInteger(value) && value > 0)) throw new Error("Invalid desktop stream dimensions, frame rate, or port");
|
|
3581
|
-
const
|
|
3582
|
-
const
|
|
3583
|
-
const videoPort = videoSocket.address().port;
|
|
3584
|
-
const audioPort = audioSocket.address().port;
|
|
4316
|
+
const webrtc = webrtcEnabled && await loadWebRtc();
|
|
4317
|
+
const viewers = /* @__PURE__ */ new Map();
|
|
3585
4318
|
const videoAssembler = new H264RtpAssembler();
|
|
3586
4319
|
const audioClock = new RtpClock(48e3);
|
|
3587
|
-
const
|
|
3588
|
-
|
|
4320
|
+
const videoSplicer = new RtpSplicer();
|
|
4321
|
+
const audioSplicer = new RtpSplicer();
|
|
4322
|
+
let activeProfile = "balanced";
|
|
4323
|
+
let adaptive = true;
|
|
3589
4324
|
let rung = 0;
|
|
3590
4325
|
let descents = 0;
|
|
3591
4326
|
let congestedSeconds = 0;
|
|
3592
4327
|
let comfortableSeconds = 0;
|
|
4328
|
+
let cleanTransportSeconds = 0;
|
|
4329
|
+
let lastDescentAt = 0;
|
|
4330
|
+
let lastAscentAt = 0;
|
|
4331
|
+
let blockedRung = -1;
|
|
4332
|
+
let blockedUntil = 0;
|
|
4333
|
+
let preset = basePreset;
|
|
4334
|
+
let starvedSeconds = 0;
|
|
4335
|
+
let encoderHealthyTicks = 0;
|
|
4336
|
+
let presetHealthySeconds = 0;
|
|
4337
|
+
let framesAtTick = 0;
|
|
4338
|
+
let bytesAtTick = 0;
|
|
4339
|
+
let videoKbps = 0;
|
|
4340
|
+
const recentVideoKbps = [];
|
|
4341
|
+
let encoderFps = 0;
|
|
4342
|
+
let videoWanted = false;
|
|
4343
|
+
let encoderSignature = "";
|
|
4344
|
+
let encoderStop;
|
|
4345
|
+
let joinKeyframe;
|
|
3593
4346
|
let lastVideoAt = 0;
|
|
4347
|
+
let lastKeyframeAt = 0;
|
|
4348
|
+
let lastSwapAt = 0;
|
|
3594
4349
|
let lastAudioAt = 0;
|
|
3595
4350
|
let videoFrames = 0;
|
|
4351
|
+
let videoKeyframes = 0;
|
|
3596
4352
|
let audioPackets = 0;
|
|
3597
4353
|
let videoBytes = 0;
|
|
3598
4354
|
let audioBytes = 0;
|
|
3599
|
-
const
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
4355
|
+
const ladder = () => qualityLadder(activeProfile);
|
|
4356
|
+
const socketViewers = () => [...viewers.values()].filter((viewer) => viewer.fallback);
|
|
4357
|
+
const profile = () => {
|
|
4358
|
+
const requires420 = ladder()[rung].kbps < fullChromaMinKbps || socketViewers().length > 0;
|
|
4359
|
+
const accepted = [...viewers.values()].flatMap((viewer) => viewer.rtc && viewer.rtc.profiles.size ? [viewer.rtc.profiles] : []);
|
|
4360
|
+
if (accepted.length === 0) return "main";
|
|
4361
|
+
return PROFILE_ORDER.find((candidate) => (!requires420 || candidate !== "high444") && accepted.every((set) => set.has(candidate))) ?? "baseline";
|
|
3603
4362
|
};
|
|
3604
|
-
const
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
4363
|
+
const encoderParams = () => {
|
|
4364
|
+
const quality = ladder()[rung];
|
|
4365
|
+
const sockets = socketViewers().length > 0;
|
|
4366
|
+
const keyframeMs = sockets ? Math.min(quality.keyframeMs, fallbackKeyframeMs) : quality.keyframeMs;
|
|
4367
|
+
const videoProfile = profile();
|
|
4368
|
+
const intraRefresh = !sockets;
|
|
4369
|
+
const pixelFormat = videoProfile === "high444" ? "yuv444p" : "yuv420p";
|
|
4370
|
+
return {
|
|
4371
|
+
quality,
|
|
4372
|
+
preset,
|
|
4373
|
+
profile: videoProfile,
|
|
4374
|
+
keyframeMs,
|
|
4375
|
+
intraRefresh,
|
|
4376
|
+
pixelFormat,
|
|
4377
|
+
chromaQpOffset: profiles[activeProfile].chromaQpOffset,
|
|
4378
|
+
signature: `rung ${rung} \xB7 ${activeProfile} crf ${quality.crf} \xB7 ${preset} \xB7 ${videoProfile} \xB7 keyframe ${keyframeMs}ms${intraRefresh ? " refresh" : ""}`
|
|
4379
|
+
};
|
|
4380
|
+
};
|
|
4381
|
+
const budgetBps = () => ladder()[rung].kbps * 1e3 * budgetFactor;
|
|
4382
|
+
const queueMs = (viewer) => {
|
|
4383
|
+
if (!viewer.probeAnswered) return 0;
|
|
4384
|
+
const outstanding = viewer.probeSentAt ? Date.now() - viewer.probeSentAt : 0;
|
|
4385
|
+
return Math.max(0, Math.max(viewer.probeAckedMs, outstanding) - viewer.baselineMs);
|
|
4386
|
+
};
|
|
4387
|
+
const broadcast = (packet, keyframe2 = false) => {
|
|
4388
|
+
for (const viewer of viewers.values()) {
|
|
4389
|
+
const { socket } = viewer;
|
|
4390
|
+
if (!viewer.fallback || socket.readyState !== WebSocket2.OPEN) continue;
|
|
4391
|
+
if (socket.bufferedAmount > maximumBufferedBytes) {
|
|
4392
|
+
socket.close(1013, "stream client is too slow");
|
|
3609
4393
|
continue;
|
|
3610
4394
|
}
|
|
3611
|
-
if (!
|
|
3612
|
-
|
|
4395
|
+
if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
|
|
4396
|
+
viewer.waitingForKeyframe = true;
|
|
3613
4397
|
continue;
|
|
3614
4398
|
}
|
|
3615
|
-
if (
|
|
3616
|
-
|
|
4399
|
+
if (keyframe2) viewer.waitingForKeyframe = false;
|
|
4400
|
+
socket.send(packet, { binary: true });
|
|
3617
4401
|
}
|
|
3618
4402
|
};
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
4403
|
+
let frameId = 0;
|
|
4404
|
+
let frameStart = true;
|
|
4405
|
+
let keyframe = false;
|
|
4406
|
+
let videoGeneration = 0;
|
|
4407
|
+
const onVideoPacket = (packet, generation) => {
|
|
3624
4408
|
lastVideoAt = Date.now();
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
4409
|
+
videoBytes += packet.length;
|
|
4410
|
+
if (generation !== videoGeneration) {
|
|
4411
|
+
if (videoGeneration && !frameStart) frameId += 1;
|
|
4412
|
+
videoGeneration = generation;
|
|
4413
|
+
frameStart = true;
|
|
4414
|
+
}
|
|
4415
|
+
if (frameStart) {
|
|
4416
|
+
keyframe = isKeyframeStart(rtpPayload(packet));
|
|
4417
|
+
if (keyframe) {
|
|
4418
|
+
lastKeyframeAt = lastVideoAt;
|
|
4419
|
+
videoKeyframes += 1;
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
const last = Boolean(packet[1] & 128);
|
|
4423
|
+
videoSplicer.splice(packet, generation, Math.round(9e4 / fps));
|
|
4424
|
+
let rtp;
|
|
4425
|
+
let sockets = false;
|
|
4426
|
+
for (const viewer of viewers.values()) {
|
|
4427
|
+
if (viewer.fallback) sockets = true;
|
|
4428
|
+
else viewer.rtc?.enqueueVideo(rtp ??= withPlayoutDelay(packet, PLAYOUT_DELAY_ID), frameId, keyframe, last);
|
|
4429
|
+
}
|
|
4430
|
+
frameStart = last;
|
|
4431
|
+
if (last) {
|
|
4432
|
+
videoFrames += 1;
|
|
4433
|
+
frameId += 1;
|
|
4434
|
+
}
|
|
4435
|
+
if (!sockets) return;
|
|
4436
|
+
const parsed = parseRtpPacket(packet);
|
|
4437
|
+
const frame = parsed && videoAssembler.push(parsed);
|
|
4438
|
+
if (frame) broadcast(mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data), frame.key);
|
|
4439
|
+
};
|
|
4440
|
+
const onAudioPacket = (packet, generation) => {
|
|
3633
4441
|
lastAudioAt = Date.now();
|
|
4442
|
+
audioBytes += packet.length;
|
|
3634
4443
|
audioPackets += 1;
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
const start = () => {
|
|
3645
|
-
clearTimeout(encoderStop);
|
|
3646
|
-
if (alive(child)) return;
|
|
3647
|
-
if (retiring) return;
|
|
3648
|
-
clearTimeout(restart);
|
|
3649
|
-
prepare();
|
|
3650
|
-
const argv = niceness > 0 ? ["nice", "-n", String(niceness), "ffmpeg", ...buildArgs()] : ["ffmpeg", ...buildArgs()];
|
|
3651
|
-
const spawned = spawn4(argv[0], argv.slice(1), { env: { ...process.env, DISPLAY: display }, stdio: ["ignore", "ignore", "pipe"] });
|
|
3652
|
-
spawned.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
|
|
3653
|
-
child = spawned;
|
|
3654
|
-
spawned.once("exit", (code, signal) => {
|
|
3655
|
-
if (child !== spawned) return;
|
|
3656
|
-
child = void 0;
|
|
3657
|
-
if (clients.size === 0) return;
|
|
3658
|
-
process.stderr.write(`[desktop-stream] ${label} encoder exited (${signal || code}); restarting
|
|
3659
|
-
`);
|
|
3660
|
-
restart = setTimeout(start, 500);
|
|
3661
|
-
});
|
|
3662
|
-
};
|
|
3663
|
-
return {
|
|
3664
|
-
start,
|
|
3665
|
-
running: () => alive(child),
|
|
3666
|
-
stop: () => {
|
|
3667
|
-
intent += 1;
|
|
3668
|
-
clearTimeout(restart);
|
|
3669
|
-
child?.kill("SIGTERM");
|
|
3670
|
-
retiring?.kill("SIGKILL");
|
|
3671
|
-
child = void 0;
|
|
3672
|
-
retiring = void 0;
|
|
3673
|
-
},
|
|
3674
|
-
replace: () => {
|
|
3675
|
-
const issued = intent += 1;
|
|
3676
|
-
clearTimeout(restart);
|
|
3677
|
-
const previous = child ?? retiring;
|
|
3678
|
-
child = void 0;
|
|
3679
|
-
retiring = void 0;
|
|
3680
|
-
if (!alive(previous)) {
|
|
3681
|
-
start();
|
|
3682
|
-
return;
|
|
3683
|
-
}
|
|
3684
|
-
retiring = previous;
|
|
3685
|
-
let settled = false;
|
|
3686
|
-
const resume = () => {
|
|
3687
|
-
if (settled) return;
|
|
3688
|
-
settled = true;
|
|
3689
|
-
clearTimeout(forceExit);
|
|
3690
|
-
clearTimeout(abandonWait);
|
|
3691
|
-
if (intent !== issued) return;
|
|
3692
|
-
retiring = void 0;
|
|
3693
|
-
if (clients.size === 0) return;
|
|
3694
|
-
start();
|
|
3695
|
-
};
|
|
3696
|
-
const forceExit = setTimeout(() => previous.kill("SIGKILL"), 2e3);
|
|
3697
|
-
const abandonWait = setTimeout(resume, 4e3);
|
|
3698
|
-
previous.once("exit", resume);
|
|
3699
|
-
previous.kill("SIGTERM");
|
|
3700
|
-
}
|
|
3701
|
-
};
|
|
4444
|
+
audioSplicer.splice(packet, generation, 960);
|
|
4445
|
+
let sockets = false;
|
|
4446
|
+
for (const viewer of viewers.values()) {
|
|
4447
|
+
if (viewer.fallback) sockets = true;
|
|
4448
|
+
else viewer.rtc?.sendAudio(packet);
|
|
4449
|
+
}
|
|
4450
|
+
if (!sockets) return;
|
|
4451
|
+
const parsed = parseRtpPacket(packet);
|
|
4452
|
+
if (parsed) broadcast(mediaPacket(3 /* Audio */, audioClock.microseconds(parsed.timestamp), parsed.payload));
|
|
3702
4453
|
};
|
|
3703
|
-
const
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
4454
|
+
const env = { ...process.env, DISPLAY: display };
|
|
4455
|
+
const videoEncoder = superviseEncoder({
|
|
4456
|
+
label: "video",
|
|
4457
|
+
// Initial encoder work can leave its first image older than the outgoing stream.
|
|
4458
|
+
warmupFrames: 2,
|
|
4459
|
+
env,
|
|
4460
|
+
onPacket: onVideoPacket,
|
|
4461
|
+
joinable: (packet) => isKeyframeStart(rtpPayload(packet)),
|
|
4462
|
+
args: (udpPort, warmupFrames) => {
|
|
4463
|
+
const { quality, keyframeMs, profile: videoProfile, intraRefresh, pixelFormat, chromaQpOffset } = encoderParams();
|
|
4464
|
+
const keyframeInterval = Math.max(1, Math.round(fps * keyframeMs / 1e3));
|
|
4465
|
+
const filters = [
|
|
4466
|
+
// Skip rapid catch-up captures after a stall; reset after backward clock adjustments.
|
|
4467
|
+
`select='isnan(prev_selected_t)+lt(t,prev_selected_t)+gte(t-prev_selected_t,${0.5 / fps})'`,
|
|
4468
|
+
colorMode === "bt709" ? `scale=out_color_matrix=bt709:out_range=tv,format=${pixelFormat}` : `format=${pixelFormat}`
|
|
4469
|
+
];
|
|
4470
|
+
const x264 = [
|
|
4471
|
+
"repeat-headers=1",
|
|
4472
|
+
"sliced-threads=1",
|
|
4473
|
+
"slices=4",
|
|
4474
|
+
`keyint=${keyframeInterval}`,
|
|
4475
|
+
`min-keyint=${keyframeInterval}`,
|
|
4476
|
+
"scenecut=0",
|
|
4477
|
+
`chroma-qp-offset=${chromaQpOffset}`,
|
|
4478
|
+
...intraRefresh ? ["intra-refresh=1"] : []
|
|
4479
|
+
];
|
|
4480
|
+
return [
|
|
4481
|
+
"-hide_banner",
|
|
4482
|
+
"-loglevel",
|
|
4483
|
+
"warning",
|
|
4484
|
+
"-fflags",
|
|
4485
|
+
"nobuffer",
|
|
4486
|
+
"-flags",
|
|
4487
|
+
"low_delay",
|
|
4488
|
+
"-probesize",
|
|
4489
|
+
"32",
|
|
4490
|
+
"-analyzeduration",
|
|
4491
|
+
"0",
|
|
4492
|
+
// Raw 1080p frames are 8MB each; a shallow capture queue bounds memory
|
|
4493
|
+
// and prevents stalled encoding from retaining seconds of stale frames.
|
|
4494
|
+
"-thread_queue_size",
|
|
4495
|
+
"1",
|
|
4496
|
+
"-f",
|
|
4497
|
+
"x11grab",
|
|
4498
|
+
"-draw_mouse",
|
|
4499
|
+
"0",
|
|
4500
|
+
"-framerate",
|
|
4501
|
+
String(fps),
|
|
4502
|
+
"-video_size",
|
|
4503
|
+
`${width}x${height}`,
|
|
4504
|
+
"-use_wallclock_as_timestamps",
|
|
4505
|
+
"1",
|
|
4506
|
+
"-i",
|
|
4507
|
+
`${display}.0`,
|
|
4508
|
+
"-vf",
|
|
4509
|
+
filters.join(","),
|
|
4510
|
+
...warmupFrames ? ["-force_key_frames", `expr:eq(n,${warmupFrames})`, "-forced-idr", "1"] : [],
|
|
4511
|
+
"-an",
|
|
4512
|
+
"-c:v",
|
|
4513
|
+
"libx264",
|
|
4514
|
+
"-preset",
|
|
4515
|
+
preset,
|
|
4516
|
+
"-tune",
|
|
4517
|
+
"zerolatency",
|
|
4518
|
+
"-threads",
|
|
4519
|
+
String(Math.min(4, availableParallelism())),
|
|
4520
|
+
"-profile:v",
|
|
4521
|
+
videoProfile,
|
|
4522
|
+
"-level:v",
|
|
4523
|
+
"4.2",
|
|
4524
|
+
"-pix_fmt",
|
|
4525
|
+
pixelFormat,
|
|
4526
|
+
...colorMode === "bt709" ? ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709", "-color_range", "tv"] : [],
|
|
4527
|
+
"-bf",
|
|
4528
|
+
"0",
|
|
4529
|
+
"-g",
|
|
4530
|
+
String(keyframeInterval),
|
|
4531
|
+
"-keyint_min",
|
|
4532
|
+
String(keyframeInterval),
|
|
4533
|
+
"-sc_threshold",
|
|
4534
|
+
"0",
|
|
4535
|
+
"-crf",
|
|
4536
|
+
String(quality.crf),
|
|
4537
|
+
"-maxrate",
|
|
4538
|
+
`${quality.kbps}k`,
|
|
4539
|
+
"-bufsize",
|
|
4540
|
+
`${Math.round(quality.kbps * Math.min(keyframeMs, vbvMs) / 1e3)}k`,
|
|
4541
|
+
"-x264-params",
|
|
4542
|
+
x264.join(":"),
|
|
4543
|
+
"-fps_mode",
|
|
4544
|
+
"passthrough",
|
|
4545
|
+
"-flush_packets",
|
|
4546
|
+
"1",
|
|
4547
|
+
"-f",
|
|
4548
|
+
"rtp",
|
|
4549
|
+
"-payload_type",
|
|
4550
|
+
String(H264_PROFILES[videoProfile].payloadType),
|
|
4551
|
+
"-ssrc",
|
|
4552
|
+
String(VIDEO_SSRC),
|
|
4553
|
+
`rtp://127.0.0.1:${udpPort}?pkt_size=1200&connect=1&rtcpport=${udpPort}`
|
|
4554
|
+
];
|
|
4555
|
+
}
|
|
4556
|
+
});
|
|
4557
|
+
const audioEncoder = superviseEncoder({
|
|
4558
|
+
label: "audio",
|
|
4559
|
+
env,
|
|
4560
|
+
onPacket: onAudioPacket,
|
|
4561
|
+
joinable: () => true,
|
|
4562
|
+
args: (udpPort) => [
|
|
3710
4563
|
"-hide_banner",
|
|
3711
4564
|
"-loglevel",
|
|
3712
4565
|
"warning",
|
|
@@ -3721,172 +4574,431 @@ async function runMediaServer() {
|
|
|
3721
4574
|
"-thread_queue_size",
|
|
3722
4575
|
"1024",
|
|
3723
4576
|
"-f",
|
|
3724
|
-
"
|
|
3725
|
-
"-
|
|
3726
|
-
"
|
|
3727
|
-
"-framerate",
|
|
3728
|
-
String(quality.fps),
|
|
3729
|
-
"-video_size",
|
|
3730
|
-
`${width}x${height}`,
|
|
4577
|
+
"pulse",
|
|
4578
|
+
"-fragment_size",
|
|
4579
|
+
"2048",
|
|
3731
4580
|
"-use_wallclock_as_timestamps",
|
|
3732
4581
|
"1",
|
|
3733
4582
|
"-i",
|
|
3734
|
-
|
|
3735
|
-
"-
|
|
3736
|
-
"-
|
|
3737
|
-
"
|
|
3738
|
-
"-
|
|
3739
|
-
|
|
3740
|
-
"-
|
|
3741
|
-
"
|
|
3742
|
-
"-
|
|
3743
|
-
"
|
|
3744
|
-
"-
|
|
3745
|
-
"
|
|
3746
|
-
"-
|
|
3747
|
-
"
|
|
3748
|
-
"-
|
|
3749
|
-
"
|
|
3750
|
-
"-g",
|
|
3751
|
-
String(keyframeInterval),
|
|
3752
|
-
"-keyint_min",
|
|
3753
|
-
String(keyframeInterval),
|
|
3754
|
-
"-sc_threshold",
|
|
3755
|
-
"0",
|
|
3756
|
-
"-crf",
|
|
3757
|
-
String(quality.crf),
|
|
3758
|
-
"-maxrate",
|
|
3759
|
-
`${quality.kbps}k`,
|
|
3760
|
-
"-bufsize",
|
|
3761
|
-
`${Math.round(quality.kbps * keyframeMs / 1e3)}k`,
|
|
3762
|
-
"-x264-params",
|
|
3763
|
-
`repeat-headers=1:sliced-threads=1:slices=4:keyint=${keyframeInterval}:min-keyint=${keyframeInterval}:scenecut=0`,
|
|
3764
|
-
"-fps_mode",
|
|
3765
|
-
"passthrough",
|
|
4583
|
+
process.env.REPLICAS_DESKTOP_STREAM_AUDIO_SOURCE || "default",
|
|
4584
|
+
"-vn",
|
|
4585
|
+
"-af",
|
|
4586
|
+
"aresample=async=1:first_pts=0",
|
|
4587
|
+
"-c:a",
|
|
4588
|
+
"libopus",
|
|
4589
|
+
"-application",
|
|
4590
|
+
"lowdelay",
|
|
4591
|
+
"-frame_duration",
|
|
4592
|
+
"20",
|
|
4593
|
+
"-b:a",
|
|
4594
|
+
"128k",
|
|
4595
|
+
"-ar",
|
|
4596
|
+
"48000",
|
|
4597
|
+
"-ac",
|
|
4598
|
+
"2",
|
|
3766
4599
|
"-flush_packets",
|
|
3767
4600
|
"1",
|
|
3768
4601
|
"-f",
|
|
3769
4602
|
"rtp",
|
|
3770
4603
|
"-payload_type",
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
"-frame_duration",
|
|
3805
|
-
"20",
|
|
3806
|
-
"-b:a",
|
|
3807
|
-
"128k",
|
|
3808
|
-
"-ar",
|
|
3809
|
-
"48000",
|
|
3810
|
-
"-ac",
|
|
3811
|
-
"2",
|
|
3812
|
-
"-flush_packets",
|
|
3813
|
-
"1",
|
|
3814
|
-
"-f",
|
|
3815
|
-
"rtp",
|
|
3816
|
-
"-payload_type",
|
|
3817
|
-
"111",
|
|
3818
|
-
`rtp://127.0.0.1:${audioPort}?pkt_size=1200&connect=1&rtcpport=${audioPort}`
|
|
3819
|
-
]);
|
|
3820
|
-
const startEncoders = () => {
|
|
3821
|
-
videoEncoder.start();
|
|
3822
|
-
audioEncoder.start();
|
|
4604
|
+
String(OPUS_PAYLOAD_TYPE),
|
|
4605
|
+
"-ssrc",
|
|
4606
|
+
String(AUDIO_SSRC),
|
|
4607
|
+
`rtp://127.0.0.1:${udpPort}?pkt_size=1200&connect=1&rtcpport=${udpPort}`
|
|
4608
|
+
]
|
|
4609
|
+
});
|
|
4610
|
+
const ensureEncoders = () => {
|
|
4611
|
+
clearTimeout(encoderStop);
|
|
4612
|
+
void audioEncoder.start();
|
|
4613
|
+
const { signature } = encoderParams();
|
|
4614
|
+
if (!videoWanted) {
|
|
4615
|
+
videoWanted = true;
|
|
4616
|
+
encoderSignature = signature;
|
|
4617
|
+
void videoEncoder.start();
|
|
4618
|
+
return false;
|
|
4619
|
+
}
|
|
4620
|
+
if (signature === encoderSignature) return false;
|
|
4621
|
+
encoderSignature = signature;
|
|
4622
|
+
lastSwapAt = Date.now();
|
|
4623
|
+
process.stderr.write(`[desktop-stream] encoder \u2192 ${signature}
|
|
4624
|
+
`);
|
|
4625
|
+
void videoEncoder.replace();
|
|
4626
|
+
return true;
|
|
4627
|
+
};
|
|
4628
|
+
const syncEncoder = () => videoWanted && viewers.size > 0 && ensureEncoders();
|
|
4629
|
+
const stopEncodersSoon = () => {
|
|
4630
|
+
clearTimeout(encoderStop);
|
|
4631
|
+
encoderStop = setTimeout(() => {
|
|
4632
|
+
if (viewers.size > 0) return;
|
|
4633
|
+
videoWanted = false;
|
|
4634
|
+
void videoEncoder.stop();
|
|
4635
|
+
void audioEncoder.stop();
|
|
4636
|
+
}, 5e3);
|
|
3823
4637
|
};
|
|
3824
4638
|
const setRung = (next) => {
|
|
3825
4639
|
if (next === rung) return;
|
|
3826
|
-
|
|
4640
|
+
const now = Date.now();
|
|
4641
|
+
if (next > rung) {
|
|
4642
|
+
if (now - lastDescentAt > 1e4) descents += 1;
|
|
4643
|
+
lastDescentAt = now;
|
|
4644
|
+
if (now - lastAscentAt < 15e3) {
|
|
4645
|
+
blockedRung = rung;
|
|
4646
|
+
blockedUntil = now + 12e4;
|
|
4647
|
+
}
|
|
4648
|
+
} else lastAscentAt = now;
|
|
3827
4649
|
rung = next;
|
|
4650
|
+
cleanTransportSeconds = 0;
|
|
3828
4651
|
congestedSeconds = 0;
|
|
3829
4652
|
comfortableSeconds = 0;
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
videoEncoder.replace();
|
|
4653
|
+
for (const viewer of viewers.values()) viewer.rtc?.setBudget(budgetBps());
|
|
4654
|
+
syncEncoder();
|
|
3833
4655
|
};
|
|
3834
|
-
const
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
4656
|
+
const resetLadder = (request) => {
|
|
4657
|
+
rung = request.transport === "webrtc" && request.adaptive ? webrtcStartupRung : 0;
|
|
4658
|
+
cleanTransportSeconds = 0;
|
|
4659
|
+
descents = 0;
|
|
4660
|
+
lastDescentAt = 0;
|
|
4661
|
+
lastAscentAt = 0;
|
|
4662
|
+
congestedSeconds = 0;
|
|
4663
|
+
comfortableSeconds = 0;
|
|
4664
|
+
blockedRung = -1;
|
|
4665
|
+
blockedUntil = 0;
|
|
4666
|
+
};
|
|
4667
|
+
const stableRttWindows = /* @__PURE__ */ new WeakMap();
|
|
4668
|
+
const freshRemb = (viewer, now) => viewer.remb && now - viewer.remb.at < 2e3 ? viewer.remb.kbps : void 0;
|
|
4669
|
+
const rembBelowSent = (viewer, now) => {
|
|
4670
|
+
const remb = freshRemb(viewer, now);
|
|
4671
|
+
const sent = Math.min(...recentVideoKbps);
|
|
4672
|
+
return remb !== void 0 && sent > 500 && remb < sent * 0.9;
|
|
4673
|
+
};
|
|
4674
|
+
const struggling = (viewer, stats) => {
|
|
4675
|
+
const stableSince = stableRttWindows.get(viewer)?.since;
|
|
4676
|
+
return viewer.fallback ? queueMs(viewer) > congestedBacklogMs || stats.decodeQueue > congestedDecodeQueue : stats.rttMs > viewer.rtcBaselineMs + 40 && (stableSince === void 0 || Date.now() - stableSince < 2e3) || stats.videoFps < fps * 0.6 && viewer.lowFpsReports >= 2 || (stats.lossPercent ?? 0) > 3 || (stats.jitterBufferMs ?? 0) > 80 || rembBelowSent(viewer, Date.now());
|
|
4677
|
+
};
|
|
4678
|
+
const lowFpsObservationAt = /* @__PURE__ */ new WeakMap();
|
|
4679
|
+
const severe = (viewer, stats) => !viewer.fallback && ((stats.lossPercent ?? 0) > 10 || stats.videoFps < fps * 0.25 && viewer.lowFpsReports >= 2);
|
|
4680
|
+
let lastSevereDescentAt = 0;
|
|
4681
|
+
const descendSevere = (now) => {
|
|
4682
|
+
comfortableSeconds = 0;
|
|
4683
|
+
congestedSeconds = 0;
|
|
4684
|
+
if (!adaptive || now - lastSevereDescentAt < 1e3) return;
|
|
4685
|
+
lastSevereDescentAt = now;
|
|
4686
|
+
const estimates = [...viewers.values()].flatMap((viewer) => {
|
|
4687
|
+
const reported = viewer.stats && now - viewer.stats.at < 750 ? viewer.stats.availableKbps ?? 0 : 0;
|
|
4688
|
+
const remb = rembBelowSent(viewer, now) ? freshRemb(viewer, now) ?? 0 : 0;
|
|
4689
|
+
return [...reported > 0 ? [reported] : [], ...remb > 0 ? [remb] : []];
|
|
4690
|
+
});
|
|
4691
|
+
const rungs = ladder();
|
|
4692
|
+
const estimatedRung = estimates.length ? rungs.findIndex((quality) => quality.kbps <= Math.min(...estimates) * 0.8) : rung + 2;
|
|
4693
|
+
setRung(Math.min(rungs.length - 1, Math.max(rung + 2, estimatedRung < 0 ? rungs.length - 1 : estimatedRung)));
|
|
3841
4694
|
};
|
|
3842
|
-
const probe = setInterval(() => {
|
|
3843
|
-
for (const [client, state] of clients) {
|
|
3844
|
-
if (client.readyState !== WebSocket2.OPEN || state.probeSentAt) continue;
|
|
3845
|
-
state.probeSentAt = Date.now();
|
|
3846
|
-
client.send(JSON.stringify({ type: "ping", sentAt: state.probeSentAt }));
|
|
3847
|
-
}
|
|
3848
|
-
}, probeIntervalMs);
|
|
3849
|
-
probe.unref();
|
|
3850
4695
|
const control = setInterval(() => {
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
4696
|
+
encoderFps = videoFrames - framesAtTick;
|
|
4697
|
+
framesAtTick = videoFrames;
|
|
4698
|
+
videoKbps = Math.round((videoBytes - bytesAtTick) * 8 / 1e3);
|
|
4699
|
+
bytesAtTick = videoBytes;
|
|
4700
|
+
if (recentVideoKbps.push(videoKbps) > 3) recentVideoKbps.shift();
|
|
4701
|
+
if (viewers.size === 0) return;
|
|
4702
|
+
if (videoEncoder.running() && encoderFps < fps * 0.9) {
|
|
4703
|
+
cleanTransportSeconds = 0;
|
|
4704
|
+
congestedSeconds = 0;
|
|
4705
|
+
comfortableSeconds = 0;
|
|
4706
|
+
presetHealthySeconds = 0;
|
|
4707
|
+
encoderHealthyTicks = 0;
|
|
4708
|
+
starvedSeconds += 1;
|
|
4709
|
+
if (starvedSeconds >= 5 && preset !== "ultrafast") {
|
|
4710
|
+
preset = "ultrafast";
|
|
4711
|
+
syncEncoder();
|
|
4712
|
+
}
|
|
4713
|
+
return;
|
|
4714
|
+
}
|
|
4715
|
+
starvedSeconds = 0;
|
|
4716
|
+
encoderHealthyTicks += 1;
|
|
4717
|
+
if (encoderHealthyTicks < 3) return;
|
|
4718
|
+
if (preset !== basePreset && ++presetHealthySeconds >= 120) {
|
|
4719
|
+
preset = basePreset;
|
|
4720
|
+
syncEncoder();
|
|
4721
|
+
}
|
|
4722
|
+
if (!adaptive) {
|
|
4723
|
+
if (rung !== 0) setRung(0);
|
|
4724
|
+
return;
|
|
4725
|
+
}
|
|
4726
|
+
const now = Date.now();
|
|
4727
|
+
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.stats.rttMs <= 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
|
+
if (reporting.length > 0 && reporting.every((viewer) => severe(viewer, viewer.stats))) {
|
|
4730
|
+
descendSevere(now);
|
|
4731
|
+
return;
|
|
4732
|
+
}
|
|
4733
|
+
const votes = reporting.flatMap((viewer) => viewer.everHealthy || now - viewer.connectedAt > 15e3 ? [struggling(viewer, viewer.stats)] : []);
|
|
4734
|
+
if (votes.length === 0) return;
|
|
4735
|
+
const strugglers = votes.filter(Boolean).length;
|
|
4736
|
+
if (strugglers === votes.length) {
|
|
3855
4737
|
comfortableSeconds = 0;
|
|
3856
4738
|
congestedSeconds += 1;
|
|
3857
|
-
if (congestedSeconds >= 2) setRung(Math.min(
|
|
4739
|
+
if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1));
|
|
3858
4740
|
return;
|
|
3859
4741
|
}
|
|
3860
|
-
|
|
3861
|
-
|
|
4742
|
+
if (strugglers > 0) {
|
|
4743
|
+
congestedSeconds = 0;
|
|
4744
|
+
comfortableSeconds = 0;
|
|
4745
|
+
return;
|
|
4746
|
+
}
|
|
4747
|
+
congestedSeconds = Math.max(0, congestedSeconds - 0.5);
|
|
4748
|
+
if (descents === 0 && reporting.some((viewer) => !viewer.fallback && (viewer.stats.rttMs > viewer.rtcBaselineMs + 20 || (viewer.stats.playbackDroppedPercent ?? 0) > 5 && cleanTransportSeconds < 15))) {
|
|
3862
4749
|
comfortableSeconds = 0;
|
|
3863
4750
|
return;
|
|
3864
4751
|
}
|
|
3865
4752
|
comfortableSeconds += 1;
|
|
3866
|
-
|
|
4753
|
+
const startupSeconds = reporting.every((viewer) => !viewer.fallback) ? 2 : 3;
|
|
4754
|
+
const recoverySeconds = lastAscentAt > lastDescentAt && cleanTransportSeconds >= 15 ? 15 : Math.min(60, 15 * 2 ** Math.min(descents, 2));
|
|
4755
|
+
if (comfortableSeconds < (descents === 0 ? startupSeconds : recoverySeconds)) return;
|
|
4756
|
+
const target = Math.max(0, rung - 1);
|
|
4757
|
+
if (target === blockedRung && now < blockedUntil) return;
|
|
4758
|
+
setRung(target);
|
|
3867
4759
|
}, 1e3);
|
|
3868
4760
|
control.unref();
|
|
4761
|
+
const probe = setInterval(() => {
|
|
4762
|
+
for (const viewer of socketViewers()) {
|
|
4763
|
+
if (viewer.socket.readyState !== WebSocket2.OPEN || viewer.probeSentAt) continue;
|
|
4764
|
+
viewer.probeSentAt = Date.now();
|
|
4765
|
+
viewer.socket.send(JSON.stringify({ type: "ping", sentAt: viewer.probeSentAt }));
|
|
4766
|
+
}
|
|
4767
|
+
}, probeIntervalMs);
|
|
4768
|
+
probe.unref();
|
|
4769
|
+
let cpuSample = { at: Date.now(), server: process.cpuUsage(), encoder: processCpuMs(videoEncoder.pid()) };
|
|
4770
|
+
let cpu = { serverPercent: 0, encoderPercent: 0 };
|
|
4771
|
+
const telemetry = setInterval(() => {
|
|
4772
|
+
if (viewers.size === 0) return;
|
|
4773
|
+
const now = Date.now();
|
|
4774
|
+
const server = process.cpuUsage();
|
|
4775
|
+
const encoder = processCpuMs(videoEncoder.pid());
|
|
4776
|
+
const elapsed = Math.max(1, now - cpuSample.at);
|
|
4777
|
+
cpu = {
|
|
4778
|
+
serverPercent: Math.round((server.user + server.system - cpuSample.server.user - cpuSample.server.system) / 10 / elapsed),
|
|
4779
|
+
encoderPercent: Math.max(0, Math.round((encoder - cpuSample.encoder) * 100 / elapsed))
|
|
4780
|
+
};
|
|
4781
|
+
cpuSample = { at: now, server, encoder };
|
|
4782
|
+
const { quality, profile: videoProfile, pixelFormat } = encoderParams();
|
|
4783
|
+
for (const viewer of viewers.values()) {
|
|
4784
|
+
if (viewer.socket.readyState !== WebSocket2.OPEN) continue;
|
|
4785
|
+
const pacer = viewer.rtc?.snapshot();
|
|
4786
|
+
const message = {
|
|
4787
|
+
type: "telemetry",
|
|
4788
|
+
encoder: {
|
|
4789
|
+
profile: activeProfile,
|
|
4790
|
+
codecProfile: videoProfile,
|
|
4791
|
+
pixelFormat,
|
|
4792
|
+
color: colorMode,
|
|
4793
|
+
rung,
|
|
4794
|
+
rungs: ladder().length,
|
|
4795
|
+
kbpsCap: quality.kbps,
|
|
4796
|
+
crf: quality.crf,
|
|
4797
|
+
preset,
|
|
4798
|
+
fps: encoderFps,
|
|
4799
|
+
kbps: videoKbps,
|
|
4800
|
+
adaptive
|
|
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
|
+
}
|
|
4816
|
+
};
|
|
4817
|
+
viewer.socket.send(JSON.stringify(message));
|
|
4818
|
+
}
|
|
4819
|
+
}, telemetryIntervalMs);
|
|
4820
|
+
telemetry.unref();
|
|
4821
|
+
const requestKeyframe = (joinedAt, requestingViewer) => {
|
|
4822
|
+
const now = Date.now();
|
|
4823
|
+
if (!videoWanted || viewers.size === 0 || videoEncoder.replacing() || lastKeyframeAt < now - videoEncoder.activeForMs() || joinedAt !== void 0 && lastKeyframeAt > joinedAt) return;
|
|
4824
|
+
const startup = requestingViewer && !requestingViewer.hasDecodedFrame && now - requestingViewer.connectedAt < 1e4;
|
|
4825
|
+
const wait = (joinedAt === void 0 ? startup ? 500 : 2e3 : 350) - (now - lastSwapAt);
|
|
4826
|
+
if (wait > 0) {
|
|
4827
|
+
if (joinedAt !== void 0) {
|
|
4828
|
+
clearTimeout(joinKeyframe);
|
|
4829
|
+
joinKeyframe = setTimeout(() => requestKeyframe(joinedAt), wait);
|
|
4830
|
+
}
|
|
4831
|
+
return;
|
|
4832
|
+
}
|
|
4833
|
+
if (!startup && encoderParams().keyframeMs - (now - lastKeyframeAt) <= 350) return;
|
|
4834
|
+
lastSwapAt = now;
|
|
4835
|
+
void videoEncoder.replace();
|
|
4836
|
+
};
|
|
4837
|
+
const enterFallback = (viewer, reason) => {
|
|
4838
|
+
if (viewer.fallback) return;
|
|
4839
|
+
viewer.fallback = true;
|
|
4840
|
+
viewer.waitingForKeyframe = true;
|
|
4841
|
+
viewer.input.release();
|
|
4842
|
+
viewer.rtc?.close();
|
|
4843
|
+
viewer.rtc = void 0;
|
|
4844
|
+
process.stderr.write(`[desktop-stream] viewer streaming over the socket: ${reason}
|
|
4845
|
+
`);
|
|
4846
|
+
if (!syncEncoder()) requestKeyframe();
|
|
4847
|
+
};
|
|
4848
|
+
const optional = (value, max) => value === void 0 ? void 0 : clamp(value, 0, max);
|
|
4849
|
+
const handleMessage = (viewer, message, reply) => {
|
|
4850
|
+
switch (message.type) {
|
|
4851
|
+
case "ping":
|
|
4852
|
+
reply({ type: "pong", sentAt: message.sentAt });
|
|
4853
|
+
return;
|
|
4854
|
+
case "pong":
|
|
4855
|
+
if (!viewer.probeSentAt || message.sentAt !== viewer.probeSentAt) return;
|
|
4856
|
+
viewer.probeAckedMs = Date.now() - viewer.probeSentAt;
|
|
4857
|
+
viewer.probeSentAt = 0;
|
|
4858
|
+
viewer.probeAnswered = true;
|
|
4859
|
+
viewer.baselineMs = Math.min(viewer.baselineMs, viewer.probeAckedMs);
|
|
4860
|
+
return;
|
|
4861
|
+
case "stats": {
|
|
4862
|
+
if (message.sequence !== void 0 && message.sequence <= (viewer.stats?.sequence ?? -1)) return;
|
|
4863
|
+
viewer.stats = {
|
|
4864
|
+
at: Date.now(),
|
|
4865
|
+
sequence: message.sequence ?? viewer.stats?.sequence,
|
|
4866
|
+
videoFps: clamp(message.videoFps, 0, 1e3),
|
|
4867
|
+
videoPacketsPerSecond: optional(message.videoPacketsPerSecond, 1e6),
|
|
4868
|
+
audioPacketsPerSecond: clamp(message.audioPacketsPerSecond, 0, 1e3),
|
|
4869
|
+
decodeQueue: clamp(message.decodeQueue, 0, 1e3),
|
|
4870
|
+
rttMs: clamp(message.rttMs, 0, 6e5),
|
|
4871
|
+
lossPercent: optional(message.lossPercent, 100),
|
|
4872
|
+
nacks: optional(message.nacks, 1e5),
|
|
4873
|
+
freezes: optional(message.freezes, 1e3),
|
|
4874
|
+
jitterBufferMs: optional(message.jitterBufferMs, 6e4),
|
|
4875
|
+
availableKbps: optional(message.availableKbps, 1e6),
|
|
4876
|
+
playbackDroppedPercent: optional(message.playbackDroppedPercent, 100)
|
|
4877
|
+
};
|
|
4878
|
+
const now = viewer.stats.at;
|
|
4879
|
+
if (viewer.stats.videoFps >= fps * 0.6 || viewer.stats.videoPacketsPerSecond === 0) {
|
|
4880
|
+
viewer.lowFpsReports = 0;
|
|
4881
|
+
lowFpsObservationAt.delete(viewer);
|
|
4882
|
+
} else if (now - (lowFpsObservationAt.get(viewer) ?? 0) >= 1e3) {
|
|
4883
|
+
viewer.lowFpsReports = Math.min(2, viewer.lowFpsReports + 1);
|
|
4884
|
+
lowFpsObservationAt.set(viewer, now);
|
|
4885
|
+
}
|
|
4886
|
+
if (!viewer.fallback && viewer.stats.rttMs > 0) viewer.rtcBaselineMs = Math.min(viewer.rtcBaselineMs, viewer.stats.rttMs);
|
|
4887
|
+
const rtt = viewer.stats.rttMs;
|
|
4888
|
+
let window = stableRttWindows.get(viewer);
|
|
4889
|
+
const stableNetwork = !viewer.fallback && rtt > viewer.rtcBaselineMs + 20 && (viewer.stats.videoPacketsPerSecond ?? 0) > 0 && (viewer.stats.lossPercent ?? 0) <= 1 && (viewer.stats.jitterBufferMs ?? 0) <= 80;
|
|
4890
|
+
if (stableNetwork && viewer.stats.videoFps >= fps * 0.9) {
|
|
4891
|
+
if (!window || now - window.at > 3e3 || Math.max(window.max, rtt) - Math.min(window.min, rtt) > 20) {
|
|
4892
|
+
window = { since: now, at: now, min: rtt, max: rtt };
|
|
4893
|
+
}
|
|
4894
|
+
window.at = now;
|
|
4895
|
+
window.min = Math.min(window.min, rtt);
|
|
4896
|
+
window.max = Math.max(window.max, rtt);
|
|
4897
|
+
stableRttWindows.set(viewer, window);
|
|
4898
|
+
if (now - window.since >= 1e4) {
|
|
4899
|
+
viewer.rtcBaselineMs = window.min;
|
|
4900
|
+
stableRttWindows.delete(viewer);
|
|
4901
|
+
}
|
|
4902
|
+
} else if (!stableNetwork || !window || now - window.at > 750 || viewer.stats.videoFps < fps * 0.6 || Math.max(window.max, rtt) - Math.min(window.min, rtt) > 20) {
|
|
4903
|
+
stableRttWindows.delete(viewer);
|
|
4904
|
+
} else {
|
|
4905
|
+
window.min = Math.min(window.min, rtt);
|
|
4906
|
+
window.max = Math.max(window.max, rtt);
|
|
4907
|
+
}
|
|
4908
|
+
if (viewer.rtc) {
|
|
4909
|
+
const elevated = rtt > viewer.rtcBaselineMs + 20;
|
|
4910
|
+
if (!elevated) viewer.elevated = void 0;
|
|
4911
|
+
else if (!viewer.elevated || now - viewer.elevated.at > 750) viewer.elevated = { since: now, at: now };
|
|
4912
|
+
else viewer.elevated.at = now;
|
|
4913
|
+
if (rtt > viewer.rtcBaselineMs + 40 || viewer.elevated && now - viewer.elevated.since >= 500) {
|
|
4914
|
+
viewer.rtc.protect(0);
|
|
4915
|
+
viewer.protectBlockedUntil = now + 3e4;
|
|
4916
|
+
} else if (now >= viewer.protectBlockedUntil && ((viewer.stats.nacks ?? 0) > 0 || (viewer.stats.lossPercent ?? 0) > 0)) {
|
|
4917
|
+
viewer.rtc.protect(protectMs);
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
if (viewer.stats.videoFps > 0) viewer.hasDecodedFrame = true;
|
|
4921
|
+
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);
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4925
|
+
case "answer":
|
|
4926
|
+
viewer.rtc?.answer(message.sdp);
|
|
4927
|
+
syncEncoder();
|
|
4928
|
+
return;
|
|
4929
|
+
case "candidate":
|
|
4930
|
+
viewer.rtc?.candidate(message.candidate, message.mid);
|
|
4931
|
+
return;
|
|
4932
|
+
case "fallback":
|
|
4933
|
+
enterFallback(viewer, message.reason);
|
|
4934
|
+
return;
|
|
4935
|
+
case "keyframe":
|
|
4936
|
+
if (!viewer.fallback) requestKeyframe(void 0, viewer);
|
|
4937
|
+
return;
|
|
4938
|
+
case "theme":
|
|
4939
|
+
applyDesktopTheme(message.theme);
|
|
4940
|
+
return;
|
|
4941
|
+
default:
|
|
4942
|
+
viewer.input.handle(message);
|
|
4943
|
+
}
|
|
4944
|
+
};
|
|
4945
|
+
const config = {
|
|
4946
|
+
type: "config",
|
|
4947
|
+
protocolVersion: STREAM_PROTOCOL_VERSION,
|
|
4948
|
+
inputRace: true,
|
|
4949
|
+
width,
|
|
4950
|
+
height,
|
|
4951
|
+
fps,
|
|
4952
|
+
audioCodec: "opus",
|
|
4953
|
+
audioSampleRate: 48e3,
|
|
4954
|
+
audioChannels: 2
|
|
4955
|
+
};
|
|
3869
4956
|
const httpServer = createServer2((request, response) => {
|
|
3870
4957
|
const url = new URL(request.url || "/", "http://localhost");
|
|
3871
4958
|
if (url.pathname === "/health") {
|
|
4959
|
+
const all = [...viewers.values()];
|
|
4960
|
+
const memory = process.memoryUsage();
|
|
4961
|
+
const { keyframeMs, profile: videoProfile, pixelFormat, quality } = encoderParams();
|
|
3872
4962
|
response.setHeader("content-type", "application/json");
|
|
3873
4963
|
response.end(JSON.stringify({
|
|
3874
|
-
|
|
4964
|
+
version: COMPUTER_USE_VERSION,
|
|
4965
|
+
clients: viewers.size,
|
|
4966
|
+
webrtc,
|
|
4967
|
+
transports: all.map((viewer) => viewer.fallback ? "websocket" : viewer.rtc?.connected ? "webrtc" : "negotiating"),
|
|
4968
|
+
requests: all.map((viewer) => viewer.request),
|
|
3875
4969
|
encoder: videoEncoder.running(),
|
|
3876
4970
|
audioEncoder: audioEncoder.running(),
|
|
4971
|
+
generation: videoEncoder.generation(),
|
|
3877
4972
|
lastVideoFrameMsAgo: lastVideoAt ? Date.now() - lastVideoAt : null,
|
|
3878
4973
|
lastAudioPacketMsAgo: lastAudioAt ? Date.now() - lastAudioAt : null,
|
|
3879
4974
|
videoFrames,
|
|
4975
|
+
videoKeyframes,
|
|
3880
4976
|
audioPackets,
|
|
3881
4977
|
videoBytes,
|
|
3882
4978
|
audioBytes,
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
4979
|
+
videoKbps,
|
|
4980
|
+
encoderFps,
|
|
4981
|
+
cpu,
|
|
4982
|
+
clientStats: all.flatMap((viewer) => viewer.stats ? [viewer.stats] : []),
|
|
4983
|
+
clientPacer: all.map((viewer) => viewer.rtc?.snapshot() ?? null),
|
|
4984
|
+
clientRttMs: all.map((viewer) => viewer.rtc?.rttMs ?? viewer.stats?.rttMs ?? null),
|
|
4985
|
+
clientPath: all.map((viewer) => viewer.rtc?.path ?? null),
|
|
4986
|
+
clientQueueMs: all.map((viewer) => Math.round(queueMs(viewer))),
|
|
3886
4987
|
qualityRung: rung,
|
|
4988
|
+
adaptive,
|
|
4989
|
+
streamProfile: activeProfile,
|
|
4990
|
+
crf: quality.crf,
|
|
4991
|
+
kbpsCap: quality.kbps,
|
|
4992
|
+
preset,
|
|
4993
|
+
profile: videoProfile,
|
|
4994
|
+
pixelFormat,
|
|
4995
|
+
color: colorMode,
|
|
4996
|
+
keyframeMs,
|
|
4997
|
+
rss: memory.rss,
|
|
4998
|
+
heapUsed: memory.heapUsed,
|
|
3887
4999
|
width,
|
|
3888
5000
|
height,
|
|
3889
|
-
fps
|
|
5001
|
+
fps
|
|
3890
5002
|
}));
|
|
3891
5003
|
return;
|
|
3892
5004
|
}
|
|
@@ -3897,70 +5009,104 @@ async function runMediaServer() {
|
|
|
3897
5009
|
response.end("Not found");
|
|
3898
5010
|
return;
|
|
3899
5011
|
}
|
|
5012
|
+
response.setHeader("content-type", contentTypes[extname(path)] || "application/octet-stream");
|
|
3900
5013
|
if (extname(path) === ".html") {
|
|
3901
|
-
|
|
3902
|
-
if (
|
|
5014
|
+
ensureEncoders();
|
|
5015
|
+
if (viewers.size === 0) stopEncodersSoon();
|
|
5016
|
+
response.setHeader("cache-control", "no-store");
|
|
5017
|
+
const digest = createHash3("sha1");
|
|
5018
|
+
for (const name of readdirSync(webroot).filter((file) => /\.(js|css)$/.test(file)).sort()) digest.update(readFileSync6(resolve2(webroot, name)));
|
|
5019
|
+
response.end(readFileSync6(path, "utf8").replaceAll("__VERSION__", `${COMPUTER_USE_VERSION}-${digest.digest("hex").slice(0, 8)}`));
|
|
5020
|
+
return;
|
|
3903
5021
|
}
|
|
3904
|
-
response.setHeader("cache-control",
|
|
3905
|
-
response.setHeader("content-type", contentTypes[extname(path)] || "application/octet-stream");
|
|
5022
|
+
response.setHeader("cache-control", "private, max-age=31536000, immutable");
|
|
3906
5023
|
createReadStream(path).pipe(response);
|
|
3907
5024
|
});
|
|
3908
|
-
const webSocketServer = new WebSocketServer({ noServer: true, maxPayload:
|
|
5025
|
+
const webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
3909
5026
|
httpServer.on("upgrade", (request, socket, head) => {
|
|
3910
|
-
|
|
5027
|
+
const url = new URL(request.url || "/", "http://localhost");
|
|
5028
|
+
if (url.pathname !== "/stream") {
|
|
3911
5029
|
socket.destroy();
|
|
3912
5030
|
return;
|
|
3913
5031
|
}
|
|
3914
|
-
webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client,
|
|
5032
|
+
webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest)));
|
|
3915
5033
|
});
|
|
3916
|
-
webSocketServer.on("connection", (
|
|
3917
|
-
|
|
3918
|
-
|
|
5034
|
+
webSocketServer.on("connection", (socket, request) => {
|
|
5035
|
+
const viewer = {
|
|
5036
|
+
socket,
|
|
3919
5037
|
input: new InputBridge(display, width, height),
|
|
5038
|
+
request,
|
|
5039
|
+
fallback: false,
|
|
5040
|
+
waitingForKeyframe: true,
|
|
5041
|
+
everHealthy: false,
|
|
5042
|
+
lowFpsReports: 0,
|
|
5043
|
+
connectedAt: Date.now(),
|
|
3920
5044
|
probeSentAt: 0,
|
|
3921
5045
|
probeAckedMs: 0,
|
|
3922
5046
|
probeAnswered: false,
|
|
3923
|
-
baselineMs: Number.POSITIVE_INFINITY
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
5047
|
+
baselineMs: Number.POSITIVE_INFINITY,
|
|
5048
|
+
rtcBaselineMs: Number.POSITIVE_INFINITY,
|
|
5049
|
+
protectBlockedUntil: 0
|
|
5050
|
+
};
|
|
5051
|
+
if (viewers.size === 0) resetLadder(request);
|
|
5052
|
+
if (request.profile !== activeProfile || request.adaptive !== adaptive) {
|
|
5053
|
+
process.stderr.write(`[desktop-stream] viewer requests ${request.profile} profile, ${request.adaptive ? "adaptive" : "pinned"} quality
|
|
5054
|
+
`);
|
|
5055
|
+
activeProfile = request.profile;
|
|
5056
|
+
adaptive = request.adaptive;
|
|
5057
|
+
if (!adaptive) rung = 0;
|
|
5058
|
+
}
|
|
5059
|
+
viewers.set(socket, viewer);
|
|
5060
|
+
const reply = (message) => {
|
|
5061
|
+
if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
|
|
5062
|
+
};
|
|
5063
|
+
const offerWebRtc = webrtc && request.transport === "webrtc";
|
|
5064
|
+
const ice = offerWebRtc ? currentIceServers() : void 0;
|
|
5065
|
+
const fallbackProfile = profile() === "high444" ? "main" : profile();
|
|
5066
|
+
const configMessage = { ...config, videoCodec: H264_PROFILES[fallbackProfile].codec, iceServers: ice?.browser, request };
|
|
5067
|
+
reply(configMessage);
|
|
5068
|
+
if (ice) {
|
|
3930
5069
|
try {
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
5070
|
+
viewer.rtc = new WebRtcViewer({ iceServers: ice.native, iceTransportPolicy: "all", portRange: icePorts, budgetBps: budgetBps() }, {
|
|
5071
|
+
offer: (sdp) => reply({ type: "offer", sdp }),
|
|
5072
|
+
candidate: (candidate, mid) => reply({ type: "candidate", candidate, mid }),
|
|
5073
|
+
message: (message, answer) => handleMessage(viewer, message, answer),
|
|
5074
|
+
keyframe: (reason) => {
|
|
5075
|
+
if (reason === "stale") descendSevere(Date.now());
|
|
5076
|
+
requestKeyframe(void 0, viewer);
|
|
5077
|
+
},
|
|
5078
|
+
remb: (bitsPerSecond) => {
|
|
5079
|
+
viewer.remb = { kbps: bitsPerSecond / 1e3, at: Date.now() };
|
|
5080
|
+
},
|
|
5081
|
+
connected: () => requestKeyframe(Date.now()),
|
|
5082
|
+
// A viewer that loses its peer cannot send the key-ups for whatever it
|
|
5083
|
+
// was holding, and its socket may stay open for a long time yet.
|
|
5084
|
+
disconnected: (state) => {
|
|
5085
|
+
viewer.input.release();
|
|
5086
|
+
if (state === "failed") reply({ type: "webrtc-failed" });
|
|
5087
|
+
}
|
|
5088
|
+
});
|
|
5089
|
+
} catch (error) {
|
|
5090
|
+
process.stderr.write(`[desktop-stream] could not create a peer connection: ${error instanceof Error ? error.message : String(error)}
|
|
5091
|
+
`);
|
|
3934
5092
|
}
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
state.probeAnswered = true;
|
|
3943
|
-
state.baselineMs = Math.min(state.baselineMs, state.probeAckedMs);
|
|
3944
|
-
} else if (message.type === "stats") {
|
|
3945
|
-
const state = clients.get(client);
|
|
3946
|
-
if (!state) return;
|
|
3947
|
-
state.stats = {
|
|
3948
|
-
videoFps: clamp(message.videoFps, 0, 1e3),
|
|
3949
|
-
audioPacketsPerSecond: clamp(message.audioPacketsPerSecond, 0, 1e3),
|
|
3950
|
-
decodeQueue: clamp(message.decodeQueue, 0, 1e3),
|
|
3951
|
-
rttMs: clamp(message.rttMs, 0, 6e5)
|
|
3952
|
-
};
|
|
3953
|
-
} else if (message.type === "theme") applyDesktopTheme(message.theme);
|
|
3954
|
-
else clients.get(client)?.input.handle(message);
|
|
5093
|
+
}
|
|
5094
|
+
if (!viewer.rtc) enterFallback(viewer, offerWebRtc ? "webrtc unavailable" : "websocket requested");
|
|
5095
|
+
ensureEncoders();
|
|
5096
|
+
socket.on("message", (raw, binary) => {
|
|
5097
|
+
if (binary) return;
|
|
5098
|
+
const message = parseInput(raw.toString());
|
|
5099
|
+
if (message) handleMessage(viewer, message, reply);
|
|
3955
5100
|
});
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
5101
|
+
socket.once("close", () => {
|
|
5102
|
+
viewer.rtc?.close();
|
|
5103
|
+
viewer.input.close();
|
|
5104
|
+
viewers.delete(socket);
|
|
5105
|
+
if (viewers.size > 0) {
|
|
5106
|
+
syncEncoder();
|
|
5107
|
+
return;
|
|
5108
|
+
}
|
|
5109
|
+
clearTimeout(joinKeyframe);
|
|
3964
5110
|
stopEncodersSoon();
|
|
3965
5111
|
});
|
|
3966
5112
|
});
|
|
@@ -3968,7 +5114,7 @@ async function runMediaServer() {
|
|
|
3968
5114
|
httpServer.once("error", reject);
|
|
3969
5115
|
httpServer.listen(port, "0.0.0.0", () => {
|
|
3970
5116
|
httpServer.off("error", reject);
|
|
3971
|
-
console.log(`replicas desktop stream listening on ${port}`);
|
|
5117
|
+
console.log(`replicas desktop stream ${COMPUTER_USE_VERSION} listening on ${port} (${webrtc ? "webrtc + websocket" : "websocket only"}, default ${defaultRequest.transport}, ${colorMode} colour)`);
|
|
3972
5118
|
resolveServer();
|
|
3973
5119
|
});
|
|
3974
5120
|
});
|
|
@@ -3979,17 +5125,21 @@ async function runMediaServer() {
|
|
|
3979
5125
|
closing = true;
|
|
3980
5126
|
clearInterval(probe);
|
|
3981
5127
|
clearInterval(control);
|
|
5128
|
+
clearInterval(telemetry);
|
|
3982
5129
|
clearTimeout(encoderStop);
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
5130
|
+
clearTimeout(joinKeyframe);
|
|
5131
|
+
for (const viewer of viewers.values()) {
|
|
5132
|
+
viewer.rtc?.close();
|
|
5133
|
+
viewer.input.close();
|
|
5134
|
+
viewer.socket.close(1001, "server shutting down");
|
|
3986
5135
|
}
|
|
3987
|
-
videoEncoder.stop();
|
|
3988
|
-
audioEncoder.stop();
|
|
3989
|
-
videoSocket.close();
|
|
3990
|
-
audioSocket.close();
|
|
5136
|
+
void videoEncoder.stop();
|
|
5137
|
+
void audioEncoder.stop();
|
|
3991
5138
|
webSocketServer.close();
|
|
3992
|
-
httpServer.close(() =>
|
|
5139
|
+
httpServer.close(() => {
|
|
5140
|
+
cleanupWebRtc();
|
|
5141
|
+
resolveShutdown();
|
|
5142
|
+
});
|
|
3993
5143
|
};
|
|
3994
5144
|
process.once("SIGTERM", close);
|
|
3995
5145
|
process.once("SIGINT", close);
|
|
@@ -3998,7 +5148,7 @@ async function runMediaServer() {
|
|
|
3998
5148
|
|
|
3999
5149
|
// src/setup.ts
|
|
4000
5150
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
4001
|
-
import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync, writeFileSync as writeFileSync5 } from "fs";
|
|
5151
|
+
import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
4002
5152
|
import { dirname as dirname7, resolve as resolve3 } from "path";
|
|
4003
5153
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4004
5154
|
var dependencies = [
|
|
@@ -4067,7 +5217,7 @@ function hasCommand(command) {
|
|
|
4067
5217
|
}
|
|
4068
5218
|
});
|
|
4069
5219
|
}
|
|
4070
|
-
function setupComputer(options = {}) {
|
|
5220
|
+
async function setupComputer(options = {}) {
|
|
4071
5221
|
const found = {};
|
|
4072
5222
|
const missing = [];
|
|
4073
5223
|
for (const [name, command] of dependencies) {
|
|
@@ -4088,6 +5238,14 @@ function setupComputer(options = {}) {
|
|
|
4088
5238
|
console.log("\u2713 ffmpeg encoder libx264");
|
|
4089
5239
|
}
|
|
4090
5240
|
}
|
|
5241
|
+
const webrtcVersion = await import("node-datachannel").then((module) => module.default.getLibraryVersion()).catch(() => null);
|
|
5242
|
+
if (webrtcVersion) {
|
|
5243
|
+
found["node-datachannel"] = `libdatachannel ${webrtcVersion}`;
|
|
5244
|
+
console.log("\u2713 WebRTC (node-datachannel)");
|
|
5245
|
+
} else {
|
|
5246
|
+
missing.push("WebRTC native module (node-datachannel)");
|
|
5247
|
+
console.error("\u2717 WebRTC (node-datachannel) failed to load");
|
|
5248
|
+
}
|
|
4091
5249
|
const noVncPackageRoot = dirname7(dirname7(fileURLToPath2(import.meta.resolve("@novnc/novnc"))));
|
|
4092
5250
|
const noVncCore = resolve3(noVncPackageRoot, "core");
|
|
4093
5251
|
const noVncVendor = resolve3(noVncPackageRoot, "vendor");
|
|
@@ -4113,7 +5271,7 @@ function setupComputer(options = {}) {
|
|
|
4113
5271
|
cpSync(assets, "/usr/local/share/replicas/desktop", { recursive: true, force: true });
|
|
4114
5272
|
const fontRoot = "/usr/local/share/fonts/replicas";
|
|
4115
5273
|
mkdirSync6(fontRoot, { recursive: true });
|
|
4116
|
-
for (const file of
|
|
5274
|
+
for (const file of readdirSync2(assets).filter((name) => name.endsWith(".ttf"))) cpSync(resolve3(assets, file), resolve3(fontRoot, file), { force: true });
|
|
4117
5275
|
spawnSync6("fc-cache", ["-f", fontRoot], { stdio: "ignore" });
|
|
4118
5276
|
const viewerRoot = "/usr/local/share/replicas/desktop/novnc";
|
|
4119
5277
|
mkdirSync6(viewerRoot, { recursive: true });
|
|
@@ -4164,7 +5322,7 @@ async function main() {
|
|
|
4164
5322
|
if (argv[0] === "setup") {
|
|
4165
5323
|
const unsupported = argv.slice(1).filter((arg) => arg !== "--check");
|
|
4166
5324
|
if (unsupported.length > 0) throw new Error(`Unknown setup option: ${unsupported[0]}`);
|
|
4167
|
-
setupComputer({ checkOnly: argv.includes("--check") });
|
|
5325
|
+
await setupComputer({ checkOnly: argv.includes("--check") });
|
|
4168
5326
|
return;
|
|
4169
5327
|
}
|
|
4170
5328
|
if (argv[0] === "daemon") {
|