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` 自动选择视频输入:
|
控制端会先通过 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>` 覆盖。
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user