replicas-computer 0.2.14 → 0.3.0

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/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 alive2 = false;
1001
+ let alive3 = false;
1002
1002
  try {
1003
1003
  process.kill(pid, 0);
1004
- alive2 = true;
1004
+ alive3 = true;
1005
1005
  } catch {
1006
1006
  }
1007
- if (alive2) fail(`recording already in progress (pid ${pid}). run \`replicas computer record stop\` first.`);
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 alive = false;
1067
+ let alive2 = false;
1068
1068
  try {
1069
1069
  process.kill(child.pid, 0);
1070
- alive = true;
1070
+ alive2 = true;
1071
1071
  } catch {
1072
1072
  }
1073
- if (alive) fail("ffmpeg is running but did not produce recording output within 5 seconds; run `replicas computer record stop` to finalize or retry");
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 alive = true;
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
- alive = false;
1092
+ alive2 = false;
1093
1093
  break;
1094
1094
  }
1095
1095
  await sleep(200);
1096
1096
  }
1097
- if (alive) fail(`ffmpeg did not finalize recording within 30 seconds (pid ${pid})`);
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.2.14";
2940
+ var COMPUTER_USE_VERSION = "0.3.0";
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 spawn4 } from "child_process";
3171
- import { createSocket } from "dgram";
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/input-bridge.ts
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?.kill("SIGTERM");
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 = spawn3("xte", [], {
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/media-protocol.ts
3334
- var STREAM_PROTOCOL_VERSION = 1;
3335
- var MEDIA_PACKET_HEADER_BYTES = 12;
3336
-
3337
- // src/rtp.ts
3338
- function parseRtpPacket(data) {
3339
- if (data.length < 12 || data[0] >> 6 !== 2) return null;
3340
- if (data[1] >= 192 && data[1] <= 223) return null;
3341
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
3342
- const padding = Boolean(data[0] & 32);
3343
- const extension = Boolean(data[0] & 16);
3344
- let offset = 12 + (data[0] & 15) * 4;
3345
- if (offset > data.length) return null;
3346
- if (extension) {
3347
- if (offset + 4 > data.length) return null;
3348
- offset += 4 + view.getUint16(offset + 2) * 4;
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
- const end = data.length - (padding ? data[data.length - 1] : 0);
3351
- if (offset >= end) return null;
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
- marker: Boolean(data[1] & 128),
3354
- payload: data.subarray(offset, end),
3355
- sequence: view.getUint16(2),
3356
- timestamp: view.getUint32(4)
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 RtpClock = class {
3360
- constructor(rate) {
3361
- this.rate = rate;
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
- * A fresh encoder starts RTP timestamps from a new random base, so the
3371
- * timeline continues from the last value emitted rather than jumping
3372
- * backwards — decoders and the viewer's media element both require
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
- reset() {
3376
- this.origin = this.emitted;
3377
- this.base = void 0;
3378
- this.last = void 0;
3379
- this.wraps = 0;
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
- microseconds(timestamp) {
3382
- if (this.last !== void 0 && timestamp < this.last && this.last - timestamp > 2147483648) this.wraps += 1;
3383
- this.last = timestamp;
3384
- const extended = timestamp + this.wraps * 4294967296;
3385
- this.base ??= extended;
3386
- this.emitted = this.origin + Math.round((extended - this.base) * 1e6 / this.rate);
3387
- return this.emitted;
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
- var startCode = new Uint8Array([0, 0, 0, 1]);
3391
- var H264RtpAssembler = class {
3392
- clock = new RtpClock(9e4);
3393
- chunks = [];
3394
- key = false;
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
- reset() {
3408
- this.clock.reset();
3409
- this.clearFrame();
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
- push(packet) {
3412
- if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
3413
- this.clearFrame();
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.timestamp ??= packet.timestamp;
3416
- if (this.sequence !== void 0 && packet.sequence !== (this.sequence + 1) % 65536) this.damaged = true;
3417
- this.sequence = packet.sequence;
3418
- const type = packet.payload[0] & 31;
3419
- if (this.damaged) {
3420
- } else if (type > 0 && type < 24 && this.fragmentType === void 0) {
3421
- this.chunks.push(startCode, packet.payload);
3422
- this.key ||= type === 5;
3423
- } else if (type === 24 && this.fragmentType === void 0) {
3424
- let offset2 = 1;
3425
- let nals = 0;
3426
- while (offset2 < packet.payload.length) {
3427
- if (offset2 + 2 > packet.payload.length) {
3428
- this.damaged = true;
3429
- break;
3430
- }
3431
- const size = packet.payload[offset2] * 256 + packet.payload[offset2 + 1];
3432
- offset2 += 2;
3433
- if (!size || offset2 + size > packet.payload.length) {
3434
- this.damaged = true;
3435
- break;
3436
- }
3437
- const nal = packet.payload.subarray(offset2, offset2 + size);
3438
- this.chunks.push(startCode, nal);
3439
- this.key ||= (nal[0] & 31) === 5;
3440
- offset2 += size;
3441
- nals += 1;
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
- if (!nals) this.damaged = true;
3444
- } else if (type === 28 && packet.payload.length >= 3) {
3445
- const fragmentType = packet.payload[1] & 31;
3446
- const start = Boolean(packet.payload[1] & 128);
3447
- const end = Boolean(packet.payload[1] & 64);
3448
- if (start && !end && this.fragmentType === void 0) {
3449
- this.fragmentType = fragmentType;
3450
- this.chunks.push(startCode, new Uint8Array([packet.payload[0] & 224 | fragmentType]));
3451
- this.key ||= fragmentType === 5;
3452
- } else if (!start && this.fragmentType === fragmentType) {
3453
- if (end) this.fragmentType = void 0;
3454
- } else {
3455
- this.damaged = true;
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
- const length = this.chunks.reduce((total, chunk) => total + chunk.length, 0);
3465
- const data = new Uint8Array(length);
3466
- let offset = 0;
3467
- for (const chunk of this.chunks) {
3468
- data.set(chunk, offset);
3469
- offset += chunk.length;
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 cores = availableParallelism();
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 qualityLadder = [
3493
- { fps, kbps: configuredTopKbps, crf: 18 },
3494
- { fps: Math.max(1, Math.round(fps / 2)), kbps: 6e3, crf: 20 },
3495
- { fps: Math.max(1, Math.round(fps / 2)), kbps: 3e3, crf: 23 },
3496
- { fps: Math.max(1, Math.round(fps / 4)), kbps: 1200, crf: 26 }
3497
- ];
3498
- var keyframeMs = 250;
3499
- var probeIntervalMs = 250;
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 display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
3506
- var videoCodec = "avc1.42E02A";
3507
- var config = {
3508
- type: "config",
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 validInput(value) {
3546
- if (!value || typeof value !== "object" || !("type" in value) || typeof value.type !== "string") return false;
3547
- if (value.type === "release") return true;
3548
- if (value.type === "theme") return "theme" in value && (value.theme === "light" || value.theme === "dark");
3549
- if (value.type === "stats") return "videoFps" in value && typeof value.videoFps === "number" && Number.isFinite(value.videoFps) && "audioPacketsPerSecond" in value && typeof value.audioPacketsPerSecond === "number" && Number.isFinite(value.audioPacketsPerSecond) && "decodeQueue" in value && typeof value.decodeQueue === "number" && Number.isFinite(value.decodeQueue) && "rttMs" in value && typeof value.rttMs === "number" && Number.isFinite(value.rttMs);
3550
- if (value.type === "ping" || value.type === "pong") return "sentAt" in value && typeof value.sentAt === "number";
3551
- if (value.type === "key") return "code" in value && typeof value.code === "string" && value.code.length < 40 && "down" in value && typeof value.down === "boolean";
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 = spawn4("bash", [SERVICES_SCRIPT, "--theme", theme], { detached: true, stdio: "ignore" });
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 videoSocket = await bindUdp();
3582
- const audioSocket = await bindUdp();
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 clients = /* @__PURE__ */ new Map();
3588
- let encoderStop;
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 queueMs = (state) => {
3600
- if (!state.probeAnswered) return 0;
3601
- const outstanding = state.probeSentAt ? Date.now() - state.probeSentAt : 0;
3602
- return Math.max(0, Math.max(state.probeAckedMs, outstanding) - state.baselineMs);
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 broadcast = (packet, keyframe = false) => {
3605
- for (const [client, state] of clients) {
3606
- if (client.readyState !== WebSocket2.OPEN) continue;
3607
- if (client.bufferedAmount > maximumBufferedBytes) {
3608
- client.close(1013, "stream client is too slow");
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 (!keyframe && (state.waitingForKeyframe || queueMs(state) > targetBacklogMs || client.bufferedAmount > fallbackBufferedBytes)) {
3612
- state.waitingForKeyframe = true;
4395
+ if (!keyframe2 && (viewer.waitingForKeyframe || queueMs(viewer) > targetBacklogMs || socket.bufferedAmount > fallbackBufferedBytes)) {
4396
+ viewer.waitingForKeyframe = true;
3613
4397
  continue;
3614
4398
  }
3615
- if (keyframe) state.waitingForKeyframe = false;
3616
- client.send(packet, { binary: true });
4399
+ if (keyframe2) viewer.waitingForKeyframe = false;
4400
+ socket.send(packet, { binary: true });
3617
4401
  }
3618
4402
  };
3619
- videoSocket.on("message", (data) => {
3620
- const rtp = parseRtpPacket(data);
3621
- if (!rtp) return;
3622
- const frame = videoAssembler.push(rtp);
3623
- if (!frame) return;
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
- videoFrames += 1;
3626
- videoBytes += frame.data.byteLength;
3627
- const packet = mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data);
3628
- broadcast(packet, frame.key);
3629
- });
3630
- audioSocket.on("message", (data) => {
3631
- const rtp = parseRtpPacket(data);
3632
- if (!rtp) return;
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
- audioBytes += rtp.payload.byteLength;
3636
- broadcast(mediaPacket(3 /* Audio */, audioClock.microseconds(rtp.timestamp), rtp.payload));
3637
- });
3638
- const superviseEncoder = (label, prepare, buildArgs, niceness = 0) => {
3639
- let child;
3640
- let retiring;
3641
- let restart;
3642
- let intent = 0;
3643
- const alive = (process2) => Boolean(process2 && process2.exitCode === null && process2.signalCode === null);
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 videoEncoder = superviseEncoder("video", () => {
3704
- videoAssembler.reset();
3705
- for (const state of clients.values()) state.waitingForKeyframe = true;
3706
- }, () => {
3707
- const quality = qualityLadder[rung];
3708
- const keyframeInterval = Math.max(1, Math.round(quality.fps * keyframeMs / 1e3));
3709
- return [
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
- "x11grab",
3725
- "-draw_mouse",
3726
- "0",
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
- `${display}.0`,
3735
- "-an",
3736
- "-c:v",
3737
- "libx264",
3738
- "-preset",
3739
- x264Preset,
3740
- "-tune",
3741
- "zerolatency",
3742
- "-profile:v",
3743
- "baseline",
3744
- "-level:v",
3745
- "4.2",
3746
- "-pix_fmt",
3747
- "yuv420p",
3748
- "-bf",
3749
- "0",
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
- "96",
3772
- `rtp://127.0.0.1:${videoPort}?pkt_size=1200&connect=1&rtcpport=${videoPort}`
3773
- ];
3774
- }, 10);
3775
- const audioEncoder = superviseEncoder("audio", () => audioClock.reset(), () => [
3776
- "-hide_banner",
3777
- "-loglevel",
3778
- "warning",
3779
- "-fflags",
3780
- "nobuffer",
3781
- "-flags",
3782
- "low_delay",
3783
- "-probesize",
3784
- "32",
3785
- "-analyzeduration",
3786
- "0",
3787
- "-thread_queue_size",
3788
- "1024",
3789
- "-f",
3790
- "pulse",
3791
- "-fragment_size",
3792
- "2048",
3793
- "-use_wallclock_as_timestamps",
3794
- "1",
3795
- "-i",
3796
- process.env.REPLICAS_DESKTOP_STREAM_AUDIO_SOURCE || "default",
3797
- "-vn",
3798
- "-af",
3799
- "aresample=async=1:first_pts=0",
3800
- "-c:a",
3801
- "libopus",
3802
- "-application",
3803
- "lowdelay",
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
- if (next > rung) descents += 1;
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
- process.stderr.write(`[desktop-stream] quality rung ${rung} (${qualityLadder[rung].fps} fps, ${qualityLadder[rung].kbps}k)
3831
- `);
3832
- videoEncoder.replace();
4653
+ for (const viewer of viewers.values()) viewer.rtc?.setBudget(budgetBps());
4654
+ syncEncoder();
3833
4655
  };
3834
- const stopEncodersSoon = () => {
3835
- clearTimeout(encoderStop);
3836
- encoderStop = setTimeout(() => {
3837
- if (clients.size > 0) return;
3838
- videoEncoder.stop();
3839
- audioEncoder.stop();
3840
- }, 5e3);
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
- const states = [...clients.values()];
3852
- if (states.length === 0) return;
3853
- const struggling = states.filter((state) => queueMs(state) > congestedBacklogMs || (state.stats?.decodeQueue ?? 0) > congestedDecodeQueue).length;
3854
- if (struggling === states.length) {
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(qualityLadder.length - 1, rung + 1));
4739
+ if (congestedSeconds >= 2) setRung(Math.min(ladder().length - 1, rung + 1));
3858
4740
  return;
3859
4741
  }
3860
- congestedSeconds = 0;
3861
- if (struggling > 0) {
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
- if (comfortableSeconds >= Math.min(300, 15 * 2 ** descents)) setRung(Math.max(0, rung - 1));
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
- clients: clients.size,
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
- clientStats: [...clients.values()].flatMap((client) => client.stats ? [client.stats] : []),
3884
- clientQueueMs: [...clients.values()].map((client) => Math.round(queueMs(client))),
3885
- clientBaselineMs: [...clients.values()].map((client) => Number.isFinite(client.baselineMs) ? Math.round(client.baselineMs) : null),
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: qualityLadder[rung].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
- startEncoders();
3902
- if (clients.size === 0) stopEncodersSoon();
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", extname(path) === ".html" ? "no-store" : "private, max-age=3600");
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: 16 * 1024 });
5025
+ const webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
3909
5026
  httpServer.on("upgrade", (request, socket, head) => {
3910
- if (new URL(request.url || "/", "http://localhost").pathname !== "/stream") {
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, request));
5032
+ webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest)));
3915
5033
  });
3916
- webSocketServer.on("connection", (client) => {
3917
- clients.set(client, {
3918
- waitingForKeyframe: true,
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
- client.send(JSON.stringify(config));
3926
- startEncoders();
3927
- client.on("message", (raw, binary) => {
3928
- if (binary) return;
3929
- let message;
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
- message = JSON.parse(raw.toString());
3932
- } catch {
3933
- return;
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
- if (!validInput(message)) return;
3936
- if (message.type === "ping") client.send(JSON.stringify({ type: "pong", sentAt: message.sentAt }));
3937
- else if (message.type === "pong") {
3938
- const state = clients.get(client);
3939
- if (!state || message.sentAt !== state.probeSentAt) return;
3940
- state.probeAckedMs = Date.now() - state.probeSentAt;
3941
- state.probeSentAt = 0;
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
- client.once("close", () => {
3957
- clients.get(client)?.input.close();
3958
- clients.delete(client);
3959
- if (clients.size > 0) return;
3960
- rung = 0;
3961
- descents = 0;
3962
- congestedSeconds = 0;
3963
- comfortableSeconds = 0;
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
- for (const [client, state] of clients) {
3984
- state.input.close();
3985
- client.close(1001, "server shutting down");
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(() => resolveShutdown());
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 readdirSync(assets).filter((name) => name.endsWith(".ttf"))) cpSync(resolve3(assets, file), resolve3(fontRoot, file), { force: true });
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") {