benchmarks/webcodecs/benchmarks.js (355 lines of code) (raw):
const DEFAULT_PARAMS = {
width: 640,
height: 480,
bitrate: 1000000, // 1 Mbps
framerate: 30,
latencyModes: ["realtime", "quality"],
codecList: [
{ codec: "av01.0.04M.08" },
{ codec: "vp8" },
{ codec: "vp09.00.10.08" },
// H264 baseline
{ codec: "avc1.42001E", avc: { format: "avc" } },
{ codec: "avc1.42001E", avc: { format: "annexb" } }
]
};
addTestsLoader(async function() {
const { configList, width, height, framerate, bitrate } = parseParamsFromURL();
let testcases = [];
for (const c of configList) {
let config = {
...c, // latencyMode and avc should have been set
width,
height,
bitrate,
framerate,
};
const support = await VideoEncoder.isConfigSupported(config);
if (support.supported) {
let name = `${config.codec}`;
if (config.avc) {
name += ` (${config.avc.format})`;
}
if (config.latencyMode == "realtime") {
testcases.push({
name: `${name} realtime encode`,
func: async function() {
return await realtimeEncodeTest(config);
}
});
}
if (config.latencyMode == "quality") {
testcases.push({
name: `${name} quality encode`,
func: async function() {
return await qualityEncodeTest(config);
}
});
}
}
}
return testcases;
});
function parseParamsFromURL() {
const params = new URLSearchParams(window.location.search);
const framerate = params.has("framerate")
? Math.max(parseInt(params.get("framerate")), 0) || DEFAULT_PARAMS.framerate
: DEFAULT_PARAMS.framerate;
const bitrate = params.has("bitrate")
? Math.max(parseInt(params.get("bitrate")), 0) || DEFAULT_PARAMS.bitrate
: DEFAULT_PARAMS.bitrate;
let width = DEFAULT_PARAMS.width;
let height = DEFAULT_PARAMS.height;
const w = parseInt(params.get("width"));
const h = parseInt(params.get("height"));
if (w > 0 && h > 0) {
width = w;
height = h;
}
const latencyModes = params
.get("latencyModes")
?.split(",")
.filter(mode => mode === "realtime" || mode === "quality") || DEFAULT_PARAMS.latencyModes;
const configList = params.has("codecs")
? params.get("codecs").split(",").flatMap(codecString => {
const [codec, format] = codecString.split(":");
const config = format ? { codec, avc: { format } } : { codec };
return latencyModes.map(latencyMode => ({ ...config, latencyMode }));
})
: DEFAULT_PARAMS.codecList.flatMap(config =>
latencyModes.map(latencyMode => ({ ...config, latencyMode }))
);
return { configList, width, height, framerate, bitrate };
}
async function realtimeEncodeTest(config) {
const fps = 30;
const totalDuration = 3000; // ms
const keyFrameInterval = 15; // 1 key every 15 frames
const worker = new Worker("encoder-worker.js");
config.latencyMode = "realtime";
configureEncoder(worker, config);
const canvas = createCanvas(config.width, config.height);
await encodeCanvas(worker, canvas, fps, totalDuration, keyFrameInterval);
let { encodeTimes, outputTimes } = await getEncoderResults(worker);
let results = { key: {}, delta: {} };
results.key.encodeTimes = encodeTimes.filter(x => x.type == "key");
results.delta.encodeTimes = encodeTimes.filter(x => x.type != "key");
results.key.outputTimes = outputTimes.filter(x => x.type == "key");
results.delta.outputTimes = outputTimes.filter(x => x.type != "key");
results.key.frameDroppingRate =
(results.key.encodeTimes.length - results.key.outputTimes.length) /
results.key.encodeTimes.length * 100;
results.delta.frameDroppingRate =
(results.delta.encodeTimes.length - results.delta.outputTimes.length) /
results.delta.encodeTimes.length * 100;
results.key.roundTripTimes = calculateRoundTripTimes(
results.key.encodeTimes,
results.key.outputTimes
);
results.key.roundTripResult = getMeanAndStandardDeviation(
results.key.roundTripTimes.map(x => x.time)
);
results.delta.roundTripTimes = calculateRoundTripTimes(
results.delta.encodeTimes,
results.delta.outputTimes
);
results.delta.roundTripResult = getMeanAndStandardDeviation(
results.delta.roundTripTimes.map(x => x.time)
);
removeCanvas(canvas);
worker.terminate();
return {
"frame-to-frame mean (key)": {
value: results.key.roundTripResult.mean,
unit: "ms",
},
"frame-to-frame cv (key)": {
value: results.key.roundTripResult.cv * 100,
unit: "%",
},
"frame-dropping rate (key)": {
value: results.key.frameDroppingRate,
unit: "%",
},
"frame-to-frame mean (non key)": {
value: results.delta.roundTripResult.mean,
unit: "ms",
},
"frame-to-frame cv (non key)": {
value: results.delta.roundTripResult.cv * 100,
unit: "%",
},
"frame-dropping rate (non key)": {
value: results.delta.frameDroppingRate,
unit: "%",
},
};
}
async function qualityEncodeTest(config) {
const fps = 30;
const totalDuration = 3000; // ms
const keyFrameInterval = 15; // 1 key every 15 frames
const worker = new Worker("encoder-worker.js");
config.latencyMode = "quality";
configureEncoder(worker, config);
const canvas = createCanvas(config.width, config.height);
await encodeCanvas(worker, canvas, fps, totalDuration, keyFrameInterval);
let { encodeTimes, outputTimes } = await getEncoderResults(worker);
let duration = getTotalDuration(encodeTimes, outputTimes);
removeCanvas(canvas);
worker.terminate();
return {
"first encode to last output": {
value: duration,
unit: "ms",
},
};
}
function createCanvas(width, height) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
document.body.appendChild(canvas);
return canvas;
}
function removeCanvas(canvas) {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
document.body.removeChild(canvas);
}
function drawClock(canvas) {
const ctx = canvas.getContext("2d");
ctx.save();
ctx.fillStyle = "#dfdacd";
ctx.fillRect(0, 0, canvas.width, canvas.height);
let radius = canvas.height / 2;
ctx.translate(radius, radius);
radius = radius * 0.7;
drawFace(ctx, radius);
markHours(ctx, radius);
markMinutes(ctx, radius);
drawTime(ctx, radius);
ctx.restore();
}
function drawFace(ctx, radius) {
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, radius, 0, 2 * Math.PI);
ctx.fillStyle = "#feefde";
ctx.fill();
ctx.strokeStyle = "#6e6d6e";
ctx.lineWidth = radius * 0.1;
ctx.stroke();
ctx.restore();
}
function markHours(ctx, radius) {
ctx.save();
ctx.strokeStyle = "#947360";
ctx.lineWidth = radius * 0.05;
for (let i = 0; i < 12; i++) {
ctx.beginPath();
ctx.rotate(Math.PI / 6);
ctx.moveTo(radius * 0.7, 0);
ctx.lineTo(radius * 0.9, 0);
ctx.stroke();
}
ctx.restore();
}
function markMinutes(ctx, radius) {
ctx.save();
ctx.strokeStyle = "#947360";
ctx.lineWidth = radius * 0.01;
for (let i = 0; i < 60; i++) {
if (i % 5 !== 0) {
ctx.beginPath();
ctx.moveTo(radius * 0.8, 0);
ctx.lineTo(radius * 0.85, 0);
ctx.stroke();
}
ctx.rotate(Math.PI / 30);
}
ctx.restore();
}
function drawTime(ctx, radius) {
ctx.save();
const now = new Date();
let hour = now.getHours();
let minute = now.getMinutes();
let second = now.getSeconds() + now.getMilliseconds() / 1000;
hour = hour % 12;
hour =
(hour * Math.PI) / 6 +
(minute * Math.PI) / (6 * 60) +
(second * Math.PI) / (360 * 60);
drawHand(ctx, hour, radius * 0.5, radius * 0.07, "#a1afa0");
minute = (minute * Math.PI) / 30 + (second * Math.PI) / (30 * 60);
drawHand(ctx, minute, radius * 0.8, radius * 0.07, "#a1afa0");
second = (second * Math.PI) / 30;
drawHand(ctx, second, radius * 0.9, radius * 0.02, "#970c10");
ctx.restore();
}
function drawHand(ctx, pos, length, width, color = "black") {
ctx.save();
ctx.strokeStyle = color;
ctx.beginPath();
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.moveTo(0, 0);
ctx.rotate(pos);
ctx.lineTo(0, -length);
ctx.stroke();
ctx.rotate(-pos);
ctx.restore();
}
function configureEncoder(worker, config) {
worker.postMessage({
command: "configure",
config,
});
}
async function encodeCanvas(
worker,
canvas,
fps,
totalDuration,
keyFrameIntervalInFrames
) {
const frameDuration = Math.round(1000 / fps); // ms
let encodeDuration = 0;
let frameCount = 0;
let intervalId;
return new Promise((resolve, _) => {
// first callback happens after frameDuration.
intervalId = setInterval(() => {
if (encodeDuration > totalDuration) {
clearInterval(intervalId);
resolve(encodeDuration);
return;
}
drawClock(canvas);
const frame = new VideoFrame(canvas, { timestamp: encodeDuration });
worker.postMessage({
command: "encode",
frame,
isKey: frameCount % keyFrameIntervalInFrames == 0,
});
frameCount += 1;
encodeDuration += frameDuration;
frame.close();
}, frameDuration);
});
}
async function getEncoderResults(worker) {
worker.postMessage({ command: "flush" });
return new Promise((resolve, _) => {
worker.onmessage = event => {
if (event.data.command === "result") {
const { encodeTimes, outputTimes } = event.data;
resolve({ encodeTimes, outputTimes });
}
};
});
}
function getTotalDuration(encodeTimes, outputTimes) {
if (!outputTimes.length || encodeTimes.length < outputTimes.length) {
return Infinity;
}
return outputTimes[outputTimes.length - 1].time - encodeTimes[0].time;
}
function calculateRoundTripTimes(encodeTimes, outputTimes) {
let roundTripTimes = [];
let encodeIndex = 0;
let outputIndex = 0;
while (
encodeIndex < encodeTimes.length &&
outputIndex < outputTimes.length
) {
const encodeEntry = encodeTimes[encodeIndex];
const outputEntry = outputTimes[outputIndex];
if (encodeEntry.timestamp === outputEntry.timestamp) {
const roundTripTime = outputEntry.time - encodeEntry.time;
roundTripTimes.push({
timestamp: outputEntry.timestamp,
time: roundTripTime,
});
encodeIndex++;
outputIndex++;
} else if (encodeEntry.timestamp < outputEntry.timestamp) {
encodeIndex++;
} else {
outputIndex++;
}
}
return roundTripTimes;
}
function getMeanAndStandardDeviation(values) {
if (!values.length) {
return { mean: 0, stddev: 0, cv: 0 };
}
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const stddev = Math.sqrt(
values.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b) /
values.length
);
const cv = stddev / mean;
return { mean, stddev, cv };
}