diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md index b26f19ca..236db491 100644 --- a/test/okvm-test/README.md +++ b/test/okvm-test/README.md @@ -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 ` 覆盖。 diff --git a/test/okvm-test/agent/main.go b/test/okvm-test/agent/main.go index f9f05127..a6cac13f 100644 --- a/test/okvm-test/agent/main.go +++ b/test/okvm-test/agent/main.go @@ -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 { diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py index cb9e50f7..afda979f 100644 --- a/test/okvm-test/okvm_report.py +++ b/test/okvm-test/okvm_report.py @@ -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") diff --git a/test/okvm-test/okvm_testctl.py b/test/okvm-test/okvm_testctl.py index 440e0d64..b285eab6 100755 --- a/test/okvm-test/okvm_testctl.py +++ b/test/okvm-test/okvm_testctl.py @@ -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")