功能:完善2K串流、显示恢复与输入优化
This commit is contained in:
1 parent
e47b01beec
commit
938ec97cce
95 files changed
+5788
-72
No files matched your search
@@ -55,6 +55,11 @@ target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Test)
|
||||
if(UNIX)
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter)
|
||||
add_test(NAME display-guard COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_display_guard.py)
|
||||
endif()
|
||||
|
||||
add_executable(test-moonlight-session
|
||||
tests/test_moonlight_session.cpp
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# RK3528 vendor-kernel media interfaces. Members of video may use the VPU.
|
||||
KERNEL=="mpp_service", GROUP="video", MODE="0660"
|
||||
KERNEL=="rga", GROUP="video", MODE="0660"
|
||||
SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660"
|
||||
@@ -18,6 +18,7 @@
|
||||
# NVENC重启故障及各档性能记录见 docs/moonlight-kiosk-20261028.md。
|
||||
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
|
||||
# performance-overlay 1=显示性能叠图(调试用)
|
||||
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
|
||||
# hang-timeout 串流假死判定时间(秒),0 表示关闭该检测
|
||||
host=192.168.1.70
|
||||
port=47984
|
||||
@@ -34,6 +35,7 @@ absolute-mouse=1
|
||||
vsync=1
|
||||
frame-pacing=1
|
||||
performance-overlay=0
|
||||
profile=smooth
|
||||
retry=3
|
||||
retry-delay=5
|
||||
hang-timeout=90
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=VDI HDMI mode recovery after monitor power cycle (X11 POC)
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=DISPLAY=:0
|
||||
Environment=XAUTHORITY=%h/.Xauthority
|
||||
ExecStart=%h/vdi-terminal/bin/vdi-display-guard
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore this RK3528 terminal's verified HDMI timing after X11 hotplug."""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
OUTPUT = "HDMI-1"
|
||||
MODE = "2560x1440_60_fixed297"
|
||||
# The vendor PHY rejects the EDID's 241.70 MHz timing. Keep the verified PLL.
|
||||
TIMING = "297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync".split()
|
||||
POLL_SECONDS = 2
|
||||
SETTLE_SECONDS = 2
|
||||
|
||||
|
||||
def log(message):
|
||||
print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)
|
||||
|
||||
|
||||
def xrandr(*args):
|
||||
result = subprocess.run(["xrandr", *args], capture_output=True, text=True,
|
||||
timeout=10, check=False)
|
||||
if result.returncode:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip()[:400])
|
||||
return result.stdout
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayState:
|
||||
connected: bool
|
||||
geometry: str
|
||||
active_mode: str
|
||||
modes: set
|
||||
defined: bool
|
||||
|
||||
@property
|
||||
def healthy(self):
|
||||
return (self.connected and self.geometry == "2560x1440+0+0"
|
||||
and self.active_mode == MODE)
|
||||
|
||||
|
||||
def parse_state(query):
|
||||
connected, geometry, active = False, "", ""
|
||||
modes, defined, in_output = set(), False, False
|
||||
for line in query.splitlines():
|
||||
fields = line.split()
|
||||
if not fields:
|
||||
continue
|
||||
if fields[0] == MODE:
|
||||
defined = True # Includes a globally defined but detached mode.
|
||||
if not line[0].isspace():
|
||||
in_output = fields[0] == OUTPUT
|
||||
if in_output:
|
||||
connected = len(fields) > 1 and fields[1] == "connected"
|
||||
geometry = next((f for f in fields[2:]
|
||||
if re.fullmatch(r"\d+x\d+\+\d+\+\d+", f)), "")
|
||||
elif in_output and len(fields) > 1 and re.match(r"^\d+(?:\.\d+)?[+*]*$", fields[1]):
|
||||
modes.add(fields[0])
|
||||
if "*" in " ".join(fields[1:]):
|
||||
active = fields[0]
|
||||
return DisplayState(connected, geometry, active, modes, defined)
|
||||
|
||||
|
||||
class Guard:
|
||||
def __init__(self, runner=xrandr, reporter=log):
|
||||
self.run = runner
|
||||
self.log = reporter
|
||||
self.bad_since = None
|
||||
self.next_retry = 0
|
||||
self.failures = 0
|
||||
self.last_status = None
|
||||
|
||||
def report(self, status):
|
||||
if status != self.last_status:
|
||||
self.log(status)
|
||||
self.last_status = status
|
||||
|
||||
def step(self, now):
|
||||
try:
|
||||
# Xorg already receives DRM hotplug events. Read its current state
|
||||
# without probing every EDID/mode: the vendor driver logs clock
|
||||
# warnings for those probes, adding unnecessary work while streaming.
|
||||
state = parse_state(self.run("--current"))
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
|
||||
self.bad_since = None
|
||||
self.report("X11 unavailable; waiting: " + str(error)[:400])
|
||||
return False
|
||||
if not state.connected:
|
||||
self.bad_since = None
|
||||
self.next_retry = 0
|
||||
self.failures = 0
|
||||
self.report(OUTPUT + " disconnected; no mode changes")
|
||||
return False
|
||||
if state.healthy:
|
||||
self.bad_since = None
|
||||
self.next_retry = 0
|
||||
self.failures = 0
|
||||
self.report("healthy: " + MODE)
|
||||
return True
|
||||
self.report("mode drift: " + (state.active_mode or "off") + " " + state.geometry)
|
||||
if self.bad_since is None:
|
||||
self.bad_since = now
|
||||
if now - self.bad_since < SETTLE_SECONDS or now < self.next_retry:
|
||||
return False
|
||||
try:
|
||||
if not state.defined:
|
||||
self.run("--newmode", MODE, *TIMING)
|
||||
if MODE not in state.modes:
|
||||
self.run("--addmode", OUTPUT, MODE)
|
||||
self.run("--output", OUTPUT, "--mode", MODE, "--pos", "0x0")
|
||||
if not parse_state(self.run("--current")).healthy:
|
||||
raise RuntimeError("mode was not retained after apply")
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
|
||||
self.failures = min(self.failures + 1, 6)
|
||||
delay = min(60, 2 ** self.failures)
|
||||
# Base backoff on completion, including a slow/timed-out modeset.
|
||||
self.next_retry = time.monotonic() + delay
|
||||
self.log("restore failed; retry in %ds: %s" % (delay, str(error)[:400]))
|
||||
return False
|
||||
self.bad_since = None
|
||||
self.next_retry = 0
|
||||
self.failures = 0
|
||||
self.report("restored: " + MODE + " (stream process untouched)")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="initial display recovery with a 20s retry window, then exit")
|
||||
args = parser.parse_args()
|
||||
os.environ.setdefault("DISPLAY", ":0")
|
||||
os.environ.setdefault("XAUTHORITY", str(Path.home() / ".Xauthority"))
|
||||
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", str(Path.home() / ".cache/vdi-terminal")))
|
||||
runtime.mkdir(parents=True, exist_ok=True)
|
||||
# Same lock for startup and the systemd service. Never run competing guards.
|
||||
with (runtime / "vdi-display-guard.lock").open("a") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
log("display guard already running")
|
||||
return 0
|
||||
stop = False
|
||||
|
||||
def request_stop(signum, frame):
|
||||
nonlocal stop
|
||||
stop = True
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
guard = Guard()
|
||||
deadline = time.monotonic() + 20
|
||||
while not stop:
|
||||
if guard.step(time.monotonic()) and args.once:
|
||||
return 0
|
||||
if args.once and time.monotonic() >= deadline:
|
||||
return 1
|
||||
time.sleep(POLL_SECONDS)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,26 +3,28 @@
|
||||
export DISPLAY="${DISPLAY:-:0}"
|
||||
export XAUTHORITY="${XAUTHORITY:-/home/armbian/.Xauthority}"
|
||||
|
||||
# Match the local output to the 2K Moonlight stream.
|
||||
# This kernel rejects the monitor's 241.70 MHz timing but accepts the 297 MHz
|
||||
# PLL rate. Longer vertical blanking produces 2560x1440 at approximately 60 Hz.
|
||||
# HDMI can appear a few seconds after the desktop autostart entry runs.
|
||||
: >/tmp/vdi-display-mode.log
|
||||
mode="2560x1440_60_fixed297"
|
||||
for _ in $(seq 1 20); do
|
||||
if xrandr --query 2>>/tmp/vdi-display-mode.log | grep -q '^HDMI-1 connected'; then
|
||||
xrandr --newmode "$mode" 297.00 \
|
||||
2560 2608 2640 2720 1440 1443 1448 1820 \
|
||||
+HSync -VSync >>/tmp/vdi-display-mode.log 2>&1 || true
|
||||
xrandr --addmode HDMI-1 "$mode" >>/tmp/vdi-display-mode.log 2>&1 || true
|
||||
if xrandr --output HDMI-1 --mode "$mode" >>/tmp/vdi-display-mode.log 2>&1; then
|
||||
break
|
||||
fi
|
||||
xrandr --output HDMI-1 --mode 1920x1080 --rate 60 \
|
||||
>>/tmp/vdi-display-mode.log 2>&1 || true
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Optional verified rendering deployment. Parse an allowlist as data; never
|
||||
# source/eval configuration. Explicit environment overrides the saved defaults.
|
||||
render_conf="${VDI_RENDER_CONF:-$HOME/.config/vdi-terminal/render.conf}"
|
||||
if [ -r "$render_conf" ]; then
|
||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
case "$key" in
|
||||
profile) export VDI_STREAM_PROFILE="${VDI_STREAM_PROFILE:-$value}" ;;
|
||||
stats) export VDI_STREAM_STATS="${VDI_STREAM_STATS:-$value}" ;;
|
||||
program) export VDI_MOONLIGHT_PROGRAM="${VDI_MOONLIGHT_PROGRAM:-$value}" ;;
|
||||
decoder) export VDI_MOONLIGHT_DECODER="${VDI_MOONLIGHT_DECODER:-$value}" ;;
|
||||
codec) export VDI_MOONLIGHT_CODEC="${VDI_MOONLIGHT_CODEC:-$value}" ;;
|
||||
esac
|
||||
done < "$render_conf"
|
||||
fi
|
||||
|
||||
# Use the same idempotent recovery for boot and HDMI hotplug. The user service
|
||||
# supervises ongoing recovery; a singleton lock prevents competing instances.
|
||||
"$(dirname "$0")/vdi-display-guard" --once >>/tmp/vdi-display-mode.log 2>&1 || true
|
||||
if ! systemctl --user start vdi-display-guard.service >>/tmp/vdi-display-mode.log 2>&1; then
|
||||
echo 'WARNING: display guard service unavailable; only boot recovery active' \
|
||||
>>/tmp/vdi-display-mode.log
|
||||
fi
|
||||
|
||||
exec /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- \
|
||||
/home/armbian/vdi-terminal/bin/kiosk-shell
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# 职责:
|
||||
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
|
||||
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
|
||||
# 2. 按配置拉起 Moonlight,全屏串流云桌面
|
||||
# 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死
|
||||
# 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连
|
||||
#
|
||||
@@ -12,7 +12,8 @@
|
||||
#
|
||||
# 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的
|
||||
# --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device",
|
||||
# 因此配置里显式指定 decoder=software。接入 MPP/VPU 硬解后再改回 auto。
|
||||
# 因此系统客户端默认 decoder=software;验证后的独立 RKMPP 客户端
|
||||
# 通过 VDI_MOONLIGHT_PROGRAM 与 decoder=hardware 显式启用。
|
||||
|
||||
set -u
|
||||
|
||||
@@ -38,9 +39,10 @@ DECODER="software"
|
||||
DISPLAY_MODE="fullscreen"
|
||||
AUDIO=1
|
||||
ABSOLUTE_MOUSE=1
|
||||
VSYNC=0
|
||||
FRAME_PACING=0
|
||||
VSYNC=1
|
||||
FRAME_PACING=1
|
||||
PERFORMANCE_OVERLAY=0
|
||||
PROFILE=""
|
||||
RETRY=3
|
||||
RETRY_DELAY=5
|
||||
HANG_TIMEOUT=90
|
||||
@@ -76,6 +78,7 @@ load_config() {
|
||||
vsync) VSYNC="$value" ;;
|
||||
frame-pacing) FRAME_PACING="$value" ;;
|
||||
performance-overlay) PERFORMANCE_OVERLAY="$value" ;;
|
||||
profile) PROFILE="$value" ;;
|
||||
retry) RETRY="$value" ;;
|
||||
retry-delay) RETRY_DELAY="$value" ;;
|
||||
hang-timeout) HANG_TIMEOUT="$value" ;;
|
||||
@@ -84,6 +87,33 @@ load_config() {
|
||||
done < "$CONF"
|
||||
}
|
||||
|
||||
apply_profile() {
|
||||
case "$PROFILE" in
|
||||
smooth)
|
||||
VSYNC=1
|
||||
FRAME_PACING=1
|
||||
;;
|
||||
low-latency)
|
||||
VSYNC=0
|
||||
FRAME_PACING=0
|
||||
;;
|
||||
diagnostic)
|
||||
VSYNC=1
|
||||
FRAME_PACING=1
|
||||
PERFORMANCE_OVERLAY=1
|
||||
;;
|
||||
"")
|
||||
# 兼容旧配置:没有profile时保留moonlight.conf中的显式开关。
|
||||
;;
|
||||
*)
|
||||
log "未知串流 profile=$PROFILE,回退 smooth"
|
||||
PROFILE=smooth
|
||||
VSYNC=1
|
||||
FRAME_PACING=1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
|
||||
rx_bytes() {
|
||||
iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route)
|
||||
@@ -145,7 +175,7 @@ disable_screensaver() {
|
||||
build_cmd() {
|
||||
# moonlight-qt stream 只接受 <host> <app> 位置参数,没有 --port 选项,
|
||||
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
|
||||
CMD="moonlight-qt stream $HOST $APP"
|
||||
CMD="${VDI_MOONLIGHT_PROGRAM:-moonlight-qt} stream $HOST $APP"
|
||||
CMD="$CMD --resolution ${WIDTH}x${HEIGHT}"
|
||||
CMD="$CMD --fps $FPS"
|
||||
CMD="$CMD --bitrate $BITRATE"
|
||||
@@ -155,7 +185,7 @@ build_cmd() {
|
||||
CMD="$CMD --keep-awake"
|
||||
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
|
||||
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
|
||||
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
|
||||
# 按profile选择同步策略;默认保持当前稳定档。
|
||||
[ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync"
|
||||
[ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing"
|
||||
[ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay"
|
||||
@@ -163,7 +193,7 @@ build_cmd() {
|
||||
|
||||
run_stream() {
|
||||
build_cmd
|
||||
log "启动串流: $CMD"
|
||||
log "启动串流 profile=${PROFILE:-config} decoder=$DECODER codec=$CODEC vsync=$VSYNC frame-pacing=$FRAME_PACING renderer=${SDL_RENDER_DRIVER:-} video-driver=${SDL_VIDEODRIVER:-}: $CMD"
|
||||
# shellcheck disable=SC2086
|
||||
$CMD >> "$LOG_FILE" 2>&1 &
|
||||
ML_PID=$!
|
||||
@@ -220,6 +250,19 @@ stop)
|
||||
esac
|
||||
|
||||
load_config
|
||||
PROFILE="${VDI_STREAM_PROFILE:-$PROFILE}"
|
||||
apply_profile
|
||||
DECODER="${VDI_MOONLIGHT_DECODER:-$DECODER}"
|
||||
CODEC="${VDI_MOONLIGHT_CODEC:-$CODEC}"
|
||||
case "$DECODER" in
|
||||
software|hardware|auto) ;;
|
||||
*) log "未知解码器 $DECODER,回退 software"; DECODER=software ;;
|
||||
esac
|
||||
case "$CODEC" in
|
||||
H.264|HEVC|AV1|auto) ;;
|
||||
*) log "未知视频编码 $CODEC,回退 H.264"; CODEC=H.264 ;;
|
||||
esac
|
||||
PERFORMANCE_OVERLAY="${VDI_STREAM_STATS:-$PERFORMANCE_OVERLAY}"
|
||||
if [ -z "$HOST" ]; then
|
||||
log "配置缺失或缺少 host: $CONF"
|
||||
exit 2
|
||||
@@ -227,6 +270,10 @@ fi
|
||||
|
||||
export DISPLAY="${VDI_DISPLAY:-:0}"
|
||||
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
|
||||
export SDL_RENDER_DRIVER="${VDI_SDL_RENDER_DRIVER:-opengles2}"
|
||||
export SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR="${VDI_SDL_BYPASS_COMPOSITOR:-1}"
|
||||
[ -n "${VDI_SDL_FORCE_EGL:-}" ] && export SDL_VIDEO_X11_FORCE_EGL="$VDI_SDL_FORCE_EGL"
|
||||
[ -n "${VDI_SDL_VIDEODRIVER:-}" ] && export SDL_VIDEODRIVER="$VDI_SDL_VIDEODRIVER"
|
||||
|
||||
disable_screensaver
|
||||
kill_stale
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Select explicitly after RKMPP decode + real stream validation. Does not replace
|
||||
# the distribution Moonlight or add a second cursor.
|
||||
set -eu
|
||||
task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
|
||||
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
|
||||
if [ ! -x "$client" ] || [ ! -r /dev/mpp_service ] || [ ! -w /dev/mpp_service ]; then
|
||||
echo 'RKMPP client or device access unavailable' >&2
|
||||
exit 78
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
|
||||
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
|
||||
export GL_IS_SLOW="${GL_IS_SLOW:-0}"
|
||||
export H264_DECODER_HINT=h264_rkmpp
|
||||
export HEVC_DECODER_HINT=hevc_rkmpp
|
||||
# Only affects unpaced rendering in the patched client. Leave the last presented
|
||||
# frame and decoder references intact; replace only waiting decoded frames.
|
||||
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}"
|
||||
exec "$client" "$@"
|
||||
@@ -82,10 +82,10 @@ void DesktopListPage::rebuildCards() {
|
||||
|
||||
for (const DesktopInfo& d : desktops_) {
|
||||
auto* card = new QFrame(this);
|
||||
card->setObjectName(QStringLiteral("desktopCard"));
|
||||
card->setMinimumHeight(96);
|
||||
card->setStyleSheet(QStringLiteral(
|
||||
"QFrame { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
|
||||
"QFrame:hover { border: 1px solid #2f6feb; }"
|
||||
"QFrame#desktopCard { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
|
||||
"QLabel { border: none; background: transparent; }"));
|
||||
|
||||
auto* row = new QHBoxLayout(card);
|
||||
|
||||
@@ -64,7 +64,6 @@ void MainWindow::setupUi() {
|
||||
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
||||
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
|
||||
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
|
||||
"QLineEdit:focus { border: 1px solid #2f6feb; }"
|
||||
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
|
||||
"border-radius: 6px; color: #e6e9ef; }"
|
||||
"QPushButton:hover { background: #2b3654; }"));
|
||||
@@ -289,11 +288,8 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
||||
}
|
||||
|
||||
bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
|
||||
MoonlightSession::Options opts;
|
||||
opts.decoder = QStringLiteral("software");
|
||||
opts.videoCodec = QStringLiteral("H.264");
|
||||
opts.audioOnHost = true;
|
||||
moonlightSession_->setOptions(opts);
|
||||
moonlightSession_->setOptions(MoonlightSession::optionsFromEnvironment(
|
||||
QProcessEnvironment::systemEnvironment()));
|
||||
|
||||
phaseClock_.restart();
|
||||
connectionPhase_ = ConnectionPhase::kProcessStart;
|
||||
|
||||
@@ -14,7 +14,11 @@ constexpr int kReadyStableMs = 750;
|
||||
constexpr int kTerminateGraceMs = 2000;
|
||||
|
||||
bool isTestDecodeLine(const QByteArray& line) {
|
||||
return line.toLower().contains("test decode successful");
|
||||
const QByteArray lower = line.toLower();
|
||||
// Hardware decoders use a separate test context. Upstream logs this only
|
||||
// after that context has successfully decoded and validated a test frame.
|
||||
return lower.contains("test decode successful") ||
|
||||
lower.contains("not reusing test decoder for ");
|
||||
}
|
||||
|
||||
bool isRendererLine(const QByteArray& line) {
|
||||
@@ -105,6 +109,66 @@ void MoonlightSession::setOptions(const Options& options) {
|
||||
options_ = options;
|
||||
}
|
||||
|
||||
MoonlightSession::Options MoonlightSession::optionsFromEnvironment(
|
||||
const QProcessEnvironment& environment) {
|
||||
Options options;
|
||||
const QString profile = environment.value(QStringLiteral("VDI_STREAM_PROFILE"));
|
||||
if (profile == QStringLiteral("low-latency")) {
|
||||
options.profile = profile;
|
||||
options.vsync = false;
|
||||
options.framePacing = false;
|
||||
} else if (profile == QStringLiteral("diagnostic")) {
|
||||
options.profile = profile;
|
||||
options.performanceOverlay = true;
|
||||
}
|
||||
// Statistics must be independently selectable for a fair profile A/B test.
|
||||
if (environment.contains(QStringLiteral("VDI_STREAM_STATS"))) {
|
||||
options.performanceOverlay = environment.value(QStringLiteral("VDI_STREAM_STATS")) == QStringLiteral("1");
|
||||
}
|
||||
const QString decoder = environment.value(QStringLiteral("VDI_MOONLIGHT_DECODER"));
|
||||
if (QStringList{QStringLiteral("software"), QStringLiteral("hardware"),
|
||||
QStringLiteral("auto")}.contains(decoder)) {
|
||||
options.decoder = decoder;
|
||||
}
|
||||
const QString codec = environment.value(QStringLiteral("VDI_MOONLIGHT_CODEC"));
|
||||
if (QStringList{QStringLiteral("H.264"), QStringLiteral("HEVC"),
|
||||
QStringLiteral("AV1"), QStringLiteral("auto")}.contains(codec)) {
|
||||
options.videoCodec = codec;
|
||||
}
|
||||
const QString program = environment.value(QStringLiteral("VDI_MOONLIGHT_PROGRAM"));
|
||||
if (!program.isEmpty()) {
|
||||
options.program = program;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticket,
|
||||
const Options& options) {
|
||||
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
|
||||
QStringList args;
|
||||
args << QStringLiteral("stream")
|
||||
<< ticket.endpoint
|
||||
<< ticket.app
|
||||
<< QStringLiteral("--resolution")
|
||||
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
|
||||
<< QStringLiteral("--fps") << QString::number(ticket.fps)
|
||||
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
|
||||
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
|
||||
<< QStringLiteral("--video-codec") << options.videoCodec
|
||||
<< QStringLiteral("--video-decoder") << options.decoder
|
||||
<< QStringLiteral("--absolute-mouse")
|
||||
<< QStringLiteral("--keep-awake")
|
||||
<< (options.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"))
|
||||
<< (options.framePacing ? QStringLiteral("--frame-pacing")
|
||||
: QStringLiteral("--no-frame-pacing"));
|
||||
if (options.performanceOverlay) {
|
||||
args << QStringLiteral("--performance-overlay");
|
||||
}
|
||||
args << (options.audioOnHost ? QStringLiteral("--audio-on-host")
|
||||
: QStringLiteral("--no-audio-on-host"));
|
||||
return args;
|
||||
}
|
||||
|
||||
bool MoonlightSession::isRunning() const {
|
||||
return process_->state() != QProcess::NotRunning;
|
||||
}
|
||||
@@ -156,34 +220,26 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
|
||||
process_->setProgram(QStringLiteral("moonlight-qt"));
|
||||
process_->setProgram(options_.program);
|
||||
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
||||
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
|
||||
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"), QStringLiteral("opengles2"));
|
||||
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
|
||||
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
|
||||
// Xorg下绕过桌面合成器;Wayland/KMS部署时可通过SDL_VIDEODRIVER切换,
|
||||
// 不在客户端里硬编码会话类型,避免当前Xorg镜像无法启动。
|
||||
environment.insert(QStringLiteral("SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"),
|
||||
environment.value(QStringLiteral("VDI_SDL_BYPASS_COMPOSITOR"), QStringLiteral("1")));
|
||||
const QString forceEgl = qEnvironmentVariable("VDI_SDL_FORCE_EGL");
|
||||
if (!forceEgl.isEmpty()) {
|
||||
environment.insert(QStringLiteral("SDL_VIDEO_X11_FORCE_EGL"), forceEgl);
|
||||
}
|
||||
const QString sdlVideoDriver = qEnvironmentVariable("VDI_SDL_VIDEODRIVER");
|
||||
if (!sdlVideoDriver.isEmpty()) {
|
||||
environment.insert(QStringLiteral("SDL_VIDEODRIVER"), sdlVideoDriver);
|
||||
}
|
||||
process_->setProcessEnvironment(environment);
|
||||
|
||||
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
|
||||
QStringList args;
|
||||
args << QStringLiteral("stream")
|
||||
<< ticket.endpoint
|
||||
<< ticket.app
|
||||
<< QStringLiteral("--resolution")
|
||||
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
|
||||
<< QStringLiteral("--fps") << QString::number(ticket.fps)
|
||||
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
|
||||
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
|
||||
<< QStringLiteral("--video-codec") << options_.videoCodec
|
||||
<< QStringLiteral("--video-decoder") << options_.decoder
|
||||
<< QStringLiteral("--absolute-mouse")
|
||||
<< QStringLiteral("--keep-awake");
|
||||
args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
|
||||
args << (options_.framePacing ? QStringLiteral("--frame-pacing")
|
||||
: QStringLiteral("--no-frame-pacing"));
|
||||
if (options_.performanceOverlay) {
|
||||
args << QStringLiteral("--performance-overlay");
|
||||
}
|
||||
args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
|
||||
: QStringLiteral("--no-audio-on-host"));
|
||||
const QStringList args = buildStreamArguments(ticket, options_);
|
||||
process_->setArguments(args);
|
||||
|
||||
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
|
||||
@@ -191,6 +247,23 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
|
||||
logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
|
||||
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
|
||||
|
||||
// 把最终生效的环境和参数写入日志,避免只看Moonlight叠图时无法判断
|
||||
// 产品入口到底使用了哪一套呈现策略。
|
||||
QFile launchLog(logPath_);
|
||||
if (launchLog.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
QTextStream stream(&launchLog);
|
||||
stream << "\n[VDI launch]\n"
|
||||
<< "profile=" << options_.profile << "\n"
|
||||
<< "program=" << options_.program << "\n"
|
||||
<< "decoder=" << options_.decoder << "\n"
|
||||
<< "codec=" << options_.videoCodec << "\n"
|
||||
<< "vsync=" << (options_.vsync ? "1" : "0") << "\n"
|
||||
<< "frame-pacing=" << (options_.framePacing ? "1" : "0") << "\n"
|
||||
<< "renderer=" << environment.value(QStringLiteral("SDL_RENDER_DRIVER")) << "\n"
|
||||
<< "video-driver=" << environment.value(QStringLiteral("SDL_VIDEODRIVER")) << "\n"
|
||||
<< "args=" << args.join(QLatin1Char(' ')) << "\n";
|
||||
}
|
||||
|
||||
process_->start();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QStringList>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
@@ -37,7 +39,13 @@ class MoonlightSession : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// 产品入口与调试脚本遵循相同的profile语义。
|
||||
// smooth:优先稳定呈现,适合当前2K20/2K30;
|
||||
// low-latency:关闭客户端额外的同步等待,适合1080p60 A/B;
|
||||
// diagnostic:在smooth基础上打开Moonlight性能叠图。
|
||||
struct Options {
|
||||
QString program = QStringLiteral("moonlight-qt");
|
||||
QString profile = QStringLiteral("smooth");
|
||||
bool vsync = true;
|
||||
bool framePacing = true;
|
||||
bool performanceOverlay = false;
|
||||
@@ -64,6 +72,9 @@ public:
|
||||
~MoonlightSession() override;
|
||||
|
||||
void setOptions(const Options& options);
|
||||
static Options optionsFromEnvironment(const QProcessEnvironment& environment);
|
||||
static QStringList buildStreamArguments(const ConnectionTicket& ticket,
|
||||
const Options& options);
|
||||
bool start(const ConnectionTicket& ticket, EventCallback callback);
|
||||
void stop();
|
||||
bool isRunning() const;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Failure and hotplug scenarios; no real display or sleep required."""
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
guard_module = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/vdi-display-guard"))
|
||||
Guard = guard_module["Guard"]
|
||||
MODE = guard_module["MODE"]
|
||||
|
||||
|
||||
class FakeRandR:
|
||||
def __init__(self):
|
||||
self.connected = True
|
||||
self.defined = True
|
||||
self.attached = True
|
||||
self.active = "1920x1440"
|
||||
self.writes = []
|
||||
self.fail_apply = False
|
||||
self.query_error = False
|
||||
self.keep_wrong_mode = False
|
||||
|
||||
def __call__(self, *args):
|
||||
if args == ("--current",):
|
||||
if self.query_error:
|
||||
raise RuntimeError("Can't open display :0")
|
||||
status = "connected primary " + ("2560x1440+0+0" if self.active == MODE else "1920x1440+0+0")
|
||||
if not self.connected:
|
||||
status = "disconnected"
|
||||
query = "Screen 0: current 1920 x 1440\nHDMI-1 " + status + " (normal)\n"
|
||||
if self.connected:
|
||||
query += " 1920x1440 75.00" + ("*" if self.active != MODE else "") + "\n"
|
||||
if self.attached:
|
||||
query += " " + MODE + " 60.00" + ("*" if self.active == MODE else "") + "\n"
|
||||
if self.defined and (not self.attached or not self.connected):
|
||||
query += " " + MODE + " (0x189) 297.000MHz +HSync -VSync\n"
|
||||
query += " h: width 2560 start 2608\n v: height 1440 start 1443\n"
|
||||
return query
|
||||
self.writes.append(args)
|
||||
if args[0] == "--newmode":
|
||||
if self.defined:
|
||||
raise RuntimeError("BadName: duplicate mode")
|
||||
self.defined = True
|
||||
elif args[0] == "--addmode":
|
||||
assert self.defined
|
||||
self.attached = True
|
||||
elif args[0] == "--output":
|
||||
if self.fail_apply:
|
||||
raise RuntimeError("Configure crtc failed")
|
||||
assert self.attached
|
||||
if not self.keep_wrong_mode:
|
||||
self.active = MODE
|
||||
return ""
|
||||
|
||||
|
||||
class DisplayGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.xr = FakeRandR()
|
||||
self.logs = []
|
||||
self.guard = Guard(self.xr, self.logs.append)
|
||||
|
||||
def tick(self, now):
|
||||
with patch("time.monotonic", return_value=now):
|
||||
return self.guard.step(now)
|
||||
|
||||
def test_mode_drift_restores_after_settling(self):
|
||||
self.assertFalse(self.tick(0))
|
||||
self.assertEqual(self.xr.writes, [])
|
||||
self.assertTrue(self.tick(2))
|
||||
self.assertEqual(len(self.xr.writes), 1)
|
||||
self.assertEqual(self.xr.active, MODE)
|
||||
|
||||
def test_healthy_display_does_not_modeset_or_repeat_logs(self):
|
||||
self.xr.active = MODE
|
||||
for now in range(0, 100, 2):
|
||||
self.assertTrue(self.tick(now))
|
||||
self.assertEqual(self.xr.writes, [])
|
||||
self.assertEqual(len(self.logs), 1)
|
||||
|
||||
def test_disconnected_never_writes_and_reconnect_settles(self):
|
||||
self.tick(0)
|
||||
self.xr.connected = False
|
||||
for now in range(2, 20, 2):
|
||||
self.assertFalse(self.tick(now))
|
||||
self.assertEqual(self.xr.writes, [])
|
||||
self.xr.connected = True
|
||||
self.assertFalse(self.tick(20))
|
||||
self.assertTrue(self.tick(22))
|
||||
|
||||
def test_new_x_server_recreates_and_attaches_mode(self):
|
||||
self.xr.defined = self.xr.attached = False
|
||||
self.tick(0)
|
||||
self.assertTrue(self.tick(2))
|
||||
self.assertEqual([w[0] for w in self.xr.writes], ["--newmode", "--addmode", "--output"])
|
||||
|
||||
def test_hotplug_detached_mode_is_not_created_twice(self):
|
||||
self.xr.attached = False
|
||||
self.tick(0)
|
||||
self.assertTrue(self.tick(2))
|
||||
self.assertEqual([w[0] for w in self.xr.writes], ["--addmode", "--output"])
|
||||
|
||||
def test_late_desktop_settings_overwrite_is_corrected_again(self):
|
||||
self.tick(0)
|
||||
self.assertTrue(self.tick(2))
|
||||
self.xr.active = "1920x1440"
|
||||
self.assertFalse(self.tick(4))
|
||||
self.assertTrue(self.tick(6))
|
||||
self.assertEqual(len(self.xr.writes), 2)
|
||||
|
||||
def test_modeset_failure_backs_off_and_eventually_recovers(self):
|
||||
self.xr.fail_apply = True
|
||||
self.tick(0)
|
||||
self.tick(2) # retry at 4
|
||||
self.tick(4) # retry at 8
|
||||
self.tick(6) # no write
|
||||
self.assertEqual(len(self.xr.writes), 2)
|
||||
self.xr.fail_apply = False
|
||||
self.assertTrue(self.tick(8))
|
||||
self.assertEqual(len(self.xr.writes), 3)
|
||||
|
||||
def test_apply_success_must_be_verified(self):
|
||||
self.xr.keep_wrong_mode = True
|
||||
self.tick(0)
|
||||
self.assertFalse(self.tick(2))
|
||||
self.assertIn("not retained", self.logs[-1])
|
||||
|
||||
def test_x11_unavailable_does_not_write_and_then_recovers(self):
|
||||
self.xr.query_error = True
|
||||
for now in range(0, 20, 2):
|
||||
self.assertFalse(self.tick(now))
|
||||
self.assertEqual(self.xr.writes, [])
|
||||
self.assertEqual(len(self.logs), 1)
|
||||
self.xr.query_error = False
|
||||
self.assertFalse(self.tick(20))
|
||||
self.assertTrue(self.tick(22))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,7 +10,11 @@ class MoonlightSessionTest : public QObject {
|
||||
private slots:
|
||||
void parsesSplitReadyMarkers();
|
||||
void requiresBothReadyMarkers();
|
||||
void acceptsSeparateHardwareTestDecoder();
|
||||
void rejectsSecondStart();
|
||||
void buildsConsistentStreamArguments();
|
||||
void selectsProfileWithoutLosingStats();
|
||||
void rejectsInvalidDecoderOverride();
|
||||
};
|
||||
|
||||
void MoonlightSessionTest::parsesSplitReadyMarkers() {
|
||||
@@ -38,6 +42,19 @@ void MoonlightSessionTest::requiresBothReadyMarkers() {
|
||||
QVERIFY(!parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::acceptsSeparateHardwareTestDecoder() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Using custom H.264 decoder (H264_DECODER_HINT): h264_rkmpp\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
parser.append("Not reusing test decoder for h264_rkmpp\n");
|
||||
QVERIFY(parser.hasTestDecode());
|
||||
QVERIFY(!parser.isReady());
|
||||
parser.append("Renderer 'EGL' with 'DRM' backend chosen\n");
|
||||
QVERIFY(parser.isReady());
|
||||
parser.append("Connection terminated: -1\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::rejectsSecondStart() {
|
||||
QTemporaryDir tempDir;
|
||||
QVERIFY(tempDir.isValid());
|
||||
@@ -90,5 +107,61 @@ void MoonlightSessionTest::rejectsSecondStart() {
|
||||
qputenv("PATH", oldPath);
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::buildsConsistentStreamArguments() {
|
||||
ConnectionTicket ticket;
|
||||
ticket.endpoint = QStringLiteral("192.168.1.70");
|
||||
ticket.app = QStringLiteral("Desktop");
|
||||
ticket.width = 2560;
|
||||
ticket.height = 1440;
|
||||
ticket.fps = 30;
|
||||
|
||||
MoonlightSession::Options options;
|
||||
options.profile = QStringLiteral("low-latency");
|
||||
options.decoder = QStringLiteral("software");
|
||||
options.videoCodec = QStringLiteral("H.264");
|
||||
options.vsync = false;
|
||||
options.framePacing = false;
|
||||
options.performanceOverlay = true;
|
||||
|
||||
const QStringList args = MoonlightSession::buildStreamArguments(ticket, options);
|
||||
QVERIFY(args.contains(QStringLiteral("--resolution")));
|
||||
QVERIFY(args.contains(QStringLiteral("2560x1440")));
|
||||
QVERIFY(args.contains(QStringLiteral("--fps")));
|
||||
QVERIFY(args.contains(QStringLiteral("30")));
|
||||
QVERIFY(args.contains(QStringLiteral("--bitrate")));
|
||||
QVERIFY(args.contains(QStringLiteral("30000")));
|
||||
QVERIFY(args.contains(QStringLiteral("--no-vsync")));
|
||||
QVERIFY(args.contains(QStringLiteral("--no-frame-pacing")));
|
||||
QVERIFY(args.contains(QStringLiteral("--performance-overlay")));
|
||||
QVERIFY(args.contains(QStringLiteral("--absolute-mouse")));
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::selectsProfileWithoutLosingStats() {
|
||||
QProcessEnvironment environment;
|
||||
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("low-latency"));
|
||||
environment.insert(QStringLiteral("VDI_STREAM_STATS"), QStringLiteral("1"));
|
||||
auto options = MoonlightSession::optionsFromEnvironment(environment);
|
||||
QVERIFY(!options.vsync);
|
||||
QVERIFY(!options.framePacing);
|
||||
QVERIFY(options.performanceOverlay);
|
||||
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("smooth"));
|
||||
options = MoonlightSession::optionsFromEnvironment(environment);
|
||||
QVERIFY(options.vsync);
|
||||
QVERIFY(options.framePacing);
|
||||
QVERIFY(options.performanceOverlay);
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::rejectsInvalidDecoderOverride() {
|
||||
QProcessEnvironment environment;
|
||||
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("h264_rkmpp"));
|
||||
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("invalid"));
|
||||
auto options = MoonlightSession::optionsFromEnvironment(environment);
|
||||
QCOMPARE(options.decoder, QStringLiteral("software"));
|
||||
QCOMPARE(options.profile, QStringLiteral("smooth"));
|
||||
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("hardware"));
|
||||
options = MoonlightSession::optionsFromEnvironment(environment);
|
||||
QCOMPARE(options.decoder, QStringLiteral("hardware"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(MoonlightSessionTest)
|
||||
#include "test_moonlight_session.moc"
|
||||
@@ -0,0 +1,40 @@
|
||||
# VDI Moonlight 交互优化补丁
|
||||
|
||||
本目录维护当前 RK3528 / Xorg POC 私有客户端的源码修改,不替换系统 Moonlight。
|
||||
|
||||
基线:Moonlight Qt v6.2.0 `de2467e433821664cdd2224aad8c89a625be1ad9`,其检出的 moonlight-common-c 子模块保持对应版本。MPP、FFmpeg 版本仍由 `tools/build-rkmpp-client.sh` 固定。
|
||||
|
||||
## 补丁
|
||||
|
||||
- `0001-vdi-input-and-latest-frame.patch` 在 Moonlight Qt 根目录应用。鼠标移动只合并队首连续事件,遇到按钮、键盘、窗口、呈现事件或其他鼠标设备立即停止;每次最多额外合并 64 个事件。绝对模式在发送按键前使用该按键事件的坐标。低延迟队列可选地把已等待的解码帧替换成新帧。
|
||||
- `0002-vdi-absolute-click-barrier.patch` 在 `moonlight-common-c/moonlight-common-c` 中应用。鼠标按钮封住此前的位置批次,后续移动只能进入新批次;发送线程只锁定自己的批次,避免旧包出队时误清除新批次。所有包依旧走原可靠鼠标通道,保留 1ms 合并限流和原坐标边界修正。
|
||||
- `0003-vdi-input-priority.patch` 在 Qt 根目录应用。可选地让帧就绪后的连续输入先执行,最多 32 条或 2ms,不能跨窗口/焦点/其他事件。`VDI_INPUT_PRIORITY=1` 才启用,默认关闭。另提供 `VDI_INPUT_TIMING=1` 的汇总统计,区分 SDL 输入派发年龄与主线程呈现调用耗时,不代表端到端延迟。
|
||||
|
||||
2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。诊断候选二进制保存为 `moonlight.input-priority-20261103`;默认客户端已恢复到本轮前 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。详细计划/初测限制见 `docs/mouse-optimization-plan-20261103.md`,独立光标接入设计见 `docs/independent-cursor-design-20261103.md`。
|
||||
|
||||
`VDI_LATEST_FRAME=1` 只在没有 VSync pacing source 的呈现队列生效。最多保留一个待呈现解码帧,丢弃数计入原 `pacerDroppedFrames`。不丢压缩包,不改变 H.264 参考链,也不提前释放正在呈现或延迟释放的上一帧。未设置或设为 `0` 时保留上游帧队列策略;kiosk 私有 wrapper 默认设为 `1`。smooth 档仍使用原 pacing 行为。
|
||||
|
||||
这些补丁不增加本地光标,不隐藏 Windows 光标,不改变控制协议。远程光标仍随视频显示,其延迟仍受服务器捕获/编码、网络、解码和显示影响。
|
||||
|
||||
## 构建与测试
|
||||
|
||||
在盒子上运行构建器时,将本目录的 `patches/` 一起复制过去,通过 `VDI_MOONLIGHT_PATCH_DIR` 指定目录。构建器先检查固定提交,再检查补丁:已完整应用则跳过;上下文不匹配则失败,不强行覆盖其他修改。
|
||||
|
||||
```bash
|
||||
VDI_BUILD_STAGE=moonlight \
|
||||
VDI_MOONLIGHT_PATCH_DIR="$HOME/vdi-build/rkmpp-experiment/patches" \
|
||||
bash tools/build-rkmpp-client.sh
|
||||
```
|
||||
|
||||
`tests/` 的文件复制到盒子 `$VDI_RKMPP_BUILD_ROOT/latency-tests/` 后运行:
|
||||
|
||||
```bash
|
||||
bash tools/test-moonlight-latency.sh
|
||||
VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latency.sh
|
||||
```
|
||||
|
||||
测试直接包含实际修改后的 SDL helper、AVFrame 队列 helper 与 `InputStream.c`,并链接客户端实际使用的 native 队列/线程库。覆盖跨点击合并、键盘/呈现边界、工作量上限、触摸隔离、200 帧的积压替换、已呈现帧所有权及 1,000 轮“移动→按下→移动→释放→移动”的发送线程滞后场景。测试显式启用断言,避免上游 release 头文件的 NDEBUG 使检查失效。
|
||||
|
||||
新增 `test_input_priority.cpp` 验证优先调度不越过焦点/窗口/退出/解码器重置事件,保持输入 FIFO,隔离其他窗口与合成触摸,并验证数量与时间限制。普通与 ASAN 版本均通过。当前 RKMPP/EGL 没有主线程呈现事件,新调度开关不能用作该路径的性能提升宣传。
|
||||
|
||||
现场数据、局限与完整回退入口见 `docs/input-rendering-optimization-20261103.md`。
|
||||
@@ -0,0 +1,271 @@
|
||||
--- a/app/streaming/input/mouse.cpp
|
||||
+++ b/app/streaming/input/mouse.cpp
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "input.h"
|
||||
+#include "vdi_mouse_batch.h"
|
||||
|
||||
#include <Limelight.h>
|
||||
#include "SDL_compat.h"
|
||||
@@ -84,6 +85,11 @@
|
||||
button = BUTTON_RIGHT;
|
||||
}
|
||||
|
||||
+ // Button coordinates are captured with this event, not a later mouse state.
|
||||
+ if (m_AbsoluteMouseMode) {
|
||||
+ sendAbsoluteMousePosition(event->x, event->y);
|
||||
+ }
|
||||
+
|
||||
LiSendMouseButtonEvent(event->state == SDL_PRESSED ?
|
||||
BUTTON_ACTION_PRESS :
|
||||
BUTTON_ACTION_RELEASE,
|
||||
@@ -101,81 +107,78 @@
|
||||
return;
|
||||
}
|
||||
|
||||
- // Batch all pending mouse motion events to save CPU time
|
||||
- Sint32 x = event->x, y = event->y, xrel = event->xrel, yrel = event->yrel;
|
||||
- SDL_Event nextEvent;
|
||||
- while (SDL_PeepEvents(&nextEvent, 1, SDL_GETEVENT, SDL_MOUSEMOTION, SDL_MOUSEMOTION) > 0) {
|
||||
- event = &nextEvent.motion;
|
||||
-
|
||||
- // Ignore synthetic mouse events
|
||||
- if (event->which != SDL_TOUCH_MOUSEID) {
|
||||
- x = event->x;
|
||||
- y = event->y;
|
||||
- xrel += event->xrel;
|
||||
- yrel += event->yrel;
|
||||
- }
|
||||
- }
|
||||
+ // Coalesce only adjacent motions. Never consume motion past a click/key
|
||||
+ // or render event, and bound work so a fast mouse cannot starve rendering.
|
||||
+ SDL_MouseMotionEvent batched = *event;
|
||||
+ vdiBatchMouseMotion(batched);
|
||||
+ Sint32 x = batched.x, y = batched.y;
|
||||
+ Sint32 xrel = batched.xrel, yrel = batched.yrel;
|
||||
|
||||
// We should not reference the original event anymore
|
||||
event = nullptr;
|
||||
|
||||
if (m_AbsoluteMouseMode) {
|
||||
- int windowWidth, windowHeight;
|
||||
- SDL_GetWindowSize(m_Window, &windowWidth, &windowHeight);
|
||||
-
|
||||
- SDL_Rect src, dst;
|
||||
- bool mouseInVideoRegion;
|
||||
-
|
||||
- src.x = src.y = 0;
|
||||
- src.w = m_StreamWidth;
|
||||
- src.h = m_StreamHeight;
|
||||
-
|
||||
- dst.x = dst.y = 0;
|
||||
- dst.w = windowWidth;
|
||||
- dst.h = windowHeight;
|
||||
-
|
||||
- // Use the stream and window sizes to determine the video region
|
||||
- StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
|
||||
-
|
||||
- mouseInVideoRegion = isMouseInVideoRegion(x, y, windowWidth, windowHeight);
|
||||
-
|
||||
- // Clamp motion to the video region
|
||||
- x = qMin(qMax(x - dst.x, 0), dst.w);
|
||||
- y = qMin(qMax(y - dst.y, 0), dst.h);
|
||||
-
|
||||
- // Send the mouse position update if one of the following is true:
|
||||
- // a) it is in the video region now
|
||||
- // b) it just left the video region (to ensure the mouse is clamped to the video boundary)
|
||||
- // c) a mouse button is still down from before the cursor left the video region (to allow smooth dragging)
|
||||
- Uint32 buttonState = SDL_GetMouseState(nullptr, nullptr);
|
||||
- if (buttonState == 0) {
|
||||
- if (m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
|
||||
- if (m_NeedsManualCaptureOnLeave) {
|
||||
- // Stop capturing the mouse now
|
||||
- SDL_CaptureMouse(SDL_FALSE);
|
||||
- }
|
||||
- m_PendingMouseButtonsAllUpOnVideoRegionLeave = false;
|
||||
- }
|
||||
- }
|
||||
- if (mouseInVideoRegion || m_MouseWasInVideoRegion || m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
|
||||
- LiSendMousePositionEvent((short)x, (short)y, dst.w, dst.h);
|
||||
- }
|
||||
-
|
||||
- // Adjust the cursor visibility if applicable
|
||||
- if (mouseInVideoRegion ^ m_MouseWasInVideoRegion) {
|
||||
- SDL_ShowCursor((mouseInVideoRegion && m_MouseCursorCapturedVisibilityState == SDL_DISABLE) ? SDL_DISABLE : SDL_ENABLE);
|
||||
- if (!mouseInVideoRegion && buttonState != 0) {
|
||||
- // If we still have a button pressed on leave, wait for that to come up
|
||||
- // before we stop sending mouse position events.
|
||||
- m_PendingMouseButtonsAllUpOnVideoRegionLeave = true;
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- m_MouseWasInVideoRegion = mouseInVideoRegion;
|
||||
+ sendAbsoluteMousePosition(x, y);
|
||||
}
|
||||
else {
|
||||
LiSendMouseMoveEvent(xrel, yrel);
|
||||
}
|
||||
+}
|
||||
+
|
||||
+void SdlInputHandler::sendAbsoluteMousePosition(Sint32 x, Sint32 y)
|
||||
+{
|
||||
+ int windowWidth, windowHeight;
|
||||
+ SDL_GetWindowSize(m_Window, &windowWidth, &windowHeight);
|
||||
+
|
||||
+ SDL_Rect src, dst;
|
||||
+ bool mouseInVideoRegion;
|
||||
+
|
||||
+ src.x = src.y = 0;
|
||||
+ src.w = m_StreamWidth;
|
||||
+ src.h = m_StreamHeight;
|
||||
+
|
||||
+ dst.x = dst.y = 0;
|
||||
+ dst.w = windowWidth;
|
||||
+ dst.h = windowHeight;
|
||||
+
|
||||
+ // Use the stream and window sizes to determine the video region
|
||||
+ StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
|
||||
+
|
||||
+ mouseInVideoRegion = isMouseInVideoRegion(x, y, windowWidth, windowHeight);
|
||||
+
|
||||
+ // Clamp motion to the video region
|
||||
+ x = qMin(qMax(x - dst.x, 0), dst.w);
|
||||
+ y = qMin(qMax(y - dst.y, 0), dst.h);
|
||||
+
|
||||
+ // Send the mouse position update if one of the following is true:
|
||||
+ // a) it is in the video region now
|
||||
+ // b) it just left the video region (to ensure the mouse is clamped to the video boundary)
|
||||
+ // c) a mouse button is still down from before the cursor left the video region (to allow smooth dragging)
|
||||
+ Uint32 buttonState = SDL_GetMouseState(nullptr, nullptr);
|
||||
+ if (buttonState == 0) {
|
||||
+ if (m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
|
||||
+ if (m_NeedsManualCaptureOnLeave) {
|
||||
+ // Stop capturing the mouse now
|
||||
+ SDL_CaptureMouse(SDL_FALSE);
|
||||
+ }
|
||||
+ m_PendingMouseButtonsAllUpOnVideoRegionLeave = false;
|
||||
+ }
|
||||
+ }
|
||||
+ if (mouseInVideoRegion || m_MouseWasInVideoRegion || m_PendingMouseButtonsAllUpOnVideoRegionLeave) {
|
||||
+ LiSendMousePositionEvent((short)x, (short)y, dst.w, dst.h);
|
||||
+ }
|
||||
+
|
||||
+ // Adjust the cursor visibility if applicable
|
||||
+ if (mouseInVideoRegion ^ m_MouseWasInVideoRegion) {
|
||||
+ SDL_ShowCursor((mouseInVideoRegion && m_MouseCursorCapturedVisibilityState == SDL_DISABLE) ? SDL_DISABLE : SDL_ENABLE);
|
||||
+ if (!mouseInVideoRegion && buttonState != 0) {
|
||||
+ // If we still have a button pressed on leave, wait for that to come up
|
||||
+ // before we stop sending mouse position events.
|
||||
+ m_PendingMouseButtonsAllUpOnVideoRegionLeave = true;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ m_MouseWasInVideoRegion = mouseInVideoRegion;
|
||||
}
|
||||
|
||||
void SdlInputHandler::handleMouseWheelEvent(SDL_MouseWheelEvent* event)
|
||||
--- a/app/streaming/input/input.h
|
||||
+++ b/app/streaming/input/input.h
|
||||
@@ -95,6 +95,8 @@
|
||||
void handleMouseButtonEvent(SDL_MouseButtonEvent* event);
|
||||
|
||||
void handleMouseMotionEvent(SDL_MouseMotionEvent* event);
|
||||
+
|
||||
+ void sendAbsoluteMousePosition(Sint32 x, Sint32 y);
|
||||
|
||||
void handleMouseWheelEvent(SDL_MouseWheelEvent* event);
|
||||
|
||||
--- /dev/null
|
||||
+++ b/app/streaming/input/vdi_mouse_batch.h
|
||||
@@ -0,0 +1,22 @@
|
||||
+#pragma once
|
||||
+#include <SDL.h>
|
||||
+
|
||||
+// Only the SDL main thread consumes events; other threads may append events.
|
||||
+inline void vdiBatchMouseMotion(SDL_MouseMotionEvent& motion)
|
||||
+{
|
||||
+ for (int i = 0; i < 64; ++i) {
|
||||
+ SDL_Event next;
|
||||
+ if (SDL_PeepEvents(&next, 1, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) != 1 ||
|
||||
+ next.type != SDL_MOUSEMOTION || next.motion.which != motion.which ||
|
||||
+ next.motion.windowID != motion.windowID || next.motion.state != motion.state) {
|
||||
+ break;
|
||||
+ }
|
||||
+ if (SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) != 1) {
|
||||
+ break;
|
||||
+ }
|
||||
+ motion.x = next.motion.x;
|
||||
+ motion.y = next.motion.y;
|
||||
+ motion.xrel += next.motion.xrel;
|
||||
+ motion.yrel += next.motion.yrel;
|
||||
+ }
|
||||
+}
|
||||
--- a/app/streaming/video/ffmpeg-renderers/pacer/pacer.cpp
|
||||
+++ b/app/streaming/video/ffmpeg-renderers/pacer/pacer.cpp
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "pacer.h"
|
||||
+#include "vdi_latest_frame.h"
|
||||
#include "streaming/streamutils.h"
|
||||
|
||||
#ifdef Q_OS_WIN32
|
||||
@@ -34,6 +35,7 @@
|
||||
m_VsyncThread(nullptr),
|
||||
m_DeferredFreeFrame(nullptr),
|
||||
m_Stopping(false),
|
||||
+ m_LatestFrameOnly(qEnvironmentVariableIntValue("VDI_LATEST_FRAME") == 1),
|
||||
m_VsyncSource(nullptr),
|
||||
m_VsyncRenderer(renderer),
|
||||
m_MaxVideoFps(0),
|
||||
@@ -178,7 +180,14 @@
|
||||
|
||||
void Pacer::enqueueFrameForRenderingAndUnlock(AVFrame *frame)
|
||||
{
|
||||
- dropFrameForEnqueue(m_RenderQueue);
|
||||
+ if (m_LatestFrameOnly && m_VsyncSource == nullptr) {
|
||||
+ // Drop only decoded, never presented frames. Keep the decoder reference
|
||||
+ // chain and the deferred free of the last presented frame intact.
|
||||
+ m_VideoStats->pacerDroppedFrames += vdiDiscardQueuedFrames(m_RenderQueue);
|
||||
+ }
|
||||
+ else {
|
||||
+ dropFrameForEnqueue(m_RenderQueue);
|
||||
+ }
|
||||
m_RenderQueue.enqueue(frame);
|
||||
|
||||
m_FrameQueueLock.unlock();
|
||||
@@ -261,6 +270,8 @@
|
||||
|
||||
bool Pacer::initialize(SDL_Window* window, int maxVideoFps, bool enablePacing)
|
||||
{
|
||||
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI latest-frame queue: %s",
|
||||
+ (m_LatestFrameOnly && !enablePacing) ? "enabled" : "disabled");
|
||||
m_MaxVideoFps = maxVideoFps;
|
||||
m_DisplayFps = StreamUtils::getDisplayRefreshRate(window);
|
||||
m_RendererAttributes = m_VsyncRenderer->getRendererAttributes();
|
||||
--- a/app/streaming/video/ffmpeg-renderers/pacer/pacer.h
|
||||
+++ b/app/streaming/video/ffmpeg-renderers/pacer/pacer.h
|
||||
@@ -68,6 +68,7 @@
|
||||
SDL_Thread* m_VsyncThread;
|
||||
AVFrame* m_DeferredFreeFrame;
|
||||
bool m_Stopping;
|
||||
+ bool m_LatestFrameOnly;
|
||||
|
||||
IVsyncSource* m_VsyncSource;
|
||||
IFFmpegRenderer* m_VsyncRenderer;
|
||||
--- /dev/null
|
||||
+++ b/app/streaming/video/ffmpeg-renderers/pacer/vdi_latest_frame.h
|
||||
@@ -0,0 +1,16 @@
|
||||
+#pragma once
|
||||
+#include <QQueue>
|
||||
+extern "C" {
|
||||
+#include <libavutil/frame.h>
|
||||
+}
|
||||
+
|
||||
+inline unsigned vdiDiscardQueuedFrames(QQueue<AVFrame*>& queue)
|
||||
+{
|
||||
+ unsigned dropped = 0;
|
||||
+ while (!queue.isEmpty()) {
|
||||
+ AVFrame* stale = queue.dequeue();
|
||||
+ av_frame_free(&stale);
|
||||
+ ++dropped;
|
||||
+ }
|
||||
+ return dropped;
|
||||
+}
|
||||
@@ -0,0 +1,143 @@
|
||||
--- a/src/InputStream.c
|
||||
+++ b/src/InputStream.c
|
||||
@@ -86,11 +86,16 @@
|
||||
int deltaX, deltaY;
|
||||
bool dirty; // Update ready to send (queued packet holder in packetQueue)
|
||||
} currentRelativeMouseState;
|
||||
-static struct {
|
||||
- int x, y;
|
||||
- int width, height;
|
||||
- bool dirty; // Update ready to send (queued packet holder in packetQueue)
|
||||
-} currentAbsoluteMouseState;
|
||||
+// Only this packet may absorb newer positions. A button event seals the batch.
|
||||
+static PPACKET_HOLDER currentQueuedAbsoluteMousePacket;
|
||||
+
|
||||
+static void latchAbsoluteMousePacket(PPACKET_HOLDER holder) {
|
||||
+ PltLockMutex(&batchedInputMutex);
|
||||
+ if (currentQueuedAbsoluteMousePacket == holder) {
|
||||
+ currentQueuedAbsoluteMousePacket = NULL;
|
||||
+ }
|
||||
+ PltUnlockMutex(&batchedInputMutex);
|
||||
+}
|
||||
|
||||
// Initializes the input stream
|
||||
int initializeInputStream(void) {
|
||||
@@ -120,7 +125,7 @@
|
||||
|
||||
memset(currentGamepadSensorState, 0, sizeof(currentGamepadSensorState));
|
||||
memset(¤tRelativeMouseState, 0, sizeof(currentRelativeMouseState));
|
||||
- memset(¤tAbsoluteMouseState, 0, sizeof(currentAbsoluteMouseState));
|
||||
+ currentQueuedAbsoluteMousePacket = NULL;
|
||||
PltCreateMutex(&batchedInputMutex);
|
||||
|
||||
return 0;
|
||||
@@ -444,24 +449,9 @@
|
||||
now = PltGetMillis();
|
||||
}
|
||||
|
||||
- PltLockMutex(&batchedInputMutex);
|
||||
-
|
||||
- // Populate the packet with the latest state
|
||||
- holder->packet.mouseMoveAbs.x = BE16(currentAbsoluteMouseState.x);
|
||||
- holder->packet.mouseMoveAbs.y = BE16(currentAbsoluteMouseState.y);
|
||||
-
|
||||
- // There appears to be a rounding error in GFE's scaling calculation which prevents
|
||||
- // the cursor from reaching the far edge of the screen when streaming at smaller
|
||||
- // resolutions with a higher desktop resolution (like streaming 720p with a desktop
|
||||
- // resolution of 1080p, or streaming 720p/1080p with a desktop resolution of 4K).
|
||||
- // Subtracting one from the reference dimensions seems to work around this issue.
|
||||
- holder->packet.mouseMoveAbs.width = BE16(currentAbsoluteMouseState.width - 1);
|
||||
- holder->packet.mouseMoveAbs.height = BE16(currentAbsoluteMouseState.height - 1);
|
||||
-
|
||||
- // The state change is no longer pending
|
||||
- currentAbsoluteMouseState.dirty = false;
|
||||
-
|
||||
- PltUnlockMutex(&batchedInputMutex);
|
||||
+ // Payload was filled by the producer under the same mutex. Latch
|
||||
+ // only our packet; a later batch may already be queued after a click.
|
||||
+ latchAbsoluteMousePacket(holder);
|
||||
|
||||
lastMousePacketTime = now;
|
||||
}
|
||||
@@ -780,52 +770,37 @@
|
||||
}
|
||||
|
||||
PltLockMutex(&batchedInputMutex);
|
||||
-
|
||||
- // Overwrite the previous mouse location with the new one
|
||||
- currentAbsoluteMouseState.x = x;
|
||||
- currentAbsoluteMouseState.y = y;
|
||||
- currentAbsoluteMouseState.width = referenceWidth;
|
||||
- currentAbsoluteMouseState.height = referenceHeight;
|
||||
-
|
||||
- // Queue a packet holder if this is the only pending absolute mouse event
|
||||
- if (!currentAbsoluteMouseState.dirty) {
|
||||
- // Set the dirty flag to claim ownership of inserting the packet holder
|
||||
- // and unlock to allow other threads to enqueue or process input.
|
||||
- currentAbsoluteMouseState.dirty = true;
|
||||
- PltUnlockMutex(&batchedInputMutex);
|
||||
-
|
||||
+ holder = currentQueuedAbsoluteMousePacket;
|
||||
+ bool newPacket = holder == NULL;
|
||||
+ if (newPacket) {
|
||||
holder = allocatePacketHolder(0);
|
||||
if (holder == NULL) {
|
||||
- currentAbsoluteMouseState.dirty = false;
|
||||
+ PltUnlockMutex(&batchedInputMutex);
|
||||
return -1;
|
||||
}
|
||||
-
|
||||
holder->channelId = CTRL_CHANNEL_MOUSE;
|
||||
-
|
||||
- // TODO: Send this as unreliable sequenced when we have a delayed reliable retransmission thread
|
||||
holder->enetPacketFlags = ENET_PACKET_FLAG_RELIABLE;
|
||||
-
|
||||
holder->packet.mouseMoveAbs.header.size = BE32(sizeof(NV_ABS_MOUSE_MOVE_PACKET) - sizeof(uint32_t));
|
||||
holder->packet.mouseMoveAbs.header.magic = LE32(MOUSE_MOVE_ABS_MAGIC);
|
||||
holder->packet.mouseMoveAbs.unused = 0;
|
||||
-
|
||||
- // Remaining fields are set in the input thread based on the latest currentAbsoluteMouseState values
|
||||
-
|
||||
+ }
|
||||
+ holder->packet.mouseMoveAbs.x = BE16(x);
|
||||
+ holder->packet.mouseMoveAbs.y = BE16(y);
|
||||
+ // Preserve upstream's far-edge rounding workaround.
|
||||
+ holder->packet.mouseMoveAbs.width = BE16(referenceWidth - 1);
|
||||
+ holder->packet.mouseMoveAbs.height = BE16(referenceHeight - 1);
|
||||
+ err = 0;
|
||||
+ if (newPacket) {
|
||||
+ currentQueuedAbsoluteMousePacket = holder;
|
||||
err = LbqOfferQueueItem(&packetQueue, holder, &holder->entry);
|
||||
if (err != LBQ_SUCCESS) {
|
||||
+ currentQueuedAbsoluteMousePacket = NULL;
|
||||
LC_ASSERT(err == LBQ_BOUND_EXCEEDED);
|
||||
Limelog("Input queue reached maximum size limit\n");
|
||||
freePacketHolder(holder);
|
||||
-
|
||||
- // We weren't able to insert the entry, so let the next call try again
|
||||
- currentAbsoluteMouseState.dirty = false;
|
||||
- }
|
||||
- }
|
||||
- else {
|
||||
- // There's already a packet holder queued to send this event
|
||||
- PltUnlockMutex(&batchedInputMutex);
|
||||
- err = 0;
|
||||
- }
|
||||
+ }
|
||||
+ }
|
||||
+ PltUnlockMutex(&batchedInputMutex);
|
||||
|
||||
// This is not thread safe, but it's not a big deal because callers that want to
|
||||
// use LiSendRelativeMotionAsMousePositionEvent() must not mix these function
|
||||
@@ -872,7 +847,11 @@
|
||||
holder->packet.mouseButton.header.magic = LE32(holder->packet.mouseButton.header.magic);
|
||||
holder->packet.mouseButton.button = (uint8_t)button;
|
||||
|
||||
+ PltLockMutex(&batchedInputMutex);
|
||||
+ // Do not let a later position overwrite the move preceding this click.
|
||||
+ currentQueuedAbsoluteMousePacket = NULL;
|
||||
err = LbqOfferQueueItem(&packetQueue, holder, &holder->entry);
|
||||
+ PltUnlockMutex(&batchedInputMutex);
|
||||
if (err != LBQ_SUCCESS) {
|
||||
LC_ASSERT(err == LBQ_BOUND_EXCEEDED);
|
||||
Limelog("Input queue reached maximum size limit\n");
|
||||
@@ -0,0 +1,179 @@
|
||||
--- a/app/streaming/session.cpp
|
||||
+++ b/app/streaming/session.cpp
|
||||
@@ -39,6 +39,7 @@
|
||||
#include <QImage>
|
||||
#include <QGuiApplication>
|
||||
#include <QCursor>
|
||||
+#include "input/vdi_input_priority.h"
|
||||
#include <QScreen>
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
@@ -1962,8 +1963,14 @@
|
||||
|
||||
// Hijack this thread to be the SDL main thread. We have to do this
|
||||
// because we want to suspend all Qt processing until the stream is over.
|
||||
+ VdiInputPriority vdiInputPriority(qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1,
|
||||
+ SDL_CODE_FRAME_READY);
|
||||
+ VdiInputTiming vdiInputTiming(qEnvironmentVariableIntValue("VDI_INPUT_TIMING") == 1);
|
||||
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI input priority: %s",
|
||||
+ qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1 ? "enabled" : "disabled");
|
||||
SDL_Event event;
|
||||
for (;;) {
|
||||
+ if (!vdiInputPriority.takeDeferred(event, SDL_GetWindowID(m_Window))) {
|
||||
#if SDL_VERSION_ATLEAST(2, 0, 18) && !defined(STEAM_LINK)
|
||||
// SDL 2.0.18 has a proper wait event implementation that uses platform
|
||||
// support to block on events rather than polling on Windows, macOS, X11,
|
||||
@@ -1995,6 +2002,11 @@
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
+ if (vdiInputPriority.deferFrame(event)) {
|
||||
+ continue;
|
||||
+ }
|
||||
+ }
|
||||
+ vdiInputTiming.observe(event, SDL_GetWindowID(m_Window));
|
||||
switch (event.type) {
|
||||
case SDL_QUIT:
|
||||
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||
@@ -2005,7 +2017,9 @@
|
||||
switch (event.user.code) {
|
||||
case SDL_CODE_FRAME_READY:
|
||||
if (m_VideoDecoder != nullptr) {
|
||||
+ const auto renderStart = vdiInputTiming.startRender();
|
||||
m_VideoDecoder->renderFrameOnMainThread();
|
||||
+ vdiInputTiming.endRender(renderStart);
|
||||
}
|
||||
break;
|
||||
case SDL_CODE_FLUSH_WINDOW_EVENT_BARRIER:
|
||||
@@ -2308,6 +2322,7 @@
|
||||
}
|
||||
|
||||
DispatchDeferredCleanup:
|
||||
+ vdiInputTiming.report(vdiInputPriority.prioritized());
|
||||
// Switch back to synchronous logging mode
|
||||
StreamUtils::exitAsyncLoggingMode();
|
||||
|
||||
--- /dev/null
|
||||
+++ b/app/streaming/input/vdi_input_priority.h
|
||||
@@ -0,0 +1,121 @@
|
||||
+#pragma once
|
||||
+#include <SDL.h>
|
||||
+#include <array>
|
||||
+#include <algorithm>
|
||||
+#include <cstdint>
|
||||
+
|
||||
+// SDL main thread only. Inputs may pass one frame-ready notification, never a
|
||||
+// window/focus/quit/other notification or another frame. Input FIFO is preserved.
|
||||
+class VdiInputPriority {
|
||||
+public:
|
||||
+ VdiInputPriority(bool enabled, Sint32 frameCode)
|
||||
+ : m_Enabled(enabled), m_FrameCode(frameCode) {}
|
||||
+
|
||||
+ bool deferFrame(const SDL_Event& event) {
|
||||
+ if (!m_Enabled || event.type != SDL_USEREVENT || event.user.code != m_FrameCode)
|
||||
+ return false;
|
||||
+ SDL_assert(!m_Pending);
|
||||
+ m_Frame = event;
|
||||
+ m_Pending = true;
|
||||
+ m_Count = 0;
|
||||
+ m_Start = SDL_GetPerformanceCounter();
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
+ bool takeDeferred(SDL_Event& event, Uint32 windowId) {
|
||||
+ if (!m_Pending) return false;
|
||||
+ SDL_Event next;
|
||||
+ const auto elapsed = SDL_GetPerformanceCounter() - m_Start;
|
||||
+ if (m_Count < 32 && elapsed < SDL_GetPerformanceFrequency() / 500 &&
|
||||
+ SDL_PeepEvents(&next, 1, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1 &&
|
||||
+ isInputForWindow(next, windowId) &&
|
||||
+ SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1) {
|
||||
+ ++m_Count;
|
||||
+ ++m_Prioritized;
|
||||
+ return true;
|
||||
+ }
|
||||
+ event = m_Frame;
|
||||
+ m_Pending = false;
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
+ uint64_t prioritized() const { return m_Prioritized; }
|
||||
+
|
||||
+ static bool isInputForWindow(const SDL_Event& event, Uint32 windowId) {
|
||||
+ switch (event.type) {
|
||||
+ case SDL_MOUSEMOTION:
|
||||
+ return event.motion.windowID == windowId && event.motion.which != SDL_TOUCH_MOUSEID;
|
||||
+ case SDL_MOUSEBUTTONDOWN:
|
||||
+ case SDL_MOUSEBUTTONUP:
|
||||
+ return event.button.windowID == windowId && event.button.which != SDL_TOUCH_MOUSEID;
|
||||
+ case SDL_MOUSEWHEEL:
|
||||
+ return event.wheel.windowID == windowId && event.wheel.which != SDL_TOUCH_MOUSEID;
|
||||
+ case SDL_KEYDOWN:
|
||||
+ case SDL_KEYUP:
|
||||
+ return event.key.windowID == windowId;
|
||||
+ default:
|
||||
+ return false;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+private:
|
||||
+ bool m_Enabled;
|
||||
+ Sint32 m_FrameCode;
|
||||
+ bool m_Pending = false;
|
||||
+ SDL_Event m_Frame{};
|
||||
+ unsigned m_Count = 0;
|
||||
+ Uint64 m_Start = 0;
|
||||
+ uint64_t m_Prioritized = 0;
|
||||
+};
|
||||
+
|
||||
+// Aggregated only when explicitly requested. Ages are SDL queue/dispatch ages,
|
||||
+// not network RTT or physical input-to-photon latency. 0.1 ms histogram bins.
|
||||
+class VdiInputTiming {
|
||||
+public:
|
||||
+ explicit VdiInputTiming(bool enabled) : m_Enabled(enabled) {}
|
||||
+ void observe(const SDL_Event& event, Uint32 windowId) {
|
||||
+ if (!m_Enabled || !event.common.timestamp ||
|
||||
+ !VdiInputPriority::isInputForWindow(event, windowId)) return;
|
||||
+ const Uint32 age = SDL_GetTicks() - event.common.timestamp;
|
||||
+ // Ignore invalid/future synthetic timestamps; subtraction handles wrap.
|
||||
+ if (age < 60000) m_Input.add(Uint64(age) * 10);
|
||||
+ }
|
||||
+ Uint64 startRender() const { return m_Enabled ? SDL_GetPerformanceCounter() : 0; }
|
||||
+ void endRender(Uint64 start) {
|
||||
+ if (m_Enabled) m_Render.add((SDL_GetPerformanceCounter() - start) * 10000 / SDL_GetPerformanceFrequency());
|
||||
+ }
|
||||
+ void report(uint64_t prioritized) const {
|
||||
+ if (!m_Enabled) return;
|
||||
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||
+ "VDI input timing: SDL dispatch age only; prioritized=%llu",
|
||||
+ (unsigned long long)prioritized);
|
||||
+ m_Input.report("input dispatch age");
|
||||
+ m_Render.report("main-thread render call");
|
||||
+ }
|
||||
+private:
|
||||
+ struct Histogram {
|
||||
+ std::array<uint64_t, 1024> bins{};
|
||||
+ uint64_t count = 0, total = 0, max = 0;
|
||||
+ void add(uint64_t tenths) {
|
||||
+ ++bins[std::min<uint64_t>(tenths, bins.size()-1)];
|
||||
+ ++count; total += tenths; max = std::max(max, tenths);
|
||||
+ }
|
||||
+ double quantile(unsigned percent) const {
|
||||
+ const uint64_t rank = (count * percent + 99) / 100;
|
||||
+ uint64_t accumulated = 0;
|
||||
+ for (size_t i = 0; i < bins.size(); ++i) {
|
||||
+ accumulated += bins[i];
|
||||
+ if (accumulated >= rank) return i / 10.0;
|
||||
+ }
|
||||
+ return 0;
|
||||
+ }
|
||||
+ void report(const char* label) const {
|
||||
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
|
||||
+ "VDI %s: samples=%llu avg=%.2f p50=%.1f p95=%.1f max=%.1f ms (percentiles capped at 102.3ms)",
|
||||
+ label, (unsigned long long)count, count ? total / (10.0*count) : 0,
|
||||
+ count ? quantile(50) : 0, count ? quantile(95) : 0, max / 10.0);
|
||||
+ }
|
||||
+ };
|
||||
+ bool m_Enabled;
|
||||
+ Histogram m_Input, m_Render;
|
||||
+};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Include the production implementation so batching can be inspected without
|
||||
// starting a real network connection. Unused transport code is linker-collected.
|
||||
#include "InputStream.c"
|
||||
#undef NDEBUG
|
||||
#include <assert.h>
|
||||
|
||||
static PPACKET_HOLDER pop(void) {
|
||||
PPACKET_HOLDER holder = NULL;
|
||||
assert(LbqPollQueueElement(&packetQueue, (void**)&holder) == LBQ_SUCCESS);
|
||||
return holder;
|
||||
}
|
||||
|
||||
static void expectPosition(int x) {
|
||||
PPACKET_HOLDER holder = pop();
|
||||
assert(holder->packet.header.magic == LE32(MOUSE_MOVE_ABS_MAGIC));
|
||||
latchAbsoluteMousePacket(holder);
|
||||
assert(holder->packet.mouseMoveAbs.x == BE16(x));
|
||||
assert((uint16_t)holder->packet.mouseMoveAbs.width == (uint16_t)BE16(2559));
|
||||
freePacketHolder(holder);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
AppVersionQuad[0] = 7;
|
||||
LbqInitializeLinkedBlockingQueue(&packetQueue, MAX_QUEUED_INPUT_PACKETS);
|
||||
LbqInitializeLinkedBlockingQueue(&packetHolderFreeList, MAX_QUEUED_INPUT_PACKETS);
|
||||
PltCreateMutex(&batchedInputMutex);
|
||||
initialized = true;
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
// A deliberately stalled sender must preserve each click's coordinates.
|
||||
assert(LiSendMousePositionEvent(10, 10, 2560, 1440) == 0);
|
||||
assert(LiSendMousePositionEvent(20, 20, 2560, 1440) == 0);
|
||||
assert(LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT) == 0);
|
||||
assert(LiSendMousePositionEvent(30, 30, 2560, 1440) == 0);
|
||||
assert(LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT) == 0);
|
||||
assert(LiSendMousePositionEvent(40, 40, 2560, 1440) == 0);
|
||||
expectPosition(20);
|
||||
PPACKET_HOLDER press = pop();
|
||||
assert(press->packet.header.magic == LE32(BUTTON_ACTION_PRESS + 1));
|
||||
freePacketHolder(press);
|
||||
expectPosition(30);
|
||||
PPACKET_HOLDER release = pop();
|
||||
assert(release->packet.header.magic == LE32(BUTTON_ACTION_RELEASE + 1));
|
||||
freePacketHolder(release);
|
||||
// Latching an older packet above must not prevent the latest batch
|
||||
// from still absorbing newer positions.
|
||||
assert(LiSendMousePositionEvent(50, 50, 2560, 1440) == 0);
|
||||
expectPosition(50);
|
||||
PPACKET_HOLDER empty;
|
||||
assert(LbqPollQueueElement(&packetQueue, (void**)&empty) == LBQ_NO_ELEMENT);
|
||||
}
|
||||
initialized = false;
|
||||
PLINKED_BLOCKING_QUEUE_ENTRY entry = LbqDestroyLinkedBlockingQueue(&packetQueue);
|
||||
assert(entry == NULL);
|
||||
entry = LbqDestroyLinkedBlockingQueue(&packetHolderFreeList);
|
||||
while (entry) {
|
||||
PLINKED_BLOCKING_QUEUE_ENTRY next = entry->flink;
|
||||
free(entry->data);
|
||||
entry = next;
|
||||
}
|
||||
PltDeleteMutex(&batchedInputMutex);
|
||||
puts("PASS: 1000 queued move/press/move/release cycles preserve positions");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "vdi_input_priority.h"
|
||||
#undef NDEBUG
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
|
||||
constexpr Sint32 frameCode = 123;
|
||||
static SDL_Event frame() {
|
||||
SDL_Event e{}; e.type = SDL_USEREVENT; e.user.code = frameCode; return e;
|
||||
}
|
||||
static SDL_Event input(Uint32 type, Uint32 window = 1) {
|
||||
SDL_Event e{}; e.type = type; e.key.windowID = window; return e;
|
||||
}
|
||||
static void push(SDL_Event e) { assert(SDL_PushEvent(&e) == 1); }
|
||||
static SDL_Event pop() {
|
||||
SDL_Event e{};
|
||||
assert(SDL_PeepEvents(&e,1,SDL_GETEVENT,SDL_FIRSTEVENT,SDL_LASTEVENT)==1);
|
||||
return e;
|
||||
}
|
||||
int main() {
|
||||
assert(SDL_Init(SDL_INIT_EVENTS)==0);
|
||||
SDL_Event out{};
|
||||
VdiInputPriority disabled(false, frameCode);
|
||||
push(input(SDL_MOUSEMOTION));
|
||||
assert(!disabled.deferFrame(frame()) && !disabled.takeDeferred(out,1));
|
||||
assert(pop().type == SDL_MOUSEMOTION);
|
||||
|
||||
// Only a contiguous input prefix can pass a frame. No focus/quit/decoder
|
||||
// barrier may be skipped, even if input is waiting further down the queue.
|
||||
for (Uint32 barrier : {Uint32(SDL_WINDOWEVENT),Uint32(SDL_QUIT),
|
||||
Uint32(SDL_USEREVENT),Uint32(SDL_RENDER_DEVICE_RESET),
|
||||
Uint32(SDL_CONTROLLERDEVICEADDED)}) {
|
||||
VdiInputPriority scheduler(true, frameCode);
|
||||
push(input(SDL_MOUSEBUTTONDOWN)); push(input(SDL_MOUSEBUTTONUP));
|
||||
push(input(barrier)); push(input(SDL_KEYDOWN));
|
||||
assert(scheduler.deferFrame(frame()));
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_MOUSEBUTTONDOWN);
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_MOUSEBUTTONUP);
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT && out.user.code==frameCode);
|
||||
assert(!scheduler.takeDeferred(out,1));
|
||||
assert(pop().type==barrier && pop().type==SDL_KEYDOWN);
|
||||
assert(scheduler.prioritized()==2);
|
||||
}
|
||||
// Preserve all input order, including keyboard releases and drag edges.
|
||||
{
|
||||
VdiInputPriority scheduler(true,frameCode);
|
||||
for (Uint32 t : {Uint32(SDL_KEYDOWN),Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONDOWN),
|
||||
Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONUP),Uint32(SDL_KEYUP)}) push(input(t));
|
||||
assert(scheduler.deferFrame(frame()));
|
||||
for (Uint32 t : {Uint32(SDL_KEYDOWN),Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONDOWN),
|
||||
Uint32(SDL_MOUSEMOTION),Uint32(SDL_MOUSEBUTTONUP),Uint32(SDL_KEYUP)})
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==t);
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
|
||||
}
|
||||
for (bool synthetic : {false,true}) {
|
||||
VdiInputPriority scheduler(true,frameCode);
|
||||
auto e=input(SDL_MOUSEMOTION,synthetic?1:2);
|
||||
if(synthetic) e.motion.which=SDL_TOUCH_MOUSEID;
|
||||
push(e); assert(scheduler.deferFrame(frame()));
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
|
||||
assert(pop().type==SDL_MOUSEMOTION);
|
||||
}
|
||||
// A high-rate input device cannot starve video.
|
||||
{
|
||||
VdiInputPriority scheduler(true,frameCode);
|
||||
for(int i=0;i<1000;++i) push(input(SDL_MOUSEWHEEL));
|
||||
assert(scheduler.deferFrame(frame()));
|
||||
unsigned n=0;
|
||||
while(scheduler.takeDeferred(out,1) && out.type!=SDL_USEREVENT) ++n;
|
||||
assert(n<=32 && n>0 && out.type==SDL_USEREVENT);
|
||||
SDL_FlushEvents(SDL_FIRSTEVENT,SDL_LASTEVENT);
|
||||
}
|
||||
{
|
||||
VdiInputPriority scheduler(true,frameCode);
|
||||
push(input(SDL_MOUSEBUTTONUP));
|
||||
assert(scheduler.deferFrame(frame())); SDL_Delay(4);
|
||||
assert(scheduler.takeDeferred(out,1) && out.type==SDL_USEREVENT);
|
||||
assert(pop().type==SDL_MOUSEBUTTONUP);
|
||||
}
|
||||
// Instrumentation never consumes events or changes cursor visibility.
|
||||
VdiInputTiming timing(true);
|
||||
auto e=input(SDL_MOUSEMOTION); e.common.timestamp=SDL_GetTicks();
|
||||
timing.observe(e,1); auto start=timing.startRender(); timing.endRender(start);
|
||||
timing.report(0);
|
||||
SDL_Quit();
|
||||
std::puts("PASS: input FIFO, focus/quit/reset/window barriers, disabled mode, device/window isolation, count/time bounds");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#include <SDL.h>
|
||||
#include <cstdio>
|
||||
#include "vdi_mouse_batch.h"
|
||||
#include "vdi_latest_frame.h"
|
||||
#undef NDEBUG
|
||||
#include <cassert>
|
||||
|
||||
static SDL_Event motion(int x, Uint32 which = 1)
|
||||
{
|
||||
SDL_Event event{};
|
||||
event.type = SDL_MOUSEMOTION;
|
||||
event.motion.which = which;
|
||||
event.motion.windowID = 1;
|
||||
event.motion.x = x;
|
||||
event.motion.y = 100;
|
||||
event.motion.xrel = 1;
|
||||
return event;
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
assert(SDL_Init(SDL_INIT_EVENTS) == 0);
|
||||
for (Uint32 boundary : {Uint32(SDL_MOUSEBUTTONDOWN), Uint32(SDL_MOUSEBUTTONUP),
|
||||
Uint32(SDL_KEYDOWN), Uint32(SDL_USEREVENT)}) {
|
||||
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
|
||||
SDL_Event first = motion(10), adjacent = motion(20), later = motion(90), barrier{};
|
||||
barrier.type = boundary;
|
||||
assert(SDL_PushEvent(&adjacent) == 1);
|
||||
assert(SDL_PushEvent(&barrier) == 1);
|
||||
assert(SDL_PushEvent(&later) == 1);
|
||||
vdiBatchMouseMotion(first.motion);
|
||||
assert(first.motion.x == 20 && first.motion.xrel == 2);
|
||||
SDL_Event next;
|
||||
assert(SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1);
|
||||
assert(next.type == boundary);
|
||||
assert(SDL_PeepEvents(&next, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 1);
|
||||
assert(next.motion.x == 90);
|
||||
}
|
||||
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
|
||||
SDL_Event first = motion(0);
|
||||
for (int i = 1; i <= 200; ++i) {
|
||||
SDL_Event next = motion(i);
|
||||
assert(SDL_PushEvent(&next) == 1);
|
||||
}
|
||||
vdiBatchMouseMotion(first.motion);
|
||||
assert(first.motion.x == 64); // Work per dispatch is bounded.
|
||||
assert(SDL_PeepEvents(nullptr, 0, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT) == 136);
|
||||
SDL_FlushEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
|
||||
first = motion(10);
|
||||
SDL_Event synthetic = motion(99, SDL_TOUCH_MOUSEID);
|
||||
assert(SDL_PushEvent(&synthetic) == 1);
|
||||
vdiBatchMouseMotion(first.motion);
|
||||
assert(first.motion.x == 10);
|
||||
|
||||
AVFrame* presented = av_frame_alloc();
|
||||
assert(presented);
|
||||
presented->buf[0] = av_buffer_alloc(16);
|
||||
assert(presented->buf[0]);
|
||||
QQueue<AVFrame*> queue;
|
||||
assert(vdiDiscardQueuedFrames(queue) == 0);
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
unsigned dropped = vdiDiscardQueuedFrames(queue);
|
||||
assert(dropped == (i == 0 ? 0u : 1u));
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
assert(frame);
|
||||
frame->pts = i;
|
||||
queue.enqueue(frame);
|
||||
assert(queue.size() == 1 && queue.head()->pts == i);
|
||||
assert(av_buffer_get_ref_count(presented->buf[0]) == 1);
|
||||
}
|
||||
assert(vdiDiscardQueuedFrames(queue) == 1);
|
||||
av_frame_free(&presented);
|
||||
SDL_Quit();
|
||||
std::puts("PASS: SDL event boundaries, bounded motion work, latest decoded frame ownership");
|
||||
}
|
||||
Reference in new issue
Block a user