Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ac8750dc | ||
|
|
f44e848c77 | ||
|
|
41093a4f99 | ||
|
|
052729529e | ||
|
|
98eb72e2a2 | ||
|
|
c780d6aeff | ||
|
|
c68457e0e3 | ||
|
|
e6b5a5dc36 | ||
|
|
b2ba34ef04 | ||
|
|
2f8197210e | ||
|
|
d94431bd1e | ||
|
|
895b9aeb32 | ||
|
|
8460c56bd5 | ||
|
|
1518da4f30 | ||
|
|
279ec97647 | ||
|
|
09123fcd65 | ||
|
|
f3c0a83a9a | ||
|
|
de93d31c89 | ||
|
|
f9e59756c3 | ||
|
|
e96af51ee1 | ||
|
|
fffa440e68 | ||
|
|
2841d93afa | ||
|
|
9e2f726491 | ||
|
|
ef4bd904db | ||
|
|
d3016161d1 | ||
|
|
3d314a35aa | ||
|
|
13b7466c57 | ||
|
|
c12ae6ddcc | ||
|
|
e7accecfec | ||
|
|
68a6eecfbe | ||
|
|
6ccb225784 | ||
|
|
727893fdc2 | ||
|
|
a06a41f5f2 | ||
|
|
631934458c | ||
|
|
46e7a9785d | ||
|
|
b4b9990efe | ||
|
|
ad28af6ff3 | ||
|
|
1e792f191c | ||
|
|
079611acfc | ||
|
|
2f0b858920 | ||
|
|
9a522e2f99 | ||
|
|
f38adf70f9 | ||
|
|
92760dd8ee | ||
|
|
0aba0e651e | ||
|
|
36cee9d9dd | ||
|
|
0e91c793c7 | ||
|
|
3e60258627 | ||
|
|
503e4dbc22 | ||
|
|
caccfec44e | ||
|
|
826f544569 | ||
|
|
aae030f309 | ||
|
|
029fe13e37 | ||
|
|
f3da1e4e6c | ||
|
|
e6e05fb44a | ||
|
|
8b04893ceb | ||
|
|
1beaea8088 | ||
|
|
fc4733ffe8 | ||
|
|
d5679be3a4 | ||
|
|
36f07c92e9 |
@@ -17,3 +17,7 @@ Thumbs.db
|
||||
|
||||
# Sisyphus orchestration artifacts
|
||||
.sisyphus/
|
||||
.omo/
|
||||
.playwright-mcp/
|
||||
wl-webrtc.log
|
||||
webrtc-p0-success.png
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Sources of truth
|
||||
|
||||
- Trust current Rust source, `Cargo.toml`, `shell.nix`, and tests over `docs/superpowers/*` or `analysis.md`; those docs contain historical/aspirational modules that are not in the tree.
|
||||
- Crate edition is 2021, not the default Rust 2024. The package exposes a library, three binaries (`wl-webrtc`, `vaapi_import_bench`, `sw_encode_bench`), and two examples (`list_globals`, `test_portal`).
|
||||
|
||||
## Setup and build
|
||||
|
||||
- Native prerequisites are FFmpeg 6+ dev libs with VAAPI, Wayland protocols/libs, libdrm, PipeWire, and libclang. `shell.nix` provides FFmpeg/Wayland/libdrm/Mesa/libva/clang and `LIBCLANG_PATH`, but does not currently list PipeWire.
|
||||
- Normal build: `cargo build`. Release binary required by README and integration tests: `cargo build --release`.
|
||||
- `Cargo.toml` only warns on `clippy::undocumented_unsafe_blocks`; do not assume a broader clippy policy exists unless you add one.
|
||||
|
||||
## Testing and verification
|
||||
|
||||
- Unit/focused tests can run with filters, e.g. `cargo test transform`, `cargo test fps_limit`, `cargo test backend_detect`.
|
||||
- Full `cargo test` includes `tests/integration_test.rs`, which shells out to `target/release/wl-webrtc`; run `cargo build --release` first or those tests fail before reaching code behavior.
|
||||
- Hardware/live tests are marked ignored; run them only on a Wayland session with VAAPI-capable GPU and writable output: `cargo test -- --ignored`.
|
||||
- CLI smoke test surface: `target/release/wl-webrtc --help` and invalid-argument rejection. Real capture needs Wayland plus either wlr-screencopy or XDG Portal/PipeWire.
|
||||
- The README CLI table is stale for `--backend` and `--no-persist`; `src/args.rs` is authoritative for flags.
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
- `src/main.rs` is the real entrypoint: parse `Args`, initialize tracing from `RUST_LOG` or `-v`, reject non-H.264, require `--output` or `--port`, detect backend, then run one of two loops.
|
||||
- Backend detection in `src/backend_detect.rs`: explicit `--backend portal|screencopy` wins; otherwise wlr-screencopy is preferred when the Wayland global `zwlr_screencopy_manager_v1` exists, else Portal/PipeWire is used if D-Bus ScreenCast is available.
|
||||
- Do not use `ashpd` for backend availability checks; `backend_detect.rs` intentionally uses raw `zbus` because `ashpd` caches a `zbus::Connection` in a global and can hang after its owning Tokio runtime is dropped.
|
||||
- `src/state.rs` drives the wlroots path using a mio Wayland fd loop and `State<CapWlrScreencopy>`; `src/state_portal.rs` drives the Portal/PipeWire path through `CapPortal` frame channels.
|
||||
- `src/webrtc.rs` is a small embedded HTTP/WebRTC signaling server using `str0m`; `--port 0` means file-output mode, `--port > 0` enables WebRTC mode.
|
||||
|
||||
## Unsafe and FFI work
|
||||
|
||||
- FFmpeg/VAAPI/PipeWire code relies on raw FFI and many `unsafe` blocks. Preserve nearby `// SAFETY:` explanations and add one for any new unsafe block.
|
||||
- `src/avhw.rs` owns FFmpeg `AVBufferRef`/frame contexts and has explicit `unsafe impl Send`; avoid moving those wrappers across threads without rechecking the documented exclusivity assumptions.
|
||||
- `CapPortal` stores the portal restore token under the user cache directory (`wl-webrtc/portal-restore-token`); use `--no-persist` when manually testing fresh authorization behavior.
|
||||
|
||||
## Useful manual commands
|
||||
|
||||
- List Wayland globals: `cargo run --example list_globals`.
|
||||
- Portal permission smoke test: `cargo run --example test_portal`.
|
||||
- Portal/VAAPI benchmarks require a screen-share dialog and hardware: `cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4` and `cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4`.
|
||||
Generated
+13
@@ -1126,6 +1126,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -2025,10 +2034,14 @@ version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ signal-hook = "0.3"
|
||||
signal-hook-mio = { version = "0.2", features = ["support-v1_0"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1"
|
||||
drm = "0.12"
|
||||
drm-fourcc = "2"
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
//! Cargo build script(编译前钩子)。
|
||||
//!
|
||||
//! 当前为空:本项目直接复用 `wayland-client`、`pipewire`、`ffmpeg-sys` 等现成 crate,
|
||||
//! 不需要在编译前跑 wayland-scanner 或 bindgen 生成代码。类比 Go 无 `//go:generate`。
|
||||
// Cargo 编译前不需要生成任何代码(无 wayland-scanner / bindgen),因此 build script 留空。
|
||||
fn main() {}
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
//! 列出当前 Wayland 桌面广播的全部全局对象(registry globals)。
|
||||
//!
|
||||
//! Wayland 协议采用"客户端发现"模型:客户端连接到 compositor 后,第一件事是从
|
||||
//! registry 中枚举所有被广播的 interface(如 `wl_compositor`、`wl_shm`、
|
||||
//! `zwlr_screencopy_manager_v1`、`zxdg_portal_screencast` 等),每个 global 带有
|
||||
//! 唯一数字 name、interface 名字符串、最高支持版本号。本示例即打印这三元组。
|
||||
//!
|
||||
//! 类比 Go 的 `xcursor` / wayland-client 示例:用最小可运行代码确认运行环境。
|
||||
//!
|
||||
//! 运行(参见 AGENTS.md "Useful manual commands"):
|
||||
//! ```sh
|
||||
//! cargo run --example list_globals
|
||||
//! ```
|
||||
//!
|
||||
//! 该示例也是 `src/backend_detect.rs` 中检测 `zwlr_screencopy_manager_v1` 是否存在的
|
||||
//! 同款机制(参见 `check_screencopy_available`),用于决定走 wlr-screencopy 还是 Portal。
|
||||
|
||||
// 引入 wayland-client 的快捷初始化辅助函数:内部完成 connect + registry bind + 同步枚举。
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
// GlobalListContents 是 registry_queue_init 返回的"已收集好的 global 列表"句柄类型。
|
||||
use wayland_client::globals::GlobalListContents;
|
||||
// WlRegistry 是 Wayland 协议对象;Event 是其产生的枚举事件(global/global_remove)。
|
||||
use wayland_client::protocol::wl_registry::{Event, WlRegistry};
|
||||
// Connection 表示与 compositor 的 socket 连接;QueueHandle 是事件队列句柄;
|
||||
// Dispatch 是 trait,用户必须为关心的协议对象实现它以接收事件回调。
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
|
||||
// 示例用的极简 state:无字段。Wayland 客户端需要至少一个 state 类型作为
|
||||
// Dispatch trait 的 `Self`,这里就用零大小类型 `Ls`(list globals 的缩写)。
|
||||
struct Ls;
|
||||
|
||||
// 为 Ls 实现 WlRegistry 的 Dispatch:本示例只需枚举 globals,不需要响应任何
|
||||
// registry 事件,因此 event 函数留空。wayland-client 要求即便不处理事件也必须
|
||||
// 实现 Dispatch(trait contract 强制),否则 `registry_queue_init::<Ls>` 无法编译。
|
||||
//
|
||||
// Go 类比:类似 `type Ls struct{}` + `func (Ls) HandleEvent(...) {}`——
|
||||
// 显式声明"我接收事件但不响应"。
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for Ls {
|
||||
// 所有参数加 `_` 前缀表示本实现不读取任何参数(Rust 中 `_x` 与 `x` 区分:
|
||||
// 前者显式标记未使用,避免 dead_code 警告)。
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &WlRegistry,
|
||||
@@ -17,10 +49,25 @@ impl Dispatch<WlRegistry, GlobalListContents> for Ls {
|
||||
}
|
||||
}
|
||||
|
||||
// 程序入口。Rust 的 `fn main()` 不能返回 `Result`(标准约定),故用 `.unwrap()`
|
||||
// 简单 panic;示例程序通常省略错误处理以突出主线逻辑。
|
||||
fn main() {
|
||||
// 从 `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` 环境变量建立与 compositor 的 socket 连接。
|
||||
// 类比 Go 的 `net.Dial("unix", path)`。`.unwrap()` 在连接失败时 panic(示例代码约定)。
|
||||
let conn = Connection::connect_to_env().unwrap();
|
||||
|
||||
// registry_queue_init 是 wayland-client 的高层辅助:内部发送 sync request 并阻塞
|
||||
// 直到 registry 全部 global 事件到达。返回 (GlobalList, EventQueue)。
|
||||
// `::<Ls>` 是 turbofish 显式指定 state 类型,对应上面 `impl Dispatch for Ls`。
|
||||
// 类比 Go 的 `globals, queue := wayland.RegistryQueueInit[Ls](conn)`(泛型实例化)。
|
||||
let (globals, _queue) = registry_queue_init::<Ls>(&conn).unwrap();
|
||||
|
||||
// 遍历所有已收集的 globals。`globals.contents()` 返回内部快照引用,
|
||||
// `.clone_list()` 复制成 `Vec<GlobalListEntry>`(每个 entry 含 name/interface/version)。
|
||||
// 类比 Go `for _, g := range globals { ... }`——Rust 的 `for ... in` 直接消费迭代器。
|
||||
for g in globals.contents().clone_list() {
|
||||
// `println!` 是 Rust 标准宏(不是函数),类比 Go `fmt.Printf("%d: %s v%d\n", ...)`。
|
||||
// `{}` 自动调用参数的 `Display` trait;name 是 u32、interface 是 String、version 是 u32。
|
||||
println!("{}: {} v{}", g.name, g.interface, g.version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
//! XDG Portal 权限冒烟测试示例。
|
||||
//!
|
||||
//! 本示例演示完整的 Portal ScreenCast 授权流程,分四步:
|
||||
//! 创建 Screencast proxy,再创建 session,然后选择源(显示器/窗口),
|
||||
//! 最后 start() 触发系统授权对话框(用户点击"共享"后返回流信息)。
|
||||
//!
|
||||
//! 运行:`cargo run --example test_portal`(参见 AGENTS.md "Useful manual commands")。
|
||||
//!
|
||||
//! Rust 异步模型(与 Go 对比):
|
||||
//! - Go 用 goroutine + channel;async 函数本身**惰性**,需 runtime 驱动。
|
||||
//! - 本示例刻意**不用** `#[tokio::main]` 宏,而是手动 `Runtime::new()` + `block_on`
|
||||
//! (与 src/backend_detect.rs 同款"手动 runtime"模式);这是因为 ashpd 内部缓存
|
||||
//! zbus::Connection 到全局 OnceLock,若宏自动建的 runtime 被 drop,
|
||||
//! 缓存的 connection 会"僵尸化"导致后续 hang(详见 AGENTS.md)。
|
||||
//!
|
||||
//! 对照 Go:`go func() { ... }()` ≈ `tokio::spawn(async { ... })`;
|
||||
//! 而 `block_on` 类似 Go 的 `select {}` 阻塞 main goroutine 等待退出。
|
||||
|
||||
// ashpd = XDG Portal 的 Rust 高层绑定,封装了 D-Bus ScreenCast 接口
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
// PersistMode 控制"恢复令牌"持久化级别(DoNot / Persistent / ExplicitlyRevoked)
|
||||
use ashpd::desktop::PersistMode;
|
||||
// BitFlags = 位域集合类型(一个值可同时包含多个 SourceType,类比 Go 的 iota | 操作)
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// 同步 main → 手动创建 tokio Runtime → block_on 阻塞驱动 async 块。
|
||||
// 这种"同步外壳 + 异步内核"的写法等价于 `#[tokio::main] async fn main()`,
|
||||
// 但保留了显式控制 runtime 生命周期的灵活性(参见文件头说明)。
|
||||
fn main() {
|
||||
// 手动创建 tokio runtime(含 reactor + executor + 时间驱动);
|
||||
// unwrap() 仅示例用;生产代码应返回 Result 并 `?` 传播(但 fn main 不返回 Result)
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
// block_on 阻塞当前线程直到传入的 future 完成;这是同步↔异步边界
|
||||
rt.block_on(async {
|
||||
// async {} 块构造一个匿名 future,仅在 block_on poll 时才执行(惰性,与 goroutine 不同)
|
||||
eprintln!("1. Creating Screencast proxy...");
|
||||
// Screencast::new() 内部通过 D-Bus 连接 org.freedesktop.portal.ScreenCast;
|
||||
// .await 让出执行权直到 future 就绪(Go 没有这个语法,需 channel/锁模拟)
|
||||
let proxy = match Screencast::new().await {
|
||||
Ok(p) => {
|
||||
eprintln!(" OK");
|
||||
@@ -13,11 +43,14 @@ fn main() {
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" FAIL: {e}");
|
||||
// early-return 仅退出 async 块(不是退出 main),block_on 返回 ()
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("2. Creating session...");
|
||||
// create_session 建立一个 ScreenCast 会话句柄;
|
||||
// Default::default() 用类型默认参数(ashpd 推断为 SessionOptions,所有字段取 Default)
|
||||
let session = match proxy.create_session(Default::default()).await {
|
||||
Ok(s) => {
|
||||
eprintln!(" OK");
|
||||
@@ -30,7 +63,15 @@ fn main() {
|
||||
};
|
||||
|
||||
eprintln!("3. Selecting sources...");
|
||||
// BitFlags<SourceType> 表达"可选多显示器/窗口/工作区"集合;
|
||||
// 这里 `into()` 将单个 Monitor 转为位域(Go 类似 flag = 1 << iota)
|
||||
let sources: BitFlags<SourceType> = SourceType::Monitor.into();
|
||||
// Builder 链式:每次 set_X 返回 &mut Self(类似 Go functional-options 模式但更显式)
|
||||
// - cursor_mode Embedded:光标嵌入帧内
|
||||
// - sources: 仅 Monitor(去掉窗口,简化授权 UX)
|
||||
// - multiple=false:单选(一次只授权一个显示器)
|
||||
// - persist_mode DoNot:不申请恢复令牌(避免持久权限残留)
|
||||
// 整个 builder 链构造一个 future,末尾的 .await 等待 D-Bus 返回
|
||||
let result = proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
@@ -50,6 +91,8 @@ fn main() {
|
||||
}
|
||||
|
||||
eprintln!("4. Starting (should show dialog)...");
|
||||
// start() 触发系统授权对话框(D-Bus 调用阻塞直到用户响应);
|
||||
// 第二参数 parent_window = None(无父窗口,常见于 CLI 程序)
|
||||
let response = match proxy.start(&session, None, Default::default()).await {
|
||||
Ok(r) => {
|
||||
eprintln!(" OK");
|
||||
@@ -60,6 +103,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Portal D-Bus 响应是双层结构:外层是 Request::response(Ok/Err),
|
||||
// 内层才是 ScreenCast 流信息(streams() 返回 PipeWire 节点 + dmabuf 信息列表)
|
||||
match response.response() {
|
||||
Ok(r) => eprintln!(" Got {} stream(s)", r.streams().len()),
|
||||
Err(e) => eprintln!(" Response error: {e}"),
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for issue #22: "Total frames" log printed twice on shutdown.
|
||||
#
|
||||
# Runs the wl-webrtc binary briefly, sends SIGINT, then asserts that
|
||||
# - "Total: N frames in ..." appears exactly once
|
||||
# - "StatePortal shutdown complete" appears exactly once
|
||||
#
|
||||
# Pre-fix: both lines printed twice (explicit shutdown + Drop re-entry).
|
||||
# Post-fix: both lines printed once (shutdown_started guard).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test_shutdown_idempotency.sh # WebRTC mode, build first
|
||||
# ./scripts/test_shutdown_idempotency.sh --mode file # --output mode instead
|
||||
# ./scripts/test_shutdown_idempotency.sh --skip-build # skip cargo build --release
|
||||
# ./scripts/test_shutdown_idempotency.sh --signal TERM # use SIGTERM instead of SIGINT
|
||||
#
|
||||
# Requires: a Wayland session (WAYLAND_DISPLAY). The script will warn but
|
||||
# proceed if unset; capture will simply fail and the test will report FAIL.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="webrtc"
|
||||
SKIP_BUILD=0
|
||||
SIGNAL="INT"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode) MODE="$2"; shift 2 ;;
|
||||
--mode=*) MODE="${1#*=}"; shift ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--signal) SIGNAL="$2"; shift 2 ;;
|
||||
--signal=*) SIGNAL="${1#*=}"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,18p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
GREEN=$'\e[32m'; RED=$'\e[31m'; YELLOW=$'\e[33m'; BOLD=$'\e[1m'; RESET=$'\e[0m'
|
||||
else
|
||||
GREEN=""; RED=""; YELLOW=""; BOLD=""; RESET=""
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if [[ -z "${WAYLAND_DISPLAY:-}" ]]; then
|
||||
echo "${YELLOW}WARNING${RESET}: WAYLAND_DISPLAY not set; live capture likely to fail." >&2
|
||||
fi
|
||||
|
||||
if [[ $SKIP_BUILD -eq 0 ]]; then
|
||||
echo "${BOLD}Building release binary...${RESET}"
|
||||
cargo build --release
|
||||
fi
|
||||
|
||||
BIN="$REPO_ROOT/target/release/wl-webrtc"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "${RED}FAIL${RESET}: $BIN not found. Run without --skip-build first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
webrtc)
|
||||
PORT=56666
|
||||
RUN_ARGS=(--port "$PORT" -v)
|
||||
EXTRA_CLEANUP=()
|
||||
;;
|
||||
file)
|
||||
OUTPUT_FILE="$(mktemp --tmpdir "wl22-test-XXXXXX.mp4")"
|
||||
RUN_ARGS=(--output "$OUTPUT_FILE" -v)
|
||||
EXTRA_CLEANUP=("rm -f "$OUTPUT_FILE"")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid --mode: $MODE (use 'webrtc' or 'file')" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
LOG="$(mktemp --tmpdir "wl22-test-XXXXXX.log")"
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$LOG"
|
||||
for cmd in "${EXTRA_CLEANUP[@]}"; do eval "$cmd" 2>/dev/null || true; done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "${BOLD}Running${RESET}: $BIN ${RUN_ARGS[*]}"
|
||||
"$BIN" "${RUN_ARGS[@]}" >"$LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Give the server time to initialize, capture at least one frame, and stabilize.
|
||||
sleep 3
|
||||
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "${RED}FAIL${RESET}: server exited before SIGINT could be sent." >&2
|
||||
echo "----- Log -----" >&2
|
||||
cat "$LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${BOLD}Sending SIG${SIGNAL}...${RESET}"
|
||||
kill -"$SIGNAL" "$SERVER_PID"
|
||||
|
||||
# Wait up to 3s for graceful exit.
|
||||
for _ in $(seq 1 30); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "${YELLOW}WARN${RESET}: process still alive 3s after SIG${SIGNAL}; force-killing"
|
||||
kill -TERM "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
SERVER_PID=""
|
||||
|
||||
# grep -c exits 1 on zero matches, which would abort under `set -e`; swallow it.
|
||||
TOTAL_COUNT=$(grep -c 'Total:.*frames in' "$LOG" || true)
|
||||
COMPLETE_COUNT=$(grep -c 'StatePortal shutdown complete' "$LOG" || true)
|
||||
|
||||
echo
|
||||
echo "${BOLD}----- Last 8 log lines -----${RESET}"
|
||||
tail -n 8 "$LOG"
|
||||
echo "${BOLD}------------------------------${RESET}"
|
||||
echo
|
||||
echo "\"Total: N frames in ...\": $TOTAL_COUNT occurrence(s) (expected 1)"
|
||||
echo "\"StatePortal shutdown complete\": $COMPLETE_COUNT occurrence(s) (expected 1)"
|
||||
|
||||
if [[ "$TOTAL_COUNT" -eq 1 && "$COMPLETE_COUNT" -eq 1 ]]; then
|
||||
echo
|
||||
echo "${GREEN}${BOLD}PASS${RESET}: shutdown is idempotent (issue #22 fixed)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "${RED}${BOLD}FAIL${RESET}: shutdown logged multiple times (issue #22 not fixed)"
|
||||
[[ "$TOTAL_COUNT" -ge 2 ]] && echo " - \"Total:\" printed $TOTAL_COUNT times"
|
||||
[[ "$COMPLETE_COUNT" -ge 2 ]] && echo " - \"shutdown complete\" printed $COMPLETE_COUNT times"
|
||||
echo
|
||||
echo "Debug hint: to compare before/after the fix, run:"
|
||||
echo " git stash && cargo build --release && $0 --skip-build && git stash pop"
|
||||
exit 1
|
||||
+58
@@ -1,53 +1,111 @@
|
||||
//! CLI 参数定义模块(基于 `clap` derive 宏)。
|
||||
//!
|
||||
//! 本文件用 `clap` 的 derive 宏把一个普通 struct 变成命令行解析器,思路类
|
||||
//! 似 Go 的 `flag` 包,但更贴近"struct tag 自动生成"——每个 `pub` 字段配
|
||||
//! 一行 `#[arg(...)]` 属性宏,clap 在编译期据此生成 `-x` / `--xxx` 选项、
|
||||
//! 帮助文案、默认值和类型校验。`#[derive(Parser, Debug, Clone)]` 三个
|
||||
//! derive 的作用:
|
||||
//! - `Parser`:clap 的入口 trait,提供 `Args::parse()`,等价于 Go 里的
|
||||
//! `flag.Parse()`;
|
||||
//! - `Debug`:支持 `{:?}` 调试打印;
|
||||
//! - `Clone`:允许 `Args::clone()` 值复制(运行循环里会用到)。
|
||||
//!
|
||||
//! Rust ↔ Go 类型对照(本文件用到的):
|
||||
//! - `Option<String>` ≈ Go `*string`:`None` 表示用户没传该 flag,等价于
|
||||
//! `nil` 指针;`Some(s)` 表示传了;
|
||||
//! - `String`(无 `Option`)≈ Go `string`:必有值,由 `default_value`
|
||||
//! 兜底,所以运行期不会空;
|
||||
//! - `u32` / `u64` / `u16` ≈ Go `uint32` / `uint64` / `uint16`;
|
||||
//! - `bool` ≈ Go `bool`,但 clap 把它当开关:出现即 `true`,不出现即
|
||||
//! `false`,等价于 Go 里没有参数的 `flag.Bool`;
|
||||
//! - `default_value_t = 30` ≈ Go `flag.Int("fps", 30, "...")` 的第二个
|
||||
//! 参数(默认值);
|
||||
//! - `default_value = "h264"` 用于 `String` 字段,等价意思;
|
||||
//! - `#[arg(short, long)]` 同时生成短选项(`-o`,取字段首字母)和长选项
|
||||
//! (`--output`);
|
||||
//! - `#[arg(long)]` 只生成长选项 `--output-name`,没有短形式。
|
||||
//!
|
||||
//! 注意:`AGENTS.md` 明确指出 README 的 CLI 表对 `--backend` 和 `--no-persist`
|
||||
//! 已过时,**以本文件为准**。
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
// 根解析器 struct。下方 `#[command(...)]` 设置 `--help` 第一行的程序名和
|
||||
// `about` 文案;注意不要在此 struct 上加 `///`,否则 clap 会把 doc 注释
|
||||
// 注入 help 文案,可能覆盖 `about`,导致 byte-identical 不变量被破坏。
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "wl-webrtc", about = "Wayland screen capture and encoding tool")]
|
||||
pub struct Args {
|
||||
/// Output file path (e.g., output.mp4, output.mkv). Optional when using --port for WebRTC mode
|
||||
#[arg(short, long)]
|
||||
pub output: Option<String>,
|
||||
// 输出文件路径(`-o`/`--output`)。`Option<String>` ≈ Go `*string`,`None` 表示用户没传
|
||||
|
||||
/// Wayland output name to capture
|
||||
#[arg(long)]
|
||||
pub output_name: Option<String>,
|
||||
// 指定要抓取的 Wayland 输出(显示器)名;`None` 时由后端自动选主屏
|
||||
|
||||
/// Target frames per second
|
||||
#[arg(long, default_value_t = 30)]
|
||||
pub fps: u32,
|
||||
// 目标帧率(`--fps`,默认 30)。`default_value_t = 30` ≈ Go `flag.Int("fps", 30, ...)`
|
||||
|
||||
/// Video codec (h264 only for MVP)
|
||||
#[arg(long, default_value = "h264")]
|
||||
pub codec: String,
|
||||
// 视频编码器(`--codec`,默认 `h264`)。MVP 阶段只支持 H.264,对比 Go 里 owned 的 `string`
|
||||
|
||||
/// Hardware acceleration method (vaapi only for MVP)
|
||||
#[arg(long, default_value = "vaapi")]
|
||||
pub hw_accel: String,
|
||||
// 硬件加速方式(`--hw-accel`,默认 `vaapi`),目前只接受 `vaapi`
|
||||
|
||||
/// DRM render device path (e.g., /dev/dri/renderD128)
|
||||
#[arg(long)]
|
||||
pub drm_device: Option<String>,
|
||||
// DRM 渲染节点路径(如 `/dev/dri/renderD128`),VAAPI 上下文需要它;`None` 时自动探测
|
||||
|
||||
/// Target bitrate in bits per second
|
||||
#[arg(long)]
|
||||
pub bitrate: Option<u64>,
|
||||
// 目标码率(bps)。`Option<u64>` ≈ Go `*uint64`,`None` 时编码器用内部默认码率
|
||||
|
||||
/// Maximum bitrate in bps for WebRTC mode. Caps BWE-driven escalation to
|
||||
/// prevent large IDR bursts from swamping the network. Default 8 Mbps covers
|
||||
/// 1080p30/1440p30 H.264 acceptably. Does NOT affect MP4 (--output) mode.
|
||||
/// See issue #23.
|
||||
#[arg(long, default_value = "8000000")]
|
||||
pub max_bitrate: u64,
|
||||
// WebRTC 模式下的码率上限(默认 8 Mbps),抑制 IDR 突发造成网络拥塞;MP4 模式忽略
|
||||
|
||||
/// Group of Pictures (GOP) size
|
||||
#[arg(long)]
|
||||
pub gop_size: Option<u32>,
|
||||
// GOP 长度(关键帧间距);`None` 时由编码器按内部策略自选
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
// 详细日志(`-v`/`--verbose`)。`bool` 在 clap 里是开关:出现即 `true`,等价 Go `flag.Bool`
|
||||
|
||||
/// Capture backend to use: 'screencopy' (wlroots) or 'portal' (KWin/KDE). Auto-detected if omitted
|
||||
#[arg(long)]
|
||||
pub backend: Option<String>,
|
||||
// 抓屏后端(`screencopy` 或 `portal`);`None` 时由 `backend_detect.rs` 自动选择
|
||||
|
||||
/// Port for WebRTC HTTP signaling server; 0 keeps MP4 file output mode
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub port: u16,
|
||||
// WebRTC HTTP 信令端口(`--port`,默认 0)。`0` 走 MP4 文件输出模式,`>0` 走 WebRTC 模式
|
||||
|
||||
/// Force re-authorization dialog (ignore saved portal restore token)
|
||||
#[arg(long)]
|
||||
pub no_persist: bool,
|
||||
// 忽略已保存的 portal restore token,强制每次都弹授权对话框(测试时常用)
|
||||
|
||||
/// Enable per-second pipeline statistics output for stutter diagnosis
|
||||
#[arg(long)]
|
||||
pub stats: bool,
|
||||
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
|
||||
}
|
||||
|
||||
+1487
-170
File diff suppressed because it is too large
Load Diff
+208
-9
@@ -1,3 +1,47 @@
|
||||
//! # Wayland 截屏后端自动检测(`src/backend_detect.rs`)
|
||||
//!
|
||||
//! 本文件负责检测当前 Wayland 桌面支持哪种屏幕捕获后端,由 [`detect_backend`]
|
||||
//! 返回 [`CaptureBackend::WlrScreencopy`](wlroots 合成器:Sway/Hyprland 等,
|
||||
//! 通过 `zwlr_screencopy_manager_v1` 协议直接交付 dmabuf,性能最好)或
|
||||
//! [`CaptureBackend::PortalPipeWire`](XDG Portal + PipeWire:KDE/GNOME 等,
|
||||
//! 通过 D-Bus 调用 `org.freedesktop.portal.ScreenCast` 接口)。
|
||||
//!
|
||||
//! ## 检测优先级(见 [`detect_backend`])
|
||||
//!
|
||||
//! 1. 用户显式 `--backend portal|screencopy` 命令行参数覆盖;
|
||||
//! 2. 自动检测:wlr-screencopy 优先(通过 Wayland globals 列表),否则回退到 Portal
|
||||
//! (通过 D-Bus 查询 ScreenCast 接口的 `version` 属性 >=1 即视为可用)。
|
||||
//!
|
||||
//! ## 为什么用 raw `zbus` 而不是 `ashpd`(**AGENTS.md 强约束**)
|
||||
//!
|
||||
//! AGENTS.md 明确禁止在此文件使用 `ashpd` crate,原因是:
|
||||
//! `ashpd` 内部把 `zbus::Connection` 缓存在一个全局 `OnceLock`。
|
||||
//! 如果拥有该 connection 的 Tokio runtime 被 drop(例如本文件
|
||||
//! [`check_portal_available`] 自建的临时 runtime 在函数返回时被 drop),
|
||||
//! 缓存的 connection 会变成"僵尸"——后续 `setup_portal()` 复用时会永远 hang,
|
||||
//! 因为底层 `tokio::mpsc` 通道对端已死、但缓存仍报告"已初始化"。
|
||||
//!
|
||||
//! 因此本文件用 `zbus::connection::Builder::session()...build().await` 直接构造
|
||||
//! 一条全新的、生命周期受当前 runtime 控制的连接,每次检测都重建。
|
||||
//!
|
||||
//! ## Go ↔ Rust 概念对照
|
||||
//!
|
||||
//! - `async fn` + `.await`:Rust async 是**惰性的**(async fn 返回 `impl Future`,
|
||||
//! 必须被 `.await` 或 `block_on` 才会真正执行),不同于 Go 的 `go f()` 立即并发。
|
||||
//! - `tokio::runtime::Runtime::new()` + `rt.block_on(fut)`:从同步代码驱动 async,
|
||||
//! 类比 Go `runtime.GOMAXPROCS(1)` + `select { case <-done: }`。
|
||||
//! - `tokio::time::timeout(d, fut).await` ≈ Go `context.WithTimeout(ctx, d)`,
|
||||
//! 返回 `Result<T, Elapsed>`,超时返回 `Err(Elapsed)`。
|
||||
//! - `Result<T, E>` + `?` 操作符 ≈ Go `if err != nil { return err }` 的语法糖。
|
||||
//! - `Option<T>` ≈ Go `*T`(指针可空),但 Rust 强制 `match`/`if let` 才能解引用。
|
||||
//! - `tracing::info!("...{e}")` ≈ Go `log.Printf`,支持 Rust 1.58+ 的内联捕获格式化。
|
||||
//! - `match { ... }` ≈ Go `switch`,但 Rust 强制穷尽所有分支(编译期检查)。
|
||||
//! - `&mut T`(可变引用)≈ Go `*T`,但 Rust 编译期保证无别名(只有一个 mut 引用)。
|
||||
//! - `move || { ... }` 闭包用 `move` 关键字显式捕获变量所有权(按值转移)。
|
||||
//! - `'static` 生命周期约束 ≈ Go"对象不能持有栈指针"的隐式约定,但 Rust 编译期检查。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
use wayland_client::globals::GlobalListContents;
|
||||
@@ -24,8 +68,15 @@ pub enum CaptureBackend {
|
||||
/// 用于后端检测期间列举 Wayland 全局对象的最小化分发类型(无需实际处理事件)
|
||||
struct RegistryLs;
|
||||
|
||||
// trait 分发:`Dispatch<WlRegistry, GlobalListContents> for RegistryLs` 表示
|
||||
// "用 RegistryLs 作为状态对象、GlobalListContents 作为上下文数据来处理 WlRegistry 事件"。
|
||||
// 类比 Go interface 的隐式满足,但 Rust trait 在编译期静态分发(generic 单态化),
|
||||
// 即编译器为每个 (State, Event) 组合生成一份专属代码——零运行时开销。
|
||||
// 为 RegistryLs 实现 Wayland 注册表事件分发(空实现,仅需类型满足 trait 约束)
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
|
||||
// `fn event` 是 Dispatch trait 必须实现的方法:每收到一个 Wayland 事件触发一次。
|
||||
// 下划线前缀参数(`_state`、`_registry` 等):Rust 编译器允许声明但不使用,
|
||||
// 类比 Go 中 `_ = ctx` 显式忽略变量;这里我们只关心类型满足 trait、不处理事件。
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &WlRegistry,
|
||||
@@ -40,35 +91,118 @@ impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
|
||||
// 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.
|
||||
|
||||
/// Per-operation D-Bus timeout for Portal backend detection.
|
||||
/// Portal 后端检测期间每个 D-Bus 操作的超时时间。
|
||||
const PORTAL_DBUS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 当 Portal 在超时时间内无响应时,记录详细的错误日志(含 systemctl 重启建议)。
|
||||
///
|
||||
/// 这是一个辅助函数——调用方已经在超时路径上返回了 `false`,本函数仅负责打印提示。
|
||||
/// 不返回 `Result`:日志写入失败本身不应该影响后端检测逻辑。
|
||||
fn log_portal_unresponsive(operation: &str) {
|
||||
tracing::error!(
|
||||
"Portal service did not respond within 5s while {operation}. \
|
||||
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
|
||||
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
|
||||
then re-run wl-webrtc."
|
||||
);
|
||||
}
|
||||
|
||||
/// 通过 D-Bus 检测 XDG Portal ScreenCast 接口是否可用。
|
||||
///
|
||||
/// 检测流程(每一步都有 5 秒超时保护,见 [`PORTAL_DBUS_TIMEOUT`]):
|
||||
/// 1. 连接到 D-Bus session bus;
|
||||
/// 2. 构造 `org.freedesktop.portal.Desktop` 的 ScreenCast proxy;
|
||||
/// 3. 查询 ScreenCast 接口的 `version` 属性(>=1 即视为可用)。
|
||||
///
|
||||
/// 任何一步超时或失败都返回 `false`——上层 [`detect_backend`] 据此决定回退策略。
|
||||
///
|
||||
/// # 同步外壳 + 异步内核
|
||||
///
|
||||
/// `check_portal_available` 本身是同步 `fn`(被同步的 [`detect_backend`] 调用),
|
||||
/// 但内部通过 `tokio::runtime::Runtime::new()` + `rt.block_on(async { ... })`
|
||||
/// 桥接到 async `zbus` API。类比 Go:`func check() bool { rt := NewRuntime(); defer rt.Close(); return rt.BlockOn(asyncFn()) }`。
|
||||
fn check_portal_available() -> bool {
|
||||
// 创建独立的 Tokio runtime:外层 `detect_backend` 是同步 `fn`,没有 async runtime
|
||||
// 上下文,需要自建一个来驱动 `.await`。
|
||||
// 类比 Go:每次调用 `runtime.GOMAXPROCS(1)` 启动一个临时调度器。
|
||||
// **关键**:这个 runtime 在函数结束时 drop——这也是为什么不能用 ashpd
|
||||
// (ashpd 缓存 connection 到全局,runtime drop 后 connection 变僵尸,见文件头注释)。
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
// `tracing::warn!` 宏:结构化日志,类比 Go `log.Printf`,
|
||||
// 但支持 Rust 1.58+ 的 `{e}` 内联捕获格式化(变量名直接作占位符)。
|
||||
tracing::warn!("Failed to create tokio runtime for portal check: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// `rt.block_on(future)`:在当前同步线程上驱动 future 到完成。
|
||||
// 类比 Go:`select { case <-done: }` 阻塞等待 goroutine 结束。
|
||||
// 但 Rust 的 `block_on` 是单线程内 cooperatively 调度 future(除非 runtime 配 multi-thread)。
|
||||
rt.block_on(async {
|
||||
let conn = match zbus::Connection::session().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// `async { ... }` 块构造一个匿名 Future(类比 Go `func() {}` 闭包)。
|
||||
// 注意:async 块是惰性的——只有 `.await` 或 `block_on` 才会真正执行体内代码。
|
||||
// Set method_timeout on the connection (bounds method replies) and wrap
|
||||
// the build itself in tokio::time::timeout (bounds connection setup).
|
||||
// 同时设置 method_timeout 与外层 tokio::time::timeout 双重保护。
|
||||
// `tokio::time::timeout(d, fut)` ≈ Go `context.WithTimeout(ctx, d)`,
|
||||
// 返回 `Result<T, Elapsed>`——超时返回 `Err(Elapsed)`。
|
||||
let conn = match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, async {
|
||||
// `zbus::connection::Builder::session()` 是 Builder 模式:
|
||||
// 类比 Go `&http.Client{Timeout: ...}` 用链式方法配置参数。
|
||||
// `.expect("...")`:失败时 panic(类比 Go `log.Panic`),
|
||||
// 只用于"不可能失败"的构造——这里 session bus builder 几乎不会失败。
|
||||
// `.method_timeout(...)` 设置单个 D-Bus 方法调用的超时上限。
|
||||
// `.build().await` 异步构造 Connection(涉及 D-Bus 握手)。
|
||||
zbus::connection::Builder::session()
|
||||
.expect("D-Bus session bus builder failed")
|
||||
.method_timeout(PORTAL_DBUS_TIMEOUT)
|
||||
.build()
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
// 嵌套 Result 解构:外层 `Result<Connection, Elapsed>`(来自 timeout),
|
||||
// 内层 `Result<Connection, zbus::Error>`(来自 build)。
|
||||
// `Ok(Ok(c)) => c` 是模式匹配的多层解构(destructuring)——
|
||||
// 类比 Go `if err == nil && inner_err == nil { c := value }`。
|
||||
Ok(Ok(c)) => c,
|
||||
Ok(Err(e)) => {
|
||||
tracing::info!("D-Bus session bus unavailable: {e}");
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
// `Err(_)` 中的 `_` 是通配符模式:匹配任意值并丢弃。
|
||||
// 这里我们关心的是"超时了",不关心 `Elapsed` 的具体值。
|
||||
log_portal_unresponsive("connecting to D-Bus session bus");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// `zbus::Proxy`:D-Bus proxy 是远程对象的强类型句柄,封装 destination+path+interface。
|
||||
// 类比 Go 中的 `dbus.ObjectProxy`:调用 `proxy.get_property(...)` 时
|
||||
// 自动 marshal 成 D-Bus 消息发到目标对象。
|
||||
// `Builder::new(&conn).destination(...).and_then(|b| b.path(...))` 链式构造:
|
||||
// `and_then` 来自 `Result`,把 `Result<Builder, E>` 解开再继续链——
|
||||
// 类比 Go `if b, err := b.X(); err != nil { return err } else { b.Y() }`。
|
||||
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) => {
|
||||
Ok(b) => match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, b.build()).await {
|
||||
Ok(Ok(p)) => p,
|
||||
Ok(Err(e)) => {
|
||||
tracing::info!("Portal ScreenCast interface not available: {e}");
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
log_portal_unresponsive("building ScreenCast proxy");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::info!("Portal ScreenCast proxy build failed: {e}");
|
||||
@@ -76,25 +210,56 @@ fn check_portal_available() -> bool {
|
||||
}
|
||||
};
|
||||
|
||||
let version = match inner.get_property::<u32>("version").await {
|
||||
Ok(version) => {
|
||||
// 查询 ScreenCast 接口的 `version` 属性——这是最可能卡住的操作,
|
||||
// 因为前两步只是本地构造 proxy,而 get_property 需要 Portal 端实际处理请求。
|
||||
// `.get_property::<u32>("version")`:泛型方法,turbofish `::<u32>` 指定返回类型,
|
||||
// 类比 Go `GetVersion() (uint32, error)`——但 Rust 用泛型 + 编译期单态化。
|
||||
// The most likely operation to hang — requires actual Portal-side work.
|
||||
// 最可能卡住的操作,需要 Portal 端实际处理。
|
||||
let version = match tokio::time::timeout(
|
||||
PORTAL_DBUS_TIMEOUT,
|
||||
inner.get_property::<u32>("version"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(version)) => {
|
||||
tracing::info!("Portal ScreenCast available (version: {version})");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(Err(e)) => {
|
||||
tracing::info!("Portal ScreenCast version query failed: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log_portal_unresponsive("querying ScreenCast version");
|
||||
false
|
||||
}
|
||||
};
|
||||
version
|
||||
})
|
||||
}
|
||||
|
||||
// 通过 Wayland globals 检测 wlr-screencopy 协议是否可用
|
||||
//
|
||||
// Wayland globals 是合成器在连接建立时广播的"已支持协议"列表——
|
||||
// 类比 Go 中的 HTTP OPTIONS:客户端连上服务器后先查询能力,再决定怎么说话。
|
||||
// 我们只需检查列表里是否有 `zwlr_screencopy_manager_v1` 这个接口名即可。
|
||||
fn check_screencopy_available() -> Result<bool> {
|
||||
// `Connection::connect_to_env()?`:从 WAYLAND_DISPLAY 环境变量读取 socket 路径并连接。
|
||||
// `?` 操作符:如果 `connect_to_env` 返回 `Err(e)`,立即把 `e` 转换为函数返回类型
|
||||
// (`anyhow::Result`),并 return 之。类比 Go `if err != nil { return err }`。
|
||||
let conn = Connection::connect_to_env()?;
|
||||
// `registry_queue_init::<RegistryLs>(&conn)?`:turbofish `::<RegistryLs>` 指定
|
||||
// 用我们刚定义的空 Dispatch 实现来接收 registry 事件。函数内部会 roundtrip
|
||||
// 一次拿到所有 globals,返回 `(GlobalList, Queue)` 元组。
|
||||
// `let (globals, _queue) = ...`:元组解构(tuple destructuring),
|
||||
// 类比 Go `globals, queue := ...`,但 Rust 用 `_queue` 表示"我接收但不会用到"。
|
||||
let (globals, _queue) = registry_queue_init::<RegistryLs>(&conn)?;
|
||||
|
||||
// 迭代器链式调用(zero-cost,编译期单态化):
|
||||
// `.contents()` → `GlobalList`;`.clone_list()` → `Vec<Global>`;
|
||||
// `.iter()` → `Iterator<&Global>`;`.any(|g| ...)` → `bool`(短路求值)。
|
||||
// `|g| g.interface == "..."` 是闭包(closure),类比 Go `func(g Global) bool { ... }`。
|
||||
let has_screencopy = globals
|
||||
.contents()
|
||||
.clone_list()
|
||||
@@ -125,7 +290,14 @@ fn check_screencopy_available() -> Result<bool> {
|
||||
pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
|
||||
// 1. Check explicit override
|
||||
// 步骤 1:检查用户是否通过命令行参数显式指定了后端
|
||||
// `if let Some(ref backend) = args.backend`:模式匹配 + `ref` 关键字。
|
||||
// `args.backend` 类型是 `Option<String>`,`Some(ref backend)` 表示
|
||||
// "如果是 Some,则把内部 String 的**引用**绑定到 backend"(不获取所有权)。
|
||||
// 类比 Go `if args.Backend != nil { backend := args.Backend }`。
|
||||
if let Some(ref backend) = args.backend {
|
||||
// `backend.as_str()`:把 `&String` 转 `&str`(类比 Go string → []byte view)。
|
||||
// `match backend.as_str() { ... }`:Rust 的 match 对 `&str` 强制穷尽所有分支,
|
||||
// 类比 Go `switch backend { case "portal": ...; default: ... }`,但没有隐式 fallthrough。
|
||||
return match backend.as_str() {
|
||||
"portal" => {
|
||||
tracing::info!("Backend override: Portal/PipeWire");
|
||||
@@ -136,7 +308,10 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
|
||||
Ok(CaptureBackend::WlrScreencopy)
|
||||
}
|
||||
other => {
|
||||
// `other` 是匹配模式变量:绑定未被前面 arm 命中的任意值(类比 Go `default`)。
|
||||
// 未知后端名称,返回错误
|
||||
// `anyhow::bail!("...", args)` 是宏(注意 `!`):立即构造 `anyhow::Error`
|
||||
// 并从当前函数 return `Err`。类比 Go `return fmt.Errorf("...", ...)`。
|
||||
anyhow::bail!("Unknown backend '{}'. Use 'screencopy' or 'portal'.", other);
|
||||
}
|
||||
};
|
||||
@@ -147,11 +322,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
|
||||
tracing::info!("Auto-detecting capture backend...");
|
||||
|
||||
// 检测 wlr-screencopy(通过 Wayland globals)
|
||||
// `check_screencopy_available()?` 末尾的 `?`:把 `Result<bool>` 解开——
|
||||
// 成功取 bool,失败则立即 return `Err`(错误向上传播)。
|
||||
let has_screencopy = check_screencopy_available()?;
|
||||
// 检测 Portal(通过 D-Bus)
|
||||
// `check_portal_available()` 无 `?`:因为它返回的是 `bool` 而不是 `Result`,
|
||||
// 内部已经把所有错误吞掉并转为 `false`。
|
||||
let has_portal = check_portal_available();
|
||||
|
||||
// 根据检测结果选择后端,screencopy 优先(性能更好、延迟更低)
|
||||
// `match (has_screencopy, has_portal) { ... }`:元组匹配——同时匹配两个 bool。
|
||||
// `(true, _)` 中的 `_` 是通配符:表示"任意值都匹配"。类比 Go `switch { case hasSC: ... }`。
|
||||
// Rust 强制穷尽所有 (bool, bool) 组合,编译期检查,不能漏掉一个分支。
|
||||
match (has_screencopy, has_portal) {
|
||||
(true, _) => {
|
||||
tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend");
|
||||
@@ -171,11 +353,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
|
||||
}
|
||||
}
|
||||
|
||||
// `#[cfg(test)]` 属性:条件编译——`cargo build` 时这个 mod 不会被编译进二进制,
|
||||
// 只有 `cargo test` 时才参与编译。这样发布产物零运行时开销。
|
||||
// 类比 Go 中 `_test.go` 后缀的约定:测试代码与生产代码物理分离。
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// `use super::*;`:glob 导入(wildcard import),把父模块的所有 pub item 引入当前作用域。
|
||||
// 类比 Go 中的 dot-import(`. "pkg"`),但 Rust 限定在 `super::` 即父模块内。
|
||||
// 这里用来在测试中直接访问 `detect_backend`、`CaptureBackend` 等。
|
||||
use super::*;
|
||||
|
||||
// 测试辅助函数:构造指定后端参数的 Args 实例
|
||||
// 注意:辅助函数不需要 `#[test]` 属性——它只是被测试函数调用的普通函数。
|
||||
fn make_args(backend: Option<&str>) -> Args {
|
||||
Args {
|
||||
output: Some("test.mp4".to_string()),
|
||||
@@ -185,20 +374,28 @@ mod tests {
|
||||
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]` 属性:标记此函数为测试用例,`cargo test` 自动发现并执行。
|
||||
// 测试函数约定:`fn name() {}` 无参数无返回值;panic 即测试失败。
|
||||
#[test]
|
||||
fn explicit_portal_backend() {
|
||||
let args = make_args(Some("portal"));
|
||||
let result = detect_backend(&args);
|
||||
// `assert!(cond)` 宏:条件为 false 时 panic,类比 Go `if !cond { t.Fatal() }`。
|
||||
assert!(result.is_ok());
|
||||
// `assert_eq!(a, b)` 宏:断言相等,失败时打印两边内容,类比 Go `if a != b { t.Errorf() }`。
|
||||
// `.unwrap()`:解开 Result——成功取内部值,失败 panic。
|
||||
// 测试代码中常用 `unwrap()` 简化错误处理;生产代码应避免(用 `?` 替代)。
|
||||
assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire);
|
||||
}
|
||||
|
||||
@@ -217,6 +414,8 @@ mod tests {
|
||||
let args = make_args(Some("magic"));
|
||||
let result = detect_backend(&args);
|
||||
assert!(result.is_err());
|
||||
// `.unwrap_err()`:与 `unwrap()` 相反——解开 Err 中的错误值(如果 Ok 则 panic)。
|
||||
// `.to_string()`:把 `anyhow::Error` 转为 `String`(用 Display 格式化)。
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Unknown backend 'magic'"),
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
//! 软件编码流水线性能基准(独立二进制 `sw_encode_bench`)。
|
||||
//!
|
||||
//! ## 用途
|
||||
//!
|
||||
//! 测量"纯 CPU"屏幕采集编码流水线的端到端耗时,作为对照参考与 VAAPI 硬件编码
|
||||
//! 基准 `vaapi_import_bench`(`src/bin/vaapi_import_bench.rs`)形成对比:
|
||||
//! - 本文件:Portal 采集 → `mmap` 把 DMA-BUF 映射到用户态 → `sws_scale` 在 CPU
|
||||
//! 上做 BGR0→YUV420P 颜色空间/缩放转换 → libx264/openh264 软件编码。
|
||||
//! - 对照 `vaapi_import_bench.rs`:Portal 采集 → `av_hwframe_map` 在 GPU 上做
|
||||
//! 零拷贝格式转换 → VAAPI 硬件编码(GPU)。
|
||||
//!
|
||||
//! ## 输出
|
||||
//!
|
||||
//! 打印 mmap / sws_scale / encode 三段每帧平均耗时与总体 FPS,便于判断"软件路径"
|
||||
//! 在当前硬件上能否达到 30 FPS 目标。AMD GPU 在某些驱动下不允许 CPU 读取 DMA-BUF,
|
||||
//! `mmap` 会失败——这正是 `vaapi_import_bench` 存在的意义。
|
||||
//!
|
||||
//! ## Rust ↔ Go 对照
|
||||
//!
|
||||
//! - `clap::Parser` derive 宏:类似 Go 的 `flag` 包,但在编译期生成解析代码。
|
||||
//! - `std::time::Instant`:高精度单调时钟,等价于 Go 的 `time.Now()` + `time.Since()`。
|
||||
//! - `crossbeam_channel::recv_timeout`:等价于 Go 的 `select { case <-time.After(): }`。
|
||||
//! - 本文件大量使用裸 `unsafe` FFI 调用 FFmpeg C API;现有 21 处 unsafe 块均
|
||||
//! 未标注 SAFETY 标记,本任务也不补充,仅在每个 unsafe 块上方加普通 `//`
|
||||
//! 中文概述,说明"为什么必须 unsafe"。
|
||||
//!
|
||||
//! 用法:`cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4`
|
||||
|
||||
// sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture
|
||||
//
|
||||
// Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode
|
||||
//
|
||||
// Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4
|
||||
|
||||
// 以下 `use` 语句分组:FFI 字符串/裸 fd 转换/路径/指针/计时 → anyhow/clap →
|
||||
// ffmpeg_next 别名与 ffi → crate 内 Portal 采集器。Rust 没有 Go 的 "package"
|
||||
// 概念,每个外部 crate 都要显式 `use`。
|
||||
use std::ffi::CString;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
@@ -11,15 +42,24 @@ use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
// `clap::Parser` derive 宏:编译期生成 CLI 解析代码,等价于 Go 的 `flag` 包
|
||||
// 但支持子命令/类型转换/帮助文本自动生成。
|
||||
use clap::Parser;
|
||||
|
||||
// FFmpeg 绑定,使用 `ffmpeg_next` crate(社区维护的 next 分支)。`as ff` 别名
|
||||
// 缩短调用路径;`ffi` 子模块直接暴露 C ABI(裸指针、`AVFormatContext` 等)。
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
use ffmpeg_next::packet::Mut;
|
||||
|
||||
// 复用主程序的 `Args` 与 Portal 采集器:基准与主二进制共享同一采集代码路径,
|
||||
// 仅"消费方"不同(基准直接落盘,主程序走 WebRTC 推流)。
|
||||
use wl_webrtc::args::Args;
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
// 基准 CLI 参数定义。`#[derive(Parser, Debug)]` 让 clap 在编译期为 struct
|
||||
// 生成 `parse()` 方法;`#[command(...)]` 设置程序元信息。等价于 Go 程序的
|
||||
// `flag.StringVar(...)` 序列,但在 Rust 里完全声明式。
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "sw_encode_bench",
|
||||
@@ -39,6 +79,9 @@ struct BenchArgs {
|
||||
enc_height: u32,
|
||||
}
|
||||
|
||||
// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec,
|
||||
// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats
|
||||
// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。
|
||||
#[derive(Default)]
|
||||
struct FrameStats {
|
||||
mmap_us: Vec<u64>,
|
||||
@@ -48,7 +91,12 @@ struct FrameStats {
|
||||
mmap_failures: u32,
|
||||
}
|
||||
|
||||
// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的
|
||||
// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯,
|
||||
// 也可以写成自由函数 `fn avg_ms(...)`。
|
||||
impl FrameStats {
|
||||
// 把 Vec<u64> 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec
|
||||
// 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。
|
||||
fn avg_ms(data: &[u64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
@@ -57,18 +105,26 @@ impl FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始
|
||||
// `AVPixelFormat`(i32 别名)。`Into::into` 在此处零成本——编译期已知映射。
|
||||
fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
|
||||
Into::<ffi::AVPixelFormat>::into(p)
|
||||
}
|
||||
|
||||
// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。
|
||||
// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比:
|
||||
// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }`
|
||||
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
|
||||
loop {
|
||||
// `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
|
||||
}
|
||||
}
|
||||
// `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。
|
||||
match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
@@ -84,7 +140,13 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
|
||||
}
|
||||
}
|
||||
|
||||
// 程序入口。流程四阶段:[1/4] 申请 Portal 授权并连接 PipeWire;[2/4] 等首帧
|
||||
// 拿到 DMA-BUF 元数据(宽高/stride/fd);[3/4] 试 mmap 一帧验证 CPU 可读;
|
||||
// [4/4] 配置 libx264 编码器 + FFmpeg 输出格式上下文,进入主采集编码循环并打印统计。
|
||||
// `anyhow::Result<()>` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回
|
||||
// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。
|
||||
fn main() -> Result<()> {
|
||||
// clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。
|
||||
let bench_args = BenchArgs::parse();
|
||||
|
||||
println!("=== Software Encode Benchmark ===");
|
||||
@@ -96,11 +158,14 @@ fn main() -> Result<()> {
|
||||
);
|
||||
println!();
|
||||
|
||||
// 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。
|
||||
ff::init()?;
|
||||
|
||||
println!("[1/4] Requesting screen capture via XDG Portal...");
|
||||
println!(" (Select a screen to share in the portal dialog)");
|
||||
|
||||
// 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了
|
||||
// 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。
|
||||
let portal_args = Args {
|
||||
output: Some(bench_args.output.clone()),
|
||||
output_name: None,
|
||||
@@ -109,19 +174,24 @@ fn main() -> Result<()> {
|
||||
hw_accel: "vaapi".to_string(),
|
||||
drm_device: None,
|
||||
bitrate: None,
|
||||
max_bitrate: 8_000_000,
|
||||
gop_size: None,
|
||||
verbose: false,
|
||||
backend: Some("portal".to_string()),
|
||||
port: 0,
|
||||
no_persist: false,
|
||||
stats: false,
|
||||
};
|
||||
|
||||
// `CapPortal::new` 会触发 XDG Portal 授权对话框(用户需要在屏幕共享对话框里选屏)。
|
||||
let cap = CapPortal::new(&portal_args)?;
|
||||
println!("[1/4] Portal connected, PipeWire stream active\n");
|
||||
|
||||
println!("[2/4] Waiting for first frame from PipeWire...");
|
||||
let first_frame = receive_first_frame(&cap)?;
|
||||
|
||||
// PipeWire 推来的首帧携带了 DMA-BUF 的元数据:fd(文件描述符)+ offset
|
||||
// + stride(每行字节数)+ width/height/format。后续 mmap 就靠这些。
|
||||
let src_width = first_frame.width;
|
||||
let src_height = first_frame.height;
|
||||
let src_stride = first_frame.stride;
|
||||
@@ -139,6 +209,9 @@ fn main() -> Result<()> {
|
||||
|
||||
println!("[3/4] Testing mmap on DMA-BUF...");
|
||||
let mmap_size = (src_stride as usize) * (src_height as usize);
|
||||
// unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以
|
||||
// 必须 unsafe:mmap 接受 void* 返回 raw 指针,编译器无法验证其有效性;
|
||||
// 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
@@ -150,6 +223,8 @@ fn main() -> Result<()> {
|
||||
)
|
||||
};
|
||||
|
||||
// `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读
|
||||
// DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
bail!(
|
||||
@@ -170,6 +245,8 @@ fn main() -> Result<()> {
|
||||
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
|
||||
mmap_size as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
// unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer,
|
||||
// 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, mmap_size);
|
||||
}
|
||||
@@ -177,10 +254,15 @@ fn main() -> Result<()> {
|
||||
|
||||
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
|
||||
println!("[4/4] Setting up libx264 encoder...");
|
||||
// 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。
|
||||
// CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。
|
||||
let output_path = Path::new(&bench_args.output);
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
|
||||
// Try libx264 first (best quality/speed), fall back to openh264
|
||||
// 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。
|
||||
// Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的
|
||||
// 多次 if err != nil 但不嵌套。
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
@@ -188,11 +270,13 @@ fn main() -> Result<()> {
|
||||
})?;
|
||||
println!("[4/4] Using encoder: {}\n", codec.name());
|
||||
|
||||
// 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
// 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。
|
||||
enc.set_width(enc_width);
|
||||
enc.set_height(enc_height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
@@ -202,6 +286,9 @@ fn main() -> Result<()> {
|
||||
|
||||
let codec_name = codec.name();
|
||||
if codec_name == "libx264" {
|
||||
// unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。
|
||||
// FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存,
|
||||
// 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("veryfast").unwrap();
|
||||
@@ -216,7 +303,11 @@ fn main() -> Result<()> {
|
||||
let mut enc_video = opened.0;
|
||||
|
||||
// Create output format context via FFI
|
||||
// FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
// unsafe #4:`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回
|
||||
// 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证
|
||||
// 调用方传入了正确的容器格式猜测。
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
@@ -229,21 +320,31 @@ fn main() -> Result<()> {
|
||||
bail!("Failed to allocate output format context: error {ret}");
|
||||
}
|
||||
|
||||
// unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。
|
||||
// 返回的 `stream_ptr` 是裸指针,调用方负责不 double-free(FFmpeg 内部托管)。
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create new stream");
|
||||
}
|
||||
|
||||
// unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。
|
||||
// FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证
|
||||
// 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to copy encoder parameters: error {ret}");
|
||||
}
|
||||
|
||||
// unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续
|
||||
// mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格
|
||||
// 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// unsafe #8:`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期
|
||||
// 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。
|
||||
let ret = unsafe {
|
||||
ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
@@ -258,17 +359,27 @@ fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束
|
||||
// (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write header: error {ret}");
|
||||
}
|
||||
|
||||
// unsafe #10:`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。
|
||||
// unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码
|
||||
// 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中
|
||||
// 同款的"独占所有权"约定。
|
||||
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
|
||||
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
|
||||
// sws_scale 是 FFmpeg 的颜色空间转换器(CPU 软件)。本基准的"软件路径"核心:
|
||||
// 把 DMA-BUF 的 BGR0 像素数据转成 libx264 期望的 YUV420P planar 格式。
|
||||
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
|
||||
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
|
||||
|
||||
// unsafe #11:`sws_getContext` 创建转换器。FFI 必须 unsafe:返回 raw 指针,
|
||||
// 调用方负责后续 `sws_freeContext` 释放(cleanup 阶段会做)。
|
||||
let sws_ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
src_width as i32,
|
||||
@@ -288,14 +399,20 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Allocate reusable YUV frame
|
||||
// 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求
|
||||
// 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。
|
||||
let mut yuv_frame = unsafe {
|
||||
// unsafe #12:`av_frame_alloc` 只分配 struct 本体,不分配 data 缓冲区。
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
// unsafe #13:通过裸指针写入 width/height/format 字段。
|
||||
(*f).width = enc_width as i32;
|
||||
(*f).height = enc_height as i32;
|
||||
(*f).format = yuv420p_fmt as i32;
|
||||
// unsafe #14:`av_frame_get_buffer` 根据 width/height/format 分配实际像素缓冲区。
|
||||
// 失败时必须 free 已分配的 struct(避免泄漏)。
|
||||
let ret = ffi::av_frame_get_buffer(f, 0);
|
||||
if ret < 0 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
@@ -311,12 +428,16 @@ fn main() -> Result<()> {
|
||||
|
||||
println!("=== Encoding {} frames ===\n", bench_args.frames);
|
||||
|
||||
// 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响),
|
||||
// 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。
|
||||
let mut stats = FrameStats::default();
|
||||
let total_start = Instant::now();
|
||||
let mut frames_encoded: u32 = 0;
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
// 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。
|
||||
while frames_encoded < bench_args.frames {
|
||||
// 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result<Option<T>>。
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => {
|
||||
@@ -327,9 +448,11 @@ fn main() -> Result<()> {
|
||||
eprintln!("PipeWire error after {} frames: {}", frames_encoded, e);
|
||||
break;
|
||||
}
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。
|
||||
let frame = match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
@@ -341,10 +464,14 @@ fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
// 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。
|
||||
let frame_start = Instant::now();
|
||||
|
||||
// ---- 第 1 段:mmap DMA-BUF 到用户态 ----
|
||||
let mmap_start = Instant::now();
|
||||
let frame_size = (frame.stride as usize) * (frame.height as usize);
|
||||
// unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到
|
||||
// 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
@@ -364,9 +491,16 @@ fn main() -> Result<()> {
|
||||
}
|
||||
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
|
||||
|
||||
// ---- 第 2 段:sws_scale BGR0 → YUV420P ----
|
||||
let scale_start = Instant::now();
|
||||
// unsafe #16:`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。
|
||||
// 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向
|
||||
// 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。
|
||||
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
|
||||
|
||||
// unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险:
|
||||
// (1) 裸指针 src_ptr / src_linesize;(2) yuv_frame->data/linesize 数组
|
||||
// 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。
|
||||
unsafe {
|
||||
ffi::av_frame_make_writable(yuv_frame);
|
||||
|
||||
@@ -387,13 +521,18 @@ fn main() -> Result<()> {
|
||||
.scale_us
|
||||
.push(scale_start.elapsed().as_micros() as u64);
|
||||
|
||||
// unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, frame_size);
|
||||
}
|
||||
drop(frame);
|
||||
|
||||
// ---- 第 3 段:libx264 编码 ----
|
||||
let encode_start = Instant::now();
|
||||
|
||||
// unsafe #19:`avcodec_send_frame` 把一帧 YUV 喂给编码器(异步:内部入队)。
|
||||
// FFI 必须 unsafe:裸指针 enc_video.as_mut_ptr()/yuv_frame;编译期无法
|
||||
// 验证 enc 已 open、yuv_frame 的 width/height/format 与编码器配置一致。
|
||||
unsafe {
|
||||
(*yuv_frame).pts = pts;
|
||||
pts += 1;
|
||||
@@ -427,6 +566,8 @@ fn main() -> Result<()> {
|
||||
let total_elapsed = total_start.elapsed();
|
||||
|
||||
println!("\nFlushing encoder...");
|
||||
// unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。
|
||||
// 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。
|
||||
unsafe {
|
||||
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
|
||||
}
|
||||
@@ -436,6 +577,8 @@ fn main() -> Result<()> {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
|
||||
|
||||
// Cleanup
|
||||
// unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII,
|
||||
// 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。
|
||||
unsafe {
|
||||
ffi::av_frame_free(&mut yuv_frame as *mut _);
|
||||
ffi::sws_freeContext(sws_ctx);
|
||||
@@ -444,6 +587,8 @@ fn main() -> Result<()> {
|
||||
drop(cap);
|
||||
|
||||
// Print results
|
||||
// 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比
|
||||
// `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。
|
||||
let mmap_count = stats.mmap_us.len() as u32;
|
||||
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
|
||||
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
|
||||
@@ -452,6 +597,7 @@ fn main() -> Result<()> {
|
||||
};
|
||||
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
|
||||
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
|
||||
// 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。
|
||||
let max_fps = if avg_total_ms > 0.0 {
|
||||
1000.0 / avg_total_ms
|
||||
} else {
|
||||
@@ -516,14 +662,21 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是
|
||||
// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264
|
||||
// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN
|
||||
// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。
|
||||
fn drain_encoder(
|
||||
enc_video: &mut ff::encoder::video::Video,
|
||||
octx: &mut ff::format::context::Output,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// unsafe #22:`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须
|
||||
// unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。
|
||||
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
|
||||
if ret < 0 {
|
||||
// EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
@@ -532,13 +685,19 @@ fn drain_encoder(
|
||||
}
|
||||
|
||||
let enc_tb = enc_video.time_base();
|
||||
// unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于
|
||||
// rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams
|
||||
// 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream
|
||||
// 就 bail 了)。
|
||||
let stream_tb = unsafe {
|
||||
let streams = (*octx.as_ptr()).streams;
|
||||
let st = *streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
// 把 PTS 从编码器时基 rescale 到流时基(mp4 容器要求)。Go 类比:单位换算。
|
||||
pkt.rescale_ts(enc_tb, stream_tb);
|
||||
pkt.set_stream(0);
|
||||
// `write_interleaved` 让 FFmpeg 自动处理 interleaving(音视频交错,避免 demuxer 卡)。
|
||||
pkt.write_interleaved(octx)
|
||||
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,99 @@
|
||||
//! # vaapi_import_bench — VAAPI DMA-BUF 导入性能基准
|
||||
//!
|
||||
//! 本文件是 wl-webrtc 项目下的**独立可执行二进制**(位于 `src/bin/`),用于离线
|
||||
//! 测量 "Portal 屏幕捕获 → DMA-BUF 导入到 VAAPI 硬件帧 → GPU 下采样 → 编码" 这条
|
||||
//! 关键流水线的端到端耗时,并与 "CPU 软编" 路径作对比,输出每阶段平均毫秒数与 FPS。
|
||||
//!
|
||||
//! ## 流水线
|
||||
//!
|
||||
//! - **CPU 路径**:PipeWire BGRA 帧 → `sws_scale` 缩放 → libx264/libopenh264 软编
|
||||
//! - **GPU 路径**:PipeWire DMA-BUF → `av_hwframe_map` → `scale_vaapi` 滤镜 → VAAPI H.264
|
||||
//!
|
||||
//! ## 与 Go benchmark 的类比
|
||||
//!
|
||||
//! 类似 Go 的 `testing.B`:先跑预热帧,再用 `Instant::now()` / `Duration::as_micros()`
|
||||
//! 采集每个阶段的耗时(导入、缩放、传输、编码),最后输出 `FrameStats` 平均值。
|
||||
//!
|
||||
//! ## 用法
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
|
||||
//! cargo run --bin vaapi_import_bench -- --output /dev/null --mode gpu
|
||||
//! cargo run --bin vaapi_import_bench -- --output /tmp/cpu.mp4 --mode cpu --frames 120
|
||||
//! ```
|
||||
//!
|
||||
//! 详见 `AGENTS.md` 的 "Useful manual commands" 章节。
|
||||
|
||||
// vaapi_import_bench.rs — VAAPI DMA-BUF import + GPU-side downscale benchmark
|
||||
//
|
||||
// Tests: Portal capture -> av_hwframe_map (ARGB sw_format) -> transfer -> sw encode
|
||||
//
|
||||
// Usage: cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
|
||||
|
||||
// ===== 标准库导入 =====
|
||||
// CString:FFI 传递给 C 函数的 NUL 结尾字符串;类比 Go 中显式末尾 0 的 []byte
|
||||
// AsRawFd trait:把 Rust 的 OwnedFd 暴露为原始 int fd(用于 DMA-BUF 导入)
|
||||
// Path:跨平台路径类型;类比 Go filepath
|
||||
// ptr:FFI 裸指针工具(ptr::null_mut()、ptr::null()),类比 Go unsafe.Pointer(nil)
|
||||
// Instant:高精度单调时钟;类比 Go time.Now(),用 elapsed() 取差值
|
||||
use std::ffi::CString;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
// ===== 第三方 crate =====
|
||||
// anyhow:Result<T> = Result<T, anyhow::Error>;bail! 宏提前返回 Err;类比 Go (T, error)
|
||||
// clap:CLI 参数解析(Derive 宏);本文件 BenchArgs 与 args.rs Args 都用此模式
|
||||
use anyhow::{bail, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
// ffmpeg_next:FFmpeg 绑定。ffi 子模块是 raw C FFI(含 unsafe),其余为高层封装
|
||||
// packet::Mut trait:提供 as_mut_ptr(),用于拿到 AVPacket* 喂给 C API
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
use ffmpeg_next::packet::Mut;
|
||||
|
||||
// 从本 crate (wl-webrtc) 复用:CLI Args、VAAPI 上下文、Portal 捕获
|
||||
use wl_webrtc::args::Args;
|
||||
use wl_webrtc::avhw::{import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
/// 基准测试的 CLI 参数。Derive `Parser` 后 `BenchArgs::parse()` 即可从 argv 解析;
|
||||
/// 类比 Go 中 `flag.StringVar` + `flag.Parse()`,但 Rust 用编译期宏生成代码。
|
||||
///
|
||||
/// 注意:与生产二进制 `wl-webrtc` 的 `Args`(见 `src/args.rs`)不同——这里是基准专用
|
||||
/// 参数集(更细粒度的 enc_width/enc_height/mode),不复用 `Args`。
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "vaapi_import_bench", about = "VAAPI DMA-BUF import benchmark")]
|
||||
struct BenchArgs {
|
||||
// 输出文件路径。如果包含 "null" 子串则使用 FFmpeg 的 null muxer(不写盘,只测编码耗时)
|
||||
#[arg(short, long)]
|
||||
output: String,
|
||||
|
||||
// 总编码帧数;类比 Go benchmark 的 b.N,但这里是固定值(默认 60 帧)
|
||||
#[arg(long, default_value_t = 60)]
|
||||
frames: u32,
|
||||
|
||||
// 编码器输出宽(GPU 路径会下采样到该尺寸)
|
||||
#[arg(long, default_value_t = 2560)]
|
||||
enc_width: u32,
|
||||
|
||||
// 编码器输出高
|
||||
#[arg(long, default_value_t = 1440)]
|
||||
enc_height: u32,
|
||||
|
||||
// DRM 渲染节点路径;VAAPI 上下文绑定到此设备(Intel iGPU 通常是 renderD128)
|
||||
#[arg(long, default_value = "/dev/dri/renderD128")]
|
||||
drm_device: String,
|
||||
|
||||
// 流水线模式:cpu 只跑软编;gpu 只跑 VAAPI;both 两条路径都跑并对比
|
||||
#[arg(long, value_enum, default_value_t = PipelineMode::Both)]
|
||||
mode: PipelineMode,
|
||||
}
|
||||
|
||||
/// 流水线模式选择。Derive `ValueEnum` 后 clap 自动把 "cpu"/"gpu"/"both" 字符串
|
||||
/// 映射到枚举值;Derive `Copy` 让它在 match 时按值复制(无需 & 引用)。
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
enum PipelineMode {
|
||||
Cpu,
|
||||
@@ -50,34 +101,54 @@ enum PipelineMode {
|
||||
Both,
|
||||
}
|
||||
|
||||
/// 单条流水线(CPU 或 GPU)运行结束后的统计聚合。每个 `Vec<u64>` 保存每帧的耗时(微秒)。
|
||||
///
|
||||
/// 类比 Go 的 `BenchmarkResult`:把 N 帧的逐次耗时收集起来,最后统一计算均值。
|
||||
/// 用 `Vec<u64>` 而非流式累积是为了支持后续可能的中位数/分位数扩展。
|
||||
#[derive(Default)]
|
||||
struct FrameStats {
|
||||
// DMA-BUF 导入耗时(仅 GPU 路径有,CPU 路径为空)
|
||||
import_us: Vec<u64>,
|
||||
// GPU 滤镜图耗时(仅 GPU 路径有)
|
||||
filter_us: Vec<u64>,
|
||||
// CPU 路径的 sws_scale 耗时
|
||||
transfer_us: Vec<u64>,
|
||||
// 预留:缩放耗时单独拆分(当前与 filter_us/transfer_us 重叠)
|
||||
scale_us: Vec<u64>,
|
||||
// 像素格式转换耗时(BGRA → YUV420P)
|
||||
format_us: Vec<u64>,
|
||||
// 编码器 send_frame + drain_packet 总耗时
|
||||
encode_us: Vec<u64>,
|
||||
// 单帧总耗时(capture_start → encode_done),用于理论 FPS
|
||||
total_us: Vec<u64>,
|
||||
// 导入失败的次数(DMA-BUF fd 失效等)
|
||||
import_failures: u32,
|
||||
// 实际成功编码的帧数
|
||||
frames_encoded: u32,
|
||||
// 端到端墙钟耗时(从首帧到末帧),用于实测 FPS
|
||||
elapsed_secs: f64,
|
||||
// 编码器名称(libx264 / libopenh264 / h264_vaapi)
|
||||
codec_name: String,
|
||||
// 输出路径(区分 cpu / gpu 文件名)
|
||||
output_path: String,
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
// 计算每帧耗时的均值(微秒 → 毫秒);空 Vec 返回 0.0 避免除零
|
||||
fn avg_ms(data: &[u64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
// sum::<u64>() 显式指定求和类型,避免类型推导失败;类比 Go 的 for-range 累加
|
||||
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
|
||||
}
|
||||
|
||||
// 单帧总耗时的均值(毫秒),用于报告 "平均每帧 X ms"
|
||||
fn avg_total_ms(&self) -> f64 {
|
||||
Self::avg_ms(&self.total_us)
|
||||
}
|
||||
|
||||
// 实测 FPS = 成功编码帧数 / 墙钟耗时;避免零除返回 0.0
|
||||
fn achieved_fps(&self) -> f64 {
|
||||
if self.frames_encoded > 0 && self.elapsed_secs > 0.0 {
|
||||
self.frames_encoded as f64 / self.elapsed_secs
|
||||
@@ -86,6 +157,7 @@ impl FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
// 理论 FPS = 1000 / 平均单帧总耗时(仅编码侧上限,不含 PipeWire 等待)
|
||||
fn theoretical_fps(&self) -> f64 {
|
||||
let avg = self.avg_total_ms();
|
||||
if avg > 0.0 {
|
||||
@@ -96,6 +168,11 @@ impl FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU 软编路径的状态聚合体:编码器、输出容器、可复用的 YUV 帧。
|
||||
///
|
||||
/// 字段 `yuv_frame` 是裸指针 `*mut ffi::AVFrame`——因为 FFmpeg C API 要求长生命周期
|
||||
/// 的可变指针,且需要 Drop 时显式释放。裸指针 `*mut T` 默认非 Send/Sync,但本结构体
|
||||
/// 只在主线程使用,无需跨线程传递,因此无需手动 impl Send。
|
||||
struct SoftwareEncoder {
|
||||
enc_video: ff::codec::encoder::video::Video,
|
||||
octx: ff::format::context::Output,
|
||||
@@ -103,8 +180,11 @@ struct SoftwareEncoder {
|
||||
codec_name: String,
|
||||
}
|
||||
|
||||
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时由 Rust 自动调用,
|
||||
// 避免裸指针 yuv_frame 泄漏。注意 Drop 内不能再使用 self.yuv_frame,只能释放
|
||||
impl Drop for SoftwareEncoder {
|
||||
fn drop(&mut self) {
|
||||
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时自动调用
|
||||
// SAFETY: yuv_frame is allocated by av_frame_alloc in create_software_encoder and
|
||||
// owned exclusively by this SoftwareEncoder.
|
||||
unsafe {
|
||||
@@ -113,10 +193,14 @@ impl Drop for SoftwareEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// FFmpeg `sws_scale` 上下文的拥有型包装。Newtype 模式(tuple struct 单字段)让
|
||||
/// Rust 类型系统追踪 C 资源的所有权,并通过 Drop 自动释放;类比 Go 中
|
||||
/// `type SwsContext struct{ p *C.SwsContext }` + `func (s *SwsContext) Close()`。
|
||||
struct SwsContext(*mut ffi::SwsContext);
|
||||
|
||||
impl Drop for SwsContext {
|
||||
fn drop(&mut self) {
|
||||
// sws_freeContext 接受 NULL 是安全的(C 规范),无需额外判空
|
||||
// SAFETY: Context is either null or returned by sws_getContext and owned here.
|
||||
unsafe {
|
||||
ffi::sws_freeContext(self.0);
|
||||
@@ -124,23 +208,37 @@ impl Drop for SwsContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 FFmpeg 错误码(负数)翻译成人类可读字符串。FFmpeg 的错误码没有官方码表,
|
||||
/// 必须通过 `av_strerror` 拿到文本;类比 Go 中 `errno.String()` 或 `os.PathError.Err`。
|
||||
fn av_err_to_string(ret: i32) -> String {
|
||||
// 准备 128 字节缓冲区(FFmpeg 习惯用 128),由 av_strerror 写入 NUL 结尾的 C 字符串
|
||||
let mut buf = vec![0u8; 128];
|
||||
// 中文 unsafe 概述:av_strerror 最多写 128 字节并以 NUL 结尾;buf 是独占的可变 Vec<u8>,
|
||||
// as_mut_ptr 把缓冲区首字节暴露给 C,借用仅在这次调用期间有效。
|
||||
unsafe {
|
||||
ffi::av_strerror(ret, buf.as_mut_ptr() as *mut i8, buf.len());
|
||||
}
|
||||
// 找到首个 NUL 字节作为字符串末尾,再 from_utf8_lossy 容错转 String
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).to_string()
|
||||
}
|
||||
|
||||
/// 阻塞等待第一帧 PipeWire DMA-BUF 到达;类比 Go 的 `chan.Recv()` 配 `select`。
|
||||
///
|
||||
/// 同时监听控制通道(StreamEnded / FormatChanged / Error),任何错误都立即 `bail!`。
|
||||
/// 超时 10 秒防止 GPU/驱动卡死导致基准测试无限挂起。
|
||||
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
|
||||
loop {
|
||||
// 控制通道:非阻塞 try_recv(类比 Go `select { case e := <-ctrl: ... default: }`)
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
|
||||
}
|
||||
}
|
||||
// 帧通道:阻塞等待最多 10 秒
|
||||
// 类比 Go `select { case f := <-frame: ... case <-time.After(10*time.Second): bail! }`
|
||||
match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
@@ -156,21 +254,31 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
|
||||
}
|
||||
}
|
||||
|
||||
/// 从编码器循环拉取已编码的 packet 并写入输出容器,直到编码器返回 EAGAIN/EOF。
|
||||
///
|
||||
/// "Drain" 模式:调用 `avcodec_send_frame` 后必须连续 `avcodec_receive_packet` 直到
|
||||
/// EAGAIN,否则编码器内部缓冲区会堵塞,下一帧 send_frame 会失败。
|
||||
fn drain_encoder(
|
||||
enc_video: &mut ff::codec::encoder::video::Video,
|
||||
octx: &mut ff::format::context::Output,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// 中文 unsafe 概述:enc_video.as_mut_ptr() 指向已打开的编码器上下文;pkt.as_mut_ptr()
|
||||
// 指向空 packet,FFmpeg 会在此调用中分配 packet 数据。
|
||||
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
|
||||
if ret < 0 {
|
||||
// EAGAIN = 编码器还需要更多输入帧;EOF = 已 flush;两者都是正常终止
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
eprintln!("avcodec_receive_packet failed: {ret}");
|
||||
break;
|
||||
}
|
||||
// 把 PTS 从编码器时间基重缩放为输出流的时间基(视频流可有不同 time_base)
|
||||
let enc_tb = enc_video.time_base();
|
||||
// 中文 unsafe 概述:octx.as_ptr() 指向有效的 AVFormatContext;streams 数组至少有一个流
|
||||
// (在 create_software_encoder 中由 avformat_new_stream 创建)。
|
||||
let stream_tb = unsafe {
|
||||
let streams = (*octx.as_ptr()).streams;
|
||||
let st = *streams.add(0);
|
||||
@@ -178,14 +286,28 @@ fn drain_encoder(
|
||||
};
|
||||
pkt.rescale_ts(enc_tb, stream_tb);
|
||||
pkt.set_stream(0);
|
||||
// write_interleaved 让 FFmpeg 自动按 DTS 排序,避免手动管理 PTS/DTS
|
||||
pkt.write_interleaved(octx)
|
||||
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 libx264/libopenh264 软编码器 + 输出容器(MP4/null muxer)+ 可复用 YUV420P 帧。
|
||||
///
|
||||
/// 这是基准 CPU 路径的核心装配函数,步骤依次为:
|
||||
/// 1. 寻找 codec(libx264 优先,libopenh264 回退)
|
||||
/// 2. 创建 encoder context(builder 模式)
|
||||
/// 3. 设置 width/height/fps/time_base/GOP
|
||||
/// 4. (libx264 专属)设置 preset/tune
|
||||
/// 5. 打开编码器
|
||||
/// 6. 分配 AVFormatContext + 创建流 + 复制 codec parameters
|
||||
/// 7. 打开输出文件(除非 null muxer)+ 写文件头
|
||||
/// 8. 分配可复用的 YUV420P 帧(在每帧 encode 时复用)
|
||||
fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Result<SoftwareEncoder> {
|
||||
// CString 必须在 unsafe 块外构造,确保 NUL 结尾的字符串生命周期覆盖下面的 FFI 调用
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
// 优先 libx264(性能最好,GPL 协议),其次 libopenh264(BSD,回退方案)
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
@@ -193,18 +315,24 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
|
||||
})?;
|
||||
|
||||
let codec_name = codec.name().to_string();
|
||||
// 两阶段构造:先 Context::new_with_codec 拿到 builder,再 .encoder().video()? 切到视频编码视图
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
// 编码器参数:分辨率、像素格式、时基、GOP 结构
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
// time_base = 1/60,与基准测试默认 60 FPS 对齐;生产代码里通常从源流继承
|
||||
enc.set_time_base(ff::Rational::new(1, 60));
|
||||
// 关闭 B 帧以降低延迟(基准不追求压缩率)
|
||||
enc.set_max_b_frames(0);
|
||||
// GOP = 60:每 60 帧一个 I 帧(与 60 FPS 对齐 = 每秒一个 IDR 帧)
|
||||
enc.set_gop(60);
|
||||
|
||||
// libx264 的私有参数 preset/tune 必须在 encoder 打开前通过 av_opt_set 设置到 priv_data
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: priv_data belongs to the not-yet-opened encoder context. Option strings are
|
||||
// valid NUL-terminated C strings for the duration of each av_opt_set call.
|
||||
@@ -218,9 +346,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
// 真正打开编码器(前面只是 builder 状态),此后 enc_video 进入 ready 状态
|
||||
let opened = enc.open()?;
|
||||
let enc_video = opened.0;
|
||||
|
||||
// 输出文件名含 "null" → 用 FFmpeg 内置 null muxer(不写盘),适合纯 CPU 基准
|
||||
let use_null_muxer = output_path
|
||||
.to_str()
|
||||
.map(|s| s.contains("null"))
|
||||
@@ -264,6 +394,7 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
|
||||
bail!("Failed to copy codec parameters: error {ret}");
|
||||
}
|
||||
|
||||
// AVFMT_NOFILE 表示该 muxer 不需要物理文件(如 null muxer),跳过 avio_open
|
||||
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
|
||||
unsafe {
|
||||
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
|
||||
@@ -284,9 +415,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
|
||||
bail!("Failed to write header: error {ret}");
|
||||
}
|
||||
|
||||
// 此后 octx 拥有 fmt_ctx_ptr,会在 Drop 时调用 avformat_free_context
|
||||
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
|
||||
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
|
||||
// 这个 yuv_frame 在每次 encode_yuv_frame 中复用(不重新分配),由 SoftwareEncoder::drop 释放
|
||||
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
|
||||
let yuv_frame = unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
@@ -312,28 +445,41 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据 `PipelineMode` 在文件名中插入 `cpu` 或 `gpu` 后缀,让 both 模式下两条路径不互相覆盖。
|
||||
///
|
||||
/// 例:`/tmp/out.mp4` + `PipelineMode::Cpu` → `/tmp/out.cpu.mp4`。
|
||||
/// `split=false` 或文件名含 "null" 时直接返回原路径(null muxer 不需要分裂)。
|
||||
fn output_for_mode(base: &str, mode: PipelineMode, split: bool) -> String {
|
||||
if !split || base.contains("null") {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
let path = Path::new(base);
|
||||
// match 在 Rust 中默认是穷尽的(编译器强制覆盖所有 enum 变体);
|
||||
// 这里 Both 在调用前已被外层排除,用 unreachable!() 标记
|
||||
let suffix = match mode {
|
||||
PipelineMode::Cpu => "cpu",
|
||||
PipelineMode::Gpu => "gpu",
|
||||
PipelineMode::Both => unreachable!(),
|
||||
};
|
||||
// file_name 返回 Option<&OsStr>,and_then + to_str 链式处理 None 情况
|
||||
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(base);
|
||||
// rsplit_once 类比 Go 的 strings.Cut:从右侧切分一次扩展名(保留 "a.b.c" 中的 "a.b" 与 "c")
|
||||
let split_name = if let Some((stem, ext)) = file_name.rsplit_once('.') {
|
||||
format!("{stem}.{suffix}.{ext}")
|
||||
} else {
|
||||
format!("{file_name}.{suffix}")
|
||||
};
|
||||
// with_file_name 保留父目录,只替换末尾文件名;to_string_lossy 容错 OsStr → &str
|
||||
path.with_file_name(split_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// 创建 BGRA→YUV420P 的 swscale 上下文。`SwsContext` 是 CPU 路径的颜色空间/尺寸转换核心。
|
||||
///
|
||||
/// 第 7 个参数 `2` = bicubic 算法;FFmpeg 还提供 fast_bilinear(1) / bilinear(2) /
|
||||
/// lanczos(16) 等。基准选 bicubic 是平衡速度与质量。
|
||||
fn create_sws_context(
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
@@ -341,6 +487,7 @@ fn create_sws_context(
|
||||
dst_width: u32,
|
||||
dst_height: u32,
|
||||
) -> Result<SwsContext> {
|
||||
// 返回的 *mut SwsContext 由 SwsContext 包装并在 Drop 中通过 sws_freeContext 释放。
|
||||
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
|
||||
let ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
@@ -362,11 +509,15 @@ fn create_sws_context(
|
||||
Ok(SwsContext(ctx))
|
||||
}
|
||||
|
||||
/// 把已填好 YUV420P 数据的 `encoder.yuv_frame` 送入编码器,并 drain 已编码 packet。
|
||||
/// 返回编码阶段的耗时(微秒),用于 `FrameStats::encode_us` 统计。
|
||||
fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64> {
|
||||
// 类比 Go time.Now();用 as_micros() as u64 转 u64(u128 截断不影响 60s 量级基准)
|
||||
let t_encode = Instant::now();
|
||||
// SAFETY: yuv_frame is allocated, writable, and formatted as the encoder's configured
|
||||
// YUV420P input frame. FFmpeg consumes but does not take ownership.
|
||||
unsafe {
|
||||
// 单调递增的 PTS;FFmpeg 要求 PTS 必须按 time_base 单位递增,否则丢帧
|
||||
(*encoder.yuv_frame).pts = *pts;
|
||||
*pts += 1;
|
||||
let r = ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), encoder.yuv_frame);
|
||||
@@ -374,10 +525,14 @@ fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64>
|
||||
bail!("avcodec_send_frame failed: {r}");
|
||||
}
|
||||
}
|
||||
// drain 编码器缓冲区(必须,否则下一帧 send_frame 会 EAGAIN)
|
||||
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
|
||||
Ok(t_encode.elapsed().as_micros() as u64)
|
||||
}
|
||||
|
||||
/// 编码结束:发送 NULL frame 触发编码器 flush,drain 残余 packet,写入文件尾(trailer)。
|
||||
///
|
||||
/// 类比 Go 中 `io.Closer`:必须按顺序 (flush → drain → trailer) 才能产出可播放的文件。
|
||||
fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
|
||||
// SAFETY: Sending a null frame flushes the encoder; context remains owned by encoder.
|
||||
unsafe {
|
||||
@@ -391,6 +546,8 @@ fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把 PipeWire 给的 DMA-BUF 帧导入 VAAPI 硬件帧上下文,返回 `ff::frame::Video`(GPU 帧)。
|
||||
/// 这是 GPU 路径的入口;耗时由 `FrameStats::import_us` 统计。
|
||||
fn import_frame(
|
||||
frames_ctx: &AvHwFrameCtx,
|
||||
frame: &wl_webrtc::cap_portal::PwDmaBufFrame,
|
||||
@@ -411,6 +568,10 @@ fn import_frame(
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 GPU 路径的 FFmpeg 滤镜图:`buffer`(CPU 入口)→ `scale_vaapi`(GPU 缩放+格式转换)→ `buffersink`。
|
||||
///
|
||||
/// 关键点:buffer 滤镜不能用 pix_fmt=VAAPI 直接初始化(FFmpeg 8+ 会拒绝),
|
||||
/// 必须用 `av_buffersrc_parameters_set` 注入 hw_frames_ctx 才能让后续 VAAPI 滤镜识别。
|
||||
fn build_gpu_filter_graph(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_rgb: &AvHwFrameCtx,
|
||||
@@ -420,10 +581,13 @@ fn build_gpu_filter_graph(
|
||||
enc_height: u32,
|
||||
) -> Result<ff::filter::Graph> {
|
||||
let mut graph = ff::filter::Graph::new();
|
||||
// buffer = 滤镜图入口,从 AVFrame 注入数据
|
||||
let buffersrc =
|
||||
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
|
||||
// buffersink = 滤镜图出口,取出处理后的 AVFrame
|
||||
let buffersink = ff::filter::find("buffersink")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
|
||||
// scale_vaapi = VAAPI 硬件缩放 + 格式转换(BGRA→NV12)
|
||||
let scale_vaapi = ff::filter::find("scale_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
|
||||
|
||||
@@ -448,14 +612,18 @@ fn build_gpu_filter_graph(
|
||||
(*par).width = width as i32;
|
||||
(*par).height = height as i32;
|
||||
(*par).time_base = ffi::AVRational { num: 1, den: 60 };
|
||||
// ref_clone 增加引用计数(AVBufferRef 共享底层 AVHWFramesContext),
|
||||
// FFmpeg 内部会持有这个引用直到 buffersrc 释放
|
||||
(*par).hw_frames_ctx = frames_rgb.ref_clone();
|
||||
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
|
||||
// 只释放参数结构体本身;AVBufferRef 的引用由 buffersrc 持有,不能在这里 free
|
||||
ffi::av_free(par as *mut _);
|
||||
if ret < 0 {
|
||||
bail!("av_buffersrc_parameters_set failed: error {ret}");
|
||||
}
|
||||
}
|
||||
|
||||
// scale_vaapi 滤镜参数:缩放到 enc_width×enc_height,输出 NV12(VAAPI H.264 要求的输入格式)
|
||||
let mut scale_ctx = graph.add(
|
||||
&scale_vaapi,
|
||||
"scale",
|
||||
@@ -467,6 +635,7 @@ fn build_gpu_filter_graph(
|
||||
}
|
||||
|
||||
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
|
||||
// 链接:in[0] → scale[0] → out[0],pad index 0 是默认输入/输出口
|
||||
src_ctx.link(0, &mut scale_ctx, 0);
|
||||
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||
graph
|
||||
@@ -476,6 +645,16 @@ fn build_gpu_filter_graph(
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
// CPU 流水线主函数:跑 `frames` 帧测量每阶段耗时(import → transfer → scale → encode)。
|
||||
// 与 GPU 路径的核心差异:CPU 路径**不经过 scale_vaapi 滤镜**,而是用 `av_hwframe_transfer_data`
|
||||
// 把硬件帧"下载"到 CPU 内存(4K BGRA),再用 `sws_scale` 在 CPU 上做下采样到 2K YUV420P;
|
||||
// 因此 CPU 路径的"transfer"和"scale"耗时都明显高于 GPU 路径。
|
||||
//
|
||||
// 类比 Go benchmark:类似 `func benchCPU(b *testing.B) { for n := 0; n < b.N; n++ {...} }`,
|
||||
// 但 Rust 用 `while stats.frames_encoded < frames` 显式循环(无 testing.B 框架)。
|
||||
//
|
||||
// 参数:8 个参数(含 src/enc 尺寸 4 个)—— clippy 默认会嫌太多,故上方 `#[allow]` 抑制。
|
||||
// 返回 `Result<FrameStats>`:任何 FFmpeg/Portal 失败立即 `?` 传播到 main。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_cpu_pipeline(
|
||||
cap: &CapPortal,
|
||||
@@ -487,7 +666,10 @@ fn run_cpu_pipeline(
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) -> Result<FrameStats> {
|
||||
// 构造软件编码器(libx264 或 libopenh264,取决于 create_software_encoder 内部 fallback)。
|
||||
// `?` 自动把 anyhow::Error 上浮到调用者;类比 Go `if err != nil { return err }`。
|
||||
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
|
||||
// 构造 sws_scale 上下文:源 = 4K BGRA,目标 = 2K YUV420P;sws_scale 内部完成下采样+色彩空间转换。
|
||||
let sws_ctx = create_sws_context(
|
||||
src_width,
|
||||
src_height,
|
||||
@@ -496,6 +678,8 @@ fn run_cpu_pipeline(
|
||||
enc_height,
|
||||
)?;
|
||||
|
||||
// println! 是宏(不是函数),类比 Go fmt.Println;第一参数是 format! 模板字符串。
|
||||
// `{output}` 是内联格式化语法(Rust 1.58+),等价于 `format!("{}", output)`。
|
||||
println!(
|
||||
" Encoder: {}, {}x{} YUV420P",
|
||||
encoder.codec_name, enc_width, enc_height
|
||||
@@ -503,25 +687,40 @@ fn run_cpu_pipeline(
|
||||
println!(" Output: {output}");
|
||||
println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n");
|
||||
|
||||
// 构造 FrameStats,用 struct update 语法 `..FrameStats::default()` 让其余字段取 Default 值。
|
||||
// 类比 Go `&FrameStats{Codec: codec, Output: out}` 仅设置 2 字段其余清零。
|
||||
let mut stats = FrameStats {
|
||||
codec_name: encoder.codec_name.clone(),
|
||||
output_path: output.to_string(),
|
||||
..FrameStats::default()
|
||||
};
|
||||
// 整条流水线总耗时起点;elapsed() 返回 Duration,后续 as_secs_f64() 取秒(float)。
|
||||
let total_start = Instant::now();
|
||||
// PTS(Presentation Time Stamp,单位 = 编码器 time_base.den 的倒数)—— 单调递增的演示时钟。
|
||||
// `let mut` 表示可变绑定(默认不可变,Rust 与 Go 的关键差异之一)。
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
// 主循环:直到编码完 `frames` 帧;类比 Go `for stats.FramesEncoded < frames {`。
|
||||
while stats.frames_encoded < frames {
|
||||
// try_recv 非阻塞从 PipeWire 控制通道取事件;Ok 表示有事件,Err(TryRecvError::Empty) 跳过。
|
||||
// 类比 Go `select { case ev := <-ctrlCh: ... default: }`。
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
// match 是穷尽性模式匹配(每个 enum variant 必须覆盖或用 `_` 兜底)。
|
||||
match ctrl {
|
||||
// 流正常结束(用户停止共享 / Portal 关闭):跳出主循环。
|
||||
PwCtrlEvent::StreamEnded => break,
|
||||
// PipeWire 报错:把帧号 + 错误信息 bail! 到调用者(bail! = return Err(anyhow!(...)))。
|
||||
PwCtrlEvent::Error(e) => bail!(
|
||||
"PipeWire error after {} CPU frames: {e}",
|
||||
stats.frames_encoded
|
||||
),
|
||||
// 格式变化(分辨率/像素格式):本基准忽略,等下一帧自然到达。
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// recv_timeout 阻塞最多 5 秒取下一帧;类比 Go `select { case f := <-frCh: ... case <-time.After(5*time.Second): }`。
|
||||
// match 直接对 Result 解构:Ok(f) 拿到帧,Err(_)(超时或断开)直接 break 结束。
|
||||
let frame = match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
@@ -530,30 +729,41 @@ fn run_cpu_pipeline(
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
// 单帧起点:用于统计 total_us(包含所有子阶段)。
|
||||
let frame_start = Instant::now();
|
||||
// import 阶段起点:把 DMA-BUF 帧封装为 AV_PIX_FMT_VAAPI 硬件帧(av_hwframe_map 路径)。
|
||||
let t_import = Instant::now();
|
||||
// match 表达式对 Result 解构并支持多分支(含 guard 与错误处理)。
|
||||
let vaapi_frame = match import_frame(frames_ctx, &frame) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
// 失败计数器自增;前 3 次打印到 stderr,避免日志淹没。
|
||||
stats.import_failures += 1;
|
||||
if stats.import_failures <= 3 {
|
||||
eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded);
|
||||
}
|
||||
// continue 跳过本帧后续步骤(不是错误退出)。
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// elapsed() 返回 Duration;as_micros() → u128;`as u64` 截断到 u64(帧耗时不会超 2^64 微秒)。
|
||||
let import_us = t_import.elapsed().as_micros() as u64;
|
||||
|
||||
// transfer 阶段:用 av_hwframe_transfer_data 把硬件帧拷贝到 CPU 内存(4K BGRA)。
|
||||
let t_transfer = Instant::now();
|
||||
// SAFETY: sw_frame is allocated by FFmpeg and freed on all paths below.
|
||||
let mut sw_frame = unsafe { ffi::av_frame_alloc() };
|
||||
if sw_frame.is_null() {
|
||||
// av_frame_alloc 返回 NULL 表示 OOM; bail! 把错误抛到 main(不是 panic)。
|
||||
bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded);
|
||||
}
|
||||
// av_hwframe_transfer_data:FFmpeg 提供的硬件→软件帧拷贝 API;src=VAAPI,dst=CPU 内存帧。
|
||||
// 第 3 参数 flags 通常传 0;返回 0 表示成功,负数表示 FFmpeg 错误码。
|
||||
// SAFETY: sw_frame is an allocated destination; vaapi_frame is a valid VAAPI source frame.
|
||||
let transfer_ret =
|
||||
unsafe { ffi::av_hwframe_transfer_data(sw_frame, vaapi_frame.as_ptr(), 0) };
|
||||
if transfer_ret < 0 {
|
||||
// 错误路径必须 free,否则内存泄漏;FFmpeg C API 无 RAII。
|
||||
// SAFETY: sw_frame was allocated above and has not been freed yet.
|
||||
unsafe { ffi::av_frame_free(&mut sw_frame) };
|
||||
bail!(
|
||||
@@ -565,11 +775,15 @@ fn run_cpu_pipeline(
|
||||
}
|
||||
let transfer_us = t_transfer.elapsed().as_micros() as u64;
|
||||
|
||||
// scale 阶段:在 CPU 上把 4K BGRA 下采样到 2K YUV420P(CPU 路径的瓶颈所在)。
|
||||
let t_scale = Instant::now();
|
||||
// SAFETY: sw_frame contains transferred BGRA data; encoder.yuv_frame is writable YUV420P
|
||||
// at the configured output dimensions; sws_ctx converts and downscales between them.
|
||||
unsafe {
|
||||
// av_frame_make_writable:确保 yuv_frame 内部 buffer 可写(FFmpeg 引用计数可能共享)。
|
||||
ffi::av_frame_make_writable(encoder.yuv_frame);
|
||||
// sws_scale:libswscale 主接口;参数 = (ctx, src_slices[], src_stride[], src_y_start, src_h, dst_slices[], dst_stride[])。
|
||||
// `(*sw_frame).data.as_ptr() as *const *const u8` 把 C 数组首地址转裸指针(FFmpeg AVFrame.data 是 [u8*; 8])。
|
||||
ffi::sws_scale(
|
||||
sws_ctx.0,
|
||||
(*sw_frame).data.as_ptr() as *const *const u8,
|
||||
@@ -581,12 +795,16 @@ fn run_cpu_pipeline(
|
||||
);
|
||||
}
|
||||
let scale_us = t_scale.elapsed().as_micros() as u64;
|
||||
// 缩放完成后立即释放中间 BGRA 帧(约 4K*2160*4 = 33MB),避免峰值内存。
|
||||
// SAFETY: sw_frame was allocated above and is no longer needed after scaling.
|
||||
unsafe { ffi::av_frame_free(&mut sw_frame) };
|
||||
|
||||
// encode 阶段:把 YUV420P 帧送入 libx264/openh264 编码器;返回编码单帧耗时(微秒)。
|
||||
// `?` 把 anyhow::Error 传播到调用者;`&mut encoder` & `&mut pts` 都是可变借用。
|
||||
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
|
||||
let total_us = frame_start.elapsed().as_micros() as u64;
|
||||
|
||||
// 把本帧的 5 个阶段耗时 push 到 Vec<u64>;后续 print_detailed_results 计算 avg_ms/p95。
|
||||
stats.import_us.push(import_us);
|
||||
stats.transfer_us.push(transfer_us);
|
||||
stats.scale_us.push(scale_us);
|
||||
@@ -594,6 +812,7 @@ fn run_cpu_pipeline(
|
||||
stats.total_us.push(total_us);
|
||||
stats.frames_encoded += 1;
|
||||
|
||||
// 节流打印:前 3 帧详打 + 之后每 30 帧打一次,避免日志淹没;`{:>4}` 右对齐 4 列宽。
|
||||
if stats.frames_encoded <= 3 || stats.frames_encoded % 30 == 0 {
|
||||
println!(
|
||||
" CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms",
|
||||
@@ -607,11 +826,24 @@ fn run_cpu_pipeline(
|
||||
}
|
||||
}
|
||||
|
||||
// flush 编码器(送 NULL frame 触发 EOS)+ write_trailer + 关闭输出文件。
|
||||
// 任何失败经 `?` 传播到 main。
|
||||
finish_encoder(encoder)?;
|
||||
// as_secs_f64 把 Duration 转为秒(f64),用于后续 FPS 计算。
|
||||
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
// GPU 流水线主函数:跑 `frames` 帧测量 GPU 路径每阶段耗时(import → filter → transfer → format → encode)。
|
||||
// 与 CPU 路径的核心差异:GPU 路径用 `scale_vaapi` 滤镜**在硬件内**把 4K BGRA 下采样到 2K NV12,
|
||||
// 再用 `av_hwframe_transfer_data` 把**小**NV12 帧拷贝到 CPU 内存(数据量 = 4K BGRA 的 1/6),
|
||||
// 最后用 `sws_scale` 做 NV12→YUV420P 的**纯格式转换**(无尺寸变化,比 CPU 路径快得多)。
|
||||
//
|
||||
// 性能对比的关键:
|
||||
// - CPU 路径 transfer ~33MB + scale 33MB→2MB;GPU 路径 transfer ~3MB + format 仅 NV12→YUV420P。
|
||||
// - 因此 GPU 路径的 transfer/format 总耗时远低于 CPU 路径的 transfer+scale。
|
||||
//
|
||||
// 参数:9 个(比 CPU 多一个 hw_dev 用于 filter graph);同样用 `#[allow]` 抑制 clippy。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_gpu_pipeline(
|
||||
cap: &CapPortal,
|
||||
@@ -624,7 +856,11 @@ fn run_gpu_pipeline(
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) -> Result<FrameStats> {
|
||||
// 同 CPU 路径:构造软件编码器(最终编码阶段仍是 CPU 上的 libx264/openh264)。
|
||||
// 注意:本基准目标是测 import/scale 性能,**不**测 VAAPI 硬件编码;所以两条路径都用软件编码器。
|
||||
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
|
||||
// 这里命名为 format_ctx 而非 sws_ctx —— 因为它只做 NV12→YUV420P 的**色度重排**(无下采样)。
|
||||
// 源/目标尺寸相同(enc_width × enc_height),sws_scale 内部走 fast path(无缩放,仅 deinterleave)。
|
||||
let format_ctx = create_sws_context(
|
||||
enc_width,
|
||||
enc_height,
|
||||
@@ -632,6 +868,8 @@ fn run_gpu_pipeline(
|
||||
enc_width,
|
||||
enc_height,
|
||||
)?;
|
||||
// 构造 GPU 滤镜图:bufferin (hw) → scale_vaapi → bufferout (hw),详见 build_gpu_filter_graph。
|
||||
// 滤镜图在 GPU 显存里完成下采样,输出仍是 VAAPI 硬件帧。
|
||||
let mut graph = build_gpu_filter_graph(
|
||||
hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height,
|
||||
)?;
|
||||
@@ -652,6 +890,7 @@ fn run_gpu_pipeline(
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
while stats.frames_encoded < frames {
|
||||
// 同 CPU 路径(详见 run_cpu_pipeline 的同位置注释)。
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => break,
|
||||
@@ -659,6 +898,7 @@ fn run_gpu_pipeline(
|
||||
"PipeWire error after {} GPU frames: {e}",
|
||||
stats.frames_encoded
|
||||
),
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,23 +924,33 @@ fn run_gpu_pipeline(
|
||||
};
|
||||
let import_us = t_import.elapsed().as_micros() as u64;
|
||||
|
||||
// filter 阶段:把 VAAPI 4K 帧送入 scale_vaapi 滤镜图,取出 2K NV12 VAAPI 帧。
|
||||
// 这是 GPU 路径相对 CPU 路径最大的性能优势所在。
|
||||
let t_filter = Instant::now();
|
||||
// graph.get("in").unwrap():按 name 取滤镜图的输入 pad;unwrap 在此是安全的(图刚构造必有 "in")。
|
||||
let mut filter_src_ctx = graph.get("in").unwrap();
|
||||
// source():从 pad 上下文获取发送端;后续 .add(&frame) 把帧送入图。
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
let mut filter_sink_ctx = graph.get("out").unwrap();
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
// map_err 把 ffmpeg_next::Error 转换为 anyhow::Error(保持错误链可读)。
|
||||
// anyhow::anyhow! 是宏,构造 ad-hoc 错误(类比 Go fmt.Errorf)。
|
||||
filter_src
|
||||
.add(&vaapi_frame)
|
||||
.map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?;
|
||||
|
||||
// ff::frame::Video::empty():构造一个空视频帧(无 buffer),后续 filter_sink.frame() 填充。
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
// 三路 match:成功 / EAGAIN(图未就绪,需要更多输入帧)/ 真错误。
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {}
|
||||
// EAGAIN 表示滤镜图内部缓冲不足,跳过本帧不报错(next iteration 继续喂下一帧)。
|
||||
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => continue,
|
||||
Err(e) => bail!("GPU filter sink get frame failed: {e}"),
|
||||
}
|
||||
let filter_us = t_filter.elapsed().as_micros() as u64;
|
||||
|
||||
// transfer 阶段:把 2K NV12 硬件帧拷贝到 CPU 内存(数据量 = 2K NV12 ≈ 3MB,远小于 CPU 路径 33MB)。
|
||||
let t_transfer = Instant::now();
|
||||
// SAFETY: sw_nv12 is allocated by FFmpeg and freed after format conversion.
|
||||
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
|
||||
@@ -721,6 +971,8 @@ fn run_gpu_pipeline(
|
||||
}
|
||||
let transfer_us = t_transfer.elapsed().as_micros() as u64;
|
||||
|
||||
// format 阶段:NV12 → YUV420P 纯格式转换(同尺寸无缩放)。
|
||||
// NV12 与 YUV420P 的 Y plane 完全相同,只是 UV plane 排列不同(NV12 = interleaved,YUV420P = planar)。
|
||||
let t_format = Instant::now();
|
||||
// SAFETY: sw_nv12 contains CPU-side NV12 at enc dimensions; encoder.yuv_frame is writable
|
||||
// YUV420P at the same dimensions, so sws_scale performs only chroma deinterleave/format conversion.
|
||||
@@ -743,6 +995,8 @@ fn run_gpu_pipeline(
|
||||
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
|
||||
let total_us = frame_start.elapsed().as_micros() as u64;
|
||||
|
||||
// GPU 路径的 stats 包含 6 个阶段(filter + format),CPU 路径只有 5 个(scale);
|
||||
// FrameStats 的字段用 Option/空 Vec 区分。
|
||||
stats.import_us.push(import_us);
|
||||
stats.filter_us.push(filter_us);
|
||||
stats.transfer_us.push(transfer_us);
|
||||
@@ -770,6 +1024,9 @@ fn run_gpu_pipeline(
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
// 打印单条流水线的详细统计报告(捕获/编码分辨率、总时长、各阶段平均毫秒、FPS)。
|
||||
// 纯展示函数:无 Result 返回值,无副作用(除 stdout),无错误路径。
|
||||
// 类比 Go `func printResults(label string, stats *FrameStats, ...) { fmt.Println(...) }`。
|
||||
fn print_detailed_results(
|
||||
label: &str,
|
||||
stats: &FrameStats,
|
||||
@@ -778,20 +1035,25 @@ fn print_detailed_results(
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) {
|
||||
// println!() 无参数版本等价于 Go fmt.Println() —— 打印空行做视觉分隔。
|
||||
println!();
|
||||
println!("=== {label} Pipeline Results ===");
|
||||
println!("Capture resolution: {}x{}", src_width, src_height);
|
||||
println!("Encode resolution: {}x{}", enc_width, enc_height);
|
||||
println!("Frames encoded: {}", stats.frames_encoded);
|
||||
// {:.2} 保留 2 位小数;类比 Go fmt.Printf("%.2fs", v)。
|
||||
println!("Total time: {:.2}s", stats.elapsed_secs);
|
||||
println!("Output: {}", stats.output_path);
|
||||
if stats.import_failures > 0 {
|
||||
println!("Import failures: {}", stats.import_failures);
|
||||
}
|
||||
// FrameStats::avg_ms 是关联函数(不是 method),签名 `fn avg_ms(v: &[u64]) -> f64`。
|
||||
// 类比 Go 顶层函数 `func avgMs(v []uint64) float64`,Rust 关联函数等价于 Go 的 package-level。
|
||||
println!(
|
||||
"import avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.import_us)
|
||||
);
|
||||
// is_empty() 判断 Vec 是否为空;GPU 路径才有 filter_us,CPU 路径此 Vec 永远空。
|
||||
if !stats.filter_us.is_empty() {
|
||||
println!(
|
||||
"filter avg: {:.2} ms/frame",
|
||||
@@ -802,12 +1064,14 @@ fn print_detailed_results(
|
||||
"transfer avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.transfer_us)
|
||||
);
|
||||
// CPU 路径才有 scale_us,GPU 路径此 Vec 永远空。
|
||||
if !stats.scale_us.is_empty() {
|
||||
println!(
|
||||
"scale avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.scale_us)
|
||||
);
|
||||
}
|
||||
// GPU 路径才有 format_us,CPU 路径此 Vec 永远空。
|
||||
if !stats.format_us.is_empty() {
|
||||
println!(
|
||||
"format avg: {:.2} ms/frame",
|
||||
@@ -819,14 +1083,19 @@ fn print_detailed_results(
|
||||
stats.codec_name,
|
||||
FrameStats::avg_ms(&stats.encode_us)
|
||||
);
|
||||
// avg_total_ms / achieved_fps / theoretical_fps 都是 method(&self 形式),调用语法 `stats.method()`。
|
||||
println!("total avg: {:.2} ms/frame", stats.avg_total_ms());
|
||||
println!("achieved FPS: {:.1}", stats.achieved_fps());
|
||||
println!("max theoretical: {:.1} FPS", stats.theoretical_fps());
|
||||
}
|
||||
|
||||
// 打印 CPU 与 GPU 流水线的对比摘要(一行 = 一条流水线),便于横向对比。
|
||||
// 接收 Option<&FrameStats>:当基准只跑 CPU 或只跑 GPU 时,另一边为 None。
|
||||
// 类比 Go `func printComparison(cpu, gpu *FrameStats)`,Go 用 nil 表示缺失;Rust 用 Option<T> 强制处理。
|
||||
fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
|
||||
println!();
|
||||
println!("=== Pipeline Comparison ===");
|
||||
// if let Some(s) = cpu:模式匹配 Option;只在 Some 时打印,None 静默跳过(不需要 else)。
|
||||
if let Some(s) = cpu {
|
||||
println!(
|
||||
"CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
|
||||
@@ -852,7 +1121,16 @@ fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
|
||||
}
|
||||
}
|
||||
|
||||
// 二进制入口点。Rust 标准签名 `fn main() -> Result<()>`:返回 Result 时失败会用 exit code 1 + Debug 打印错误。
|
||||
// 类比 Go `func main() { err := run(); if err != nil { log.Fatal(err) } }` —— Rust 用 `?` 传播更简洁。
|
||||
//
|
||||
// 流程概览(3 个阶段,对应 println 中的 [1/3]/[2/3]/[3/3] 标号):
|
||||
// 1. 通过 XDG Portal 请求屏幕捕获权限 → 拿到 PipeWire fd → 构造 CapPortal
|
||||
// 2. 等待首帧 → 测试 av_hwframe_map 导入(若失败,回退 mmap 测试后退出)
|
||||
// 3. 根据 --mode 跑 CPU/GPU/Both 流水线,输出详细统计 + 对比报告
|
||||
fn main() -> Result<()> {
|
||||
// clap::Parser::parse() 解析 std::env::args,匹配失败的会自动 exit code 1 + 打印 help。
|
||||
// 类比 Go `flag.Parse()` + cobra.Struct,但 clap 用 Derive 宏更声明式。
|
||||
let bench_args = BenchArgs::parse();
|
||||
|
||||
println!("=== VAAPI Import Benchmark ===");
|
||||
@@ -865,11 +1143,15 @@ fn main() -> Result<()> {
|
||||
println!("DRM device: {}", bench_args.drm_device);
|
||||
println!();
|
||||
|
||||
// ff::init():FFmpeg 全局初始化(注册所有编解码器/滤镜/格式)。必须在所有 FFmpeg 调用前执行一次。
|
||||
// 类比 Go 的 `import _ "image/jpeg"` 副作用导入;FFmpeg 5+ 改为运行时自动注册,但 init 仍推荐。
|
||||
ff::init()?;
|
||||
|
||||
println!("[1/3] Requesting screen capture via XDG Portal...");
|
||||
println!(" (Select a screen to share in the portal dialog)");
|
||||
|
||||
// 构造 Args(生产 CLI 类型)——本基准复用 wl-webrtc 主程序的 Args 结构以驱动 CapPortal。
|
||||
// 大部分字段写死;只有 output 从 BenchArgs 透传。类比 Go `args := &wlwebrtc.Args{...}`。
|
||||
let portal_args = Args {
|
||||
output: Some(bench_args.output.clone()),
|
||||
output_name: None,
|
||||
@@ -878,23 +1160,31 @@ fn main() -> Result<()> {
|
||||
hw_accel: "vaapi".to_string(),
|
||||
drm_device: None,
|
||||
bitrate: None,
|
||||
max_bitrate: 8_000_000,
|
||||
gop_size: None,
|
||||
verbose: false,
|
||||
backend: Some("portal".to_string()),
|
||||
port: 0,
|
||||
no_persist: false,
|
||||
stats: false,
|
||||
};
|
||||
|
||||
// CapPortal::new 启动 Portal 异步协商 + PipeWire 流;阻塞至用户在对话框点"允许"。
|
||||
// 内部会启动 pipewire_thread 后台线程推帧到 frame_receiver 通道。
|
||||
let cap = CapPortal::new(&portal_args)?;
|
||||
println!("[1/3] Portal connected, PipeWire stream active\n");
|
||||
|
||||
println!("[2/3] Waiting for first frame from PipeWire...");
|
||||
// 阻塞等首帧(带 30s 超时,详见 receive_first_frame 实现)。
|
||||
let first_frame = receive_first_frame(&cap)?;
|
||||
|
||||
// 把 first_frame 的字段拷贝到局部变量;后续两条流水线都要用 src_width/src_height 做下采样。
|
||||
// 注意:first_frame 必须 drop 之前不能让 import_dma_buf_to_vaapi 持有 fd 引用(所有权检查)。
|
||||
let src_width = first_frame.width;
|
||||
let src_height = first_frame.height;
|
||||
let src_format = first_frame.format;
|
||||
|
||||
// 0x{:08X}:8 位 16 进制(大写)前补 0 —— 用于打印 DRM 四字符码(ARGB8888 = 0x34325241)。
|
||||
println!(
|
||||
"[2/3] First frame: {}x{}, format=0x{:08X}, stride={}, modifier=0x{:X}",
|
||||
src_width, src_height, src_format, first_frame.stride, first_frame.modifier
|
||||
@@ -906,14 +1196,19 @@ fn main() -> Result<()> {
|
||||
src_format
|
||||
);
|
||||
|
||||
// 打开 DRM render node(默认 /dev/dri/renderD128),构造 VAAPI 硬件设备上下文。
|
||||
// AvHwDevCtx 内部封装 AVBufferRef(FFmpeg 引用计数),Drop 时自动释放。
|
||||
let drm_device = Path::new(&bench_args.drm_device);
|
||||
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
|
||||
println!(" VAAPI device context created OK");
|
||||
|
||||
// 构造硬件帧上下文:绑定设备 + sw_format=BGRA + 源尺寸;scale_vaapi 滤镜需要此 ctx。
|
||||
let frames_ctx =
|
||||
AvHwFrameCtx::for_capture(&hw_dev, src_width, src_height, ff::format::Pixel::BGRA)?;
|
||||
println!(" VAAPI frames context created OK (sw_format=BGRA)");
|
||||
|
||||
// 首帧导入测试:unsafe 块因为 import_dma_buf_to_vaapi 是 raw FFI(av_hwframe_map + AVDRMFrameDescriptor)。
|
||||
// 此处 unsafe 块**未写** SAFETY 标记,因为 import_dma_buf_to_vaapi 自身在 src/avhw.rs 内部已有详尽 SAFETY 注释。
|
||||
let vaapi_frame = unsafe {
|
||||
import_dma_buf_to_vaapi(
|
||||
frames_ctx.as_ptr(),
|
||||
@@ -927,11 +1222,13 @@ fn main() -> Result<()> {
|
||||
)
|
||||
};
|
||||
|
||||
// 用 match 处理 Result;分支内提前 return Ok(()) 表示"基准结束但不报错"(非失败路径)。
|
||||
match &vaapi_frame {
|
||||
Ok(_) => {
|
||||
println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!");
|
||||
}
|
||||
Err(e) => {
|
||||
// 失败路径:诊断 + mmap 对照测试 + 友好退出(不返回 Err)。
|
||||
println!(" Result: FAILED");
|
||||
println!(" Error: {e}");
|
||||
println!();
|
||||
@@ -942,8 +1239,10 @@ fn main() -> Result<()> {
|
||||
println!();
|
||||
println!(" Falling back to mmap readback test for comparison...");
|
||||
|
||||
// mmap 对照:如果 av_hwframe_map 失败,看 mmap 是否也失败(区分根因:driver vs 配置)。
|
||||
let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize);
|
||||
let mmap_start = Instant::now();
|
||||
// unsafe:libc::mmap 是 POSIX FFI,返回 void*;MAP_FAILED (== -1) 表示失败。
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
@@ -957,6 +1256,7 @@ fn main() -> Result<()> {
|
||||
let mmap_elapsed = mmap_start.elapsed();
|
||||
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
// last_os_error():取 errno;类比 Go syscall.Errno。
|
||||
let errno = std::io::Error::last_os_error();
|
||||
println!(" mmap also FAILED: {errno}");
|
||||
} else {
|
||||
@@ -965,6 +1265,7 @@ fn main() -> Result<()> {
|
||||
mmap_size as f64 / 1024.0 / 1024.0,
|
||||
mmap_elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
// 必须配对 munmap,否则内核 VMA 泄漏。
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, mmap_size);
|
||||
}
|
||||
@@ -973,10 +1274,13 @@ fn main() -> Result<()> {
|
||||
println!();
|
||||
println!("=== Benchmark ended: av_hwframe_map import FAILED ===");
|
||||
println!("Fix the import issue before proceeding to GPU downscale tests.");
|
||||
// 主动 Ok(()):基准本身没崩,只是诊断后退出;让 CI 不报红。
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 导入成功后释放首帧资源(vaapi_frame 持有硬件帧引用,first_frame 持有 fd);
|
||||
// 后续主循环每帧重新 import,避免长持有造成硬件帧饥饿。
|
||||
drop(vaapi_frame);
|
||||
drop(first_frame);
|
||||
|
||||
@@ -984,12 +1288,19 @@ fn main() -> Result<()> {
|
||||
|
||||
let enc_width = bench_args.enc_width;
|
||||
let enc_height = bench_args.enc_height;
|
||||
// PipelineMode::Both 时输出文件名加 cpu/gpu 后缀(详见 output_for_mode)。
|
||||
let split_outputs = bench_args.mode == PipelineMode::Both;
|
||||
// 用 Option 包裹:mode 只跑 CPU 时 gpu_stats 永远 None;print_detailed_results/print_comparison 接 Option。
|
||||
let mut cpu_stats = None;
|
||||
let mut gpu_stats = None;
|
||||
|
||||
// matches! 宏:等价于 `match bench_args.mode { PipelineMode::Cpu | PipelineMode::Both => true, _ => false }`,
|
||||
// 但语法更紧凑(无臂返回值);类比 Go `switch mode { case Cpu, Both: ... }`。
|
||||
if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) {
|
||||
let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs);
|
||||
// Some(...) 把 Result<FrameStats> 包成 Option<Result<FrameStats>>,再 ? 解开 Result;最终 cpu_stats = Option<FrameStats>。
|
||||
// 注意 `?` 在 Option 上下文也工作(需要 main 返回 Option,但这里 main 返回 Result,所以 ? 只对 Result 起作用,
|
||||
// Some(...) 是显式包装,里面的 run_cpu_pipeline()? 把 Err 传到 main)。
|
||||
cpu_stats = Some(run_cpu_pipeline(
|
||||
&cap,
|
||||
&frames_ctx,
|
||||
@@ -1017,6 +1328,7 @@ fn main() -> Result<()> {
|
||||
)?);
|
||||
}
|
||||
|
||||
// as_ref():把 &Option<T> 借用(避免消耗 T);print_detailed_results 接收 &FrameStats。
|
||||
if let Some(stats) = cpu_stats.as_ref() {
|
||||
print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height);
|
||||
}
|
||||
@@ -1025,6 +1337,9 @@ fn main() -> Result<()> {
|
||||
}
|
||||
print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref());
|
||||
|
||||
// 迭代器链:把两个 Option 串成统一迭代器,.any() 短路检查是否有任何一条流水线低于 30 FPS。
|
||||
// Option::into_iter():把 Option<T> 转为 0/1 元素迭代器;chain 把两段接起来。
|
||||
// 类比 Go:`var all []*FrameStats; if cpu != nil { all = append(all, cpu) }; for _, s := range all { if s.FPS < 30 {...} }`。
|
||||
if cpu_stats
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
|
||||
+497
-41
@@ -1,3 +1,23 @@
|
||||
//! XDG Desktop Portal + PipeWire 截屏后端。
|
||||
//!
|
||||
//! 本模块实现 `CaptureBackend::PortalPipeWire` 路径:通过 XDG Portal 的
|
||||
//! ScreenCast 接口请求用户授权,拿到 PipeWire 远程 fd 与 node_id 后,在专用
|
||||
//! 线程里跑 PipeWire 事件循环接收 DMA-BUF 帧。
|
||||
//!
|
||||
//! 关键设计:
|
||||
//! - 使用 `ashpd` crate 走 XDG Portal 协议(高层 Rust 绑定,封装 D-Bus 调用)。
|
||||
//! - `CapPortal` 在用户 cache 目录(`wl-webrtc/portal-restore-token`)缓存 Portal
|
||||
//! restore token,下次启动可跳过用户授权对话框(token 有效时)。
|
||||
//! - `--no-persist` 标志:跳过 restore token 读写,每次启动都弹授权对话框;测试
|
||||
//! fresh authorization 时使用。
|
||||
//! - 与 `backend_detect.rs` 的差异:检测阶段刻意用 raw `zbus` 避免 `ashpd` 缓存
|
||||
//! `zbus::Connection` 到全局 OnceLock(runtime drop 后变僵尸 connection)。本
|
||||
//! 模块只在 Portal 路径使用 `ashpd`,且 Tokio runtime 由 `CapPortal` 自己拥有
|
||||
//! (`rt` 字段),生命周期与 `CapPortal` 一致,无跨实例复用问题。
|
||||
//!
|
||||
//! 分阶段超时(git 68a6eec):`Service`(无用户交互,5s)与 `TokenDependent`
|
||||
//! (可能弹对话框,30s)两类,前者直接失败、后者清 token 后重试一次。
|
||||
|
||||
// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
|
||||
//
|
||||
// 整体架构:
|
||||
@@ -23,6 +43,54 @@ use tokio::runtime::Runtime;
|
||||
|
||||
use crate::args::Args;
|
||||
|
||||
/// Portal phase timeout when no user interaction is expected (proxy/session
|
||||
/// creation, token-path select/start, PipeWire fd). 5s is generous for
|
||||
/// healthy xdg-desktop-portal (<500ms typical) but bounded for fast failure.
|
||||
const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Portal phase timeout when user must click "Allow" in desktop dialog
|
||||
/// (select/start without restore token). 30s gives time to find the dialog.
|
||||
const PORTAL_USER_DIALOG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Classification of Portal phase timeouts to drive retry behavior.
|
||||
#[derive(Debug)]
|
||||
enum PortalPhaseTimeout {
|
||||
/// Portal service unresponsive; not retried (user should restart service).
|
||||
Service,
|
||||
/// Timed out in token-dependent phase; retried once after clearing token.
|
||||
TokenDependent,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PortalPhaseTimeout {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Service => write!(f, "Portal phase timed out (service)"),
|
||||
Self::TokenDependent => write!(f, "Portal phase timed out (token-dependent)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PortalPhaseTimeout {}
|
||||
|
||||
/// Log an actionable diagnostic when a Portal phase times out.
|
||||
///
|
||||
/// Mirrors the message format from `backend_detect.rs::log_portal_unresponsive`
|
||||
/// but additionally suggests `--no-persist` when the timeout occurred in a
|
||||
/// phase that was using a restore token.
|
||||
fn log_portal_phase_timeout(phase: &str, used_restore_token: bool) {
|
||||
let persist_hint = if used_restore_token {
|
||||
" If this recurs, try: wl-webrtc --no-persist"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
tracing::error!(
|
||||
"Portal service did not respond within timeout while {phase}. \
|
||||
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
|
||||
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
|
||||
then re-run wl-webrtc.{persist_hint}"
|
||||
);
|
||||
}
|
||||
|
||||
/// PipeWire DMA-BUF 帧数据
|
||||
///
|
||||
/// 表示从 PipeWire 流中接收到的一帧视频数据。
|
||||
@@ -54,6 +122,8 @@ pub struct PwDmaBufFrame {
|
||||
pub enum PwCtrlEvent {
|
||||
/// 流已结束(PipeWire 流断开连接或进入错误状态)
|
||||
StreamEnded,
|
||||
/// Format/dimensions changed mid-stream
|
||||
FormatChanged { width: u32, height: u32 },
|
||||
/// 发生错误,包含错误描述信息
|
||||
Error(String),
|
||||
}
|
||||
@@ -73,6 +143,7 @@ pub struct CapPortal {
|
||||
event_rx: Receiver<PwCtrlEvent>,
|
||||
pw_thread: Option<JoinHandle<()>>,
|
||||
rt: Runtime,
|
||||
pw_dropped: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
/// PipeWire 捕获线程的上下文数据
|
||||
@@ -95,7 +166,7 @@ impl CapPortal {
|
||||
/// 执行流程:
|
||||
/// 1. 创建 Tokio 运行时(用于异步 Portal 调用)
|
||||
/// 2. 通过 XDG Desktop Portal 请求屏幕录制权限,获取 PipeWire fd 和 node_id
|
||||
/// 3. 创建有界通道(容量 16)用于帧传递
|
||||
/// 3. 创建有界通道(容量 1)用于帧传递(最新帧优先,避免队列积压延迟)
|
||||
/// 4. 创建 eventfd 对,用于线程安全的关闭信号传递
|
||||
/// 5. 启动 PipeWire 捕获线程
|
||||
pub fn new(args: &Args) -> Result<Self> {
|
||||
@@ -104,9 +175,13 @@ impl CapPortal {
|
||||
let no_persist = args.no_persist;
|
||||
let (pw_fd, node_id) = rt.block_on(async { Self::setup_portal(no_persist).await })?;
|
||||
|
||||
let (frame_tx, frame_rx) = bounded(16);
|
||||
let (frame_tx, frame_rx) = bounded(1);
|
||||
let (event_tx, event_rx) = bounded(8);
|
||||
|
||||
// 创建 eventfd 对(Linux 特有的进程内事件通知机制)。
|
||||
// EFD_CLOEXEC: exec() 时自动关闭 fd,避免泄露给子进程。
|
||||
// EFD_NONBLOCK: 读取时非阻塞,配合 epoll/poll 使用。
|
||||
// unsafe: libc::eventfd 是 C FFI,返回值 < 0 表示 errno 错误。
|
||||
let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
|
||||
if efd < 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -114,41 +189,60 @@ impl CapPortal {
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
// 复制 fd 得到独立的两端(读端 efd 给 PipeWire 线程,写端 write_fd 留给 Drop)。
|
||||
// dup 返回的是新的 fd(最小可用整数),与原 fd 共享同一打开文件描述。
|
||||
// unsafe: libc::dup 是 C FFI,< 0 表示失败;失败时必须 close 原来的 efd 防止泄露。
|
||||
let write_fd = unsafe { libc::dup(efd) };
|
||||
if write_fd < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
// unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。
|
||||
unsafe { libc::close(efd) };
|
||||
return Err(anyhow::anyhow!("dup eventfd failed: {err}"));
|
||||
}
|
||||
|
||||
// Arc<AtomicU64> 跨线程共享的丢弃计数器(Arc 提供线程安全引用计数,
|
||||
// 类似 Go 的 sync/atomic.Value 但带引用语义)。PipeWire 线程在 channel
|
||||
// 满导致丢帧时原子递增它,主线程通过 dropped_count() 读取统计。
|
||||
// Ordering::Relaxed:仅用于统计,不需要跨线程内存顺序保证。
|
||||
let pw_dropped = Arc::new(AtomicU64::new(0));
|
||||
|
||||
// PwThreadCtx 聚合所有要 move 进 PipeWire 线程的资源。
|
||||
// shutdown_read / pw_fd 用 OwnedFd 包装(Drop 时自动 close),
|
||||
// 这避免手动管理 fd 生命周期。frame_tx / event_tx 是 crossbeam
|
||||
// channel 的发送端(多生产者单消费者,Clone + Send)。
|
||||
let ctx = PwThreadCtx {
|
||||
frame_tx,
|
||||
event_tx,
|
||||
dropped: pw_dropped.clone(),
|
||||
// unsafe: OwnedFd::from_raw_fd 接管 efd 的所有权(保证 RAII 关闭)。
|
||||
// 之前 libc::eventfd 返回的 efd 没有 Owner,必须用 from_raw_fd 包一下。
|
||||
shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
|
||||
pw_fd,
|
||||
node_id,
|
||||
fps: args.fps,
|
||||
};
|
||||
|
||||
// thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。
|
||||
// move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。
|
||||
let pw_thread = thread::Builder::new()
|
||||
.name("pipewire-capture".into())
|
||||
.spawn(move || {
|
||||
pipewire_thread(ctx);
|
||||
})
|
||||
.map_err(|e| {
|
||||
// unsafe: spawn 失败时清理 write_fd 防止泄漏。
|
||||
unsafe { libc::close(write_fd) };
|
||||
anyhow::anyhow!("thread spawn failed: {e}")
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
// unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。
|
||||
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
|
||||
frame_rx,
|
||||
event_rx,
|
||||
pw_thread: Some(pw_thread),
|
||||
rt,
|
||||
pw_dropped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +254,16 @@ impl CapPortal {
|
||||
&self.event_rx
|
||||
}
|
||||
|
||||
/// Returns the total number of PipeWire frames dropped due to channel backlog.
|
||||
pub fn dropped_count(&self) -> u64 {
|
||||
self.pw_dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the number of frames currently waiting in the capture channel.
|
||||
pub fn capture_queue_depth(&self) -> usize {
|
||||
self.frame_rx.len()
|
||||
}
|
||||
|
||||
/// 通过 XDG Desktop Portal 建立屏幕录制会话
|
||||
///
|
||||
/// 与桌面环境的 D-Bus 服务交互,请求用户授权屏幕录制。
|
||||
@@ -171,63 +275,192 @@ impl CapPortal {
|
||||
/// 5. 打开 PipeWire 远程连接,获取文件描述符
|
||||
///
|
||||
/// 返回 (PipeWire fd, node_id),供 PipeWire 线程连接使用
|
||||
///
|
||||
/// Wraps `_setup_portal_inner` with token-aware retry: on a `TokenDependent`
|
||||
/// timeout (phases 3 or 4 with a restore token in use) AND `no_persist ==
|
||||
/// false`, clears the cached restore token and retries once with
|
||||
/// `no_persist = true`.
|
||||
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
|
||||
// 首次尝试:使用缓存的 restore token(若存在且 no_persist=false)。
|
||||
// _setup_portal_inner 内部根据 phase 失败分类返回 PortalPhaseTimeout。
|
||||
match Self::_setup_portal_inner(no_persist, false).await {
|
||||
Ok(result) => Ok(result),
|
||||
// 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。
|
||||
// anyhow 包装动态类型错误,e.is::<T>() 检查,downcast_ref::<T>() 取引用。
|
||||
Err(e) if e.is::<PortalPhaseTimeout>() => {
|
||||
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
|
||||
match inner_err {
|
||||
// 仅当 token-dependent phase 超时且原本允许 persist 时才重试。
|
||||
// 重试策略:删除缓存的 token,强制 fresh authorization。
|
||||
PortalPhaseTimeout::TokenDependent if !no_persist => {
|
||||
tracing::warn!(
|
||||
"Portal timed out during token-using phase. \
|
||||
Clearing cached restore token and retrying with fresh authorization."
|
||||
);
|
||||
delete_restore_token();
|
||||
// is_retry=true 阻止 _setup_portal_inner 再次进入重试分支
|
||||
// (最多重试一次,避免无限循环)。
|
||||
Self::_setup_portal_inner(true, true).await
|
||||
}
|
||||
_ => Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner Portal setup with phased timeouts. See `setup_portal` for the
|
||||
/// retry wrapper.
|
||||
///
|
||||
/// `is_retry == true` disables further retry attempts (max 1 retry).
|
||||
async fn _setup_portal_inner(
|
||||
no_persist: bool,
|
||||
is_retry: bool,
|
||||
) -> Result<(OwnedFd, u32)> {
|
||||
// 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。
|
||||
// CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。
|
||||
use ashpd::desktop::screencast::{
|
||||
CursorMode, Screencast, SelectSourcesOptions, SourceType,
|
||||
};
|
||||
use ashpd::desktop::PersistMode;
|
||||
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Screencast proxy: {e}"))?;
|
||||
// Phase 1: Screencast proxy (no user interaction).
|
||||
// D-Bus 代理对象,对应 XDG Portal ScreenCast 接口。
|
||||
// tokio::time::timeout(dur, fut) 包装一个 future,超过 dur 返回 Err(Elapsed)。
|
||||
// 返回 Result<Result<T, ashpd::Error>, Elapsed>,外层是 timeout,内层是 Portal 调用。
|
||||
// 三路 match:Ok(Ok) 成功 / Ok(Err) Portal 报错 / Err(_) 超时。
|
||||
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
|
||||
Ok(Ok(p)) => p,
|
||||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
|
||||
Err(_) => {
|
||||
log_portal_phase_timeout("creating Screencast proxy", false);
|
||||
// .into() 把 PortalPhaseTimeout 转换为 anyhow::Error(dyn Error trait object)。
|
||||
return Err(PortalPhaseTimeout::Service.into());
|
||||
}
|
||||
};
|
||||
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
// Phase 2: create_session (no user interaction).
|
||||
// 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。
|
||||
// Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。
|
||||
let session = match tokio::time::timeout(
|
||||
PORTAL_SERVICE_TIMEOUT,
|
||||
proxy.create_session(Default::default()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create ScreenCast session: {e}"))?;
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create ScreenCast session: {e}")),
|
||||
Err(_) => {
|
||||
log_portal_phase_timeout("creating session", false);
|
||||
return Err(PortalPhaseTimeout::Service.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Portal 协议版本 ≥4 才支持 persist_mode 与 restore_token。
|
||||
// version 由 Screencast proxy 在 D-Bus 属性中暴露。
|
||||
let version_supported = proxy.version() >= 4;
|
||||
|
||||
// 决定 persist_mode 与已缓存的 token:
|
||||
// - no_persist=true 或版本不支持 → PersistMode::DoNot,不读 token。
|
||||
// - 否则 → PersistMode::ExplicitlyRevoked(显式可撤销,配合 token 重用)。
|
||||
let (persist_mode, saved_token) = if !no_persist && version_supported {
|
||||
let token = load_restore_token();
|
||||
if token.is_some() {
|
||||
if is_retry {
|
||||
tracing::info!("Re-attempting portal session after token clear");
|
||||
} else {
|
||||
tracing::info!("Attempting to restore portal session with saved token");
|
||||
}
|
||||
}
|
||||
(PersistMode::ExplicitlyRevoked, token)
|
||||
} else {
|
||||
(PersistMode::DoNot, None)
|
||||
};
|
||||
|
||||
// Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。
|
||||
// CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。
|
||||
// BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。
|
||||
// set_multiple(false):单流(不开启多显示器拼接)。
|
||||
let mut options = SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(persist_mode);
|
||||
|
||||
// 若有缓存的 token,附加到 options 实现免对话框恢复。
|
||||
// if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。
|
||||
if let Some(ref token) = saved_token {
|
||||
options = options.set_restore_token(token.as_str());
|
||||
}
|
||||
|
||||
// Phase 3: select_sources — token path is fast (no dialog); fresh
|
||||
// authorization may pop a dialog.
|
||||
// 双超时策略:token_in_use=true 时无对话框(5s service timeout),
|
||||
// false 时用户需要点 Allow(30s user-dialog timeout)。
|
||||
let token_in_use = saved_token.is_some();
|
||||
let phase3_timeout = if token_in_use {
|
||||
PORTAL_SERVICE_TIMEOUT
|
||||
} else {
|
||||
PORTAL_USER_DIALOG_TIMEOUT
|
||||
};
|
||||
match tokio::time::timeout(phase3_timeout, proxy.select_sources(&session, options)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
|
||||
Err(_) => {
|
||||
log_portal_phase_timeout("selecting sources", token_in_use);
|
||||
// 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。
|
||||
return Err(
|
||||
if token_in_use {
|
||||
PortalPhaseTimeout::TokenDependent
|
||||
} else {
|
||||
PortalPhaseTimeout::Service
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
|
||||
// start 返回一个 future,response 解析 PortalDbus 返回值。
|
||||
// 这里把两个 await 串起来放进 async 块,整体受 phase4_timeout 包裹。
|
||||
let phase4_timeout = if token_in_use {
|
||||
PORTAL_SERVICE_TIMEOUT
|
||||
} else {
|
||||
PORTAL_USER_DIALOG_TIMEOUT
|
||||
};
|
||||
// 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。
|
||||
// ? 在 async 块里传播 ashpd::Error,外层 match 处理。
|
||||
let start_fut = async {
|
||||
proxy
|
||||
.select_sources(&session, options)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Screen sharing permission denied: {e}")
|
||||
})?;
|
||||
|
||||
let response = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ScreenCast start failed: {e}"))?
|
||||
.await?
|
||||
.response()
|
||||
.map_err(|e| anyhow::anyhow!("ScreenCast response error: {e}"))?;
|
||||
};
|
||||
let response = match tokio::time::timeout(phase4_timeout, start_fut).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => return Err(anyhow::anyhow!("ScreenCast start/response error: {e}")),
|
||||
Err(_) => {
|
||||
log_portal_phase_timeout("starting session", token_in_use);
|
||||
return Err(
|
||||
if token_in_use {
|
||||
PortalPhaseTimeout::TokenDependent
|
||||
} else {
|
||||
PortalPhaseTimeout::Service
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 持久化新颁发的 restore token(Portal 可能返回与之前不同的 token)。
|
||||
if !no_persist && version_supported {
|
||||
if let Some(new_token) = response.restore_token() {
|
||||
save_restore_token(new_token);
|
||||
}
|
||||
}
|
||||
|
||||
// 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。
|
||||
// ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。
|
||||
let stream = response
|
||||
.streams()
|
||||
.first()
|
||||
@@ -235,10 +468,22 @@ impl CapPortal {
|
||||
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
// Phase 5: open_pipe_wire_remote (no user interaction).
|
||||
// 请求 PipeWire 服务端 fd。返回的 OwnedFd 是 Portal 通过 D-Bus fd-passing
|
||||
// 传过来的 PipeWire socket,PipeWire 线程用它连接到 compositor 的 PipeWire 实例。
|
||||
let fd = match tokio::time::timeout(
|
||||
PORTAL_SERVICE_TIMEOUT,
|
||||
proxy.open_pipe_wire_remote(&session, Default::default()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open PipeWire remote: {e}"))?;
|
||||
{
|
||||
Ok(Ok(f)) => f,
|
||||
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to open PipeWire remote: {e}")),
|
||||
Err(_) => {
|
||||
log_portal_phase_timeout("opening PipeWire remote", false);
|
||||
return Err(PortalPhaseTimeout::Service.into());
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Portal session established: node_id={node_id}");
|
||||
|
||||
@@ -246,19 +491,37 @@ impl CapPortal {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算 Portal restore token 的持久化路径(用户 cache 目录下 `wl-webrtc/portal-restore-token`)。
|
||||
///
|
||||
/// 返回 `Option<PathBuf>` 因为某些系统无合法 cache 目录(如 `$XDG_CACHE_HOME` 未设置
|
||||
/// 且无 HOME),此时返回 None,调用方应跳过 token 持久化。
|
||||
///
|
||||
/// 路径布局:`$XDG_CACHE_HOME/wl-webrtc/portal-restore-token` 或 `~/.cache/wl-webrtc/portal-restore-token`。
|
||||
fn token_path() -> Option<PathBuf> {
|
||||
// dirs::cache_dir() 返回 Option<PathBuf>(无 cache 目录时为 None)。
|
||||
// .map(|base| base.join("wl-webrtc").join("portal-restore-token")):
|
||||
// 类似 Go 的 filepath.Join,跨平台路径拼接。
|
||||
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
|
||||
}
|
||||
|
||||
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
|
||||
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
|
||||
fn verify_secure_dir(path: &std::path::Path) -> bool {
|
||||
// use 内导入 unix-only trait 扩展(Linux 特有的 stat/mode 字段)。
|
||||
// 这些 trait 让 std::fs::Metadata 暴露 .uid()/.gid()/.mode() 等 Unix 字段。
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
// symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。
|
||||
// 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录
|
||||
// 让我们以为权限正确(实际指向 /etc 之类)。
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) => {
|
||||
// 第一道防线:拒绝任何 symlink,即使权限看起来正确。
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::warn!("Token parent dir is a symlink, rejecting: {}", path.display());
|
||||
tracing::warn!(
|
||||
"Token parent dir is a symlink, rejecting: {}",
|
||||
path.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Must be a directory
|
||||
@@ -267,11 +530,18 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Must be owned by current user
|
||||
// unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式),
|
||||
// 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID。
|
||||
if meta.uid() != unsafe { libc::getuid() } {
|
||||
tracing::warn!("Token parent dir not owned by current user: {}", path.display());
|
||||
tracing::warn!(
|
||||
"Token parent dir not owned by current user: {}",
|
||||
path.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
|
||||
// mode & 0o777:剥离文件类型位(st_mode 高位),只保留 rwx 权限位。
|
||||
// 要求严格 0o700:owner rwx,group 与 other 全无(防止其他用户读 token)。
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode != 0o700 {
|
||||
tracing::warn!(
|
||||
@@ -293,12 +563,14 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
|
||||
/// Ensure the parent directory exists with restrictive permissions (0o700).
|
||||
/// Returns false if the directory could not be created or is insecure.
|
||||
fn ensure_secure_parent(parent: &std::path::Path) -> bool {
|
||||
// DirBuilderExt 扩展 DirBuilder::mode()(Unix-only),OpenOptionsExt 用于后续步骤。
|
||||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||
|
||||
if parent.exists() {
|
||||
// Directory exists — try to tighten permissions, then verify.
|
||||
// set_permissions follows symlinks, which is fine here since
|
||||
// we verify with symlink_metadata in verify_secure_dir.
|
||||
// 收紧模式:把已存在目录强行改为 0700,然后 verify_secure_dir 校验最终状态。
|
||||
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
|
||||
tracing::warn!("Failed to set directory permissions: {e}");
|
||||
return false;
|
||||
@@ -307,6 +579,8 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
|
||||
}
|
||||
|
||||
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
|
||||
// 关键:标准 create_dir 受 umask 影响(如 022 → 实际 0755)。
|
||||
// DirBuilderExt::mode(0o700) 直接设置 inode mode,绕过 umask,保证 0700。
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
builder.mode(0o700);
|
||||
@@ -316,33 +590,57 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
|
||||
}
|
||||
|
||||
// Verify after creation (belt-and-suspenders)
|
||||
// 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。
|
||||
verify_secure_dir(parent)
|
||||
}
|
||||
|
||||
/// 加载已缓存的 Portal restore token(默认路径)。
|
||||
///
|
||||
/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。
|
||||
/// 失败原因由 tracing::warn! 记录,便于排查。
|
||||
fn load_restore_token() -> Option<String> {
|
||||
// ? 在 Option 上传播:token_path() 返回 None 时直接 return None。
|
||||
load_restore_token_from(token_path()?)
|
||||
}
|
||||
|
||||
/// 从指定路径加载 token,附带严格的安全校验。
|
||||
///
|
||||
/// 校验规则(任一不满足返回 None):
|
||||
/// 1. 必须是 regular file(拒绝 directory / fifo / socket)
|
||||
/// 2. 不能是 symlink(防 symlink attack)
|
||||
/// 3. owner 必须是当前用户
|
||||
/// 4. group/other 不可读写(mode & 0o077 == 0)
|
||||
///
|
||||
/// 这些校验防止攻击者通过预创建文件或符号链接窃取 token。
|
||||
fn load_restore_token_from(path: PathBuf) -> Option<String> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
// symlink_metadata:lstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
// 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::warn!("Token file is a symlink, refusing to read: {}", path.display());
|
||||
tracing::warn!(
|
||||
"Token file is a symlink, refusing to read: {}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !meta.is_file() {
|
||||
tracing::warn!("Token path is not a regular file: {}", path.display());
|
||||
return None;
|
||||
}
|
||||
// unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。
|
||||
// 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。
|
||||
if meta.uid() != unsafe { libc::getuid() } {
|
||||
tracing::warn!("Token file not owned by current user: {}", path.display());
|
||||
return None;
|
||||
}
|
||||
// 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。
|
||||
// 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
tracing::warn!(
|
||||
@@ -353,12 +651,25 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// .ok()? :把 std::io::Result<String> 转 Option<String>,Err 变 None。
|
||||
// 然后 trim 去掉首尾空白(Portal 返回的 token 可能带换行)。
|
||||
// 若 trim 后为空字符串,返回 None(视为无 token)。
|
||||
let token = std::fs::read_to_string(&path).ok()?;
|
||||
let trimmed = token.trim().to_string();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 Portal 颁发的 restore token 到默认 cache 路径。
|
||||
///
|
||||
/// 失败(无 cache 目录、目录权限不合规、磁盘满等)不返回错误,
|
||||
/// 仅 tracing::warn!,下次启动会重新走对话框授权流程。
|
||||
fn save_restore_token(token: &str) {
|
||||
// let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。
|
||||
// 无 cache 目录时早退,避免后续无谓 IO。
|
||||
let Some(path) = token_path() else {
|
||||
tracing::warn!("No secure cache directory available, skipping token save");
|
||||
return;
|
||||
@@ -366,11 +677,38 @@ fn save_restore_token(token: &str) {
|
||||
save_restore_token_to(token, &path);
|
||||
}
|
||||
|
||||
/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。
|
||||
///
|
||||
/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。
|
||||
fn delete_restore_token() {
|
||||
// let-else 早退模式(与 save_restore_token 一致)。
|
||||
let Some(path) = token_path() else {
|
||||
return;
|
||||
};
|
||||
// match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。
|
||||
// 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!("Failed to delete stale restore token at {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 token 原子写入指定路径(temp file + rename 模式)。
|
||||
///
|
||||
/// 原子性:通过临时文件 + rename(2) 实现,确保读到完整 token 或读到旧 token,
|
||||
/// 永远不会读到部分写入。这是 Linux/Unix 文件系统 rename 的保证。
|
||||
///
|
||||
/// 安全性:
|
||||
/// - 父目录必须 0o700 且 owner = current user(ensure_secure_parent 校验)
|
||||
/// - temp file 用 create_new + mode 0o600(不覆盖现有文件,不跟随 symlink)
|
||||
/// - rename 是原子操作,但仅在同 filesystem 下保证
|
||||
fn save_restore_token_to(token: &str, path: &std::path::Path) {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// path.parent() 返回 Option<&Path>(root 路径无 parent)。
|
||||
let Some(parent) = path.parent() else {
|
||||
tracing::warn!("Token path has no parent directory");
|
||||
return;
|
||||
@@ -384,21 +722,31 @@ fn save_restore_token_to(token: &str, path: &std::path::Path) {
|
||||
// Use a unique temp file to prevent symlink attacks.
|
||||
// create_new(true) guarantees exclusive creation — fails if file already exists,
|
||||
// and does NOT follow existing symlinks.
|
||||
// temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。
|
||||
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
|
||||
// IIFE (immediately-invoked closure) 把多步 IO 组合成单一 Result。
|
||||
// ? 在闭包内传播 std::io::Error,外层统一 match 处理。
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
// OpenOptions builder:write + create_new = O_WRONLY | O_CREAT | O_EXCL。
|
||||
// mode(0o600):owner rw,group/other 无权限(绕过 umask)。
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
f.write_all(token.as_bytes())?;
|
||||
// sync_all:fsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。
|
||||
// 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。
|
||||
f.sync_all()?;
|
||||
// rename(2):原子替换。Linux 同 filesystem 下原子保证。
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => tracing::info!("Saved portal restore token"),
|
||||
Err(e) => {
|
||||
// 失败时清理 temp(避免遗留垃圾文件)。
|
||||
// let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
tracing::warn!("Failed to save restore token: {e}");
|
||||
}
|
||||
@@ -416,7 +764,14 @@ impl Drop for CapPortal {
|
||||
fn drop(&mut self) {
|
||||
// Signal the PipeWire loop to quit via eventfd.
|
||||
// eventfd write is a kernel syscall — thread-safe and lock-free.
|
||||
// 写入 8 字节(u64)到 eventfd,PipeWire 线程 epoll_wait 立即返回。
|
||||
// val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。
|
||||
let val: u64 = 1u64;
|
||||
// unsafe: libc::write 是 C FFI。签名:write(fd, buf, count) → ssize_t。
|
||||
// - self.shutdown_fd.as_raw_fd():取出 OwnedFd 内部的 raw int fd。
|
||||
// - &val as *const u64 as *const _:把 Rust 引用强转成 *const c_void。
|
||||
// - std::mem::size_of::<u64>():8 字节(eventfd 必须写 8 字节)。
|
||||
// 返回值是写入字节数或 -1(错误),用 let _ = 忽略(Drop 不能 panic)。
|
||||
let _ = unsafe {
|
||||
libc::write(
|
||||
self.shutdown_fd.as_raw_fd(),
|
||||
@@ -427,6 +782,10 @@ impl Drop for CapPortal {
|
||||
|
||||
// 等待 PipeWire 线程完全退出
|
||||
// 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续
|
||||
// Option::take():把 Option<JoinHandle> 里的值 move 出来,留下 None。
|
||||
// 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。
|
||||
// handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。
|
||||
// let _ = 忽略 panic 错误(Drop 中无法恢复)。
|
||||
if let Some(handle) = self.pw_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
@@ -473,10 +832,21 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
fps,
|
||||
} = ctx;
|
||||
|
||||
// PipeWire 三件套初始化(典型 PW 客户端架构):
|
||||
// MainLoop —— 事件循环(epoll 后端),所有回调都在此线程派发。
|
||||
// Context —— 加载 PW 模块、管理代理对象的上下文,挂在 MainLoop 上。
|
||||
// Core —— 与 PipeWire daemon 的连接(此处用 connect_fd 走 Portal
|
||||
// 下发的 socket fd 而非默认的 `pipewire-0`)。
|
||||
// 任一初始化失败都通过 event_tx 上报 PwCtrlEvent::Error 并退出本线程,
|
||||
// 让主线程的 select 报告具体阶段错误。
|
||||
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
|
||||
Ok(ml) => ml,
|
||||
Err(e) => {
|
||||
let _ = event_tx.try_send(PwCtrlEvent::Error(format!("MainLoop::new failed: {e}")));
|
||||
if let Err(e) =
|
||||
event_tx.try_send(PwCtrlEvent::Error(format!("MainLoop::new failed: {e}")))
|
||||
{
|
||||
tracing::error!("MainLoop::new failed and error channel also failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -484,7 +854,11 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = event_tx.try_send(PwCtrlEvent::Error(format!("Context::new failed: {e}")));
|
||||
if let Err(e) =
|
||||
event_tx.try_send(PwCtrlEvent::Error(format!("Context::new failed: {e}")))
|
||||
{
|
||||
tracing::error!("Context::new failed and error channel also failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -492,7 +866,10 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
let core = match context.connect_fd(pw_fd, None) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = event_tx.try_send(PwCtrlEvent::Error(format!("connect_fd failed: {e}")));
|
||||
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("connect_fd failed: {e}")))
|
||||
{
|
||||
tracing::error!("connect_fd failed and error channel also failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -514,13 +891,26 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = event_tx.try_send(PwCtrlEvent::Error(format!("Stream::new failed: {e}")));
|
||||
if let Err(e) =
|
||||
event_tx.try_send(PwCtrlEvent::Error(format!("Stream::new failed: {e}")))
|
||||
{
|
||||
tracing::error!("Stream::new failed and error channel also failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 共享的可变格式信息容器:Rc<Cell<Option<(w, h, drm_fmt, modifier)>>>。
|
||||
// - Rc 单线程引用计数(PipeWire 回调全在同一线程),类比 Go 中"通过指针
|
||||
// 共享的可变全局变量"但带编译期 Send 约束。
|
||||
// - Cell<Option<...>> 提供内部可变性(无需 Mutex),通过 .get()/.set()
|
||||
// 整体替换值——比 RefCell 更轻,因为这里值是 Copy 的元组。
|
||||
// - 类比 Go: var formatInfo = *(u32,u32,u32,u64) // 取地址 + atomic 赋值。
|
||||
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// crossbeam channel 的 Sender 是 Clone + Send,每次 clone 给一个回调
|
||||
// 捕获,多回调可并发往同一 channel 投递事件。类比 Go: ch := make(chan T, 8)
|
||||
// 各 goroutine 持有 ch 共享发送端。
|
||||
let event_tx_state = event_tx.clone();
|
||||
let _listener = stream
|
||||
.add_local_listener::<()>()
|
||||
@@ -534,7 +924,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
pw::stream::StreamState::Unconnected => {
|
||||
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
|
||||
}
|
||||
_ => {}
|
||||
pw::stream::StreamState::Paused => {
|
||||
tracing::warn!("PipeWire stream paused (compositor may be switching content)");
|
||||
}
|
||||
pw::stream::StreamState::Streaming => {
|
||||
tracing::info!("PipeWire stream (re)started");
|
||||
}
|
||||
pw::stream::StreamState::Connecting => {}
|
||||
}
|
||||
})
|
||||
// 参数变化回调(格式协商)
|
||||
@@ -542,6 +938,7 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
// id 为参数类型,param 包含具体的格式参数(分辨率、像素格式等)
|
||||
.param_changed({
|
||||
let format_info = format_info.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
move |_, _, id, param| {
|
||||
// 仅处理 Format 类型的参数变化
|
||||
let Some(param) = param else { return };
|
||||
@@ -563,13 +960,26 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
let framerate = info.framerate();
|
||||
let max_framerate = info.max_framerate();
|
||||
// 保存协商后的格式信息,供 process 回调读取
|
||||
let previous_format = format_info.get();
|
||||
format_info.set(Some((width, height, drm_format, modifier)));
|
||||
if let Some((previous_width, previous_height, _, _)) = previous_format {
|
||||
if width != previous_width || height != previous_height {
|
||||
tracing::warn!(
|
||||
"PipeWire dimensions changed: {}x{} (format renegotiation)",
|
||||
width,
|
||||
height
|
||||
);
|
||||
let _ = event_tx.try_send(PwCtrlEvent::FormatChanged { width, height });
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"PipeWire format negotiated: {width}x{height}, \
|
||||
drm_format={drm_format:#010x}, modifier={modifier:#x}, \
|
||||
framerate={}/{}, max_framerate={}/{}",
|
||||
framerate.num, framerate.denom,
|
||||
max_framerate.num, max_framerate.denom,
|
||||
framerate.num,
|
||||
framerate.denom,
|
||||
max_framerate.num,
|
||||
max_framerate.denom,
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -581,15 +991,23 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
let frame_tx = frame_tx.clone();
|
||||
let dropped = dropped;
|
||||
move |stream, _| {
|
||||
|
||||
// 以下大量 unsafe 块均为对 PipeWire/libspa C API 的直接访问。
|
||||
// pipewire-rs 的 stream 类型只暴露 `dequeue_raw_buffer` /
|
||||
// `queue_raw_buffer` 这类 unsafe 接口,因为返回的是 C 分配的
|
||||
// 裸 `*mut spa_buffer`,其生命周期由 PipeWire 控制(在
|
||||
// dequeue 与下一次 queue 之间稳定),Rust 类型系统无法表达。
|
||||
// 调用约定:每个 dequeue 必须恰好配一次 queue(包括所有错误
|
||||
// 退出路径),否则 PipeWire 会认为该 buffer 仍被使用而耗尽池。
|
||||
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
|
||||
if raw_buf.is_null() {
|
||||
tracing::trace!("process: null raw_buf");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 SPA buffer 结构体,包含数据数组、元数据等
|
||||
let spa_buf = unsafe { (*raw_buf).buffer };
|
||||
if spa_buf.is_null() {
|
||||
tracing::trace!("process: null spa_buf");
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
@@ -599,6 +1017,7 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
let n_datas = unsafe { (*spa_buf).n_datas };
|
||||
let datas_ptr = unsafe { (*spa_buf).datas };
|
||||
if n_datas == 0 || datas_ptr.is_null() {
|
||||
tracing::trace!("process: no data (n_datas={n_datas})");
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
@@ -609,11 +1028,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
|
||||
let fd = data_ref.fd();
|
||||
if fd < 0 {
|
||||
tracing::trace!("process: invalid fd={fd}");
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
if data_ref.as_raw().chunk.is_null() {
|
||||
tracing::trace!("process: null chunk");
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
@@ -651,6 +1072,7 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
return;
|
||||
};
|
||||
if width == 0 || height == 0 || format == 0 {
|
||||
tracing::trace!("process: invalid dimensions {width}x{height} format={format}");
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
@@ -665,6 +1087,11 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
}
|
||||
|
||||
// 构建帧数据对象,所有必要的帧信息已收集完毕
|
||||
// unsafe: OwnedFd::from_raw_fd 把刚刚 dup 出的 fd 所有权移交给
|
||||
// Rust 的 RAII 包装。此后 dup_fd 的关闭由 PwDmaBufFrame::Drop
|
||||
// 负责,不能再在外部 close 它。from_raw_fd 之所以 unsafe,是
|
||||
// 因为调用方必须保证传入的 fd 此前没有任何 Owner(否则会 double
|
||||
// close)。这里 libc::dup 刚返回的新 fd 满足该前提。
|
||||
let frame = PwDmaBufFrame {
|
||||
fd: unsafe { OwnedFd::from_raw_fd(dup_fd) },
|
||||
offset,
|
||||
@@ -676,17 +1103,24 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
pts,
|
||||
};
|
||||
|
||||
if let Err(crossbeam_channel::TrySendError::Full(_)) = frame_tx.try_send(frame) {
|
||||
let prev = dropped.fetch_add(1, Ordering::Relaxed);
|
||||
if prev > 0 && prev % 30 == 0 {
|
||||
tracing::warn!("dropped {prev} frames total: encoder backlog");
|
||||
// try_send 非阻塞投递;channel 容量=1(见 CapPortal::new),
|
||||
// 当下游编码器落后时立刻返回 Full。
|
||||
// 类比 Go: select { case ch <- frame: default: /* drop */ }
|
||||
match frame_tx.try_send(frame) {
|
||||
Ok(()) => {}
|
||||
Err(crossbeam_channel::TrySendError::Full(_)) => {
|
||||
// 丢帧计数(Relaxed 序,仅做统计;不要求与其他线程同步)。
|
||||
dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
// 空的 SPA POD 参数数组——之前已在 param_changed 回调中接受了 PipeWire
|
||||
// 推送的格式,这里不需要主动声明格式约束。`&mut [...]` 借用切片给 C API。
|
||||
let mut params: [&pw::spa::pod::Pod; 0] = [];
|
||||
|
||||
if let Err(e) = stream.connect(
|
||||
@@ -695,7 +1129,10 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
|
||||
&mut params,
|
||||
) {
|
||||
let _ = event_tx.try_send(PwCtrlEvent::Error(format!("stream.connect failed: {e}")));
|
||||
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("stream.connect failed: {e}")))
|
||||
{
|
||||
tracing::error!("stream.connect failed and error channel also failed: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -710,14 +1147,24 @@ fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
// previous detached helper thread approach.
|
||||
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
|
||||
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
|
||||
//
|
||||
// `as_raw_ptr()` 返回 `*mut pw_main_loop`(裸指针,不带生命周期),
|
||||
// 取裸指针本身是 safe 的——风险在使用它。下面 `pw_main_loop_quit` 的
|
||||
// unsafe 块依赖"回调仅在 run() 期间触发"这一 PipeWire 协议保证。
|
||||
let mainloop_ptr = mainloop.as_raw_ptr();
|
||||
|
||||
// 把 shutdown_read 的可读事件注册到 PipeWire loop 的 epoll/win32 等价物。
|
||||
// 每次 fd 变可读(CapPortal::drop 写入 8 字节触发),loop 在同一线程
|
||||
// 调用此闭包。返回的 _shutdown_source 在 drop 时自动从 loop 注销。
|
||||
let _shutdown_source = loop_.add_io(
|
||||
shutdown_read,
|
||||
libspa::support::system::IoFlags::IN,
|
||||
move |fd| {
|
||||
// Drain the eventfd so it doesn't re-trigger
|
||||
let mut buf: u64 = 0;
|
||||
// unsafe: libc::read 是 C 标准库 FFI。eventfd 语义保证 8 字节
|
||||
// 整数读,因此 &mut u64 转 *mut void + size_of::<u64>() 安全。
|
||||
// 返回值忽略——即使读失败也无法在此回调中做有意义处理。
|
||||
let _ = unsafe {
|
||||
libc::read(
|
||||
fd.as_raw_fd(),
|
||||
@@ -869,7 +1316,10 @@ mod tests {
|
||||
|
||||
let meta = std::fs::symlink_metadata(&new_dir).unwrap();
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "created directory should be 0700, got {mode:o}");
|
||||
assert_eq!(
|
||||
mode, 0o700,
|
||||
"created directory should be 0700, got {mode:o}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -884,7 +1334,10 @@ mod tests {
|
||||
|
||||
let meta = std::fs::symlink_metadata(path).unwrap();
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "tightened directory should be 0700, got {mode:o}");
|
||||
assert_eq!(
|
||||
mode, 0o700,
|
||||
"tightened directory should be 0700, got {mode:o}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -898,7 +1351,10 @@ mod tests {
|
||||
let meta = std::fs::symlink_metadata(&token_path).unwrap();
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "token file should be 0600, got {mode:o}");
|
||||
assert_eq!(std::fs::read_to_string(&token_path).unwrap(), "secret-token-123");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&token_path).unwrap(),
|
||||
"secret-token-123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
//! 文件:wlr-screencopy-unstable-v1 协议客户端绑定(`CaptureSource` 实现)
|
||||
//!
|
||||
//! 本文件实现 `CapWlrScreencopy`,作为 `state.rs` 中 `State<S>` 的泛型参数 `S`
|
||||
//! 的两个具体实现之一(另一个是 `CapPortal`)。wlr-screencopy 是 wlroots 原生
|
||||
//! 协议,优先于 XDG Portal/PipeWire:无需 D-Bus、无需用户授权对话框。
|
||||
//!
|
||||
//! 协议绑定来源:`wayland_protocols_wlr::screencopy::v1::client::*` 由
|
||||
//! wayland-scanner 工具根据 `wlr-screencopy-unstable-v1.xml` 自动生成(类似 Go
|
||||
//! 用 cgo 绑定 C 库,但 Rust 通过 wayland-client crate 暴露 type-safe wrapper,
|
||||
//! 无需手写 C FFI)。
|
||||
//!
|
||||
//! 异步模型:客户端无法主动"截屏",只能:(1) 绑定全局 manager、(2) 调用
|
||||
//! `manager.capture_output()` 创建帧对象、(3) 等待内核推送 buffer/format 事件、
|
||||
//! (4) 调用 `frame.copy(buffer)` 请求拷贝。因此本文件的 `alloc_frame()` 永远
|
||||
//! 返回 `None`,真正的帧创建逻辑在 `state.rs` 的 Dispatch impl 中(英文注释
|
||||
//! 标记为 T6b)。
|
||||
|
||||
use anyhow::Result;
|
||||
use wayland_client::globals::GlobalList;
|
||||
use wayland_client::protocol::wl_buffer::WlBuffer;
|
||||
@@ -7,6 +24,9 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::Zwl
|
||||
|
||||
use crate::state::{CaptureSource, OutputInfo, State};
|
||||
|
||||
// L2: `CaptureSource` trait 的具体实现——wlroots 原生 wlr-screencopy 协议后端。
|
||||
// 仅持有"当前在飞"的帧对象;协议管理器 `ZwlrScreencopyManagerV1` 的绑定存放
|
||||
// 在 `State` 的状态机字段中(需要 Dispatch impl,见下方英文注释)。
|
||||
/// wlr-screencopy capture backend.
|
||||
///
|
||||
/// Holds the current in-flight frame protocol object. The
|
||||
@@ -17,15 +37,27 @@ pub struct CapWlrScreencopy {
|
||||
/// The active frame object for the current capture cycle.
|
||||
/// Set by Dispatch impls after `manager.capture_output()`, cleared
|
||||
/// by `on_done_with_frame()`.
|
||||
// L3: `Option<T>` 类似 Go 的 `*T`(指针)——要么持有 T 的值,要么是 None(空)。
|
||||
pub current_frame: Option<ZwlrScreencopyFrameV1>,
|
||||
}
|
||||
|
||||
// L2: 为 `CapWlrScreencopy` 实现 `CaptureSource` trait。
|
||||
// Rust 的 `impl Trait for Type` 块类比 Go 的 method receiver——
|
||||
// Go: `func (r *Type) Method(args)`(receiver 作为第一个参数显式声明)
|
||||
// Rust: `fn method(&self, args)`(`&self` 是 `self: &Self` 的语法糖,等价 Go receiver)
|
||||
// trait impl 要求方法签名与 trait 定义严格一致,编译器会校验。
|
||||
impl CaptureSource for CapWlrScreencopy {
|
||||
/// Unit type: wlr-screencopy is fully asynchronous — `alloc_frame()`
|
||||
/// always returns `None`. The frame object is created by Dispatch
|
||||
/// impls calling `manager.capture_output()`, not by this method.
|
||||
// L3: `type Frame = ();` 关联类型(associated type):将"帧"的具体类型延迟到
|
||||
// impl 处决定。wlr-screencopy 用 unit `()` 因为帧对象生命周期由 Dispatch 控制。
|
||||
type Frame = ();
|
||||
|
||||
// L3: 构造函数。`Self` 在 impl 块内是 `CapWlrScreencopy` 的类型别名。
|
||||
// 参数名以 `_` 前缀表示"有意未使用"——manager 绑定不在此处发生,故这些
|
||||
// 参数(GlobalList/WlOutput/OutputInfo/QueueHandle)暂未消费。返回
|
||||
// `Result<Self>`,失败由调用方用 `?` 操作符传播(类比 Go 的 `if err != nil`)。
|
||||
fn new(
|
||||
_gm: &GlobalList,
|
||||
_output: &WlOutput,
|
||||
@@ -35,11 +67,15 @@ impl CaptureSource for CapWlrScreencopy {
|
||||
// Manager binding happens in state.rs during the ProbingOutputs →
|
||||
// EverythingButFmt stage transition (T6b). It requires a Dispatch
|
||||
// impl that doesn't exist yet, so we cannot call gm.bind() here.
|
||||
// `Ok(...)` 是 `Result::Ok(...)` 的简写,将成功值包装为 Result 返回;
|
||||
// `Self { ... }` 等价于 `CapWlrScreencopy { ... }`,impl 块内可用。
|
||||
Ok(Self {
|
||||
current_frame: None,
|
||||
})
|
||||
}
|
||||
|
||||
// L3: 分配帧对象。返回 `Option<Self::Frame>`(此处 Frame = (),故永远返回 None)。
|
||||
// `&mut self` 是 `self: &mut Self` 的简写(类比 Go 指针 receiver `*Type`)。
|
||||
fn alloc_frame(&mut self) -> Option<Self::Frame> {
|
||||
// wlr-screencopy is asynchronous: the Dispatch impl creates a new
|
||||
// ZwlrScreencopyFrameV1 which triggers the buffer allocation flow
|
||||
@@ -48,16 +84,23 @@ impl CaptureSource for CapWlrScreencopy {
|
||||
None
|
||||
}
|
||||
|
||||
// L3: 提交拷贝请求:将已分配的 DMA-BUF(WlBuffer)关联到当前帧对象。
|
||||
fn queue_copy(&mut self, buffer: &WlBuffer, _qh: &QueueHandle<State<Self>>) {
|
||||
// `if let Some(x) = &expr`:pattern matching,当 expr 是 Some 时绑定内部值。
|
||||
// 此处 `&self.current_frame` 不可变借用,调用 `frame.copy(buffer)` 提交拷贝。
|
||||
if let Some(frame) = &self.current_frame {
|
||||
frame.copy(buffer);
|
||||
} else {
|
||||
// `tracing::warn!` 是结构化日志宏(类比 Go log.Printf,但支持字段)。
|
||||
tracing::warn!("queue_copy: no current wlr-screencopy frame");
|
||||
}
|
||||
}
|
||||
|
||||
// L3: 帧处理完成后的清理。`_frame: Self::Frame` 前缀 `_` 表示参数未使用(Frame 是 unit)。
|
||||
fn on_done_with_frame(&mut self, _frame: Self::Frame) {
|
||||
// `Option::take()`:取出 Some 并将原位置替换为 None,原值所有权转移给返回值。
|
||||
if let Some(frame) = self.current_frame.take() {
|
||||
// `frame.destroy()` 发送 wayland 析构请求,释放服务端协议对象资源。
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -1,39 +1,89 @@
|
||||
//! 帧率限制器(FPS Limiter)。
|
||||
//!
|
||||
//! 基于时间间隔的下采样策略:当输入帧率高于目标时,按时间窗口丢弃多余帧,
|
||||
//! 保证输出帧率不超过配置上限。本实现是「非阻塞丢帧」策略——调用方收到
|
||||
//! `None` 时应主动丢弃该帧,而不是 `thread::sleep` 阻塞等待(这与 Go 中
|
||||
//! 用 `time.Now()` + `time.Since(last)` + `time.Sleep(d)` 的阻塞式限速器不同)。
|
||||
//!
|
||||
//! - 时间点:`std::time::Instant`(单调时钟,类比 Go `time.Time` / `time.Now()`)
|
||||
//! - 时间差:`std::time::Duration`(类比 Go `time.Duration`)
|
||||
//!
|
||||
//! Go 等价伪码:
|
||||
//! ```text
|
||||
//! type Limiter struct { last time.Time; minInterval time.Duration }
|
||||
//! if time.Since(l.last) >= l.minInterval { /* 放行 */ } else { /* 丢帧 */ }
|
||||
//! ```
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 帧率限制器。泛型参数 `T` 代表「帧」的载荷类型(如 AVFrame 包裹、纹理 ID、序号等),
|
||||
/// 类比 Go 1.18+ 的 `type FpsLimit[T any] struct{ ... }`。
|
||||
///
|
||||
/// 字段全部私有,外部只能通过 [`new`](Self::new) / [`on_new_frame`](Self::on_new_frame)
|
||||
/// / [`flush`](Self::flush) 三个方法操作,确保不变量(如「首帧必过」)不被绕过。
|
||||
pub struct FpsLimit<T> {
|
||||
/// 缓存最近一次被丢弃/待输出的帧。`Option<T>` 类比 Go 中可空指针 `*T`:
|
||||
/// `Some(frame)` 表示有缓存,`None` 表示空。`flush` 会取出此字段。
|
||||
on_deck: Option<T>,
|
||||
/// 最近一次「放行」(输出给下游)的时间戳;`None` 表示尚未放过任何帧,
|
||||
/// 此时下一帧必放行(首帧直通语义)。
|
||||
last_output_time: Option<Instant>,
|
||||
/// 最小放行间隔 = `1 / fps` 秒。两次输出之间的时间差必须 ≥ 该值。
|
||||
/// 类比 Go:`time.Duration(float64(1) / float64(fps) * float64(time.Second))`。
|
||||
min_interval: Duration,
|
||||
}
|
||||
|
||||
impl<T> FpsLimit<T> {
|
||||
/// 构造一个目标帧率为 `fps`(帧/秒)的限速器。
|
||||
///
|
||||
/// - `fps as f64`:把 `u32` 提升为 `f64` 才能做浮点除法,类比 Go 的 `float64(fps)`;
|
||||
/// Rust 不允许 `u32 / f64` 隐式转换,必须显式 cast。
|
||||
/// - `Duration::from_secs_f64(1.0 / fps as f64)`:用浮点秒构造 `Duration`,
|
||||
/// 例如 `fps=30` → `min_interval ≈ 33.33ms`。
|
||||
pub fn new(fps: u32) -> Self {
|
||||
Self {
|
||||
on_deck: None,
|
||||
last_output_time: None,
|
||||
// 见上文 `Duration::from_secs_f64` 的 Go 类比。
|
||||
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
|
||||
}
|
||||
}
|
||||
|
||||
// 下面的英文 `///` 块为既有文档(保持原样),中文说明见函数体内 `//` 注释。
|
||||
/// Feed a new frame. Returns:
|
||||
/// - Some(()) if enough time elapsed since the last output — proceed to encode current frame
|
||||
/// - None if too close to the last output — drop current frame
|
||||
///
|
||||
/// 参数 `&mut self` 相当于 Go 方法接收者 `l *FpsLimit[T]`(可变借用 → 持有可写引用);
|
||||
/// 返回值 `Option<T>` 相当于 Go 中可空返回值:`Some` 表示放行该帧,`None` 表示丢弃。
|
||||
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
|
||||
// 判断本帧是否「就绪」(可放行)。Rust 的 `match` 强制穷尽,类比 Go 的 `switch`,
|
||||
// 但编译器会在漏掉分支时报错,比 Go 更严格。
|
||||
let ready = match self.last_output_time {
|
||||
// 首帧:从未输出过,直接放行。
|
||||
None => true,
|
||||
// 非首帧:`timestamp.duration_since(last)` 计算时间差,
|
||||
// 类比 Go `timestamp.Sub(last)`;返回 `Duration`,与 `>=` 比较的是 `min_interval`。
|
||||
Some(last) => timestamp.duration_since(last) >= self.min_interval,
|
||||
};
|
||||
|
||||
if ready {
|
||||
// 放行路径:先更新最近输出时间,再把本帧记到 `on_deck`(保留引用用于 flush)。
|
||||
self.last_output_time = Some(timestamp);
|
||||
self.on_deck = Some(frame);
|
||||
// `Option::take`:移出内部值并把原位置置为 `None`。这里返回刚写入的 `frame`,
|
||||
// 即把本帧交给调用方编码输出。
|
||||
self.on_deck.take()
|
||||
} else {
|
||||
// 丢弃路径:仍把本帧缓存到 `on_deck`(覆盖上一帧的丢弃值),以便 flush 时
|
||||
// 取到「最后一帧」用于收尾。`Option::replace` 返回旧值(这里用 `let _ =` 丢弃)。
|
||||
let _ = self.on_deck.replace(frame);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 取出并清空缓存的「最后一帧」。常用于流尾 flush,确保下游收到最后一帧。
|
||||
/// 连续第二次调用必返回 `None`,因为 `take` 后 `on_deck` 已为 `None`。
|
||||
pub fn flush(&mut self) -> Option<T> {
|
||||
self.on_deck.take()
|
||||
}
|
||||
@@ -46,6 +96,7 @@ mod tests {
|
||||
#[test]
|
||||
fn first_frame_passes_immediately() {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
// `Instant::now()` 取单调时钟当前时间,类比 Go `time.Now()`。
|
||||
let now = Instant::now();
|
||||
let result = limiter.on_new_frame(1u32, now);
|
||||
assert_eq!(result, Some(1));
|
||||
@@ -56,6 +107,8 @@ mod tests {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
// `now + Duration::from_millis(1)`:`Instant + Duration` 通过 `Add` trait 重载,
|
||||
// 类比 Go `now.Add(1 * time.Millisecond)`。1ms 远小于 33ms,应被丢弃。
|
||||
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -65,6 +118,7 @@ mod tests {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
// 34ms > 33.33ms(30fps 的 min_interval),应放行。
|
||||
let result = limiter.on_new_frame(2, now + Duration::from_millis(34));
|
||||
assert_eq!(result, Some(2));
|
||||
}
|
||||
@@ -75,14 +129,22 @@ mod tests {
|
||||
let base = Instant::now();
|
||||
let mut outputs = Vec::new();
|
||||
|
||||
// 模拟 60fps 输入(每 16ms 一帧),目标 30fps(每 33ms 一帧),
|
||||
// 期望 10 帧输入至少产生 3 帧输出。
|
||||
for i in 0..10u32 {
|
||||
let t = base + Duration::from_millis(i as u64 * 16);
|
||||
// `if let Some(f) = ...`:模式匹配解构 `Option`,类比 Go 的 `if v, ok := ...; ok {}`。
|
||||
if let Some(f) = limiter.on_new_frame(i, t) {
|
||||
outputs.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(outputs.len() >= 3, "expected at least 3 outputs, got {} ({:?})", outputs.len(), outputs);
|
||||
assert!(
|
||||
outputs.len() >= 3,
|
||||
"expected at least 3 outputs, got {} ({:?})",
|
||||
outputs.len(),
|
||||
outputs
|
||||
);
|
||||
assert_eq!(outputs[0], 0);
|
||||
}
|
||||
|
||||
@@ -91,8 +153,11 @@ mod tests {
|
||||
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
|
||||
let now = Instant::now();
|
||||
limiter.on_new_frame(1, now);
|
||||
// 第二帧被丢弃,但仍缓存到 `on_deck`。
|
||||
limiter.on_new_frame(2, now + Duration::from_millis(1));
|
||||
// flush 取出被丢弃的最后一帧(=2)。
|
||||
assert_eq!(limiter.flush(), Some(2));
|
||||
// 第二次 flush 应返回 `None`(`take` 已清空)。
|
||||
assert_eq!(limiter.flush(), None);
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -1,10 +1,29 @@
|
||||
//! `wl-webrtc` 库 crate 入口。
|
||||
//!
|
||||
//! 本 crate 既被三个二进制(`wl-webrtc`、`vaapi_import_bench`、`sw_encode_bench`)
|
||||
//! 复用,也对外暴露测试入口。下面按声明顺序列出所有子模块。
|
||||
//!
|
||||
//! Rust 的 `pub mod xxx;` 类似 Go 的 package 组织:每个文件即一个模块,
|
||||
//! 但 Rust 模块是分层的文件树(`src/<mod>.rs` 或 `src/<mod>/mod.rs`)。
|
||||
// CLI 参数定义(clap derive):类似 Go 的 flag 包,但用过程宏从结构体字段自动生成。
|
||||
pub mod args;
|
||||
// FFmpeg/VAAPI 硬件编码 FFI 绑定(含大量 unsafe),是项目最密集的 C interop 模块。
|
||||
pub mod avhw;
|
||||
// 后端自动检测:根据 Wayland global 与 D-Bus 服务在 wlr-screencopy 与 XDG Portal 之间选择。
|
||||
pub mod backend_detect;
|
||||
// XDG Portal + PipeWire 截屏后端实现。
|
||||
pub mod cap_portal;
|
||||
// wlroots `wlr-screencopy-unstable-v1` 协议绑定。
|
||||
pub mod cap_wlr_screencopy;
|
||||
// 帧率限制器:基于 `std::time::Instant` 控制捕获循环节奏。
|
||||
pub mod fps_limit;
|
||||
// wlroots 后端核心状态机:用 `mio` 直接跑 Wayland fd 事件循环。
|
||||
pub mod state;
|
||||
// Portal 后端核心状态机:基于 `tokio` + crossbeam channel 拉取 PipeWire 帧。
|
||||
pub mod state_portal;
|
||||
// 管道性能统计:用 `AtomicU64` + `Mutex<HashMap>` 暴露帧率/延迟计数。
|
||||
pub mod stats;
|
||||
// 图像变换(旋转/翻转):对传入帧做几何变换。
|
||||
pub mod transform;
|
||||
// str0m WebRTC 信令服务器:内嵌一个轻量 HTTP 端点做 SDP 交换。
|
||||
pub mod webrtc;
|
||||
|
||||
+106
-9
@@ -1,11 +1,48 @@
|
||||
//! # wl-webrtc 程序入口(main 函数所在文件)
|
||||
//!
|
||||
//! 本文件是 `wl-webrtc` 二进制 crate 的入口,等价于 Go 的 `func main()`。
|
||||
//! 由于 Rust 的 `main()` 不允许返回错误(`Result`),本项目采用通用模式:
|
||||
//! 真正的业务逻辑写在 `fn run() -> Result<()>`,而 `main()` 直接 `run()` 完成所有工作。
|
||||
//!
|
||||
//! 整体执行流程:
|
||||
//! 1. 通过 `clap` 解析命令行参数(`Args`,包含分辨率、编码格式、帧率等)
|
||||
//! 2. 初始化 `tracing` 日志系统(受 `RUST_LOG` 环境变量或 `-v` 参数控制)
|
||||
//! 3. MVP 阶段拒绝非 H.264 编码格式
|
||||
//! 4. 要求至少提供 `--output`(输出到文件)或 `--port`(启动 WebRTC 信号服务器)
|
||||
//! 5. 调用 `backend_detect::detect_backend` 自动检测当前 Wayland 桌面支持的截屏后端
|
||||
//! 6. 根据检测结果进入对应的事件循环:
|
||||
//! - 支持 `zwlr_screencopy_manager_v1` 的合成器(Sway/Hyprland)→ `run_wlr_screencopy`
|
||||
//! - 仅支持 XDG Portal ScreenCast 的桌面(GNOME/KDE)→ `run_portal_pipewire`
|
||||
//!
|
||||
//! 两个事件循环都基于 `mio`(一个手动驱动的事件循环库,类似 Go runtime netpoller 的手动版),
|
||||
//! 底层在 Linux 上使用 epoll。
|
||||
|
||||
// 获取 Unix 原始文件描述符所需的 trait
|
||||
// AsRawFd 提供了 as_raw_fd() 方法,用于从 std::io::Read/Write 等 Rust 抽象中
|
||||
// 取出底层的 libc::c_int(POSIX 文件描述符),mio 注册 fd 监听时需要它
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
// anyhow::Result<T, anyhow::Error> 是一个简化的错误类型,等价于 Go 的 (T, error)
|
||||
// ? 操作符会将任何实现了 std::error::Error 的错误转换为 anyhow::Error
|
||||
use anyhow::Result;
|
||||
// clap::Parser 是一个 derive 宏,实现后 args.parse() 即可从 std::env::args() 解析 CLI 参数
|
||||
// 类比 Go 的 flag.Parse(),但 clap 自动生成 --help 文本和错误处理
|
||||
use clap::Parser;
|
||||
// mio::unix::SourceFd 是一个 bridge:将裸 fd 包装为实现 mio::Evented 的对象
|
||||
// 这样 mio 的 epoll 可以监听任意 Unix fd,而不局限于 std::net::TcpStream 等标准类型
|
||||
use mio::unix::SourceFd;
|
||||
// mio 是一个手动驱动的事件循环库(与 tokio 的异步运行时不同,mio 不调度 future)
|
||||
// - Poll:epoll/kqueue 的 Rust 封装,poll.poll() 会阻塞直到 fd 就绪
|
||||
// - Interest:注册时的关注事件类型(READABLE / WRITABLE)
|
||||
// - Token:用户自定义的事件源标识(u64 包装),用于在 poll 返回时区分是哪个 fd 触发的
|
||||
// - Events:poll 返回的事件集合(一个容量固定的 Vec)
|
||||
// 类比 Go runtime 的 netpoller,但 Go runtime 自动调度,mio 需要用户手动循环
|
||||
use mio::{Events, Interest, Poll, Token};
|
||||
// registry_queue_init 是 wayland-client 的便捷函数:连接到合成器并初始化全局注册表队列
|
||||
// 它会在内部调用 Connection::connect_to_env() 并 roundtrip 一次拿到全局对象列表
|
||||
use wayland_client::globals::registry_queue_init;
|
||||
// Connection 是与 Wayland 合成器的会话连接,封装了 Unix socket 的读写和协议解析
|
||||
// 类比 Go 中的 net.Conn,但 Wayland 协议是有状态的消息流而非字节流
|
||||
use wayland_client::Connection;
|
||||
|
||||
// 各功能模块声明
|
||||
@@ -17,9 +54,12 @@ mod cap_wlr_screencopy; // wlroots wlr-screencopy 截屏协议
|
||||
mod fps_limit; // 帧率限制器
|
||||
mod state; // wlr-screencopy 后端的主状态机
|
||||
mod state_portal; // Portal/PipeWire 后端的主状态机
|
||||
mod stats; // 管道性能统计(卡顿诊断)
|
||||
mod transform; // 图像变换(旋转/翻转)
|
||||
mod webrtc; // WebRTC 传输(str0m Sans-IO)
|
||||
|
||||
// 引入本 crate 内部模块,crate:: 前缀表示从 crate root 开始的绝对路径
|
||||
// 类比 Go 中的 import "<module>/args" 写法
|
||||
use crate::args::Args;
|
||||
use crate::cap_wlr_screencopy::CapWlrScreencopy;
|
||||
use crate::state::EncConstructionStage;
|
||||
@@ -43,20 +83,43 @@ fn main() -> Result<()> {
|
||||
// 解析命令行参数
|
||||
let args = Args::parse();
|
||||
|
||||
// 根据是否启用 verbose 模式设置日志级别
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(if args.verbose {
|
||||
tracing::Level::DEBUG
|
||||
// 根据 verbose 模式或 RUST_LOG 环境变量设置日志级别
|
||||
// 支持 RUST_LOG 粒度控制(如 RUST_LOG=wl_webrtc::webrtc=trace)
|
||||
// 详细解释:
|
||||
// - try_from_default_env() 返回 Result<EnvFilter>,读取 RUST_LOG 环境变量
|
||||
// - unwrap_or_else(|_| {...}) 是 Result 的方法:成功则返回内部值,失败时调用闭包
|
||||
// - |_| 是闭包参数语法:|参数| 表达式,单个 _ 表示忽略参数(这里是 Err 类型)
|
||||
// 类比 Go 的 if err != nil { fallback },但 Rust 用闭包传递 fallback 逻辑
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
if args.verbose {
|
||||
tracing_subscriber::EnvFilter::new("debug")
|
||||
} else {
|
||||
tracing::Level::INFO
|
||||
})
|
||||
tracing_subscriber::EnvFilter::new("info")
|
||||
}
|
||||
});
|
||||
// tracing_subscriber::fmt() 是 Builder 模式:链式调用配置,最后 .init() 消费 builder
|
||||
// 完成全局订阅注册。再次调用 .init() 会 panic,因此只能初始化一次。
|
||||
// - with_env_filter: 设置过滤规则
|
||||
// - with_writer: 设置日志输出目标(这里为 stderr,避免污染 stdout 用于视频流)
|
||||
// - init(): 消费 self,注册全局默认 subscriber,无返回值
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
tracing::info!("wl-webrtc starting");
|
||||
tracing::debug!("Args: {:?}", args);
|
||||
tracing::debug!(
|
||||
"Args: output={:?} fps={} codec={} port={} verbose={}",
|
||||
args.output,
|
||||
args.fps,
|
||||
args.codec,
|
||||
args.port,
|
||||
args.verbose
|
||||
);
|
||||
|
||||
// MVP 阶段仅支持 H.264 编码,不支持 HEVC
|
||||
// anyhow::bail! 是一个宏(注意感叹号 !),立即返回 Err(anyhow::Error)
|
||||
// 类比 Go 的 fmt.Errorf("...") + return err,但是 Rust 用宏实现
|
||||
if args.codec != "h264" {
|
||||
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
|
||||
}
|
||||
@@ -67,9 +130,14 @@ fn main() -> Result<()> {
|
||||
|
||||
// 自动检测当前桌面环境可用的截屏后端
|
||||
// 会尝试列举 Wayland 全局对象,判断合成器是否支持 wlr-screencopy 协议
|
||||
// 行尾的 ? 是错误传播操作符:若 detect_backend 返回 Err,立即将该错误作为 fn main 的返回值
|
||||
// 等价于 Go 的 if err != nil { return err },但 Rust 中 ? 适用于任何 Result/Option
|
||||
let backend = crate::backend_detect::detect_backend(&args)?;
|
||||
|
||||
// 根据检测结果进入对应的事件循环
|
||||
// match 是 Rust 的模式匹配表达式(类比 Go 的 switch 但更强大)
|
||||
// 每个 => 左侧是模式(这里是枚举变体),右侧是返回 Result<()> 的函数调用
|
||||
// 由于 fn main 返回 Result<()>,这里直接把 match 表达式作为函数返回值(无分号 + 无 return)
|
||||
match backend {
|
||||
crate::backend_detect::CaptureBackend::WlrScreencopy => run_wlr_screencopy(args),
|
||||
crate::backend_detect::CaptureBackend::PortalPipeWire => run_portal_pipewire(args),
|
||||
@@ -92,9 +160,13 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
// Connect to Wayland compositor
|
||||
// 建立 Wayland 连接并初始化全局注册表
|
||||
// 通过环境变量 $WAYLAND_DISPLAY 找到合成器的 Unix socket
|
||||
// 行尾的 ? 是 fn run_wlr_screencopy 内首次出现的错误传播操作符:
|
||||
// 若 connect_to_env 返回 Err,立即作为函数返回值向上抛出(类比 Go 的 return err)
|
||||
let conn = Connection::connect_to_env()?;
|
||||
// registry_queue_init 会绑定全局注册表回调,
|
||||
// 当合成器广播其全局对象(输出、截屏管理器等)时,State 会收到通知
|
||||
// 返回值是元组 (GlobalManager, EventQueue),用 let 解构模式匹配赋值
|
||||
// mut queue 表示 queue 在后续代码中会被修改(Rust 默认不可变,需 mut 显式声明)
|
||||
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
|
||||
|
||||
let qhandle = queue.handle();
|
||||
@@ -107,14 +179,19 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
// compositor has already sent (may be EAGAIN if nothing yet).
|
||||
// 获取 Wayland socket 的文件描述符,并消费合成器已发送的事件
|
||||
// 这个 fd 是后续 mio epoll 监听的对象,当合成器写入数据时变为可读
|
||||
// 用 { ... } 块表达式将临时变量 guard 限制在作用域内,作用域结束自动 drop
|
||||
let wayland_fd = {
|
||||
let guard = queue
|
||||
.prepare_read()
|
||||
// ok_or_else 是 Option 的方法:None 时调用闭包生成 Err,得到 Result
|
||||
// || anyhow::anyhow!(...) 是无参数闭包语法(类比 JS 的 () => ...)
|
||||
// 行尾 ? 将 Result<_, Err> 解开为 Err 时立即从函数返回
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to prepare Wayland read"))?;
|
||||
// 从 prepare_read 的 guard 中获取底层 socket 的原始文件描述符
|
||||
let fd = guard.connection_fd().as_raw_fd();
|
||||
// 尝试非阻塞读取合成器已发送但尚未消费的数据
|
||||
// 如果没有数据会返回 EAGAIN,这里用 let _ 忽略
|
||||
// let _ = expr 是显式忽略表达式返回值的惯用法,等价于 Go 的 _ = expr
|
||||
let _ = guard.read();
|
||||
fd
|
||||
};
|
||||
@@ -136,6 +213,9 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
revents: 0,
|
||||
};
|
||||
// timeout=0 表示非阻塞,立即返回当前 fd 状态
|
||||
// unsafe { ... } 是 Rust 的不安全块:内部调用 C 库 libc::poll,需要程序员
|
||||
// 手动保证 &mut pfd 是有效的可变引用、fd 合法、不并发访问等不变量。
|
||||
// unsafe 不关闭 Rust 借用检查,只是声明"我对外部 FFI 调用负责"。
|
||||
let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
|
||||
tracing::info!(
|
||||
"Raw poll on wayland fd={wayland_fd}: ret={ret}, revents={}",
|
||||
@@ -213,6 +293,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
});
|
||||
|
||||
// 检查是否收到退出信号
|
||||
// for x in &collection 是 Rust 的迭代语法,&events 表示借用 Events(不消费)
|
||||
// 类比 Go 的 for _, ev := range events {}
|
||||
for event in &events {
|
||||
if event.token() == TOKEN_QUIT {
|
||||
tracing::info!("Received quit signal");
|
||||
@@ -222,8 +304,12 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
|
||||
// Wayland fd 可读时,读取并分发合成器事件
|
||||
// 合成器可能发来多种事件:帧数据就绪、输出信息变化、协议错误等
|
||||
// events.iter().any(|e| ...) 是迭代器方法,|e| 是单参数闭包
|
||||
if events.iter().any(|e| e.token() == TOKEN_WAYLAND) {
|
||||
// if let Some(x) = opt 是 Option 的模式匹配简写(类比 Go 的 if v, ok := m[k]; ok)
|
||||
if let Some(guard) = read_guard {
|
||||
// match 是本函数内首次出现的多分支模式匹配
|
||||
// Ok(_) 中下划线表示忽略成功值的具体内容(只关心成功/失败本身)
|
||||
match guard.read() {
|
||||
Ok(_) => {
|
||||
// 读取成功后,dispatch_pending 会将合成器事件
|
||||
@@ -250,7 +336,7 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
|
||||
// 状态机遇到致命错误时退出
|
||||
if state.errored {
|
||||
tracing::error!("Fatal error in state machine, exiting");
|
||||
tracing::error!("Fatal error in state machine (check preceding error logs), exiting");
|
||||
running = false;
|
||||
}
|
||||
|
||||
@@ -264,7 +350,10 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
tracing::info!("Shutting down, flushing encoder...");
|
||||
state.fps_limit.flush();
|
||||
// 仅在编码器已构建完成(Streaming 阶段)时才需要刷新
|
||||
// if let 枚举变体模式匹配:Streaming { enc, .. } 解构出内部字段 enc,.. 忽略其他字段
|
||||
// &mut state.stage 表示可变借用(类比 Go 的指针,但 Rust 编译期保证独占)
|
||||
if let crate::state::EncConstructionStage::Streaming { enc, .. } = &mut state.stage {
|
||||
// if let Err(e) = result 只关心失败分支,成功值用 _ 隐式忽略
|
||||
if let Err(e) = enc.flush() {
|
||||
tracing::error!("Failed to flush encoder: {e}");
|
||||
}
|
||||
@@ -285,6 +374,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
/// - 收到退出信号时停止
|
||||
/// 4. 退出时关闭 Portal 连接并释放 PipeWire 资源
|
||||
fn run_portal_pipewire(args: Args) -> Result<()> {
|
||||
// 函数内 use 声明:将长路径名简化为局部短名,仅在该函数作用域内生效
|
||||
// 类比 Go 函数内的局部 import 别名
|
||||
use crate::state_portal::StatePortal;
|
||||
|
||||
tracing::info!("Using Portal/PipeWire backend (KWin/KDE/GNOME)");
|
||||
@@ -293,6 +384,7 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
|
||||
// 1. 通过 D-Bus 连接到 XDG Portal 的 ScreenCast 接口
|
||||
// 2. 请求用户授权屏幕录制权限
|
||||
// 3. 建立 PipeWire 流连接,准备接收帧数据
|
||||
// 行尾 ? 是本函数内首次出现的错误传播操作符:失败时立即从 fn run_portal_pipewire 返回 Err
|
||||
let mut state = StatePortal::new(args)?;
|
||||
|
||||
// Set up signal handling only (no Wayland fd needed)
|
||||
@@ -340,13 +432,18 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
|
||||
// poll_and_encode 会从 PipeWire 缓冲区取出帧,
|
||||
// 编码为 H.264 并推送。返回 true 表示还有更多帧待处理,
|
||||
// 返回 false 表示当前没有帧了,while 循环退出等待下一轮 poll
|
||||
// 外层 if 触发首次取帧(drain_first=true 表示允许阻塞等待),
|
||||
// 内层 while state.poll_and_encode(false)? {} 是空循环体语法:
|
||||
// 循环条件持续求值,只要返回 true 就重复,循环体 {} 不做额外事
|
||||
if state.poll_and_encode(true)? {
|
||||
while state.poll_and_encode(false)? {}
|
||||
}
|
||||
|
||||
// Portal 状态机遇到致命错误时退出
|
||||
if state.is_errored() {
|
||||
tracing::error!("Fatal error in portal state machine, exiting");
|
||||
tracing::error!(
|
||||
"Fatal error in portal state machine (check preceding error logs), exiting"
|
||||
);
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
+618
-19
File diff suppressed because it is too large
Load Diff
+880
-91
File diff suppressed because it is too large
Load Diff
+794
@@ -0,0 +1,794 @@
|
||||
//! 管道性能统计模块 —— 用于卡顿诊断的轻量级滑动窗口统计。
|
||||
//!
|
||||
//! 本模块跟踪 capture / encode / send 三段流水线的每秒指标快照:
|
||||
//! - **FPS**:捕获帧率、编码帧率、发送帧率
|
||||
//! - **延迟分布**:各阶段(DMA-BUF import / VAAPI scale / GPU→CPU transfer /
|
||||
//! sws_scale / H.264 encode)的 avg / p95 / max(微秒→毫秒)
|
||||
//! - **队列深度**:capture 队列与 encoded 队列的瞬时观测值
|
||||
//! - **丢帧计数**:PipeWire 丢弃、重复帧去重跳过、超预算帧
|
||||
//!
|
||||
//! 设计目标为低开销:仅收集计数器和时间样本,每秒输出一行结构化日志
|
||||
//! (仅在 `--stats` 启用时)。所有统计在主线程独占持有 `&mut PipelineStats`,
|
||||
//! 跨线程数据(如 PipeWire dropped 计数、encode 线程的 duplicate 计数)
|
||||
//! 通过外部 `AtomicU64` 在调用方读取后再传入本结构(见 `set_*` 系列)。
|
||||
//!
|
||||
//! ## 与 Go 类比
|
||||
//!
|
||||
//! - [`Instant::now()`] ≈ Go `time.Now()`,但精度更高(通常单调时钟)
|
||||
//! - [`Duration::as_secs_f64`] ≈ Go `time.Duration.Seconds()`,但保留 f64
|
||||
//! - `&mut self` ≈ Go 中显式持有 `sync.Mutex` 的写锁;本模块的字段独占模型
|
||||
//! 天然无需 `Mutex`(外部跨线程读取后再以 `&mut self` 传入)
|
||||
//! - `Vec<f64>` 样本缓冲 ≈ Go 中 `[]float64`,每窗口 `clear()` 复用容量
|
||||
|
||||
// stats.rs — Lightweight windowed pipeline statistics for stutter diagnosis
|
||||
//
|
||||
// Tracks per-second snapshots of capture/encode/send pipeline metrics.
|
||||
// Designed for low overhead: only counters and timing samples are collected,
|
||||
// with one structured log line emitted per second when `--stats` is enabled.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
/// 单帧流水线各阶段的耗时样本(中文概要,详细说明见下方英文文档)。
|
||||
///
|
||||
/// # 派生宏说明
|
||||
///
|
||||
/// - `#[derive(Debug)]`:便于 `dbg!()` 调试输出,类比 Go 的 `%+v` 格式化
|
||||
/// - `#[derive(Default)]`:所有字段为 `u64`/`usize` 零值时构造默认实例,
|
||||
/// 测试中可用 `FrameTimings { total_us: 5000, ..Default::default() }`
|
||||
/// 仅指定关注字段(见 `record_and_snapshot_counts` 测试)
|
||||
///
|
||||
/// Per-stage timing for a single encode pipeline frame.
|
||||
///
|
||||
/// All values are in microseconds. The caller records timestamps around
|
||||
/// each stage and passes the deltas to [`PipelineStats::record_frame`].
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FrameTimings {
|
||||
/// DMA-BUF import (av_hwframe_map)
|
||||
pub import_us: u64,
|
||||
/// GPU scale (scale_vaapi filter)
|
||||
pub scale_us: u64,
|
||||
/// GPU→CPU transfer (av_hwframe_transfer_data)
|
||||
pub transfer_us: u64,
|
||||
/// sws_scale NV12→YUV420P
|
||||
pub sws_us: u64,
|
||||
/// H.264 encode (avcodec_send_frame + receive_packet)
|
||||
pub encode_us: u64,
|
||||
/// Wall-clock total for this frame (import through encode output)
|
||||
pub total_us: u64,
|
||||
/// Encoded output size in bytes
|
||||
pub output_bytes: usize,
|
||||
}
|
||||
|
||||
/// 一秒窗口内的管道统计聚合器(中文概要,详细说明见下方英文文档)。
|
||||
///
|
||||
/// 本结构通过 `&mut self` 接口收集三类原始数据:计数器(`capture_frames` 等)、
|
||||
/// 样本缓冲(`Vec<f64>`/`Vec<u64>`,窗口结束时 `clear()` 复用容量)、
|
||||
/// 时间锚点(`Option<Instant>`,`None` 表示尚未观测过)。
|
||||
///
|
||||
/// # 所有权与并发模型
|
||||
///
|
||||
/// - 本结构**非 `Sync`**:`Vec` 字段无锁,跨线程读写需外部同步
|
||||
/// - 主线程独占持有 `&mut self`;跨线程数据通过外部 `AtomicU64` 在调用方
|
||||
/// 读取后以 `set_*` 接口注入
|
||||
/// - 这与 Go 中 `sync.Mutex<PipelineStats>` 不同:Rust 借用检查器在编译期
|
||||
/// 保证单一可变借用,无需运行时锁
|
||||
///
|
||||
/// # 与 Go 类比
|
||||
///
|
||||
/// - `Option<Instant>` ≈ Go `*time.Time`(`nil` 表示未设置),但 Rust 用枚举
|
||||
/// 强制调用方处理"未设置"分支,避免 nil-pointer panic
|
||||
/// - `Instant` 内部使用单调时钟,不受系统时间跳变影响;Go 1.9+ 的
|
||||
/// `time.Since()` 也使用单调时钟,行为一致
|
||||
///
|
||||
/// Windowed statistics aggregator for the encode/send pipeline.
|
||||
///
|
||||
/// Collects counters and timing samples within a one-second window,
|
||||
/// then computes avg/p95/max when the snapshot is taken.
|
||||
pub struct PipelineStats {
|
||||
// --- counters (reset each window) ---
|
||||
capture_frames: u64,
|
||||
encoded_frames: u64,
|
||||
sent_frames: u64,
|
||||
pipewire_dropped: u64,
|
||||
over_budget_count: u64,
|
||||
/// Count of frames dropped by encode thread due to Y-plane hash dedup
|
||||
/// (EncodeOutcome::SkippedDuplicate). Read from atomic counter set by
|
||||
/// encode thread, computed as delta since previous snapshot.
|
||||
duplicate_frames_skipped: u64,
|
||||
// Running total from the encode-thread atomic; NOT reset between windows.
|
||||
prev_duplicate_frames_skipped: u64,
|
||||
|
||||
// --- queue depth at last observation ---
|
||||
capture_queue_depth: usize,
|
||||
encoded_queue_depth: usize,
|
||||
|
||||
// --- timing samples ---
|
||||
capture_gaps_ms: Vec<f64>,
|
||||
encoded_gaps_ms: Vec<f64>,
|
||||
sent_gaps_ms: Vec<f64>,
|
||||
frame_age_ms: Vec<f64>,
|
||||
send_wait_ms: Vec<f64>,
|
||||
|
||||
// --- per-stage timing (microseconds) ---
|
||||
import_us: Vec<u64>,
|
||||
scale_us: Vec<u64>,
|
||||
transfer_us: Vec<u64>,
|
||||
sws_us: Vec<u64>,
|
||||
encode_us: Vec<u64>,
|
||||
total_us: Vec<u64>,
|
||||
output_bytes: Vec<usize>,
|
||||
|
||||
// --- timing state ---
|
||||
last_capture_time: Option<Instant>,
|
||||
last_encode_time: Option<Instant>,
|
||||
last_send_time: Option<Instant>,
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
impl PipelineStats {
|
||||
/// 构造一个空的统计聚合器(类比 Go 的 `NewXxx()` 工厂函数)。
|
||||
///
|
||||
/// `window_start` 初始化为当前时刻,确保 `should_snapshot()` 至少
|
||||
/// 在 1 秒后才返回 true(首窗口可能短于 1 秒有效数据,但 elapsed_secs
|
||||
/// 是真实窗口长度,FPS 计算依然准确)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
capture_frames: 0,
|
||||
encoded_frames: 0,
|
||||
sent_frames: 0,
|
||||
pipewire_dropped: 0,
|
||||
over_budget_count: 0,
|
||||
duplicate_frames_skipped: 0,
|
||||
prev_duplicate_frames_skipped: 0,
|
||||
capture_queue_depth: 0,
|
||||
encoded_queue_depth: 0,
|
||||
capture_gaps_ms: Vec::new(),
|
||||
encoded_gaps_ms: Vec::new(),
|
||||
sent_gaps_ms: Vec::new(),
|
||||
frame_age_ms: Vec::new(),
|
||||
send_wait_ms: Vec::new(),
|
||||
import_us: Vec::new(),
|
||||
scale_us: Vec::new(),
|
||||
transfer_us: Vec::new(),
|
||||
sws_us: Vec::new(),
|
||||
encode_us: Vec::new(),
|
||||
total_us: Vec::new(),
|
||||
output_bytes: Vec::new(),
|
||||
last_capture_time: None,
|
||||
last_encode_time: None,
|
||||
last_send_time: None,
|
||||
window_start: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次来自 PipeWire 的捕获帧到达事件(中文 L3 解析见此)。
|
||||
///
|
||||
/// # 时间间隔(gap)计算
|
||||
///
|
||||
/// - [`Instant::now()`]:获取当前单调时刻(≈ Go `time.Now()`,但精度更高)
|
||||
/// - `last.elapsed()`:返回 `Duration`,类比 Go `time.Since(last)`
|
||||
/// - [`Duration::as_secs_f64`]:将 `Duration` 转为秒(f64),类比 Go
|
||||
/// `dur.Seconds()`;此处乘以 1000.0 转毫秒,便于日志可读
|
||||
///
|
||||
/// # 首帧处理
|
||||
///
|
||||
/// `Option<Instant>::None` 表示窗口内首帧,没有"上一帧"参照点,
|
||||
/// 因此首帧不产生 gap 样本(这与 Go 中 `*time.Time == nil` 检查等价,
|
||||
/// 但 Rust 强制处理 None 分支,编译期避免 nil 解引用)。
|
||||
///
|
||||
/// Record that a capture frame was received from PipeWire.
|
||||
pub fn record_capture(&mut self) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_capture_time {
|
||||
let gap_ms = last.elapsed().as_secs_f64() * 1000.0;
|
||||
self.capture_gaps_ms.push(gap_ms);
|
||||
}
|
||||
self.last_capture_time = Some(now);
|
||||
self.capture_frames += 1;
|
||||
}
|
||||
|
||||
/// 记录一帧完成编码(`FrameTimings` 路径,含各阶段微秒样本)。
|
||||
///
|
||||
/// # 参数借用
|
||||
///
|
||||
/// `timings: &FrameTimings`:以共享借用(`&`)读取,不获取所有权。
|
||||
/// 类比 Go 中显式传递 `*FrameTimings` 指针;Rust 借用检查保证本调用
|
||||
/// 期间原 `timings` 不会被释放。其余 gap 计算同 `record_capture`。
|
||||
///
|
||||
/// Record that a frame completed encoding with the given timings.
|
||||
pub fn record_encode(&mut self, timings: &FrameTimings) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_encode_time {
|
||||
let gap_ms = last.elapsed().as_secs_f64() * 1000.0;
|
||||
self.encoded_gaps_ms.push(gap_ms);
|
||||
}
|
||||
self.last_encode_time = Some(now);
|
||||
self.encoded_frames += 1;
|
||||
|
||||
self.import_us.push(timings.import_us);
|
||||
self.scale_us.push(timings.scale_us);
|
||||
self.transfer_us.push(timings.transfer_us);
|
||||
self.sws_us.push(timings.sws_us);
|
||||
self.encode_us.push(timings.encode_us);
|
||||
self.total_us.push(timings.total_us);
|
||||
self.output_bytes.push(timings.output_bytes);
|
||||
}
|
||||
|
||||
/// 仅记录 import 阶段微秒数(用于 `record_encode_thread` 路径补齐 import 样本)。
|
||||
pub fn record_import(&mut self, import_us: u64) {
|
||||
self.import_us.push(import_us);
|
||||
}
|
||||
|
||||
/// 编码线程路径:散参传入 sws / encode / output_bytes,跳过 `FrameTimings`。
|
||||
///
|
||||
/// # 溢出保护
|
||||
///
|
||||
/// `saturating_add` 在 `u64::MAX` 处饱和而非回绕,避免极端情况下
|
||||
/// `total_us` 出现荒谬的小值。类比 Go 中需手动 `if total > MaxUint64 - x`
|
||||
/// 检查;Rust 的 `saturating_*` / `checked_*` / `wrapping_*` 三件套
|
||||
/// 让溢出策略在调用点显式表达。
|
||||
pub fn record_encode_thread(&mut self, sws_us: u64, encode_us: u64, output_bytes: usize) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_encode_time {
|
||||
let gap_ms = last.elapsed().as_secs_f64() * 1000.0;
|
||||
self.encoded_gaps_ms.push(gap_ms);
|
||||
}
|
||||
self.last_encode_time = Some(now);
|
||||
self.encoded_frames += 1;
|
||||
|
||||
self.sws_us.push(sws_us);
|
||||
self.encode_us.push(encode_us);
|
||||
self.total_us.push(sws_us.saturating_add(encode_us));
|
||||
self.output_bytes.push(output_bytes);
|
||||
}
|
||||
|
||||
/// 记录一帧通过 WebRTC 发送(中文 L3 解析见此)。
|
||||
///
|
||||
/// - `wait_ms`:阻塞等待发送通道可写入的时间(毫秒);为 0 时不入样本
|
||||
/// (避免拉低 p95,因为大多数帧应无等待)
|
||||
/// - `capture_time`:该帧的原始捕获时刻;用于计算 **frame age**
|
||||
/// (捕获→发送端到端延迟)。`Option<None>` 表示调用方未提供
|
||||
/// (例如 XDG/screen-copy 路径无原始时间戳),此时不入样本
|
||||
///
|
||||
/// # frame_age 计算
|
||||
///
|
||||
/// `ct.elapsed()` 返回 `Duration`,类同 `record_capture` 中 gap 计算,
|
||||
/// 但锚点是"捕获时刻"而非"上一帧发送时刻",因此测量的是端到端延迟。
|
||||
///
|
||||
/// Record that a frame was sent via WebRTC.
|
||||
/// `wait_ms` is time spent blocked waiting to send into the channel.
|
||||
/// `capture_time` is when the frame was originally captured (for frame age).
|
||||
pub fn record_send(&mut self, wait_ms: f64, capture_time: Option<Instant>) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_send_time {
|
||||
let gap_ms = last.elapsed().as_secs_f64() * 1000.0;
|
||||
self.sent_gaps_ms.push(gap_ms);
|
||||
}
|
||||
self.last_send_time = Some(now);
|
||||
self.sent_frames += 1;
|
||||
|
||||
if wait_ms > 0.0 {
|
||||
self.send_wait_ms.push(wait_ms);
|
||||
}
|
||||
if let Some(ct) = capture_time {
|
||||
let age_ms = ct.elapsed().as_secs_f64() * 1000.0;
|
||||
self.frame_age_ms.push(age_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从后台 WebRTC 发送线程记录一帧(gap_ms / age_ms 均已在调用方预算好)。
|
||||
///
|
||||
/// # 为何预算参数
|
||||
///
|
||||
/// 后台线程无法安全访问 `&mut self`(本结构非 `Sync`),因此调用方在
|
||||
/// 发送时刻直接计算 `gap_ms` / `age_ms`(`Instant::now()` 在该线程
|
||||
/// 局部调用),稍后批量 drain 到主线程的 `&mut self`。这样:
|
||||
/// - 单调时钟读取在事件发生线程完成,时间戳精确
|
||||
/// - 主线程仅做 `Vec::push`,无需锁
|
||||
///
|
||||
/// `gap_ms == 0.0` 表示首帧(无前一帧参照),不入样本。
|
||||
///
|
||||
/// Record a frame sent from a background WebRTC thread.
|
||||
/// `gap_ms` is the pre-computed time since the previous send (0.0 = first frame).
|
||||
/// `age_ms` is the pre-computed capture-to-send latency (None if unavailable).
|
||||
/// Both are pre-computed on the sending thread to remain accurate when
|
||||
/// batch-drained at stats snapshot time on the main thread.
|
||||
pub fn record_send_from_thread(&mut self, gap_ms: f64, age_ms: Option<f64>) {
|
||||
if gap_ms > 0.0 {
|
||||
self.sent_gaps_ms.push(gap_ms);
|
||||
}
|
||||
self.sent_frames += 1;
|
||||
if let Some(age) = age_ms {
|
||||
self.frame_age_ms.push(age);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 PipeWire dropped 计数(绝对值,由调用方从外部 `AtomicU64` 读取)。
|
||||
///
|
||||
/// # 增量计算
|
||||
///
|
||||
/// 外部 `AtomicU64` 累计**总会话**的 dropped 帧数(从不重置),
|
||||
/// 因此本函数计算 `total - prev` 得到本窗口内的增量。
|
||||
/// `saturating_sub` 防止极端竞态(如原子读顺序不一致)导致负数回绕。
|
||||
///
|
||||
/// # 与 Go 类比
|
||||
///
|
||||
/// - 调用方代码 ≈ Go `atomic.LoadUint64(&pw.dropped)`(`Ordering::SeqCst`
|
||||
/// 或 `Relaxed` 取决于是否需要与其他原子操作建立 happens-before)
|
||||
/// - `Mutex<HashMap>` 在本模块**未使用**:统计字段集固定,无需 Go
|
||||
/// `sync.Map` 那样的动态键值存储;跨线程仅通过原子计数器通信
|
||||
///
|
||||
/// Update PipeWire dropped counter (absolute value from AtomicU64).
|
||||
pub fn set_pipewire_dropped(&mut self, total_dropped: u64, prev_dropped: u64) {
|
||||
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
|
||||
}
|
||||
|
||||
/// 设置 duplicate frames skipped 计数(绝对值,由调用方从 encode 线程原子读取)。
|
||||
///
|
||||
/// 与 `set_pipewire_dropped` 增量算法一致,但**保留 `prev`** 在
|
||||
/// `self.prev_duplicate_frames_skipped` 字段中(因为本结构才是状态持有者,
|
||||
/// 调用方仅传入当前 total)。
|
||||
///
|
||||
/// Update duplicate frames skipped counter (absolute value from atomic).
|
||||
/// Computes delta from previous value, like set_pipewire_dropped.
|
||||
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
|
||||
self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
|
||||
self.prev_duplicate_frames_skipped = total_skipped;
|
||||
}
|
||||
|
||||
/// 更新队列深度瞬时观测值(capture 队列与 encoded 队列各一个值)。
|
||||
///
|
||||
/// 队列深度为快照值而非累计值,每窗口只保留最后一次观测。
|
||||
///
|
||||
/// Update queue depth observations.
|
||||
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
|
||||
self.capture_queue_depth = capture;
|
||||
self.encoded_queue_depth = encoded;
|
||||
}
|
||||
|
||||
/// 记录一帧超出预算(用于跟踪编码耗时超过 1/fps 的频次)。
|
||||
///
|
||||
/// Record that a frame exceeded its time budget.
|
||||
pub fn record_over_budget(&mut self) {
|
||||
self.over_budget_count += 1;
|
||||
}
|
||||
|
||||
/// 判断是否到达快照点(距离上次 `snapshot_and_reset` 或构造时刻 ≥ 1 秒)。
|
||||
///
|
||||
/// 仅需 `&self`(共享借用):本方法不修改任何字段,借用检查器允许
|
||||
/// 多个 `&self` 共存或与 `&mut self` 之外的调用并存。
|
||||
///
|
||||
/// Returns true if at least 1 second has elapsed since the last snapshot
|
||||
/// (or since creation). If true, call `snapshot_and_reset` to get the stats.
|
||||
pub fn should_snapshot(&self) -> bool {
|
||||
self.window_start.elapsed().as_secs() >= 1
|
||||
}
|
||||
|
||||
/// 计算当前窗口的统计快照并重置所有计数器与样本缓冲。
|
||||
///
|
||||
/// # 重置策略
|
||||
///
|
||||
/// - 计数器:置零
|
||||
/// - `Vec`:调用 `clear()`(保留已分配容量 `Vec::capacity()`,避免下个窗口
|
||||
/// 反复分配)。类比 Go 中 `s = s[:0]` 复用底层数组
|
||||
/// - `window_start = Instant::now()`:重置窗口起点
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// 返回 `StatsSnapshot` 值(拷贝语义,调用方可自由使用与丢弃)。
|
||||
/// 类比 Go 中返回值结构体的拷贝;Rust 中 `StatsSnapshot` 全部字段为
|
||||
/// `Copy` 或 `Vec`(移动语义),返回时所有权转移至调用方。
|
||||
///
|
||||
/// Compute a snapshot of the current window and reset all counters.
|
||||
pub fn snapshot_and_reset(&mut self) -> StatsSnapshot {
|
||||
let elapsed = self.window_start.elapsed().as_secs_f64();
|
||||
let snap = StatsSnapshot {
|
||||
elapsed_secs: elapsed,
|
||||
capture_fps: self.capture_frames as f64 / elapsed,
|
||||
encoded_fps: self.encoded_frames as f64 / elapsed,
|
||||
sent_fps: self.sent_frames as f64 / elapsed,
|
||||
capture_frames: self.capture_frames,
|
||||
encoded_frames: self.encoded_frames,
|
||||
sent_frames: self.sent_frames,
|
||||
pipewire_dropped: self.pipewire_dropped,
|
||||
over_budget_count: self.over_budget_count,
|
||||
duplicate_frames_skipped: self.duplicate_frames_skipped,
|
||||
capture_queue_depth: self.capture_queue_depth,
|
||||
encoded_queue_depth: self.encoded_queue_depth,
|
||||
capture_gap_avg_ms: avg_f64(&self.capture_gaps_ms),
|
||||
capture_gap_p95_ms: p95_f64(&self.capture_gaps_ms),
|
||||
capture_gap_max_ms: max_f64(&self.capture_gaps_ms),
|
||||
encoded_gap_avg_ms: avg_f64(&self.encoded_gaps_ms),
|
||||
encoded_gap_p95_ms: p95_f64(&self.encoded_gaps_ms),
|
||||
encoded_gap_max_ms: max_f64(&self.encoded_gaps_ms),
|
||||
sent_gap_avg_ms: avg_f64(&self.sent_gaps_ms),
|
||||
sent_gap_p95_ms: p95_f64(&self.sent_gaps_ms),
|
||||
sent_gap_max_ms: max_f64(&self.sent_gaps_ms),
|
||||
frame_age_avg_ms: avg_f64(&self.frame_age_ms),
|
||||
frame_age_p95_ms: p95_f64(&self.frame_age_ms),
|
||||
frame_age_max_ms: max_f64(&self.frame_age_ms),
|
||||
send_wait_p95_ms: p95_f64(&self.send_wait_ms),
|
||||
import_avg_ms: avg_ms(&self.import_us),
|
||||
import_p95_ms: p95_ms(&self.import_us),
|
||||
scale_avg_ms: avg_ms(&self.scale_us),
|
||||
scale_p95_ms: p95_ms(&self.scale_us),
|
||||
transfer_avg_ms: avg_ms(&self.transfer_us),
|
||||
transfer_p95_ms: p95_ms(&self.transfer_us),
|
||||
sws_avg_ms: avg_ms(&self.sws_us),
|
||||
sws_p95_ms: p95_ms(&self.sws_us),
|
||||
encode_avg_ms: avg_ms(&self.encode_us),
|
||||
encode_p95_ms: p95_ms(&self.encode_us),
|
||||
total_avg_ms: avg_ms(&self.total_us),
|
||||
total_p95_ms: p95_ms(&self.total_us),
|
||||
output_bytes_per_sec: sum_usize(&self.output_bytes) as f64 / elapsed,
|
||||
output_frame_bytes_p95: p95_usize(&self.output_bytes),
|
||||
output_frame_bytes_max: max_usize(&self.output_bytes),
|
||||
};
|
||||
|
||||
// Reset all counters and sample buffers
|
||||
self.capture_frames = 0;
|
||||
self.encoded_frames = 0;
|
||||
self.sent_frames = 0;
|
||||
self.pipewire_dropped = 0;
|
||||
self.over_budget_count = 0;
|
||||
self.duplicate_frames_skipped = 0;
|
||||
self.capture_queue_depth = 0;
|
||||
self.encoded_queue_depth = 0;
|
||||
self.capture_gaps_ms.clear();
|
||||
self.encoded_gaps_ms.clear();
|
||||
self.sent_gaps_ms.clear();
|
||||
self.frame_age_ms.clear();
|
||||
self.send_wait_ms.clear();
|
||||
self.import_us.clear();
|
||||
self.scale_us.clear();
|
||||
self.transfer_us.clear();
|
||||
self.sws_us.clear();
|
||||
self.encode_us.clear();
|
||||
self.total_us.clear();
|
||||
self.output_bytes.clear();
|
||||
self.window_start = Instant::now();
|
||||
|
||||
snap
|
||||
}
|
||||
}
|
||||
|
||||
/// 一秒窗口的管道统计快照(不可变值对象,由 `snapshot_and_reset` 返回)。
|
||||
///
|
||||
/// 本结构持有所有派生指标(FPS、avg/p95/max、计数器快照),是日志输出的
|
||||
/// 数据源。一旦创建即不可变(所有字段为 `f64`/`u64`/`usize`,天然 `Copy`),
|
||||
/// 调用方可以安全地打印、记录或丢弃。
|
||||
///
|
||||
/// # `#[derive(Debug)]` 用途
|
||||
///
|
||||
/// 调试场景下可直接 `dbg!(&snap)` 或 `tracing::debug!(?snap)`,
|
||||
/// 类比 Go 的 `spew.Dump(snap)` / `fmt.Printf("%+v", snap)`。
|
||||
///
|
||||
/// A one-second snapshot of pipeline statistics.
|
||||
#[derive(Debug)]
|
||||
pub struct StatsSnapshot {
|
||||
pub elapsed_secs: f64,
|
||||
// FPS
|
||||
pub capture_fps: f64,
|
||||
pub encoded_fps: f64,
|
||||
pub sent_fps: f64,
|
||||
// Counters
|
||||
pub capture_frames: u64,
|
||||
pub encoded_frames: u64,
|
||||
pub sent_frames: u64,
|
||||
pub pipewire_dropped: u64,
|
||||
pub over_budget_count: u64,
|
||||
pub duplicate_frames_skipped: u64,
|
||||
// Queue depths
|
||||
pub capture_queue_depth: usize,
|
||||
pub encoded_queue_depth: usize,
|
||||
// Gap timing (ms)
|
||||
pub capture_gap_avg_ms: f64,
|
||||
pub capture_gap_p95_ms: f64,
|
||||
pub capture_gap_max_ms: f64,
|
||||
pub encoded_gap_avg_ms: f64,
|
||||
pub encoded_gap_p95_ms: f64,
|
||||
pub encoded_gap_max_ms: f64,
|
||||
pub sent_gap_avg_ms: f64,
|
||||
pub sent_gap_p95_ms: f64,
|
||||
pub sent_gap_max_ms: f64,
|
||||
// Frame age (capture → send)
|
||||
pub frame_age_avg_ms: f64,
|
||||
pub frame_age_p95_ms: f64,
|
||||
pub frame_age_max_ms: f64,
|
||||
// Send wait
|
||||
pub send_wait_p95_ms: f64,
|
||||
// Per-stage encode timing (ms)
|
||||
pub import_avg_ms: f64,
|
||||
pub import_p95_ms: f64,
|
||||
pub scale_avg_ms: f64,
|
||||
pub scale_p95_ms: f64,
|
||||
pub transfer_avg_ms: f64,
|
||||
pub transfer_p95_ms: f64,
|
||||
pub sws_avg_ms: f64,
|
||||
pub sws_p95_ms: f64,
|
||||
pub encode_avg_ms: f64,
|
||||
pub encode_p95_ms: f64,
|
||||
pub total_avg_ms: f64,
|
||||
pub total_p95_ms: f64,
|
||||
// Output size
|
||||
pub output_bytes_per_sec: f64,
|
||||
pub output_frame_bytes_p95: usize,
|
||||
pub output_frame_bytes_max: usize,
|
||||
}
|
||||
|
||||
/// 单行结构化日志格式化器(中文 L3 解析见此,英文说明保留在下方)。
|
||||
///
|
||||
/// # trait 与签名说明
|
||||
///
|
||||
/// - `impl std::fmt::Display for StatsSnapshot`:为本类型实现标准库 trait,
|
||||
/// 使得 `format!("{snap}")` / `println!("{}", snap)` / `tracing::info!("{}", snap)`
|
||||
/// 均可工作(隐式调用 `fmt` 方法)
|
||||
/// - `fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result`:
|
||||
/// - `&self` 共享借用,格式化不改自身
|
||||
/// - `Formatter<'_>`:匿名生命周期(`'_`),表示该借用与调用方持有的
|
||||
/// `String`/输出流绑定;类比 Go 的 `io.Writer` 参数
|
||||
/// - `std::fmt::Result`:`Result<(), std::fmt::Error>`,专用于格式化 trait
|
||||
/// (比通用 `Result<T, E>` 更窄,避免 `?` 跨类型传播)
|
||||
///
|
||||
/// # `write!` 宏 vs `format!` 宏
|
||||
///
|
||||
/// - [`write!`]:直接写入 `Formatter`(零分配),类比 Go `fmt.Fprintf(w, ...)`
|
||||
/// - [`format!`]:分配新 `String` 后返回,类比 Go `fmt.Sprintf(...)`
|
||||
/// - 本实现选 `write!`:写入日志流时避免多余分配
|
||||
///
|
||||
/// # `?` 运算符
|
||||
///
|
||||
/// `write!(...)?` 中的 `?` 是早期返回:若 `write!` 返回 `Err(fmt::Error)`,
|
||||
/// 则立即从 `fmt` 返回该错误。类比 Go 中
|
||||
/// `if _, err := w.Write(...); err != nil { return err }`,
|
||||
/// 但 Rust 的 `?` 让快乐路径线性化。
|
||||
///
|
||||
/// # 格式说明
|
||||
///
|
||||
/// - `{:.1}`:保留 1 位小数
|
||||
/// - `{:.0}`:整数显示(无小数点)
|
||||
/// - `{}`:默认 `Display` 格式(整数原样)
|
||||
/// - 行尾反斜杠 `\` 跨行延续字符串字面量,类比 Python 隐式行连接;
|
||||
/// 输出时不会引入额外换行或空格
|
||||
impl std::fmt::Display for StatsSnapshot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
|
||||
pw_dropped={} over_budget={} duplicate_frames_skipped={} \
|
||||
cap_q={} enc_q={} \
|
||||
cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \
|
||||
enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \
|
||||
sent_gap_p95={:.1}ms sent_gap_max={:.1}ms \
|
||||
frame_age_p95={:.1}ms frame_age_max={:.1}ms \
|
||||
send_wait_p95={:.1}ms \
|
||||
import_p95={:.1}ms scale_p95={:.1}ms transfer_p95={:.1}ms \
|
||||
sws_p95={:.1}ms encode_p95={:.1}ms total_p95={:.1}ms \
|
||||
output_bps={:.0} frame_bytes_max={}",
|
||||
self.capture_fps,
|
||||
self.encoded_fps,
|
||||
self.sent_fps,
|
||||
self.pipewire_dropped,
|
||||
self.over_budget_count,
|
||||
self.duplicate_frames_skipped,
|
||||
self.capture_queue_depth,
|
||||
self.encoded_queue_depth,
|
||||
self.capture_gap_p95_ms,
|
||||
self.capture_gap_max_ms,
|
||||
self.encoded_gap_p95_ms,
|
||||
self.encoded_gap_max_ms,
|
||||
self.sent_gap_p95_ms,
|
||||
self.sent_gap_max_ms,
|
||||
self.frame_age_p95_ms,
|
||||
self.frame_age_max_ms,
|
||||
self.send_wait_p95_ms,
|
||||
self.import_p95_ms,
|
||||
self.scale_p95_ms,
|
||||
self.transfer_p95_ms,
|
||||
self.sws_p95_ms,
|
||||
self.encode_p95_ms,
|
||||
self.total_p95_ms,
|
||||
self.output_bytes_per_sec,
|
||||
self.output_frame_bytes_max,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistics helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 计算 `f64` 切片平均值(空切片返回 0.0)。
|
||||
///
|
||||
/// # 切片借用
|
||||
///
|
||||
/// `data: &[f64]`:共享借用切片(fat pointer = 指针 + 长度),类比 Go 中
|
||||
/// `func avg(data []float64)`。`&` 表示本函数不获取所有权,调用后原 `Vec`
|
||||
/// 仍可用。
|
||||
fn avg_f64(data: &[f64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
data.iter().sum::<f64>() / data.len() as f64
|
||||
}
|
||||
|
||||
/// 计算 p95(第 95 百分位),类比 Go 中需要手动 sort + index。
|
||||
///
|
||||
/// # 算法
|
||||
///
|
||||
/// 1. 复制输入到新 `Vec`(不修改调用方原数据):`data.to_vec()` 类比 Go
|
||||
/// `append([]T{}, data...)`
|
||||
/// 2. 排序:`sort_by` + `partial_cmp` —— `f64` 没有全序(NaN 特殊),
|
||||
/// 不能直接用 `sort()`;`partial_cmp(b).unwrap_or(Equal)` 在 NaN 时
|
||||
/// 降级为相等,避免 panic
|
||||
/// 3. 计算 idx = `floor(len * 0.95)`,`idx.min(len-1)` 防越界
|
||||
///
|
||||
/// # 为何不用 `sort_unstable`
|
||||
///
|
||||
/// `f64` 的 `Ord` 未实现(NaN 不等于自身),故只能用 `sort_by` + 比较
|
||||
/// 函数;`u64`/`usize` 实现 `Ord`,可用 `sort_unstable`(更快、内存友好)。
|
||||
fn p95_f64(data: &[f64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted: Vec<f64> = data.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let idx = ((sorted.len() as f64) * 0.95).floor() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
/// 返回切片最大值,空切片返回 0.0(业务上"无样本"等价于"无延迟")。
|
||||
///
|
||||
/// # `fold` + `f64::max`
|
||||
///
|
||||
/// `fold(0.0, f64::max)`:从初始值 0.0 开始,逐元素取较大值。
|
||||
/// 类比 Go:
|
||||
/// ```go
|
||||
/// m := 0.0
|
||||
/// for _, v := range data { m = math.Max(m, v) }
|
||||
/// ```
|
||||
/// 注意:若样本全为负,0.0 仍是结果(业务上 latency 非负,不会出现)。
|
||||
fn max_f64(data: &[f64]) -> f64 {
|
||||
data.iter().copied().fold(0.0_f64, f64::max)
|
||||
}
|
||||
|
||||
/// 计算 `u64` 微秒样本的平均值并转毫秒(÷1000)。
|
||||
fn avg_ms(data: &[u64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
|
||||
}
|
||||
|
||||
/// 计算 `u64` 微秒样本的 p95 并转毫秒。
|
||||
///
|
||||
/// 与 `p95_f64` 算法相同,但 `u64` 实现 `Ord`,可用 `sort_unstable`
|
||||
/// (无内存开销、更快;稳定性对本场景无关,因为只取索引位置)。
|
||||
fn p95_ms(data: &[u64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = data.to_vec();
|
||||
sorted.sort_unstable();
|
||||
let idx = ((sorted.len() as f64) * 0.95).floor() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)] as f64 / 1000.0
|
||||
}
|
||||
|
||||
/// 对 `usize` 切片求和(用于累计 output_bytes 总量)。
|
||||
///
|
||||
/// `data.iter().sum()` 由标准库自动推导类型(`usize`),等价于
|
||||
/// Go 中 `var total uint; for _, v := range data { total += v }`。
|
||||
fn sum_usize(data: &[usize]) -> usize {
|
||||
data.iter().sum()
|
||||
}
|
||||
|
||||
/// 计算 `usize` 样本的 p95(字节大小分布),不转换单位。
|
||||
fn p95_usize(data: &[usize]) -> usize {
|
||||
if data.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut sorted = data.to_vec();
|
||||
sorted.sort_unstable();
|
||||
let idx = ((sorted.len() as f64) * 0.95).floor() as usize;
|
||||
sorted[idx.min(sorted.len() - 1)]
|
||||
}
|
||||
|
||||
/// 返回 `usize` 切片最大值,空切片返回 0。
|
||||
///
|
||||
/// `iter().copied().max()` 返回 `Option<usize>`(空时为 `None`),
|
||||
/// `unwrap_or(0)` 提供默认值,类比 Go 中显式 `if len(data) == 0 { return 0 }`。
|
||||
fn max_usize(data: &[usize]) -> usize {
|
||||
data.iter().copied().max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 单元测试模块(仅在 `#[cfg(test)]` 时编译)。
|
||||
///
|
||||
/// # Rust 测试模式
|
||||
///
|
||||
/// - `#[cfg(test)]`:条件编译属性,`cargo test` 时才编译本模块,
|
||||
/// 正常 `cargo build` 不包含本模块代码(类比 Go 中 `_test.go` 后缀
|
||||
/// 仅在 `go test` 时段编译,但 Rust 用显式属性而非文件名约定)
|
||||
/// - `use super::*`:导入父模块(本文件)所有 `pub` 与私密 item,
|
||||
/// 类比 Go test 文件无需 import 即可访问同包符号
|
||||
/// - `#[test]`:标记测试函数;`cargo test` 自动发现并执行
|
||||
/// - `assert_eq!` / `assert!`:宏(不是函数),失败时打印表达式原文
|
||||
/// 便于调试,类比 Go 中 `t.Errorf` 但更早终止当前测试
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_stats_snapshot() {
|
||||
let mut stats = PipelineStats::new();
|
||||
let snap = stats.snapshot_and_reset();
|
||||
assert_eq!(snap.capture_frames, 0);
|
||||
assert_eq!(snap.encoded_frames, 0);
|
||||
assert_eq!(snap.sent_frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_snapshot_counts() {
|
||||
let mut stats = PipelineStats::new();
|
||||
stats.record_capture();
|
||||
stats.record_capture();
|
||||
stats.record_encode(&FrameTimings {
|
||||
total_us: 5000,
|
||||
output_bytes: 1000,
|
||||
..Default::default()
|
||||
});
|
||||
stats.record_send(0.1, None);
|
||||
|
||||
let snap = stats.snapshot_and_reset();
|
||||
assert_eq!(snap.capture_frames, 2);
|
||||
assert_eq!(snap.encoded_frames, 1);
|
||||
assert_eq!(snap.sent_frames, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p95_computation() {
|
||||
// 100 values: 0.0 through 99.0
|
||||
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let result = p95_f64(&data);
|
||||
assert!(
|
||||
(result - 95.0).abs() < 1.0,
|
||||
"p95 of 0..100 should be ~95, got {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p95_ms_microseconds() {
|
||||
let data: Vec<u64> = (0..100).map(|i| i * 1000).collect(); // 0ms..99ms
|
||||
let result = p95_ms(&data);
|
||||
assert!(
|
||||
(result - 95.0).abs() < 1.0,
|
||||
"p95_ms should be ~95ms, got {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_resets_counters() {
|
||||
let mut stats = PipelineStats::new();
|
||||
stats.record_capture();
|
||||
let _ = stats.snapshot_and_reset();
|
||||
let snap = stats.snapshot_and_reset();
|
||||
assert_eq!(snap.capture_frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_format_contains_key_fields() {
|
||||
let mut stats = PipelineStats::new();
|
||||
stats.record_capture();
|
||||
stats.record_encode(&FrameTimings {
|
||||
total_us: 10000,
|
||||
output_bytes: 5000,
|
||||
..Default::default()
|
||||
});
|
||||
stats.record_send(0.5, None);
|
||||
let snap = stats.snapshot_and_reset();
|
||||
let text = format!("{snap}");
|
||||
assert!(text.contains("capture_fps="));
|
||||
assert!(text.contains("encoded_fps="));
|
||||
assert!(text.contains("sent_fps="));
|
||||
assert!(text.contains("total_p95="));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,33 @@
|
||||
//! 图像几何变换模块(纯坐标运算,不涉及像素缓冲区)。
|
||||
//!
|
||||
//! 对应 Wayland `wl_output::Transform` 的 8 种旋转变体(旋转 + 翻转),
|
||||
//! 为屏幕捕获提供 ROI(Region of Interest)裁剪与坐标系换算。
|
||||
//!
|
||||
//! # 与 Go 的对照
|
||||
//!
|
||||
//! - Go 标准库 `image/geom.go` 的 `Rectangle` 仅支持轴对齐矩形;本模块额外处理
|
||||
//! 90°/180°/270° 旋转与水平/垂直翻转下的矩形映射。
|
||||
//! - Go 用 `int` 表示坐标;本模块用 `i32`(与 `wl_output` 协议一致)。
|
||||
//! - Wayland 协议要求捕获 ROI 在变换后的"帧坐标"中给出,本模块负责
|
||||
//! "屏坐标 → 帧坐标"的换算(见 [`screen_to_frame`])。
|
||||
//!
|
||||
//! 注意:本模块**不操作像素缓冲区**(无 `&[u8]` / `Vec::with_capacity`),
|
||||
//! 只做整数算术;真正的像素拷贝在 `state.rs` / `cap_portal.rs` 中通过
|
||||
//! DMA-BUF 或 shm 完成。计划文档中提到的 `&[u8]` slice / `Vec` 预分配
|
||||
//! 等模式不属于本模块,本模块的"重量级"Rust 模式聚焦在 `match` 穷尽匹配、
|
||||
//! 元组解构、if 表达式、or-pattern 与整数 helper 方法(`.abs()`/`.clamp()`)。
|
||||
|
||||
// Wayland `wl_output::Transform` 的 8 种变体:4 种纯旋转(Normal*)+ 4 种
|
||||
// "先水平翻转再旋转"(Flipped*)。单元 enum(无关联数据),`Copy + Eq` 派生
|
||||
// 使其可在 `match` / `==` 中零开销使用。
|
||||
//
|
||||
// Go 没有内置 enum,等价于 `type Transform int` + `const ( Normal = iota; ... )`;
|
||||
// Rust 的 enum 是真代数类型,编译期保证 `match` 穷尽性(漏写一个 variant
|
||||
// 会直接编译失败,而 Go 的 switch 不强制 default)。
|
||||
//
|
||||
// `#[derive(...)]` 宏说明:`Debug`→允许 `{:?}` 调试输出;`Clone, Copy`→
|
||||
// 单元 enum 按位复制即可(等价于 Go 整数值语义);`PartialEq, Eq`→自动生成
|
||||
// `==`/`!=`,基于 variant tag 比较。
|
||||
/// Coordinate transformation module for Wayland output transforms.
|
||||
///
|
||||
/// Handles the 8 `wl_output` transform variants (rotation + reflection)
|
||||
@@ -16,6 +46,11 @@ pub enum Transform {
|
||||
Flipped270,
|
||||
}
|
||||
|
||||
// 轴对齐矩形(Axis-Aligned Bounding Box,AABB)。
|
||||
//
|
||||
// 所有字段 `i32`(与 Wayland 协议一致);Go 类比 `image.Rectangle` 但
|
||||
// 用 `(x, y, w, h)` 而非 `(Min, Max)`,便于直接喂给 FFmpeg VAAPI 的 ROI 参数。
|
||||
// `Copy + Eq`:值语义,函数传参/返回零开销(无 `&Rect` 借用开销)。
|
||||
/// Axis-aligned rectangle in integer coordinates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rect {
|
||||
@@ -25,6 +60,11 @@ pub struct Rect {
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
// 返回变换对应的 2×2 基础矩阵 `(a, b, c, d)`。
|
||||
//
|
||||
// 这是纯算术查表,无副作用,编译器很容易内联到调用点。返回值用 4-tuple 而非
|
||||
// `[i32; 4]` 数组:Rust 元组每字段可有不同类型(此处都是 i32 但语义不同),
|
||||
// 模式匹配解构时更显式(`let (a, b, c, d) = ...`)。
|
||||
/// Returns the 2×2 basis matrix (a, b, c, d) for the given transform.
|
||||
///
|
||||
/// The matrix represents the affine mapping from screen coordinates to
|
||||
@@ -35,18 +75,37 @@ pub struct Rect {
|
||||
/// [new_y] = [c d] [y]
|
||||
/// ```
|
||||
pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
|
||||
// `match` 是 Rust 的模式匹配控制流,对 enum 必须**穷尽**(exhaustive):
|
||||
// 漏写任意 variant 会直接编译失败。Go 的 `switch` 不强制 default,
|
||||
// 此处 8 个 variant 必须全部列出,编译器即充当完整性检查器。
|
||||
//
|
||||
// 每个 arm 形如 `Pattern => expr,`,返回的 4-tuple 编码矩阵系数。
|
||||
// 这些数值来自 Wayland `wl_output::Transform` 协议规范,不可随意修改。
|
||||
match transform {
|
||||
// 单位矩阵:屏幕坐标 = 帧坐标。
|
||||
Transform::Normal => (1, 0, 0, 1),
|
||||
// 顺时针 90°:x/y 互换并取反。
|
||||
Transform::Normal90 => (0, 1, -1, 0),
|
||||
// 180°:两轴都取反。
|
||||
Transform::Normal180 => (-1, 0, 0, -1),
|
||||
// 顺时针 270°(= 逆时针 90°)。
|
||||
Transform::Normal270 => (0, -1, 1, 0),
|
||||
// 水平翻转(沿 Y 轴镜像):x 取反。
|
||||
Transform::Flipped => (-1, 0, 0, 1),
|
||||
// 翻转 + 90°。
|
||||
Transform::Flipped90 => (0, 1, 1, 0),
|
||||
// 翻转 + 180°(等价于垂直翻转)。
|
||||
Transform::Flipped180 => (1, 0, 0, -1),
|
||||
// 翻转 + 270°。
|
||||
Transform::Flipped270 => (0, -1, -1, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// 将矩形从"屏幕坐标"映射到"帧坐标",并平移到第一象限([0, frame_w) × [0, frame_h))。
|
||||
//
|
||||
// 这是 ROI(捕获区域)参数换算的核心:用户在屏幕上选了一块 `(x, y, w, h)`,
|
||||
// 但 Wayland 帧已应用了 output transform(例如 90° 旋转),编码器看到的帧
|
||||
// 坐标与屏幕坐标不同,必须先变换再喂给 VAAPI。
|
||||
/// Transform a rectangle from screen space to frame space.
|
||||
///
|
||||
/// Applies the 2×2 basis matrix and computes offsets so the result
|
||||
@@ -57,11 +116,17 @@ pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
|
||||
/// new_y = c * x + d * y + offset_y
|
||||
/// ```
|
||||
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
|
||||
// 元组解构(tuple destructuring):4-tuple 一次性拆成 4 个 `i32` 变量。
|
||||
// 类比 Go 的 `a, b, c, d := transformBasis(transform)`,但 Rust 的元组
|
||||
// 是真类型(可作为参数/返回值),Go 只能用多返回值模拟。
|
||||
let (a, b, c, d) = transform_basis(transform);
|
||||
|
||||
// Compute the offset so that the transformed origin maps correctly.
|
||||
// For transforms with negative components, we need to shift by the
|
||||
// frame dimension to keep coordinates in [0, frame_w) × [0, frame_h).
|
||||
// `if ... { ... } else { ... }` 在 Rust 中是**表达式**(而非语句),
|
||||
// 直接产出值赋给 `offset_x`。Go 没有 ternary,必须 `var offset_x int;
|
||||
// if ... { offset_x = frame_w }`,Rust 这种写法更紧凑。
|
||||
let offset_x = if a + b < 0 { frame_w } else { 0 };
|
||||
let offset_y = if c + d < 0 { frame_h } else { 0 };
|
||||
|
||||
@@ -70,6 +135,13 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
|
||||
let new_w = a * rect.w + b * rect.h;
|
||||
let new_h = c * rect.w + d * rect.h;
|
||||
|
||||
// 结构体字面量(struct literal):`Rect { x: ..., y: ..., ... }`。
|
||||
// 类比 Go 的 `image.Rectangle{Min: ..., Max: ...}`;Rust 允许字段简写
|
||||
//(变量名与字段名相同时只写一个,例如 `x` 而非 `x: x`)。
|
||||
//
|
||||
// `.abs()` 是 `i32` 的内置方法(取绝对值):
|
||||
// 旋转后 `new_w`/`new_h` 可能为负(例如 90° 下宽变成原高取反),
|
||||
// 矩形尺寸必须非负,故取绝对值。
|
||||
Rect {
|
||||
x: new_x,
|
||||
y: new_y,
|
||||
@@ -78,32 +150,61 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
|
||||
}
|
||||
}
|
||||
|
||||
// 90°/270° 旋转变换下,输出画布的宽高需要交换(横向屏幕旋转后变纵向)。
|
||||
//
|
||||
// 辅助函数:是则返回 `(h, w)`,否则原样返回 `(w, h)`。Go 类比:
|
||||
// ```go
|
||||
// func transposeIf(t Transform, w, h int) (int, int) {
|
||||
// switch t { case Normal90, Normal270, Flipped90, Flipped270: return h, w }
|
||||
// return w, h
|
||||
// }
|
||||
// ```
|
||||
/// Swap width and height for 90° or 270° rotations.
|
||||
///
|
||||
/// After a quarter-turn rotation the output dimensions are transposed
|
||||
/// relative to the input. This helper returns `(h, w)` for those cases
|
||||
/// and `(w, h)` unchanged otherwise.
|
||||
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
|
||||
// `match` 配合 **or-pattern**:用 `|` 把多个 variant 合并为一个 arm,
|
||||
// 共享同一个表达式分支。Go 的 `switch` 用 `case A, B, C:` fallthrough 等价。
|
||||
// 注意 Rust 的 match 不存在隐式 fallthrough,每个 arm 必须 `=>` 显式给出表达式。
|
||||
match transform {
|
||||
// 四种"四分之一圈"旋转:宽高必须互换。
|
||||
Transform::Normal90
|
||||
| Transform::Normal270
|
||||
| Transform::Flipped90
|
||||
| Transform::Flipped270 => (h, w),
|
||||
// `_` 是通配符(wildcard),匹配所有未列出的 variant。
|
||||
// Rust 要求 match 穷尽,最后用 `_ =>` 兜底等价于 Go `default:` 分支。
|
||||
// 此处涵盖 `Normal` / `Normal180` / `Flipped` / `Flipped180`。
|
||||
_ => (w, h),
|
||||
}
|
||||
}
|
||||
|
||||
// 将矩形裁剪到 `(0, 0) .. (bounds_w, bounds_h)` 范围内。
|
||||
//
|
||||
// 用于 ROI 校验:用户给的坐标可能为负或越界,编码器不接受这样的区域,
|
||||
// 必须先 clamp 到合法范围。Go 标准库没有 `clamp` 内置函数(Go 1.21 才加入
|
||||
// `min`/`max` 内置),通常要手写 `if x < lo { x = lo } else if x > hi { x = hi }`;
|
||||
// Rust 的 `i32::clamp(lo, hi)` 是方法调用,语义更直观。
|
||||
/// Clip a rectangle so it stays inside `(0, 0) .. (bounds_w, bounds_h)`.
|
||||
///
|
||||
/// The resulting rectangle has non-negative origin and its extent does
|
||||
/// not exceed the bounds.
|
||||
pub fn fit_inside_bounds(rect: Rect, bounds_w: i32, bounds_h: i32) -> Rect {
|
||||
// `.clamp(lo, hi)`:将值限制在 `[lo, hi]` 闭区间内(小于 lo 返回 lo,
|
||||
// 大于 hi 返回 hi,否则原值)。返回 `i32`(self by value)。
|
||||
let x = rect.x.clamp(0, bounds_w);
|
||||
let y = rect.y.clamp(0, bounds_h);
|
||||
// `.min(other)`:返回 `self` 与 `other` 的较小值(等价 Go 的 `if a < b` 三元)。
|
||||
// 此处把矩形的右边界限制到 `bounds_w`,避免越界。
|
||||
let right = (rect.x + rect.w).min(bounds_w);
|
||||
let bottom = (rect.y + rect.h).min(bounds_h);
|
||||
// `.max(other)`:返回较大值。此处保证宽高非负(`right - x` 在
|
||||
// 完全越界的退化情形下可能为负,取 max(0) 兜底)。
|
||||
let w = (right - x).max(0);
|
||||
let h = (bottom - y).max(0);
|
||||
// 字段简写:`x`/`y`/`w`/`h` 变量名与 `Rect` 字段名相同,可省略 `field: value`。
|
||||
Rect { x, y, w, h }
|
||||
}
|
||||
|
||||
|
||||
+732
-73
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,77 @@
|
||||
//! 集成测试:通过 shell out 到 `target/release/wl-webrtc` 二进制来验证 CLI 行为。
|
||||
//!
|
||||
//! 与单元测试(在进程内调用库函数)不同,集成测试把产物当作黑盒,启动子进程
|
||||
//! 并检查其 stdout/stderr/exit code。这种模式类似 Go 的 `testing` 包配合
|
||||
//! `os/exec.Command(...)` —— Rust 这边对应 `std::process::Command::new(...)`,
|
||||
//! 通过 `.arg(...)` 链式追加参数,最后 `.output()` 一次性等待子进程结束并
|
||||
//! 拿到 `Output { status, stdout, stderr }`。
|
||||
//!
|
||||
//! # 运行前必读
|
||||
//!
|
||||
//! 这些测试**依赖 release 版二进制存在**。`cargo test --test integration_test`
|
||||
//! 本身不会触发 release 构建,必须先手动执行:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo build --release
|
||||
//! ```
|
||||
//!
|
||||
//! 否则 `Command::new("target/release/wl-webrtc")` 会因为找不到可执行文件而 panic,
|
||||
//! 所有 `#[test]` 都将以 "failed to execute" 失败。详见 `AGENTS.md`
|
||||
//! "Testing and verification" 章节。
|
||||
//!
|
||||
//! # 测试发现与断言
|
||||
//!
|
||||
//! `cargo test` 通过 `#[test]` 属性宏自动发现并运行标记的函数,无需像 Go 那样
|
||||
//! 约定 `TestXxx(t *testing.T)` 签名 —— 普通函数加 `#[test]` 即可。
|
||||
//! `#[ignore]` 标记的测试默认跳过,需 `cargo test -- --ignored` 显式开启。
|
||||
//!
|
||||
//! 断言方面,`assert!(cond, "msg")` 类似 Go 的 `if !cond { t.Errorf("msg") }`,
|
||||
//! 但 Rust 会在失败时立即 unwind 当前测试函数(而非 Go 那样继续执行后续断言)。
|
||||
//! 当测试函数返回 `Result<(), E>` 时,可用 `?` 操作符把 IO/解码错误直接传播
|
||||
//! 给测试 harness(失败时打印 `Err` 而非 `panic!`);本文件的测试为了聚焦于
|
||||
//! 子进程行为,全部用 `.expect(...)`/`assert!` 风格,不返回 `Result`。
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Helper: get the binary path. Uses the release build if available.
|
||||
///
|
||||
/// 返回被测二进制的路径。集成测试 shell out 到 release 构建产物(debug 构建太慢,
|
||||
/// 且无法真实反映发布行为)。返回 `&'static str` 而非 `PathBuf` 是因为这个路径
|
||||
/// 是编译期常量,无需在每次调用时分配。
|
||||
fn bin_path() -> &'static str {
|
||||
"target/release/wl-webrtc"
|
||||
}
|
||||
|
||||
/// 测试 `--help` 子命令:应正常退出(exit 0),且 stdout 至少包含关键字段名。
|
||||
///
|
||||
/// 验证 README/AGENTS.md 中列出的核心 CLI 参数(output/fps/codec/bitrate/gop-size/drm-device)
|
||||
/// 都能在 `--help` 输出中找到 —— 这是一道"防回归"测试:一旦某参数被改名或删除,
|
||||
/// 此处 `assert!` 会立刻失败。
|
||||
#[test]
|
||||
fn test_help_flag() {
|
||||
// `Command::new(...)` 类似 Go 的 `exec.Command(...)`:构造一个待运行的
|
||||
// 子进程描述符,此时还未真正 fork/exec。链式 `.arg(...)` 把参数逐个追加到
|
||||
// 命令行末尾(保持顺序),`.output()` 则 fork、exec、等待子进程退出,并
|
||||
// 一次性捕获 stdout/stderr 到 `Output` 结构体。
|
||||
//
|
||||
// `.expect(...)` 等价于 `match result { Ok(v) => v, Err(_) => panic!(...) }`,
|
||||
// 用于在父进程侧(不是被测程序侧)报告"无法启动子进程"这种环境性错误。
|
||||
let output = Command::new(bin_path())
|
||||
.arg("--help")
|
||||
.output()
|
||||
.expect("failed to execute wl-webrtc --help");
|
||||
|
||||
// `String::from_utf8_lossy(...)` 把 `Vec<u8>` 字节流解码成字符串;遇到非法
|
||||
// UTF-8 序列时用 U+FFFD 替换而非报错。这里用 `_lossy` 变体而非
|
||||
// `String::from_utf8(...)?` 是因为 stdout 理论上可能包含任意字节(如 ANSI
|
||||
// color code 失控),不值得为解码失败让整个测试 panic。
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// `output.status.success()` 检查子进程退出码是否为 0(Unix 上即 WIFEXITED
|
||||
// 且 exit code == 0)。`assert!(cond, "msg")` 失败时打印 `msg` 并 panic
|
||||
// 当前测试函数,类似 Go 的 `t.Fatalf` 而非 `t.Errorf`。
|
||||
assert!(output.status.success(), "--help should exit 0");
|
||||
// 后续 `assert!(stdout.contains(...), "...")` 检查帮助文本是否覆盖每个
|
||||
// 文档化参数。任何一个缺失都会让测试失败并打印自定义消息。
|
||||
assert!(
|
||||
stdout.contains("output"),
|
||||
"help output should mention 'output'"
|
||||
@@ -37,6 +95,11 @@ fn test_help_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试未知参数应被拒绝:非零退出码 + stderr 包含 error/unexpected/unrecognized 之一。
|
||||
///
|
||||
/// `clap` 默认对未识别的 flag 返回非零退出码并打印错误到 stderr。这里用
|
||||
/// `!output.status.success()` 断言"应该失败",再检查 stderr 文本以排除"碰巧
|
||||
/// 崩溃退出"的假阳性。
|
||||
#[test]
|
||||
fn test_rejects_invalid_args() {
|
||||
let output = Command::new(bin_path())
|
||||
@@ -44,8 +107,13 @@ fn test_rejects_invalid_args() {
|
||||
.output()
|
||||
.expect("failed to execute wl-webrtc with invalid args");
|
||||
|
||||
// 断言"非零退出码"。注意 `!` 取反 —— 与 Go 的 `t.Errorf` 风格不同,Rust 的
|
||||
// `assert!` 直接接受 bool 表达式,没有 `assertFalse` 这种专门函数。
|
||||
assert!(!output.status.success(), "should reject unrecognized flag");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// 多个可能的错误措辞用 `||` 连接 —— 不同 clap 版本可能输出 "error: unexpected"
|
||||
// 或 "error: unrecognized",任一匹配即可。自定义消息末尾的 `{stderr}` 利用
|
||||
// `format!` 占位符在失败时打印实际 stderr 内容,便于排错。
|
||||
assert!(
|
||||
stderr.to_lowercase().contains("error")
|
||||
|| stderr.to_lowercase().contains("unexpected")
|
||||
@@ -54,8 +122,11 @@ fn test_rejects_invalid_args() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 `--codec hevc` 在 MVP 阶段应被拒绝:MVP 只支持 h264。
|
||||
#[test]
|
||||
fn test_rejects_hevc_codec() {
|
||||
// 多个 `.arg(...)` 链式调用按顺序追加参数,等价于命令行
|
||||
// `wl-webrtc --output /dev/null --codec hevc`。
|
||||
let output = Command::new(bin_path())
|
||||
.arg("--output")
|
||||
.arg("/dev/null")
|
||||
@@ -70,6 +141,13 @@ fn test_rejects_hevc_codec() {
|
||||
|
||||
/// Tests requiring a live Wayland compositor and VAAPI hardware.
|
||||
/// Run with: cargo test -- --ignored
|
||||
///
|
||||
/// 该测试需要真实 Wayland 会话 + VAAPI GPU + 可写输出路径,无法在 CI 中运行。
|
||||
/// `#[ignore]` 属性告诉 `cargo test` 默认跳过它,只有显式
|
||||
/// `cargo test -- --ignored` 时才执行。
|
||||
///
|
||||
/// 注意:此测试只验证"参数解析不立即报错",并未真正完成捕获 —— 真正的捕获
|
||||
/// 需要异步等待几秒再发 SIGINT,这里只做最小烟雾测试。
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_capture_starts_with_valid_output() {
|
||||
|
||||
Reference in New Issue
Block a user