feat(media): integrate embedded media services and shared native encoding
Embed STUN and SFU in the shared Go process, reuse native encoding pipelines with independent adaptation, and complete Client lifecycle, settings, diagnostics and packaging fixes. Retain standard Browser senders after measured encoded-reuse limitations.
This commit is contained in:
1 parent
61a9b74cbd
commit
cfc323adce
274 files changed
+37854
-13238
No files matched your search
+21
-14
@@ -10,7 +10,6 @@ import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
readFileSync,
|
||||
@@ -18,14 +17,14 @@ import {
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
||||
import { clientPackageTarget, clientGoEnvironment, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
||||
import { writeClientPlatformAssets } from "./client-icons.mjs";
|
||||
import { writeClientLicenseNotices } from "./package-licenses.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
import { resetBuildWorkspace } from "./build-workspace.mjs";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
@@ -130,10 +129,20 @@ function readDescriptor(path) {
|
||||
};
|
||||
}
|
||||
|
||||
function assertOutsideRepository(repositoryRoot, outputRoot) {
|
||||
function assertOutputDirectory(repositoryRoot, outputRoot, target) {
|
||||
const path = relative(repositoryRoot, outputRoot);
|
||||
if (path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path))) {
|
||||
fail("Client output directory must be outside the repository");
|
||||
const candidateOutput = join(repositoryRoot, "build", "client-package", target.id,
|
||||
"candidate", `Screener-Client-${target.id}`);
|
||||
if (outputRoot !== candidateOutput &&
|
||||
(path === "" || (path.split(/[\\/]/)[0] !== ".." && !isAbsolute(path)))) {
|
||||
fail("Client output must be outside the repository or its exact candidate workspace");
|
||||
}
|
||||
if (outputRoot === candidateOutput) {
|
||||
for (let parent = dirname(outputRoot); parent !== repositoryRoot; parent = dirname(parent)) {
|
||||
const metadata = lstatSync(parent, { throwIfNoEntry: false });
|
||||
if (metadata && (!metadata.isDirectory() || metadata.isSymbolicLink() ||
|
||||
realpathSync(parent) !== parent)) fail("Client candidate output must not contain links");
|
||||
}
|
||||
}
|
||||
if (existsSync(outputRoot)) {
|
||||
fail("Client output directory must not already exist");
|
||||
@@ -172,13 +181,16 @@ for (let index = 0; index < options.length; index += 2) {
|
||||
}
|
||||
const target = clientPackageTarget(targetArgument);
|
||||
if (!target) fail("Client package target is invalid");
|
||||
if (target.cgo && process.platform !== target.nodePlatform) {
|
||||
fail(`Client target ${target.id} requires a native macOS runner with its SDK and cgo`);
|
||||
}
|
||||
|
||||
const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||
const descriptorPath = realpathSync(resolve(positional[0]));
|
||||
const outputRoot = resolve(process.cwd(), positional[1]);
|
||||
const capturePath = captureArgument ? realpathSync(resolve(captureArgument)) : null;
|
||||
const tunnelPath = tunnelArgument ? realpathSync(resolve(tunnelArgument)) : null;
|
||||
assertOutsideRepository(repositoryRoot, outputRoot);
|
||||
assertOutputDirectory(repositoryRoot, outputRoot, target);
|
||||
if (capturePath && !target.captureName) {
|
||||
fail("Capture runtime is invalid for the Client package target");
|
||||
}
|
||||
@@ -198,7 +210,7 @@ if (!existsSync(artifactPath) || sha256(artifactPath) !== descriptor.artifactSha
|
||||
fail("Application release artifact does not match its descriptor");
|
||||
}
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-client-${revision.slice(0, 7)}-`));
|
||||
const temporaryRoot = resetBuildWorkspace(repositoryRoot, "client-package", target.id, "assembly");
|
||||
const packageRoot = join(temporaryRoot, "package");
|
||||
const releaseRoot = join(temporaryRoot, "release");
|
||||
try {
|
||||
@@ -245,12 +257,7 @@ try {
|
||||
"-o",
|
||||
clientPath,
|
||||
"./cmd/screener-client",
|
||||
], repositoryRoot, {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
});
|
||||
], repositoryRoot, clientGoEnvironment(target));
|
||||
chmodSync(clientPath, 0o755);
|
||||
assertTargetExecutable(clientPath, target, "Client executable");
|
||||
writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand, target,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import http from 'node:http';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// Explicit local experiment, never part of product startup or ordinary CI.
|
||||
const codec = process.argv.includes('--h264') ? 'H264' : 'VP8';
|
||||
const lateJoin = process.argv.includes('--late-join');
|
||||
const limited = process.argv.includes('--limit-secondary');
|
||||
const limitedPrimary = process.argv.includes('--limit-primary');
|
||||
const requestKeyframe = process.argv.includes('--request-keyframe');
|
||||
const ordinary = process.argv.includes('--ordinary');
|
||||
const standard = process.argv.includes('--standard');
|
||||
const cloneOwn = process.argv.includes('--clone-own');
|
||||
const durationMs = limitedPrimary ? 14000 : 7000;
|
||||
const suffix = (lateJoin ? '-late' : '') + (limited ? '-limited' : '') +
|
||||
(limitedPrimary ? '-primary-limited' : '') + (requestKeyframe ? '-key' : '') + (ordinary ? '-ordinary' : '') +
|
||||
(standard ? '-standard' : '') + (cloneOwn ? '-clone-own' : '');
|
||||
const worker = `const writers=new Map();
|
||||
self.onrtctransform=event=>{
|
||||
const {readable,writable,options}=event.transformer;
|
||||
const writer=writable.getWriter();writers.set(options.name,writer);
|
||||
readable.pipeTo(new WritableStream({async write(frame){
|
||||
if(options.name!=='A')return;
|
||||
const copy=new RTCEncodedVideoFrame(frame);
|
||||
await writer.write(${cloneOwn ? 'new RTCEncodedVideoFrame(frame)' : 'frame'});
|
||||
if(writers.has('B'))await writers.get('B').write(copy);
|
||||
}})).catch(error=>self.postMessage({error:error.name+': '+error.message}));
|
||||
};`;
|
||||
mkdirSync('build/encoder-pool', { recursive: true });
|
||||
const html = `<!doctype html><meta charset="utf-8"><title>Encoded fanout probe</title>
|
||||
<canvas id="source" width="640" height="360"></canvas>
|
||||
<video id="a" autoplay muted playsinline></video><video id="b" autoplay muted playsinline></video>
|
||||
<pre id="result">starting</pre><script>
|
||||
const pc = [], tracks = [], errors = [];
|
||||
const transformWorker=${standard ? "new Worker('/worker.js')" : 'null'};
|
||||
if(transformWorker)transformWorker.onmessage=event=>{if(event.data.error)errors.push(event.data.error);};
|
||||
let drawing = true, frame = 0, sharedFrames = 0, dummyFrames = 0;
|
||||
let primaryKeys = 0, joined = false, waitingKey = ${requestKeyframe}, connectedAt = null, firstDecodedMs = null;
|
||||
document.querySelector('#b').requestVideoFrameCallback(() => {
|
||||
if (connectedAt !== null) firstDecodedMs = performance.now() - connectedAt;
|
||||
});
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const source = document.querySelector('#source');
|
||||
const ctx = source.getContext('2d');
|
||||
function draw() {
|
||||
ctx.fillStyle = '#162b38'; ctx.fillRect(0,0,640,360);
|
||||
ctx.fillStyle = '#58cf94'; ctx.fillRect((frame * 6) % 580,70,60,180);
|
||||
ctx.fillStyle = '#ffb94a'; ctx.font = '42px sans-serif'; ctx.fillText(String(frame++),30,50);
|
||||
if (drawing) requestAnimationFrame(draw);
|
||||
}
|
||||
draw();
|
||||
function gather(connection) {
|
||||
if (connection.iceGatheringState === 'complete') return Promise.resolve();
|
||||
return new Promise((resolve,reject) => {
|
||||
const timer=setTimeout(()=>reject(Error('ICE gathering deadline')),5000);
|
||||
connection.addEventListener('icegatheringstatechange',()=>{
|
||||
if(connection.iceGatheringState==='complete'){ clearTimeout(timer); resolve(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
function connected(connection) {
|
||||
if (connection.connectionState === 'connected') return Promise.resolve();
|
||||
return new Promise((resolve,reject) => {
|
||||
const listener=()=>{if(connection.connectionState==='connected'){clearTimeout(timer);connection.removeEventListener('connectionstatechange',listener);resolve();}};
|
||||
const timer=setTimeout(()=>{connection.removeEventListener('connectionstatechange',listener);reject(Error('connection deadline'));},5000);
|
||||
connection.addEventListener('connectionstatechange',listener);
|
||||
});
|
||||
}
|
||||
async function pair(track,video,name) {
|
||||
const send=new RTCPeerConnection({encodedInsertableStreams:${!standard},iceServers:[]});
|
||||
const receive=new RTCPeerConnection({iceServers:[]}); pc.push(send,receive);
|
||||
receive.ontrack=e=>{video.srcObject=new MediaStream([e.track]); video.play().catch(()=>{});};
|
||||
const sender=send.addTrack(track,new MediaStream([track]));
|
||||
send.getTransceivers()[0].setCodecPreferences(RTCRtpSender.getCapabilities('video').codecs.filter(c=>c.mimeType==='video/${codec}'));
|
||||
const streams=${standard ? "null;sender.transform=new RTCRtpScriptTransform(transformWorker,{name})" : 'sender.createEncodedStreams()'};
|
||||
return {send,receive,sender,streams,async connect(){
|
||||
await send.setLocalDescription(await send.createOffer()); await gather(send);
|
||||
await receive.setRemoteDescription(send.localDescription);
|
||||
await receive.setLocalDescription(await receive.createAnswer()); await gather(receive);
|
||||
await send.setRemoteDescription(receive.localDescription);
|
||||
}};
|
||||
}
|
||||
async function stats(connection) {
|
||||
return [...(await connection.getStats()).values()].filter(s=>(s.type==='inbound-rtp'||s.type==='outbound-rtp'||s.type==='remote-outbound-rtp')&&s.kind==='video').map(s=>{
|
||||
const keys=['type','framesEncoded','framesDecoded','framesSent','framesReceived','frameWidth','frameHeight','framesPerSecond','bytesSent','bytesReceived','totalEncodeTime','qualityLimitationReason','targetBitrate','pliCount','nackCount','reportsSent','remoteTimestamp','encoderImplementation','powerEfficientEncoder'];
|
||||
return Object.fromEntries(keys.filter(k=>s[k]!==undefined).map(k=>[k,s[k]]));
|
||||
});
|
||||
}
|
||||
async function run() {
|
||||
if(${standard ? "!globalThis.RTCRtpScriptTransform" : "!RTCRtpSender.prototype.createEncodedStreams"}) throw Error('requested transform unavailable');
|
||||
const stream=source.captureStream(30);const main=stream.getVideoTracks()[0];main.contentHint='motion';tracks.push(main);
|
||||
const dummy=document.createElement('canvas');dummy.width=dummy.height=16;
|
||||
dummy.getContext('2d').fillRect(0,0,16,16);
|
||||
const dummyTrack=${ordinary ? "main.clone()" : "dummy.captureStream(0).getVideoTracks()[0]"}; dummyTrack.contentHint='motion';tracks.push(dummyTrack);
|
||||
const A=await pair(main,document.querySelector('#a'),'A');
|
||||
const B=await pair(dummyTrack,document.querySelector('#b'),'B');
|
||||
${limited ? "const parameters=B.sender.getParameters();parameters.encodings[0].maxBitrate=50000;await B.sender.setParameters(parameters);" : ''}
|
||||
${limitedPrimary ? "const primaryParameters=A.sender.getParameters();primaryParameters.encodings[0].maxBitrate=50000;await A.sender.setParameters(primaryParameters);" : ''}
|
||||
let readerA,readerB,pumpA,pumpB;
|
||||
if(!${standard}){
|
||||
const writerA=A.streams.writable.getWriter(),writerB=B.streams.writable.getWriter();
|
||||
readerA=A.streams.readable.getReader();readerB=B.streams.readable.getReader();
|
||||
pumpA=(async()=>{for(;;){const r=await readerA.read();if(r.done)return;if(r.value.type==='key')primaryKeys++;const copy=new RTCEncodedVideoFrame(r.value);await writerA.write(r.value);
|
||||
if(!${ordinary}&&(!${requestKeyframe}||joined)){if(waitingKey&©.type!=='key')continue;waitingKey=false;await writerB.write(copy);sharedFrames++;}
|
||||
}})().catch(e=>errors.push(e.message));
|
||||
pumpB=(async()=>{for(;;){const r=await readerB.read();if(r.done)return;dummyFrames++;if(${ordinary})await writerB.write(r.value);}})().catch(e=>errors.push(e.message));
|
||||
}
|
||||
${lateJoin ? 'await A.connect();await wait(250);await B.connect();' : 'await B.connect();await A.connect();'}
|
||||
await connected(B.send);joined=true;connectedAt=performance.now();
|
||||
${requestKeyframe ? "await A.sender.setParameters(A.sender.getParameters(), {encodingOptions:[{keyFrame:true}]});" : ''}
|
||||
${ordinary ? '' : 'dummyTrack.requestFrame();'}
|
||||
await wait(${durationMs});
|
||||
const result={codec:'${codec}',lateJoin:${lateJoin},ordinary:${ordinary},standard:${standard},cloneOwn:${cloneOwn},requestedKeyframe:${requestKeyframe},primaryKeys,firstDecodedMs,durationMs:${durationMs},
|
||||
primaryMaxBitrate:A.sender.getParameters().encodings[0].maxBitrate??null,secondaryMaxBitrate:B.sender.getParameters().encodings[0].maxBitrate??null,browser:navigator.userAgent,sharedFrames,dummyFrames,errors,
|
||||
aSend:await stats(A.send),bSend:await stats(B.send),aReceive:await stats(A.receive),bReceive:await stats(B.receive),
|
||||
aVideo:[document.querySelector('#a').videoWidth,document.querySelector('#a').videoHeight],
|
||||
bVideo:[document.querySelector('#b').videoWidth,document.querySelector('#b').videoHeight]};
|
||||
drawing=false;tracks.forEach(t=>t.stop());pc.forEach(p=>p.close());
|
||||
if(readerA){await readerA.cancel();await readerB.cancel();await Promise.allSettled([pumpA,pumpB]);}
|
||||
transformWorker?.terminate();
|
||||
return result;
|
||||
}
|
||||
window.probeDone=false;
|
||||
run().then(async result=>{
|
||||
window.probeResult=result;window.probeDone=true;document.querySelector('#result').textContent=JSON.stringify(result,null,2);
|
||||
await fetch('/result',{method:'POST',body:JSON.stringify(result)});
|
||||
}).catch(async e=>{
|
||||
drawing=false;tracks.forEach(t=>t.stop());pc.forEach(p=>p.close());
|
||||
transformWorker?.terminate();
|
||||
window.probeResult={error:e.stack,errors};window.probeDone=true;
|
||||
document.querySelector('#result').textContent=JSON.stringify(window.probeResult);
|
||||
await fetch('/result',{method:'POST',body:JSON.stringify(window.probeResult)});
|
||||
});
|
||||
</script>`;
|
||||
|
||||
const timer = setTimeout(()=>server.close(),60000);
|
||||
const server = http.createServer((req,res)=>{
|
||||
if(req.url==='/worker.js'){res.setHeader('content-type','text/javascript');res.end(worker);}
|
||||
else if(req.url==='/result'&&req.method==='POST'){
|
||||
let data='';req.on('data',chunk=>{data+=chunk;if(data.length>65536)req.destroy();});
|
||||
req.on('end',()=>{const result=JSON.parse(data);writeFileSync('build/encoder-pool/browser-fanout-'+codec.toLowerCase()+suffix+'.json',JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result));res.end('ok');clearTimeout(timer);server.close();});
|
||||
} else {res.setHeader('content-type','text/html');res.end(html);}
|
||||
});
|
||||
server.listen(0,'127.0.0.1',()=>console.log('http://127.0.0.1:'+server.address().port));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { lstatSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function resetBuildWorkspace(repositoryRoot, ...parts) {
|
||||
if (parts.length < 2 || parts.some((part) => typeof part !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(part))) {
|
||||
throw new Error("Build workspace requires a named descendant of build");
|
||||
}
|
||||
let directory = realpathSync(repositoryRoot);
|
||||
for (const part of ["build", ...parts]) {
|
||||
directory = join(directory, part);
|
||||
const metadata = lstatSync(directory, { throwIfNoEntry: false });
|
||||
if (metadata && (!metadata.isDirectory() || metadata.isSymbolicLink() ||
|
||||
realpathSync(directory) !== directory)) {
|
||||
throw new Error("Build workspace must not contain links");
|
||||
}
|
||||
if (!metadata) mkdirSync(directory, { mode: 0o700 });
|
||||
}
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
mkdirSync(directory, { mode: 0o700 });
|
||||
return directory;
|
||||
}
|
||||
+20
-31
@@ -9,7 +9,7 @@ import {
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CLIENT_PACKAGE_TARGETS, clientPackageTarget } from "./client-package-targets.mjs";
|
||||
import { CLIENT_PACKAGE_TARGETS, clientPackageTarget, clientGoEnvironment } from "./client-package-targets.mjs";
|
||||
|
||||
// Every Go command embeds the Vite output, so the build, vet and test steps all
|
||||
// fail without it. The Hosted binary is cross-built for its deployment target.
|
||||
@@ -65,7 +65,14 @@ function checkCore() {
|
||||
|
||||
const buildRoot = join(root, "build", "client-check");
|
||||
mkdirSync(buildRoot, { recursive: true });
|
||||
const builds = CLIENT_PACKAGE_TARGETS.flatMap((target) =>
|
||||
const buildTargets = CLIENT_PACKAGE_TARGETS.filter((target) => {
|
||||
if (target.cgo && process.platform !== target.nodePlatform) {
|
||||
process.stderr.write(`Skipped ${target.id}: its cgo dependencies require a native macOS runner and SDK; Darwin acceptance remains pending.\n`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const builds = buildTargets.flatMap((target) =>
|
||||
["screener-client", "screener-peer-gate"].map((command) => ({ target, command })),
|
||||
);
|
||||
builds.push({ target: SERVER_TARGET, command: "screener-server" });
|
||||
@@ -74,12 +81,7 @@ function checkCore() {
|
||||
? `${command}.exe`
|
||||
: `${command}-${target.id}`;
|
||||
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
|
||||
env: {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: "0",
|
||||
},
|
||||
env: clientGoEnvironment(target),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -90,32 +92,19 @@ function runClientTests(go) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows associates its listen prompt with the test executable path. Keep
|
||||
// the one UDP integration package at a stable path so repeated checks do not
|
||||
// create a new firewall rule for every Go build directory.
|
||||
const packages = run(go, ["list", "./..."], { capture: true })
|
||||
// Windows firewall permissions follow executable paths, including tests that
|
||||
// open sockets indirectly. Never execute a test from Go's temporary directory.
|
||||
const packages = run(go, ["list", "-f", '{{if or .TestGoFiles .XTestGoFiles}}[{{printf "%q" .ImportPath}},{{printf "%q" .Dir}}]{{end}}', "./..."], { capture: true })
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const stableNetworkPackages = [
|
||||
{
|
||||
package: "github.com/TNTcraftHIM/Screener/internal/client/mediaedge",
|
||||
binary: "mediaedge.test.exe",
|
||||
},
|
||||
];
|
||||
const stablePackageNames = new Set(
|
||||
stableNetworkPackages.map((entry) => entry.package),
|
||||
);
|
||||
const otherPackages = packages.filter((value) => !stablePackageNames.has(value));
|
||||
if (otherPackages.length > 0) {
|
||||
run(go, ["test", ...otherPackages]);
|
||||
}
|
||||
.filter(Boolean)
|
||||
.map((value) => JSON.parse(value));
|
||||
const stableRoot = join(root, "build", "client-check");
|
||||
mkdirSync(stableRoot, { recursive: true });
|
||||
for (const entry of stableNetworkPackages) {
|
||||
const binary = join(stableRoot, entry.binary);
|
||||
run(go, ["test", "-c", "-o", binary, entry.package]);
|
||||
run(binary, ["-test.v"]);
|
||||
for (const [entry, directory] of packages) {
|
||||
const binary = join(stableRoot, `${basename(entry)}.test.exe`);
|
||||
run(go, ["test", "-c", "-o", binary, entry]);
|
||||
run(binary, [], { cwd: directory });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +164,7 @@ function checkPlatformCapture() {
|
||||
const probe = JSON.parse(raw);
|
||||
const expectedPlatform = process.platform === "win32" ? "windows" : process.platform;
|
||||
if (
|
||||
probe?.protocol !== 4 ||
|
||||
probe?.protocol !== 7 ||
|
||||
probe.platform !== expectedPlatform ||
|
||||
typeof probe.platformBuild !== "string" ||
|
||||
typeof probe.videoCapture !== "boolean" ||
|
||||
|
||||
@@ -203,6 +203,7 @@ async function main(): Promise<void> {
|
||||
stage = "share-picker";
|
||||
await click(host, 'button.lr-tv-big.is-action');
|
||||
stage = "share-browser-capture";
|
||||
await click(host, 'button[data-source-tab="browser"]');
|
||||
await click(host, '.lr-source-option.is-browser');
|
||||
stage = "share-ingress";
|
||||
await waitForSample(() => read(host), (s) => s.sharing && s.receiveRequests === 1 && s.outbound === 1, 30000);
|
||||
|
||||
@@ -151,6 +151,20 @@ function startPageServer(port: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeCaptureControl(child: ChildProcessWithoutNullStreams, command: string): void {
|
||||
const payload = Buffer.from(command, "ascii");
|
||||
if (payload.length === 0 || payload.length > 64 || !/^[\x20-\x7e]+$/.test(command)) {
|
||||
throw new Error("Capture control exceeds its SMED bound");
|
||||
}
|
||||
const frame = Buffer.alloc(32 + payload.length);
|
||||
frame.write("SMED", 0, "ascii");
|
||||
frame[4] = 2;
|
||||
frame[5] = 7;
|
||||
frame.writeUInt32BE(payload.length, 28);
|
||||
payload.copy(frame, 32);
|
||||
child.stdin.write(frame);
|
||||
}
|
||||
|
||||
function observeCapture(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
evidence: CaptureEvidence,
|
||||
@@ -159,22 +173,22 @@ function observeCapture(
|
||||
let framesAtRequest = 0;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 28) {
|
||||
if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 1) {
|
||||
while (buffered.length >= 32) {
|
||||
if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 2) {
|
||||
child.kill();
|
||||
return;
|
||||
}
|
||||
const kind = buffered[5]!;
|
||||
const flags = buffered[6]!;
|
||||
const size = buffered.readUInt32BE(24);
|
||||
const maximum = kind === 3 ? 4 * 1024 : 1024 * 1024;
|
||||
if (size === 0 || size > maximum) {
|
||||
const size = buffered.readUInt32BE(28);
|
||||
const maximum = kind === 3 ? 4 * 1024 : 4 * 1024 * 1024;
|
||||
if ((size === 0 && kind !== 5) || size > maximum) {
|
||||
child.kill();
|
||||
return;
|
||||
}
|
||||
if (buffered.length < 28 + size) return;
|
||||
const payload = buffered.subarray(28, 28 + size);
|
||||
buffered = buffered.subarray(28 + size);
|
||||
if (buffered.length < 32 + size) return;
|
||||
const payload = buffered.subarray(32, 32 + size);
|
||||
buffered = buffered.subarray(32 + size);
|
||||
if (kind === 3) {
|
||||
const state = JSON.parse(payload.toString("utf8")) as { state?: string };
|
||||
evidence.starting ||= state.state === "starting";
|
||||
@@ -184,7 +198,7 @@ function observeCapture(
|
||||
if (!evidence.keyFrameRequested && evidence.frames >= 5) {
|
||||
evidence.keyFrameRequested = true;
|
||||
framesAtRequest = evidence.frames;
|
||||
child.stdin.write("K");
|
||||
writeCaptureControl(child, "K -1");
|
||||
} else if (evidence.keyFrameRequested && evidence.frames > framesAtRequest && flags === 1) {
|
||||
evidence.recoveryFrame = true;
|
||||
}
|
||||
@@ -200,20 +214,20 @@ function observeAudioCapture(
|
||||
let buffered = Buffer.alloc(0);
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 28) {
|
||||
if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 1) {
|
||||
while (buffered.length >= 32) {
|
||||
if (buffered.subarray(0, 4).toString("ascii") !== "SMED" || buffered[4] !== 2) {
|
||||
child.kill();
|
||||
return;
|
||||
}
|
||||
const kind = buffered[5]!;
|
||||
const size = buffered.readUInt32BE(24);
|
||||
const maximum = kind === 3 ? 4 * 1024 : 1024 * 1024;
|
||||
if (size === 0 || size > maximum || buffered.length < 28 + size) {
|
||||
const size = buffered.readUInt32BE(28);
|
||||
const maximum = kind === 3 ? 4 * 1024 : 4 * 1024 * 1024;
|
||||
if (size === 0 || size > maximum || buffered.length < 32 + size) {
|
||||
if (size === 0 || size > maximum) child.kill();
|
||||
return;
|
||||
}
|
||||
const payload = buffered.subarray(28, 28 + size);
|
||||
buffered = buffered.subarray(28 + size);
|
||||
const payload = buffered.subarray(32, 32 + size);
|
||||
buffered = buffered.subarray(32 + size);
|
||||
if (kind === 3) {
|
||||
const state = JSON.parse(payload.toString("utf8")) as {
|
||||
state?: string;
|
||||
@@ -237,7 +251,7 @@ async function stopCapture(child: ChildProcessWithoutNullStreams): Promise<void>
|
||||
if (child.exitCode !== 0) throw new Error("Native capture exited unsuccessfully");
|
||||
return;
|
||||
}
|
||||
child.stdin.write("\n");
|
||||
writeCaptureControl(child, "Q");
|
||||
const code = await withDeadline(
|
||||
() => new Promise<number | null>((resolveExit) => child.once("exit", resolveExit)),
|
||||
Date.now() + 3_000,
|
||||
@@ -341,7 +355,7 @@ async function browserMediaGate(input: {
|
||||
rejectRequest(new Error("Native request timed out: " + type));
|
||||
}, 8_000);
|
||||
pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
|
||||
socket!.send(JSON.stringify({ version: 8, id, type, ...fields }));
|
||||
socket!.send(JSON.stringify({ version: 9, id, type, ...fields }));
|
||||
});
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
@@ -724,7 +738,7 @@ async function main(): Promise<void> {
|
||||
const probe = JSON.parse(run(executable, ["--probe"])) as Probe;
|
||||
const adapter = probe.adapters.find((candidate) => candidate.hardwareH264.length > 0);
|
||||
const encoder = adapter?.hardwareH264[0];
|
||||
if (probe.protocol !== 4 || !adapter || !encoder) {
|
||||
if (probe.protocol !== 7 || !adapter || !encoder) {
|
||||
throw new Error("No hardware H264 capture path is available");
|
||||
}
|
||||
|
||||
@@ -802,7 +816,8 @@ async function main(): Promise<void> {
|
||||
"balanced",
|
||||
"--codec",
|
||||
"h264",
|
||||
"--protocol-v4",
|
||||
"--protocol-v7",
|
||||
"--output", "1280", "720", "30", "3000000",
|
||||
], { stdio: "pipe", windowsHide: true });
|
||||
capture.stderr.resume();
|
||||
observeCapture(capture, evidence);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -36,6 +36,15 @@ type VideoCodec = "h264" | "vp8";
|
||||
|
||||
const CODEC_PROBE = `(() => {
|
||||
const peers = [], BrowserPeer = RTCPeerConnection;
|
||||
const sockets = [], BrowserSocket = WebSocket;
|
||||
window.WebSocket = class extends BrowserSocket {
|
||||
constructor(...args) { super(...args); sockets.push(this); }
|
||||
};
|
||||
window.__screenerGatePublicSignal = () => sockets.some((socket) => {
|
||||
const url = new URL(socket.url);
|
||||
return socket.readyState === WebSocket.OPEN && url.protocol === 'wss:' &&
|
||||
url.host === location.host && url.pathname === '/signal';
|
||||
});
|
||||
window.RTCPeerConnection = class extends BrowserPeer {
|
||||
constructor(...args) { super(...args); peers.push(this); }
|
||||
};
|
||||
@@ -68,6 +77,7 @@ interface GateResult {
|
||||
livePresetChanges: number;
|
||||
liveQualityChanged: boolean;
|
||||
pausedQualityChanged: boolean;
|
||||
backgroundProfileRecovery: boolean | null;
|
||||
mediaObjectPreserved: boolean;
|
||||
nativeSourceChanged: boolean;
|
||||
nativeQualityEvidence: boolean;
|
||||
@@ -87,6 +97,9 @@ interface GateResult {
|
||||
replacementViewerFrames: number | null;
|
||||
clientCrashEndedShare: boolean | null;
|
||||
mode: GateMode;
|
||||
viewerLocation: "local-browser" | "public-url-browser" | "remote-peer";
|
||||
publicViewerPage: boolean;
|
||||
publicViewerSignal: boolean;
|
||||
cleanup: Awaited<ReturnType<typeof cleanupRun>>;
|
||||
error: string | null;
|
||||
stage?: string;
|
||||
@@ -563,7 +576,9 @@ async function main(): Promise<void> {
|
||||
if (requestedCodec !== "auto" && requestedCodec !== "h264" && requestedCodec !== "vp8") {
|
||||
throw new Error("SCREENER_CLIENT_NATIVE_HOST_CODEC must be auto, h264, or vp8");
|
||||
}
|
||||
const remote = mode === "local" ? null : remoteOptions();
|
||||
const remote = mode === "cross-nat" || mode === "one-link" &&
|
||||
Boolean(process.env.SCREENER_REMOTE_HOST?.trim() || process.env.SCREENER_REMOTE_SSH_KEY?.trim())
|
||||
? remoteOptions() : null;
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
if (!chromePath) throw new Error("CHROME_PATH is required");
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
@@ -574,6 +589,7 @@ async function main(): Promise<void> {
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
const sourceProfile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
await mkdir(BUILD_ROOT, { recursive: true });
|
||||
const diagnosticDirectory = await mkdtemp(join(BUILD_ROOT, "native-host-gate-"));
|
||||
const sourcePort = await reservePort();
|
||||
const appPort = await reservePort();
|
||||
const debugPort = await reservePort();
|
||||
@@ -590,7 +606,6 @@ async function main(): Promise<void> {
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let clientPort = 0;
|
||||
let clientDiagnostics = "";
|
||||
let remoteTunnel: ChildProcess | null = null;
|
||||
let stage = "setup";
|
||||
const result: GateResult = {
|
||||
@@ -605,6 +620,7 @@ async function main(): Promise<void> {
|
||||
livePresetChanges: 0,
|
||||
liveQualityChanged: false,
|
||||
pausedQualityChanged: false,
|
||||
backgroundProfileRecovery: mode === "local" && sourceKind === "window" ? false : null,
|
||||
mediaObjectPreserved: false,
|
||||
nativeSourceChanged: false,
|
||||
nativeQualityEvidence: false,
|
||||
@@ -624,6 +640,9 @@ async function main(): Promise<void> {
|
||||
replacementViewerFrames: mode === "local" ? 0 : null,
|
||||
clientCrashEndedShare: crashGate ? false : null,
|
||||
mode,
|
||||
viewerLocation: remote ? "remote-peer" : mode === "one-link" ? "public-url-browser" : "local-browser",
|
||||
publicViewerPage: false,
|
||||
publicViewerSignal: false,
|
||||
cleanup: {
|
||||
browserExited: false,
|
||||
nativeExited: false,
|
||||
@@ -641,15 +660,17 @@ async function main(): Promise<void> {
|
||||
stage = "source-server";
|
||||
source = await sourceServer(sourcePort);
|
||||
stage = "capture-build";
|
||||
process.stderr.write(`${JSON.stringify({ stage, status: "started", at: new Date().toISOString() })}\n`);
|
||||
run(powershell(), [
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||
join(ROOT, "native", "capture", "windows", "build.ps1"),
|
||||
"-OutputDirectory", captureBuild,
|
||||
]);
|
||||
process.stderr.write(`${JSON.stringify({ stage, status: "finished", at: new Date().toISOString() })}\n`);
|
||||
stage = "client-build";
|
||||
run(go, [
|
||||
"build", "-trimpath", "-o", clientBinary, "./cmd/screener-client",
|
||||
], ROOT);
|
||||
"build", "-p", "1", "-trimpath", "-o", clientBinary, "./cmd/screener-client",
|
||||
], ROOT, { ...process.env, GOMAXPROCS: "2" });
|
||||
if (remote) {
|
||||
stage = "remote-peer-build";
|
||||
run(
|
||||
@@ -676,12 +697,13 @@ async function main(): Promise<void> {
|
||||
"--capture-process", captureBinary,
|
||||
"--config", clientConfig,
|
||||
"--port", String(appPort),
|
||||
"--debug", "--log-dir", diagnosticDirectory,
|
||||
], {
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
SCREENER_DEBUG: "route",
|
||||
SCREENER_DEBUG: "",
|
||||
SCREENER_CLIENT_GATE_NO_BROWSER: "true",
|
||||
// STUN_URLS reaches only the Client's own Pion edge: the in-process
|
||||
// room server never reads it, so the cross-NAT arm stays isolated.
|
||||
@@ -690,9 +712,7 @@ async function main(): Promise<void> {
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
client.stderr.on("data", (chunk: Buffer) => {
|
||||
clientDiagnostics = (clientDiagnostics + chunk.toString()).slice(-262_144);
|
||||
});
|
||||
client.stderr.resume();
|
||||
stage = "client-ready";
|
||||
const clientInfo = await readClientEndpoint(client, mode === "one-link");
|
||||
clientPort = clientInfo.endpoint.port;
|
||||
@@ -878,7 +898,17 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} else {
|
||||
const viewerURL = new URL(hostState.invite);
|
||||
viewerURL.hostname = "localhost";
|
||||
if (mode === "local") viewerURL.hostname = "localhost";
|
||||
else {
|
||||
if (viewerURL.protocol !== "https:" || viewerURL.origin !== clientInfo.publicOrigin) {
|
||||
throw new Error("One-link invitation does not use the actual public origin");
|
||||
}
|
||||
stage = "public-page-ready";
|
||||
await waitForValue(async (deadline) => {
|
||||
const response = await fetch(viewerURL, { signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
|
||||
return response.ok;
|
||||
}, Boolean, 20_000);
|
||||
}
|
||||
stage = "viewer-page";
|
||||
const viewer = await createPage(cdp, viewerURL.toString(), CODEC_PROBE, true);
|
||||
stage = "viewer-media";
|
||||
@@ -906,6 +936,14 @@ async function main(): Promise<void> {
|
||||
result.viewerWidth = viewerState.width;
|
||||
result.viewerHeight = viewerState.height;
|
||||
await assertVideoCodec(cdp, viewer, actualCodec);
|
||||
if (mode === "one-link") {
|
||||
result.codecPreserved = true;
|
||||
result.publicViewerPage = await evaluate<boolean>(cdp, viewer,
|
||||
`location.origin === ${JSON.stringify(clientInfo.publicOrigin)} && location.pathname === ${JSON.stringify(viewerURL.pathname)}`,
|
||||
Date.now() + 5_000);
|
||||
result.publicViewerSignal = await evaluate<boolean>(cdp, viewer,
|
||||
"window.__screenerGatePublicSignal()", Date.now() + 5_000);
|
||||
} else {
|
||||
await evaluate<boolean>(
|
||||
cdp,
|
||||
viewer,
|
||||
@@ -1127,6 +1165,66 @@ async function main(): Promise<void> {
|
||||
result.viewerWidth = resumed.width;
|
||||
result.viewerHeight = resumed.height;
|
||||
await assertVideoCodec(cdp, viewer, actualCodec);
|
||||
if (sourceKind === "window") {
|
||||
stage = "native-background-profile";
|
||||
const { targetInfos } = await sourceCdp!.call<{
|
||||
targetInfos: Array<{ targetId: string; type: string; url: string }>;
|
||||
}>("Target.getTargets", {}, undefined, Date.now() + 5_000);
|
||||
const target = targetInfos.find((value) =>
|
||||
value.type === "page" && value.url === `http://127.0.0.1:${sourcePort}/`,
|
||||
);
|
||||
if (!target) throw new Error("Capture source page is missing");
|
||||
const { windowId } = await sourceCdp!.call<{ windowId: number }>(
|
||||
"Browser.getWindowForTarget", { targetId: target.targetId },
|
||||
undefined, Date.now() + 5_000,
|
||||
);
|
||||
await sourceCdp!.call("Browser.setWindowBounds", {
|
||||
windowId, bounds: { windowState: "minimized" },
|
||||
}, undefined, Date.now() + 5_000);
|
||||
try {
|
||||
await evaluate<void>(cdp, host,
|
||||
`new Promise((resolve) => {
|
||||
document.querySelector('button.lr-chip[aria-label="720p"]')?.click();
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
})`,
|
||||
Date.now() + 5_000,
|
||||
);
|
||||
await waitForValue((deadline) => evaluate<boolean>(cdp!, host,
|
||||
`Boolean(document.querySelector('#host-advanced-door .lr-door-body[aria-busy="true"]'))`,
|
||||
deadline,
|
||||
), Boolean, 5_000);
|
||||
} finally {
|
||||
await sourceCdp!.call("Browser.setWindowBounds", {
|
||||
windowId, bounds: { windowState: "normal" },
|
||||
}, undefined, Date.now() + 5_000);
|
||||
}
|
||||
stage = "native-background-profile-recovery";
|
||||
// A quiet source retains its pending replacement until restoration.
|
||||
// Prove its actual delivery before applying the following profile.
|
||||
for (const [label, width, height] of [["720p", 1280, 720], ["480p", 854, 480]] as const) {
|
||||
const before = await evaluate<number>(cdp, viewer,
|
||||
`document.querySelector('video')?.getVideoPlaybackQuality().totalVideoFrames ?? 0`,
|
||||
Date.now() + 5_000,
|
||||
);
|
||||
await evaluate<void>(cdp, host,
|
||||
`document.querySelector('button.lr-chip[aria-label="${label}"]')?.click()`,
|
||||
Date.now() + 5_000,
|
||||
);
|
||||
await waitForValue((deadline) => evaluate<boolean>(cdp!, viewer,
|
||||
`(() => {
|
||||
const video = document.querySelector('video');
|
||||
return Boolean(video && video.srcObject === window.__screenerGateMedia &&
|
||||
video.videoWidth === ${width} && video.videoHeight === ${height} &&
|
||||
video.getVideoPlaybackQuality().totalVideoFrames >= ${before + 10});
|
||||
})()`, deadline,
|
||||
), Boolean, 20_000);
|
||||
await waitForValue((deadline) => evaluate<boolean>(cdp!, host,
|
||||
`Boolean(document.querySelector('#host-advanced-door .lr-door-body[aria-busy="false"]'))`,
|
||||
deadline,
|
||||
), Boolean, 5_000);
|
||||
}
|
||||
result.backgroundProfileRecovery = true;
|
||||
}
|
||||
stage = "native-source-picker";
|
||||
await waitForValue(
|
||||
(deadline) => evaluate<boolean>(
|
||||
@@ -1191,8 +1289,22 @@ async function main(): Promise<void> {
|
||||
await assertVideoCodec(cdp, viewer, actualCodec);
|
||||
result.codecPreserved = true;
|
||||
result.nativeQualityEvidence = await waitForValue(
|
||||
async () => /"event":"sender-quality-evidence"[^\n]*"state":"(healthy|degraded)"/
|
||||
.test(clientDiagnostics),
|
||||
async () => {
|
||||
const logPath = join(diagnosticDirectory, "client.log");
|
||||
if ((await stat(logPath)).size > 8 * 1024 * 1024) {
|
||||
throw new Error("Native Host gate diagnostic log exceeded its bound");
|
||||
}
|
||||
const diagnostics = await readFile(logPath, "utf8");
|
||||
return diagnostics.split(/\r?\n/).some((line) => {
|
||||
try {
|
||||
const record = JSON.parse(line);
|
||||
return record.msg === "screener-route" && record.event === "sender-quality-evidence" &&
|
||||
(record.state === "healthy" || record.state === "degraded");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
Boolean,
|
||||
12_000,
|
||||
);
|
||||
@@ -1300,6 +1412,7 @@ async function main(): Promise<void> {
|
||||
result.replacementViewerConnected = null;
|
||||
result.replacementViewerFrames = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Client stderr can contain implementation diagnostics or URLs; keep gate
|
||||
@@ -1343,12 +1456,16 @@ async function main(): Promise<void> {
|
||||
result.passed = result.error === null && result.actualCodec !== null && result.preSharePreferenceEnabled &&
|
||||
result.hostNativeActive &&
|
||||
result.hostInvite &&
|
||||
(mode !== "local"
|
||||
(remote
|
||||
? result.remoteViewerConnected && result.remoteViewerPackets >= 30 &&
|
||||
result.remoteNatPath && result.remotePeerExited === true &&
|
||||
(mode !== "cross-nat" || result.reverseSignalTunnelClosed === true)
|
||||
: result.viewerConnected && result.viewerFrames >= 30 &&
|
||||
: mode === "one-link"
|
||||
? result.publicViewerPage && result.publicViewerSignal && result.viewerConnected &&
|
||||
result.viewerFrames >= 30 && result.viewerWidth === 1920 && result.viewerHeight === 1080 && result.codecPreserved
|
||||
: result.viewerConnected && result.viewerFrames >= 30 &&
|
||||
result.qualityControlsEnabled && result.liveQualityChanged &&
|
||||
result.backgroundProfileRecovery !== false &&
|
||||
result.livePresetChanges === 2 &&
|
||||
result.codecPreserved &&
|
||||
result.pausedQualityChanged && result.mediaObjectPreserved &&
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import {
|
||||
spawn,
|
||||
spawnSync,
|
||||
type ChildProcessWithoutNullStreams,
|
||||
} from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createSocket } from "node:dgram";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { AccessToken } from "livekit-server-sdk";
|
||||
import { createServer as createViteServer, type ViteDevServer } from "vite";
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
cleanupRun,
|
||||
createPage,
|
||||
evaluate,
|
||||
launchChrome,
|
||||
reservePort,
|
||||
type PageHandle,
|
||||
waitForVersion,
|
||||
} from "./browser-gate-harness";
|
||||
import {
|
||||
closeSourceBrowser,
|
||||
powershell,
|
||||
SOURCE_TITLE,
|
||||
sourceServer,
|
||||
startSourceBrowser,
|
||||
waitForCaptureWindow,
|
||||
} from "./client-native-host-gate";
|
||||
import {
|
||||
readClientEndpoint,
|
||||
} from "./client-gate-endpoint";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "..");
|
||||
const BUILD_ROOT = join(ROOT, "build", "client-check");
|
||||
|
||||
function run(command: string, args: string[], cwd = ROOT): void {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout).trim() || `${command} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reserveUDPPort(): Promise<number> {
|
||||
const socket = createSocket("udp4");
|
||||
return await new Promise<number>((resolvePort, rejectPort) => {
|
||||
socket.once("error", rejectPort);
|
||||
socket.bind(0, "127.0.0.1", () => {
|
||||
const address = socket.address();
|
||||
socket.close(() => resolvePort(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHTTP(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if ((await fetch(url)).ok) return;
|
||||
} catch {
|
||||
// The isolated service is still starting.
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
||||
}
|
||||
throw new Error(`service did not become ready: ${url}`);
|
||||
}
|
||||
|
||||
async function issueToken(
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
room: string,
|
||||
identity: string,
|
||||
publish: boolean,
|
||||
): Promise<string> {
|
||||
const token = new AccessToken(apiKey, apiSecret, { identity, ttl: 300 });
|
||||
token.addGrant({
|
||||
roomJoin: true,
|
||||
room,
|
||||
canPublish: publish,
|
||||
canSubscribe: !publish,
|
||||
canPublishData: false,
|
||||
});
|
||||
return await token.toJwt();
|
||||
}
|
||||
|
||||
async function stopProcess(process: ChildProcessWithoutNullStreams | null): Promise<boolean> {
|
||||
if (!process || process.exitCode !== null || process.signalCode !== null) return true;
|
||||
process.kill();
|
||||
return await new Promise<boolean>((resolveExit) => {
|
||||
const timer = setTimeout(() => resolveExit(false), 5_000);
|
||||
process.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolveExit(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error("The native SFU gate currently requires Windows");
|
||||
}
|
||||
if (process.env.SCREENER_CLIENT_NATIVE_SFU_GATE !== "true") {
|
||||
throw new Error("SCREENER_CLIENT_NATIVE_SFU_GATE=true is required");
|
||||
}
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
const livekitPath = process.env.SCREENER_LIVEKIT_SERVER?.trim();
|
||||
if (!chromePath || !livekitPath) {
|
||||
throw new Error("CHROME_PATH and SCREENER_LIVEKIT_SERVER are required");
|
||||
}
|
||||
const go = process.env.SCREENER_GO?.trim() || "go";
|
||||
const sourcePort = await reservePort();
|
||||
const sourceDebugPort = await reservePort();
|
||||
const vitePort = await reservePort();
|
||||
const debugPort = await reservePort();
|
||||
const livekitPort = await reservePort();
|
||||
const livekitTCPPort = await reservePort();
|
||||
const livekitUDPPort = await reserveUDPPort();
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
const sourceProfile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
const configPath = join(profile, "client.json");
|
||||
const captureBinary = join(BUILD_ROOT, "screener-client-capture.exe");
|
||||
const clientBinary = join(BUILD_ROOT, "screener-client.exe");
|
||||
const origin = `http://127.0.0.1:${vitePort}`;
|
||||
const livekitURL = `ws://127.0.0.1:${livekitPort}`;
|
||||
const apiKey = `gate${randomBytes(8).toString("hex")}`;
|
||||
const apiSecret = randomBytes(32).toString("base64url");
|
||||
const room = `native-sfu-${randomBytes(8).toString("hex")}`;
|
||||
const result = {
|
||||
passed: false,
|
||||
hostPublished: false,
|
||||
viewerFrames: 0,
|
||||
viewerWidth: 0,
|
||||
viewerHeight: 0,
|
||||
profileUpdated: false,
|
||||
cleanup: false,
|
||||
error: null as string | null,
|
||||
stage: "setup",
|
||||
};
|
||||
let vite: ViteDevServer | null = null;
|
||||
let livekit: ChildProcessWithoutNullStreams | null = null;
|
||||
let client: ChildProcessWithoutNullStreams | null = null;
|
||||
let source: { close(): Promise<void> } | null = null;
|
||||
let sourceChrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let sourceCdp: CdpConnection | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let hostPage: PageHandle | null = null;
|
||||
let viewerPage: PageHandle | null = null;
|
||||
let clientPort = 0;
|
||||
try {
|
||||
result.stage = "build";
|
||||
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
||||
run(powershell(), [
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
|
||||
join(ROOT, "native", "capture", "windows", "build.ps1"),
|
||||
"-OutputDirectory", BUILD_ROOT,
|
||||
]);
|
||||
run(go, ["build", "-trimpath", "-o", clientBinary, "./cmd/screener-client"],
|
||||
ROOT);
|
||||
|
||||
result.stage = "livekit";
|
||||
livekit = spawn(livekitPath, [
|
||||
"--bind", "127.0.0.1",
|
||||
"--node-ip", "127.0.0.1",
|
||||
"--port", String(livekitPort),
|
||||
"--udp-port", String(livekitUDPPort),
|
||||
"--rtc.tcp_port", String(livekitTCPPort),
|
||||
"--rtc.enable_loopback_candidate",
|
||||
], {
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
env: { ...process.env, LIVEKIT_KEYS: `${apiKey}: ${apiSecret}` },
|
||||
});
|
||||
livekit.stdout.resume();
|
||||
livekit.stderr.resume();
|
||||
await waitForHTTP(`http://127.0.0.1:${livekitPort}`, 10_000);
|
||||
|
||||
result.stage = "vite";
|
||||
vite = await createViteServer({
|
||||
root: ROOT,
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { host: "127.0.0.1", port: vitePort, strictPort: true },
|
||||
plugins: [{
|
||||
name: "native-sfu-gate-page",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
if (request.url?.split("?", 1)[0] !== "/native-sfu-gate") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.end("<!doctype html><html><body></body></html>");
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await vite.listen();
|
||||
|
||||
result.stage = "source";
|
||||
source = await sourceServer(sourcePort);
|
||||
({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser(
|
||||
chromePath,
|
||||
sourceProfile,
|
||||
sourceDebugPort,
|
||||
sourcePort,
|
||||
));
|
||||
await waitForCaptureWindow(captureBinary);
|
||||
|
||||
result.stage = "client";
|
||||
client = spawn(clientBinary, [
|
||||
"--site", origin,
|
||||
"--capture-process", captureBinary,
|
||||
"--config", configPath,
|
||||
], {
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
env: { ...process.env, SCREENER_CLIENT_GATE_NO_BROWSER: "true" },
|
||||
});
|
||||
client.stderr.resume();
|
||||
clientPort = (await readClientEndpoint(client, {
|
||||
ignoreNonEndpointLines: true,
|
||||
})).port;
|
||||
|
||||
result.stage = "browser";
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--disable-logging",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=WebRtcHideLocalIpsWithMdns",
|
||||
]);
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
|
||||
hostPage = await createPage(cdp, `${origin}/native-sfu-gate`, undefined, true);
|
||||
viewerPage = await createPage(cdp, `${origin}/native-sfu-gate`, undefined, true);
|
||||
const hostToken = await issueToken(apiKey, apiSecret, room, "host", true);
|
||||
const viewerToken = await issueToken(apiKey, apiSecret, room, "viewer", false);
|
||||
|
||||
result.stage = "publish";
|
||||
const hostResult = await evaluate<{ audio?: boolean; error?: string }>(
|
||||
cdp,
|
||||
hostPage,
|
||||
`(async () => { try {
|
||||
const gate = await import('/scripts/client-native-sfu-page.ts');
|
||||
return await gate.startNativeSfuHost(${JSON.stringify({
|
||||
url: livekitURL,
|
||||
token: hostToken,
|
||||
sourceTitle: SOURCE_TITLE,
|
||||
})});
|
||||
} catch (error) {
|
||||
return { error: String(error?.stack || error?.message || error) };
|
||||
} })()`,
|
||||
Date.now() + 30_000,
|
||||
);
|
||||
if (hostResult.error) throw new Error(hostResult.error);
|
||||
result.hostPublished = true;
|
||||
|
||||
result.stage = "subscribe";
|
||||
const viewer = await evaluate<{
|
||||
frames?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
error?: string;
|
||||
}>(
|
||||
cdp,
|
||||
viewerPage,
|
||||
`(async () => { try {
|
||||
const gate = await import('/scripts/client-native-sfu-page.ts');
|
||||
return await gate.startNativeSfuViewer(${JSON.stringify({
|
||||
url: livekitURL,
|
||||
token: viewerToken,
|
||||
})});
|
||||
} catch (error) {
|
||||
return { error: String(error?.stack || error?.message || error) };
|
||||
} })()`,
|
||||
Date.now() + 30_000,
|
||||
);
|
||||
if (viewer.error) throw new Error(viewer.error);
|
||||
result.viewerFrames = viewer.frames ?? 0;
|
||||
result.viewerWidth = viewer.width ?? 0;
|
||||
result.viewerHeight = viewer.height ?? 0;
|
||||
result.stage = "profile-update";
|
||||
const updated = await evaluate<boolean>(
|
||||
cdp,
|
||||
hostPage,
|
||||
`(async () => {
|
||||
const gate = await import('/scripts/client-native-sfu-page.ts');
|
||||
return await gate.updateNativeSfuHost({
|
||||
resolution: '480p',
|
||||
maxFramerate: 15,
|
||||
maxBitrate: 2000000,
|
||||
degradationPreference: 'maintain-resolution',
|
||||
screenAudioQuality: 'saver',
|
||||
});
|
||||
})()`,
|
||||
Date.now() + 20_000,
|
||||
);
|
||||
const updatedViewer = await evaluate<{
|
||||
frames: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>(
|
||||
cdp,
|
||||
viewerPage,
|
||||
`import('/scripts/client-native-sfu-page.ts').then((gate) =>
|
||||
gate.waitForNativeSfuViewer(854, 480))`,
|
||||
Date.now() + 25_000,
|
||||
);
|
||||
result.profileUpdated = updated && updatedViewer.frames >= 15;
|
||||
result.viewerFrames = updatedViewer.frames;
|
||||
result.viewerWidth = updatedViewer.width;
|
||||
result.viewerHeight = updatedViewer.height;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (cdp && viewerPage) {
|
||||
await evaluate(
|
||||
cdp,
|
||||
viewerPage,
|
||||
"import('/scripts/client-native-sfu-page.ts').then((gate) => gate.stopNativeSfuGate())",
|
||||
Date.now() + 5_000,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (cdp && hostPage) {
|
||||
await evaluate(
|
||||
cdp,
|
||||
hostPage,
|
||||
"import('/scripts/client-native-sfu-page.ts').then((gate) => gate.stopNativeSfuGate())",
|
||||
Date.now() + 5_000,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await vite?.close().catch(() => undefined);
|
||||
if (sourceChrome && sourceCdp) {
|
||||
await closeSourceBrowser(sourceChrome, sourceCdp).catch(() => false);
|
||||
sourceChrome = null;
|
||||
sourceCdp = null;
|
||||
}
|
||||
await source?.close().catch(() => undefined);
|
||||
const livekitStopped = await stopProcess(livekit);
|
||||
const cleanup = await cleanupRun({
|
||||
cdp,
|
||||
native: client,
|
||||
chrome,
|
||||
server: null,
|
||||
profile,
|
||||
ports: [
|
||||
vitePort,
|
||||
debugPort,
|
||||
livekitPort,
|
||||
livekitTCPPort,
|
||||
...(clientPort ? [clientPort] : []),
|
||||
],
|
||||
});
|
||||
const sourceCleanup = await cleanupRun({
|
||||
cdp: null,
|
||||
native: sourceChrome,
|
||||
chrome: null,
|
||||
server: null,
|
||||
profile: sourceProfile,
|
||||
ports: [sourcePort, sourceDebugPort],
|
||||
});
|
||||
result.cleanup = livekitStopped && cleanup.browserExited &&
|
||||
cleanup.nativeExited && cleanup.portsClosed && cleanup.profileRemoved &&
|
||||
sourceCleanup.profileRemoved && sourceCleanup.portsClosed;
|
||||
}
|
||||
result.passed = result.error === null && result.hostPublished &&
|
||||
result.profileUpdated && result.viewerFrames >= 15 &&
|
||||
result.viewerWidth === 854 && result.viewerHeight === 480 && result.cleanup;
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,186 +0,0 @@
|
||||
import {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
type QualitySettings,
|
||||
} from "../src/shared/protocol";
|
||||
import { NativeClient } from "../src/client/native/client";
|
||||
import { NativeMediaBridge } from "../src/client/native/media-bridge";
|
||||
import { SfuPublisher } from "../src/client/sfu/publisher";
|
||||
|
||||
let hostResources: {
|
||||
client: NativeClient;
|
||||
bridge: NativeMediaBridge;
|
||||
publisher: SfuPublisher;
|
||||
shareId: string;
|
||||
} | null = null;
|
||||
let viewerRoom: import("livekit-client").Room | null = null;
|
||||
let viewerVideo: HTMLVideoElement | null = null;
|
||||
|
||||
export async function startNativeSfuHost(input: {
|
||||
url: string;
|
||||
token: string;
|
||||
sourceTitle: string;
|
||||
}): Promise<{ audio: boolean }> {
|
||||
if (hostResources) throw new Error("native SFU Host is already active");
|
||||
const client = await NativeClient.connect();
|
||||
if (!client) throw new Error("native Client is unavailable");
|
||||
const shareId = "native_sfu_share_123456";
|
||||
let bridge: NativeMediaBridge | null = null;
|
||||
let publisher: SfuPublisher | null = null;
|
||||
try {
|
||||
const adapters = await client.captureOptions();
|
||||
const adapter = adapters.find((candidate) => candidate.hardwareH264.length > 0);
|
||||
const encoder = adapter?.hardwareH264[0];
|
||||
const sources = await client.sources();
|
||||
const target = sources.find((candidate) =>
|
||||
candidate.kind === "window" &&
|
||||
candidate.title.includes(input.sourceTitle)
|
||||
);
|
||||
if (!adapter || !encoder || !target) {
|
||||
throw new Error("native capture path is unavailable");
|
||||
}
|
||||
const started = await client.startShare({
|
||||
codec: "h264",
|
||||
shareId,
|
||||
source: target,
|
||||
audio: true,
|
||||
adapterIndex: adapter.index,
|
||||
encoderIndex: encoder.index,
|
||||
edgeCapacity: 3,
|
||||
profile: DEFAULT_QUALITY_SETTINGS,
|
||||
});
|
||||
bridge = new NativeMediaBridge(
|
||||
shareId,
|
||||
client,
|
||||
() => undefined,
|
||||
started.audio,
|
||||
);
|
||||
const stream = await bridge.start();
|
||||
publisher = new SfuPublisher();
|
||||
if (!(await publisher.connect({ url: input.url, token: input.token }))) {
|
||||
throw new Error("native SFU publisher did not connect");
|
||||
}
|
||||
if (!(await publisher.activate(stream, DEFAULT_QUALITY_SETTINGS, "h264"))) {
|
||||
throw new Error("native SFU publisher did not activate");
|
||||
}
|
||||
hostResources = { client, bridge, publisher, shareId };
|
||||
return { audio: stream.getAudioTracks().length > 0 };
|
||||
} catch (error) {
|
||||
await publisher?.disconnect().catch(() => undefined);
|
||||
bridge?.dispose();
|
||||
await client.stopShare(shareId).catch(() => undefined);
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startNativeSfuViewer(input: {
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<{ frames: number; width: number; height: number }> {
|
||||
if (viewerRoom) throw new Error("native SFU Viewer is already active");
|
||||
const sdk = await import("livekit-client");
|
||||
const room = new sdk.Room({
|
||||
adaptiveStream: false,
|
||||
disconnectOnPageLeave: false,
|
||||
});
|
||||
viewerRoom = room;
|
||||
const result = new Promise<{ frames: number; width: number; height: number }>(
|
||||
(resolve, reject) => {
|
||||
const timer = window.setTimeout(
|
||||
() => reject(new Error("native SFU Viewer timed out")),
|
||||
20_000,
|
||||
);
|
||||
room.on(sdk.RoomEvent.TrackSubscribed, (track, publication) => {
|
||||
if (track.kind !== sdk.Track.Kind.Video) return;
|
||||
publication.setVideoQuality(sdk.VideoQuality.HIGH);
|
||||
const video = track.attach() as HTMLVideoElement;
|
||||
viewerVideo = video;
|
||||
video.muted = true;
|
||||
video.autoplay = true;
|
||||
document.body.append(video);
|
||||
void video.play().catch(() => undefined);
|
||||
let frames = 0;
|
||||
const next = () => {
|
||||
video.requestVideoFrameCallback(() => {
|
||||
if (video.videoWidth === 1920 && video.videoHeight === 1080) {
|
||||
frames += 1;
|
||||
} else {
|
||||
frames = 0;
|
||||
}
|
||||
if (frames < 30) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timer);
|
||||
resolve({
|
||||
frames,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
});
|
||||
});
|
||||
};
|
||||
next();
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
await room.connect(input.url, input.token);
|
||||
return await result;
|
||||
} catch (error) {
|
||||
await room.disconnect(false).catch(() => undefined);
|
||||
viewerRoom = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNativeSfuHost(
|
||||
profile: QualitySettings,
|
||||
): Promise<boolean> {
|
||||
const host = hostResources;
|
||||
if (!host) throw new Error("native SFU Host is unavailable");
|
||||
await host.client.updateShare(host.shareId, profile);
|
||||
return await host.publisher.updateProfile(profile);
|
||||
}
|
||||
|
||||
export async function waitForNativeSfuViewer(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<{ frames: number; width: number; height: number }> {
|
||||
const video = viewerVideo;
|
||||
if (!video) throw new Error("native SFU Viewer is unavailable");
|
||||
return await new Promise((resolve, reject) => {
|
||||
let frames = 0;
|
||||
const timer = window.setTimeout(
|
||||
() => reject(new Error("native SFU Viewer profile timed out")),
|
||||
20_000,
|
||||
);
|
||||
const next = () => {
|
||||
video.requestVideoFrameCallback(() => {
|
||||
frames = video.videoWidth === width && video.videoHeight === height
|
||||
? frames + 1
|
||||
: 0;
|
||||
if (frames < 15) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timer);
|
||||
resolve({ frames, width: video.videoWidth, height: video.videoHeight });
|
||||
});
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopNativeSfuGate(): Promise<void> {
|
||||
const viewer = viewerRoom;
|
||||
viewerRoom = null;
|
||||
viewerVideo = null;
|
||||
if (viewer) await viewer.disconnect(false).catch(() => undefined);
|
||||
const host = hostResources;
|
||||
hostResources = null;
|
||||
if (!host) return;
|
||||
await host.publisher.disconnect().catch(() => undefined);
|
||||
host.bridge.dispose();
|
||||
await host.client.stopShare(host.shareId).catch(() => undefined);
|
||||
host.client.close();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
nodeArch: "x64",
|
||||
goos: "windows",
|
||||
goarch: "amd64",
|
||||
cgo: false,
|
||||
clientName: "screener-client.exe",
|
||||
tunnelName: "cloudflared.exe",
|
||||
captureName: "screener-client-capture.exe",
|
||||
@@ -20,6 +21,7 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
nodeArch: "x64",
|
||||
goos: "linux",
|
||||
goarch: "amd64",
|
||||
cgo: false,
|
||||
clientName: "screener-client",
|
||||
tunnelName: "cloudflared",
|
||||
captureName: "screener-client-capture",
|
||||
@@ -33,6 +35,7 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
nodeArch: "arm64",
|
||||
goos: "darwin",
|
||||
goarch: "arm64",
|
||||
cgo: true,
|
||||
clientName: "screener-client",
|
||||
tunnelName: "cloudflared",
|
||||
captureName: "screener-client-capture",
|
||||
@@ -45,3 +48,12 @@ export const CLIENT_PACKAGE_TARGETS = [
|
||||
export function clientPackageTarget(id) {
|
||||
return CLIENT_PACKAGE_TARGETS.find((target) => target.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function clientGoEnvironment(target) {
|
||||
return {
|
||||
...process.env,
|
||||
GOOS: target.goos,
|
||||
GOARCH: target.goarch,
|
||||
CGO_ENABLED: target.cgo ? "1" : "0",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { CLIENT_PACKAGE_TARGETS, clientGoEnvironment } from "./client-package-targets.mjs";
|
||||
|
||||
test("build and license discovery use the same explicit target cgo policy", () => {
|
||||
const previous = process.env.CGO_ENABLED;
|
||||
try {
|
||||
process.env.CGO_ENABLED = "unexpected-inherited-value";
|
||||
const settings = CLIENT_PACKAGE_TARGETS.map((target) => {
|
||||
const env = clientGoEnvironment(target);
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
||||
if (pathKey) assert.equal(env[pathKey], process.env[pathKey]);
|
||||
assert.equal(process.env.CGO_ENABLED, "unexpected-inherited-value");
|
||||
return [target.id, env.GOOS, env.GOARCH, env.CGO_ENABLED];
|
||||
});
|
||||
assert.deepEqual(settings, [
|
||||
["windows-amd64", "windows", "amd64", "0"],
|
||||
["linux-amd64", "linux", "amd64", "0"],
|
||||
["darwin-arm64", "darwin", "arm64", "1"],
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CGO_ENABLED;
|
||||
else process.env.CGO_ENABLED = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createSocket } from "node:dgram";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer as createViteServer, type ViteDevServer } from "vite";
|
||||
import type { CreateRoomResponse } from "../src/shared/protocol";
|
||||
import {
|
||||
CdpConnection, cleanupRun, createPage, evaluate, fetchJsonBefore, launchChrome,
|
||||
reservePort, waitForSample, waitForVersion, withDeadline, type PageHandle,
|
||||
} from "./browser-gate-harness";
|
||||
import type { snapshot } from "./embedded-sfu-page";
|
||||
import { readClientEndpoint } from "./client-gate-endpoint";
|
||||
import { SOURCE_TITLE, sourceServer, startSourceBrowser, waitForCaptureWindow } from "./client-native-host-gate";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "..");
|
||||
const BUILD_ROOT = join(ROOT, "build", "embedded-media");
|
||||
type Snapshot = ReturnType<typeof snapshot>;
|
||||
|
||||
const AUDIO_PROBE = `(() => {
|
||||
const peers = [], BrowserPeer = RTCPeerConnection;
|
||||
window.RTCPeerConnection = class extends BrowserPeer {
|
||||
constructor(...args) { super(...args); peers.push(this); }
|
||||
};
|
||||
window.__screenerGateAudioEnergy = async () => {
|
||||
const trackId = document.querySelector('video')?.srcObject?.getAudioTracks()[0]?.id;
|
||||
const diagnostic = window.__screenerGateAudioStats = { hasAudioTrack: !!trackId, peerCount: peers.length, inbound: [] };
|
||||
if (!trackId) return 0;
|
||||
for (const peer of peers) {
|
||||
if (peer.connectionState !== 'connected') continue;
|
||||
const report = await peer.getStats();
|
||||
for (const row of report.values()) {
|
||||
if (row.type === 'inbound-rtp' && row.kind === 'audio') diagnostic.inbound.push({
|
||||
trackMatches: row.trackIdentifier === trackId, energy: row.totalAudioEnergy ?? null,
|
||||
sampleDuration: row.totalSamplesDuration ?? null, receivedSamples: row.totalSamplesReceived ?? null,
|
||||
});
|
||||
if (row.type === 'inbound-rtp' && row.kind === 'audio' && row.trackIdentifier === trackId &&
|
||||
Number.isFinite(row.totalAudioEnergy) && row.totalAudioEnergy > 0) return row.totalAudioEnergy;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
})()`;
|
||||
|
||||
async function udpPort(requested = 0): Promise<number> {
|
||||
const socket = createSocket("udp4");
|
||||
return await new Promise((resolvePort, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(requested, "127.0.0.1", () => {
|
||||
const port = socket.address().port;
|
||||
socket.close(() => resolvePort(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.env.SCREENER_EMBEDDED_SFU_GATE !== "true") {
|
||||
throw new Error("SCREENER_EMBEDDED_SFU_GATE=true is required; this is an explicit local Browser media gate");
|
||||
}
|
||||
const chromePath = process.env.CHROME_PATH?.trim();
|
||||
if (!chromePath) throw new Error("CHROME_PATH is required");
|
||||
const nativeArm = process.env.SCREENER_EMBEDDED_SFU_NATIVE === "true";
|
||||
const twoRooms = process.env.SCREENER_EMBEDDED_SFU_TWO_ROOMS === "true";
|
||||
if (twoRooms && !nativeArm) throw new Error("Two-room arm requires Native publication");
|
||||
const codec = process.env.SCREENER_EMBEDDED_SFU_CODEC?.trim() || "vp8";
|
||||
if (codec !== "vp8" && codec !== "h264") throw new Error("SFU gate codec must be vp8 or h264");
|
||||
const captureBinary = process.env.SCREENER_CAPTURE_PROCESS?.trim();
|
||||
if (nativeArm && (process.platform !== "win32" || !captureBinary)) {
|
||||
throw new Error("Native arm requires Windows and SCREENER_CAPTURE_PROCESS");
|
||||
}
|
||||
await mkdir(BUILD_ROOT, { recursive: true });
|
||||
const binary = join(BUILD_ROOT, `screener-server${process.platform === "win32" ? ".exe" : ""}`);
|
||||
const clientBinary = join(BUILD_ROOT, "screener-client.exe");
|
||||
for (const command of nativeArm ? ["screener-server", "screener-client"] : ["screener-server"]) {
|
||||
const build = spawnSync(process.env.SCREENER_GO?.trim() || "go", [
|
||||
"build", "-p", "1", "-trimpath", "-o", command === "screener-server" ? binary : clientBinary, `./cmd/${command}`,
|
||||
], { cwd: ROOT, env: { ...process.env, GOMAXPROCS: "2" }, encoding: "utf8", windowsHide: true, timeout: 120_000 });
|
||||
if (build.error || build.status !== 0) throw build.error ?? new Error(build.stderr || `${command} build failed`);
|
||||
}
|
||||
const [serverPort, vitePort, debugPort, mediaPort] = await Promise.all([
|
||||
reservePort(), reservePort(), reservePort(), udpPort(),
|
||||
]);
|
||||
const origin = `http://127.0.0.1:${vitePort}`;
|
||||
const backend = `http://127.0.0.1:${serverPort}`;
|
||||
const profile = await mkdtemp(join(tmpdir(), "screener-client-media-"));
|
||||
const sourceProfile = nativeArm ? await mkdtemp(join(tmpdir(), "screener-client-media-")) : null;
|
||||
const [sourcePort, sourceDebugPort] = nativeArm ? [await reservePort(), await reservePort()] : [0, 0];
|
||||
const result = { passed: false, arm: `${nativeArm ? "native" : "browser"}-${codec}-simulcast${twoRooms ? "-two-rooms" : ""}`, stage: "start",
|
||||
startedAt: new Date().toISOString(), finishedAt: "", processes: [] as Array<{ role: string; pid: number | null }>,
|
||||
high: null as { frames: number; width: number; height: number } | null,
|
||||
low: null as { frames: number; width: number; height: number } | null,
|
||||
audioEnergy: { high: 0, low: 0 },
|
||||
audioDiagnostics: null as unknown,
|
||||
secondRoom: twoRooms ? { connected: false, framesAfterFirstStopped: 0, audioBefore: 0, audioAfter: 0, stopped: false } : null,
|
||||
host: null as Snapshot | null, viewer: null as Snapshot | null,
|
||||
publicationRetired: false, nativeShareStopped: !nativeArm, udpReleased: false, cleanup: false, error: null as string | null };
|
||||
let server: ChildProcessWithoutNullStreams | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let vite: ViteDevServer | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let host: PageHandle | null = null;
|
||||
let viewer: PageHandle | null = null;
|
||||
let secondHost: PageHandle | null = null;
|
||||
let secondViewer: PageHandle | null = null;
|
||||
let serverError = "";
|
||||
let client: ChildProcessWithoutNullStreams | null = null;
|
||||
let clientPort = 0;
|
||||
let sourceChrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let sourceCdp: CdpConnection | null = null;
|
||||
let sourceHTTP: Awaited<ReturnType<typeof sourceServer>> | null = null;
|
||||
const call = async <T>(page: PageHandle, expression: string, timeout = 25_000): Promise<T> => {
|
||||
const value = await evaluate<{ value?: T; error?: string }>(cdp!, page,
|
||||
`import('/scripts/embedded-sfu-page.ts').then(async (gate) => { try { return { value: await (${expression}) }; } catch (error) { return { error: String(error?.message || error) }; } })`,
|
||||
Date.now() + timeout);
|
||||
if (value.error) throw new Error(value.error);
|
||||
return value.value as T;
|
||||
};
|
||||
const until = async (page: PageHandle, accept: (value: Snapshot) => boolean) => {
|
||||
let last: Snapshot | null = null;
|
||||
try {
|
||||
await waitForSample(() => call<Snapshot>(page, "gate.snapshot()", 3_000),
|
||||
(value) => { last = value; return !!value.error || accept(value); }, 25_000);
|
||||
} catch { throw new Error(`Browser stage timed out: ${JSON.stringify(last)}`); }
|
||||
if (last!.error) throw new Error(last!.error);
|
||||
return last!;
|
||||
};
|
||||
const processStarted = (role: string, child: ChildProcessWithoutNullStreams) => {
|
||||
const entry = { role, pid: child.pid ?? null };
|
||||
result.processes.push(entry);
|
||||
process.stderr.write(`${JSON.stringify({ ...entry, startedAt: new Date().toISOString() })}\n`);
|
||||
};
|
||||
try {
|
||||
server = spawn(binary, [], { cwd: ROOT, stdio: "pipe", windowsHide: true,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
SCREENER_ENV: "development", LISTEN_HOST: "127.0.0.1", PORT: String(serverPort),
|
||||
PUBLIC_BASE_URL: origin, ALLOWED_ORIGINS: origin,
|
||||
SFU_LISTEN_HOST: "127.0.0.1", SFU_UDP_PORT: String(mediaPort), SFU_PUBLIC_IP: "127.0.0.1" },
|
||||
});
|
||||
processStarted("server", server);
|
||||
server.stdout.resume();
|
||||
server.stderr.on("data", (data: Buffer) => { serverError = (serverError + data.toString()).slice(-2000); });
|
||||
await waitForSample((deadline) => fetchJsonBefore<{ status: string }>(`${backend}/healthz`, deadline),
|
||||
() => true, 15_000);
|
||||
vite = await createViteServer({ root: ROOT, configFile: false, appType: "custom", logLevel: "silent",
|
||||
server: { host: "127.0.0.1", port: vitePort, strictPort: true, watch: null,
|
||||
proxy: { "/api": { target: backend }, "/signal": { target: backend, ws: true } } },
|
||||
plugins: [{ name: "embedded-sfu-gate", configureServer(instance) {
|
||||
instance.middlewares.use((request, response, next) => {
|
||||
if (request.url !== "/embedded-sfu-gate") return next();
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.end("<!doctype html><html><head><title>Embedded SFU acceptance</title></head><body></body></html>");
|
||||
});
|
||||
} }],
|
||||
});
|
||||
await vite.listen();
|
||||
if (nativeArm) {
|
||||
result.stage = "native-client-start";
|
||||
client = spawn(clientBinary, ["--site", origin, "--capture-process", captureBinary!,
|
||||
"--config", join(profile, "client.json")], { cwd: ROOT, stdio: "pipe", windowsHide: true,
|
||||
env: { ...process.env, GOMAXPROCS: "2", SCREENER_CLIENT_GATE_NO_BROWSER: "true" } });
|
||||
processStarted("client", client);
|
||||
client.stderr.resume();
|
||||
clientPort = (await readClientEndpoint(client, { timeoutMs: 15_000, ignoreNonEndpointLines: true })).port;
|
||||
client.stdout.resume();
|
||||
result.stage = "native-source-window";
|
||||
sourceHTTP = await sourceServer(sourcePort);
|
||||
({ child: sourceChrome, cdp: sourceCdp } = await startSourceBrowser(chromePath, sourceProfile!, sourceDebugPort, sourcePort));
|
||||
processStarted("source-browser", sourceChrome);
|
||||
await waitForCaptureWindow(captureBinary!);
|
||||
const targets = await sourceCdp.call<{ targetInfos: Array<{ targetId: string; url: string }> }>(
|
||||
"Target.getTargets", {}, undefined, Date.now() + 5_000);
|
||||
const target = targets.targetInfos.find((entry) => entry.url === `http://127.0.0.1:${sourcePort}/`);
|
||||
if (!target) throw new Error("Gate source page is unavailable");
|
||||
const attached = await sourceCdp.call<{ sessionId: string }>("Target.attachToTarget",
|
||||
{ targetId: target.targetId, flatten: true }, undefined, Date.now() + 5_000);
|
||||
const audioState = await evaluate<string>(sourceCdp, attached, `(async () => {
|
||||
const audio = new AudioContext(), tone = audio.createOscillator(), gain = audio.createGain();
|
||||
gain.gain.value = 0.01; tone.connect(gain).connect(audio.destination); tone.start();
|
||||
window.__gateAudio = audio; await audio.resume(); return audio.state;
|
||||
})()`, Date.now() + 5_000);
|
||||
if (audioState !== "running") throw new Error("Native source tone did not start");
|
||||
}
|
||||
chrome = launchChrome(chromePath, debugPort, profile, [
|
||||
"--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
|
||||
"--disable-extensions", "--disable-background-networking", "--autoplay-policy=no-user-gesture-required",
|
||||
"--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding", "--disable-features=WebRtcHideLocalIpsWithMdns",
|
||||
]);
|
||||
processStarted("decoder-browser", chrome);
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
|
||||
if (nativeArm) await cdp.call("Browser.setPermission", {
|
||||
permission: { name: "local-network-access" }, setting: "granted", origin,
|
||||
}, undefined, Date.now() + 5_000);
|
||||
result.stage = "gate-page";
|
||||
const gatePage = await fetch(`${origin}/embedded-sfu-gate`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (!gatePage.ok || !(await gatePage.text()).includes("Embedded SFU acceptance")) {
|
||||
throw new Error("Local SFU gate page is unavailable");
|
||||
}
|
||||
host = await createPage(cdp, `${origin}/embedded-sfu-gate`);
|
||||
viewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
|
||||
result.stage = "authenticate";
|
||||
const room = await call<CreateRoomResponse>(host, nativeArm
|
||||
? `gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: clientPort })}, ${JSON.stringify(codec)})`
|
||||
: `gate.startHost(undefined, ${JSON.stringify(codec)})`);
|
||||
await until(host, (value) => value.authenticated);
|
||||
await call(viewer, `gate.startViewer(${JSON.stringify(room)})`);
|
||||
result.stage = "simulcast-first-frame";
|
||||
result.viewer = await until(viewer, (value) => value.committed && value.audioKbps > 0);
|
||||
if (twoRooms) {
|
||||
result.stage = "second-native-room";
|
||||
secondHost = await createPage(cdp, `${origin}/embedded-sfu-gate`);
|
||||
secondViewer = await createPage(cdp, `${origin}/embedded-sfu-gate`, AUDIO_PROBE);
|
||||
const secondRoom = await call<CreateRoomResponse>(secondHost,
|
||||
`gate.startHost(${JSON.stringify({ title: SOURCE_TITLE, port: clientPort })}, ${JSON.stringify(codec)})`);
|
||||
if (secondRoom.roomId === room.roomId) throw new Error("Native sessions did not create independent rooms");
|
||||
await until(secondHost, (value) => value.authenticated);
|
||||
await call(secondViewer, `gate.startViewer(${JSON.stringify(secondRoom)})`);
|
||||
await until(secondViewer, (value) => value.committed && value.audioKbps > 0);
|
||||
await call(secondViewer, "gate.waitForFrames(1280, 720)");
|
||||
await waitForSample(() => call<number>(secondViewer!, "gate.audioEnergy()", 3_000),
|
||||
(energy) => { result.secondRoom!.audioBefore = energy; return energy > 0; }, 5_000);
|
||||
const second = await call<Snapshot>(secondHost, "gate.snapshot()");
|
||||
result.secondRoom!.connected = second.published && second.simulcast && second.nativeShareStarted;
|
||||
}
|
||||
result.stage = "simulcast-first-frame";
|
||||
await cdp.call("Page.bringToFront", {}, viewer.sessionId, Date.now() + 3_000);
|
||||
result.host = await call(host, "gate.snapshot()");
|
||||
result.high = await call(viewer, nativeArm ? "gate.waitForFrames(1280, 720, 300)" : "gate.waitForFrames(1280, 720)", nativeArm ? 32_000 : 25_000);
|
||||
result.stage = "decoded-audio-energy";
|
||||
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
|
||||
(energy) => { result.audioEnergy.high = energy; return energy > 0; }, 5_000);
|
||||
result.host = await call(host, "gate.snapshot()");
|
||||
result.stage = "live-profile";
|
||||
if (!await call<boolean>(host, "gate.lowerProfile()")) {
|
||||
result.host = await call(host, "gate.snapshot()");
|
||||
throw new Error("Live profile update failed");
|
||||
}
|
||||
result.low = await call(viewer, "gate.waitForFrames(854, 480)");
|
||||
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
|
||||
(energy) => { result.audioEnergy.low = energy; return energy > result.audioEnergy.high; }, 5_000);
|
||||
result.stage = "publication-retirement";
|
||||
await call(host, "gate.stopSharing()");
|
||||
await until(host, (value) => value.publicationRetired);
|
||||
await until(viewer, (value) => value.sharingStopped);
|
||||
result.publicationRetired = true;
|
||||
if (secondHost && secondViewer) {
|
||||
result.stage = "second-room-isolation";
|
||||
if (!await call<boolean>(host, "gate.stop()")) throw new Error("First Native session did not stop");
|
||||
if (!(await call<Snapshot>(host, "gate.snapshot()")).nativeShareStopped) throw new Error("First Native capture did not stop");
|
||||
result.secondRoom!.audioBefore = await call<number>(secondViewer, "gate.audioEnergy()");
|
||||
await cdp.call("Page.bringToFront", {}, secondViewer.sessionId, Date.now() + 3_000);
|
||||
const continued = await call<{ frames: number }>(secondViewer, "gate.waitForFrames(1280, 720, 30)");
|
||||
result.secondRoom!.framesAfterFirstStopped = continued.frames;
|
||||
await waitForSample(() => call<number>(secondViewer!, "gate.audioEnergy()", 3_000),
|
||||
(energy) => { result.secondRoom!.audioAfter = energy; return energy > result.secondRoom!.audioBefore; }, 5_000);
|
||||
if ((await call<Snapshot>(secondHost, "gate.snapshot()")).error) throw new Error("First session retirement affected second Native Host");
|
||||
await call(secondHost, "gate.stopSharing()");
|
||||
await until(secondHost, (value) => value.publicationRetired);
|
||||
await until(secondViewer, (value) => value.sharingStopped);
|
||||
if (!await call<boolean>(secondHost, "gate.stop()")) throw new Error("Second Native session did not stop");
|
||||
result.secondRoom!.stopped = (await call<Snapshot>(secondHost, "gate.snapshot()")).nativeShareStopped;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
if (viewer && cdp && result.stage === "decoded-audio-energy") result.audioDiagnostics = await evaluate(
|
||||
cdp, viewer, "window.__screenerGateAudioStats ?? null", Date.now() + 3_000).catch(() => null);
|
||||
if (server?.exitCode !== null && serverError) result.error += `; server: ${serverError}`;
|
||||
} finally {
|
||||
const pagesStopped = await Promise.all([host, viewer, secondHost, secondViewer].map((page) =>
|
||||
page && cdp ? call<boolean>(page, "gate.stop()", 5_000).catch(() => false) : true));
|
||||
if (host && cdp && nativeArm) {
|
||||
result.nativeShareStopped = await call<Snapshot>(host, "gate.snapshot()", 3_000)
|
||||
.then((value) => value.nativeShareStopped, () => false);
|
||||
}
|
||||
const viteClosed = vite
|
||||
? await withDeadline(() => vite!.close(), Date.now() + 5_000).then(() => true, () => false)
|
||||
: true;
|
||||
const cleanup = await cleanupRun({ cdp, native: client, chrome, server, profile,
|
||||
ports: [serverPort, vitePort, debugPort, ...(clientPort ? [clientPort] : [])] });
|
||||
const sourceCleanup = sourceProfile ? await cleanupRun({ cdp: sourceCdp, native: null,
|
||||
chrome: sourceChrome, server: sourceHTTP, profile: sourceProfile, ports: [sourcePort, sourceDebugPort] }) : null;
|
||||
result.udpReleased = await withDeadline(() => udpPort(mediaPort), Date.now() + 3_000)
|
||||
.then(() => true, () => false);
|
||||
result.cleanup = viteClosed && result.udpReleased && pagesStopped.every(Boolean) &&
|
||||
Object.values(cleanup).every(Boolean) && (!sourceCleanup || Object.values(sourceCleanup).every(Boolean));
|
||||
}
|
||||
result.finishedAt = new Date().toISOString();
|
||||
result.passed = !result.error && result.host?.published === true && result.host.simulcast &&
|
||||
result.viewer?.decoded === true && result.viewer.committed && result.viewer.peerFailures > 0 &&
|
||||
result.viewer.audioKbps > 0 && result.audioEnergy.high > 0 && result.audioEnergy.low > result.audioEnergy.high &&
|
||||
result.high !== null && result.low !== null &&
|
||||
result.publicationRetired && result.cleanup && result.nativeShareStopped &&
|
||||
(!result.secondRoom || result.secondRoom.connected && result.secondRoom.framesAfterFirstStopped >= 30 &&
|
||||
result.secondRoom.audioBefore > 0 && result.secondRoom.audioAfter > result.secondRoom.audioBefore && result.secondRoom.stopped) &&
|
||||
(!nativeArm || result.host.nativeShareStarted && result.host.nativeFramesPerSecond > 0 && result.host.nativeBitrateKbps > 0 && result.host.nativeEncodingCount === 2);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
createRoomResponseSchema,
|
||||
clientMessageSchema,
|
||||
decodeServerMessage,
|
||||
SIGNALING_PROTOCOL,
|
||||
type ClientMessage,
|
||||
type CreateRoomResponse,
|
||||
type QualitySettings,
|
||||
type ServerMessage,
|
||||
} from "../src/shared/protocol";
|
||||
import { SfuPublisher } from "../src/client/sfu/publisher";
|
||||
import { SfuSubscriber } from "../src/client/sfu/subscriber";
|
||||
import type { SfuConnectionConfig } from "../src/client/sfu/peer";
|
||||
import { NativeClient } from "../src/client/native/client";
|
||||
import { NativeSfuPublisher } from "../src/client/native/native-sfu-publisher";
|
||||
import { defaultNativeCapturePath } from "../src/client/native/capture-selection";
|
||||
import type { HostPublisherTransport } from "../src/client/media/host-sfu-route";
|
||||
|
||||
const high: QualitySettings = {
|
||||
resolution: "720p", maxFramerate: 15, maxBitrate: 2_000_000,
|
||||
degradationPreference: "maintain-resolution", screenAudioQuality: "saver",
|
||||
};
|
||||
const low: QualitySettings = { ...high, resolution: "480p" };
|
||||
const shareGeneration = crypto.randomUUID();
|
||||
let socket: WebSocket | null = null;
|
||||
let publisher: HostPublisherTransport | null = null;
|
||||
let native: NativeClient | null = null;
|
||||
let nativeShareStarted = false;
|
||||
let nativeShareStopped = false;
|
||||
let nativeFramesPerSecond = 0;
|
||||
let nativeBitrateKbps = 0;
|
||||
let nativeEncodingCount = 0;
|
||||
let subscriber: SfuSubscriber | null = null;
|
||||
let configuration: SfuConnectionConfig | null = null;
|
||||
let source: MediaStream | null = null;
|
||||
let audio: AudioContext | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let video: HTMLVideoElement | null = null;
|
||||
let drawTimer: number | null = null;
|
||||
let preparedRevision = 0;
|
||||
let decoded = false;
|
||||
let authenticated = false;
|
||||
let committed = false;
|
||||
let publicationRetired = false;
|
||||
let sharingStopped = false;
|
||||
let stopping = false;
|
||||
let peerFailures = 0;
|
||||
let published = false;
|
||||
let simulcast = false;
|
||||
let codec: "vp8" | "h264" = "vp8";
|
||||
let audioKbps = 0;
|
||||
let audioCodec: string | null = null;
|
||||
let error: string | null = null;
|
||||
let messageTail = Promise.resolve();
|
||||
|
||||
function send(message: ClientMessage): boolean {
|
||||
clientMessageSchema.parse(message);
|
||||
if (socket?.readyState !== WebSocket.OPEN) return false;
|
||||
if (message.type === "sfu-signal" && message.description?.type === "offer") {
|
||||
simulcast = message.description.sdp.includes("a=simulcast:send q;h");
|
||||
}
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
function failed(reason: unknown): void {
|
||||
error = reason instanceof Error ? reason.message : String(reason);
|
||||
}
|
||||
|
||||
async function accept(message: ServerMessage, host: boolean): Promise<void> {
|
||||
if (message.type === "error") throw new Error(`${message.code}: ${message.message}`);
|
||||
if (message.type === "authenticated") authenticated = true;
|
||||
if (message.type === "sharing-stopped") {
|
||||
sharingStopped = true;
|
||||
await subscriber?.disconnect();
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (message.phase === "prepare" && !host) {
|
||||
if (message.candidate.transport === "direct") {
|
||||
// Exercise the ordinary fallback operation without changing room policy.
|
||||
peerFailures += 1;
|
||||
send({ type: "route-failed", revision: message.revision,
|
||||
phase: "prepare", connectionId: message.candidate.connectionId });
|
||||
} else preparedRevision = message.revision;
|
||||
}
|
||||
if (message.phase === "active") {
|
||||
if (!host && message.assignment.upstream.kind === "sfu") {
|
||||
if (!decoded) throw new Error("SFU committed before decoded-frame proof");
|
||||
committed = true;
|
||||
}
|
||||
if (configuration && message.assignment.sfuPublicationGeneration === configuration.publicationGeneration) {
|
||||
configuration = { ...configuration, revision: message.revision };
|
||||
publisher?.updateConfig(configuration);
|
||||
subscriber?.updateConfig(configuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (configuration) {
|
||||
if (configuration.connectionId !== message.connectionId ||
|
||||
configuration.publicationGeneration !== message.publicationGeneration) {
|
||||
throw new Error("Unexpected SFU replacement during bounded acceptance");
|
||||
}
|
||||
configuration = message;
|
||||
publisher?.updateConfig(message);
|
||||
subscriber?.updateConfig(message);
|
||||
return;
|
||||
}
|
||||
configuration = message;
|
||||
if (host) {
|
||||
const events = { send, onDisconnected: () => {
|
||||
if (!stopping) failed("Publisher disconnected");
|
||||
} };
|
||||
publisher = native
|
||||
? new NativeSfuPublisher(native, shareGeneration, { iceServers: [] }, { ...events,
|
||||
onStats: (metrics) => {
|
||||
nativeFramesPerSecond = metrics?.framesPerSecond ?? 0;
|
||||
nativeBitrateKbps = metrics?.bitrateKbps ?? 0;
|
||||
nativeEncodingCount = metrics?.videoEncodingCount ?? 0;
|
||||
},
|
||||
})
|
||||
: new SfuPublisher(events);
|
||||
await publisher.connect(message);
|
||||
published = await publisher.activate(source!, high, codec);
|
||||
if (!published) throw new Error("Publisher did not activate");
|
||||
} else {
|
||||
video = document.createElement("video");
|
||||
video.muted = false;
|
||||
video.volume = 0.1;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.style.cssText = "max-width:100%;width:640px";
|
||||
document.body.append(video);
|
||||
subscriber = new SfuSubscriber({
|
||||
send,
|
||||
onStream: (stream) => {
|
||||
if (!video || video.srcObject === stream) return;
|
||||
video.srcObject = stream;
|
||||
if (stream) void video.play().catch(failed);
|
||||
},
|
||||
onStats: (metrics) => {
|
||||
audioKbps = metrics.audioBitrateKbps ?? 0;
|
||||
audioCodec = metrics.audioCodec;
|
||||
},
|
||||
onFirstDecodedFrame: () => {
|
||||
decoded = true;
|
||||
return send({ type: "route-ready", revision: preparedRevision, phase: "prepare" });
|
||||
},
|
||||
onDisconnected: () => { if (!sharingStopped) failed("Subscriber disconnected"); },
|
||||
});
|
||||
await subscriber.connect(message);
|
||||
subscriber.armDecodedFrameProof();
|
||||
subscriber.activate();
|
||||
}
|
||||
}
|
||||
if (message.type === "sfu-signal") {
|
||||
await publisher?.acceptSignal(message);
|
||||
await subscriber?.acceptSignal(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(room: CreateRoomResponse, host: boolean): Promise<void> {
|
||||
socket = new WebSocket(new URL("/signal", location.href).href.replace(/^http/, "ws"));
|
||||
socket.addEventListener("message", ({ data }) => {
|
||||
messageTail = messageTail.then(() => accept(decodeServerMessage(String(data)), host)).catch(failed);
|
||||
});
|
||||
socket.addEventListener("error", () => failed("Room WebSocket failed"));
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (host && stopping && event.code === 1000 && event.reason === "Sharing stopped") {
|
||||
void publisher?.disconnect().then(() => { publicationRetired = true; });
|
||||
} else if (!stopping && !sharingStopped) failed(`Unexpected room socket close: ${event.code}`);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Room WebSocket timed out")), 10_000);
|
||||
socket!.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
});
|
||||
const common = { type: "authenticate", protocol: SIGNALING_PROTOCOL,
|
||||
roomId: room.roomId, clientId: crypto.randomUUID() } as const;
|
||||
send(host
|
||||
? { ...common, role: "host", token: room.hostToken, shareGeneration,
|
||||
qualitySettings: high, routePolicy: { peerOnly: false, topologyOptimization: false, natPrediction: false } }
|
||||
: { ...common, role: "viewer", viewerGrant: new URLSearchParams(new URL(room.inviteUrl).hash.slice(1)).get("v")! });
|
||||
}
|
||||
|
||||
export async function startHost(nativeSource?: { title: string; port: number }, selectedCodec: "vp8" | "h264" = "vp8"): Promise<CreateRoomResponse> {
|
||||
codec = selectedCodec;
|
||||
const response = await fetch("/api/rooms", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeEntryPolicy: "private" }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Room creation failed: ${response.status}`);
|
||||
const room = createRoomResponseSchema.parse(await response.json());
|
||||
if (nativeSource) {
|
||||
native = await NativeClient.connect();
|
||||
if (!native || native.health.port !== nativeSource.port) throw new Error("Gate Client was not discovered");
|
||||
const target = (await native.sources()).find((candidate) => candidate.kind === "window" && candidate.title === nativeSource.title);
|
||||
const path = defaultNativeCapturePath(await native.captureOptions(), codec, native.health.nativeMedia.softwareVP8);
|
||||
if (!target || !path) throw new Error("Native source is unavailable");
|
||||
native.onEvent((event) => {
|
||||
if (!stopping && event.type === "share-ended" && event.shareId === shareGeneration) failed("Native capture ended");
|
||||
});
|
||||
const started = await native.startShare({ shareId: shareGeneration, source: target,
|
||||
codec, audio: true, profile: high, edgeCapacity: 2, ...path });
|
||||
nativeShareStarted = true;
|
||||
if (started.codec !== codec || !started.audio) throw new Error("Requested Native codec and Opus capture did not start");
|
||||
await connect(room, true);
|
||||
return room;
|
||||
}
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = 1280;
|
||||
canvas.height = 720;
|
||||
canvas.style.cssText = "max-width:100%;width:640px";
|
||||
document.body.append(canvas);
|
||||
const context = canvas.getContext("2d")!;
|
||||
let frame = 0;
|
||||
drawTimer = window.setInterval(() => {
|
||||
context.fillStyle = "#167d8d";
|
||||
context.fillRect(0, 0, canvas!.width, canvas!.height);
|
||||
context.fillStyle = "#f2d748";
|
||||
context.fillRect((frame++ * 17) % canvas!.width, 60, 100, 100);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "48px sans-serif";
|
||||
context.fillText(String(frame), 40, 240);
|
||||
}, 1000 / 15);
|
||||
source = canvas.captureStream(15);
|
||||
audio = new AudioContext();
|
||||
const tone = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
gain.gain.value = 0.05;
|
||||
const destination = audio.createMediaStreamDestination();
|
||||
tone.connect(gain).connect(destination);
|
||||
tone.start();
|
||||
await audio.resume();
|
||||
source.addTrack(destination.stream.getAudioTracks()[0]!);
|
||||
await connect(room, true);
|
||||
return room;
|
||||
}
|
||||
|
||||
export async function startViewer(room: CreateRoomResponse): Promise<void> {
|
||||
await connect(room, false);
|
||||
}
|
||||
|
||||
export function snapshot() {
|
||||
return { authenticated, published, simulcast, decoded, committed, peerFailures,
|
||||
audioKbps, audioCodec, publicationRetired, sharingStopped, nativeShareStarted, nativeShareStopped,
|
||||
nativeFramesPerSecond, nativeBitrateKbps, nativeEncodingCount,
|
||||
warning: publisher?.getQualityWarning?.() ?? null,
|
||||
failureStage: publisher?.getFailureStage?.() ?? null, error };
|
||||
}
|
||||
|
||||
export async function audioEnergy(): Promise<number> {
|
||||
const probe = (window as unknown as { __screenerGateAudioEnergy: () => Promise<number> }).__screenerGateAudioEnergy;
|
||||
return await probe();
|
||||
}
|
||||
|
||||
export async function waitForFrames(width: number, height: number, minimumFrames = 15) {
|
||||
if (!video) throw new Error("Viewer video is unavailable");
|
||||
const target = video;
|
||||
return await new Promise<{ frames: number; width: number; height: number }>((resolve, reject) => {
|
||||
let frames = 0;
|
||||
let callback = 0;
|
||||
const timer = setTimeout(() => {
|
||||
target.cancelVideoFrameCallback(callback);
|
||||
reject(new Error(`Viewer did not decode ${width}x${height}; current ${target.videoWidth}x${target.videoHeight}; ${frames} frame callbacks; ${document.visibilityState}; ${error ?? "no signaling error"}`));
|
||||
}, minimumFrames > 15 ? 30_000 : 20_000);
|
||||
const next = () => {
|
||||
callback = target.requestVideoFrameCallback(() => {
|
||||
frames = target.videoWidth === width && target.videoHeight === height ? frames + 1 : 0;
|
||||
if (frames < minimumFrames) return next();
|
||||
clearTimeout(timer);
|
||||
resolve({ frames, width: target.videoWidth, height: target.videoHeight });
|
||||
});
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
export async function lowerProfile(): Promise<boolean> {
|
||||
if (!publisher) throw new Error("Publisher is unavailable");
|
||||
if (native) await native.updateShare(shareGeneration, low);
|
||||
else {
|
||||
if (!canvas) throw new Error("Browser source is unavailable");
|
||||
canvas.width = 854;
|
||||
canvas.height = 480;
|
||||
}
|
||||
send({ type: "set-quality-settings", qualitySettings: low });
|
||||
return await publisher.updateProfile(low);
|
||||
}
|
||||
|
||||
export function stopSharing(): void {
|
||||
stopping = true;
|
||||
send({ type: "stop-sharing", shareGeneration });
|
||||
}
|
||||
|
||||
export async function stop(): Promise<boolean> {
|
||||
stopping = true;
|
||||
await publisher?.disconnect();
|
||||
await subscriber?.disconnect();
|
||||
if (native) {
|
||||
if (nativeShareStarted) {
|
||||
await native.stopShare(shareGeneration);
|
||||
nativeShareStopped = true;
|
||||
}
|
||||
native.close();
|
||||
native = null;
|
||||
}
|
||||
if (drawTimer !== null) clearInterval(drawTimer);
|
||||
source?.getTracks().forEach((track) => track.stop());
|
||||
await audio?.close();
|
||||
video?.remove();
|
||||
canvas?.remove();
|
||||
const closed = !source || source.getTracks().every((track) => track.readyState === "ended");
|
||||
socket?.close();
|
||||
return closed;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const customInput = process.env.SCREENER_ENCODED_OUTPUT;
|
||||
const codec = process.env.SCREENER_ENCODED_CODEC ?? "vp8";
|
||||
assert.ok(["vp8", "avc1.42c033"].includes(codec), "Expected VP8 or constrained-baseline H264");
|
||||
const inputPath = customInput ? resolve(customInput) : new URL("../build/embedded-media/encoded-group.received.json", import.meta.url);
|
||||
const input = await readFile(inputPath);
|
||||
const streams = JSON.parse(input);
|
||||
assert.equal(streams.length, 2);
|
||||
for (const frames of streams) {
|
||||
if (customInput) assert.ok(frames.length > 0 && frames.length <= 1200);
|
||||
else assert.equal(frames.length, 40);
|
||||
}
|
||||
const expectedCounts = streams.map((frames) => frames.length);
|
||||
const page = `<!doctype html><meta charset="utf-8"><title>Shared encoded output decode</title>
|
||||
<pre id="result">Decoding encoded input...</pre><script>
|
||||
(async () => {
|
||||
const streams = await (await fetch('/frames')).json();
|
||||
const results = [];
|
||||
for (const rows of streams) {
|
||||
let accept, reject;
|
||||
const decoded = [];
|
||||
const decoder = new VideoDecoder({output: frame => accept(frame), error: error => reject(error)});
|
||||
try {
|
||||
decoder.configure({codec:${JSON.stringify(codec)}, hardwareAcceleration:'prefer-software', optimizeForLatency:true});
|
||||
for (const row of rows) {
|
||||
const pending = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
|
||||
decoder.decode(new EncodedVideoChunk({type:row.Recovery ? 'key' : 'delta',
|
||||
timestamp:Math.round(row.PTS/1000), data:Uint8Array.from(atob(row.Data), c=>c.charCodeAt(0))}));
|
||||
const frame = await pending;
|
||||
try {
|
||||
if (frame.displayWidth !== row.Width || frame.displayHeight !== row.Height || frame.timestamp !== Math.round(row.PTS/1000))
|
||||
throw Error('Decoded frame differs from selected output');
|
||||
decoded.push([row.Index, frame.displayWidth, frame.displayHeight, frame.timestamp]);
|
||||
} finally { frame.close(); }
|
||||
}
|
||||
results.push(decoded);
|
||||
} finally { if (decoder.state !== 'closed') decoder.close(); }
|
||||
}
|
||||
window.decodeResult = {passed:true, userAgent:navigator.userAgent, results};
|
||||
})().catch(error => { window.decodeResult = {passed:false,error:String(error)}; }).finally(async()=>{
|
||||
document.querySelector('#result').textContent = JSON.stringify(window.decodeResult);
|
||||
await fetch('/result', {method:'POST',body:JSON.stringify(window.decodeResult)});
|
||||
});
|
||||
</script>`;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.url === "/frames") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(input);
|
||||
} else if (request.url === "/result" && request.method === "POST") {
|
||||
const body = [];
|
||||
for await (const chunk of request) body.push(chunk);
|
||||
const result = JSON.parse(Buffer.concat(body).toString());
|
||||
assert.equal(result.passed, true, result.error);
|
||||
assert.deepEqual(result.results.map((frames) => frames.length), expectedCounts);
|
||||
result.receivedSha256 = createHash("sha256").update(input).digest("hex");
|
||||
result.codec = codec;
|
||||
result.scope = "Encoded input -> two Chrome WebCodecs decoders; input provenance is owned by its producing fixture, not proof of Browser WebRTC transport, jitter-buffer or hardware acceptance";
|
||||
result.sources = {};
|
||||
for (const path of ["go.mod", "go.sum", "internal/media/encoded/packetizer.go",
|
||||
"internal/media/forwarding/source.go", "internal/media/forwarding/encoded_source.go",
|
||||
"internal/media/forwarding/output.go", "internal/media/forwarding/transport.go",
|
||||
"internal/client/mediaedge/engine.go", "internal/client/mediaedge/source.go",
|
||||
"internal/client/mediaedge/edge.go", "internal/client/mediaedge/group_fixture_test.go",
|
||||
"internal/client/mediaedge/relay_network_fixture_test.go", "internal/client/mediaedge/relay_derivation.go",
|
||||
"native/capture/windows/encoded_group.fixture.cpp"]) {
|
||||
result.sources[path] = createHash("sha256").update(await readFile(new URL(`../${path}`, import.meta.url))).digest("hex");
|
||||
}
|
||||
const resultPath = customInput ? `${inputPath}.decode-result.json` : new URL("../build/embedded-media/encoded-group.decode-result.json", import.meta.url);
|
||||
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
response.end("passed");
|
||||
console.log(`Passed: receivers decoded ${expectedCounts.join(" and ")} received frames`);
|
||||
clearTimeout(deadline);
|
||||
server.close();
|
||||
} else {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(page);
|
||||
}
|
||||
});
|
||||
const deadline = setTimeout(() => { process.exitCode = 1; server.closeAllConnections(); server.close(); }, 60000);
|
||||
server.listen(0, "127.0.0.1", () => console.log(`http://127.0.0.1:${server.address().port}`));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const output = new URL("../build/embedded-media/browser-variants-result.json", import.meta.url);
|
||||
const worker = await readFile(new URL("./encoded-variants-worker.js", import.meta.url));
|
||||
const page = `<!doctype html><meta charset="utf-8"><title>Encoded variants probe</title>
|
||||
<button id="run">Run bounded software-codec comparison</button><pre id="status">Ready</pre>
|
||||
<script>
|
||||
let running = false;
|
||||
document.querySelector('#run').onclick = () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
const worker = new Worker('/worker.js');
|
||||
const timeout = setTimeout(() => { worker.terminate(); window.probeError = 'deadline'; }, 240000);
|
||||
worker.onmessage = async ({data}) => {
|
||||
document.querySelector('#status').textContent = JSON.stringify(data);
|
||||
if (data.progress) return;
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
if (data.error) { window.probeError = data.error; return; }
|
||||
window.probeResult = data;
|
||||
await fetch('/result', {method:'POST', body:JSON.stringify(data)});
|
||||
};
|
||||
worker.onerror = ({message}) => { clearTimeout(timeout); worker.terminate(); window.probeError = message; };
|
||||
worker.postMessage('run');
|
||||
};
|
||||
</script>`;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.url === "/worker.js") {
|
||||
response.writeHead(200, { "Content-Type": "text/javascript" }).end(worker);
|
||||
} else if (request.url === "/result" && request.method === "POST") {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const result = JSON.parse(Buffer.concat(chunks).toString());
|
||||
result.workerSha256 = createHash("sha256").update(worker).digest("hex");
|
||||
await mkdir(new URL("./", output), { recursive: true });
|
||||
await writeFile(output, `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(`Saved ${result.results.length} runs to ${fileURLToPath(output)}`);
|
||||
response.end("saved");
|
||||
} else {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(page);
|
||||
}
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => console.log(`http://127.0.0.1:${server.address().port}`));
|
||||
process.on("SIGINT", () => server.close());
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { cpus, platform, release } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const executable = resolve(process.argv[2] ?? "build/embedded-media/encoded-variants.probe.exe");
|
||||
const output = resolve("build/embedded-media/encoded-variants-result.json");
|
||||
const steady = ["high", "two-high", "high-low", "all", "mid-low", "low"];
|
||||
const traces = ["envelope", "exact-cold", "exact-warm", "warm-clock-ablation"];
|
||||
const results = [];
|
||||
const report = {
|
||||
completed: false,
|
||||
scope: "CPU-only Native VP8 encode microbenchmark, not playback/network/H264 acceptance",
|
||||
input: "32 synthetic NV12 frames per size; generation and scaling excluded",
|
||||
profiles: [[480, 270, 312500], [960, 540, 1250000], [1920, 1080, 5000000]],
|
||||
fps: 30,
|
||||
threadsPerEncoder: 4,
|
||||
demandTrace: ["H/H", "H/L", "L/L", "M/M", "H/H", "H/L", "H/H"],
|
||||
secondsPerDemand: 2,
|
||||
envelope: "maximum-demand active set; no LiveKit demand detection/debounce emulation",
|
||||
warm: "retain at most three codec objects within the 14-second trace, stop inactive encoding",
|
||||
clockAblation: "change only encoder PTS to a continuous active-frame clock; not a production proposal",
|
||||
cpuMeasurement: "whole-window GetProcessTimes and QueryProcessCycleTime on Windows; cycles are not converted to milliseconds",
|
||||
sources: Object.fromEntries(["native/capture/windows/encoded_variants.probe.cpp", "native/capture/windows/vp8_encoder.cpp"].map(
|
||||
(path) => [path, createHash("sha256").update(readFileSync(path)).digest("hex")],
|
||||
)),
|
||||
host: { platform: platform(), release: release(), cpu: cpus()[0]?.model, logicalCpus: cpus().length },
|
||||
results,
|
||||
};
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
|
||||
for (let round = 0; round < 2; round++) {
|
||||
for (const scene of ["still", "motion"]) {
|
||||
const cases = [...steady, ...(scene === "motion" ? traces : [])];
|
||||
if (round) cases.reverse();
|
||||
for (const scenario of cases) {
|
||||
const processResult = spawnSync(executable, [scenario, scene], {
|
||||
encoding: "utf8", windowsHide: true, timeout: 45000, maxBuffer: 1024 * 1024,
|
||||
});
|
||||
assert.equal(processResult.error, undefined, processResult.error?.message);
|
||||
assert.equal(processResult.status, 0, processResult.stderr);
|
||||
const result = JSON.parse(processResult.stdout);
|
||||
assert.equal(result.scenario, scenario);
|
||||
assert.equal(result.scene, scene);
|
||||
if (traces.includes(scenario)) {
|
||||
const expected = scenario === "envelope" ? [420, 360, 300, 0] : [180, 60, 300, 0];
|
||||
assert.deepEqual(result.summary.encodedFrames, expected);
|
||||
assert.equal(result.transitions.length, 7);
|
||||
assert.equal(result.summary.createCount, scenario === "exact-warm" || scenario === "warm-clock-ablation" ? 3 : 5);
|
||||
} else {
|
||||
const masks = { high: 4, "two-high": 12, "high-low": 5, all: 7, "mid-low": 3, low: 1 };
|
||||
assert.deepEqual(result.summary.encodedFrames, [0, 1, 2, 3].map((slot) => masks[scenario] & (1 << slot) ? 90 : 0));
|
||||
if (scenario === "two-high") assert.equal(result.summary.encodedBytes[2], result.summary.encodedBytes[3]);
|
||||
}
|
||||
results.push({ round: round + 1, ...result });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`${round + 1} ${scene} ${scenario}: mean=${result.summary.workMeanMs}ms p95=${result.summary.workP95Ms}ms cpu=${result.summary.cpuCoresAt30Fps} cores`);
|
||||
}
|
||||
}
|
||||
}
|
||||
report.completed = true;
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`Passed ${results.length} bounded runs; results: ${output}`);
|
||||
@@ -0,0 +1,221 @@
|
||||
// Isolated component experiment; no application transport, feedback or policy.
|
||||
const profiles = [[480, 270, 312500], [960, 540, 1250000], [1920, 1080, 5000000]];
|
||||
const fps = 30;
|
||||
const trace = [[2, 2], [2, 0], [0, 0], [1, 1], [2, 2], [2, 0], [2, 2]];
|
||||
const phaseFrames = 32;
|
||||
const now = () => performance.now();
|
||||
const assert = (condition, message) => { if (!condition) throw Error(message); };
|
||||
const summarize = (values) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return { mean: values.reduce((a, b) => a + b, 0) / values.length, p95: sorted[Math.ceil(sorted.length * .95) - 1], max: sorted.at(-1) };
|
||||
};
|
||||
|
||||
function makeInput() {
|
||||
return Array.from({ length: 32 }, (_, frame) => {
|
||||
const data = new Uint8Array(1920 * 1080 * 3 / 2).fill(128);
|
||||
for (let y = 0; y < 1080; y++) for (let x = 0; x < 1920; x++) {
|
||||
const u = (x + frame * 4) % 128;
|
||||
const v = y % 128;
|
||||
data[y * 1920 + x] = 32 + ((u >> 3) ^ (v >> 3)) * 8 + ((u ^ v) & 31) * 2;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function config(codec, layer) {
|
||||
const [width, height, bitrate] = profiles[layer];
|
||||
return { codec, width, height, bitrate, framerate: fps, latencyMode: "realtime",
|
||||
bitrateMode: "constant", hardwareAcceleration: "prefer-software",
|
||||
...(codec.startsWith("avc") ? { avc: { format: "annexb" } } : {}) };
|
||||
}
|
||||
|
||||
function encoder(codec, layer) {
|
||||
let result;
|
||||
let failure;
|
||||
const value = new VideoEncoder({ output: (chunk, metadata) => { result = { chunk, metadata }; }, error: (error) => { failure = error; } });
|
||||
value.configure(config(codec, layer));
|
||||
return {
|
||||
value,
|
||||
async encode(frame, keyFrame) {
|
||||
result = undefined;
|
||||
value.encode(frame, { keyFrame });
|
||||
await value.flush();
|
||||
if (failure) throw failure;
|
||||
assert(result?.chunk.timestamp === frame.timestamp, "Missing/out-of-order encoded frame");
|
||||
if (keyFrame) assert(result.chunk.type === "key", "Missing switch keyframe");
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decoder(codec) {
|
||||
let complete;
|
||||
let reject;
|
||||
let layer = -1;
|
||||
let failure;
|
||||
const value = new VideoDecoder({
|
||||
output: (frame) => { const accept = complete; complete = undefined; if (accept) accept(frame); else frame.close(); },
|
||||
error: (error) => { failure = error; reject?.(error); },
|
||||
});
|
||||
return {
|
||||
value,
|
||||
async decode(packet, nextLayer) {
|
||||
if (layer !== nextLayer) {
|
||||
const [codedWidth, codedHeight] = profiles[nextLayer];
|
||||
value.configure({ codec, codedWidth, codedHeight, optimizeForLatency: true, hardwareAcceleration: "prefer-software" });
|
||||
layer = nextLayer;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
const promise = new Promise((resolve, rejectPromise) => { complete = resolve; reject = rejectPromise; });
|
||||
value.decode(packet);
|
||||
const frame = await promise;
|
||||
assert(frame.displayWidth === profiles[nextLayer][0] && frame.displayHeight === profiles[nextLayer][1], "Wrong decoded dimensions");
|
||||
return frame;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawFrame(input, frame) {
|
||||
return new VideoFrame(input[frame % input.length], { format: "NV12", codedWidth: 1920, codedHeight: 1080,
|
||||
timestamp: Math.round(frame * 1e6 / fps), duration: Math.round(1e6 / fps) });
|
||||
}
|
||||
|
||||
async function prepareIngress(codec, input) {
|
||||
const source = encoder(codec, 2);
|
||||
const packets = [];
|
||||
try {
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const frame = rawFrame(input, i);
|
||||
try {
|
||||
const { chunk } = await source.encode(frame, i === 0);
|
||||
const data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
packets.push({ data, type: chunk.type });
|
||||
} finally { frame.close(); }
|
||||
}
|
||||
return packets;
|
||||
} finally { source.value.close(); }
|
||||
}
|
||||
|
||||
async function run(codec, policy, role, input, ingress) {
|
||||
const encoders = [null, null, null];
|
||||
const receivers = [decoder(codec), decoder(codec)];
|
||||
const inputDecoder = role === "relay" ? decoder(codec) : null;
|
||||
let previous = [-1, -1];
|
||||
const encoded = [0, 0, 0];
|
||||
const generatedBytes = [0, 0, 0];
|
||||
const selectedBytes = [0, 0];
|
||||
const decoded = [0, 0];
|
||||
const switches = [];
|
||||
const phases = [];
|
||||
let creates = 0;
|
||||
let ingressDecodes = 0;
|
||||
const metrics = { management: [], input: [], encodeReady: [], allReceivers: [], processing: [] };
|
||||
const start = now();
|
||||
try {
|
||||
for (let index = 0; index < trace.length * phaseFrames; index++) {
|
||||
const phase = Math.floor(index / phaseFrames);
|
||||
const demand = trace[phase];
|
||||
const tick = now();
|
||||
const demanded = new Set(demand);
|
||||
const active = policy === "exact" ? demanded : new Set(Array.from({ length: Math.max(...demand) + 1 }, (_, i) => i));
|
||||
// A relay already has H. It never re-encodes H or starts lower encoders just for H/H.
|
||||
if (role === "relay") {
|
||||
active.delete(2);
|
||||
if (!demand.some((i) => i < 2)) active.clear();
|
||||
}
|
||||
const switched = demand.map((layer, child) => previous[child] !== layer);
|
||||
const keyframes = new Set(demand.filter((_, child) => switched[child]));
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
if (active.has(layer) && !encoders[layer]) { encoders[layer] = encoder(codec, layer); creates++; keyframes.add(layer); }
|
||||
if (!active.has(layer) && encoders[layer]) { encoders[layer].value.close(); encoders[layer] = null; }
|
||||
}
|
||||
const afterManagement = now();
|
||||
let sourceFrame;
|
||||
const outputs = new Map();
|
||||
const deliveries = [];
|
||||
const deliver = async (layer, child, packetPromise) => {
|
||||
const packet = await packetPromise;
|
||||
assert(packet, "Missing demanded encoding");
|
||||
if (switched[child]) assert(packet.type === "key", "Switch did not start at a recovery frame");
|
||||
const frame = await receivers[child].decode(packet, layer);
|
||||
assert(frame.timestamp === Math.round(index * 1e6 / fps), "Wrong decoded timestamp");
|
||||
frame.close();
|
||||
decoded[child]++;
|
||||
selectedBytes[child] += packet.byteLength;
|
||||
const delivered = now();
|
||||
if (switched[child] && index > 0) switches.push({ index, child, layer, ms: delivered - tick, bytes: packet.byteLength });
|
||||
return delivered;
|
||||
};
|
||||
if (role === "relay") {
|
||||
const sourcePacket = ingress[index % ingress.length];
|
||||
const packet = new EncodedVideoChunk({ ...sourcePacket, timestamp: Math.round(index * 1e6 / fps) });
|
||||
outputs.set(2, Promise.resolve(packet));
|
||||
demand.forEach((layer, child) => { if (layer === 2) deliveries[child] = deliver(layer, child, outputs.get(layer)); });
|
||||
if (active.size) {
|
||||
sourceFrame = await inputDecoder.decode(packet, 2);
|
||||
ingressDecodes++;
|
||||
}
|
||||
// Demand phases align with input GOPs; real upstream PLI/network delay is not measured.
|
||||
} else {
|
||||
sourceFrame = rawFrame(input, index);
|
||||
}
|
||||
const afterInput = now();
|
||||
try {
|
||||
for (const layer of active) {
|
||||
outputs.set(layer, encoders[layer].encode(sourceFrame, keyframes.has(layer)).then(({ chunk }) => {
|
||||
encoded[layer]++;
|
||||
generatedBytes[layer] += chunk.byteLength;
|
||||
return chunk;
|
||||
}));
|
||||
}
|
||||
demand.forEach((layer, child) => { deliveries[child] ??= deliver(layer, child, outputs.get(layer)); });
|
||||
await Promise.all(outputs.values());
|
||||
} finally { sourceFrame?.close(); }
|
||||
const afterEncode = now();
|
||||
const decodedAt = await Promise.all(deliveries);
|
||||
const afterDecode = Math.max(...decodedAt);
|
||||
const row = { management: afterManagement - tick, input: afterInput - afterManagement,
|
||||
encodeReady: afterEncode - afterInput, allReceivers: afterDecode - tick, processing: now() - tick };
|
||||
for (const key of Object.keys(metrics)) metrics[key].push(row[key]);
|
||||
if (!phases[phase]) phases[phase] = { demand, frames: 0, work: [] };
|
||||
phases[phase].frames++;
|
||||
phases[phase].work.push(row.processing);
|
||||
previous = demand;
|
||||
const delay = start + (index + 1) * 1000 / fps - now();
|
||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
const expected = role === "host" ? policy === "exact" ? [96, 32, 160] : [224, 192, 160]
|
||||
: policy === "exact" ? [96, 32, 0] : [128, 96, 0];
|
||||
assert(encoded.every((count, layer) => count === expected[layer]), "Unexpected encode count");
|
||||
assert(decoded.every((count) => count === 224), "Missing receiver frames");
|
||||
assert(ingressDecodes === (role === "relay" ? 128 : 0), "Repeated or unnecessary input decode");
|
||||
return { codec, policy, role, encoded, decoded, ingressDecodes, creates, generatedBytes, selectedBytes, switches,
|
||||
elapsedMs: now() - start, metrics: Object.fromEntries(Object.entries(metrics).map(([key, values]) => [key, summarize(values)])),
|
||||
overFrameBudget: metrics.processing.filter((value) => value > 1000 / fps).length,
|
||||
phases: phases.map(({ demand, frames, work }) => ({ demand, frames, work: summarize(work) })) };
|
||||
} finally {
|
||||
for (const slot of [...encoders, ...receivers, inputDecoder]) if (slot && slot.value.state !== "closed") slot.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async () => {
|
||||
try {
|
||||
const input = makeInput();
|
||||
const results = [];
|
||||
const support = {};
|
||||
for (const codec of ["vp8", "avc1.420028"]) {
|
||||
support[codec] = (await VideoEncoder.isConfigSupported(config(codec, 2))).supported;
|
||||
if (!support[codec]) continue;
|
||||
const ingress = await prepareIngress(codec, input);
|
||||
for (let round = 1; round <= 2; round++) {
|
||||
for (const role of ["host", "relay"]) for (const policy of round === 1 ? ["exact", "envelope"] : ["envelope", "exact"]) {
|
||||
self.postMessage({ progress: `${codec} ${round} ${role} ${policy}` });
|
||||
results.push({ round, ...await run(codec, policy, role, input, ingress) });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.postMessage({ completed: true, scope: "WebCodecs software preference; independent child decode; no RTP, network, hardware or perceptual acceptance",
|
||||
userAgent: navigator.userAgent, support, profiles, fps, phaseFrames, trace, results });
|
||||
} catch (error) { self.postMessage({ error: String(error.stack ?? error) }); }
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
@@ -16,12 +15,12 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { writeServerLicenseNotices } from "./package-licenses.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
import { resetBuildWorkspace } from "./build-workspace.mjs";
|
||||
|
||||
// The Hosted deployment target. deploy/release-app.sh runs the archived binary
|
||||
// as the service user, so the release is always built for linux/amd64, and
|
||||
@@ -192,7 +191,7 @@ assertCleanRevision(repositoryRoot, revision);
|
||||
const mainAsset = mainAssetOf(join(repositoryRoot, CLIENT_DIST));
|
||||
|
||||
const releaseId = revision.slice(0, 7);
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-app-${releaseId}-`));
|
||||
const temporaryRoot = resetBuildWorkspace(repositoryRoot, "app-package", "assembly");
|
||||
const runtimeRoot = join(temporaryRoot, "runtime");
|
||||
const verifyRoot = join(temporaryRoot, "verify");
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { networkInterfaces, tmpdir } from "node:os";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { createServer } from "node:net";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { clientPackageTarget, CLOUDFLARED_VERSION } from "./client-package-targets.mjs";
|
||||
import { tarExecutable } from "./archive-tool.mjs";
|
||||
import { resetBuildWorkspace } from "./build-workspace.mjs";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
@@ -254,7 +255,7 @@ async function verifyPackage(root, target, revision, temporaryRoot) {
|
||||
if (target.captureName) {
|
||||
const capture = join(root, "runtime", "native", target.captureName);
|
||||
const probe = JSON.parse(run(capture, ["--probe"], root));
|
||||
if (probe?.protocol !== 4) fail("Packaged native capture probe is invalid");
|
||||
if (probe?.protocol !== 7) fail("Packaged native capture probe is invalid");
|
||||
}
|
||||
await verifyLocalPackage(root, target, temporaryRoot);
|
||||
}
|
||||
@@ -322,9 +323,7 @@ if (process.platform !== target.nodePlatform || process.arch !== target.nodeArch
|
||||
assertOutsideRepository(repositoryRoot, outputRoot);
|
||||
|
||||
const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase();
|
||||
const temporaryRoot = join(tmpdir(), "screener-client-candidate", target.id);
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 });
|
||||
const temporaryRoot = resetBuildWorkspace(repositoryRoot, "client-package", target.id, "candidate");
|
||||
try {
|
||||
const tunnelDownload = join(temporaryRoot, target.tunnelAsset);
|
||||
await download(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFi
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { clientGoEnvironment } from "./client-package-targets.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const pinned = JSON.parse(readFileSync(join(root, "licenses", "upstream.json"), "utf8"));
|
||||
@@ -13,7 +14,7 @@ const section = (label, body) => `\n===== ${label} =====\n\n${body.trim()}\n`;
|
||||
// The packages the Vite bundle ships to the Browser. Nothing installs
|
||||
// node_modules at runtime any more, so this list is the notice contract rather
|
||||
// than package.json's dependencies field.
|
||||
const WEB_BUNDLE_PACKAGES = ["react", "react-dom", "livekit-client", "sdp-transform", "zod"];
|
||||
const WEB_BUNDLE_PACKAGES = ["react", "react-dom", "sdp-transform", "zod"];
|
||||
|
||||
// Both binaries embed the Web bundle, which serves its own notice file.
|
||||
const WEB_NOTICE_LINE = "Web dependencies: served at /third-party-licenses.txt\n";
|
||||
@@ -72,7 +73,7 @@ export function writeWebLicenseNotices(repositoryRoot, outputFile) {
|
||||
// without a recognised license filename fails closed.
|
||||
function goNotices(repositoryRoot, goCommand, target, command) {
|
||||
const options = { cwd: repositoryRoot, encoding: "utf8", windowsHide: true,
|
||||
env: { ...process.env, GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: "0" } };
|
||||
env: clientGoEnvironment(target) };
|
||||
const runGo = (args) => execFileSync(goCommand, args, options).trim();
|
||||
const goroot = runGo(["env", "GOROOT"]);
|
||||
const template = '{{if .Module}}{{if not .Module.Main}}[{{printf "%q" .Module.Path}},{{printf "%q" .Module.Version}},{{printf "%q" .Module.Dir}},{{printf "%q" .Dir}}]{{end}}{{end}}';
|
||||
@@ -139,8 +140,8 @@ export function writeClientLicenseNotices(repositoryRoot, packageRoot, goCommand
|
||||
if (target.goos === "windows" &&
|
||||
existsSync(join(packageRoot, "runtime", "native", target.captureName))) {
|
||||
const dependencies = JSON.parse(readFileSync(join(repositoryRoot, "native", "capture",
|
||||
"windows", "libvpx-dependencies.json"), "utf8"));
|
||||
const name = `libvpx@${dependencies.libvpx.version}`;
|
||||
"windows", "webrtc-dependencies.json"), "utf8"));
|
||||
const name = `webrtc@${dependencies.webrtc.version}`;
|
||||
text += section(name, pinnedNotice(name));
|
||||
}
|
||||
writeFileSync(join(packageRoot, "THIRD-PARTY-NOTICES.txt"), text + moduleSections(notices.modules));
|
||||
|
||||
@@ -67,4 +67,3 @@ scripts/check-project-state.ps1
|
||||
scripts/check-docs.mjs
|
||||
scripts/install-hooks.sh
|
||||
scripts/install-hooks.ps1
|
||||
deploy/coturn/turnserver.conf.example
|
||||
Reference in new issue
Block a user