Files
Piik/scripts/browser-codec-preflight.ts
T

385 lines
19 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { AccessToken, TrackSource } from "livekit-server-sdk";
import {
CdpConnection,
cleanupRun,
createPage,
reservePort,
waitForVersion,
type PageHandle,
} from "./native-one-viewer-gate";
const WIDTH = 1_920;
const HEIGHT = 1_080;
const FRAME_RATE = 30;
const MAX_BITRATE = 5_000_000;
const SETTLE_MS = 5_000;
const SAMPLE_MS = 10_000;
interface RuntimeResult<T> {
result: { value?: T };
exceptionDetails?: {
text?: string;
exception?: { description?: string };
};
}
interface ProcessInfo {
type: string;
id: number;
cpuTime: number;
}
interface ProcessSnapshot {
processInfo: ProcessInfo[];
}
async function evaluate<T>(
cdp: CdpConnection,
page: PageHandle,
expression: string,
timeoutMs: number,
): Promise<T> {
const value = await cdp.call<RuntimeResult<T>>(
"Runtime.evaluate",
{
expression,
awaitPromise: true,
returnByValue: true,
userGesture: true,
},
page.sessionId,
Date.now() + timeoutMs,
);
if (value.exceptionDetails) {
throw new Error(
value.exceptionDetails.exception?.description ??
value.exceptionDetails.text ??
"Browser evaluation failed",
);
}
return value.result.value as T;
}
function sourceHtml(): string {
return `<!doctype html><title>Codec Preflight Source</title>
<style>html,body{margin:0;overflow:hidden;background:#000}canvas{display:block;width:100vw;height:100vh}</style>
<canvas width="${WIDTH}" height="${HEIGHT}"></canvas><script>
const c=document.querySelector('canvas'),x=c.getContext('2d',{alpha:false});let f=0;
function draw(){f+=1;for(let r=0;r<14;r+=1)for(let q=0;q<24;q+=1){
x.fillStyle='hsl('+((f*29+r*71+q*43)%360)+' 80% '+(35+(f+r+q)%30)+'%)';
x.fillRect(q*80,r*78,80,78)}x.fillStyle='#fff';x.fillRect(f*13%1920,0,12,1080);
x.fillStyle='#000';x.font='60px monospace';x.fillText(String(f).padStart(8,'0'),24,72)}
draw();setInterval(draw,1000/${FRAME_RATE});</script>`;
}
async function startServer(port: number): Promise<Server> {
const livekitBundle = await readFile(
resolve("node_modules/livekit-client/dist/livekit-client.umd.js"),
);
const server = createServer((request, response) => {
const path = new URL(request.url ?? "/", `http://127.0.0.1:${port}`).pathname;
if (path === "/livekit.js") {
response.setHeader("content-type", "text/javascript; charset=utf-8");
response.end(livekitBundle);
return;
}
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(path === "/source"
? sourceHtml()
: "<!doctype html><title>Codec Preflight</title><script src='/livekit.js'></script><video autoplay muted playsinline style='width:960px'></video>");
});
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(port, "127.0.0.1", resolveListen);
});
return server;
}
function browserProbe(): string {
return `(() => {
const WIDTH=${WIDTH},HEIGHT=${HEIGHT},FRAME_RATE=${FRAME_RATE},MAX_BITRATE=${MAX_BITRATE};
const SETTLE_MS=${SETTLE_MS},SAMPLE_MS=${SAMPLE_MS};let stream=null,track=null;
const delay=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));
async function waitFor(predicate,timeout,label){const deadline=performance.now()+timeout;
while(performance.now()<deadline){if(await predicate())return;await delay(50)}
throw new Error('Timed out waiting for '+label)}
function preferences(codec){const all=RTCRtpSender.getCapabilities('video')?.codecs||[];
const mime=codec==='h264'?'video/h264':'video/vp8';const primary=all.filter((item)=>item.mimeType.toLowerCase()===mime);
if(!primary.length)throw new Error(mime+' unavailable');
const repair=new Set(['video/rtx','video/red','video/ulpfec','video/flexfec-03']);
return [...primary,...all.filter((item)=>repair.has(item.mimeType.toLowerCase()))]}
async function gathered(pc){if(pc.iceGatheringState==='complete')return;
await new Promise((resolve)=>{const changed=()=>{if(pc.iceGatheringState!=='complete')return;
pc.removeEventListener('icegatheringstatechange',changed);resolve()};pc.addEventListener('icegatheringstatechange',changed)})}
async function negotiate(a,b){await a.setLocalDescription(await a.createOffer());await gathered(a);
await b.setRemoteDescription(a.localDescription);await b.setLocalDescription(await b.createAnswer());
await gathered(b);await a.setRemoteDescription(b.localDescription)}
function statsValue(item){if(!item)return null;const keys=['id','timestamp','bytesSent','bytesReceived',
'framesEncoded','framesDecoded','framesDropped','framesPerSecond','frameWidth','frameHeight','totalEncodeTime',
'totalDecodeTime','qualityLimitationReason','qualityLimitationDurations','encoderImplementation',
'decoderImplementation','powerEfficientEncoder','powerEfficientDecoder','frames'];const value={};
for(const key of keys)value[key]=item[key]??null;return value}
async function snapshot(sender,receiver,rendered){const [sendReport,receiveReport]=await Promise.all([sender.getStats(),receiver.getStats()]);
let outbound=null,inbound=null;sendReport.forEach((item)=>{if(item.type==='outbound-rtp'&&item.kind==='video'&&!item.isRemote)outbound=item});
receiveReport.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video'&&!item.isRemote)inbound=item});
const sendCodec=outbound?.codecId?sendReport.get(outbound.codecId):null;
const receiveCodec=inbound?.codecId?receiveReport.get(inbound.codecId):null;
const mediaSource=outbound?.mediaSourceId?sendReport.get(outbound.mediaSourceId):null;
const codec=(item)=>item?{mimeType:item.mimeType||null,sdpFmtpLine:item.sdpFmtpLine||null}:null;
return {rendered:rendered(),sender:statsValue(outbound),receiver:statsValue(inbound),
mediaSource:statsValue(mediaSource),senderCodec:codec(sendCodec),receiverCodec:codec(receiveCodec)}}
function delta(after,before,key){const a=after?.[key],b=before?.[key];return Number.isFinite(a)&&Number.isFinite(b)&&a>=b?a-b:null}
function summarize(before,after,sampleMs){const encoded=delta(after.sender,before.sender,'framesEncoded');
const decoded=delta(after.receiver,before.receiver,'framesDecoded');const bytes=delta(after.sender,before.sender,'bytesSent');
const sourceFrames=delta(after.mediaSource,before.mediaSource,'frames');
const encodeTime=delta(after.sender,before.sender,'totalEncodeTime');const decodeTime=delta(after.receiver,before.receiver,'totalDecodeTime');
return {senderCodec:after.senderCodec,receiverCodec:after.receiverCodec,sender:after.sender,receiver:after.receiver,
measured:{sampleMs,encodedFps:encoded===null?null:encoded*1000/sampleMs,
decodedFps:decoded===null?null:decoded*1000/sampleMs,bitrateKbps:bytes===null?null:bytes*8/sampleMs,
encodeMsPerFrame:encodeTime!==null&&encoded>0?encodeTime*1000/encoded:null,
decodeMsPerFrame:decodeTime!==null&&decoded>0?decodeTime*1000/decoded:null,
sourceFps:sourceFrames===null?after.mediaSource?.framesPerSecond:sourceFrames*1000/sampleMs,
encodedSourceRatio:sourceFrames>0&&encoded!==null?encoded/sourceFrames:null,
renderedFrames:delta(after,before,'rendered')}}}
async function setup(){stream=await navigator.mediaDevices.getDisplayMedia({video:{
width:{ideal:WIDTH,max:WIDTH},height:{ideal:HEIGHT,max:HEIGHT},frameRate:{ideal:FRAME_RATE,max:FRAME_RATE}},audio:false});
track=stream.getVideoTracks()[0];if(!track)throw new Error('No display video track');track.contentHint='motion';
return {settings:track.getSettings(),constraints:track.getConstraints(),contentHint:track.contentHint,label:track.label}}
function capabilities(){const simple=(codec)=>({mimeType:codec.mimeType,sdpFmtpLine:codec.sdpFmtpLine||null});
return {sender:(RTCRtpSender.getCapabilities('video')?.codecs||[]).map(simple),
receiver:(RTCRtpReceiver.getCapabilities('video')?.codecs||[]).map(simple)}}
async function edge(codec,sourceTrack){
const sendPc=new RTCPeerConnection({iceServers:[]}),receivePc=new RTCPeerConnection({iceServers:[]});
const transceiver=sendPc.addTransceiver(sourceTrack,{direction:'sendonly'});transceiver.setCodecPreferences(preferences(codec));
let receiver=null,remoteTrack=null;receivePc.addEventListener('track',(event)=>{receiver=event.receiver;remoteTrack=event.track});
await negotiate(sendPc,receivePc);await waitFor(()=>sendPc.connectionState==='connected'&&receivePc.connectionState==='connected',10000,'connection');
await waitFor(()=>receiver&&remoteTrack,5000,'remote track');const video=document.createElement('video');let rendered=0;
video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video);
video.srcObject=new MediaStream([remoteTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)}
await video.play();const parameters=transceiver.sender.getParameters();if(!parameters.encodings.length)parameters.encodings=[{}];
parameters.encodings[0].maxBitrate=MAX_BITRATE;parameters.encodings[0].maxFramerate=FRAME_RATE;
parameters.encodings[0].scaleResolutionDownBy=1;parameters.degradationPreference='balanced';await transceiver.sender.setParameters(parameters);
await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},10000,'decoded frame');
return {sendPc,receivePc,sender:transceiver.sender,receiver,remoteTrack,rendered:()=>rendered}}
async function run(codec,count,topology){if(!track)throw new Error('Capture not initialized');const edges=[];
try{edges.push(await edge(codec,track));
if(topology==='relay')edges.push(await edge(codec,edges[0].remoteTrack));
else for(let index=1;index<count;index+=1)edges.push(await edge(codec,track));await delay(SETTLE_MS);
const before=await Promise.all(edges.map((item)=>snapshot(item.sender,item.receiver,item.rendered))),started=performance.now();
await delay(SAMPLE_MS);const sampleMs=performance.now()-started;
const after=await Promise.all(edges.map((item)=>snapshot(item.sender,item.receiver,item.rendered)));
return edges.map((_,index)=>summarize(before[index],after[index],sampleMs))}
finally{for(const item of edges){item.sendPc.close();item.receivePc.close()}await delay(300)}}
async function runLivekit(codec,url,publisherToken,subscriberToken){if(!track)throw new Error('Capture not initialized');
const sdk=globalThis.LivekitClient,publisher=new sdk.Room({dynacast:true}),subscriber=new sdk.Room();let remoteTrack=null,rendered=0;
subscriber.on(sdk.RoomEvent.TrackSubscribed,(nextTrack)=>{if(nextTrack.kind==='video')remoteTrack=nextTrack});
try{await publisher.connect(url,publisherToken,{autoSubscribe:false,rtcConfig:{iceServers:[]}});
await subscriber.connect(url,subscriberToken,{rtcConfig:{iceServers:[]}});
const publication=await publisher.localParticipant.publishTrack(track,{source:sdk.Track.Source.ScreenShare,
videoCodec:codec,backupCodec:false,screenShareEncoding:{maxBitrate:MAX_BITRATE,maxFramerate:FRAME_RATE},
degradationPreference:'balanced'});
await waitFor(()=>publication.videoTrack?.sender&&remoteTrack,15000,'LiveKit publication');
const video=document.createElement('video');video.autoplay=true;video.muted=true;video.playsInline=true;document.body.append(video);
video.srcObject=new MediaStream([remoteTrack.mediaStreamTrack]);if(video.requestVideoFrameCallback){const frame=()=>{rendered+=1;video.requestVideoFrameCallback(frame)};video.requestVideoFrameCallback(frame)}
await video.play();const receiver={getStats:async()=>{const report=await remoteTrack.getRTCStatsReport();if(!report)throw new Error('LiveKit receiver stats unavailable');return report}};
await waitFor(async()=>{const report=await receiver.getStats();let frames=0;report?.forEach((item)=>{if(item.type==='inbound-rtp'&&item.kind==='video')frames=Math.max(frames,item.framesDecoded||0)});return frames>0&&rendered>0},15000,'LiveKit decoded frame');
await delay(SETTLE_MS);const sender=publication.videoTrack.sender;
const before=await snapshot(sender,receiver,()=>rendered),started=performance.now();await delay(SAMPLE_MS);
const sampleMs=performance.now()-started,after=await snapshot(sender,receiver,()=>rendered);
return {...summarize(before,after,sampleMs),senderParameters:sender.getParameters()}}
finally{await Promise.allSettled([publisher.disconnect(false),subscriber.disconnect(false)]);await delay(300)}}
function stop(){stream?.getTracks().forEach((item)=>item.stop())}
Object.defineProperty(globalThis,'__CODEC_PREFLIGHT__',{value:{setup,capabilities,run,runLivekit,stop}});
})()`;
}
async function processSnapshot(cdp: CdpConnection): Promise<ProcessSnapshot> {
return cdp.call<ProcessSnapshot>(
"SystemInfo.getProcessInfo",
{},
undefined,
Date.now() + 5_000,
);
}
function processCpu(
before: ProcessSnapshot,
after: ProcessSnapshot,
wallMs: number,
): Record<string, unknown> {
const baseline = new Map(
before.processInfo.map((item) => [`${item.type}:${item.id}`, item.cpuTime]),
);
let cpuSeconds = 0;
let stableProcesses = 0;
for (const item of after.processInfo) {
const previous = baseline.get(`${item.type}:${item.id}`);
if (previous === undefined || item.cpuTime < previous) continue;
cpuSeconds += item.cpuTime - previous;
stableProcesses += 1;
}
const wallSeconds = wallMs / 1_000;
return {
wallSeconds,
cpuSeconds,
logicalCoreEquivalent: cpuSeconds / wallSeconds,
stableProcesses,
processIdentityChanged:
stableProcesses !== before.processInfo.length ||
stableProcesses !== after.processInfo.length,
};
}
async function main(): Promise<void> {
const chromePath = process.env.CHROME_PATH?.trim();
if (!chromePath) throw new Error("CHROME_PATH must point to Chrome");
const codec = process.env.CODEC_PREFLIGHT_CODEC?.trim().toLowerCase();
if (codec !== "vp8" && codec !== "h264") {
throw new Error("CODEC_PREFLIGHT_CODEC must be vp8 or h264");
}
const edges = Number(process.env.CODEC_PREFLIGHT_EDGES ?? "1");
if (edges !== 1 && edges !== 2) {
throw new Error("CODEC_PREFLIGHT_EDGES must be 1 or 2");
}
const topology = process.env.CODEC_PREFLIGHT_TOPOLOGY?.trim() || "direct";
if (topology !== "direct" && topology !== "relay" && topology !== "livekit") {
throw new Error("CODEC_PREFLIGHT_TOPOLOGY must be direct, relay, or livekit");
}
const [debugPort, pagePort] = await Promise.all([reservePort(), reservePort()]);
const profile = await mkdtemp(join(tmpdir(), "screener-codec-preflight-"));
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let server: Server | null = null;
try {
server = await startServer(pagePort);
chrome = spawn(chromePath, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--autoplay-policy=no-user-gesture-required",
"--auto-select-tab-capture-source-by-title=Codec Preflight Source",
"--window-size=1920,1080",
"about:blank",
], { stdio: "pipe", windowsHide: true });
chrome.stdout.resume();
chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5_000);
const gpuInfo = await cdp.call<unknown>("SystemInfo.getInfo", {}, undefined, Date.now() + 5_000);
await createPage(cdp, `http://127.0.0.1:${pagePort}/source`, undefined, true);
const page = await createPage(cdp, `http://127.0.0.1:${pagePort}/test`, undefined, true);
await evaluate<void>(cdp, page, browserProbe(), 5_000);
const capabilities = await evaluate<unknown>(
cdp,
page,
"globalThis.__CODEC_PREFLIGHT__.capabilities()",
5_000,
);
const capture = await evaluate<unknown>(
cdp,
page,
"globalThis.__CODEC_PREFLIGHT__.setup()",
15_000,
);
const before = await processSnapshot(cdp);
const started = performance.now();
let media: unknown;
if (topology === "livekit") {
const livekitUrl = process.env.CODEC_PREFLIGHT_LIVEKIT_URL?.trim();
const apiKey = process.env.CODEC_PREFLIGHT_LIVEKIT_KEY?.trim();
const apiSecret = process.env.CODEC_PREFLIGHT_LIVEKIT_SECRET?.trim();
if (!livekitUrl || !apiKey || !apiSecret) {
throw new Error("LiveKit preflight credentials are missing");
}
const room = `codec-preflight-${Date.now()}`;
const token = async (identity: string, publish: boolean) => {
const access = new AccessToken(apiKey, apiSecret, { identity, ttl: 60 });
access.addGrant({
roomJoin: true,
room,
canPublish: publish,
canPublishSources: publish ? [TrackSource.SCREEN_SHARE] : undefined,
canSubscribe: !publish,
canPublishData: false,
});
return access.toJwt();
};
const [publisherToken, subscriberToken] = await Promise.all([
token("host", true),
token("viewer", false),
]);
media = await evaluate<unknown>(
cdp,
page,
`globalThis.__CODEC_PREFLIGHT__.runLivekit(${JSON.stringify(codec)}, ${JSON.stringify(livekitUrl)}, ${JSON.stringify(publisherToken)}, ${JSON.stringify(subscriberToken)})`,
60_000,
);
} else {
media = await evaluate<unknown>(
cdp,
page,
`globalThis.__CODEC_PREFLIGHT__.run(${JSON.stringify(codec)}, ${edges}, ${JSON.stringify(topology)})`,
45_000,
);
}
const wallMs = performance.now() - started;
const after = await processSnapshot(cdp);
await evaluate<void>(cdp, page, "globalThis.__CODEC_PREFLIGHT__.stop()", 5_000);
const report = {
schemaVersion: 1,
browser: version.Browser,
configuration: {
width: WIDTH,
height: HEIGHT,
frameRate: FRAME_RATE,
maxBitrate: MAX_BITRATE,
contentHint: "motion",
settleMs: SETTLE_MS,
sampleMs: SAMPLE_MS,
edges,
topology,
},
gpuInfo,
capabilities,
capture,
codec,
media,
cpu: processCpu(before, after, wallMs),
};
const outputDirectory = resolve("benchmark-results");
await mkdir(outputDirectory, { recursive: true });
const output = join(
outputDirectory,
`browser-codec-preflight-${codec}-${topology}-${edges}.json`,
);
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(output);
} finally {
await cleanupRun({
cdp,
native: null,
chrome,
server: null,
profile,
ports: [debugPort],
});
if (server) {
await new Promise<void>((resolveClose) => server!.close(() => resolveClose()));
}
}
}
await main();