fix: 完善测试套件

This commit is contained in:
mofeng-git
2026-07-22 22:53:21 +08:00
parent e0055bf491
commit 8d5366444b
4 changed files with 264 additions and 83 deletions

View File

@@ -76,8 +76,8 @@ python okvm_testctl.py run `
控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入:
- USB2.0 采集卡测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率
- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV才退到不超过 1080p 的最高分辨率。
- USB2.0 采集卡MS2131 测试档位):严格测试 `1080p50 MJPEG` `1080p10 YUYV`;任一格式、分辨率或帧率未申报时,立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不回退到其他档位
- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率申报的最高帧率;如果没有 1080p YUYV立即将 `video_input_select` 标记为 `FAIL` 并中止测试,不再回退到较低分辨率。
- CSI/MIPI只测试一套 `1080p60 NV12`,不做输入格式切换。
每个输入配置都会跑三种输出:
@@ -87,6 +87,7 @@ python okvm_testctl.py run `
- H.265 WebRTC
默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。
每次应用视频输入配置后默认先空转 3 秒,稳定后再开始统计;可通过 `--video-config-settle-seconds <秒数>` 调整。
MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps <fps>` 覆盖。

View File

@@ -42,7 +42,6 @@ const (
wmChar = 0x0102
wmSysKeyDown = 0x0104
wmSysKeyUp = 0x0105
wmTimer = 0x0113
wmMouseMove = 0x0200
wmLButtonDown = 0x0201
wmLButtonUp = 0x0202
@@ -75,10 +74,11 @@ const (
wmAppFocus = wmApp + 4
wmAppDynamicStart = wmApp + 5
wmAppDynamicStop = wmApp + 6
dynamicTimerID = 1
wmAppDynamicFrame = wmApp + 7
colorWindow = 5
dynamicBackgroundColor = 0x00101010
dynamicPatchSize = 384
driveUnknown = 0
driveNoRootDir = 1
@@ -111,16 +111,17 @@ var (
procShowWindow = user32.NewProc("ShowWindow")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
procGetDC = user32.NewProc("GetDC")
procReleaseDC = user32.NewProc("ReleaseDC")
procInvalidateRect = user32.NewProc("InvalidateRect")
procUpdateWindow = user32.NewProc("UpdateWindow")
procSetTimer = user32.NewProc("SetTimer")
procKillTimer = user32.NewProc("KillTimer")
procGetKeyState = user32.NewProc("GetKeyState")
procBeginPaint = user32.NewProc("BeginPaint")
procEndPaint = user32.NewProc("EndPaint")
procFillRect = user32.NewProc("FillRect")
procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
procDeleteObject = gdi32.NewProc("DeleteObject")
procGetDeviceCaps = gdi32.NewProc("GetDeviceCaps")
procImmAssociateContext = imm32.NewProc("ImmAssociateContext")
procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
procQueryPerformanceCount = kernel32.NewProc("QueryPerformanceCounter")
@@ -192,6 +193,10 @@ type appState struct {
dynamicActive bool
dynamicFPS int
dynamicFrame int64
dynamicGeneration uint64
dynamicFramePending bool
dynamicStop chan struct{}
dynamicStarted time.Time
events []hidEvent
}
@@ -519,20 +524,22 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr {
hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
state.mu.Lock()
color := state.bgColor
dynamic := state.dynamicActive
state.mu.Unlock()
brush, _, _ := procCreateSolidBrush.Call(uintptr(color))
r := rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())}
procFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), brush)
procDeleteObject.Call(brush)
paintRect := ps.RcPaint
if paintRect.Right <= paintRect.Left || paintRect.Bottom <= paintRect.Top {
paintRect = rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())}
}
if dynamic {
fillRect(hdc, paintRect, dynamicBackgroundColor)
if patch, ok := intersectRects(paintRect, dynamicPatchRect()); ok {
fillRect(hdc, patch, color)
}
} else {
fillRect(hdc, paintRect, color)
}
procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps)))
return 0
case wmTimer:
if wParam == dynamicTimerID {
advanceDynamicFrame(hwnd)
return 0
}
ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam)
return ret
case wmAppInvalidate:
invalidateWindowNow(hwnd)
return 0
@@ -547,16 +554,12 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr {
procSetForegroundWindow.Call(hwnd)
return 0
case wmAppDynamicStart:
procKillTimer.Call(hwnd, dynamicTimerID)
interval := wParam
if interval == 0 {
interval = 16
}
procSetTimer.Call(hwnd, dynamicTimerID, interval, 0)
invalidateWindowNow(hwnd)
return 0
case wmAppDynamicFrame:
advanceDynamicFrame(hwnd, uint64(wParam))
return 0
case wmAppDynamicStop:
procKillTimer.Call(hwnd, dynamicTimerID)
invalidateWindowNow(hwnd)
return 0
case wmKeyDown:
@@ -590,7 +593,6 @@ func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr {
procShowWindow.Call(hwnd, swHide)
return 0
case wmDestroy:
procKillTimer.Call(hwnd, dynamicTimerID)
procPostQuitMessage.Call(0)
return 0
default:
@@ -704,13 +706,20 @@ func startDynamic(fps int) map[string]interface{} {
fps = 120
}
stopDynamic()
stop := make(chan struct{})
state.mu.Lock()
state.dynamicActive = true
state.dynamicFPS = fps
state.dynamicFrame = 0
state.dynamicGeneration++
generation := state.dynamicGeneration
state.dynamicFramePending = false
state.dynamicStop = stop
state.dynamicStarted = time.Now()
display := setColorStateLocked(dynamicFrameColor(0))
state.mu.Unlock()
postUIMessage(wmAppDynamicStart, uintptr(dynamicTimerIntervalMS(fps)), 0)
postUIMessage(wmAppDynamicStart, uintptr(generation), 0)
go runDynamicFrames(fps, generation, stop)
display["dynamic"] = true
display["fps"] = fps
return display
@@ -719,24 +728,51 @@ func startDynamic(fps int) map[string]interface{} {
func stopDynamic() {
state.mu.Lock()
active := state.dynamicActive
stop := state.dynamicStop
state.dynamicActive = false
state.dynamicFPS = 0
state.dynamicFrame = 0
state.dynamicFramePending = false
state.dynamicStop = nil
state.dynamicStarted = time.Time{}
state.mu.Unlock()
if stop != nil {
close(stop)
}
if active {
postUIMessage(wmAppDynamicStop, 0, 0)
}
}
func dynamicTimerIntervalMS(fps int) int {
if fps < 1 {
fps = 60
func runDynamicFrames(fps int, generation uint64, stop <-chan struct{}) {
interval := time.Second / time.Duration(fps)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
state.mu.Lock()
if !state.dynamicActive || state.dynamicGeneration != generation {
state.mu.Unlock()
return
}
if state.dynamicFramePending {
state.mu.Unlock()
continue
}
state.dynamicFramePending = true
state.mu.Unlock()
if !postUIMessage(wmAppDynamicFrame, uintptr(generation), 0) {
state.mu.Lock()
if state.dynamicGeneration == generation {
state.dynamicFramePending = false
}
state.mu.Unlock()
}
}
interval := 1000 / fps
if interval < 1 {
return 1
}
return interval
}
func dynamicFrameColor(frame int64) string {
@@ -746,16 +782,17 @@ func dynamicFrameColor(frame int64) string {
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
func advanceDynamicFrame(hwnd uintptr) {
func advanceDynamicFrame(hwnd uintptr, generation uint64) {
state.mu.Lock()
if !state.dynamicActive {
if !state.dynamicActive || state.dynamicGeneration != generation {
state.mu.Unlock()
return
}
state.dynamicFramePending = false
state.dynamicFrame++
setColorStateLocked(dynamicFrameColor(state.dynamicFrame))
state.mu.Unlock()
invalidateWindowNow(hwnd)
invalidateDynamicPatchNow(hwnd)
}
func setColorStateLocked(colorHex string) map[string]interface{} {
@@ -781,6 +818,13 @@ func setColorStateLocked(colorHex string) map[string]interface{} {
func currentDisplayState() map[string]interface{} {
state.mu.Lock()
defer state.mu.Unlock()
dynamicActualFPS := 0.0
if state.dynamicActive && !state.dynamicStarted.IsZero() {
elapsed := time.Since(state.dynamicStarted).Seconds()
if elapsed > 0 {
dynamicActualFPS = float64(state.dynamicFrame) / elapsed
}
}
return map[string]interface{}{
"color": state.colorHex,
"last_change_qpc": state.lastColorChangeQpc,
@@ -788,6 +832,9 @@ func currentDisplayState() map[string]interface{} {
"sequence": state.colorSequence,
"dynamic": state.dynamicActive,
"dynamic_fps": state.dynamicFPS,
"dynamic_frame": state.dynamicFrame,
"dynamic_actual_fps": dynamicActualFPS,
"display_refresh_hz": screenRefreshHz(),
"qpc": qpcNow(),
"unix_nano": time.Now().UnixNano(),
}
@@ -816,13 +863,15 @@ func focusWindow() {
}
}
func postUIMessage(message uint32, wParam uintptr, lParam uintptr) {
func postUIMessage(message uint32, wParam uintptr, lParam uintptr) bool {
state.mu.Lock()
hwnd := state.hwnd
state.mu.Unlock()
if hwnd != 0 {
procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam)
if hwnd == 0 {
return false
}
result, _, _ := procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam)
return result != 0
}
func invalidateWindowNow(hwnd uintptr) {
@@ -830,6 +879,60 @@ func invalidateWindowNow(hwnd uintptr) {
procUpdateWindow.Call(hwnd)
}
func invalidateDynamicPatchNow(hwnd uintptr) {
patch := dynamicPatchRect()
procInvalidateRect.Call(hwnd, uintptr(unsafe.Pointer(&patch)), 0)
procUpdateWindow.Call(hwnd)
}
func dynamicPatchRect() rect {
width := int32(screenWidth())
height := int32(screenHeight())
size := int32(dynamicPatchSize)
if size > width {
size = width
}
if size > height {
size = height
}
left := (width - size) / 2
top := (height - size) / 2
return rect{Left: left, Top: top, Right: left + size, Bottom: top + size}
}
func intersectRects(a, b rect) (rect, bool) {
intersection := rect{
Left: maxInt32(a.Left, b.Left),
Top: maxInt32(a.Top, b.Top),
Right: minInt32(a.Right, b.Right),
Bottom: minInt32(a.Bottom, b.Bottom),
}
return intersection, intersection.Right > intersection.Left && intersection.Bottom > intersection.Top
}
func fillRect(hdc uintptr, area rect, color uint32) {
brush, _, _ := procCreateSolidBrush.Call(uintptr(color))
if brush == 0 {
return
}
procFillRect.Call(hdc, uintptr(unsafe.Pointer(&area)), brush)
procDeleteObject.Call(brush)
}
func minInt32(a, b int32) int32 {
if a < b {
return a
}
return b
}
func maxInt32(a, b int32) int32 {
if a > b {
return a
}
return b
}
func disableIME(hwnd uintptr) {
if hwnd != 0 {
procImmAssociateContext.Call(hwnd, 0)
@@ -870,7 +973,18 @@ func screenSize() (int, int) {
func screenInfo() map[string]int {
width, height := screenSize()
return map[string]int{"width": width, "height": height}
return map[string]int{"width": width, "height": height, "refresh_hz": screenRefreshHz()}
}
func screenRefreshHz() int {
const vRefresh = 116
hdc, _, _ := procGetDC.Call(0)
if hdc == 0 {
return 0
}
defer procReleaseDC.Call(0, hdc)
refresh, _, _ := procGetDeviceCaps.Call(hdc, vRefresh)
return int(refresh)
}
func screenWidth() int {

View File

@@ -165,7 +165,7 @@ class Reporter:
"- 测试设备:",
f"- 视频设备:{markdown_inline(self.user_video_device())}",
f"- HID设备{markdown_inline(self.user_hid_backend())}",
f"- 网络延迟:{markdown_inline(self.user_network_latency())}",
f"- HTTP 延迟:{markdown_inline(self.user_http_latency())}",
"",
"## 视频性能",
"",
@@ -229,14 +229,14 @@ class Reporter:
return result
return None
def user_network_latency(self) -> str:
def user_http_latency(self) -> str:
result = self.find_result("network_latency")
if not result:
return "无数据"
tcp = result.data.get("tcp_connect") or {}
if tcp.get("samples"):
return format_latency_values(tcp)
return result.message or "无数据"
http = result.data.get("http_health") or {}
if http.get("samples"):
return format_latency_values(http)
return "无数据"
def user_video_device(self) -> str:
result = self.find_result("video_input_select")

View File

@@ -275,6 +275,24 @@ class DeviceSelector:
return width, height, best
return None
@staticmethod
def _pick_required_mode(
fmt: dict[str, Any] | None,
width: int,
height: int,
fps: float,
) -> tuple[int, int, float] | None:
if not fmt:
return None
for res in fmt.get("resolutions", []):
if int(res.get("width", 0)) != width or int(res.get("height", 0)) != height:
continue
for advertised_fps in res.get("fps", []):
value = float(advertised_fps)
if abs(value - fps) <= 0.05:
return width, height, value
return None
@staticmethod
def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None:
candidates: list[tuple[int, int, float]] = []
@@ -289,6 +307,22 @@ class DeviceSelector:
return None
return max(candidates, key=lambda x: (x[0] * x[1], x[2]))
@staticmethod
def _pick_highest_fps_at_resolution(
fmt: dict[str, Any] | None,
width: int,
height: int,
) -> tuple[int, int, float] | None:
if not fmt:
return None
candidates: list[float] = []
for res in fmt.get("resolutions", []):
if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height:
candidates.extend(float(value) for value in res.get("fps", []))
if not candidates:
return None
return width, height, max(candidates)
def select(self) -> list[VideoInputCase]:
video_devices = self.devices.get("video", [])
if not video_devices:
@@ -310,17 +344,36 @@ class DeviceSelector:
mjpeg = self._find_format(device, "MJPEG")
yuyv = self._find_format(device, "YUYV")
target_fps = 60 if input_class == "usb3" else 30
if input_class == "usb2":
required_modes = (
("MJPEG", mjpeg, 50.0, "usb2_mjpeg"),
("YUYV", yuyv, 10.0, "usb2_yuyv"),
)
missing: list[str] = []
for fmt_name, fmt_info, required_fps, label in required_modes:
picked = self._pick_required_mode(fmt_info, 1920, 1080, required_fps)
if not picked:
missing.append(f"{fmt_name} 1920x1080@{required_fps:g}fps")
continue
width, height, fps = picked
cases.append(VideoInputCase(label, input_class, path, fmt_name, width, height, fps))
if missing:
raise RuntimeError(
"USB 2.0 capture card is missing required mode(s): " + ", ".join(missing)
)
return cases
target_fps = 60
if mjpeg:
picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg)
if picked:
w, h, f = picked
cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f))
if yuyv:
picked = self._pick_highest_1080(yuyv)
if picked:
picked = self._pick_highest_fps_at_resolution(yuyv, 1920, 1080)
if not picked:
raise RuntimeError("USB 3.0 capture card is missing required mode: YUYV 1920x1080")
w, h, f = picked
cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f))
cases.append(VideoInputCase("usb3_yuyv", input_class, path, "YUYV", w, h, f))
return cases
@@ -597,7 +650,16 @@ echo "$BACKUP"
def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]:
selector = DeviceSelector(self.lsusb_tree, devices)
try:
cases = selector.select()
except RuntimeError as exc:
self.reporter.add(
"video_input_select",
"FAIL",
str(exc),
devices=devices.get("video", []),
)
raise
if not cases:
self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", []))
return []
@@ -692,7 +754,6 @@ echo "$BACKUP"
)
continue
try:
self.apply_video_case(case)
if output_mode == "mjpeg":
motion_started = await self.start_mjpeg_motion()
try:
@@ -796,6 +857,9 @@ echo "$BACKUP"
"quality": self.args.jpeg_quality,
},
)
settle_seconds = max(0.0, float(self.args.video_config_settle_seconds))
if settle_seconds > 0:
time.sleep(settle_seconds)
def configure_video_case(self, case: VideoInputCase) -> None:
self.apply_video_case(case)
@@ -818,7 +882,7 @@ echo "$BACKUP"
frame_count = 0
byte_count = 0
first_frame_s: float | None = None
for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case):
for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds):
now = time.monotonic()
if now >= deadline:
break
@@ -855,6 +919,7 @@ echo "$BACKUP"
async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]:
self.set_stream_mode(codec)
self.apply_video_case(case)
try:
from playwright.async_api import async_playwright
except ImportError:
@@ -885,7 +950,13 @@ echo "$BACKUP"
try:
page = await context.new_page()
await page.goto(self.api.base)
result = await page.evaluate(js, {"seconds": self.args.sample_seconds})
result = await page.evaluate(
js,
{
"seconds": self.args.sample_seconds,
"settleSeconds": max(0.0, float(self.args.video_config_settle_seconds)),
},
)
except Exception as exc:
text = str(exc)
if codec == "h265" and is_webrtc_codec_unsupported_error(text):
@@ -988,7 +1059,6 @@ echo "$BACKUP"
)
continue
try:
self.apply_video_case(latency_case)
if output_mode == "mjpeg":
await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False)
else:
@@ -1010,17 +1080,8 @@ echo "$BACKUP"
return cases[0]
def configure_hdmi_probe_case(self, case: VideoInputCase) -> None:
self.api.patch(
"/config/video",
{
"device": case.device,
"format": case.fmt,
"width": case.width,
"height": case.height,
"fps": int(round(case.fps)),
"quality": self.args.jpeg_quality,
},
)
self.set_stream_mode("mjpeg")
self.apply_video_case(case)
self.reporter.add(
"config_video_hdmi_probe",
"PASS",
@@ -1044,7 +1105,6 @@ echo "$BACKUP"
evidence_title=f"HDMI 纯色采集帧 {name}",
expected_rgb=expected,
threshold=self.args.hdmi_color_fail,
video_case=case,
)
err = rgb_error(expected, stats["mean_rgb"])
closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"])
@@ -1113,6 +1173,9 @@ echo "$BACKUP"
if self.args.hdmi_latency_trials <= 0:
self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg")
return
self.set_stream_mode("mjpeg")
self.apply_video_case(case)
self.api.post("/stream/start", {})
offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt")
trials: list[dict[str, Any]] = []
colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")]
@@ -1128,7 +1191,6 @@ echo "$BACKUP"
timeout=detect_timeout,
threshold=self.args.hdmi_color_fail,
evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None,
video_case=case,
)
display = await self.agent.command("display_state", {}, timeout=5)
actual_agent_ns = int(display.get("last_change_unix_nano") or 0)
@@ -1209,6 +1271,9 @@ echo "$BACKUP"
setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000})
if not setup.get("connected"):
raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}")
settle_seconds = max(0.0, float(self.args.video_config_settle_seconds))
if settle_seconds > 0:
await page.wait_for_timeout(settle_seconds * 1000)
for i in range(self.args.hdmi_latency_trials):
source, target = colors[i % len(colors)]
await self.agent.command("show", {"color": source, "full": True}, timeout=5)
@@ -1313,7 +1378,6 @@ echo "$BACKUP"
evidence_title: str | None = None,
expected_rgb: tuple[int, int, int] | None = None,
threshold: float | None = None,
video_case: VideoInputCase | None = None,
) -> dict[str, Any]:
threshold = self.args.hdmi_color_fail if threshold is None else threshold
required_matches = max(1, int(self.args.hdmi_match_frames))
@@ -1324,7 +1388,7 @@ echo "$BACKUP"
consecutive_matches = 0
frames_seen = 0
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case):
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout):
frames_seen += 1
stats = jpeg_rgb_stats(frame)
stats["wall_ns"] = wall_ns
@@ -1370,12 +1434,11 @@ echo "$BACKUP"
timeout: float,
threshold: float,
evidence_title: str | None = None,
video_case: VideoInputCase | None = None,
) -> dict[str, Any]:
last: dict[str, Any] | None = None
last_frame: bytes | None = None
frames_seen = 0
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case):
for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout):
frames_seen += 1
stats = jpeg_rgb_stats(frame)
err = rgb_error(expected_rgb, stats["mean_rgb"])
@@ -1400,10 +1463,8 @@ echo "$BACKUP"
path.write_bytes(frame)
self.reporter.add_evidence("HDMI 采集帧", title, path)
def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None):
def iter_mjpeg_frames(self, client_label: str, timeout: float):
self.set_stream_mode("mjpeg")
if video_case is not None:
self.apply_video_case(video_case)
self.api.post("/stream/start", {})
client_id = f"test-{self.run_id}-{client_label}"
url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}"
@@ -1441,8 +1502,6 @@ echo "$BACKUP"
raise
time.sleep(0.5)
self.set_stream_mode("mjpeg", timeout=10)
if video_case is not None:
self.apply_video_case(video_case)
self.api.post("/stream/start", {})
async def run_hid_test(self) -> None:
@@ -1834,7 +1893,7 @@ VIDEO_SCREENSHOT_READY_JS = r"""
WEBRTC_MEASURE_JS = r"""
async ({seconds}) => {
async ({seconds, settleSeconds}) => {
const api = async (path, opts = {}) => {
const response = await fetch('/api' + path, {
credentials: 'include',
@@ -1895,6 +1954,12 @@ async ({seconds}) => {
await new Promise(r => setTimeout(r, 100));
}
// Let capture, encoding, decoding, and browser rendering reach steady state.
// Samples collected during this interval are intentionally discarded.
if (settleSeconds > 0) {
await new Promise(r => setTimeout(r, settleSeconds * 1000));
}
const samples = [];
const endAt = performance.now() + seconds * 1000;
while (performance.now() < endAt) {
@@ -2252,6 +2317,7 @@ def build_parser() -> argparse.ArgumentParser:
run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup")
run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds")
run.add_argument("--sample-seconds", type=int, default=30)
run.add_argument("--video-config-settle-seconds", type=float, default=3.0, help="idle time after applying video configuration before collecting samples")
run.add_argument("--jpeg-quality", type=int, default=80)
run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests")
run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks")