Files
kvm/ui/benchmarks/sweep-benchmark.spec.ts
Adam Shiervani cb7746fb78 feat(video): add H.265 codec support with auto-negotiation (#1371)
* feat(video): add H.265 codec support with auto-negotiation

Add H.265 (HEVC) encoding support to the RV1106 hardware encoder alongside
existing H.264. The codec is negotiated per-WebRTC session based on browser
capabilities.

- Add codec preference setting (Auto/H.265/H.264) to config, RPC, and UI
- Auto mode inspects the browser's SDP offer and prefers H.265 when supported,
  with graceful fallback to H.264 for browsers without H.265 (e.g. Firefox)
- Move WebRTC video track creation from newSession() to ExchangeOffer() so
  the codec can be resolved after seeing the browser's offer
- Set encoder codec type in onFirstSessionConnected() before VideoStart()
- Show active codec in the status bar when troubleshooting mode is enabled
- Remove quality factor >1.0 ceiling from ctrl.c to allow bitrate testing
- Fix Go wrapper to check return value from C quality factor setter
- Add e2e tests: video quality bitrate measurement, codec negotiation,
  codec preference persistence, and a quality factor sweep benchmark
- Add visual noise helpers (remote host terminal) to e2e test infrastructure

* chore(e2e): remove video quality benchmark tests and helpers

Remove video-quality-sweep and video-quality spec files — these are
benchmarking tools, not regression tests. Also removes the visual noise
helpers and hardcoded developer SSH address from helpers.ts.

* feat(video): bump bitrate cap to 4000 kbps and tighten VBR ceiling

- Increase base_bitrate_high from 2000 to 4000 kbps, giving users
  better image quality at every quality factor setting.
- Tighten VBR max_bitrate from 2x to 1.5x target, reducing encoder
  overshoot while still allowing headroom for dynamic content.
- Add frames dropped, decode time, freeze count to WebRTC test hooks
  for pipeline health monitoring.
- Move bitrate sweep benchmark to ui/benchmarks/ with its own
  playwright config, separate from the e2e test suite.

Sweep results (visual noise, H.264, 1080p):
  factor=0.1: 3082 kbps, 60fps, 0 dropped, 2.9ms decode
  factor=0.5: 6357 kbps, 60fps, 0 dropped, 3.6ms decode
  factor=1.0: 9445 kbps, 59fps, 0 dropped, 4.3ms decode
2026-03-29 21:34:38 +02:00

192 lines
6.4 KiB
TypeScript

/**
* Bitrate sweep benchmark.
*
* Run: JETKVM_URL=http://<kvm-ip> JETKVM_REMOTE_HOST=<user@host-ip> npx playwright test --config=benchmarks/playwright.config.ts
*/
import { test, expect, type Page } from "@playwright/test";
import { execSync } from "child_process";
import { ensureLocalAuthMode, waitForWebRTCReady, callJsonRpc } from "../e2e/helpers";
const REMOTE_HOST = process.env.JETKVM_REMOTE_HOST ?? "tony@192.168.1.180";
const SSH_OPTS = "-o StrictHostKeyChecking=no -o ConnectTimeout=5";
function remoteSSH(cmd: string): string {
return execSync(`ssh ${SSH_OPTS} ${REMOTE_HOST} ${JSON.stringify(cmd)}`, {
timeout: 10000,
})
.toString()
.trim();
}
function startVisualNoise(): void {
remoteSSH(
"DISPLAY=:0 gnome-terminal --full-screen -- bash -c 'while true; do head -c 2000 /dev/urandom | base64; sleep 0.05; done' &>/dev/null &",
);
}
function stopVisualNoise(): void {
try {
remoteSSH(
"pkill -f 'head -c 2000 /dev/urandom' || true; wmctrl -c :ACTIVE: 2>/dev/null || true",
);
} catch {
// ignore
}
}
interface Measurement {
factor: number;
targetKbps: number;
actualKbps: number;
jitterBufMs: number;
fps: number;
dropped: number;
avgDecodeMs: number;
freezes: number;
freezeDurationMs: number;
}
async function measureAtFactor(page: Page, factor: number): Promise<Measurement> {
await callJsonRpc(page, "setStreamQualityFactor", { factor });
// Fresh WebRTC session — resets jitter buffer state
await page.goto("about:blank");
await page.waitForTimeout(500);
await page.goto("/");
await page.waitForLoadState("networkidle");
await ensureLocalAuthMode(page, { mode: "noPassword" });
await waitForWebRTCReady(page);
// Wait for encoder to stabilize at new bitrate
await page.waitForTimeout(3000);
const snap1 = await page.evaluate(() => window.__kvmTestHooks?.getInboundVideoStats());
expect(snap1).not.toBeNull();
await page.waitForTimeout(5000);
const snap2 = await page.evaluate(() => window.__kvmTestHooks?.getInboundVideoStats());
expect(snap2).not.toBeNull();
const deltaBytes = snap2!.bytesReceived - snap1!.bytesReceived;
const deltaSec = (snap2!.timestamp - snap1!.timestamp) / 1000;
const deltaDelay = snap2!.jitterBufferDelay - snap1!.jitterBufferDelay;
const deltaEmitted = snap2!.jitterBufferEmittedCount - snap1!.jitterBufferEmittedCount;
const deltaDecoded = snap2!.framesDecoded - snap1!.framesDecoded;
const deltaDecodeTime = snap2!.totalDecodeTime - snap1!.totalDecodeTime;
// target = 512 + (4000 - 512) * factor
const targetKbps = 512 + Math.round((4000 - 512) * factor);
return {
factor,
targetKbps,
actualKbps: (deltaBytes * 8) / 1000 / deltaSec,
jitterBufMs: deltaEmitted > 0 ? (deltaDelay / deltaEmitted) * 1000 : -1,
fps: snap2!.framesPerSecond,
dropped: snap2!.framesDropped - snap1!.framesDropped,
avgDecodeMs: deltaDecoded > 0 ? (deltaDecodeTime / deltaDecoded) * 1000 : -1,
freezes: snap2!.freezeCount - snap1!.freezeCount,
freezeDurationMs: (snap2!.totalFreezesDuration - snap1!.totalFreezesDuration) * 1000,
};
}
function pad(s: string, n: number): string {
return s.padStart(n);
}
test.describe("Bitrate sweep benchmark", () => {
test.setTimeout(600_000); // 10 min
test("sweep: 4000kbps cap, 1.5x VBR ceiling", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await ensureLocalAuthMode(page, { mode: "noPassword" });
await waitForWebRTCReady(page);
const originalQuality = (await callJsonRpc(page, "getStreamQualityFactor")) as number;
startVisualNoise();
await page.waitForTimeout(3000);
const factors = [0.1, 0.3, 0.5, 0.7, 1.0, 0.7, 0.5, 0.3, 0.1];
const results: Measurement[] = [];
try {
const header =
pad("factor", 7) +
pad("target", 10) +
pad("actual", 10) +
pad("fps", 5) +
pad("dropped", 9) +
pad("decode", 9) +
pad("freezes", 9) +
pad("jitter", 10);
console.log("\n" + "=".repeat(header.length));
console.log("BITRATE SWEEP — Visual noise on remote host");
console.log("=".repeat(header.length));
console.log(header);
console.log("-".repeat(header.length));
for (const factor of factors) {
const m = await measureAtFactor(page, factor);
results.push(m);
console.log(
pad(m.factor.toFixed(2), 7) +
pad(`${m.targetKbps} kb`, 10) +
pad(`${m.actualKbps.toFixed(0)} kb`, 10) +
pad(m.fps.toFixed(0), 5) +
pad(m.dropped.toString(), 9) +
pad(`${m.avgDecodeMs.toFixed(1)}ms`, 9) +
pad(m.freezes.toString(), 9) +
pad(`${m.jitterBufMs.toFixed(1)}ms`, 10),
);
}
// Summary
console.log("\n" + "=".repeat(header.length));
console.log("SUMMARY");
console.log("=".repeat(header.length));
const max = results.reduce((a, b) => (a.actualKbps > b.actualKbps ? a : b));
const min = results.reduce((a, b) => (a.actualKbps < b.actualKbps ? a : b));
console.log(
`Max (${max.factor}): ${max.actualKbps.toFixed(0)} kbps, ${max.fps.toFixed(0)} fps, ${max.dropped} dropped, ${max.avgDecodeMs.toFixed(1)}ms decode, ${max.freezes} freezes`,
);
console.log(
`Min (${min.factor}): ${min.actualKbps.toFixed(0)} kbps, ${min.fps.toFixed(0)} fps, ${min.dropped} dropped, ${min.avgDecodeMs.toFixed(1)}ms decode, ${min.freezes} freezes`,
);
console.log(`Dynamic range: ${(max.actualKbps / min.actualKbps).toFixed(1)}x bitrate`);
const totalDropped = results.reduce((s, r) => s + r.dropped, 0);
const totalFreezes = results.reduce((s, r) => s + r.freezes, 0);
console.log(
`\nHealth: ${totalDropped} total frames dropped, ${totalFreezes} total freezes across all factors`,
);
if (totalDropped === 0 && totalFreezes === 0) {
console.log("✓ Pipeline healthy — no drops or freezes at any factor");
}
// Find first factor with drops
const firstDrop = results.find(r => r.dropped > 0);
if (firstDrop) {
console.log(
`⚠ First drops at factor ${firstDrop.factor}: ${firstDrop.dropped} frames dropped, ${firstDrop.actualKbps.toFixed(0)} kbps`,
);
}
expect(results.length).toBe(factors.length);
} finally {
stopVisualNoise();
await callJsonRpc(page, "setStreamQualityFactor", { factor: originalQuality });
}
});
});