mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-31 01:51:53 +08:00
567 lines
19 KiB
Python
567 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
One-KVM benchmark script (Windows-friendly).
|
|
|
|
Measures FPS + CPU usage across:
|
|
- input pixel formats (capture card formats)
|
|
- output codecs (mjpeg/h264/h265/vp8/vp9)
|
|
- resolution/FPS matrix
|
|
- encoder backends (software/hardware)
|
|
|
|
Requirements:
|
|
pip install requests websockets playwright
|
|
playwright install
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import csv
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Iterable, List, Optional, Tuple
|
|
|
|
import requests
|
|
import websockets
|
|
from playwright.async_api import async_playwright
|
|
|
|
|
|
SESSION_COOKIE = "one_kvm_session"
|
|
DEFAULT_MATRIX = [
|
|
(1920, 1080, 30),
|
|
(1920, 1080, 60),
|
|
(1280, 720, 30),
|
|
(1280, 720, 60),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class Case:
|
|
input_format: str
|
|
output_codec: str
|
|
encoder: Optional[str]
|
|
width: int
|
|
height: int
|
|
fps: int
|
|
|
|
|
|
@dataclass
|
|
class Result:
|
|
input_format: str
|
|
output_codec: str
|
|
encoder: str
|
|
width: int
|
|
height: int
|
|
fps: int
|
|
avg_fps: float
|
|
avg_cpu: float
|
|
note: str = ""
|
|
|
|
|
|
class KvmClient:
|
|
def __init__(self, base_url: str, username: str, password: str) -> None:
|
|
self.base = base_url.rstrip("/")
|
|
self.s = requests.Session()
|
|
self.login(username, password)
|
|
|
|
def login(self, username: str, password: str) -> None:
|
|
r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password})
|
|
r.raise_for_status()
|
|
|
|
def get_cookie(self) -> str:
|
|
return self.s.cookies.get(SESSION_COOKIE, "")
|
|
|
|
def get_video_config(self) -> Dict:
|
|
r = self.s.get(f"{self.base}/api/config/video")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def get_stream_config(self) -> Dict:
|
|
r = self.s.get(f"{self.base}/api/config/stream")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def get_devices(self) -> Dict:
|
|
r = self.s.get(f"{self.base}/api/devices")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def get_codecs(self) -> Dict:
|
|
r = self.s.get(f"{self.base}/api/stream/codecs")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None:
|
|
payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps}
|
|
if device:
|
|
payload["device"] = device
|
|
r = self.s.patch(f"{self.base}/api/config/video", json=payload)
|
|
r.raise_for_status()
|
|
|
|
def patch_stream(self, encoder: Optional[str]) -> None:
|
|
if encoder is None:
|
|
return
|
|
r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder})
|
|
r.raise_for_status()
|
|
|
|
def set_mode(self, mode: str) -> None:
|
|
r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode})
|
|
r.raise_for_status()
|
|
|
|
def get_mode(self) -> Dict:
|
|
r = self.s.get(f"{self.base}/api/stream/mode")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None:
|
|
deadline = time.time() + timeout_sec
|
|
while time.time() < deadline:
|
|
data = self.get_mode()
|
|
if not data.get("switching") and data.get("mode") == mode:
|
|
return
|
|
time.sleep(0.5)
|
|
raise RuntimeError(f"mode switch timeout: {mode}")
|
|
|
|
def start_stream(self) -> None:
|
|
r = self.s.post(f"{self.base}/api/stream/start")
|
|
r.raise_for_status()
|
|
|
|
def stop_stream(self) -> None:
|
|
r = self.s.post(f"{self.base}/api/stream/stop")
|
|
r.raise_for_status()
|
|
|
|
def cpu_sample(self) -> float:
|
|
r = self.s.get(f"{self.base}/api/info")
|
|
r.raise_for_status()
|
|
return float(r.json()["device_info"]["cpu_usage"])
|
|
|
|
def close_webrtc_session(self, session_id: str) -> None:
|
|
if not session_id:
|
|
return
|
|
self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id})
|
|
|
|
|
|
class MjpegStream:
|
|
def __init__(self, url: str, cookie: str) -> None:
|
|
self._stop = threading.Event()
|
|
self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"})
|
|
self._thread = threading.Thread(target=self._reader, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _reader(self) -> None:
|
|
try:
|
|
for chunk in self._resp.iter_content(chunk_size=4096):
|
|
if self._stop.is_set():
|
|
break
|
|
if not chunk:
|
|
time.sleep(0.01)
|
|
except Exception:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
self._stop.set()
|
|
try:
|
|
self._resp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]:
|
|
if not values:
|
|
return DEFAULT_MATRIX
|
|
result: List[Tuple[int, int, int]] = []
|
|
for item in values:
|
|
# WIDTHxHEIGHT@FPS
|
|
part = item.strip().lower()
|
|
if "@" not in part or "x" not in part:
|
|
raise ValueError(f"invalid matrix item: {item}")
|
|
res_part, fps_part = part.split("@", 1)
|
|
w_str, h_str = res_part.split("x", 1)
|
|
result.append((int(w_str), int(h_str), int(fps_part)))
|
|
return result
|
|
|
|
|
|
def avg(values: Iterable[float]) -> float:
|
|
vals = list(values)
|
|
return sum(vals) / len(vals) if vals else 0.0
|
|
|
|
|
|
def normalize_format(fmt: str) -> str:
|
|
return fmt.strip().upper()
|
|
|
|
|
|
def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]:
|
|
video_devices = devices.get("video", [])
|
|
if preferred:
|
|
for d in video_devices:
|
|
if d.get("path") == preferred:
|
|
return d
|
|
return video_devices[0] if video_devices else None
|
|
|
|
|
|
def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]:
|
|
supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {}
|
|
for fmt in device.get("formats", []):
|
|
fmt_name = normalize_format(fmt.get("format", ""))
|
|
res_map: Dict[Tuple[int, int], List[int]] = {}
|
|
for res in fmt.get("resolutions", []):
|
|
key = (int(res.get("width", 0)), int(res.get("height", 0)))
|
|
fps_list = [int(f) for f in res.get("fps", [])]
|
|
res_map[key] = fps_list
|
|
supported[fmt_name] = res_map
|
|
return supported
|
|
|
|
|
|
def is_combo_supported(
|
|
supported: Dict[str, Dict[Tuple[int, int], List[int]]],
|
|
fmt: str,
|
|
width: int,
|
|
height: int,
|
|
fps: int,
|
|
) -> bool:
|
|
res_map = supported.get(fmt)
|
|
if not res_map:
|
|
return False
|
|
fps_list = res_map.get((width, height), [])
|
|
return fps in fps_list
|
|
|
|
|
|
async def mjpeg_sample(
|
|
base_url: str,
|
|
cookie: str,
|
|
client_id: str,
|
|
duration_sec: float,
|
|
cpu_sample_fn,
|
|
) -> Tuple[float, float]:
|
|
mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}"
|
|
stream = MjpegStream(mjpeg_url, cookie)
|
|
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws"
|
|
|
|
fps_samples: List[float] = []
|
|
cpu_samples: List[float] = []
|
|
|
|
# discard first cpu sample (needs delta)
|
|
cpu_sample_fn()
|
|
|
|
try:
|
|
async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws:
|
|
start = time.time()
|
|
while time.time() - start < duration_sec:
|
|
try:
|
|
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
except asyncio.TimeoutError:
|
|
msg = None
|
|
|
|
if msg:
|
|
data = json.loads(msg)
|
|
if data.get("type") == "stream.stats_update":
|
|
clients = data.get("clients_stat", {})
|
|
if client_id in clients:
|
|
fps = float(clients[client_id].get("fps", 0))
|
|
fps_samples.append(fps)
|
|
|
|
cpu_samples.append(float(cpu_sample_fn()))
|
|
finally:
|
|
stream.close()
|
|
|
|
return avg(fps_samples), avg(cpu_samples)
|
|
|
|
|
|
async def webrtc_sample(
|
|
base_url: str,
|
|
cookie: str,
|
|
duration_sec: float,
|
|
cpu_sample_fn,
|
|
headless: bool,
|
|
) -> Tuple[float, float, str]:
|
|
fps_samples: List[float] = []
|
|
cpu_samples: List[float] = []
|
|
session_id = ""
|
|
|
|
# discard first cpu sample (needs delta)
|
|
cpu_sample_fn()
|
|
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(headless=headless)
|
|
context = await browser.new_context()
|
|
await context.add_cookies([{
|
|
"name": SESSION_COOKIE,
|
|
"value": cookie,
|
|
"url": base_url,
|
|
"path": "/",
|
|
}])
|
|
page = await context.new_page()
|
|
await page.goto(base_url + "/", wait_until="domcontentloaded")
|
|
|
|
await page.evaluate(
|
|
"""
|
|
async (base) => {
|
|
const pc = new RTCPeerConnection();
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
pc.onicecandidate = async (e) => {
|
|
if (e.candidate && window.__sid) {
|
|
await fetch(base + "/api/webrtc/ice", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate })
|
|
});
|
|
}
|
|
};
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
const resp = await fetch(base + "/api/webrtc/offer", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sdp: offer.sdp })
|
|
});
|
|
const ans = await resp.json();
|
|
window.__sid = ans.session_id;
|
|
await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp });
|
|
(ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c));
|
|
window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 };
|
|
}
|
|
""",
|
|
base_url,
|
|
)
|
|
|
|
try:
|
|
await page.wait_for_function(
|
|
"window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'",
|
|
timeout=15000,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
start = time.time()
|
|
while time.time() - start < duration_sec:
|
|
fps = await page.evaluate(
|
|
"""
|
|
async () => {
|
|
const s = window.__kvmStats;
|
|
const report = await s.pc.getStats();
|
|
let fps = 0;
|
|
for (const r of report.values()) {
|
|
if (r.type === "inbound-rtp" && r.kind === "video") {
|
|
if (r.framesPerSecond) {
|
|
fps = r.framesPerSecond;
|
|
} else if (r.framesDecoded && s.lastTs) {
|
|
const dt = (r.timestamp - s.lastTs) / 1000.0;
|
|
const df = r.framesDecoded - s.lastFrames;
|
|
fps = dt > 0 ? df / dt : 0;
|
|
}
|
|
s.lastTs = r.timestamp;
|
|
s.lastFrames = r.framesDecoded || s.lastFrames;
|
|
break;
|
|
}
|
|
}
|
|
return fps;
|
|
}
|
|
"""
|
|
)
|
|
fps_samples.append(float(fps))
|
|
cpu_samples.append(float(cpu_sample_fn()))
|
|
await asyncio.sleep(1)
|
|
|
|
session_id = await page.evaluate("window.__sid || ''")
|
|
await browser.close()
|
|
|
|
return avg(fps_samples), avg(cpu_samples), session_id
|
|
|
|
|
|
async def run_case(
|
|
client: KvmClient,
|
|
device: Optional[str],
|
|
case: Case,
|
|
duration_sec: float,
|
|
warmup_sec: float,
|
|
headless: bool,
|
|
) -> Result:
|
|
client.patch_video(device, case.input_format, case.width, case.height, case.fps)
|
|
|
|
if case.output_codec != "mjpeg":
|
|
client.patch_stream(case.encoder)
|
|
|
|
client.set_mode(case.output_codec)
|
|
client.wait_mode_ready(case.output_codec)
|
|
|
|
client.start_stream()
|
|
time.sleep(warmup_sec)
|
|
|
|
note = ""
|
|
if case.output_codec == "mjpeg":
|
|
avg_fps, avg_cpu = await mjpeg_sample(
|
|
client.base,
|
|
client.get_cookie(),
|
|
client_id=f"bench-{int(time.time() * 1000)}",
|
|
duration_sec=duration_sec,
|
|
cpu_sample_fn=client.cpu_sample,
|
|
)
|
|
else:
|
|
avg_fps, avg_cpu, session_id = await webrtc_sample(
|
|
client.base,
|
|
client.get_cookie(),
|
|
duration_sec=duration_sec,
|
|
cpu_sample_fn=client.cpu_sample,
|
|
headless=headless,
|
|
)
|
|
if session_id:
|
|
client.close_webrtc_session(session_id)
|
|
else:
|
|
note = "no-session-id"
|
|
|
|
client.stop_stream()
|
|
|
|
return Result(
|
|
input_format=case.input_format,
|
|
output_codec=case.output_codec,
|
|
encoder=case.encoder or "n/a",
|
|
width=case.width,
|
|
height=case.height,
|
|
fps=case.fps,
|
|
avg_fps=avg_fps,
|
|
avg_cpu=avg_cpu,
|
|
note=note,
|
|
)
|
|
|
|
|
|
def write_csv(results: List[Result], path: str) -> None:
|
|
with open(path, "w", newline="") as f:
|
|
w = csv.writer(f)
|
|
w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"])
|
|
for r in results:
|
|
w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note])
|
|
|
|
|
|
def write_md(results: List[Result], path: str) -> None:
|
|
lines = [
|
|
"| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |",
|
|
"|---|---|---|---:|---:|---:|---:|---:|---|",
|
|
]
|
|
for r in results:
|
|
lines.append(
|
|
f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |"
|
|
)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write("\n".join(lines))
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)")
|
|
parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50")
|
|
parser.add_argument("--username", required=True)
|
|
parser.add_argument("--password", required=True)
|
|
parser.add_argument("--device", help="video device path, e.g. /dev/video0")
|
|
parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12")
|
|
parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9")
|
|
parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m")
|
|
parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30")
|
|
parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)")
|
|
parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling")
|
|
parser.add_argument("--csv", default="bench_results.csv")
|
|
parser.add_argument("--md", default="bench_results.md")
|
|
parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if sys.platform.startswith("win"):
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
base_url = args.base_url.strip()
|
|
if not base_url.startswith(("http://", "https://")):
|
|
base_url = "http://" + base_url
|
|
client = KvmClient(base_url, args.username, args.password)
|
|
|
|
devices = client.get_devices()
|
|
video_cfg = client.get_video_config()
|
|
device_path = args.device or video_cfg.get("device")
|
|
device_info = select_device(devices, device_path)
|
|
if not device_info:
|
|
print("No video device found.", file=sys.stderr)
|
|
return 2
|
|
device_path = device_info.get("path")
|
|
|
|
supported_map = build_supported_map(device_info)
|
|
|
|
if args.input_formats:
|
|
input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()]
|
|
else:
|
|
input_formats = list(supported_map.keys())
|
|
|
|
matrix = parse_matrix(args.matrix)
|
|
|
|
codecs_info = client.get_codecs()
|
|
available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")}
|
|
available_codecs.add("mjpeg")
|
|
|
|
if args.output_codecs:
|
|
output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()]
|
|
else:
|
|
output_codecs = sorted(list(available_codecs))
|
|
|
|
if args.encoder_backends:
|
|
encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()]
|
|
else:
|
|
encoder_backends = ["software", "auto"]
|
|
|
|
cases: List[Case] = []
|
|
for fmt in input_formats:
|
|
for (w, h, fps) in matrix:
|
|
if not is_combo_supported(supported_map, fmt, w, h, fps):
|
|
continue
|
|
for codec in output_codecs:
|
|
if codec not in available_codecs:
|
|
continue
|
|
if codec == "mjpeg":
|
|
cases.append(Case(fmt, codec, None, w, h, fps))
|
|
else:
|
|
for enc in encoder_backends:
|
|
cases.append(Case(fmt, codec, enc, w, h, fps))
|
|
|
|
print(f"Total cases: {len(cases)}")
|
|
results: List[Result] = []
|
|
|
|
for idx, case in enumerate(cases, 1):
|
|
print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}")
|
|
try:
|
|
result = asyncio.run(
|
|
run_case(
|
|
client,
|
|
device=device_path,
|
|
case=case,
|
|
duration_sec=args.duration,
|
|
warmup_sec=args.warmup,
|
|
headless=args.headless,
|
|
)
|
|
)
|
|
results.append(result)
|
|
print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}")
|
|
except Exception as exc:
|
|
results.append(
|
|
Result(
|
|
input_format=case.input_format,
|
|
output_codec=case.output_codec,
|
|
encoder=case.encoder or "n/a",
|
|
width=case.width,
|
|
height=case.height,
|
|
fps=case.fps,
|
|
avg_fps=0.0,
|
|
avg_cpu=0.0,
|
|
note=f"error: {exc}",
|
|
)
|
|
)
|
|
print(f" -> error: {exc}")
|
|
|
|
write_csv(results, args.csv)
|
|
write_md(results, args.md)
|
|
print(f"Saved: {args.csv}, {args.md}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|