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

View File

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

View File

@@ -165,7 +165,7 @@ class Reporter:
"- 测试设备:", "- 测试设备:",
f"- 视频设备:{markdown_inline(self.user_video_device())}", f"- 视频设备:{markdown_inline(self.user_video_device())}",
f"- HID设备{markdown_inline(self.user_hid_backend())}", 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 result
return None return None
def user_network_latency(self) -> str: def user_http_latency(self) -> str:
result = self.find_result("network_latency") result = self.find_result("network_latency")
if not result: if not result:
return "无数据" return "无数据"
tcp = result.data.get("tcp_connect") or {} http = result.data.get("http_health") or {}
if tcp.get("samples"): if http.get("samples"):
return format_latency_values(tcp) return format_latency_values(http)
return result.message or "无数据" return "无数据"
def user_video_device(self) -> str: def user_video_device(self) -> str:
result = self.find_result("video_input_select") result = self.find_result("video_input_select")

View File

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