Root causes (3, discovered via Oracle review + x264 source verification):
A. PLI storm: WebRtcInner.force_keyframe_to_encode is a single bool with
no rate limit. Client PLI storms (5 PLIs in 845ms observed) produced
5 back-to-back IDRs (~1.5MB burst), swamping the network.
B. Bitrate runaway: BWE feedback had no upper bound. Observed bitrate
escalating from 5 Mbps to 9.9 Mbps in 12 seconds, all applied to
encoder. Combined with A, made each IDR grow 4-5x.
C. VBV effectively disabled (NEW finding not in original issue):
x264's vbv-maxrate and vbv-bufsize x264opts expect kbit/s and kbit
(confirmed via x264 source ratecontrol.c:658-661 which multiplies by
1000 at use site). The code passed bps, making VBV interpret 5.5 Mbps
as 5.5 Gbps (clipped to 2 Gbps). Buffer was 173MB instead of 172KB,
so VBV never constrained anything. This is why IDRs could balloon to
336KB after runtime bitrate increases.
Fixes (3, all in this commit per Oracle review):
Fix 0 (avhw.rs): divide bitrate by 1000 when formatting x264opts string.
Updated existing vbv_x264opts_format and vbv_bufsize_is_quarter_of_maxrate
tests to assert correct kbit/s values. Old tests passed but asserted
wrong values - classic 'tests covered the wrong implementation'.
Fix 1 (webrtc.rs): split keyframe trigger into two paths.
- set_need_keyframe() (internal: connect, resolution change) remains
unthrottled but updates last_forced_keyframe_at timestamp.
- request_keyframe_from_viewer() (external PLI) rate-limited to
Duration::from_secs(1), checked against last_forced_keyframe_at.
- Key insight from Oracle: track ALL keyframe production time, not
just PLI time, to prevent 'connect -> immediate PLI -> duplicate IDR'.
Fix 2 (args.rs + state_portal.rs + avhw.rs):
- New --max-bitrate CLI flag, default 8 Mbps.
- Primary clamp in webrtc_thread_loop (policy layer): clamp BWE via
variable shadowing so all downstream code (bitrate_tx, resolution
adaptation) uses clamped value.
- Defensive guardrail in encode_cpu_frame UpdateBitrate handler:
50 Mbps hard ceiling in case future callers bypass policy layer.
- Per Oracle: flat 8 Mbps default, no auto-scaling
(max(8M, 2*initial) would have failed the observed case).
Verification (82.1s session, 34.7 fps avg vs previous 18.2):
PLI storm absorption:
- 15 PLIs received from viewer
- 10 PLIs throttled (67%)
- 6 IDRs produced total (1 connect + 5 honored)
- 3 distinct PLI storms (625ms, 640ms, 858ms duration) all absorbed
VBV constraint working:
- First IDR: 65KB (was 67KB)
- Largest IDR: 169KB (was 336KB)
- All IDRs under VBV buffer bound of 172KB
- No more runaway IDR growth
Bitrate cap working:
- 5 bitrate updates applied (was 8)
- Peak bitrate: 7.4 Mbps (was 9.9 Mbps)
- 52952 BWE readings clamped (>8 Mbps filtered out)
Overall quality:
- Frame rate: 34.7 fps (was 18.2, 1.9x improvement, exceeds 30 fps target)
- Average bitrate: 4451 kb/s (was 5616, lower and more stable)
Out of scope (deferred to future work):
- Runtime VBV reconfiguration on bitrate change (Fix 3): encoder
recreation is expensive, observe whether VBV mismatch becomes a
quality issue after cap is in place
- Asymmetric BWE filter (Fix 4): rise=15%/fall=5% thresholds; nice
tuning but cap is sufficient for now
- Compositor stalls (#15): still causes some stutter at session end
but no longer compounds into latency
Tests:
- cargo test: 91 passed + 3 passed + 0 failed
- vbv_x264opts_format and vbv_bufsize_is_quarter_of_maxrate updated
and passing with new kbit/s assertions
- SAFETY comments preserved verbatim
- cargo build --release: clean, no new warnings
229 lines
7.9 KiB
Rust
229 lines
7.9 KiB
Rust
use anyhow::Result;
|
||
use wayland_client::globals::registry_queue_init;
|
||
use wayland_client::globals::GlobalListContents;
|
||
use wayland_client::protocol::wl_registry::{Event, WlRegistry};
|
||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||
|
||
use crate::args::Args;
|
||
|
||
// 屏幕捕获后端类型
|
||
|
||
/// Capture backend to use for screen capture.
|
||
/// 屏幕捕获后端枚举
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CaptureBackend {
|
||
/// wlroots wlr-screencopy protocol (Sway, Hyprland, etc.)
|
||
/// wlroots 合成器的 wlr-screencopy 协议(适用于 Sway、Hyprland 等)
|
||
WlrScreencopy,
|
||
/// xdg-desktop-portal with PipeWire (KWin/KDE, GNOME, etc.)
|
||
/// XDG 桌面门户 + PipeWire 方式(适用于 KWin/KDE、GNOME 等)
|
||
PortalPipeWire,
|
||
}
|
||
|
||
/// Minimal dispatch type for listing Wayland globals during backend detection.
|
||
/// 用于后端检测期间列举 Wayland 全局对象的最小化分发类型(无需实际处理事件)
|
||
struct RegistryLs;
|
||
|
||
// 为 RegistryLs 实现 Wayland 注册表事件分发(空实现,仅需类型满足 trait 约束)
|
||
impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
|
||
fn event(
|
||
_state: &mut Self,
|
||
_registry: &WlRegistry,
|
||
_event: Event,
|
||
_data: &GlobalListContents,
|
||
_conn: &Connection,
|
||
_qhandle: &QueueHandle<Self>,
|
||
) {
|
||
}
|
||
}
|
||
|
||
// CAUTION: must NOT use ashpd here — ashpd caches zbus::Connection in a global
|
||
// OnceLock; if the tokio runtime owning that connection is dropped before
|
||
// setup_portal() runs, the cached connection becomes dead and hangs forever.
|
||
fn check_portal_available() -> bool {
|
||
let rt = match tokio::runtime::Runtime::new() {
|
||
Ok(rt) => rt,
|
||
Err(e) => {
|
||
tracing::warn!("Failed to create tokio runtime for portal check: {e}");
|
||
return false;
|
||
}
|
||
};
|
||
|
||
rt.block_on(async {
|
||
let conn = match zbus::Connection::session().await {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::info!("D-Bus session bus unavailable: {e}");
|
||
return false;
|
||
}
|
||
};
|
||
|
||
let inner: zbus::Proxy = match zbus::proxy::Builder::new(&conn)
|
||
.destination("org.freedesktop.portal.Desktop")
|
||
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
|
||
.and_then(|b| b.interface("org.freedesktop.portal.ScreenCast"))
|
||
{
|
||
Ok(b) => match b.build().await {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
tracing::info!("Portal ScreenCast interface not available: {e}");
|
||
return false;
|
||
}
|
||
},
|
||
Err(e) => {
|
||
tracing::info!("Portal ScreenCast proxy build failed: {e}");
|
||
return false;
|
||
}
|
||
};
|
||
|
||
let version = match inner.get_property::<u32>("version").await {
|
||
Ok(version) => {
|
||
tracing::info!("Portal ScreenCast available (version: {version})");
|
||
true
|
||
}
|
||
Err(e) => {
|
||
tracing::info!("Portal ScreenCast version query failed: {e}");
|
||
false
|
||
}
|
||
};
|
||
version
|
||
})
|
||
}
|
||
|
||
// 通过 Wayland globals 检测 wlr-screencopy 协议是否可用
|
||
fn check_screencopy_available() -> Result<bool> {
|
||
let conn = Connection::connect_to_env()?;
|
||
let (globals, _queue) = registry_queue_init::<RegistryLs>(&conn)?;
|
||
|
||
let has_screencopy = globals
|
||
.contents()
|
||
.clone_list()
|
||
.iter()
|
||
.any(|g| g.interface == "zwlr_screencopy_manager_v1");
|
||
|
||
// Drop the Wayland connection explicitly before returning.
|
||
// The screencopy path creates its own connection. Holding two connections
|
||
// simultaneously is wasteful and may cause issues on some compositors.
|
||
// 显式释放检测用的 Wayland 连接,避免与后续捕获后端同时占用两个连接
|
||
drop(conn);
|
||
|
||
Ok(has_screencopy)
|
||
}
|
||
|
||
/// Detect which capture backend to use.
|
||
/// 检测应使用哪种屏幕捕获后端
|
||
///
|
||
/// Priority:
|
||
/// 优先级:
|
||
/// 1. Explicit `--backend` override from CLI args
|
||
/// 用户通过 `--backend` 命令行参数显式指定
|
||
/// 2. Auto-detect: check wlr-screencopy (Wayland) and Portal (D-Bus) respectively
|
||
/// 自动检测:分别通过 Wayland globals 和 D-Bus 检测两个后端
|
||
///
|
||
/// Both backends are checked independently. If neither is available, returns an error.
|
||
/// 两个后端独立检测,都不可用时返回错误。
|
||
pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
|
||
// 1. Check explicit override
|
||
// 步骤 1:检查用户是否通过命令行参数显式指定了后端
|
||
if let Some(ref backend) = args.backend {
|
||
return match backend.as_str() {
|
||
"portal" => {
|
||
tracing::info!("Backend override: Portal/PipeWire");
|
||
Ok(CaptureBackend::PortalPipeWire)
|
||
}
|
||
"screencopy" => {
|
||
tracing::info!("Backend override: wlr-screencopy");
|
||
Ok(CaptureBackend::WlrScreencopy)
|
||
}
|
||
other => {
|
||
// 未知后端名称,返回错误
|
||
anyhow::bail!("Unknown backend '{}'. Use 'screencopy' or 'portal'.", other);
|
||
}
|
||
};
|
||
}
|
||
|
||
// 2. Auto-detect: check both backends independently
|
||
// 步骤 2:自动检测 — 分别检测两个后端的可用性
|
||
tracing::info!("Auto-detecting capture backend...");
|
||
|
||
// 检测 wlr-screencopy(通过 Wayland globals)
|
||
let has_screencopy = check_screencopy_available()?;
|
||
// 检测 Portal(通过 D-Bus)
|
||
let has_portal = check_portal_available();
|
||
|
||
// 根据检测结果选择后端,screencopy 优先(性能更好、延迟更低)
|
||
match (has_screencopy, has_portal) {
|
||
(true, _) => {
|
||
tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend");
|
||
Ok(CaptureBackend::WlrScreencopy)
|
||
}
|
||
(false, true) => {
|
||
tracing::info!("No wlr-screencopy, Portal available → using Portal/PipeWire backend");
|
||
Ok(CaptureBackend::PortalPipeWire)
|
||
}
|
||
(false, false) => {
|
||
anyhow::bail!(
|
||
"No supported capture backend found. \
|
||
Install a wlroots compositor (for wlr-screencopy) \
|
||
or xdg-desktop-portal (for Portal/PipeWire)."
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// 测试辅助函数:构造指定后端参数的 Args 实例
|
||
fn make_args(backend: Option<&str>) -> Args {
|
||
Args {
|
||
output: Some("test.mp4".to_string()),
|
||
output_name: None,
|
||
fps: 30,
|
||
codec: "h264".to_string(),
|
||
hw_accel: "vaapi".to_string(),
|
||
drm_device: None,
|
||
bitrate: None,
|
||
max_bitrate: 8_000_000,
|
||
gop_size: None,
|
||
verbose: false,
|
||
backend: backend.map(String::from),
|
||
port: 0,
|
||
no_persist: false,
|
||
stats: false,
|
||
}
|
||
}
|
||
|
||
// 测试:显式指定 portal 后端
|
||
#[test]
|
||
fn explicit_portal_backend() {
|
||
let args = make_args(Some("portal"));
|
||
let result = detect_backend(&args);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire);
|
||
}
|
||
|
||
// 测试:显式指定 screencopy 后端
|
||
#[test]
|
||
fn explicit_screencopy_backend() {
|
||
let args = make_args(Some("screencopy"));
|
||
let result = detect_backend(&args);
|
||
assert!(result.is_ok());
|
||
assert_eq!(result.unwrap(), CaptureBackend::WlrScreencopy);
|
||
}
|
||
|
||
// 测试:无效的后端名称应返回错误
|
||
#[test]
|
||
fn invalid_backend_name_returns_error() {
|
||
let args = make_args(Some("magic"));
|
||
let result = detect_backend(&args);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err().to_string();
|
||
assert!(
|
||
err.contains("Unknown backend 'magic'"),
|
||
"Expected error about unknown backend, got: {err}"
|
||
);
|
||
}
|
||
}
|