mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 10:34:24 +08:00
fix: 完善测试套件
This commit is contained in:
@@ -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>` 覆盖。
|
||||
|
||||
|
||||
@@ -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
|
||||
wmAppDynamicFrame = wmApp + 7
|
||||
|
||||
dynamicTimerID = 1
|
||||
|
||||
colorWindow = 5
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
w, h, f = picked
|
||||
cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f))
|
||||
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("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)
|
||||
cases = selector.select()
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user