Compare commits
28
Commits
master
..
74ac8750dc
| 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 |
@@ -1,14 +0,0 @@
|
||||
# quick-xml is pulled in only through wayland-scanner's build-time Wayland XML
|
||||
# code generation path:
|
||||
#
|
||||
# wayland-scanner v0.31.10 -> quick-xml v0.39.x
|
||||
#
|
||||
# The current wayland-scanner release requires quick-xml ^0.39, so it cannot
|
||||
# accept the fixed quick-xml >=0.41.0 line yet. This project does not parse
|
||||
# attacker-controlled XML at runtime through quick-xml. Remove these ignores as
|
||||
# soon as wayland-scanner or the wayland-* crates release a compatible fix.
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
]
|
||||
@@ -1,101 +0,0 @@
|
||||
# Continuous integration for wl-webrtc.
|
||||
#
|
||||
# Triggered on push/PR to master. Runs the full quality gate that the recent
|
||||
# audit baselined:
|
||||
# - clippy: 0 errors (undocumented_unsafe_blocks is deny in Cargo.toml; other
|
||||
# warnings are advisory for now).
|
||||
# - build --release: integration tests in tests/integration_test.rs shell out
|
||||
# to target/release/wl-webrtc, so the release binary must exist before tests
|
||||
# run.
|
||||
# - test --release: 79 unit + 3 integration; the 1 hardware-ignored test
|
||||
# stays ignored in CI (needs Wayland session + VAAPI GPU).
|
||||
# - cargo audit: separate job so a RUSTSEC advisory fails the build without
|
||||
# conflating with compile errors.
|
||||
#
|
||||
# The job pins Linux only — the project is Wayland/VAAPI-specific and has no
|
||||
# macOS/Windows story. Oracle audit 2026-06-28 P2 plan.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build + Clippy + Test
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HTTP_PROXY: http://172.17.0.1:7897
|
||||
HTTPS_PROXY: http://172.17.0.1:7897
|
||||
NO_PROXY: localhost,127.0.0.1,server,db,gitea.dailz.cn,gitea.com
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain (stable)
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
echo 'Acquire::http::Proxy "http://172.17.0.1:7897";' | sudo tee /etc/apt/apt.conf.d/99proxy
|
||||
echo 'Acquire::https::Proxy "http://172.17.0.1:7897";' | sudo tee -a /etc/apt/apt.conf.d/99proxy
|
||||
APT_OPTS=(-o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
|
||||
sudo apt-get "${APT_OPTS[@]}" update
|
||||
# NOTE: do NOT use --no-install-recommends for libclang-dev — on
|
||||
# Debian Bookworm (the node:20-bookworm image used by act_runner
|
||||
# under ubuntu-latest) the recommended toolchain bits are needed
|
||||
# by bindgen. The pkg-config based deps (pipewire/wayland/etc)
|
||||
# are also more reliable without the flag.
|
||||
sudo apt-get "${APT_OPTS[@]}" install -y \
|
||||
ffmpeg \
|
||||
libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev libswscale-dev libva-dev \
|
||||
libwayland-dev wayland-protocols \
|
||||
libdrm-dev \
|
||||
libpipewire-0.3-dev \
|
||||
libclang-dev clang
|
||||
|
||||
- name: Resolve LIBCLANG_PATH
|
||||
run: |
|
||||
set -e
|
||||
LIBCL=$(find /usr -name 'libclang*.so*' 2>/dev/null | head -1)
|
||||
test -n "$LIBCL" || { echo "ERROR: no libclang shared lib found under /usr"; exit 1; }
|
||||
LIBDIR=$(dirname "$LIBCL")
|
||||
echo "Resolved LIBCLANG_PATH=$LIBDIR (found $LIBCL)"
|
||||
echo "LIBCLANG_PATH=$LIBDIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Clippy (release, all targets)
|
||||
run: cargo clippy --release --all-targets
|
||||
|
||||
- name: Build release (required before tests)
|
||||
run: cargo build --release --all-targets
|
||||
|
||||
- name: Test (release)
|
||||
run: cargo test --release
|
||||
|
||||
audit:
|
||||
name: Security audit (RUSTSEC)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HTTP_PROXY: http://172.17.0.1:7897
|
||||
HTTPS_PROXY: http://172.17.0.1:7897
|
||||
NO_PROXY: localhost,127.0.0.1,server,db,gitea.dailz.cn,gitea.com
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain (stable)
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
- name: Audit dependencies
|
||||
run: cargo audit --deny warnings
|
||||
@@ -21,6 +21,3 @@ Thumbs.db
|
||||
.playwright-mcp/
|
||||
wl-webrtc.log
|
||||
webrtc-p0-success.png
|
||||
|
||||
# Stray review-tool output (regenerated per review run)
|
||||
review.json
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
- 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` sets `clippy::undocumented_unsafe_blocks = "deny"`; every `unsafe` block and `unsafe impl` must carry a `// SAFETY:` comment or the build fails. For `unsafe impl Send` on FFmpeg wrappers, see the convention in `src/avhw/mod.rs` — justification must be at the C-API level (atomic refcounts, libva `VADisplay` thread safety), not Rust borrow level.
|
||||
- `Cargo.toml` only warns on `clippy::undocumented_unsafe_blocks`; do not assume a broader clippy policy exists unless you add one.
|
||||
|
||||
## Testing and verification
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
- `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/mod.rs` drives the wlroots path using a mio Wayland fd loop and `State<CapWlrScreencopy>` (Wayland `Dispatch` impls live under `src/state/dispatch/`); `src/state_portal.rs` drives the Portal/PipeWire path through `CapPortal` frame channels (bitrate helpers and encode/webrtc thread loops are split into `src/state_portal/{bitrate,threads}.rs`). `src/cap_portal.rs` holds the `CapPortal` struct itself; its setup logic, token filesystem helpers, and PipeWire capture thread live under `src/cap_portal/`.
|
||||
- `src/webrtc.rs` is a small embedded HTTP/WebRTC signaling server using `str0m`; the embedded HTML test page is in `src/webrtc/html_page.rs`. `--port 0` means file-output mode, `--port > 0` enables WebRTC mode.
|
||||
- `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/` (split from the former `src/avhw.rs` in commit d53e881) owns FFmpeg `AVBufferRef` / frame / codec contexts. Five types (`AvHwDevCtx`, `AvHwFrameCtx`, `EncState`, `SwEncState`, `SwEncEncode`) carry `unsafe impl Send`; soundness was Oracle-audited on 2026-07-09 against FFmpeg/libva threading semantics. Moving them across threads is sound *because the C APIs use atomic refcounts*, not because of any Rust-side exclusivity — see `src/avhw/mod.rs` for the full convention.
|
||||
- `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
|
||||
|
||||
Generated
+2
-2
@@ -94,9 +94,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
|
||||
+1
-7
@@ -2,12 +2,6 @@
|
||||
name = "wl-webrtc"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# MSRV pinned to 1.87 to match the actual API floor — the codebase uses
|
||||
# u32::is_multiple_of (1.87) and Option::is_none_or (1.82) introduced by
|
||||
# clippy autofixes. README's Prerequisites section mirrors this. Bumping
|
||||
# this floor requires checking clippy::incompatible_msrv against the new
|
||||
# value.
|
||||
rust-version = "1.87"
|
||||
description = "Wayland screen capture and encoding tool"
|
||||
|
||||
[dependencies]
|
||||
@@ -39,4 +33,4 @@ dirs = "6"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
|
||||
@@ -4,7 +4,7 @@ Wayland screen capture and encoding tool.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Rust toolchain** (1.87+; MSRV pinned to match `u32::is_multiple_of` / `Option::is_none_or` usage): `rustup default stable`
|
||||
- **Rust toolchain** (1.70+): `rustup default stable`
|
||||
- **FFmpeg 6.0+** dev libraries with VAAPI support:
|
||||
- Arch: `pacman -S ffmpeg`
|
||||
- Ubuntu/Debian: `apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libva-dev`
|
||||
@@ -38,46 +38,19 @@ wl-webrtc --output output.mp4 --drm-device /dev/dri/renderD128
|
||||
|
||||
# Verbose mode
|
||||
wl-webrtc --output output.mp4 -v
|
||||
|
||||
# WebRTC streaming mode (HTTP signaling server)
|
||||
wl-webrtc --port 8080 -v
|
||||
|
||||
# Force a fresh portal authorization dialog (ignore saved restore token)
|
||||
wl-webrtc --output output.mp4 --no-persist
|
||||
|
||||
# Pin the capture backend instead of auto-detecting
|
||||
wl-webrtc --output output.mp4 --backend portal # or: --backend screencopy
|
||||
```
|
||||
|
||||
## CLI Arguments
|
||||
|
||||
> `src/args.rs` is the authoritative source. Run `wl-webrtc --help` for the live list.
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `-o`, `--output` | (optional) | Output file path (e.g. output.mp4). Optional when using `--port` for WebRTC mode. |
|
||||
| `-o`, `--output` | (required) | Output file path (e.g., output.mp4) |
|
||||
| `--output-name` | auto | Wayland output name to capture |
|
||||
| `--fps` | 30 | Target frames per second |
|
||||
| `--codec` | h264 | Video codec (h264 only for MVP) |
|
||||
| `--hw-accel` | vaapi | Hardware acceleration method |
|
||||
| `--drm-device` | auto | DRM render device path |
|
||||
| `--bitrate` | auto | Target bitrate in bps |
|
||||
| `--max-bitrate` | 8000000 | Max bitrate cap for WebRTC mode (caps BWE escalation; no effect in MP4 mode) |
|
||||
| `--gop-size` | auto | Group of Pictures size |
|
||||
| `-v`, `--verbose` | false | Enable verbose logging |
|
||||
| `--backend` | auto | Capture backend: `screencopy` (wlroots) or `portal` (KWin/KDE). Auto-detected if omitted. |
|
||||
| `--port` | 0 | WebRTC HTTP signaling server port. `0` keeps MP4 file output mode. |
|
||||
| `--no-persist` | false | Force re-authorization (ignore saved portal restore token) |
|
||||
| `--stats` | false | Print per-second pipeline statistics for stutter diagnosis |
|
||||
|
||||
## Capture backends
|
||||
|
||||
The tool supports two Wayland capture backends, auto-detected by default:
|
||||
|
||||
- **wlr-screencopy** (preferred when `zwlr_screencopy_manager_v1` is advertised):
|
||||
works on wlroots-based compositors (Sway, Hyprland, etc.).
|
||||
- **XDG Portal / PipeWire** (fallback when D-Bus ScreenCast is available):
|
||||
works on KWin/KDE and any compositor that implements the XDG Desktop Portal
|
||||
screen-cast protocol. The first run shows an authorization dialog; a restore
|
||||
token is cached under `wl-webrtc/portal-restore-token` so subsequent runs
|
||||
don't re-prompt (use `--no-persist` to force a fresh authorization).
|
||||
| `--port` | 0 | WebTransport server port (unused in MVP) |
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
+47
@@ -1,35 +1,75 @@
|
||||
//! 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
|
||||
@@ -37,28 +77,35 @@ pub struct Args {
|
||||
/// 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,
|
||||
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
|
||||
}
|
||||
|
||||
+2726
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::util::ff_err;
|
||||
|
||||
pub struct AvHwDevCtx {
|
||||
ptr: *mut ffi::AVBufferRef,
|
||||
}
|
||||
|
||||
// SAFETY: AVBufferRef's refcount is atomic (atomic_uint in libavutil/buffer.c);
|
||||
// av_buffer_ref / av_buffer_unref are safe to call concurrently from different
|
||||
// threads on the same buffer. The underlying AVHWDeviceContext (VAAPI VADisplay)
|
||||
// is designed by FFmpeg/libva to be shared across codec and filter contexts,
|
||||
// including across FFmpeg-internal codec threads. Raw refs returned by
|
||||
// ref_clone() may outlive this wrapper and be consumed by other threads; this
|
||||
// is the intended usage pattern and is sound because refcount management is
|
||||
// atomic. The &mut self on Rust methods is an API convenience, not the basis
|
||||
// for soundness.
|
||||
unsafe impl Send for AvHwDevCtx {}
|
||||
|
||||
impl AvHwDevCtx {
|
||||
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
|
||||
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
|
||||
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
// SAFETY: device_cstr is a valid C string for the duration of the call;
|
||||
// p is a valid out-pointer that FFmpeg initializes on success.
|
||||
let ret = unsafe {
|
||||
ffi::av_hwdevice_ctx_create(
|
||||
&mut p,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
device_cstr.as_ptr(),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to create VAAPI device context from {}: {}",
|
||||
drm_device.display(),
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
Ok(Self { ptr: p })
|
||||
}
|
||||
|
||||
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
|
||||
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
|
||||
unsafe { ffi::av_buffer_ref(self.ptr) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvHwDevCtx {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AvHwFrameCtx {
|
||||
ptr: *mut ffi::AVBufferRef,
|
||||
}
|
||||
|
||||
// SAFETY: AVBufferRef's refcount is atomic (see AvHwDevCtx). The underlying
|
||||
// AVHWFramesContext allocates from an AVBufferPool, whose get/put operations
|
||||
// are atomic and thread-safe. av_hwframe_get_buffer and av_hwframe_transfer_data
|
||||
// are safe to call concurrently on distinct AVFrames. Cloned refs are typically
|
||||
// attached to AVCodecContext.hw_frames_ctx and accessed by FFmpeg-internal codec
|
||||
// threads; this is the designed usage. The &mut self on Rust methods is not the
|
||||
// basis for soundness.
|
||||
unsafe impl Send for AvHwFrameCtx {}
|
||||
|
||||
impl AvHwFrameCtx {
|
||||
fn new_inner(hw_dev: &AvHwDevCtx, w: u32, h: u32, sw_fmt: ff::format::Pixel) -> Result<Self> {
|
||||
// SAFETY: hw_dev is a live AVHWDeviceContext; FFmpeg returns either a valid
|
||||
// frames context ref or null (checked below).
|
||||
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
|
||||
if p.is_null() {
|
||||
bail!("av_hwframe_ctx_alloc returned null");
|
||||
}
|
||||
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
|
||||
// Its .data field points to an AVHWFramesContext that we must configure.
|
||||
unsafe {
|
||||
let fc = (*p).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ff::format::Pixel::VAAPI.into();
|
||||
(*fc).sw_format = sw_fmt.into();
|
||||
(*fc).width = w as i32;
|
||||
(*fc).height = h as i32;
|
||||
(*fc).initial_pool_size = 4;
|
||||
}
|
||||
// SAFETY: p is a valid AVHWFramesContext ref configured above and not yet
|
||||
// transferred or freed.
|
||||
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
|
||||
if ret < 0 {
|
||||
// SAFETY: p is valid but init failed; clean up.
|
||||
unsafe { ffi::av_buffer_unref(&mut p) };
|
||||
bail!("av_hwframe_ctx_init failed: {}", ff_err(ret));
|
||||
}
|
||||
Ok(Self { ptr: p })
|
||||
}
|
||||
|
||||
pub fn for_capture(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
w: u32,
|
||||
h: u32,
|
||||
sw_fmt: ff::format::Pixel,
|
||||
) -> Result<Self> {
|
||||
Self::new_inner(hw_dev, w, h, sw_fmt)
|
||||
}
|
||||
|
||||
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.ptr
|
||||
}
|
||||
|
||||
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
|
||||
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
|
||||
unsafe { ffi::av_buffer_ref(self.ptr) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvHwFrameCtx {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
use std::mem;
|
||||
// AsRawFd is required by `frame.fd.as_raw_fd()` below but rustc emits a false
|
||||
// "unused_imports" warning because OwnedFd also has an inherent `as_raw_fd`.
|
||||
// E0599 if removed -> must stay; warning is a known rustc quirk.
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::raw::c_void;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use crate::cap_portal::PwDmaBufFrame;
|
||||
|
||||
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
|
||||
|
||||
/// Test whether `drm_device` can import the PipeWire DMA-BUF frame via VAAPI.
|
||||
pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<()> {
|
||||
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
|
||||
let frames =
|
||||
AvHwFrameCtx::for_capture(&hw_dev, frame.width, frame.height, ff::format::Pixel::BGRA)?;
|
||||
|
||||
// SAFETY: frames is a live VAAPI frames context; frame carries valid DMA-BUF metadata.
|
||||
unsafe { import_dma_buf_to_vaapi(frames.as_ptr(), frame) }?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import a DMA-BUF into a VAAPI hardware frame via zero-copy `av_hwframe_map`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Imports a DMA-BUF frame into a VAAPI hardware frame pool for GPU-side processing.
|
||||
///
|
||||
/// Takes the negotiated format/geometry from `frame` (a `PwDmaBufFrame` from
|
||||
/// PipeWire capture) plus the target `frames_ctx` (VAAPI frame pool from
|
||||
/// `AvHwFrameCtx`) and returns an `ff::frame::Video` whose data[3] points to
|
||||
/// the hardware frame.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `frames_ctx` must point to an initialized AVHWCramesContext for VAAPI
|
||||
/// - `frame.fd` must be a valid DMA-BUF file descriptor
|
||||
pub unsafe fn import_dma_buf_to_vaapi(
|
||||
frames_ctx: *mut ffi::AVBufferRef,
|
||||
frame: &PwDmaBufFrame,
|
||||
) -> Result<ff::frame::Video> {
|
||||
let duped_fd = libc::dup(frame.fd.as_raw_fd());
|
||||
if duped_fd < 0 {
|
||||
bail!("dup(fd) failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut desc: ffi::AVDRMFrameDescriptor = mem::zeroed();
|
||||
desc.nb_objects = 1;
|
||||
desc.objects[0].fd = duped_fd;
|
||||
desc.objects[0].size = (frame.height as usize) * (frame.stride as usize);
|
||||
desc.objects[0].format_modifier = frame.modifier;
|
||||
desc.nb_layers = 1;
|
||||
desc.layers[0].format = frame.format;
|
||||
desc.layers[0].nb_planes = 1;
|
||||
desc.layers[0].planes[0].object_index = 0;
|
||||
desc.layers[0].planes[0].offset = frame.offset as isize;
|
||||
desc.layers[0].planes[0].pitch = frame.stride as isize;
|
||||
|
||||
let desc_box = Box::new(desc);
|
||||
let desc_ptr = Box::into_raw(desc_box);
|
||||
|
||||
let buf_ref = ffi::av_buffer_create(
|
||||
desc_ptr as *mut u8,
|
||||
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
|
||||
Some(cleanup_drm_descriptor),
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
);
|
||||
if buf_ref.is_null() {
|
||||
let desc_box = Box::from_raw(desc_ptr);
|
||||
libc::close(desc_box.objects[0].fd);
|
||||
bail!("av_buffer_create returned null for DRM descriptor");
|
||||
}
|
||||
|
||||
let mut src = ff::frame::Video::empty();
|
||||
{
|
||||
let sp = src.as_mut_ptr();
|
||||
(*sp).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||
(*sp).width = frame.width as i32;
|
||||
(*sp).height = frame.height as i32;
|
||||
(*sp).data[0] = (*buf_ref).data;
|
||||
(*sp).buf[0] = buf_ref;
|
||||
}
|
||||
|
||||
let mut dst = ff::frame::Video::empty();
|
||||
// SAFETY: frames_ctx is guaranteed by this unsafe function's contract to be a
|
||||
// valid initialized VAAPI frames context; we set format/hw_frames_ctx on a
|
||||
// freshly allocated dst frame.
|
||||
unsafe {
|
||||
let dp = dst.as_mut_ptr();
|
||||
(*dp).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
|
||||
(*dp).hw_frames_ctx = ffi::av_buffer_ref(frames_ctx);
|
||||
if (*dp).hw_frames_ctx.is_null() {
|
||||
bail!("av_buffer_ref(frames_ctx) returned null");
|
||||
}
|
||||
}
|
||||
// SAFETY: src and dst are initialized AVFrames; dst has a valid hw_frames_ctx
|
||||
// ref and av_hwframe_map fills dst from src.
|
||||
let ret = unsafe {
|
||||
ffi::av_hwframe_map(
|
||||
dst.as_mut_ptr(),
|
||||
src.as_ptr(),
|
||||
ffi::AV_HWFRAME_MAP_READ as i32,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!("av_hwframe_map failed: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8) {
|
||||
let desc = data as *mut ffi::AVDRMFrameDescriptor;
|
||||
if !desc.is_null() && (*desc).nb_objects > 0 && (*desc).objects[0].fd >= 0 {
|
||||
libc::close((*desc).objects[0].fd);
|
||||
}
|
||||
let _ = Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
use std::mem;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
use ffmpeg_next::packet::Mut as _;
|
||||
|
||||
use super::encode_output::{self, FrameOutput, PacketOutput};
|
||||
use super::hash::hash_sampled_y_plane;
|
||||
use super::{
|
||||
ff_err, BitrateCommand, CpuNv12Frame, EncodeOutcome, ResolutionChange, SwEncodeTiming,
|
||||
};
|
||||
|
||||
pub struct SwEncEncode {
|
||||
pub(super) sws_ctx: *mut ffi::SwsContext,
|
||||
pub(super) enc_video: ff::codec::encoder::video::Video,
|
||||
pub(super) output: Option<FrameOutput>,
|
||||
pub(super) yuv_frame: *mut ffi::AVFrame,
|
||||
pub(super) last_frame_hash: u64,
|
||||
pub(super) frame_count: u64,
|
||||
pub(super) starting_timestamp: Option<i64>,
|
||||
pub(super) frames_written: bool,
|
||||
pub(super) webrtc_disconnected: bool,
|
||||
pub(super) webrtc_paused: Option<Arc<AtomicBool>>,
|
||||
pub(super) bitrate_rx: crossbeam_channel::Receiver<BitrateCommand>,
|
||||
pub(super) resolution_rx: crossbeam_channel::Receiver<ResolutionChange>,
|
||||
pub(super) enc_width: u32,
|
||||
pub(super) enc_height: u32,
|
||||
pub(super) fps: u32,
|
||||
pub(super) bitrate: u64,
|
||||
pub(super) gop_size: u32,
|
||||
/// Set true when WebRTC requests a keyframe. Forces the next frame to
|
||||
/// `AV_PICTURE_TYPE_I` and bypasses the dedup hash check. Cleared only
|
||||
/// after `avcodec_send_frame` accepts the forced frame.
|
||||
pub(super) force_keyframe_pending: bool,
|
||||
/// Last per-frame timing snapshot. Reset to `Default` at the start of
|
||||
/// every `encode_cpu_frame` call (even on early returns) so stale values
|
||||
/// from a previous frame can never leak out.
|
||||
pub(super) last_timing: SwEncodeTiming,
|
||||
/// Capture time of the frame currently being encoded. Saved from the
|
||||
/// input `CpuNv12Frame` so `drain_encoder` can propagate it into the
|
||||
/// emitted `EncodedH264Frame` for the frame_age stat (issue #20).
|
||||
pub(super) last_capture_time: Option<Instant>,
|
||||
}
|
||||
|
||||
/// WebRTC media clock frequency in Hz. Matches RTP clock for video (RFC 3551).
|
||||
/// Used as encoder time_base denominator for WebRTC mode (1/90000) so that
|
||||
/// PTS values directly become RTP timestamps with microsecond precision.
|
||||
/// MP4 mode keeps 1/fps time_base for file output simplicity.
|
||||
pub const WEBRTC_RTP_CLOCK_HZ: i128 = 90_000;
|
||||
|
||||
// SAFETY: SwEncEncode is moved to a single encode thread and accessed only there
|
||||
// via &mut self. SwsContext, AVFrame, and AVCodecContext are NOT thread-safe for
|
||||
// concurrent access but are Send-sound under single-thread exclusive use, which
|
||||
// the encode worker invariant provides. crossbeam Receiver and Arc<AtomicBool>
|
||||
// are Send by design.
|
||||
unsafe impl Send for SwEncEncode {}
|
||||
|
||||
impl SwEncEncode {
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
// SAFETY: Sending a null frame flushes the opened software encoder;
|
||||
// no frame data is dereferenced. enc_video is exclusively borrowed via &mut self.
|
||||
unsafe {
|
||||
let ret = ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
|
||||
if ret < 0 && ret != ffi::AVERROR_EOF {
|
||||
bail!("software encoder flush send failed: {}", ff_err(ret));
|
||||
}
|
||||
}
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
let _ = self.drain_encoder(start_ts)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn take_timing(&mut self) -> SwEncodeTiming {
|
||||
mem::take(&mut self.last_timing)
|
||||
}
|
||||
|
||||
pub fn encode_cpu_frame(&mut self, frame: &CpuNv12Frame) -> Result<EncodeOutcome> {
|
||||
self.last_timing = SwEncodeTiming::default();
|
||||
// Save capture_time so drain_encoder can propagate it into the
|
||||
// EncodedH264Frame emitted via the WebRTC channel (issue #20).
|
||||
self.last_capture_time = Some(frame.capture_time);
|
||||
|
||||
if self.webrtc_disconnected {
|
||||
return Ok(EncodeOutcome::SkippedDisconnected);
|
||||
}
|
||||
|
||||
// Must drain before the stride check: the import thread emits
|
||||
// ResolutionChange before the new (smaller-stride) frame arrives.
|
||||
while let Ok(cmd) = self.bitrate_rx.try_recv() {
|
||||
match cmd {
|
||||
BitrateCommand::UpdateBitrate { target_bps } => {
|
||||
// #23 defensive guardrail: clamp to reasonable max even if policy layer
|
||||
// is bypassed. 50 Mbps is a hard ceiling; primary cap is enforced in
|
||||
// state_portal.rs webrtc_thread_loop via --max-bitrate flag.
|
||||
const ENCODER_BITRATE_HARD_CAP: u64 = 50_000_000;
|
||||
let target_bps = target_bps.min(ENCODER_BITRATE_HARD_CAP);
|
||||
tracing::info!(target_bps, "updating encoder bitrate from BWE feedback");
|
||||
self.bitrate = target_bps;
|
||||
// SAFETY: enc_video is an opened AVCodecContext exclusively owned by &mut self.
|
||||
unsafe {
|
||||
let ctx = self.enc_video.as_mut_ptr();
|
||||
(*ctx).bit_rate = target_bps as i64;
|
||||
}
|
||||
}
|
||||
BitrateCommand::UpdateResolution { .. } => {}
|
||||
BitrateCommand::ForceKeyframe => {
|
||||
self.force_keyframe_pending = true;
|
||||
tracing::debug!("encode thread: ForceKeyframe requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let force_this_frame = self.force_keyframe_pending;
|
||||
|
||||
while let Ok(change) = self.resolution_rx.try_recv() {
|
||||
self.recreate_encoder(change.width, change.height)?;
|
||||
}
|
||||
|
||||
if frame.y_stride < self.enc_width as usize || frame.uv_stride < self.enc_width as usize {
|
||||
bail!("CPU NV12 frame stride is smaller than encoder width");
|
||||
}
|
||||
if let Some(ref paused) = self.webrtc_paused {
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
return Ok(EncodeOutcome::SkippedPaused);
|
||||
}
|
||||
}
|
||||
|
||||
let width = self.enc_width as usize;
|
||||
let height = self.enc_height as usize;
|
||||
let required_y_len = frame.y_stride * height.saturating_sub(1) + width;
|
||||
if frame.y_data.len() < required_y_len {
|
||||
bail!("CPU NV12 frame Y plane is smaller than encoder dimensions");
|
||||
}
|
||||
|
||||
let frame_index = self.frame_count;
|
||||
self.frame_count = self.frame_count.saturating_add(1);
|
||||
let current_hash = hash_sampled_y_plane(&frame.y_data, width, height, frame.y_stride);
|
||||
let force_gop_frame =
|
||||
self.gop_size > 0 && frame_index.is_multiple_of(u64::from(self.gop_size));
|
||||
if frame_index > 0
|
||||
&& !force_gop_frame
|
||||
&& !force_this_frame
|
||||
&& current_hash == self.last_frame_hash
|
||||
{
|
||||
tracing::debug!(frame_index, "skipping duplicate frame");
|
||||
self.last_frame_hash = current_hash;
|
||||
return Ok(EncodeOutcome::SkippedDuplicate);
|
||||
}
|
||||
self.last_frame_hash = current_hash;
|
||||
|
||||
let sws_start = Instant::now();
|
||||
// SAFETY: yuv_frame is an owned reusable YUV420P frame at the same dimensions as sw_nv12;
|
||||
// sws_ctx was created for NV12 -> YUV420P with no resize, so sws_scale only converts format.
|
||||
unsafe {
|
||||
let ret = ffi::av_frame_make_writable(self.yuv_frame);
|
||||
if ret < 0 {
|
||||
bail!("av_frame_make_writable failed: {}", ff_err(ret));
|
||||
}
|
||||
let src_slices = [
|
||||
frame.y_data.as_ptr(),
|
||||
frame.uv_data.as_ptr(),
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
];
|
||||
let src_strides = [frame.y_stride as i32, frame.uv_stride as i32, 0, 0];
|
||||
let scaled = ffi::sws_scale(
|
||||
self.sws_ctx,
|
||||
src_slices.as_ptr(),
|
||||
src_strides.as_ptr(),
|
||||
0,
|
||||
self.enc_height as i32,
|
||||
(*self.yuv_frame).data.as_ptr() as *mut *mut u8,
|
||||
(*self.yuv_frame).linesize.as_ptr(),
|
||||
);
|
||||
if scaled < 0 {
|
||||
bail!("sws_scale failed for software encoder: {scaled}");
|
||||
}
|
||||
}
|
||||
let sws_us = sws_start.elapsed().as_micros() as u64;
|
||||
|
||||
let pts = frame.pts;
|
||||
if self.starting_timestamp.is_none() {
|
||||
self.starting_timestamp = Some(pts);
|
||||
}
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
|
||||
let enc_start = Instant::now();
|
||||
// SAFETY: yuv_frame is initialized, writable, and matches the opened encoder format.
|
||||
// pict_type is reset every frame: the AVFrame is reused, so without resetting to NONE
|
||||
// a previously-forced I-type would leak into subsequent P-frames. With forced-idr=1
|
||||
// set on the encoder, AV_PICTURE_TYPE_I produces a true IDR NALU.
|
||||
unsafe {
|
||||
(*self.yuv_frame).pts = pts;
|
||||
(*self.yuv_frame).pict_type = if force_this_frame {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let ret = ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), self.yuv_frame);
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"avcodec_send_frame failed for software encoder: {}",
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if force_this_frame {
|
||||
self.force_keyframe_pending = false;
|
||||
}
|
||||
|
||||
let output_bytes = self.drain_encoder(start_ts)?;
|
||||
let encode_us = enc_start.elapsed().as_micros() as u64;
|
||||
|
||||
self.last_timing = SwEncodeTiming {
|
||||
sws_us,
|
||||
encode_us,
|
||||
output_bytes,
|
||||
};
|
||||
|
||||
Ok(EncodeOutcome::Encoded)
|
||||
}
|
||||
|
||||
pub(super) fn write_trailer_if_needed(&mut self) -> Result<()> {
|
||||
if self.frames_written {
|
||||
if let Some(FrameOutput::Muxer(ref mut octx)) = self.output {
|
||||
octx.write_trailer()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_encoder(&mut self, start_ts: i64) -> Result<usize> {
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// SAFETY: enc_video is an open encoder; pkt is writable packet storage.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
bail!("avcodec_receive_packet failed: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// Count encoded bytes produced before the Muxer/Channel match to
|
||||
// avoid branch duplication and handle multi-packet drain correctly.
|
||||
// SAFETY: pkt was just filled by a successful avcodec_receive_packet;
|
||||
// the size field is valid and initialized.
|
||||
let pkt_size = unsafe { (*pkt.as_mut_ptr()).size };
|
||||
if pkt_size > 0 {
|
||||
total_bytes += pkt_size as usize;
|
||||
}
|
||||
|
||||
match self.output {
|
||||
Some(FrameOutput::Muxer(ref mut octx)) => {
|
||||
encode_output::write_muxer_packet(
|
||||
&mut pkt,
|
||||
octx,
|
||||
self.enc_video.time_base(),
|
||||
start_ts,
|
||||
)?;
|
||||
self.frames_written = true;
|
||||
}
|
||||
Some(FrameOutput::Channel(ref tx))
|
||||
if encode_output::send_channel_packet(
|
||||
&mut pkt,
|
||||
tx,
|
||||
start_ts,
|
||||
self.last_capture_time.unwrap_or_else(Instant::now),
|
||||
)? == PacketOutput::Disconnected =>
|
||||
{
|
||||
self.webrtc_disconnected = true;
|
||||
break;
|
||||
}
|
||||
Some(FrameOutput::Channel(_)) => {}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(total_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwEncEncode {
|
||||
fn drop(&mut self) {
|
||||
if !self.sws_ctx.is_null() {
|
||||
// SAFETY: sws_ctx is owned by this state and was returned by sws_getContext.
|
||||
unsafe { ffi::sws_freeContext(self.sws_ctx) };
|
||||
self.sws_ctx = ptr::null_mut();
|
||||
}
|
||||
if !self.yuv_frame.is_null() {
|
||||
// SAFETY: yuv_frame is owned by this state and was allocated by av_frame_alloc.
|
||||
unsafe { ffi::av_frame_free(&mut self.yuv_frame) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::encode_output::FrameOutput;
|
||||
use super::software::{
|
||||
alloc_yuv420p_frame, create_nv12_to_yuv420p_sws, create_software_h264_encoder,
|
||||
create_software_h264_muxer,
|
||||
};
|
||||
use super::{BitrateCommand, EncodedH264Frame, ResolutionChange, SwEncEncode, SwEncodeTiming};
|
||||
|
||||
impl SwEncEncode {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn new_muxer(
|
||||
output_path: &Path,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<Self> {
|
||||
let sws_ctx = create_nv12_to_yuv420p_sws(enc_width, enc_height)?;
|
||||
let (enc_video, octx) =
|
||||
create_software_h264_muxer(output_path, enc_width, enc_height, fps, bitrate, gop_size)?;
|
||||
let yuv_frame = alloc_yuv420p_frame(enc_width, enc_height)?;
|
||||
let (dummy_tx, bitrate_rx) = crossbeam_channel::bounded(1);
|
||||
drop(dummy_tx);
|
||||
let (dummy_resolution_tx, resolution_rx) = crossbeam_channel::bounded(1);
|
||||
drop(dummy_resolution_tx);
|
||||
|
||||
Ok(Self {
|
||||
sws_ctx,
|
||||
enc_video,
|
||||
output: Some(FrameOutput::Muxer(octx)),
|
||||
yuv_frame,
|
||||
last_frame_hash: 0,
|
||||
frame_count: 0,
|
||||
starting_timestamp: None,
|
||||
frames_written: false,
|
||||
webrtc_disconnected: false,
|
||||
webrtc_paused: None,
|
||||
bitrate_rx,
|
||||
resolution_rx,
|
||||
enc_width,
|
||||
enc_height,
|
||||
fps,
|
||||
bitrate,
|
||||
gop_size,
|
||||
force_keyframe_pending: false,
|
||||
last_timing: SwEncodeTiming::default(),
|
||||
last_capture_time: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_webrtc(
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
tx: crossbeam_channel::Sender<EncodedH264Frame>,
|
||||
webrtc_paused: Arc<AtomicBool>,
|
||||
bitrate_rx: crossbeam_channel::Receiver<BitrateCommand>,
|
||||
resolution_rx: crossbeam_channel::Receiver<ResolutionChange>,
|
||||
) -> Result<Self> {
|
||||
let sws_ctx = create_nv12_to_yuv420p_sws(enc_width, enc_height)?;
|
||||
let enc_video =
|
||||
create_software_h264_encoder(enc_width, enc_height, fps, bitrate, gop_size)?;
|
||||
let yuv_frame = alloc_yuv420p_frame(enc_width, enc_height)?;
|
||||
|
||||
Ok(Self {
|
||||
sws_ctx,
|
||||
enc_video,
|
||||
output: Some(FrameOutput::Channel(tx)),
|
||||
yuv_frame,
|
||||
last_frame_hash: 0,
|
||||
frame_count: 0,
|
||||
starting_timestamp: None,
|
||||
frames_written: false,
|
||||
webrtc_disconnected: false,
|
||||
webrtc_paused: Some(webrtc_paused),
|
||||
bitrate_rx,
|
||||
resolution_rx,
|
||||
enc_width,
|
||||
enc_height,
|
||||
fps,
|
||||
bitrate,
|
||||
gop_size,
|
||||
force_keyframe_pending: false,
|
||||
last_timing: SwEncodeTiming::default(),
|
||||
last_capture_time: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn recreate_encoder(&mut self, width: u32, height: u32) -> Result<()> {
|
||||
if width == self.enc_width && height == self.enc_height {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
from = format_args!("{}x{}", self.enc_width, self.enc_height),
|
||||
to = format_args!("{}x{}", width, height),
|
||||
"recreating WebRTC software encoder for resolution change"
|
||||
);
|
||||
|
||||
if !self.sws_ctx.is_null() {
|
||||
// SAFETY: sws_ctx is owned exclusively by self and will be replaced below.
|
||||
unsafe { ffi::sws_freeContext(self.sws_ctx) };
|
||||
self.sws_ctx = ptr::null_mut();
|
||||
}
|
||||
if !self.yuv_frame.is_null() {
|
||||
// SAFETY: yuv_frame is owned exclusively by self and will be replaced below.
|
||||
unsafe { ffi::av_frame_free(&mut self.yuv_frame) };
|
||||
}
|
||||
|
||||
self.sws_ctx = create_nv12_to_yuv420p_sws(width, height)?;
|
||||
self.enc_video =
|
||||
create_software_h264_encoder(width, height, self.fps, self.bitrate, self.gop_size)?;
|
||||
self.yuv_frame = alloc_yuv420p_frame(width, height)?;
|
||||
self.enc_width = width;
|
||||
self.enc_height = height;
|
||||
self.last_frame_hash = 0;
|
||||
self.frame_count = 0;
|
||||
self.force_keyframe_pending = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::packet::Mut as _;
|
||||
|
||||
use super::EncodedH264Frame;
|
||||
|
||||
pub enum FrameOutput {
|
||||
Muxer(ff::format::context::Output),
|
||||
Channel(crossbeam_channel::Sender<EncodedH264Frame>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum PacketOutput {
|
||||
Written,
|
||||
Dropped,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
pub(super) fn write_muxer_packet(
|
||||
pkt: &mut ff::Packet,
|
||||
octx: &mut ff::format::context::Output,
|
||||
enc_tb: ff::Rational,
|
||||
start_ts: i64,
|
||||
) -> Result<()> {
|
||||
// SAFETY: muxer output was created with stream 0 during setup; streams
|
||||
// is non-null and stream 0 remains owned by the format context.
|
||||
let stream_tb = unsafe {
|
||||
let fmt = *octx.as_ptr();
|
||||
if fmt.nb_streams == 0 || fmt.streams.is_null() {
|
||||
bail!("no streams in output context");
|
||||
}
|
||||
let st = *fmt.streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
pkt.rescale_ts(enc_tb, stream_tb);
|
||||
|
||||
if let Some(pts) = pkt.pts() {
|
||||
pkt.set_pts(Some(pts - start_ts));
|
||||
}
|
||||
if let Some(dts) = pkt.dts() {
|
||||
pkt.set_dts(Some(dts - start_ts));
|
||||
}
|
||||
|
||||
pkt.set_stream(0);
|
||||
pkt.write_interleaved(octx)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write packet: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn send_channel_packet(
|
||||
pkt: &mut ff::Packet,
|
||||
tx: &crossbeam_channel::Sender<EncodedH264Frame>,
|
||||
start_ts: i64,
|
||||
capture_time: Instant,
|
||||
) -> Result<PacketOutput> {
|
||||
// SAFETY: pkt is a valid AVPacket just filled by avcodec_receive_packet;
|
||||
// this copies fields for read-only inspection before pkt is dropped.
|
||||
let raw = unsafe { *pkt.as_mut_ptr() };
|
||||
if raw.size <= 0 || raw.data.is_null() {
|
||||
return Ok(PacketOutput::Dropped);
|
||||
}
|
||||
|
||||
// SAFETY: `pkt` is a valid AVPacket just filled by a successful
|
||||
// `avcodec_receive_packet` call. We checked `size > 0` and `data` is
|
||||
// non-null, so `data` points to `size` initialized bytes owned by the
|
||||
// packet. `u8` has alignment 1, and the slice is copied into a Vec before
|
||||
// the packet is unreffed.
|
||||
let data = unsafe { std::slice::from_raw_parts(raw.data, raw.size as usize) };
|
||||
let pts_ticks = match pkt.pts() {
|
||||
Some(p) => p - start_ts,
|
||||
None => {
|
||||
tracing::warn!("encoder produced packet without PTS, dropping");
|
||||
return Ok(PacketOutput::Dropped);
|
||||
}
|
||||
};
|
||||
|
||||
match tx.try_send(EncodedH264Frame {
|
||||
data: data.to_vec(),
|
||||
pts_ticks,
|
||||
capture_time,
|
||||
}) {
|
||||
Ok(()) => Ok(PacketOutput::Written),
|
||||
Err(crossbeam_channel::TrySendError::Full(frame)) => {
|
||||
tracing::warn!(
|
||||
"WebRTC channel full, dropping frame: {} bytes lost",
|
||||
frame.data.len()
|
||||
);
|
||||
Ok(PacketOutput::Dropped)
|
||||
}
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(frame)) => {
|
||||
tracing::warn!(
|
||||
"WebRTC channel disconnected: {} bytes lost",
|
||||
frame.data.len()
|
||||
);
|
||||
Ok(PacketOutput::Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use crate::transform::Transform;
|
||||
|
||||
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_swenc_filter_graph(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_rgb: &AvHwFrameCtx,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
) -> Result<ff::filter::Graph> {
|
||||
let mut graph = ff::filter::Graph::new();
|
||||
let buffersrc =
|
||||
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
|
||||
let buffersink = ff::filter::find("buffersink")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
|
||||
let scale_vaapi = ff::filter::find("scale_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
|
||||
|
||||
// FFmpeg 8.0+ rejects VAAPI pix_fmt in buffer args before hw_frames_ctx is attached.
|
||||
// Use a SW placeholder, then override format/hw_frames_ctx with av_buffersrc_parameters_set.
|
||||
let args = format!(
|
||||
"video_size={}x{}:pix_fmt=bgra:time_base=1/{fps}:pixel_aspect=1/1",
|
||||
width, height,
|
||||
);
|
||||
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
|
||||
|
||||
// SAFETY: av_buffersrc_parameters_alloc returns newly allocated parameters
|
||||
// or null, which is checked below.
|
||||
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
|
||||
if par.is_null() {
|
||||
bail!("av_buffersrc_parameters_alloc returned null");
|
||||
}
|
||||
// SAFETY: par and src_ctx are valid; frames_rgb.ref_clone returns an owned hw_frames_ctx ref
|
||||
// that buffersrc consumes on successful parameter set.
|
||||
unsafe {
|
||||
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
|
||||
(*par).width = width as i32;
|
||||
(*par).height = height as i32;
|
||||
(*par).time_base = ffi::AVRational {
|
||||
num: 1,
|
||||
den: fps as i32,
|
||||
};
|
||||
(*par).hw_frames_ctx = frames_rgb.ref_clone();
|
||||
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if ret < 0 {
|
||||
bail!("av_buffersrc_parameters_set failed: {}", ff_err(ret));
|
||||
}
|
||||
}
|
||||
|
||||
let mut scale_ctx = graph.add(
|
||||
&scale_vaapi,
|
||||
"scale",
|
||||
&format!("{enc_width}:{enc_height}:format=nv12"),
|
||||
)?;
|
||||
// SAFETY: scale_vaapi keeps a ref-counted device context while the graph is alive.
|
||||
unsafe {
|
||||
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||
}
|
||||
|
||||
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
|
||||
src_ctx.link(0, &mut scale_ctx, 0);
|
||||
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||
graph
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("software GPU filter graph validation failed: {e}"))?;
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
pub(super) fn build_filter_graph(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_rgb: &AvHwFrameCtx,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
transform: Transform,
|
||||
) -> Result<ff::filter::Graph> {
|
||||
let mut graph = ff::filter::Graph::new();
|
||||
|
||||
let buffersrc =
|
||||
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
|
||||
let buffersink = ff::filter::find("buffersink")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
|
||||
let scale_vaapi = ff::filter::find("scale_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
|
||||
|
||||
// buffersrc - use AVBufferSrcParameters to set hw_frames_ctx properly.
|
||||
let args = format!(
|
||||
"video_size={}x{}:pix_fmt={}:time_base=1/{fps}:pixel_aspect=1/1",
|
||||
width,
|
||||
height,
|
||||
Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32,
|
||||
);
|
||||
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
|
||||
|
||||
// SAFETY: av_buffersrc_parameters_alloc allocates params for the buffersrc.
|
||||
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
|
||||
if par.is_null() {
|
||||
bail!("av_buffersrc_parameters_alloc returned null");
|
||||
}
|
||||
// SAFETY: Set hw_frames_ctx on the buffersrc parameters, then apply.
|
||||
unsafe {
|
||||
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
|
||||
(*par).width = width as i32;
|
||||
(*par).height = height as i32;
|
||||
(*par).time_base = ffi::AVRational {
|
||||
num: 1,
|
||||
den: fps as i32,
|
||||
};
|
||||
(*par).hw_frames_ctx = frames_rgb.ref_clone();
|
||||
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if ret < 0 {
|
||||
bail!("av_buffersrc_parameters_set failed: {}", ff_err(ret));
|
||||
}
|
||||
}
|
||||
|
||||
// scale_vaapi: hardware scaling and colourspace conversion (keeps original dimensions).
|
||||
let mut scale_ctx = graph.add(
|
||||
&scale_vaapi,
|
||||
"scale",
|
||||
&format!("{width}:{height}:format=nv12"),
|
||||
)?;
|
||||
// SAFETY: scale_vaapi needs hw_device_ctx for VAAPI device access.
|
||||
unsafe {
|
||||
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||
}
|
||||
|
||||
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
|
||||
src_ctx.link(0, &mut scale_ctx, 0);
|
||||
|
||||
match transform {
|
||||
Transform::Normal => {
|
||||
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||
}
|
||||
other => {
|
||||
let transpose = ff::filter::find("transpose_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'transpose_vaapi' not found"))?;
|
||||
let dir_val = match other {
|
||||
Transform::Normal90 => "1",
|
||||
Transform::Normal180 => "4",
|
||||
Transform::Normal270 => "2",
|
||||
Transform::Flipped => "5",
|
||||
Transform::Flipped90 => "3",
|
||||
Transform::Flipped180 => "6",
|
||||
Transform::Flipped270 => "0",
|
||||
Transform::Normal => unreachable!(),
|
||||
};
|
||||
let mut trans_ctx = graph.add(&transpose, "transpose", &format!("dir={dir_val}"))?;
|
||||
// SAFETY: trans_ctx is a live transpose_vaapi filter context;
|
||||
// scale_vaapi/transpose_vaapi keep a ref-counted device context.
|
||||
unsafe {
|
||||
(*trans_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||
}
|
||||
scale_ctx.link(0, &mut trans_ctx, 0);
|
||||
trans_ctx.link(0, &mut sink_ctx, 0);
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("Filter graph validation failed: {e}"))?;
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
use ffmpeg_next::packet::Mut as _;
|
||||
|
||||
use crate::transform::Transform;
|
||||
|
||||
use super::{
|
||||
ff_err, filter::build_filter_graph, hardware_encoder, hardware_muxer, AvHwDevCtx, AvHwFrameCtx,
|
||||
EncodeStages,
|
||||
};
|
||||
|
||||
pub struct EncState {
|
||||
enc_video: ff::codec::encoder::video::Video,
|
||||
frames_rgb: AvHwFrameCtx,
|
||||
video_filter: ff::filter::Graph,
|
||||
// Root AVHWDeviceContext, kept for ownership. Each consumer (encoder,
|
||||
// filter graph, frames ctx) already holds its own ref_clone(); this
|
||||
// field is never read after `new()` but must outlive those clones.
|
||||
#[allow(dead_code)]
|
||||
hw_device_ctx: AvHwDevCtx,
|
||||
octx: ff::format::context::Output,
|
||||
starting_timestamp: Option<i64>,
|
||||
frames_written: bool,
|
||||
}
|
||||
|
||||
// SAFETY: EncState is moved to exactly one encode worker thread and all Rust
|
||||
// methods take &mut self, so there is no concurrent *Rust-side* access.
|
||||
// FFmpeg-internal codec threads may touch hw_device_ctx / frames_rgb through
|
||||
// the encoder context if frame/slice threading is enabled; this is sound
|
||||
// because AVHWDeviceContext and AVHWFramesContext are designed for such
|
||||
// sharing (atomic refcounts, thread-safe pool, libva VADisplay thread safety).
|
||||
// This impl only lifts auto-Send inference through raw pointers inside the
|
||||
// ffmpeg-next wrappers; it does not introduce new sharing. Do NOT add fields
|
||||
// that create shared mutable state across threads without re-auditing.
|
||||
unsafe impl Send for EncState {}
|
||||
|
||||
impl EncState {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
drm_device: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
fps: u32,
|
||||
transform: Transform,
|
||||
existing_hw_ctx: Option<AvHwDevCtx>,
|
||||
) -> Result<Self> {
|
||||
tracing::info!(
|
||||
"EncState::new: {width}x{height} enc={enc_width}x{enc_height} transform={transform:?}"
|
||||
);
|
||||
let hw_device_ctx = match existing_hw_ctx {
|
||||
Some(ctx) => ctx,
|
||||
None => AvHwDevCtx::new_vaapi(drm_device)?,
|
||||
};
|
||||
|
||||
let frames_rgb =
|
||||
AvHwFrameCtx::for_capture(&hw_device_ctx, width, height, ff::format::Pixel::BGRA)?;
|
||||
|
||||
let mut video_filter =
|
||||
build_filter_graph(&hw_device_ctx, &frames_rgb, width, height, fps, transform)?;
|
||||
|
||||
let mut sink_ctx = video_filter
|
||||
.get("out")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
|
||||
// SAFETY: sink_ctx is a live buffersink; the returned hw_frames_ctx is
|
||||
// borrowed, so av_buffer_ref creates an owned reference.
|
||||
let sink_hw_frames = unsafe {
|
||||
let raw = ffi::av_buffersink_get_hw_frames_ctx(sink_ctx.as_mut_ptr());
|
||||
if raw.is_null() {
|
||||
bail!("buffersink has no hw_frames_ctx - filter graph may not be configured for hardware output");
|
||||
}
|
||||
let hw_ref = ffi::av_buffer_ref(raw);
|
||||
if hw_ref.is_null() {
|
||||
bail!("av_buffer_ref failed for buffersink hw_frames_ctx - likely out of memory");
|
||||
}
|
||||
hw_ref
|
||||
};
|
||||
|
||||
// SAFETY: sink_hw_frames is an owned AVBufferRef to an AVHWFramesContext
|
||||
// returned by the validated filter graph.
|
||||
unsafe {
|
||||
let fc = (*sink_hw_frames).data as *mut ffi::AVHWFramesContext;
|
||||
let actual_w = (*fc).width as u32;
|
||||
let actual_h = (*fc).height as u32;
|
||||
if actual_w != enc_width || actual_h != enc_height {
|
||||
tracing::warn!(
|
||||
"Filter output dimensions {actual_w}x{actual_h} differ from encoder dimensions {enc_width}x{enc_height}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let enc_video = hardware_encoder::open_h264_vaapi_encoder(
|
||||
&hw_device_ctx,
|
||||
sink_hw_frames,
|
||||
enc_width,
|
||||
enc_height,
|
||||
bitrate,
|
||||
gop_size,
|
||||
fps,
|
||||
)?;
|
||||
|
||||
let octx = hardware_muxer::create_output_context(output_path, &enc_video)?;
|
||||
|
||||
Ok(Self {
|
||||
enc_video,
|
||||
frames_rgb,
|
||||
video_filter,
|
||||
hw_device_ctx,
|
||||
octx,
|
||||
starting_timestamp: None,
|
||||
frames_written: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
|
||||
&self.frames_rgb
|
||||
}
|
||||
|
||||
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
|
||||
let mut filter_src_ctx = self
|
||||
.video_filter
|
||||
.get("in")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
let mut filter_sink_ctx = self
|
||||
.video_filter
|
||||
.get("out")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
|
||||
// Scale stage = filter graph push + pull (scale_vaapi for resolution
|
||||
// change + format conversion to NV12). Timed separately from the
|
||||
// actual avcodec_send_frame so the per-stage stats answer "where is
|
||||
// latency?" honestly. See Oracle audit 2026-06-28 step 4.
|
||||
let scale_start = Instant::now();
|
||||
filter_src
|
||||
.add(hw_frame)
|
||||
.map_err(|e| anyhow::anyhow!("Filter source add failed: {e}"))?;
|
||||
|
||||
let mut scale_us = 0u64;
|
||||
let mut encode_us = 0u64;
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {
|
||||
if filtered.pts().is_none() {
|
||||
filtered.set_pts(hw_frame.pts());
|
||||
}
|
||||
}
|
||||
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
|
||||
Err(e) => bail!("Filter sink get frame failed: {e}"),
|
||||
}
|
||||
// First successful pull closes the scale-stage measurement; later
|
||||
// pulls (rare extras) roll into encode time.
|
||||
if scale_us == 0 {
|
||||
scale_us = scale_start.elapsed().as_micros() as u64;
|
||||
}
|
||||
|
||||
let pts = filtered.pts().unwrap_or(0);
|
||||
if self.starting_timestamp.is_none() {
|
||||
self.starting_timestamp = Some(pts);
|
||||
}
|
||||
let start_ts = self.starting_timestamp.unwrap();
|
||||
|
||||
let encode_start = Instant::now();
|
||||
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!("avcodec_send_frame failed: {}", ff_err(ret));
|
||||
}
|
||||
self.drain_encoder(start_ts)?;
|
||||
encode_us += encode_start.elapsed().as_micros() as u64;
|
||||
}
|
||||
|
||||
Ok(EncodeStages {
|
||||
scale_us,
|
||||
// HW path stays on GPU - no CPU readback, transfer is N/A.
|
||||
transfer_us: 0,
|
||||
encode_us,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
let mut filter_src_ctx = self
|
||||
.video_filter
|
||||
.get("in")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
if let Err(e) = filter_src.flush() {
|
||||
tracing::debug!("filter source flush error: {e}");
|
||||
}
|
||||
|
||||
let mut filter_sink_ctx = self
|
||||
.video_filter
|
||||
.get("out")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
// SAFETY: filtered is a valid VAAPI frame drained from the
|
||||
// filter graph; enc_video is an opened encoder.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!("avcodec_send_frame failed during flush: {}", ff_err(ret));
|
||||
}
|
||||
self.drain_encoder(start_ts)?;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: Sending null frame signals end of stream to encoder.
|
||||
unsafe {
|
||||
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), std::ptr::null());
|
||||
}
|
||||
|
||||
let start_ts = self.starting_timestamp.unwrap_or(0);
|
||||
self.drain_encoder(start_ts)?;
|
||||
|
||||
if self.frames_written {
|
||||
self.octx
|
||||
.write_trailer()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_encoder(&mut self, start_ts: i64) -> Result<()> {
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// SAFETY: avcodec_receive_packet retrieves an encoded packet.
|
||||
let ret = unsafe {
|
||||
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
|
||||
};
|
||||
if ret < 0 {
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
bail!("avcodec_receive_packet failed: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
let enc_tb = self.enc_video.time_base();
|
||||
// SAFETY: octx was created with stream 0 during muxer setup; streams
|
||||
// is non-null and stream 0 remains owned by the format context.
|
||||
let stream_tb = unsafe {
|
||||
let fmt = *self.octx.as_ptr();
|
||||
if fmt.nb_streams == 0 || fmt.streams.is_null() {
|
||||
bail!("no streams in output context");
|
||||
}
|
||||
let st = *fmt.streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
pkt.rescale_ts(enc_tb, stream_tb);
|
||||
|
||||
if let Some(pts) = pkt.pts() {
|
||||
pkt.set_pts(Some(pts - start_ts));
|
||||
}
|
||||
if let Some(dts) = pkt.dts() {
|
||||
pkt.set_dts(Some(dts - start_ts));
|
||||
}
|
||||
|
||||
pkt.set_stream(0);
|
||||
pkt.write_interleaved(&mut self.octx)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write packet: {e}"))?;
|
||||
|
||||
self.frames_written = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
|
||||
use anyhow::Result;
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::{ff_err, AvHwDevCtx};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn open_h264_vaapi_encoder(
|
||||
hw_device_ctx: &AvHwDevCtx,
|
||||
sink_hw_frames: *mut ffi::AVBufferRef,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
fps: u32,
|
||||
) -> Result<ff::codec::encoder::video::Video> {
|
||||
let codec = ff::encoder::find_by_name("h264_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("h264_vaapi encoder not found"))?;
|
||||
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
enc.set_width(enc_width);
|
||||
enc.set_height(enc_height);
|
||||
enc.set_format(ff::format::Pixel::VAAPI);
|
||||
enc.set_bit_rate(bitrate as usize);
|
||||
enc.set_gop(gop_size);
|
||||
enc.set_time_base(ff::Rational::new(1, fps as i32));
|
||||
enc.set_max_b_frames(0);
|
||||
|
||||
// VBV rate limiting: caps IDR burst size for WebRTC. Without this a 4K
|
||||
// scene change can produce a 256KB keyframe that overflows the UDP send
|
||||
// buffer. bufsize=bitrate/4 is about 250ms of video at the target bitrate.
|
||||
// SAFETY: enc.as_mut_ptr() is a valid AVCodecContext for the not-yet-opened
|
||||
// encoder. rc_max_rate and rc_buffer_size are plain integer fields; assigning
|
||||
// i64/i32 values is a simple struct-field write on a properly aligned pointer.
|
||||
unsafe {
|
||||
let ctx_ptr = enc.as_mut_ptr();
|
||||
(*ctx_ptr).rc_max_rate = bitrate as i64;
|
||||
(*ctx_ptr).rc_buffer_size = (bitrate / 4) as i32;
|
||||
}
|
||||
|
||||
// SAFETY: AV_CODEC_FLAG_GLOBAL_HEADER must be set BEFORE opening the encoder.
|
||||
// It triggers SPS/PPS extradata generation needed by the muxer for
|
||||
// Annex B to AVCC conversion.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
|
||||
}
|
||||
// SAFETY: Assign hw device and frames ctx to the encoder.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).hw_device_ctx = hw_device_ctx.ref_clone();
|
||||
(*enc.as_mut_ptr()).hw_frames_ctx = sink_hw_frames;
|
||||
}
|
||||
|
||||
// SAFETY: Set repeat_pps=1 on the encoder so PPS is inserted in every encoded frame.
|
||||
// This ensures decoders can start decoding from any frame (important for WebRTC).
|
||||
// repeat_pps is only available in FFmpeg 7.0+ (not in 6.x). On older
|
||||
// FFmpeg, IDR frames carry SPS by default; PPS repetition depends on the driver.
|
||||
// For SPS repetition: IDR frames carry SPS by default, controlled by gop_size/idr_interval.
|
||||
{
|
||||
let key = CString::new("repeat_pps").unwrap();
|
||||
let val = CString::new("1").unwrap();
|
||||
// SAFETY: enc is a valid AVCodecContext for the not-yet-opened encoder;
|
||||
// priv_data is the codec's private options struct. key/val are NUL-terminated
|
||||
// CString that live across the call. av_opt_set is FFmpeg's standard
|
||||
// option-setter. Failure is non-fatal (returns < 0 on older FFmpeg).
|
||||
let ret = unsafe {
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0)
|
||||
};
|
||||
if ret < 0 {
|
||||
tracing::warn!("av_opt_set repeat_pps failed ({}), likely FFmpeg < 7.0; continuing without per-frame PPS", ff_err(ret));
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc
|
||||
.open()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}"))?;
|
||||
Ok(opened.0)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::ff_err;
|
||||
|
||||
pub(super) fn create_output_context(
|
||||
output_path: &Path,
|
||||
enc_video: &ff::codec::encoder::video::Video,
|
||||
) -> Result<ff::format::context::Output> {
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
|
||||
// SAFETY: avformat_alloc_output_context2 creates format context from
|
||||
// the file extension. Does NOT open the file.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: enc_video is a valid AVCodecContext pointer; codec_id is a plain
|
||||
// i32 enum discriminant read from it. fmt_ctx_ptr is a valid AVFormatContext
|
||||
// allocated above; oformat is a const pointer field read from it.
|
||||
// avformat_query_codec checks codec+format compatibility; both pointers are
|
||||
// valid and FF_COMPLIANCE_NORMAL is a constant. All three reads happen in one
|
||||
// block so a single SAFETY rationale covers them.
|
||||
let compat = unsafe {
|
||||
let codec_id = (*enc_video.as_ptr()).codec_id;
|
||||
let oformat = (*fmt_ctx_ptr).oformat;
|
||||
ffi::avformat_query_codec(oformat, codec_id, ffi::FF_COMPLIANCE_NORMAL)
|
||||
};
|
||||
if compat < 0 {
|
||||
bail!("H.264 codec not supported by output container format");
|
||||
}
|
||||
|
||||
// SAFETY: avformat_new_stream creates a new stream in the format context.
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create new stream in output context");
|
||||
}
|
||||
|
||||
// SAFETY: avcodec_parameters_from_context copies encoder params + extradata.
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to copy encoder parameters to stream: {}",
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: Copy encoder time_base to stream.
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// SAFETY: avio_open opens the output file for writing.
|
||||
let ret = unsafe {
|
||||
ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to open output file '{}': {}",
|
||||
output_path.display(),
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: avformat_write_header writes the container header.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write output header: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: We created fmt_ctx_ptr above and it's valid.
|
||||
Ok(unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) })
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
|
||||
const FNV1A_PRIME: u64 = 0x100000001b3;
|
||||
const Y_PLANE_HASH_ROW_STEP: usize = 8;
|
||||
|
||||
pub(super) fn hash_sampled_y_plane(
|
||||
y_data: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
stride: usize,
|
||||
) -> u64 {
|
||||
let mut hash = FNV1A_OFFSET_BASIS;
|
||||
|
||||
for row in (0..height).step_by(Y_PLANE_HASH_ROW_STEP) {
|
||||
let row_start = row * stride;
|
||||
let row_end = row_start + width;
|
||||
for &byte in &y_data[row_start..row_end] {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(FNV1A_PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
hash
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::slice;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::filter::build_swenc_filter_graph;
|
||||
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
|
||||
use super::{BitrateCommand, CpuNv12Frame, ResolutionChange};
|
||||
|
||||
pub struct SwEncImport {
|
||||
hw_dev: AvHwDevCtx,
|
||||
frames_rgb: AvHwFrameCtx,
|
||||
filter_graph: ff::filter::Graph,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
resolution_rx: Option<crossbeam_channel::Receiver<BitrateCommand>>,
|
||||
encoder_resolution_tx: Option<crossbeam_channel::Sender<ResolutionChange>>,
|
||||
}
|
||||
|
||||
impl SwEncImport {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
drm_device: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
) -> Result<Self> {
|
||||
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
|
||||
let frames_rgb =
|
||||
AvHwFrameCtx::for_capture(&hw_dev, width, height, ff::format::Pixel::BGRA)?;
|
||||
let filter_graph = build_swenc_filter_graph(
|
||||
&hw_dev,
|
||||
&frames_rgb,
|
||||
width,
|
||||
height,
|
||||
enc_width,
|
||||
enc_height,
|
||||
fps,
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
hw_dev,
|
||||
frames_rgb,
|
||||
filter_graph,
|
||||
width,
|
||||
height,
|
||||
enc_width,
|
||||
enc_height,
|
||||
fps,
|
||||
resolution_rx: None,
|
||||
encoder_resolution_tx: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_resolution_control(
|
||||
drm_device: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
resolution_rx: crossbeam_channel::Receiver<BitrateCommand>,
|
||||
encoder_resolution_tx: crossbeam_channel::Sender<ResolutionChange>,
|
||||
) -> Result<Self> {
|
||||
let mut this = Self::new(drm_device, width, height, enc_width, enc_height, fps)?;
|
||||
this.resolution_rx = Some(resolution_rx);
|
||||
this.encoder_resolution_tx = Some(encoder_resolution_tx);
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
|
||||
let _ = self.hw_dev.as_ptr();
|
||||
&self.frames_rgb
|
||||
}
|
||||
|
||||
pub fn import_and_scale(&mut self, hw_frame: &ff::frame::Video) -> Result<CpuNv12Frame> {
|
||||
self.poll_resolution_commands()?;
|
||||
|
||||
let mut filter_src_ctx = self
|
||||
.filter_graph
|
||||
.get("in")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
let mut filter_sink_ctx = self
|
||||
.filter_graph
|
||||
.get("out")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
|
||||
filter_src
|
||||
.add(hw_frame)
|
||||
.map_err(|e| anyhow::anyhow!("software pipeline filter source add failed: {e}"))?;
|
||||
|
||||
let mut first = None;
|
||||
let mut extra_count = 0usize;
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {
|
||||
if filtered.pts().is_none() {
|
||||
filtered.set_pts(hw_frame.pts());
|
||||
}
|
||||
let cpu_frame = self.transfer_filtered_to_cpu(&filtered)?;
|
||||
if first.is_none() {
|
||||
first = Some(cpu_frame);
|
||||
} else {
|
||||
extra_count += 1;
|
||||
}
|
||||
}
|
||||
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
|
||||
Err(e) => bail!("software pipeline filter sink get frame failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
if extra_count > 0 {
|
||||
tracing::warn!(
|
||||
"software import filter produced {extra_count} extra frame(s); dropping extras"
|
||||
);
|
||||
}
|
||||
|
||||
first.ok_or_else(|| anyhow::anyhow!("software pipeline produced no scaled frame"))
|
||||
}
|
||||
|
||||
pub fn flush_import(&mut self) -> Result<Vec<CpuNv12Frame>> {
|
||||
let mut filter_src_ctx = self
|
||||
.filter_graph
|
||||
.get("in")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
|
||||
let mut filter_src = filter_src_ctx.source();
|
||||
if let Err(e) = filter_src.flush() {
|
||||
tracing::debug!("filter source flush error: {e}");
|
||||
}
|
||||
|
||||
let mut filter_sink_ctx = self
|
||||
.filter_graph
|
||||
.get("out")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
|
||||
let mut filter_sink = filter_sink_ctx.sink();
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => frames.push(self.transfer_filtered_to_cpu(&filtered)?),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn poll_resolution_commands(&mut self) -> Result<()> {
|
||||
let Some(rx) = self.resolution_rx.as_ref().cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut requested = None;
|
||||
while let Ok(cmd) = rx.try_recv() {
|
||||
match cmd {
|
||||
BitrateCommand::UpdateResolution { width, height } => {
|
||||
requested = Some((width & !1, height & !1));
|
||||
}
|
||||
BitrateCommand::UpdateBitrate { .. } => {}
|
||||
BitrateCommand::ForceKeyframe => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((width, height)) = requested else {
|
||||
return Ok(());
|
||||
};
|
||||
if width == self.enc_width && height == self.enc_height {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
from = format_args!("{}x{}", self.enc_width, self.enc_height),
|
||||
to = format_args!("{}x{}", width, height),
|
||||
"rebuilding software import filter graph for resolution change"
|
||||
);
|
||||
let _ = self.flush_import();
|
||||
self.filter_graph = build_swenc_filter_graph(
|
||||
&self.hw_dev,
|
||||
&self.frames_rgb,
|
||||
self.width,
|
||||
self.height,
|
||||
width,
|
||||
height,
|
||||
self.fps,
|
||||
)?;
|
||||
self.enc_width = width;
|
||||
self.enc_height = height;
|
||||
|
||||
if let Some(tx) = &self.encoder_resolution_tx {
|
||||
tx.send(ResolutionChange { width, height })
|
||||
.map_err(|_| anyhow::anyhow!("encoder resolution channel disconnected"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transfer_filtered_to_cpu(&self, filtered: &ff::frame::Video) -> Result<CpuNv12Frame> {
|
||||
// SAFETY: av_frame_alloc returns a newly allocated AVFrame or null,
|
||||
// which is checked below.
|
||||
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
|
||||
if sw_nv12.is_null() {
|
||||
bail!("av_frame_alloc failed for NV12 transfer frame");
|
||||
}
|
||||
|
||||
// SAFETY: sw_nv12 is an allocated destination frame; filtered is a valid VAAPI NV12
|
||||
// surface produced by scale_vaapi at encoder dimensions.
|
||||
let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_nv12, filtered.as_ptr(), 0) };
|
||||
if transfer_ret < 0 {
|
||||
// SAFETY: sw_nv12 was allocated above and has not been freed yet.
|
||||
unsafe { ffi::av_frame_free(&mut sw_nv12) };
|
||||
bail!(
|
||||
"av_hwframe_transfer_data failed for GPU-downscaled frame: {}",
|
||||
ff_err(transfer_ret)
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: sw_nv12 was filled by av_hwframe_transfer_data. NV12 planes 0 and 1 are
|
||||
// initialized for enc_width x enc_height; linesize values define each row's byte span.
|
||||
let frame = unsafe {
|
||||
let y_ptr = (*sw_nv12).data[0];
|
||||
let uv_ptr = (*sw_nv12).data[1];
|
||||
if y_ptr.is_null() || uv_ptr.is_null() {
|
||||
ffi::av_frame_free(&mut sw_nv12);
|
||||
bail!("NV12 transfer frame missing Y/UV plane data");
|
||||
}
|
||||
let y_stride = (*sw_nv12).linesize[0] as usize;
|
||||
let uv_stride = (*sw_nv12).linesize[1] as usize;
|
||||
if (*sw_nv12).width != self.enc_width as i32
|
||||
|| (*sw_nv12).height != self.enc_height as i32
|
||||
{
|
||||
ffi::av_frame_free(&mut sw_nv12);
|
||||
bail!("NV12 transfer frame has unexpected dimensions");
|
||||
}
|
||||
let y_len = y_stride * self.enc_height as usize;
|
||||
let uv_len = uv_stride * (self.enc_height as usize / 2);
|
||||
let y_data = slice::from_raw_parts(y_ptr, y_len).to_vec();
|
||||
let uv_data = slice::from_raw_parts(uv_ptr, uv_len).to_vec();
|
||||
let pts = filtered.pts().unwrap_or(0);
|
||||
ffi::av_frame_free(&mut sw_nv12);
|
||||
CpuNv12Frame {
|
||||
y_data,
|
||||
uv_data,
|
||||
y_stride,
|
||||
uv_stride,
|
||||
pts,
|
||||
capture_time: std::time::Instant::now(),
|
||||
}
|
||||
};
|
||||
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
//! FFmpeg / VAAPI encoder wrappers.
|
||||
//!
|
||||
//! ## `Send` justification convention
|
||||
//!
|
||||
//! Several types in this module (`AvHwDevCtx`, `AvHwFrameCtx`, `EncState`,
|
||||
//! `SwEncState`, `SwEncEncode`) carry raw FFmpeg pointers and therefore need
|
||||
//! an explicit `unsafe impl Send`. The justification is always at the C-API
|
||||
//! level, never at the Rust-borrow level:
|
||||
//!
|
||||
//! - `AVBufferRef` refcounts are `atomic_uint` (`libavutil/buffer.c`), so
|
||||
//! `av_buffer_ref` / `av_buffer_unref` are safe to call concurrently.
|
||||
//! - `AVHWDeviceContext` (VAAPI `VADisplay`) is designed by FFmpeg/libva to
|
||||
//! be shared across codec and filter contexts, including FFmpeg-internal
|
||||
//! codec worker threads.
|
||||
//! - `AVHWFramesContext` allocates from an `AVBufferPool` whose get/put are
|
||||
//! atomic; `av_hwframe_get_buffer` is safe to call concurrently on
|
||||
//! distinct frames.
|
||||
//! - `SwsContext`, `AVFilterGraph`, `AVFrame`, `AVCodecContext` are NOT
|
||||
//! thread-safe for concurrent use, but are `Send`-sound under the
|
||||
//! single-thread exclusive access invariant that the encode worker
|
||||
//! enforces.
|
||||
//!
|
||||
//! **Anti-pattern**: justifying `Send` with "`&mut self` ensures exclusive
|
||||
//! access". `Send` is about *moving ownership between threads*, not about
|
||||
//! borrowing. The `&mut self` on Rust methods is API convenience and is not
|
||||
//! the basis for soundness — refs cloned via `ref_clone()` routinely escape
|
||||
//! to other threads / FFmpeg-internal workers, and that is fine because the
|
||||
//! underlying C APIs are designed for it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::transform::{transpose_if_transform_transposed, Transform};
|
||||
|
||||
mod device;
|
||||
mod dmabuf;
|
||||
mod encode;
|
||||
mod encode_init;
|
||||
mod encode_output;
|
||||
mod filter;
|
||||
mod hardware;
|
||||
mod hardware_encoder;
|
||||
mod hardware_muxer;
|
||||
mod hash;
|
||||
mod import;
|
||||
mod software;
|
||||
mod state;
|
||||
mod types;
|
||||
mod util;
|
||||
|
||||
pub use device::{AvHwDevCtx, AvHwFrameCtx};
|
||||
pub use dmabuf::{import_dma_buf_to_vaapi, test_dma_buf_import};
|
||||
pub use encode::{SwEncEncode, WEBRTC_RTP_CLOCK_HZ};
|
||||
#[allow(unused_imports)]
|
||||
pub use encode_output::FrameOutput;
|
||||
pub use hardware::EncState;
|
||||
#[cfg(test)]
|
||||
use hash::hash_sampled_y_plane;
|
||||
pub use import::SwEncImport;
|
||||
pub use state::SwEncState;
|
||||
pub use types::{
|
||||
BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodeStages, EncodedH264Frame, ResolutionChange,
|
||||
SwEncodeTiming,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use util::av_err_to_string;
|
||||
pub(crate) use util::ff_err;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared encoder creation (used by both wlr-screencopy and portal paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a fully configured encoder with VAAPI hardware acceleration.
|
||||
///
|
||||
/// Convenience wrapper around [`EncState::new`] that computes default values
|
||||
/// for `bitrate` and `gop_size` when not provided, and handles encoder dimension
|
||||
/// transposition for rotated/transformed outputs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_encoder(
|
||||
drm_device: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
transform: Transform,
|
||||
bitrate: Option<u64>,
|
||||
gop_size: Option<u32>,
|
||||
existing_hw_ctx: Option<AvHwDevCtx>,
|
||||
) -> Result<EncState> {
|
||||
let (enc_w, enc_h) = transpose_if_transform_transposed(transform, width as i32, height as i32);
|
||||
let actual_bitrate =
|
||||
bitrate.unwrap_or_else(|| 2 * (width as u64) * (height as u64) * (fps as u64) / 100);
|
||||
let actual_gop_size = gop_size.unwrap_or(fps);
|
||||
EncState::new(
|
||||
drm_device,
|
||||
output_path,
|
||||
width,
|
||||
height,
|
||||
enc_w as u32,
|
||||
enc_h as u32,
|
||||
actual_bitrate,
|
||||
actual_gop_size,
|
||||
fps,
|
||||
transform,
|
||||
existing_hw_ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Centralizes the `stride * row` byte-offset pattern used by the Y-plane hash
|
||||
// tests below, so clippy::erasing_op (row == 0) and clippy::identity_op (row == 1)
|
||||
// both pass without sacrificing the row-index intent the tests are written around.
|
||||
fn row_range(row: usize, stride: usize, width: usize) -> std::ops::Range<usize> {
|
||||
let start = stride * row;
|
||||
start..start + width
|
||||
}
|
||||
|
||||
// ── Task 1: VBV x264opts formatting ──
|
||||
|
||||
#[test]
|
||||
fn vbv_x264opts_format() {
|
||||
let bitrate: u64 = 5_000_000;
|
||||
// x264 expects kbit/s and kbit, not bps
|
||||
let vbv_maxrate_kbps = bitrate / 1000;
|
||||
let vbv_bufsize_kbps = (bitrate / 4) / 1000;
|
||||
let opts = format!(
|
||||
"repeat_headers=1:vbv-maxrate={vbv_maxrate_kbps}:vbv-bufsize={vbv_bufsize_kbps}"
|
||||
);
|
||||
assert_eq!(vbv_maxrate_kbps, 5000);
|
||||
assert_eq!(vbv_bufsize_kbps, 1250);
|
||||
assert!(opts.contains("vbv-maxrate=5000"));
|
||||
assert!(opts.contains("vbv-bufsize=1250"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vbv_bufsize_is_quarter_of_maxrate() {
|
||||
for bitrate in [1_000_000, 5_000_000, 10_000_000] {
|
||||
// x264 expects kbit/s and kbit; both scaled by /1000, ratio preserved
|
||||
let maxrate_kbps = bitrate / 1000;
|
||||
let bufsize_kbps = (bitrate / 4) / 1000;
|
||||
assert_eq!(
|
||||
bufsize_kbps * 4,
|
||||
maxrate_kbps,
|
||||
"bufsize should be maxrate/4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task 3: GOP formula ──
|
||||
|
||||
#[test]
|
||||
fn webrtc_gop_formula() {
|
||||
// Formula under test: GOP = max(fps * 2, 20). Hid behind a runtime lambda so
|
||||
// clippy can't constant-fold the assertions into tautologies (which would
|
||||
// silently strip the floor-case coverage for 5fps).
|
||||
fn gop(fps: u32) -> u32 {
|
||||
(fps * 2).max(20)
|
||||
}
|
||||
assert_eq!(gop(15), 30); // 15fps -> 30
|
||||
assert_eq!(gop(30), 60); // 30fps -> 60
|
||||
assert_eq!(gop(60), 120); // 60fps -> 120
|
||||
assert_eq!(gop(5), 20); // 5fps -> 20 (floor)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_level_values() {
|
||||
// Level 4.0 supports up to 1080p@30fps (used for file muxer)
|
||||
assert_eq!(40i32, 40);
|
||||
// Level 4.2 supports up to 1440p@30fps (used for WebRTC low-latency encoder)
|
||||
assert_eq!(42i32, 42);
|
||||
}
|
||||
|
||||
// ── Task 4: Duplicate frame hash detection ──
|
||||
|
||||
#[test]
|
||||
fn hash_sampled_y_plane_first_frame_consistent() {
|
||||
let width = 64;
|
||||
let height = 64;
|
||||
let stride = 64;
|
||||
let y_data = vec![0u8; stride * height];
|
||||
let hash1 = hash_sampled_y_plane(&y_data, width, height, stride);
|
||||
let hash2 = hash_sampled_y_plane(&y_data, width, height, stride);
|
||||
assert_eq!(hash1, hash2, "same input should produce same hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_sampled_y_plane_detects_different_frames() {
|
||||
let width = 64;
|
||||
let height = 64;
|
||||
let stride = 64;
|
||||
let y_data1 = vec![0u8; stride * height];
|
||||
let y_data2 = vec![128u8; stride * height];
|
||||
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
|
||||
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
|
||||
assert_ne!(
|
||||
hash1, hash2,
|
||||
"different frame data should produce different hashes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_sampled_y_plane_samples_every_8th_row() {
|
||||
// Changing a non-sampled row (e.g., row 1) should NOT change the hash
|
||||
let width = 64;
|
||||
let height = 64;
|
||||
let stride = 64;
|
||||
let y_data1 = vec![0u8; stride * height];
|
||||
let mut y_data2 = vec![0u8; stride * height];
|
||||
// Row 1 is NOT sampled (sampling is every 8th row: 0, 8, 16, ...)
|
||||
y_data2[row_range(1, stride, width)].fill(255);
|
||||
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
|
||||
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
|
||||
assert_eq!(
|
||||
hash1, hash2,
|
||||
"non-sampled row change should not affect hash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_sampled_y_plane_sensitive_to_sampled_row() {
|
||||
// Changing a sampled row (row 0) SHOULD change the hash
|
||||
let width = 64;
|
||||
let height = 64;
|
||||
let stride = 64;
|
||||
let y_data1 = vec![0u8; stride * height];
|
||||
let mut y_data2 = vec![0u8; stride * height];
|
||||
// Row 0 IS sampled (every 8th row starting from 0)
|
||||
y_data2[row_range(0, stride, width)].fill(255);
|
||||
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
|
||||
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
|
||||
assert_ne!(
|
||||
hash1, hash2,
|
||||
"sampled row change should produce different hash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_sampled_y_plane_handles_stride_greater_than_width() {
|
||||
// Stride can be larger than width due to alignment; unused padding should not affect hash
|
||||
let width = 32;
|
||||
let height = 16;
|
||||
let stride = 64; // padded stride
|
||||
let y_data1 = vec![0u8; stride * height];
|
||||
let mut y_data2 = vec![0u8; stride * height];
|
||||
// Fill the padding area (columns 32..63) of row 0 with garbage
|
||||
y_data2[width..stride].fill(0xFF);
|
||||
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
|
||||
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
|
||||
assert_eq!(
|
||||
hash1, hash2,
|
||||
"padding bytes beyond width should not affect hash"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use super::ff_err;
|
||||
|
||||
pub(super) fn create_nv12_to_yuv420p_sws(width: u32, height: u32) -> Result<*mut ffi::SwsContext> {
|
||||
// SAFETY: sws_getContext creates an owned scaler context for same-size NV12 -> YUV420P.
|
||||
let ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as i32,
|
||||
height as i32,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
width as i32,
|
||||
height as i32,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
2,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if ctx.is_null() {
|
||||
bail!("Failed to create NV12 -> YUV420P sws_scale context");
|
||||
}
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
pub(super) fn alloc_yuv420p_frame(width: u32, height: u32) -> Result<*mut ffi::AVFrame> {
|
||||
// SAFETY: Allocate an AVFrame, configure format/dimensions, then allocate writable buffers.
|
||||
unsafe {
|
||||
let mut frame = ffi::av_frame_alloc();
|
||||
if frame.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
(*frame).width = width as i32;
|
||||
(*frame).height = height as i32;
|
||||
(*frame).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
|
||||
let ret = ffi::av_frame_get_buffer(frame, 0);
|
||||
if ret < 0 {
|
||||
ffi::av_frame_free(&mut frame);
|
||||
bail!("av_frame_get_buffer failed: {}", ff_err(ret));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_software_h264_muxer(
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<(
|
||||
ff::codec::encoder::video::Video,
|
||||
ff::format::context::Output,
|
||||
)> {
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
|
||||
})?;
|
||||
let codec_name = codec.name().to_string();
|
||||
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
enc.set_bit_rate(bitrate as usize);
|
||||
enc.set_gop(gop_size);
|
||||
enc.set_time_base(ff::Rational::new(1, fps as i32));
|
||||
enc.set_max_b_frames(3);
|
||||
|
||||
// SAFETY: global headers are needed by MP4 and harmless for other common muxers.
|
||||
unsafe {
|
||||
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
|
||||
}
|
||||
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: priv_data and codec context belong to the unopened encoder;
|
||||
// strings live for each av_opt_set call.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("fast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("threads").unwrap();
|
||||
let val = CString::new("6").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
|
||||
// SAFETY: enc is a valid, initialized AVCodecContext from
|
||||
// avcodec_alloc_context3. Setting level is a simple i32 field
|
||||
// assignment on a properly aligned struct.
|
||||
(*enc.as_mut_ptr()).level = 40; // H.264 Level 4.0 (up to 1080p@30)
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc
|
||||
.open()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
|
||||
let enc_video = opened.0;
|
||||
|
||||
let use_null = output_path
|
||||
.to_str()
|
||||
.map(|s| s.contains("null"))
|
||||
.unwrap_or(false);
|
||||
let fmt_name = if use_null {
|
||||
CString::new("null").unwrap()
|
||||
} else {
|
||||
CString::new("").unwrap()
|
||||
};
|
||||
let fmt_name_ptr = if use_null {
|
||||
fmt_name.as_ptr()
|
||||
} else {
|
||||
ptr::null()
|
||||
};
|
||||
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
// SAFETY: fmt_ctx_ptr is initialized by FFmpeg; C strings live across the call.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
fmt_name_ptr,
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is valid; stream and codec parameters are owned by the format context.
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create output stream");
|
||||
}
|
||||
|
||||
// SAFETY: stream_ptr and encoder context are valid; parameters are copied into stream.
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to copy codec parameters to stream: {}", ff_err(ret));
|
||||
}
|
||||
// SAFETY: stream_ptr is valid and writable during muxer setup.
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// SAFETY: open an AVIO only for muxers that require files; null muxer advertises NOFILE.
|
||||
unsafe {
|
||||
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
|
||||
let ret = ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
);
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to open output file '{}': {}",
|
||||
output_path.display(),
|
||||
ff_err(ret)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is fully configured.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write output header: {}", ff_err(ret));
|
||||
}
|
||||
|
||||
// SAFETY: ownership of fmt_ctx_ptr transfers to ffmpeg-next Output wrapper.
|
||||
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
tracing::info!("Using software H.264 encoder: {codec_name}");
|
||||
Ok((enc_video, octx))
|
||||
}
|
||||
|
||||
pub(super) fn create_software_h264_encoder(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<ff::codec::encoder::video::Video> {
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| anyhow::anyhow!("No H.264 software encoder found"))?;
|
||||
let codec_name = codec.name().to_string();
|
||||
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
enc.set_bit_rate(bitrate as usize);
|
||||
enc.set_gop(gop_size);
|
||||
// 90kHz media clock matches RTP directly. Eliminates 1/fps quantization
|
||||
// that previously caused sequential RTP timestamps during 60fps capture,
|
||||
// leading to 2x RTP time inflation and 10s+ browser jitter buffer growth.
|
||||
// See issue #25.
|
||||
enc.set_time_base(ff::Rational::new(1, 90_000));
|
||||
// Explicit framerate is REQUIRED when time_base is not 1/fps, otherwise
|
||||
// libx264 infers wrong fps from the 90kHz time_base and VBV rate control
|
||||
// breaks. Per Oracle review round for #25.
|
||||
enc.set_frame_rate(Some(ff::Rational::new(fps as i32, 1)));
|
||||
enc.set_max_b_frames(0);
|
||||
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: priv_data and codec context belong to the unopened encoder;
|
||||
// each CString lives for the duration of its av_opt_set call.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("veryfast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("tune").unwrap();
|
||||
let val = CString::new("zerolatency").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("threads").unwrap();
|
||||
let val = CString::new("6").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
// High profile via AVCodecContext.profile (not x264opts - x264 rejects it there).
|
||||
// High enables CABAC + 8x8dct automatically.
|
||||
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
|
||||
// SAFETY: enc is a valid, initialized AVCodecContext from
|
||||
// avcodec_alloc_context3. Setting level is a simple i32 field
|
||||
// assignment on a properly aligned struct.
|
||||
(*enc.as_mut_ptr()).level = 42; // H.264 Level 4.2 (up to 1440p@30)
|
||||
// SAFETY: priv_data belongs to the unopened libx264 encoder context.
|
||||
// `forced-idr` is an FFmpeg-level private option (not x264-native),
|
||||
// so it must be set via av_opt_set, NOT via the x264opts string.
|
||||
// With forced-idr=1, setting AV_PICTURE_TYPE_I on an input frame
|
||||
// produces a true IDR NALU with inline SPS/PPS (repeat_headers=1).
|
||||
let key = CString::new("forced-idr").unwrap();
|
||||
let val = CString::new("1").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("x264opts").unwrap();
|
||||
// x264's vbv-maxrate unit is kbit/s and vbv-bufsize is kbit (NOT bps).
|
||||
// Confirmed via x264 source encoder/ratecontrol.c:658-661 which multiplies
|
||||
// these values by 1000 to convert kbit -> bit at use site. Passing bps makes
|
||||
// VBV effectively unbounded (5.5 Mbps becomes 5.5 Gbps, clipped to 2 Gbps).
|
||||
// See https://github.com/mirror/x264/blob/c24e06c2e184345ceb33eb20a15d1024d9fd3497/encoder/ratecontrol.c#L658-L661
|
||||
let vbv_maxrate_kbps = bitrate / 1000;
|
||||
let vbv_bufsize_kbps = (bitrate / 4) / 1000;
|
||||
let val = CString::new(format!(
|
||||
"repeat_headers=1:vbv-maxrate={vbv_maxrate_kbps}:vbv-bufsize={vbv_bufsize_kbps}"
|
||||
))
|
||||
.unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc
|
||||
.open()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
|
||||
tracing::info!("WebRTC encoder: {codec_name} {width}x{height} @ {fps}fps {bitrate}bps (profile High, preset veryfast)");
|
||||
Ok(opened.0)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use ffmpeg_next as ff;
|
||||
|
||||
use super::{AvHwFrameCtx, EncodeStages, EncodedH264Frame, SwEncEncode, SwEncImport};
|
||||
|
||||
pub struct SwEncState {
|
||||
import: SwEncImport,
|
||||
encode: SwEncEncode,
|
||||
}
|
||||
|
||||
// SAFETY: SwEncState is moved to a single encode thread and accessed only there.
|
||||
// All FFmpeg handles (SwsContext, AVFrame, AVCodecContext) inside SwEncImport /
|
||||
// SwEncEncode are non-thread-safe but Send-sound under exclusive access.
|
||||
// Existing sync callers move it across threads only with external serialization.
|
||||
unsafe impl Send for SwEncState {}
|
||||
|
||||
impl SwEncState {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
drm_device: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
) -> Result<Self> {
|
||||
tracing::info!(
|
||||
"SwEncState::new: GPU downscale {width}x{height} BGRA -> {enc_width}x{enc_height} NV12, software H.264"
|
||||
);
|
||||
let import = SwEncImport::new(drm_device, width, height, enc_width, enc_height, fps)?;
|
||||
let encode =
|
||||
SwEncEncode::new_muxer(output_path, enc_width, enc_height, fps, bitrate, gop_size)?;
|
||||
Ok(Self { import, encode })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_webrtc(
|
||||
drm_device: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
gop_size: u32,
|
||||
tx: crossbeam_channel::Sender<EncodedH264Frame>,
|
||||
webrtc_paused: Arc<AtomicBool>,
|
||||
) -> Result<Self> {
|
||||
tracing::info!(
|
||||
"SwEncState::new_webrtc: GPU downscale {width}x{height} BGRA -> {enc_width}x{enc_height} NV12, software H.264 -> WebRTC"
|
||||
);
|
||||
let import = SwEncImport::new(drm_device, width, height, enc_width, enc_height, fps)?;
|
||||
let (dummy_tx, bitrate_rx) = crossbeam_channel::bounded(1);
|
||||
drop(dummy_tx);
|
||||
let (dummy_resolution_tx, resolution_rx) = crossbeam_channel::bounded(1);
|
||||
drop(dummy_resolution_tx);
|
||||
let encode = SwEncEncode::new_webrtc(
|
||||
enc_width,
|
||||
enc_height,
|
||||
fps,
|
||||
bitrate,
|
||||
gop_size,
|
||||
tx,
|
||||
webrtc_paused,
|
||||
bitrate_rx,
|
||||
resolution_rx,
|
||||
)?;
|
||||
Ok(Self { import, encode })
|
||||
}
|
||||
|
||||
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
|
||||
self.import.frames_rgb()
|
||||
}
|
||||
|
||||
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
|
||||
// SW path: import_and_scale bundles GPU filter graph (scale) + GPU->CPU
|
||||
// readback (transfer) into one call. Timing them separately requires
|
||||
// extending import_and_scale's signature; for now both roll into
|
||||
// scale_us and transfer_us stays 0 with this comment as the honest
|
||||
// statement. Oracle audit 2026-06-28 step 4.
|
||||
let scale_start = Instant::now();
|
||||
let cpu_frame = self.import.import_and_scale(hw_frame)?;
|
||||
let scale_us = scale_start.elapsed().as_micros() as u64;
|
||||
|
||||
let encode_start = Instant::now();
|
||||
self.encode.encode_cpu_frame(&cpu_frame)?;
|
||||
let encode_us = encode_start.elapsed().as_micros() as u64;
|
||||
|
||||
Ok(EncodeStages {
|
||||
scale_us,
|
||||
transfer_us: 0,
|
||||
encode_us,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
for frame in self.import.flush_import()? {
|
||||
self.encode.encode_cpu_frame(&frame)?;
|
||||
}
|
||||
self.encode.flush()?;
|
||||
self.encode.write_trailer_if_needed()
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/// Commands sent from the WebRTC thread to the SW encoder when the
|
||||
/// bandwidth estimate changes significantly.
|
||||
pub enum BitrateCommand {
|
||||
UpdateBitrate {
|
||||
target_bps: u64,
|
||||
},
|
||||
UpdateResolution {
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
/// Force the next encoded frame to be an IDR. Sent by the WebRTC thread
|
||||
/// in response to str0m `Event::KeyframeRequest` or a resolution change.
|
||||
ForceKeyframe,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ResolutionChange {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Per-frame timing snapshot for the software encoder, consumed by the stats
|
||||
/// thread. `sws_us` measures NV12->YUV420P conversion, `encode_us` measures
|
||||
/// `avcodec_send_frame` + drain, and `output_bytes` counts encoded bytes
|
||||
/// produced by libavcodec (even if downstream delivery later drops them).
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub struct SwEncodeTiming {
|
||||
pub sws_us: u64,
|
||||
pub encode_us: u64,
|
||||
pub output_bytes: usize,
|
||||
}
|
||||
|
||||
/// Outcome of a single `encode_cpu_frame` call. Used by the encode thread
|
||||
/// to decide whether to report timing stats (only real encodes tick encoded_fps).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EncodeOutcome {
|
||||
/// Frame was actually encoded and produced output bytes.
|
||||
Encoded,
|
||||
/// Frame was dropped because WebRTC is paused (no client connected).
|
||||
SkippedPaused,
|
||||
/// Frame was dropped because the encoder is in disconnected state.
|
||||
SkippedDisconnected,
|
||||
/// Frame was dropped because its Y-plane hash matched the previous frame.
|
||||
SkippedDuplicate,
|
||||
}
|
||||
|
||||
/// Per-stage timing breakdown for one encode cycle on the hardware path.
|
||||
/// Returned by `EncState::encode_frame` so callers can fold the numbers
|
||||
/// into `crate::stats::FrameTimings`. `transfer_us` is always 0 on the HW
|
||||
/// path because the frame stays on the GPU; the SW path's struct (if added
|
||||
/// later) would carry a real readback measurement.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct EncodeStages {
|
||||
pub scale_us: u64,
|
||||
pub transfer_us: u64,
|
||||
pub encode_us: u64,
|
||||
}
|
||||
|
||||
/// Encoded H.264 frame with timing metadata for WebRTC output.
|
||||
///
|
||||
/// MP4 file output (FrameOutput::Muxer) does NOT use this - it writes via
|
||||
/// avformat which preserves PTS internally. WebRTC output (FrameOutput::Channel)
|
||||
/// requires explicit PTS propagation so RTP timestamps reflect real capture time.
|
||||
/// Without this, WebRTC clients' jitter buffers grow to seconds under
|
||||
/// damage-driven variable frame rate. See issue #24.
|
||||
#[derive(Debug)]
|
||||
pub struct EncodedH264Frame {
|
||||
/// H.264 NAL byte stream (Annex B or AVCC depending on encoder configuration)
|
||||
pub data: Vec<u8>,
|
||||
/// PTS in encoder time_base units (1/fps seconds), normalized so first frame = 0.
|
||||
/// Derived from real capture time, NOT frame counter.
|
||||
pub pts_ticks: i64,
|
||||
/// Wall-clock capture time, propagated from CpuNv12Frame for frame_age stat.
|
||||
pub capture_time: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Owned CPU NV12 frame data for cross-thread transfer.
|
||||
/// Produced by main thread (VAAPI import + GPU scale + transfer), consumed by encode thread.
|
||||
pub struct CpuNv12Frame {
|
||||
pub y_data: Vec<u8>,
|
||||
pub uv_data: Vec<u8>,
|
||||
pub y_stride: usize,
|
||||
pub uv_stride: usize,
|
||||
pub pts: i64,
|
||||
/// Wall-clock time when this frame was captured (PipeWire delivery).
|
||||
/// Used for frame_age stat: time from capture to WebRTC send.
|
||||
pub capture_time: std::time::Instant,
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
/// Convert an FFmpeg error code to a human-readable string.
|
||||
pub fn av_err_to_string(err: i32) -> String {
|
||||
let mut buf = vec![0u8; 128];
|
||||
// SAFETY: buf points to 128 writable bytes and lives for the duration of
|
||||
// av_strerror.
|
||||
unsafe {
|
||||
ffi::av_strerror(err, buf.as_mut_ptr() as *mut i8, buf.len());
|
||||
}
|
||||
String::from_utf8_lossy(&buf)
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Format an FFmpeg error code with both numeric value and description.
|
||||
/// Example output: "error -22 (Invalid argument)"
|
||||
pub(crate) fn ff_err(ret: i32) -> String {
|
||||
format!("error {ret} ({})", av_err_to_string(ret))
|
||||
}
|
||||
+170
-17
@@ -1,3 +1,45 @@
|
||||
//! # 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;
|
||||
@@ -26,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,
|
||||
@@ -47,6 +96,10 @@ impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
|
||||
/// 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}. \
|
||||
@@ -56,20 +109,54 @@ fn log_portal_unresponsive(operation: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 通过 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 {
|
||||
// `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)
|
||||
@@ -78,17 +165,29 @@ fn check_portal_available() -> bool {
|
||||
})
|
||||
.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"))
|
||||
@@ -111,34 +210,56 @@ fn check_portal_available() -> bool {
|
||||
}
|
||||
};
|
||||
|
||||
// 查询 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
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::info!("Portal ScreenCast version query failed: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log_portal_unresponsive("querying ScreenCast version");
|
||||
false
|
||||
}
|
||||
};
|
||||
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
|
||||
}
|
||||
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()
|
||||
@@ -169,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");
|
||||
@@ -180,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);
|
||||
}
|
||||
};
|
||||
@@ -191,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");
|
||||
@@ -215,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()),
|
||||
@@ -240,11 +385,17 @@ mod tests {
|
||||
}
|
||||
|
||||
// 测试:显式指定 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);
|
||||
}
|
||||
|
||||
@@ -263,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,76 +0,0 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
use ffmpeg_next::packet::Mut;
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
pub fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
|
||||
// Drain-and-wait loop that mirrors production's repeated-poll semantics
|
||||
// (state_portal.rs::poll_and_encode driven by main.rs's outer loop), but with
|
||||
// a single bounded 10s total deadline appropriate for a bench tool. Unlike a
|
||||
// single 10s blocking wait, this loop actually iterates: each turn drains ALL
|
||||
// pending control events (the ctrl channel is bounded to 8 — a single
|
||||
// if-let would silently miss backlog) and then waits a short slice for a
|
||||
// frame, so StreamEnded/Error arriving mid-wait are observed within ~200ms.
|
||||
const TOTAL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const WAIT_SLICE: std::time::Duration = std::time::Duration::from_millis(200);
|
||||
let deadline = Instant::now() + TOTAL_DEADLINE;
|
||||
loop {
|
||||
while 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}"),
|
||||
}
|
||||
}
|
||||
let remaining = match deadline.checked_duration_since(Instant::now()) {
|
||||
Some(r) if !r.is_zero() => r,
|
||||
_ => bail!("Timeout waiting for first frame (10s)"),
|
||||
};
|
||||
let slice = remaining.min(WAIT_SLICE);
|
||||
match cap.frame_receiver().recv_timeout(slice) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
||||
bail!("PipeWire frame channel disconnected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain_encoder(
|
||||
enc_video: &mut ff::encoder::video::Video,
|
||||
octx: &mut ff::format::context::Output,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
let mut pkt = ff::Packet::empty();
|
||||
// SAFETY: enc_video is the opened encoder; pkt is an empty Packet whose
|
||||
// inner AVPacket pointer is valid. avcodec_receive_packet fills pkt with
|
||||
// the next encoded packet, or returns EAGAIN/EOF when drained.
|
||||
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
|
||||
if ret < 0 {
|
||||
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
|
||||
break;
|
||||
}
|
||||
eprintln!("avcodec_receive_packet failed: {ret}");
|
||||
break;
|
||||
}
|
||||
|
||||
let enc_tb = enc_video.time_base();
|
||||
// SAFETY: octx.as_ptr() is a valid AVFormatContext; streams is a NULL-terminated
|
||||
// array of AVStream*. We index [0] which exists because we created exactly one
|
||||
// stream in setup. Reading time_base is a plain AVRational field access.
|
||||
let stream_tb = unsafe {
|
||||
let streams = (*octx.as_ptr()).streams;
|
||||
let st = *streams.add(0);
|
||||
ff::Rational::from((*st).time_base)
|
||||
};
|
||||
pkt.rescale_ts(enc_tb, stream_tb);
|
||||
pkt.set_stream(0);
|
||||
pkt.write_interleaved(octx)
|
||||
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
//! 软件编码流水线性能基准(独立二进制 `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;
|
||||
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",
|
||||
about = "Software encoding pipeline benchmark"
|
||||
)]
|
||||
struct BenchArgs {
|
||||
#[arg(short, long)]
|
||||
output: String,
|
||||
|
||||
#[arg(long, default_value_t = 120)]
|
||||
frames: u32,
|
||||
|
||||
#[arg(long, default_value_t = 2560)]
|
||||
enc_width: u32,
|
||||
|
||||
#[arg(long, default_value_t = 1440)]
|
||||
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>,
|
||||
scale_us: Vec<u64>,
|
||||
encode_us: Vec<u64>,
|
||||
total_us: Vec<u64>,
|
||||
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;
|
||||
}
|
||||
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
// 把 `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))
|
||||
{
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
|
||||
bail!("Timeout waiting for first frame (10s)");
|
||||
}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
||||
bail!("PipeWire frame channel disconnected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 程序入口。流程四阶段:[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 ===");
|
||||
println!("Output: {}", bench_args.output);
|
||||
println!("Target frames: {}", bench_args.frames);
|
||||
println!(
|
||||
"Encode resolution: {}x{}",
|
||||
bench_args.enc_width, bench_args.enc_height
|
||||
);
|
||||
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,
|
||||
fps: 60,
|
||||
codec: "h264".to_string(),
|
||||
hw_accel: "vaapi".to_string(),
|
||||
drm_device: None,
|
||||
bitrate: None,
|
||||
max_bitrate: 8_000_000,
|
||||
gop_size: None,
|
||||
verbose: false,
|
||||
backend: 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;
|
||||
let enc_width = bench_args.enc_width;
|
||||
let enc_height = bench_args.enc_height;
|
||||
|
||||
println!(
|
||||
"[2/4] First frame: {}x{}, stride={}, format=0x{:08X}",
|
||||
src_width, src_height, src_stride, first_frame.format
|
||||
);
|
||||
println!(
|
||||
" Capture: {}x{} Encode: {}x{}\n",
|
||||
src_width, src_height, enc_width, enc_height
|
||||
);
|
||||
|
||||
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(),
|
||||
mmap_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
first_frame.fd.as_raw_fd(),
|
||||
first_frame.offset as i64,
|
||||
)
|
||||
};
|
||||
|
||||
// `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!(
|
||||
"mmap on DMA-BUF fd FAILED — AMD driver may not support \
|
||||
CPU read of screen capture DMA-BUF buffers.\n\
|
||||
Error: {} (errno={})\n\
|
||||
\n\
|
||||
Workarounds:\n\
|
||||
1. Use VAAPI hardware import (av_hwframe_map) instead of mmap\n\
|
||||
2. Use wlroots compositor with wlr-screencopy (SHM-based)\n\
|
||||
3. Use a virtual display or software renderer",
|
||||
errno,
|
||||
errno.raw_os_error().unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"[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);
|
||||
}
|
||||
drop(first_frame);
|
||||
|
||||
// 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(|| {
|
||||
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
|
||||
})?;
|
||||
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);
|
||||
enc.set_time_base(ff::Rational::new(1, 60));
|
||||
enc.set_max_b_frames(0);
|
||||
enc.set_gop(60);
|
||||
|
||||
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();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("tune").unwrap();
|
||||
let val = CString::new("zerolatency").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc.open()?;
|
||||
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,
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
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,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to open output file '{}': error {ret}",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
src_height as i32,
|
||||
bgr0_fmt,
|
||||
enc_width as i32,
|
||||
enc_height as i32,
|
||||
yuv420p_fmt,
|
||||
2,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if sws_ctx.is_null() {
|
||||
bail!("Failed to create sws_scale context");
|
||||
}
|
||||
|
||||
// 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);
|
||||
bail!("av_frame_get_buffer failed: {ret}");
|
||||
}
|
||||
f
|
||||
};
|
||||
|
||||
println!(
|
||||
"[4/4] Encoder ready: {}, {}x{}\n",
|
||||
codec_name, enc_width, enc_height
|
||||
);
|
||||
|
||||
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 => {
|
||||
eprintln!("PipeWire stream ended after {} frames", frames_encoded);
|
||||
break;
|
||||
}
|
||||
PwCtrlEvent::Error(e) => {
|
||||
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))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
eprintln!("Frame timeout/disconnect after {} frames", frames_encoded);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 帧级别计时:本轮 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(),
|
||||
frame_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
frame.fd.as_raw_fd(),
|
||||
frame.offset as i64,
|
||||
)
|
||||
};
|
||||
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
stats.mmap_failures += 1;
|
||||
eprintln!("mmap failed on frame {}", frames_encoded);
|
||||
drop(frame);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
|
||||
let src_ptr = src_data.as_ptr();
|
||||
let src_linesize = frame.stride as i32;
|
||||
|
||||
ffi::sws_scale(
|
||||
sws_ctx,
|
||||
&src_ptr as *const *const u8,
|
||||
&src_linesize as *const i32,
|
||||
0,
|
||||
frame.height as i32,
|
||||
(*yuv_frame).data.as_ptr() as *mut *mut u8,
|
||||
(*yuv_frame).linesize.as_ptr() as *mut i32,
|
||||
);
|
||||
}
|
||||
stats
|
||||
.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;
|
||||
|
||||
let ret = ffi::avcodec_send_frame(enc_video.as_mut_ptr(), yuv_frame);
|
||||
if ret < 0 {
|
||||
eprintln!("avcodec_send_frame failed: {ret}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
drain_encoder(&mut enc_video, &mut octx)?;
|
||||
|
||||
stats
|
||||
.encode_us
|
||||
.push(encode_start.elapsed().as_micros() as u64);
|
||||
stats
|
||||
.total_us
|
||||
.push(frame_start.elapsed().as_micros() as u64);
|
||||
|
||||
frames_encoded += 1;
|
||||
if frames_encoded % 30 == 0 {
|
||||
let fps = frames_encoded as f64 / total_start.elapsed().as_secs_f64();
|
||||
println!(
|
||||
" [{}/{}] {:.1} FPS",
|
||||
frames_encoded, bench_args.frames, fps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
drain_encoder(&mut enc_video, &mut octx)?;
|
||||
|
||||
octx.write_trailer()
|
||||
.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);
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
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 {
|
||||
0.0
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("╔══════════════════════════════════════════════════════════════╗");
|
||||
println!("║ Software Encode Benchmark Results ║");
|
||||
println!("╚══════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
println!("Capture resolution: {}x{}", src_width, src_height);
|
||||
println!("Encode resolution: {}x{}", enc_width, enc_height);
|
||||
println!("Frames encoded: {}", frames_encoded);
|
||||
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
|
||||
println!();
|
||||
println!("mmap (DMA-BUF -> CPU):");
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.mmap_us)
|
||||
);
|
||||
println!(
|
||||
" success rate: {:.1}% ({}/{})",
|
||||
mmap_success_rate,
|
||||
mmap_count,
|
||||
mmap_count + stats.mmap_failures
|
||||
);
|
||||
println!();
|
||||
println!("scale (BGR0 -> YUV420P via sws_scale):");
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.scale_us)
|
||||
);
|
||||
println!();
|
||||
println!("encode ({}):", codec_name);
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.encode_us)
|
||||
);
|
||||
println!();
|
||||
println!("total pipeline:");
|
||||
println!(" avg: {:.2} ms/frame", avg_total_ms);
|
||||
println!(" achieved FPS: {:.1}", total_fps);
|
||||
println!(" max theoretical: {:.1} FPS", max_fps);
|
||||
println!();
|
||||
|
||||
if mmap_success_rate < 100.0 {
|
||||
println!(
|
||||
"WARNING: Some mmap operations failed ({}/{})",
|
||||
stats.mmap_failures,
|
||||
stats.mmap_failures + mmap_count
|
||||
);
|
||||
}
|
||||
if total_fps < 30.0 {
|
||||
println!(
|
||||
"NOTE: Achieved FPS ({:.1}) is below 30 FPS target.",
|
||||
total_fps
|
||||
);
|
||||
}
|
||||
|
||||
println!("Output written to: {}", bench_args.output);
|
||||
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;
|
||||
}
|
||||
eprintln!("avcodec_receive_packet failed: {ret}");
|
||||
break;
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
// 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 std::ffi::CString;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use wl_webrtc::args::Args;
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
mod stats;
|
||||
|
||||
use stats::{pix_fmt, BenchArgs, FrameStats};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let bench_args = BenchArgs::parse();
|
||||
|
||||
println!("=== Software Encode Benchmark ===");
|
||||
println!("Output: {}", bench_args.output);
|
||||
println!("Target frames: {}", bench_args.frames);
|
||||
println!(
|
||||
"Encode resolution: {}x{}",
|
||||
bench_args.enc_width, bench_args.enc_height
|
||||
);
|
||||
println!();
|
||||
|
||||
ff::init()?;
|
||||
|
||||
println!("[1/4] Requesting screen capture via XDG Portal...");
|
||||
println!(" (Select a screen to share in the portal dialog)");
|
||||
|
||||
let portal_args = Args {
|
||||
output: Some(bench_args.output.clone()),
|
||||
output_name: None,
|
||||
fps: 60,
|
||||
codec: "h264".to_string(),
|
||||
hw_accel: "vaapi".to_string(),
|
||||
drm_device: None,
|
||||
bitrate: None,
|
||||
max_bitrate: 8_000_000,
|
||||
gop_size: None,
|
||||
verbose: false,
|
||||
backend: Some("portal".to_string()),
|
||||
port: 0,
|
||||
no_persist: false,
|
||||
stats: false,
|
||||
};
|
||||
|
||||
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 = common::receive_first_frame(&cap)?;
|
||||
|
||||
let src_width = first_frame.width;
|
||||
let src_height = first_frame.height;
|
||||
let src_stride = first_frame.stride;
|
||||
let enc_width = bench_args.enc_width;
|
||||
let enc_height = bench_args.enc_height;
|
||||
|
||||
println!(
|
||||
"[2/4] First frame: {}x{}, stride={}, format=0x{:08X}",
|
||||
src_width, src_height, src_stride, first_frame.format
|
||||
);
|
||||
println!(
|
||||
" Capture: {}x{} Encode: {}x{}\n",
|
||||
src_width, src_height, enc_width, enc_height
|
||||
);
|
||||
|
||||
println!("[3/4] Testing mmap on DMA-BUF...");
|
||||
let mmap_size = (src_stride as usize) * (src_height as usize);
|
||||
// SAFETY: first_frame.fd is an open DMA-BUF; offset/size come from PipeWire's
|
||||
// negotiated format. PROT_READ+MAP_SHARED is the standard read-only DMA-BUF
|
||||
// mapping. Returns MAP_FAILED on error (checked below).
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
mmap_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
first_frame.fd.as_raw_fd(),
|
||||
first_frame.offset as i64,
|
||||
)
|
||||
};
|
||||
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
bail!(
|
||||
"mmap on DMA-BUF fd FAILED — AMD driver may not support \
|
||||
CPU read of screen capture DMA-BUF buffers.\n\
|
||||
Error: {} (errno={})\n\
|
||||
\n\
|
||||
Workarounds:\n\
|
||||
1. Use VAAPI hardware import (av_hwframe_map) instead of mmap\n\
|
||||
2. Use wlroots compositor with wlr-screencopy (SHM-based)\n\
|
||||
3. Use a virtual display or software renderer",
|
||||
errno,
|
||||
errno.raw_os_error().unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
|
||||
mmap_size as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
// SAFETY: mmap_ptr was returned by mmap above and is not MAP_FAILED (checked);
|
||||
// mmap_size matches the original mapping. POSIX munmap(2) releases the mapping.
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, mmap_size);
|
||||
}
|
||||
drop(first_frame);
|
||||
|
||||
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
|
||||
println!("[4/4] Setting up libx264 encoder...");
|
||||
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
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
|
||||
})?;
|
||||
println!("[4/4] Using encoder: {}\n", codec.name());
|
||||
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
enc.set_width(enc_width);
|
||||
enc.set_height(enc_height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
enc.set_time_base(ff::Rational::new(1, 60));
|
||||
enc.set_max_b_frames(0);
|
||||
enc.set_gop(60);
|
||||
|
||||
let codec_name = codec.name();
|
||||
if codec_name == "libx264" {
|
||||
// SAFETY: enc is a valid AVCodecContext for the not-yet-opened encoder;
|
||||
// priv_data is the x264 private options struct. All CStrings live across
|
||||
// both av_opt_set calls. These set the x264 "preset" and "tune" options.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("veryfast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("tune").unwrap();
|
||||
let val = CString::new("zerolatency").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc.open()?;
|
||||
let mut enc_video = opened.0;
|
||||
|
||||
// Create output format context via FFI
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
// SAFETY: fmt_ctx_ptr is an out-parameter initialized by FFmpeg; output_cstr
|
||||
// lives across the call. Returns 0 on success; we check below.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is the valid output context allocated above.
|
||||
// avformat_new_stream returns a pointer to a new AVStream or NULL on failure.
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create new stream");
|
||||
}
|
||||
|
||||
// SAFETY: stream_ptr and enc_video.as_ptr() are valid pointers; codecpar is
|
||||
// the output destination inside stream. avcodec_parameters_from_context copies
|
||||
// encoder parameters into the stream's codecpar.
|
||||
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}");
|
||||
}
|
||||
|
||||
// SAFETY: stream_ptr and enc_video are valid; time_base is a plain AVRational
|
||||
// field copied from encoder to stream.
|
||||
unsafe {
|
||||
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is valid; pb is the AVIOContext slot to initialize;
|
||||
// output_cstr is a valid NUL-terminated path; AVIO_FLAG_WRITE is a constant.
|
||||
let ret = unsafe {
|
||||
ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
)
|
||||
};
|
||||
if ret < 0 {
|
||||
bail!(
|
||||
"Failed to open output file '{}': error {ret}",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is fully configured (streams + pb set); NULL options
|
||||
// is the default. Returns 0 on success.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write header: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is a fully initialized output context (header written).
|
||||
// Output::wrap takes ownership of the pointer into a safe RAII wrapper.
|
||||
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
|
||||
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
|
||||
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
|
||||
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
|
||||
|
||||
// SAFETY: all parameters are valid enum/pixel format values; NULL filters are
|
||||
// allowed by FFmpeg. sws_getContext returns a heap-allocated SwsContext or NULL.
|
||||
let sws_ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
src_width as i32,
|
||||
src_height as i32,
|
||||
bgr0_fmt,
|
||||
enc_width as i32,
|
||||
enc_height as i32,
|
||||
yuv420p_fmt,
|
||||
2,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if sws_ctx.is_null() {
|
||||
bail!("Failed to create sws_scale context");
|
||||
}
|
||||
|
||||
// Allocate reusable YUV frame
|
||||
// SAFETY: av_frame_alloc returns NULL only on OOM. After allocation we set
|
||||
// width/height/format fields and call av_frame_get_buffer to allocate plane
|
||||
// data. On failure we free the frame via av_frame_free before bailing.
|
||||
let mut yuv_frame = unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
(*f).width = enc_width as i32;
|
||||
(*f).height = enc_height as i32;
|
||||
(*f).format = yuv420p_fmt as i32;
|
||||
let ret = ffi::av_frame_get_buffer(f, 0);
|
||||
if ret < 0 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!("av_frame_get_buffer failed: {ret}");
|
||||
}
|
||||
f
|
||||
};
|
||||
|
||||
println!(
|
||||
"[4/4] Encoder ready: {}, {}x{}\n",
|
||||
codec_name, enc_width, enc_height
|
||||
);
|
||||
|
||||
println!("=== Encoding {} frames ===\n", bench_args.frames);
|
||||
|
||||
let mut stats = FrameStats::default();
|
||||
let total_start = Instant::now();
|
||||
let mut frames_encoded: u32 = 0;
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
while frames_encoded < bench_args.frames {
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => {
|
||||
eprintln!("PipeWire stream ended after {} frames", frames_encoded);
|
||||
break;
|
||||
}
|
||||
PwCtrlEvent::Error(e) => {
|
||||
eprintln!("PipeWire error after {} frames: {}", frames_encoded, e);
|
||||
break;
|
||||
}
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let frame = match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
eprintln!("Frame timeout/disconnect after {} frames", frames_encoded);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_start = Instant::now();
|
||||
|
||||
let mmap_start = Instant::now();
|
||||
let frame_size = (frame.stride as usize) * (frame.height as usize);
|
||||
// SAFETY: frame.fd is an open DMA-BUF owned by the frame; offset/size come
|
||||
// from PipeWire's negotiated format. PROT_READ+MAP_SHARED for read-only
|
||||
// DMA-BUF access. Returns MAP_FAILED on error (checked below).
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
frame_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
frame.fd.as_raw_fd(),
|
||||
frame.offset as i64,
|
||||
)
|
||||
};
|
||||
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
stats.mmap_failures += 1;
|
||||
eprintln!("mmap failed on frame {}", frames_encoded);
|
||||
drop(frame);
|
||||
continue;
|
||||
}
|
||||
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
|
||||
|
||||
let scale_start = Instant::now();
|
||||
// SAFETY: mmap_ptr is a valid mapping of frame_size bytes (checked above);
|
||||
// constructing a read-only slice over it for the duration of sws_scale is
|
||||
// sound as long as we don't hold it past munmap (we don't).
|
||||
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
|
||||
|
||||
// SAFETY: yuv_frame and sws_ctx are valid; src_data is a valid slice of the
|
||||
// mmap'd DMA-BUF for this frame. sws_scale reads src planes (BGR0 -> YUV420P)
|
||||
// and writes into yuv_frame's data planes. av_frame_make_writable ensures
|
||||
// yuv_frame is not shared before writing.
|
||||
unsafe {
|
||||
ffi::av_frame_make_writable(yuv_frame);
|
||||
|
||||
let src_ptr = src_data.as_ptr();
|
||||
let src_linesize = frame.stride as i32;
|
||||
|
||||
ffi::sws_scale(
|
||||
sws_ctx,
|
||||
&src_ptr as *const *const u8,
|
||||
&src_linesize as *const i32,
|
||||
0,
|
||||
frame.height as i32,
|
||||
(*yuv_frame).data.as_ptr() as *mut *mut u8,
|
||||
(*yuv_frame).linesize.as_ptr() as *mut i32,
|
||||
);
|
||||
}
|
||||
stats
|
||||
.scale_us
|
||||
.push(scale_start.elapsed().as_micros() as u64);
|
||||
|
||||
// SAFETY: mmap_ptr was returned by mmap above and is not MAP_FAILED; frame_size
|
||||
// matches the original mapping. Release before dropping frame (which closes fd).
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, frame_size);
|
||||
}
|
||||
drop(frame);
|
||||
|
||||
let encode_start = Instant::now();
|
||||
|
||||
// SAFETY: yuv_frame is allocated and writable; enc_video is the opened encoder.
|
||||
// Setting pts is a plain i64 field write. avcodec_send_frame submits the frame
|
||||
// for encoding; returns < 0 on error (we log and continue).
|
||||
unsafe {
|
||||
(*yuv_frame).pts = pts;
|
||||
pts += 1;
|
||||
|
||||
let ret = ffi::avcodec_send_frame(enc_video.as_mut_ptr(), yuv_frame);
|
||||
if ret < 0 {
|
||||
eprintln!("avcodec_send_frame failed: {ret}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
common::drain_encoder(&mut enc_video, &mut octx)?;
|
||||
|
||||
stats
|
||||
.encode_us
|
||||
.push(encode_start.elapsed().as_micros() as u64);
|
||||
stats
|
||||
.total_us
|
||||
.push(frame_start.elapsed().as_micros() as u64);
|
||||
|
||||
frames_encoded += 1;
|
||||
if frames_encoded.is_multiple_of(30) {
|
||||
let fps = frames_encoded as f64 / total_start.elapsed().as_secs_f64();
|
||||
println!(
|
||||
" [{}/{}] {:.1} FPS",
|
||||
frames_encoded, bench_args.frames, fps
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let total_elapsed = total_start.elapsed();
|
||||
|
||||
println!("\nFlushing encoder...");
|
||||
// SAFETY: enc_video is the opened encoder; passing NULL frame signals EOF to
|
||||
// drain the encoder's internal pipeline. Returns < 0 on error (ignored here).
|
||||
unsafe {
|
||||
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
|
||||
}
|
||||
common::drain_encoder(&mut enc_video, &mut octx)?;
|
||||
|
||||
octx.write_trailer()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
|
||||
|
||||
// Cleanup
|
||||
// SAFETY: yuv_frame is the allocated frame from earlier (still owned by us);
|
||||
// sws_ctx is the allocated sws context. av_frame_free and sws_freeContext take
|
||||
// ownership and free their respective heap allocations.
|
||||
unsafe {
|
||||
ffi::av_frame_free(&mut yuv_frame as *mut _);
|
||||
ffi::sws_freeContext(sws_ctx);
|
||||
}
|
||||
|
||||
drop(cap);
|
||||
|
||||
// Print results
|
||||
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
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
|
||||
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
|
||||
let max_fps = if avg_total_ms > 0.0 {
|
||||
1000.0 / avg_total_ms
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
println!();
|
||||
println!("╔══════════════════════════════════════════════════════════════╗");
|
||||
println!("║ Software Encode Benchmark Results ║");
|
||||
println!("╚══════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
println!("Capture resolution: {}x{}", src_width, src_height);
|
||||
println!("Encode resolution: {}x{}", enc_width, enc_height);
|
||||
println!("Frames encoded: {}", frames_encoded);
|
||||
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
|
||||
println!();
|
||||
println!("mmap (DMA-BUF -> CPU):");
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.mmap_us)
|
||||
);
|
||||
println!(
|
||||
" success rate: {:.1}% ({}/{})",
|
||||
mmap_success_rate,
|
||||
mmap_count,
|
||||
mmap_count + stats.mmap_failures
|
||||
);
|
||||
println!();
|
||||
println!("scale (BGR0 -> YUV420P via sws_scale):");
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.scale_us)
|
||||
);
|
||||
println!();
|
||||
println!("encode ({}):", codec_name);
|
||||
println!(
|
||||
" avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.encode_us)
|
||||
);
|
||||
println!();
|
||||
println!("total pipeline:");
|
||||
println!(" avg: {:.2} ms/frame", avg_total_ms);
|
||||
println!(" achieved FPS: {:.1}", total_fps);
|
||||
println!(" max theoretical: {:.1} FPS", max_fps);
|
||||
println!();
|
||||
|
||||
if mmap_success_rate < 100.0 {
|
||||
println!(
|
||||
"WARNING: Some mmap operations failed ({}/{})",
|
||||
stats.mmap_failures,
|
||||
stats.mmap_failures + mmap_count
|
||||
);
|
||||
}
|
||||
if total_fps < 30.0 {
|
||||
println!(
|
||||
"NOTE: Achieved FPS ({:.1}) is below 30 FPS target.",
|
||||
total_fps
|
||||
);
|
||||
}
|
||||
|
||||
println!("Output written to: {}", bench_args.output);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "sw_encode_bench",
|
||||
about = "Software encoding pipeline benchmark"
|
||||
)]
|
||||
pub(crate) struct BenchArgs {
|
||||
#[arg(short, long)]
|
||||
pub(crate) output: String,
|
||||
|
||||
#[arg(long, default_value_t = 120)]
|
||||
pub(crate) frames: u32,
|
||||
|
||||
#[arg(long, default_value_t = 2560)]
|
||||
pub(crate) enc_width: u32,
|
||||
|
||||
#[arg(long, default_value_t = 1440)]
|
||||
pub(crate) enc_height: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FrameStats {
|
||||
pub(crate) mmap_us: Vec<u64>,
|
||||
pub(crate) scale_us: Vec<u64>,
|
||||
pub(crate) encode_us: Vec<u64>,
|
||||
pub(crate) total_us: Vec<u64>,
|
||||
pub(crate) mmap_failures: u32,
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
pub(crate) 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
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
|
||||
Into::<ffi::AVPixelFormat>::into(p)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,218 +0,0 @@
|
||||
// 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
|
||||
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
|
||||
use wl_webrtc::args::Args;
|
||||
use wl_webrtc::avhw::{import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
|
||||
use wl_webrtc::cap_portal::CapPortal;
|
||||
|
||||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
mod pipeline_cpu;
|
||||
mod pipeline_gpu;
|
||||
mod software;
|
||||
mod stats;
|
||||
mod util;
|
||||
|
||||
use pipeline_cpu::run_cpu_pipeline;
|
||||
use pipeline_gpu::run_gpu_pipeline;
|
||||
use stats::{BenchArgs, PipelineMode};
|
||||
use util::{output_for_mode, print_comparison, print_detailed_results};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let bench_args = BenchArgs::parse();
|
||||
|
||||
println!("=== VAAPI Import Benchmark ===");
|
||||
println!("Output: {}", bench_args.output);
|
||||
println!("Target frames: {}", bench_args.frames);
|
||||
println!(
|
||||
"Encode resolution: {}x{}",
|
||||
bench_args.enc_width, bench_args.enc_height
|
||||
);
|
||||
println!("DRM device: {}", bench_args.drm_device);
|
||||
println!();
|
||||
|
||||
ff::init()?;
|
||||
|
||||
println!("[1/3] Requesting screen capture via XDG Portal...");
|
||||
println!(" (Select a screen to share in the portal dialog)");
|
||||
|
||||
let portal_args = Args {
|
||||
output: Some(bench_args.output.clone()),
|
||||
output_name: None,
|
||||
fps: 60,
|
||||
codec: "h264".to_string(),
|
||||
hw_accel: "vaapi".to_string(),
|
||||
drm_device: None,
|
||||
bitrate: None,
|
||||
max_bitrate: 8_000_000,
|
||||
gop_size: None,
|
||||
verbose: false,
|
||||
backend: Some("portal".to_string()),
|
||||
port: 0,
|
||||
no_persist: false,
|
||||
stats: false,
|
||||
};
|
||||
|
||||
let cap = CapPortal::new(&portal_args)?;
|
||||
println!("[1/3] Portal connected, PipeWire stream active\n");
|
||||
|
||||
println!("[2/3] Waiting for first frame from PipeWire...");
|
||||
let first_frame = common::receive_first_frame(&cap)?;
|
||||
|
||||
let src_width = first_frame.width;
|
||||
let src_height = first_frame.height;
|
||||
let src_format = first_frame.format;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
println!("\n[2/3] Testing av_hwframe_map with sw_format=BGRA...");
|
||||
println!(
|
||||
" DRM format chain: PipeWire BGRA -> DRM_FORMAT_ARGB8888 (0x{:08X}) -> VA_FOURCC_BGRA -> AV_PIX_FMT_BGRA",
|
||||
src_format
|
||||
);
|
||||
|
||||
let drm_device = Path::new(&bench_args.drm_device);
|
||||
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
|
||||
println!(" VAAPI device context created OK");
|
||||
|
||||
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)");
|
||||
|
||||
// SAFETY: delegates to avhw::import_dma_buf_to_vaapi (itself an unsafe fn).
|
||||
// frames_ctx is a valid AVBufferRef from AvHwFrameCtx::for_capture above;
|
||||
// `first_frame` is the PipeWire-formatted PwDmaBufFrame whose metadata the
|
||||
// function reads directly. See that function's own SAFETY contract for the
|
||||
// full rationale.
|
||||
let vaapi_frame = unsafe { import_dma_buf_to_vaapi(frames_ctx.as_ptr(), &first_frame) };
|
||||
|
||||
match &vaapi_frame {
|
||||
Ok(_) => {
|
||||
println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Result: FAILED");
|
||||
println!(" Error: {e}");
|
||||
println!();
|
||||
println!(" Possible causes:");
|
||||
println!(" - sw_format mismatch (current: BGRA)");
|
||||
println!(" - DRM format modifier not supported by VAAPI");
|
||||
println!(" - VAAPI driver doesn't support DMA-BUF import for this format");
|
||||
println!();
|
||||
println!(" Falling back to mmap readback test for comparison...");
|
||||
|
||||
let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize);
|
||||
let mmap_start = Instant::now();
|
||||
// SAFETY: first_frame.fd is an open DMA-BUF; offset/size from PipeWire.
|
||||
// PROT_READ+MAP_SHARED is the standard read-only DMA-BUF mapping. Returns
|
||||
// MAP_FAILED on error (checked below).
|
||||
let mmap_ptr = unsafe {
|
||||
libc::mmap(
|
||||
ptr::null_mut(),
|
||||
mmap_size,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
first_frame.fd.as_raw_fd(),
|
||||
first_frame.offset as i64,
|
||||
)
|
||||
};
|
||||
let mmap_elapsed = mmap_start.elapsed();
|
||||
|
||||
if mmap_ptr == libc::MAP_FAILED {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
println!(" mmap also FAILED: {errno}");
|
||||
} else {
|
||||
println!(
|
||||
" mmap SUCCESS: {:.1} MB, setup in {:.2}ms",
|
||||
mmap_size as f64 / 1024.0 / 1024.0,
|
||||
mmap_elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
// SAFETY: mmap_ptr is a valid mapping (MAP_FAILED path was handled
|
||||
// above); mmap_size matches the original mapping. POSIX munmap(2).
|
||||
unsafe {
|
||||
libc::munmap(mmap_ptr, mmap_size);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("=== Benchmark ended: av_hwframe_map import FAILED ===");
|
||||
println!("Fix the import issue before proceeding to GPU downscale tests.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
drop(vaapi_frame);
|
||||
drop(first_frame);
|
||||
|
||||
println!("\n[3/3] Benchmarking selected pipeline(s)...");
|
||||
|
||||
let enc_width = bench_args.enc_width;
|
||||
let enc_height = bench_args.enc_height;
|
||||
let split_outputs = bench_args.mode == PipelineMode::Both;
|
||||
let mut cpu_stats = None;
|
||||
let mut gpu_stats = None;
|
||||
|
||||
if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) {
|
||||
let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs);
|
||||
cpu_stats = Some(run_cpu_pipeline(
|
||||
&cap,
|
||||
&frames_ctx,
|
||||
&output,
|
||||
bench_args.frames,
|
||||
src_width,
|
||||
src_height,
|
||||
enc_width,
|
||||
enc_height,
|
||||
)?);
|
||||
}
|
||||
|
||||
if matches!(bench_args.mode, PipelineMode::Gpu | PipelineMode::Both) {
|
||||
let output = output_for_mode(&bench_args.output, PipelineMode::Gpu, split_outputs);
|
||||
gpu_stats = Some(run_gpu_pipeline(
|
||||
&cap,
|
||||
&hw_dev,
|
||||
&frames_ctx,
|
||||
&output,
|
||||
bench_args.frames,
|
||||
src_width,
|
||||
src_height,
|
||||
enc_width,
|
||||
enc_height,
|
||||
)?);
|
||||
}
|
||||
|
||||
if let Some(stats) = cpu_stats.as_ref() {
|
||||
print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height);
|
||||
}
|
||||
if let Some(stats) = gpu_stats.as_ref() {
|
||||
print_detailed_results("GPU", stats, src_width, src_height, enc_width, enc_height);
|
||||
}
|
||||
print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref());
|
||||
|
||||
if cpu_stats
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(gpu_stats.as_ref())
|
||||
.any(|stats| stats.achieved_fps() < 30.0 && stats.frames_encoded > 0)
|
||||
{
|
||||
println!("NOTE: At least one achieved FPS result is below 30 FPS target.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use wl_webrtc::avhw::{av_err_to_string, AvHwFrameCtx};
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
use crate::pipeline_gpu::import_frame;
|
||||
use crate::software::{
|
||||
create_software_encoder, create_sws_context, encode_yuv_frame, finish_encoder,
|
||||
};
|
||||
use crate::stats::FrameStats;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn run_cpu_pipeline(
|
||||
cap: &CapPortal,
|
||||
frames_ctx: &AvHwFrameCtx,
|
||||
output: &str,
|
||||
frames: u32,
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) -> Result<FrameStats> {
|
||||
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
|
||||
let sws_ctx = create_sws_context(
|
||||
src_width,
|
||||
src_height,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_BGRA,
|
||||
enc_width,
|
||||
enc_height,
|
||||
)?;
|
||||
|
||||
println!(
|
||||
" Encoder: {}, {}x{} YUV420P",
|
||||
encoder.codec_name, enc_width, enc_height
|
||||
);
|
||||
println!(" Output: {output}");
|
||||
println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n");
|
||||
|
||||
let mut stats = FrameStats {
|
||||
codec_name: encoder.codec_name.clone(),
|
||||
output_path: output.to_string(),
|
||||
..FrameStats::default()
|
||||
};
|
||||
let total_start = Instant::now();
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
while stats.frames_encoded < frames {
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => break,
|
||||
PwCtrlEvent::Error(e) => bail!(
|
||||
"PipeWire error after {} CPU frames: {e}",
|
||||
stats.frames_encoded
|
||||
),
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let frame = match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let frame_start = Instant::now();
|
||||
let t_import = Instant::now();
|
||||
let vaapi_frame = match import_frame(frames_ctx, &frame) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
stats.import_failures += 1;
|
||||
if stats.import_failures <= 3 {
|
||||
eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let import_us = t_import.elapsed().as_micros() as u64;
|
||||
|
||||
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() {
|
||||
bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded);
|
||||
}
|
||||
// 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 {
|
||||
// SAFETY: sw_frame was allocated above and has not been freed yet.
|
||||
unsafe { ffi::av_frame_free(&mut sw_frame) };
|
||||
bail!(
|
||||
"CPU frame {}: av_hwframe_transfer_data failed: {} ({})",
|
||||
stats.frames_encoded,
|
||||
transfer_ret,
|
||||
av_err_to_string(transfer_ret)
|
||||
);
|
||||
}
|
||||
let transfer_us = t_transfer.elapsed().as_micros() as u64;
|
||||
|
||||
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 {
|
||||
ffi::av_frame_make_writable(encoder.yuv_frame);
|
||||
ffi::sws_scale(
|
||||
sws_ctx.0,
|
||||
(*sw_frame).data.as_ptr() as *const *const u8,
|
||||
(*sw_frame).linesize.as_ptr(),
|
||||
0,
|
||||
(*sw_frame).height,
|
||||
(*encoder.yuv_frame).data.as_ptr() as *mut *mut u8,
|
||||
(*encoder.yuv_frame).linesize.as_ptr(),
|
||||
);
|
||||
}
|
||||
let scale_us = t_scale.elapsed().as_micros() as u64;
|
||||
// SAFETY: sw_frame was allocated above and is no longer needed after scaling.
|
||||
unsafe { ffi::av_frame_free(&mut sw_frame) };
|
||||
|
||||
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
|
||||
let total_us = frame_start.elapsed().as_micros() as u64;
|
||||
|
||||
stats.import_us.push(import_us);
|
||||
stats.transfer_us.push(transfer_us);
|
||||
stats.scale_us.push(scale_us);
|
||||
stats.encode_us.push(encode_us);
|
||||
stats.total_us.push(total_us);
|
||||
stats.frames_encoded += 1;
|
||||
|
||||
if stats.frames_encoded <= 3 || stats.frames_encoded.is_multiple_of(30) {
|
||||
println!(
|
||||
" CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms",
|
||||
stats.frames_encoded,
|
||||
import_us as f64 / 1000.0,
|
||||
transfer_us as f64 / 1000.0,
|
||||
scale_us as f64 / 1000.0,
|
||||
encode_us as f64 / 1000.0,
|
||||
total_us as f64 / 1000.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
finish_encoder(encoder)?;
|
||||
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
|
||||
Ok(stats)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use wl_webrtc::avhw::{av_err_to_string, import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
|
||||
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
|
||||
|
||||
use crate::software::{
|
||||
create_software_encoder, create_sws_context, encode_yuv_frame, finish_encoder,
|
||||
};
|
||||
use crate::stats::FrameStats;
|
||||
|
||||
pub(crate) fn import_frame(
|
||||
frames_ctx: &AvHwFrameCtx,
|
||||
frame: &wl_webrtc::cap_portal::PwDmaBufFrame,
|
||||
) -> Result<ff::frame::Video> {
|
||||
// SAFETY: frames_ctx is a live VAAPI frames context configured for the capture format; frame
|
||||
// carries a valid DMA-BUF fd and metadata from PipeWire for the duration of the call.
|
||||
// SAFETY: frames_ctx is a valid VAAPI frames context; `frame` carries the
|
||||
// DMA-BUF metadata read by the function.
|
||||
unsafe { import_dma_buf_to_vaapi(frames_ctx.as_ptr(), frame) }
|
||||
}
|
||||
|
||||
fn build_gpu_filter_graph(
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_rgb: &AvHwFrameCtx,
|
||||
width: u32,
|
||||
height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) -> Result<ff::filter::Graph> {
|
||||
let mut graph = ff::filter::Graph::new();
|
||||
let buffersrc =
|
||||
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
|
||||
let buffersink = ff::filter::find("buffersink")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
|
||||
let scale_vaapi = ff::filter::find("scale_vaapi")
|
||||
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
|
||||
|
||||
// pix_fmt must be set via av_buffersrc_parameters_set (below), not in args —
|
||||
// FFmpeg 8.0+ rejects HW pixel formats during init() if hw_frames_ctx is missing.
|
||||
// Use a placeholder SW format here; it gets overridden by parameters_set below.
|
||||
let args = format!(
|
||||
"video_size={}x{}:pix_fmt=bgra:time_base=1/60:pixel_aspect=1/1",
|
||||
width, height,
|
||||
);
|
||||
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
|
||||
|
||||
// SAFETY: Allocate buffersrc parameters, attach a ref-counted hw_frames_ctx compatible with
|
||||
// imported VAAPI BGRA frames, apply it, then free only the parameter struct (not the ref).
|
||||
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
|
||||
if par.is_null() {
|
||||
bail!("av_buffersrc_parameters_alloc returned null");
|
||||
}
|
||||
// SAFETY: par and src_ctx are valid; frames_rgb.ref_clone returns an owned AVBufferRef.
|
||||
unsafe {
|
||||
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
|
||||
(*par).width = width as i32;
|
||||
(*par).height = height as i32;
|
||||
(*par).time_base = ffi::AVRational { num: 1, den: 60 };
|
||||
(*par).hw_frames_ctx = frames_rgb.ref_clone();
|
||||
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if ret < 0 {
|
||||
bail!("av_buffersrc_parameters_set failed: error {ret}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut scale_ctx = graph.add(
|
||||
&scale_vaapi,
|
||||
"scale",
|
||||
&format!("{enc_width}:{enc_height}:format=nv12"),
|
||||
)?;
|
||||
// SAFETY: scale_vaapi uses this ref-counted VAAPI device context while graph is alive.
|
||||
unsafe {
|
||||
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||
}
|
||||
|
||||
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
|
||||
src_ctx.link(0, &mut scale_ctx, 0);
|
||||
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||
graph
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("GPU filter graph validation failed: {e}"))?;
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn run_gpu_pipeline(
|
||||
cap: &CapPortal,
|
||||
hw_dev: &AvHwDevCtx,
|
||||
frames_ctx: &AvHwFrameCtx,
|
||||
output: &str,
|
||||
frames: u32,
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) -> Result<FrameStats> {
|
||||
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
|
||||
let format_ctx = create_sws_context(
|
||||
enc_width,
|
||||
enc_height,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
enc_width,
|
||||
enc_height,
|
||||
)?;
|
||||
let mut graph = build_gpu_filter_graph(
|
||||
hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height,
|
||||
)?;
|
||||
|
||||
println!(
|
||||
" Encoder: {}, {}x{} YUV420P",
|
||||
encoder.codec_name, enc_width, enc_height
|
||||
);
|
||||
println!(" Output: {output}");
|
||||
println!(" GPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> scale_vaapi 2K NV12 -> transfer small NV12 -> sws_scale format-only -> encode\n");
|
||||
|
||||
let mut stats = FrameStats {
|
||||
codec_name: encoder.codec_name.clone(),
|
||||
output_path: output.to_string(),
|
||||
..FrameStats::default()
|
||||
};
|
||||
let total_start = Instant::now();
|
||||
let mut pts: i64 = 0;
|
||||
|
||||
while stats.frames_encoded < frames {
|
||||
if let Ok(ctrl) = cap.event_receiver().try_recv() {
|
||||
match ctrl {
|
||||
PwCtrlEvent::StreamEnded => break,
|
||||
PwCtrlEvent::Error(e) => bail!(
|
||||
"PipeWire error after {} GPU frames: {e}",
|
||||
stats.frames_encoded
|
||||
),
|
||||
PwCtrlEvent::FormatChanged { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let frame = match cap
|
||||
.frame_receiver()
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let frame_start = Instant::now();
|
||||
let t_import = Instant::now();
|
||||
let vaapi_frame = match import_frame(frames_ctx, &frame) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
stats.import_failures += 1;
|
||||
if stats.import_failures <= 3 {
|
||||
eprintln!("GPU frame {}: import failed: {e}", stats.frames_encoded);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let import_us = t_import.elapsed().as_micros() as u64;
|
||||
|
||||
let t_filter = Instant::now();
|
||||
let mut filter_src_ctx = graph.get("in").unwrap();
|
||||
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();
|
||||
filter_src
|
||||
.add(&vaapi_frame)
|
||||
.map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?;
|
||||
|
||||
let mut filtered = ff::frame::Video::empty();
|
||||
match filter_sink.frame(&mut filtered) {
|
||||
Ok(()) => {}
|
||||
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;
|
||||
|
||||
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() };
|
||||
if sw_nv12.is_null() {
|
||||
bail!("GPU frame {}: av_frame_alloc failed", stats.frames_encoded);
|
||||
}
|
||||
// SAFETY: sw_nv12 is an allocated destination; filtered is a valid 2K NV12 VAAPI frame.
|
||||
let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_nv12, filtered.as_ptr(), 0) };
|
||||
if transfer_ret < 0 {
|
||||
// SAFETY: sw_nv12 was allocated above and has not been freed yet.
|
||||
unsafe { ffi::av_frame_free(&mut sw_nv12) };
|
||||
bail!(
|
||||
"GPU frame {}: av_hwframe_transfer_data failed: {} ({})",
|
||||
stats.frames_encoded,
|
||||
transfer_ret,
|
||||
av_err_to_string(transfer_ret)
|
||||
);
|
||||
}
|
||||
let transfer_us = t_transfer.elapsed().as_micros() as u64;
|
||||
|
||||
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.
|
||||
unsafe {
|
||||
ffi::av_frame_make_writable(encoder.yuv_frame);
|
||||
ffi::sws_scale(
|
||||
format_ctx.0,
|
||||
(*sw_nv12).data.as_ptr() as *const *const u8,
|
||||
(*sw_nv12).linesize.as_ptr(),
|
||||
0,
|
||||
(*sw_nv12).height,
|
||||
(*encoder.yuv_frame).data.as_ptr() as *mut *mut u8,
|
||||
(*encoder.yuv_frame).linesize.as_ptr(),
|
||||
);
|
||||
}
|
||||
let format_us = t_format.elapsed().as_micros() as u64;
|
||||
// SAFETY: sw_nv12 was allocated above and is no longer needed.
|
||||
unsafe { ffi::av_frame_free(&mut sw_nv12) };
|
||||
|
||||
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
|
||||
let total_us = frame_start.elapsed().as_micros() as u64;
|
||||
|
||||
stats.import_us.push(import_us);
|
||||
stats.filter_us.push(filter_us);
|
||||
stats.transfer_us.push(transfer_us);
|
||||
stats.format_us.push(format_us);
|
||||
stats.encode_us.push(encode_us);
|
||||
stats.total_us.push(total_us);
|
||||
stats.frames_encoded += 1;
|
||||
|
||||
if stats.frames_encoded <= 3 || stats.frames_encoded.is_multiple_of(30) {
|
||||
println!(
|
||||
" GPU frame {:>4}/{frames}: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms",
|
||||
stats.frames_encoded,
|
||||
import_us as f64 / 1000.0,
|
||||
filter_us as f64 / 1000.0,
|
||||
transfer_us as f64 / 1000.0,
|
||||
format_us as f64 / 1000.0,
|
||||
encode_us as f64 / 1000.0,
|
||||
total_us as f64 / 1000.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
finish_encoder(encoder)?;
|
||||
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
|
||||
Ok(stats)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use crate::common::drain_encoder;
|
||||
|
||||
pub(crate) struct SoftwareEncoder {
|
||||
pub(crate) enc_video: ff::codec::encoder::video::Video,
|
||||
pub(crate) octx: ff::format::context::Output,
|
||||
pub(crate) yuv_frame: *mut ffi::AVFrame,
|
||||
pub(crate) codec_name: String,
|
||||
}
|
||||
|
||||
impl Drop for SoftwareEncoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: yuv_frame is allocated by av_frame_alloc in create_software_encoder and
|
||||
// owned exclusively by this SoftwareEncoder.
|
||||
unsafe {
|
||||
ffi::av_frame_free(&mut self.yuv_frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SwsContext(pub(crate) *mut ffi::SwsContext);
|
||||
|
||||
impl Drop for SwsContext {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Context is either null or returned by sws_getContext and owned here.
|
||||
unsafe {
|
||||
ffi::sws_freeContext(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_software_encoder(
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<SoftwareEncoder> {
|
||||
let output_cstr = CString::new(output_path.to_str().unwrap())?;
|
||||
let codec = ff::encoder::find_by_name("libx264")
|
||||
.or_else(|| ff::encoder::find_by_name("libopenh264"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
|
||||
})?;
|
||||
|
||||
let codec_name = codec.name().to_string();
|
||||
let mut enc = {
|
||||
let ctx = ff::codec::Context::new_with_codec(codec);
|
||||
ctx.encoder().video()?
|
||||
};
|
||||
|
||||
enc.set_width(width);
|
||||
enc.set_height(height);
|
||||
enc.set_format(ff::format::Pixel::YUV420P);
|
||||
enc.set_time_base(ff::Rational::new(1, 60));
|
||||
enc.set_max_b_frames(0);
|
||||
enc.set_gop(60);
|
||||
|
||||
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.
|
||||
unsafe {
|
||||
let key = CString::new("preset").unwrap();
|
||||
let val = CString::new("veryfast").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
let key = CString::new("tune").unwrap();
|
||||
let val = CString::new("zerolatency").unwrap();
|
||||
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
let opened = enc.open()?;
|
||||
let enc_video = opened.0;
|
||||
|
||||
let use_null_muxer = output_path
|
||||
.to_str()
|
||||
.map(|s| s.contains("null"))
|
||||
.unwrap_or(false);
|
||||
let fmt_name = if use_null_muxer {
|
||||
CString::new("null").unwrap()
|
||||
} else {
|
||||
CString::new("").unwrap()
|
||||
};
|
||||
let fmt_name_ptr = if use_null_muxer {
|
||||
fmt_name.as_ptr()
|
||||
} else {
|
||||
ptr::null()
|
||||
};
|
||||
|
||||
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
|
||||
// SAFETY: fmt_ctx_ptr is an out parameter initialized by FFmpeg; output_cstr and fmt_name live
|
||||
// across the call.
|
||||
let ret = unsafe {
|
||||
ffi::avformat_alloc_output_context2(
|
||||
&mut fmt_ctx_ptr,
|
||||
ptr::null_mut(),
|
||||
fmt_name_ptr,
|
||||
output_cstr.as_ptr(),
|
||||
)
|
||||
};
|
||||
if ret < 0 || fmt_ctx_ptr.is_null() {
|
||||
bail!("Failed to allocate output format context: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is a valid output context allocated above.
|
||||
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
|
||||
if stream_ptr.is_null() {
|
||||
bail!("Failed to create output stream");
|
||||
}
|
||||
|
||||
// SAFETY: stream and codec context pointers are valid; parameters are copied into stream.
|
||||
let ret =
|
||||
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to copy codec parameters: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
|
||||
unsafe {
|
||||
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
|
||||
let ret = ffi::avio_open(
|
||||
&mut (*fmt_ctx_ptr).pb,
|
||||
output_cstr.as_ptr(),
|
||||
ffi::AVIO_FLAG_WRITE,
|
||||
);
|
||||
if ret < 0 {
|
||||
bail!("Failed to open output file: error {ret}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: fmt_ctx_ptr is a fully configured output context.
|
||||
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
|
||||
if ret < 0 {
|
||||
bail!("Failed to write header: error {ret}");
|
||||
}
|
||||
|
||||
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
|
||||
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
|
||||
|
||||
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
|
||||
let yuv_frame = unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
(*f).width = width as i32;
|
||||
(*f).height = height as i32;
|
||||
(*f).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
|
||||
let r = ffi::av_frame_get_buffer(f, 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!("av_frame_get_buffer failed: {r}");
|
||||
}
|
||||
f
|
||||
};
|
||||
|
||||
Ok(SoftwareEncoder {
|
||||
enc_video,
|
||||
octx,
|
||||
yuv_frame,
|
||||
codec_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_sws_context(
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
src_fmt: ffi::AVPixelFormat,
|
||||
dst_width: u32,
|
||||
dst_height: u32,
|
||||
) -> Result<SwsContext> {
|
||||
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
|
||||
let ctx = unsafe {
|
||||
ffi::sws_getContext(
|
||||
src_width as i32,
|
||||
src_height as i32,
|
||||
src_fmt,
|
||||
dst_width as i32,
|
||||
dst_height as i32,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||
2,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if ctx.is_null() {
|
||||
bail!("Failed to create sws_scale context");
|
||||
}
|
||||
Ok(SwsContext(ctx))
|
||||
}
|
||||
|
||||
pub(crate) fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64> {
|
||||
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 {
|
||||
(*encoder.yuv_frame).pts = *pts;
|
||||
*pts += 1;
|
||||
let r = ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), encoder.yuv_frame);
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame failed: {r}");
|
||||
}
|
||||
}
|
||||
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
|
||||
Ok(t_encode.elapsed().as_micros() as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
|
||||
// SAFETY: Sending a null frame flushes the encoder; context remains owned by encoder.
|
||||
unsafe {
|
||||
ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), ptr::null());
|
||||
}
|
||||
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
|
||||
encoder
|
||||
.octx
|
||||
.write_trailer()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "vaapi_import_bench", about = "VAAPI DMA-BUF import benchmark")]
|
||||
pub(crate) struct BenchArgs {
|
||||
#[arg(short, long)]
|
||||
pub(crate) output: String,
|
||||
|
||||
#[arg(long, default_value_t = 60)]
|
||||
pub(crate) frames: u32,
|
||||
|
||||
#[arg(long, default_value_t = 2560)]
|
||||
pub(crate) enc_width: u32,
|
||||
|
||||
#[arg(long, default_value_t = 1440)]
|
||||
pub(crate) enc_height: u32,
|
||||
|
||||
#[arg(long, default_value = "/dev/dri/renderD128")]
|
||||
pub(crate) drm_device: String,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = PipelineMode::Both)]
|
||||
pub(crate) mode: PipelineMode,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum PipelineMode {
|
||||
Cpu,
|
||||
Gpu,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FrameStats {
|
||||
pub(crate) import_us: Vec<u64>,
|
||||
pub(crate) filter_us: Vec<u64>,
|
||||
pub(crate) transfer_us: Vec<u64>,
|
||||
pub(crate) scale_us: Vec<u64>,
|
||||
pub(crate) format_us: Vec<u64>,
|
||||
pub(crate) encode_us: Vec<u64>,
|
||||
pub(crate) total_us: Vec<u64>,
|
||||
pub(crate) import_failures: u32,
|
||||
pub(crate) frames_encoded: u32,
|
||||
pub(crate) elapsed_secs: f64,
|
||||
pub(crate) codec_name: String,
|
||||
pub(crate) output_path: String,
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
pub(crate) fn avg_total_ms(&self) -> f64 {
|
||||
Self::avg_ms(&self.total_us)
|
||||
}
|
||||
|
||||
pub(crate) fn achieved_fps(&self) -> f64 {
|
||||
if self.frames_encoded > 0 && self.elapsed_secs > 0.0 {
|
||||
self.frames_encoded as f64 / self.elapsed_secs
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn theoretical_fps(&self) -> f64 {
|
||||
let avg = self.avg_total_ms();
|
||||
if avg > 0.0 {
|
||||
1000.0 / avg
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::stats::{FrameStats, PipelineMode};
|
||||
|
||||
pub(crate) 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);
|
||||
let suffix = match mode {
|
||||
PipelineMode::Cpu => "cpu",
|
||||
PipelineMode::Gpu => "gpu",
|
||||
PipelineMode::Both => unreachable!(),
|
||||
};
|
||||
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(base);
|
||||
let split_name = if let Some((stem, ext)) = file_name.rsplit_once('.') {
|
||||
format!("{stem}.{suffix}.{ext}")
|
||||
} else {
|
||||
format!("{file_name}.{suffix}")
|
||||
};
|
||||
path.with_file_name(split_name)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn print_detailed_results(
|
||||
label: &str,
|
||||
stats: &FrameStats,
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
) {
|
||||
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);
|
||||
println!("Total time: {:.2}s", stats.elapsed_secs);
|
||||
println!("Output: {}", stats.output_path);
|
||||
if stats.import_failures > 0 {
|
||||
println!("Import failures: {}", stats.import_failures);
|
||||
}
|
||||
println!(
|
||||
"import avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.import_us)
|
||||
);
|
||||
if !stats.filter_us.is_empty() {
|
||||
println!(
|
||||
"filter avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.filter_us)
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"transfer avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.transfer_us)
|
||||
);
|
||||
if !stats.scale_us.is_empty() {
|
||||
println!(
|
||||
"scale avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.scale_us)
|
||||
);
|
||||
}
|
||||
if !stats.format_us.is_empty() {
|
||||
println!(
|
||||
"format avg: {:.2} ms/frame",
|
||||
FrameStats::avg_ms(&stats.format_us)
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"encode ({}): {:.2} ms/frame",
|
||||
stats.codec_name,
|
||||
FrameStats::avg_ms(&stats.encode_us)
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
pub(crate) fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
|
||||
println!();
|
||||
println!("=== Pipeline Comparison ===");
|
||||
if let Some(s) = cpu {
|
||||
println!(
|
||||
"CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
|
||||
FrameStats::avg_ms(&s.import_us),
|
||||
FrameStats::avg_ms(&s.transfer_us),
|
||||
FrameStats::avg_ms(&s.scale_us),
|
||||
FrameStats::avg_ms(&s.encode_us),
|
||||
s.avg_total_ms(),
|
||||
s.theoretical_fps(),
|
||||
);
|
||||
}
|
||||
if let Some(s) = gpu {
|
||||
println!(
|
||||
"GPU: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
|
||||
FrameStats::avg_ms(&s.import_us),
|
||||
FrameStats::avg_ms(&s.filter_us),
|
||||
FrameStats::avg_ms(&s.transfer_us),
|
||||
FrameStats::avg_ms(&s.format_us),
|
||||
FrameStats::avg_ms(&s.encode_us),
|
||||
s.avg_total_ms(),
|
||||
s.theoretical_fps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1302
-39
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
/// 将 PipeWire SPA 视频格式转换为 DRM FourCC 格式
|
||||
///
|
||||
/// PipeWire 使用自己的 VideoFormat 枚举,而 DRM/KMS 使用 FourCC 格式标识。
|
||||
/// 此函数建立了两者之间的映射关系。
|
||||
///
|
||||
/// 支持的格式:
|
||||
/// 不支持的格式返回 0
|
||||
/// DRM 格式名描述像素值位布局(大端序),而非内存字节序。
|
||||
/// 例如 DRM_FORMAT_ARGB8888 在小端 x86 上内存为 [B,G,R,A] = PipeWire BGRA。
|
||||
pub(super) fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 {
|
||||
use drm_fourcc::DrmFourcc;
|
||||
use libspa::param::video::VideoFormat;
|
||||
match format {
|
||||
VideoFormat::BGRA => DrmFourcc::Argb8888 as u32,
|
||||
VideoFormat::BGRx => DrmFourcc::Xrgb8888 as u32,
|
||||
VideoFormat::RGBA => DrmFourcc::Abgr8888 as u32,
|
||||
VideoFormat::RGBx => DrmFourcc::Xbgr8888 as u32,
|
||||
VideoFormat::ARGB => DrmFourcc::Bgra8888 as u32,
|
||||
VideoFormat::xRGB => DrmFourcc::Bgrx8888 as u32,
|
||||
VideoFormat::ABGR => DrmFourcc::Rgba8888 as u32,
|
||||
VideoFormat::xBGR => DrmFourcc::Rgbx8888 as u32,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drm_fourcc::DrmFourcc;
|
||||
|
||||
#[test]
|
||||
fn spa_to_drm_fourcc_all_32bit() {
|
||||
use libspa::param::video::VideoFormat;
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::BGRA),
|
||||
DrmFourcc::Argb8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::BGRx),
|
||||
DrmFourcc::Xrgb8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::RGBA),
|
||||
DrmFourcc::Abgr8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::RGBx),
|
||||
DrmFourcc::Xbgr8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::ARGB),
|
||||
DrmFourcc::Bgra8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::xRGB),
|
||||
DrmFourcc::Bgrx8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::ABGR),
|
||||
DrmFourcc::Rgba8888 as u32
|
||||
);
|
||||
assert_eq!(
|
||||
spa_to_drm_fourcc(VideoFormat::xBGR),
|
||||
DrmFourcc::Rgbx8888 as u32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spa_to_drm_fourcc_unsupported() {
|
||||
use libspa::param::video::VideoFormat;
|
||||
assert_eq!(spa_to_drm_fourcc(VideoFormat::NV12), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/// 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.
|
||||
pub(super) 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}"
|
||||
);
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::Sender;
|
||||
|
||||
use super::fourcc::spa_to_drm_fourcc;
|
||||
use super::types::{PortalFormatInfo, PwCtrlEvent, PwDmaBufFrame};
|
||||
|
||||
/// PipeWire 捕获线程的上下文数据
|
||||
///
|
||||
/// 从主线程传递给 PipeWire 捕获线程的所有必要资源。
|
||||
/// 该结构体在线程创建时一次性 move 到线程中使用。
|
||||
struct PwThreadCtx {
|
||||
frame_tx: Sender<PwDmaBufFrame>,
|
||||
event_tx: Sender<PwCtrlEvent>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
shutdown_read: OwnedFd,
|
||||
pw_fd: OwnedFd,
|
||||
node_id: u32,
|
||||
}
|
||||
|
||||
fn pipewire_thread(ctx: PwThreadCtx) {
|
||||
use pipewire as pw;
|
||||
use pw::properties::properties;
|
||||
use pw::spa::param::video::VideoInfoRaw;
|
||||
use pw::stream::{StreamBox, StreamFlags};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
// 初始化 PipeWire 进程全局库。
|
||||
//
|
||||
// pipewire-rs 内部使用 OnceCell 保护 pw::init(),确保只调用一次。
|
||||
// pw::deinit() 是 unsafe 且要求"进程生命周期内仅调用一次,且所有
|
||||
// PipeWire 使用已停止"。由于 CapPortal 可被多次创建销毁,此函数
|
||||
// 不调用 pw::deinit()——进程退出时全局状态由 OS 回收。
|
||||
pw::init();
|
||||
|
||||
let PwThreadCtx {
|
||||
frame_tx,
|
||||
event_tx,
|
||||
dropped,
|
||||
shutdown_read,
|
||||
pw_fd,
|
||||
node_id,
|
||||
} = ctx;
|
||||
|
||||
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
|
||||
Ok(ml) => ml,
|
||||
Err(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;
|
||||
}
|
||||
};
|
||||
|
||||
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
|
||||
Ok(c) => c,
|
||||
Err(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;
|
||||
}
|
||||
};
|
||||
|
||||
let core = match context.connect_fd(pw_fd, None) {
|
||||
Ok(c) => c,
|
||||
Err(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;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 PipeWire 视频流
|
||||
// 属性配置:
|
||||
// - MEDIA_TYPE = "Video": 媒体类型为视频
|
||||
// - MEDIA_CATEGORY = "Capture": 类别为捕获(而非回放)
|
||||
// - MEDIA_ROLE = "Screen": 角色为屏幕(用于策略管理)
|
||||
let stream = match StreamBox::new(
|
||||
&core,
|
||||
"wl-webrtc",
|
||||
properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Video",
|
||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||
*pw::keys::MEDIA_ROLE => "Screen",
|
||||
*pw::keys::NODE_FORCE_QUANTUM => "512",
|
||||
},
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(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;
|
||||
}
|
||||
};
|
||||
|
||||
let format_info: Rc<Cell<Option<PortalFormatInfo>>> = Rc::new(Cell::new(None));
|
||||
|
||||
let event_tx_state = event_tx.clone();
|
||||
let _listener = stream
|
||||
.add_local_listener::<()>()
|
||||
.state_changed(move |_, _, old, new| {
|
||||
tracing::info!("PipeWire stream state: {old:?} -> {new:?}");
|
||||
match new {
|
||||
pw::stream::StreamState::Error(e) => {
|
||||
tracing::error!("PipeWire stream error: {e}");
|
||||
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
|
||||
}
|
||||
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 => {}
|
||||
}
|
||||
})
|
||||
// 参数变化回调(格式协商)
|
||||
// PipeWire 在流格式协商完成后触发此回调
|
||||
// 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 };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
// 解析视频格式信息(分辨率、像素格式、修饰符等)
|
||||
let mut info = VideoInfoRaw::new();
|
||||
if let Err(e) = info.parse(param) {
|
||||
tracing::warn!("Failed to parse video format: {e}");
|
||||
return;
|
||||
}
|
||||
let width = info.size().width;
|
||||
let height = info.size().height;
|
||||
// 将 SPA 视频格式转换为 DRM FourCC 格式标识符
|
||||
let drm_format = spa_to_drm_fourcc(info.format());
|
||||
// 获取 DRM 修饰符,描述 GPU buffer 的内存布局(如 tiling 模式)
|
||||
let modifier = info.modifier();
|
||||
let framerate = info.framerate();
|
||||
let max_framerate = info.max_framerate();
|
||||
// 保存协商后的格式信息,供 process 回调读取
|
||||
let previous_format = format_info.get();
|
||||
format_info.set(Some(PortalFormatInfo {
|
||||
width,
|
||||
height,
|
||||
drm_format,
|
||||
modifier,
|
||||
}));
|
||||
if let Some(prev) = previous_format {
|
||||
if width != prev.width || height != prev.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,
|
||||
);
|
||||
}
|
||||
})
|
||||
// 帧处理回调 —— 这是核心的数据路径
|
||||
// 每当 PipeWire 有新的帧数据可用时触发
|
||||
// 关键操作: 从 buffer 中提取 DMA-BUF fd,dup 后通过 channel 发送给消费者
|
||||
.process({
|
||||
let format_info = format_info.clone();
|
||||
let frame_tx = frame_tx.clone();
|
||||
move |stream, _| {
|
||||
// SAFETY: raw_buf ownership invariant — PipeWire's process callback
|
||||
// contract requires that every buffer acquired via `dequeue_raw_buffer`
|
||||
// is returned to the queue EXACTLY ONCE via `queue_raw_buffer` before
|
||||
// the callback returns — on every exit path, success or error. Failure
|
||||
// to requeue leaks the buffer slot and eventually stalls the stream.
|
||||
//
|
||||
// Audit map of this closure (verified 2026-06-28):
|
||||
// - null raw_buf (dequeue returned NULL) → nothing to requeue, return.
|
||||
// - null spa_buf / no data / bad fd / null chunk / no format_info /
|
||||
// invalid dims / dup_fd < 0 → all requeue before early-return.
|
||||
// - success (try_send Ok / Full / Disconnected) → final requeue at end.
|
||||
// The fd ownership is independent: dup() creates a fresh fd that lives
|
||||
// inside PwDmaBufFrame; on try_send error the frame Drops and closes it.
|
||||
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
|
||||
if raw_buf.is_null() {
|
||||
tracing::trace!("process: null raw_buf");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 SPA buffer 结构体,包含数据数组、元数据等
|
||||
// SAFETY: raw_buf was checked non-null above. `pw_buffer.buffer` is a
|
||||
// valid raw pointer for the lifetime of raw_buf (PipeWire keeps the
|
||||
// buffer alive until we queue it back).
|
||||
let spa_buf = unsafe { (*raw_buf).buffer };
|
||||
if spa_buf.is_null() {
|
||||
tracing::trace!("process: null spa_buf");
|
||||
// SAFETY: raw_buf is the non-null buffer we still own; returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 buffer 中的数据项数量和数据指针
|
||||
// 对于 DMA-BUF 帧,通常只有 1 个数据项(包含 fd)
|
||||
// SAFETY: spa_buf checked non-null above; `n_datas` is a plain u32 field.
|
||||
let n_datas = unsafe { (*spa_buf).n_datas };
|
||||
// SAFETY: same as above; `datas` is a raw pointer field, may be null.
|
||||
let datas_ptr = unsafe { (*spa_buf).datas };
|
||||
if n_datas == 0 || datas_ptr.is_null() {
|
||||
tracing::trace!("process: no data (n_datas={n_datas})");
|
||||
// SAFETY: raw_buf still owned, returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
// 从第一个数据项中获取 DMA-BUF 文件描述符
|
||||
// 通过 libspa 的 Data 包装类型安全地访问 SPA 数据结构
|
||||
// SAFETY: datas_ptr is non-null and n_datas > 0 (checked above). We cast
|
||||
// to pw::spa::buffer::Data and take a shared borrow; PipeWire does not
|
||||
// mutate the data array during a process cycle, so a shared reference
|
||||
// for the duration of this callback is sound.
|
||||
let data_ref: &pw::spa::buffer::Data =
|
||||
unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
|
||||
let fd = data_ref.fd();
|
||||
if fd < 0 {
|
||||
tracing::trace!("process: invalid fd={fd}");
|
||||
// SAFETY: raw_buf still owned, returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
if data_ref.as_raw().chunk.is_null() {
|
||||
tracing::trace!("process: null chunk");
|
||||
// SAFETY: raw_buf still owned, returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
let chunk = data_ref.chunk();
|
||||
let offset = chunk.offset() as u64;
|
||||
let stride = chunk.stride() as u32;
|
||||
|
||||
// 从 SPA_META_Header 元数据中提取 PTS (显示时间戳)
|
||||
// 遍历 buffer 的所有元数据项,查找 Header 类型的元数据
|
||||
// PTS 可用于音视频同步和帧率控制
|
||||
// SAFETY: spa_buf is non-null. `metas` is checked for null before
|
||||
// iteration. We iterate `i in 0..n_metas` reading shared POD fields
|
||||
// (type_, size, data) — PipeWire keeps the meta array immutable during
|
||||
// a process cycle. The size guard (`meta.size >= size_of::<spa_meta_header>()`)
|
||||
// and null-data check before reading ensure we never read past the
|
||||
// meta's actual extent.
|
||||
let pts: i64 = unsafe {
|
||||
let mut pts_val: i64 = 0;
|
||||
let n_metas = (*spa_buf).n_metas;
|
||||
let metas = (*spa_buf).metas;
|
||||
if !metas.is_null() {
|
||||
for i in 0..n_metas {
|
||||
let meta = &*metas.add(i as usize);
|
||||
if meta.type_ == libspa::sys::SPA_META_Header
|
||||
&& meta.size as usize
|
||||
>= std::mem::size_of::<libspa::sys::spa_meta_header>()
|
||||
&& !meta.data.is_null()
|
||||
{
|
||||
let header = &*(meta.data as *const libspa::sys::spa_meta_header);
|
||||
pts_val = header.pts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pts_val
|
||||
};
|
||||
|
||||
// 验证格式信息已协商完成,且分辨率和格式有效
|
||||
let Some(fmt) = format_info.get() else {
|
||||
// SAFETY: raw_buf still owned, returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
};
|
||||
let PortalFormatInfo {
|
||||
width,
|
||||
height,
|
||||
drm_format: format,
|
||||
modifier,
|
||||
} = fmt;
|
||||
if width == 0 || height == 0 || format == 0 {
|
||||
tracing::trace!("process: invalid dimensions {width}x{height} format={format}");
|
||||
// SAFETY: raw_buf still owned, returning it.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
// 复制 DMA-BUF 文件描述符
|
||||
// 必须 dup,因为原始 fd 由 PipeWire 管理,我们不能持有它
|
||||
// dup 后的 fd 由 PwDmaBufFrame 持有,生命周期独立于 PipeWire buffer
|
||||
// SAFETY: `fd` is the open DMA-BUF fd reported by PipeWire (>= 0 checked
|
||||
// above). libc::dup is the standard POSIX fd duplication call. The
|
||||
// original `fd` remains owned by PipeWire (returned with raw_buf later).
|
||||
let dup_fd = unsafe { libc::dup(fd) };
|
||||
if dup_fd < 0 {
|
||||
// SAFETY: raw_buf still owned, returning it. No fd cleanup needed
|
||||
// because dup() failed and never returned a new fd.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建帧数据对象,所有必要的帧信息已收集完毕
|
||||
// SAFETY: `dup_fd` is a freshly-dup'd open file descriptor (>= 0 checked
|
||||
// above) and we are its sole owner. OwnedFd::from_raw_fd takes ownership
|
||||
// and will close() it on Drop. The fd's lifecycle is independent of
|
||||
// raw_buf: whether try_send succeeds (frame moves into the channel) or
|
||||
// fails (Full/Disconnected — the error payload owns the frame and drops
|
||||
// it at the end of the match arm), exactly one close() occurs per dup().
|
||||
let frame_fd = unsafe { OwnedFd::from_raw_fd(dup_fd) };
|
||||
let frame = PwDmaBufFrame {
|
||||
fd: frame_fd,
|
||||
offset,
|
||||
stride,
|
||||
modifier,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
pts,
|
||||
};
|
||||
|
||||
match frame_tx.try_send(frame) {
|
||||
Ok(()) => {}
|
||||
Err(crossbeam_channel::TrySendError::Full(_)) => {
|
||||
dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
|
||||
}
|
||||
// SAFETY: final exactly-once requeue of raw_buf. Every path above
|
||||
// either returned early with its own requeue, or falls through to here.
|
||||
unsafe { stream.queue_raw_buffer(raw_buf) };
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let mut params: [&pw::spa::pod::Pod; 0] = [];
|
||||
|
||||
if let Err(e) = stream.connect(
|
||||
pw::spa::utils::Direction::Input,
|
||||
Some(node_id),
|
||||
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
|
||||
&mut params,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
let loop_ = mainloop.loop_();
|
||||
|
||||
// Register the shutdown eventfd on the PipeWire loop.
|
||||
//
|
||||
// When CapPortal::drop writes to the eventfd, the loop wakes up and
|
||||
// dispatches this callback on the loop thread. Because the callback
|
||||
// only fires while mainloop.run() is blocking this thread, mainloop
|
||||
// is guaranteed alive — eliminating the UAF that existed with the
|
||||
// previous detached helper thread approach.
|
||||
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
|
||||
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
|
||||
let mainloop_ptr = mainloop.as_raw_ptr();
|
||||
|
||||
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;
|
||||
// SAFETY: `fd` is the registered eventfd owned by the mainloop source; the
|
||||
// buffer is a stack u64 of 8 bytes matching the count argument. POSIX
|
||||
// read(2) is the standard fd-read syscall; eventfd semantics require the
|
||||
// 8-byte buffer.
|
||||
let _ = unsafe {
|
||||
libc::read(
|
||||
fd.as_raw_fd(),
|
||||
&mut buf as *mut u64 as *mut _,
|
||||
std::mem::size_of::<u64>(),
|
||||
)
|
||||
};
|
||||
// SAFETY: This callback only executes while mainloop.run() is
|
||||
// blocking this thread, so mainloop is guaranteed alive.
|
||||
unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr) };
|
||||
},
|
||||
);
|
||||
|
||||
// 启动 PipeWire 主事件循环
|
||||
// 此调用会阻塞当前线程,直到 mainloop.quit() 被调用
|
||||
// quit() 由 shutdown eventfd 的 IO 回调触发
|
||||
mainloop.run();
|
||||
|
||||
// run() returned — _shutdown_source drops first (reverse declaration order),
|
||||
// which unregisters the callback from the loop. Then mainloop drops.
|
||||
// No dangling raw pointers are possible.
|
||||
// PipeWire global state is intentionally not deinitialized here — see pw::init() comment above.
|
||||
}
|
||||
|
||||
pub(super) fn spawn_pipewire_thread(
|
||||
frame_tx: Sender<PwDmaBufFrame>,
|
||||
event_tx: Sender<PwCtrlEvent>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
shutdown_read: OwnedFd,
|
||||
pw_fd: OwnedFd,
|
||||
node_id: u32,
|
||||
) -> Result<JoinHandle<()>> {
|
||||
let ctx = PwThreadCtx {
|
||||
frame_tx,
|
||||
event_tx,
|
||||
dropped,
|
||||
shutdown_read,
|
||||
pw_fd,
|
||||
node_id,
|
||||
};
|
||||
thread::Builder::new()
|
||||
.name("pipewire-capture".into())
|
||||
.spawn(move || pipewire_thread(ctx))
|
||||
.map_err(|e| anyhow::anyhow!("thread spawn failed: {e}"))
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::logging::log_portal_phase_timeout;
|
||||
use super::token_fs::{delete_restore_token, load_restore_token, save_restore_token};
|
||||
use super::types::{PortalPhaseTimeout, PORTAL_SERVICE_TIMEOUT, PORTAL_USER_DIALOG_TIMEOUT};
|
||||
use super::CapPortal;
|
||||
|
||||
impl CapPortal {
|
||||
/// 通过 XDG Desktop Portal 建立屏幕录制会话
|
||||
///
|
||||
/// 与桌面环境的 D-Bus 服务交互,请求用户授权屏幕录制。
|
||||
/// 流程:
|
||||
/// 1. 创建 Screencast 代理(D-Bus 代理)
|
||||
/// 2. 创建 ScreenCast 会话
|
||||
/// 3. 配置源选择参数(光标模式、显示器源、不持久化会话)
|
||||
/// 4. 启动录制,获取流信息(包含 PipeWire node_id)
|
||||
/// 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`.
|
||||
pub(super) async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
|
||||
match Self::_setup_portal_inner(no_persist, false).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) if e.is::<PortalPhaseTimeout>() => {
|
||||
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
|
||||
match inner_err {
|
||||
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();
|
||||
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).
|
||||
pub(super) async fn _setup_portal_inner(
|
||||
no_persist: bool,
|
||||
is_retry: bool,
|
||||
) -> Result<(OwnedFd, u32)> {
|
||||
use ashpd::desktop::screencast::{
|
||||
CursorMode, Screencast, SelectSourcesOptions, SourceType,
|
||||
};
|
||||
use ashpd::desktop::PersistMode;
|
||||
|
||||
// Phase 1: Screencast proxy (no user interaction).
|
||||
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);
|
||||
return Err(PortalPhaseTimeout::Service.into());
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: create_session (no user interaction).
|
||||
let session = match tokio::time::timeout(
|
||||
PORTAL_SERVICE_TIMEOUT,
|
||||
proxy.create_session(Default::default()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
let version_supported = proxy.version() >= 4;
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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.
|
||||
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);
|
||||
return Err(if token_in_use {
|
||||
PortalPhaseTimeout::TokenDependent
|
||||
} else {
|
||||
PortalPhaseTimeout::Service
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
|
||||
let phase4_timeout = if token_in_use {
|
||||
PORTAL_SERVICE_TIMEOUT
|
||||
} else {
|
||||
PORTAL_USER_DIALOG_TIMEOUT
|
||||
};
|
||||
let start_fut = async {
|
||||
proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await?
|
||||
.response()
|
||||
};
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
if !no_persist && version_supported {
|
||||
if let Some(new_token) = response.restore_token() {
|
||||
save_restore_token(new_token);
|
||||
}
|
||||
}
|
||||
|
||||
let stream = response
|
||||
.streams()
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?;
|
||||
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
|
||||
// Phase 5: open_pipe_wire_remote (no user interaction).
|
||||
let fd = match tokio::time::timeout(
|
||||
PORTAL_SERVICE_TIMEOUT,
|
||||
proxy.open_pipe_wire_remote(&session, Default::default()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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}");
|
||||
|
||||
Ok((fd, node_id))
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn token_path() -> Option<PathBuf> {
|
||||
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).
|
||||
pub(super) fn verify_secure_dir(path: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.file_type().is_symlink() {
|
||||
tracing::warn!(
|
||||
"Token parent dir is a symlink, rejecting: {}",
|
||||
path.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Must be a directory
|
||||
if !meta.is_dir() {
|
||||
tracing::warn!("Token parent path is not a directory: {}", path.display());
|
||||
return false;
|
||||
}
|
||||
// Must be owned by current user
|
||||
// SAFETY: libc::getuid has no preconditions and cannot fail; it simply
|
||||
// returns the calling process's real user ID.
|
||||
// SAFETY: libc::getuid has no preconditions and cannot fail.
|
||||
if meta.uid() != unsafe { libc::getuid() } {
|
||||
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)
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode != 0o700 {
|
||||
tracing::warn!(
|
||||
"Token parent dir has insecure permissions {:o}, expected 0700: {}",
|
||||
mode,
|
||||
path.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to stat token parent dir: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the parent directory exists with restrictive permissions (0o700).
|
||||
/// Returns false if the directory could not be created or is insecure.
|
||||
pub(super) fn ensure_secure_parent(parent: &std::path::Path) -> bool {
|
||||
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.
|
||||
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;
|
||||
}
|
||||
return verify_secure_dir(parent);
|
||||
}
|
||||
|
||||
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
builder.mode(0o700);
|
||||
if let Err(e) = builder.create(parent) {
|
||||
tracing::warn!("Failed to create token directory: {e}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify after creation (belt-and-suspenders)
|
||||
verify_secure_dir(parent)
|
||||
}
|
||||
|
||||
pub(super) fn load_restore_token() -> Option<String> {
|
||||
load_restore_token_from(token_path()?)
|
||||
}
|
||||
|
||||
pub(super) fn load_restore_token_from(path: PathBuf) -> Option<String> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
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;
|
||||
}
|
||||
// SAFETY: libc::getuid has no preconditions and cannot fail.
|
||||
if meta.uid() != unsafe { libc::getuid() } {
|
||||
tracing::warn!("Token file not owned by current user: {}", path.display());
|
||||
return None;
|
||||
}
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
tracing::warn!(
|
||||
"Token file has insecure permissions {:o}, refusing to read: {}",
|
||||
mode,
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let token = std::fs::read_to_string(&path).ok()?;
|
||||
let trimmed = token.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_restore_token(token: &str) {
|
||||
let Some(path) = token_path() else {
|
||||
tracing::warn!("No secure cache directory available, skipping token save");
|
||||
return;
|
||||
};
|
||||
save_restore_token_to(token, &path);
|
||||
}
|
||||
|
||||
pub(super) fn delete_restore_token() {
|
||||
let Some(path) = token_path() else {
|
||||
return;
|
||||
};
|
||||
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()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
tracing::warn!("Token path has no parent directory");
|
||||
return;
|
||||
};
|
||||
|
||||
if !ensure_secure_parent(parent) {
|
||||
tracing::warn!("Parent directory is insecure, refusing to save token");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => tracing::info!("Saved portal restore token"),
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
tracing::warn!("Failed to save restore token: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn token_path_never_uses_tmp() {
|
||||
assert!(token_path().is_some(), "token_path should resolve on Linux");
|
||||
let path = token_path().unwrap();
|
||||
assert!(!path.starts_with("/tmp"), "must not fallback to /tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_secure_dir_rejects_wrong_permissions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
// 0o700 should pass
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(verify_secure_dir(path));
|
||||
|
||||
// 0o755 should fail
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(!verify_secure_dir(path));
|
||||
|
||||
// 0o777 should fail
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(!verify_secure_dir(path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_secure_dir_rejects_non_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("not-a-dir");
|
||||
std::fs::write(&file_path, b"test").unwrap();
|
||||
assert!(!verify_secure_dir(&file_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_secure_parent_creates_with_0700() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let new_dir = base.path().join("wl-test-new-dir");
|
||||
assert!(!new_dir.exists());
|
||||
|
||||
assert!(ensure_secure_parent(&new_dir));
|
||||
assert!(new_dir.is_dir());
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_secure_parent_tightens_existing_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
// Simulate an existing directory with loose permissions
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert!(ensure_secure_parent(path));
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_file_with_0600() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("portal-restore-token");
|
||||
|
||||
save_restore_token_to("secret-token-123", &token_path);
|
||||
|
||||
assert!(token_path.exists());
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_secure_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("portal-restore-token");
|
||||
|
||||
// Write a valid 0o600 token file
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&token_path)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut f, b"my-secret\n").unwrap();
|
||||
|
||||
let result = load_restore_token_from(token_path);
|
||||
assert_eq!(result, Some("my-secret".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_group_readable_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("portal-restore-token");
|
||||
|
||||
// Write with 0o640 (group readable) — should be rejected
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o640)
|
||||
.open(&token_path)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
|
||||
|
||||
let result = load_restore_token_from(token_path);
|
||||
assert!(result.is_none(), "should reject group-readable token file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_world_readable_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("portal-restore-token");
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o604)
|
||||
.open(&token_path)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
|
||||
|
||||
let result = load_restore_token_from(token_path);
|
||||
assert!(result.is_none(), "should reject world-readable token file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_symlink() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real_path = dir.path().join("real-file");
|
||||
let link_path = dir.path().join("portal-restore-token");
|
||||
|
||||
std::fs::write(&real_path, b"target-content\n").unwrap();
|
||||
std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
|
||||
|
||||
let result = load_restore_token_from(link_path);
|
||||
assert!(result.is_none(), "should reject symlinked token file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("portal-restore-token");
|
||||
|
||||
save_restore_token_to("roundtrip-token", &token_path);
|
||||
let loaded = load_restore_token_from(token_path);
|
||||
|
||||
assert_eq!(loaded, Some("roundtrip-token".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
pub(super) const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
pub(super) 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)]
|
||||
pub(super) 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 {}
|
||||
|
||||
/// PipeWire DMA-BUF 帧数据
|
||||
///
|
||||
/// 表示从 PipeWire 流中接收到的一帧视频数据。
|
||||
/// 帧的像素数据存储在 DMA-BUF(Linux 的零拷贝 buffer 共享机制)中,
|
||||
/// 通过文件描述符 (fd) 引用,消费者通过 mmap 或 DRM 导入来访问像素数据。
|
||||
pub struct PwDmaBufFrame {
|
||||
/// DMA-BUF 文件描述符,指向 GPU 显存中的帧缓冲区
|
||||
pub fd: OwnedFd,
|
||||
/// 帧数据在 DMA-BUF 中的字节偏移量
|
||||
pub offset: u64,
|
||||
/// 每行像素的字节跨度(可能大于 width * bpp,因为可能有对齐填充)
|
||||
pub stride: u32,
|
||||
/// DRM 格式修饰符,描述 buffer 的内存布局(如线性布局、tiling 等)
|
||||
pub modifier: u64,
|
||||
/// 帧宽度(像素)
|
||||
pub width: u32,
|
||||
/// 帧高度(像素)
|
||||
pub height: u32,
|
||||
/// DRM FourCC 格式标识符(如 BGRA、RGBA 等)
|
||||
pub format: u32,
|
||||
/// 显示时间戳 (PTS, Presentation Time Stamp),单位为纳秒
|
||||
pub pts: i64,
|
||||
}
|
||||
|
||||
/// PipeWire-negotiated video format snapshot, stashed in a `Cell` for cross-callback
|
||||
/// sharing (format-change callback writes it; process callback reads it). The four
|
||||
/// fields are the minimal subset of `PwDmaBufFrame`'s metadata that the process
|
||||
/// callback needs to construct the frame once a buffer arrives.
|
||||
///
|
||||
/// `Copy` is required because we store it inside `Cell<Option<PortalFormatInfo>>`;
|
||||
/// `Cell` requires its contents to be `Copy` (no borrowed interior state).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PortalFormatInfo {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// DRM FourCC format code (e.g. `0x34325258` for XR24 / XRGB8888).
|
||||
pub drm_format: u32,
|
||||
/// DRM format modifier describing buffer layout (linear, tiling, etc.).
|
||||
pub modifier: u64,
|
||||
}
|
||||
|
||||
/// PipeWire 控制事件枚举
|
||||
///
|
||||
/// 从 PipeWire 捕获线程发送给消费者的控制事件。
|
||||
/// 与帧数据分离,通过独立的 channel 传输,确保控制事件不被帧数据淹没。
|
||||
pub enum PwCtrlEvent {
|
||||
/// 流已结束(PipeWire 流断开连接或进入错误状态)
|
||||
StreamEnded,
|
||||
/// Format/dimensions changed mid-stream
|
||||
FormatChanged { width: u32, height: u32 },
|
||||
/// 发生错误,包含错误描述信息
|
||||
Error(String),
|
||||
}
|
||||
@@ -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 — frame allocation is
|
||||
/// driven by Dispatch impls calling `manager.capture_output()`, so there
|
||||
/// is no synchronous `alloc_frame`-style API on this trait.
|
||||
/// 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,21 +67,40 @@ 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
|
||||
// (buffer event → negotiate format → create DMA-BUF). This method
|
||||
// always returns None.
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +129,11 @@ 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);
|
||||
}
|
||||
@@ -96,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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -1,11 +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;
|
||||
|
||||
+85
-5
@@ -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;
|
||||
|
||||
// 各功能模块声明
|
||||
@@ -21,6 +58,8 @@ 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;
|
||||
@@ -46,6 +85,11 @@ fn main() -> Result<()> {
|
||||
|
||||
// 根据 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")
|
||||
@@ -53,6 +97,11 @@ fn main() -> Result<()> {
|
||||
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)
|
||||
@@ -69,6 +118,8 @@ fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// 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");
|
||||
}
|
||||
@@ -79,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),
|
||||
@@ -104,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();
|
||||
@@ -119,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
|
||||
};
|
||||
@@ -148,9 +213,9 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
revents: 0,
|
||||
};
|
||||
// timeout=0 表示非阻塞,立即返回当前 fd 状态
|
||||
// SAFETY: `pfd` is a stack-allocated libc::pollfd initialized above with a
|
||||
// valid wayland_fd and POLLIN events; nfds=1 matches the single-element
|
||||
// array; timeout=0 is non-blocking. POSIX poll(2) writes revents in place.
|
||||
// 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={}",
|
||||
@@ -176,7 +241,7 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
|
||||
// 注册 SIGINT / SIGTERM 信号用于优雅退出
|
||||
// signal_hook_mio 将 Unix 信号转换为 fd 可读事件,
|
||||
// 这样信号也可以通过 epoll 统一监听,不需要单独的信号处理器
|
||||
let mut signals = signal_hook_mio::v1_0::Signals::new([
|
||||
let mut signals = signal_hook_mio::v1_0::Signals::new(&[
|
||||
signal_hook::consts::SIGINT, // Ctrl+C
|
||||
signal_hook::consts::SIGTERM, // kill 命令默认信号
|
||||
])?;
|
||||
@@ -228,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");
|
||||
@@ -237,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 会将合成器事件
|
||||
@@ -279,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}");
|
||||
}
|
||||
@@ -300,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)");
|
||||
@@ -308,12 +384,13 @@ 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)
|
||||
// Portal 后端不需要监听 Wayland fd,只需处理 Unix 信号
|
||||
// 因为帧数据是通过 PipeWire 独立投递的,不走 Wayland 协议
|
||||
let mut signals = signal_hook_mio::v1_0::Signals::new([
|
||||
let mut signals = signal_hook_mio::v1_0::Signals::new(&[
|
||||
signal_hook::consts::SIGINT,
|
||||
signal_hook::consts::SIGTERM,
|
||||
])?;
|
||||
@@ -355,6 +432,9 @@ 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)? {}
|
||||
}
|
||||
|
||||
+2188
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
use wayland_client::protocol::wl_buffer::WlBuffer;
|
||||
use wayland_client::{Dispatch, Proxy, QueueHandle};
|
||||
|
||||
use crate::state::{CaptureSource, State};
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlBuffer, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &WlBuffer,
|
||||
event: <WlBuffer as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
if let wayland_client::protocol::wl_buffer::Event::Release = event {
|
||||
tracing::trace!("WlBuffer released");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
use std::mem;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wayland_client::{Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::{
|
||||
Event as BufferParamsEvent, ZwpLinuxBufferParamsV1,
|
||||
};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{
|
||||
Event as DmabufFeedbackEvent, ZwpLinuxDmabufFeedbackV1,
|
||||
};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{
|
||||
Event as DmabufEvent, ZwpLinuxDmabufV1,
|
||||
};
|
||||
|
||||
use crate::state::{CaptureSource, EncConstructionStage, InFlightSurface, State};
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwpLinuxDmabufV1,
|
||||
event: <ZwpLinuxDmabufV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
DmabufEvent::Format { .. } => {}
|
||||
DmabufEvent::Modifier { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZwpLinuxDmabufFeedbackV1,
|
||||
event: <ZwpLinuxDmabufFeedbackV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
DmabufFeedbackEvent::MainDevice { device } => {
|
||||
if device.len() >= 8 {
|
||||
let dev_bytes: [u8; 8] = device[..8].try_into().unwrap_or([0u8; 8]);
|
||||
let dev = u64::from_ne_bytes(dev_bytes);
|
||||
let minor = ((dev & 0xFF) | ((dev >> 12) & 0xFFFFFF00)) as u32;
|
||||
let path = PathBuf::from(format!("/dev/dri/renderD{}", minor));
|
||||
if path.exists() {
|
||||
tracing::info!(
|
||||
"Compositor DRM device: {} (dev_t: {})",
|
||||
path.display(),
|
||||
dev
|
||||
);
|
||||
state.drm_device_from_compositor = Some(path);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Compositor reported DRM device {} (dev_t: {}) but path does not exist",
|
||||
path.display(),
|
||||
dev
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"main_device event with unexpected data length: {}",
|
||||
device.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
DmabufFeedbackEvent::FormatTable { .. } => {}
|
||||
DmabufFeedbackEvent::Done => {}
|
||||
DmabufFeedbackEvent::TrancheDone => {}
|
||||
DmabufFeedbackEvent::TrancheTargetDevice { .. } => {}
|
||||
DmabufFeedbackEvent::TrancheFormats { .. } => {}
|
||||
DmabufFeedbackEvent::TrancheFlags { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwpLinuxBufferParamsV1, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ZwpLinuxBufferParamsV1,
|
||||
event: <ZwpLinuxBufferParamsV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
BufferParamsEvent::Created { .. } => {
|
||||
tracing::debug!("DMA-BUF buffer created");
|
||||
}
|
||||
BufferParamsEvent::Failed => {
|
||||
tracing::error!("DMA-BUF buffer creation failed");
|
||||
let taken = mem::replace(&mut state.in_flight_surface, InFlightSurface::None);
|
||||
match taken {
|
||||
InFlightSurface::CopyQueued { buffer, frame, .. } => {
|
||||
drop(buffer);
|
||||
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
|
||||
cap.on_done_with_frame(frame);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
state.in_flight_surface = other;
|
||||
}
|
||||
}
|
||||
proxy.destroy();
|
||||
state.errored = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mod buffer;
|
||||
mod dmabuf;
|
||||
mod output_mgr;
|
||||
mod registry;
|
||||
mod screencopy;
|
||||
mod wl_output;
|
||||
@@ -1,109 +0,0 @@
|
||||
use wayland_client::{event_created_child, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
|
||||
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_head_v1::{
|
||||
self, Event as WlrHeadEvent, ZwlrOutputHeadV1,
|
||||
};
|
||||
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::{
|
||||
self, Event as WlrOutputManagerEvent, ZwlrOutputManagerV1,
|
||||
};
|
||||
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_mode_v1::ZwlrOutputModeV1;
|
||||
|
||||
use crate::state::{CaptureSource, EncConstructionStage, State, WlrHeadInfo};
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZxdgOutputManagerV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZxdgOutputManagerV1,
|
||||
_event: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwlrOutputManagerV1, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZwlrOutputManagerV1,
|
||||
event: <ZwlrOutputManagerV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
WlrOutputManagerEvent::Head { head } => {
|
||||
let _head: ZwlrOutputHeadV1 = head;
|
||||
tracing::debug!("wlr output head advertised");
|
||||
}
|
||||
WlrOutputManagerEvent::Done { .. } => {
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
wlr_manager_done,
|
||||
outputs,
|
||||
..
|
||||
} = &mut state.stage
|
||||
{
|
||||
*wlr_manager_done = true;
|
||||
let count = outputs.len();
|
||||
for idx in 0..count {
|
||||
state.try_finalize_output(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
WlrOutputManagerEvent::Finished => {
|
||||
tracing::warn!("zwlr_output_manager_v1::Finished received during probing");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(State<S>, ZwlrOutputManagerV1, [
|
||||
zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwlrOutputHeadV1, ()> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ZwlrOutputHeadV1,
|
||||
event: <ZwlrOutputHeadV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
match event {
|
||||
WlrHeadEvent::Name { name } => {
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
wlr_heads,
|
||||
wlr_head_proxy_to_name,
|
||||
..
|
||||
} = &mut state.stage
|
||||
{
|
||||
wlr_heads.entry(name.clone()).or_insert(WlrHeadInfo {});
|
||||
wlr_head_proxy_to_name.insert(proxy.id(), name);
|
||||
}
|
||||
}
|
||||
WlrHeadEvent::Position { .. } => {}
|
||||
WlrHeadEvent::Finished => {
|
||||
tracing::debug!("zwlr_output_head_v1::Finished received");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(State<S>, ZwlrOutputHeadV1, [
|
||||
zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZwlrOutputModeV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwlrOutputModeV1,
|
||||
_event: <ZwlrOutputModeV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use wayland_client::globals::GlobalListContents;
|
||||
use wayland_client::protocol::wl_output::WlOutput;
|
||||
use wayland_client::protocol::wl_registry::WlRegistry;
|
||||
use wayland_client::{Dispatch, QueueHandle};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1;
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
|
||||
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::ZwlrOutputManagerV1;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||||
|
||||
use crate::state::{CaptureSource, EncConstructionStage, OutputId, PartialOutputInfo, State};
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlRegistry, GlobalListContents> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wayland_client::protocol::wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_conn: &wayland_client::Connection,
|
||||
qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
use wayland_client::protocol::wl_registry::Event as RegistryEvent;
|
||||
|
||||
match event {
|
||||
RegistryEvent::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"zwlr_screencopy_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Binding zwlr_screencopy_manager_v1 v{v} (name={name})");
|
||||
let mgr: ZwlrScreencopyManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
screencopy_manager, ..
|
||||
} = &mut state.stage
|
||||
{
|
||||
*screencopy_manager = Some(mgr);
|
||||
}
|
||||
}
|
||||
"zwp_linux_dmabuf_v1" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Binding zwp_linux_dmabuf_v1 v{v} (name={name})");
|
||||
let proxy: ZwpLinuxDmabufV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
..
|
||||
} = &mut state.stage
|
||||
{
|
||||
*dmabuf = Some(proxy.clone());
|
||||
if v >= 4 {
|
||||
let feedback = proxy.get_default_feedback(qhandle, ());
|
||||
*dmabuf_feedback = Some(feedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
"wl_output" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Binding wl_output v{v} (name={name})");
|
||||
let output: WlOutput = registry.bind(name, v, qhandle, OutputId(name));
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
xdg_output_manager,
|
||||
..
|
||||
} = &mut state.stage
|
||||
{
|
||||
outputs.push(PartialOutputInfo::default());
|
||||
bound_outputs.push(output.clone());
|
||||
output_names.push(name);
|
||||
if let Some(xdg_mgr) = xdg_output_manager {
|
||||
let output_id = OutputId(name);
|
||||
xdg_mgr.get_xdg_output(&output, qhandle, output_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Binding zxdg_output_manager_v1 v{v} (name={name})");
|
||||
let xdg_mgr: ZxdgOutputManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
bound_outputs,
|
||||
xdg_output_manager,
|
||||
output_names,
|
||||
..
|
||||
} = &mut state.stage
|
||||
{
|
||||
for (i, output) in bound_outputs.iter().enumerate() {
|
||||
let oname = output_names.get(i).copied().unwrap_or(0);
|
||||
let output_id = OutputId(oname);
|
||||
xdg_mgr.get_xdg_output(output, qhandle, output_id);
|
||||
}
|
||||
*xdg_output_manager = Some(xdg_mgr);
|
||||
}
|
||||
}
|
||||
"zwlr_output_manager_v1" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Binding zwlr_output_manager_v1 v{v} (name={name})");
|
||||
let mgr: ZwlrOutputManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
wlr_output_manager, ..
|
||||
} = &mut state.stage
|
||||
{
|
||||
*wlr_output_manager = Some(mgr);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
RegistryEvent::GlobalRemove { name } => {
|
||||
tracing::debug!("Global removed: name={name}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
use wayland_client::{Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{
|
||||
Event as ScreencopyFrameEvent, ZwlrScreencopyFrameV1,
|
||||
};
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||||
|
||||
use crate::cap_wlr_screencopy::CapWlrScreencopy;
|
||||
use crate::state::{EncConstructionStage, InFlightSurface, State};
|
||||
|
||||
impl Dispatch<ZwlrScreencopyFrameV1, ()> for State<CapWlrScreencopy> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ZwlrScreencopyFrameV1,
|
||||
event: <ZwlrScreencopyFrameV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<CapWlrScreencopy>>,
|
||||
) {
|
||||
match event {
|
||||
// SHM buffer offer — in v3 the compositor enumerates supported buffer
|
||||
// types (buffer and/or linux_dmabuf) before buffer_done. We only
|
||||
// support DMA-BUF, so just log and wait for linux_dmabuf / buffer_done.
|
||||
ScreencopyFrameEvent::Buffer { .. } => {
|
||||
tracing::debug!("Received SHM Buffer offer — only DMA-BUF capture is supported");
|
||||
}
|
||||
ScreencopyFrameEvent::LinuxDmabuf {
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
tracing::debug!("Screencopy LinuxDmabuf: format={format}, {width}x{height}");
|
||||
|
||||
if !matches!(state.in_flight_surface, InFlightSurface::AllocQueued) {
|
||||
tracing::warn!("Received LinuxDmabuf while no frame allocation was queued");
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(state.stage, EncConstructionStage::EverythingButFmt { .. }) {
|
||||
state.negotiate_format(format, width, height);
|
||||
if state.errored {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
|
||||
cap.current_frame = Some(proxy.clone());
|
||||
}
|
||||
state.on_frame_allocd((), format, width, height);
|
||||
}
|
||||
// v3 terminal event: all buffer offers have been enumerated.
|
||||
// If still AllocQueued, the compositor never sent linux_dmabuf —
|
||||
// DMA-BUF screencopy is unsupported, so we must error out.
|
||||
ScreencopyFrameEvent::BufferDone => {
|
||||
if matches!(state.in_flight_surface, InFlightSurface::AllocQueued) {
|
||||
tracing::error!(
|
||||
"Compositor did not offer DMA-BUF screencopy (only SHM); \
|
||||
DMA-BUF capture is required"
|
||||
);
|
||||
state.in_flight_surface = InFlightSurface::None;
|
||||
proxy.destroy();
|
||||
state.errored = true;
|
||||
}
|
||||
}
|
||||
ScreencopyFrameEvent::Ready {
|
||||
tv_sec_hi,
|
||||
tv_sec_lo,
|
||||
tv_nsec,
|
||||
} => {
|
||||
let tv_sec = (tv_sec_hi as u64) << 32 | tv_sec_lo as u64;
|
||||
let tv_usec = tv_nsec / 1000;
|
||||
tracing::trace!("Screencopy ready: tv_sec={tv_sec}, tv_usec={tv_usec}");
|
||||
state.on_copy_complete(tv_sec, tv_usec);
|
||||
}
|
||||
ScreencopyFrameEvent::Failed => {
|
||||
tracing::error!("Screencopy frame failed");
|
||||
state.on_copy_fail();
|
||||
}
|
||||
ScreencopyFrameEvent::Damage { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: crate::state::CaptureSource> Dispatch<ZwlrScreencopyManagerV1, ()> for State<S> {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwlrScreencopyManagerV1,
|
||||
_event: <ZwlrScreencopyManagerV1 as Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use wayland_client::protocol::wl_output::WlOutput;
|
||||
use wayland_client::{Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{
|
||||
Event as XdgOutputEvent, ZxdgOutputV1,
|
||||
};
|
||||
|
||||
use crate::state::{CaptureSource, EncConstructionStage, OutputId, State, Transform};
|
||||
|
||||
impl<S: CaptureSource> Dispatch<WlOutput, OutputId> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &WlOutput,
|
||||
event: wayland_client::protocol::wl_output::Event,
|
||||
data: &OutputId,
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
use wayland_client::protocol::wl_output::Event as OutputEvent;
|
||||
use wayland_client::protocol::wl_output::Mode as WlMode;
|
||||
use wayland_client::protocol::wl_output::Transform as WlTransform;
|
||||
|
||||
let OutputId(target_name) = data;
|
||||
let idx = match &state.stage {
|
||||
EncConstructionStage::ProbingOutputs { output_names, .. } => {
|
||||
output_names.iter().position(|&n| n == *target_name)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let idx = match idx {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
|
||||
match event {
|
||||
OutputEvent::Geometry { transform, .. } => {
|
||||
let t = match transform {
|
||||
wayland_client::WEnum::Value(WlTransform::Normal) => Transform::Normal,
|
||||
wayland_client::WEnum::Value(WlTransform::_90) => Transform::Normal90,
|
||||
wayland_client::WEnum::Value(WlTransform::_180) => Transform::Normal180,
|
||||
wayland_client::WEnum::Value(WlTransform::_270) => Transform::Normal270,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped) => Transform::Flipped,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped90) => Transform::Flipped90,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped180) => Transform::Flipped180,
|
||||
wayland_client::WEnum::Value(WlTransform::Flipped270) => Transform::Flipped270,
|
||||
_ => Transform::Normal,
|
||||
};
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.transform = Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputEvent::Mode {
|
||||
width,
|
||||
height,
|
||||
flags,
|
||||
..
|
||||
} => {
|
||||
let is_current = matches!(flags, wayland_client::WEnum::Value(WlMode::Current));
|
||||
if is_current {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.mode_size = Some((width, height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputEvent::Done => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.done_count += 1;
|
||||
if info.done_count >= 1 {
|
||||
state.try_finalize_output(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputEvent::Name { name } => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.wl_name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> Dispatch<ZxdgOutputV1, OutputId> for State<S> {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZxdgOutputV1,
|
||||
event: <ZxdgOutputV1 as Proxy>::Event,
|
||||
data: &OutputId,
|
||||
_conn: &wayland_client::Connection,
|
||||
_qhandle: &QueueHandle<State<S>>,
|
||||
) {
|
||||
let target_name = data.0;
|
||||
let idx = match &state.stage {
|
||||
EncConstructionStage::ProbingOutputs { output_names, .. } => {
|
||||
output_names.iter().position(|&n| n == target_name)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let idx = match idx {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
|
||||
match event {
|
||||
XdgOutputEvent::Name { name } => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.name = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
XdgOutputEvent::LogicalSize { .. } => {}
|
||||
XdgOutputEvent::Done => {
|
||||
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
|
||||
if let Some(info) = outputs.get_mut(idx) {
|
||||
info.done_count += 1;
|
||||
if info.done_count >= 1 {
|
||||
state.try_finalize_output(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::mem;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::io::FromRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use wayland_client::backend::ObjectId;
|
||||
use wayland_client::globals::GlobalList;
|
||||
use wayland_client::protocol::wl_buffer::WlBuffer;
|
||||
use wayland_client::protocol::wl_output::WlOutput;
|
||||
use wayland_client::{Dispatch, QueueHandle};
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::Flags as BufferParamsFlags;
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1;
|
||||
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1;
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
|
||||
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::ZwlrOutputManagerV1;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
|
||||
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
|
||||
|
||||
use ffmpeg_next as ff;
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
use crate::args::Args;
|
||||
use crate::avhw::{AvHwDevCtx, EncState, EncodedH264Frame, SwEncState};
|
||||
use crate::fps_limit::FpsLimit;
|
||||
use crate::stats::{FrameTimings, PipelineStats};
|
||||
use crate::transform::{transpose_if_transform_transposed, Transform};
|
||||
use crate::webrtc::WebRtcState;
|
||||
|
||||
mod dispatch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CaptureSource trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Screen capture backend trait.
|
||||
pub trait CaptureSource: Sized + 'static {
|
||||
type Frame: Send;
|
||||
|
||||
fn new(
|
||||
gm: &GlobalList,
|
||||
output: &WlOutput,
|
||||
output_info: &OutputInfo,
|
||||
qh: &QueueHandle<State<Self>>,
|
||||
) -> Result<Self>;
|
||||
|
||||
fn queue_copy(&mut self, buffer: &WlBuffer, qh: &QueueHandle<State<Self>>);
|
||||
|
||||
fn on_done_with_frame(&mut self, frame: Self::Frame);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output info types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct OutputInfo {
|
||||
pub name: String,
|
||||
pub transform: Transform,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PartialOutputInfo {
|
||||
pub name: Option<String>,
|
||||
/// Name from wl_output::Name (v4) — used to match wlr-output-management heads
|
||||
pub wl_name: Option<String>,
|
||||
pub transform: Option<Transform>,
|
||||
// Pixel dimensions from Mode event — preparatory for Phase 2 resolution logic
|
||||
pub mode_size: Option<(i32, i32)>,
|
||||
pub done_count: u32,
|
||||
}
|
||||
|
||||
/// Marker for wlr-output-management heads seen during probing; tracked by name
|
||||
/// in `EncConstructionStage::ProbingOutputs.wlr_heads`.
|
||||
// `pub(crate)` (not module-private): exposed via `EncConstructionStage::ProbingOutputs.wlr_heads`
|
||||
// which is reached from main.rs during the wlr-screencopy probing loop.
|
||||
pub(crate) struct WlrHeadInfo {}
|
||||
|
||||
/// User data for XdgOutput dispatch to identify which WlOutput it belongs to.
|
||||
pub struct OutputId(pub u32);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingEncoder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wraps the two possible encoder backends for the streaming stage.
|
||||
///
|
||||
/// - `Mp4(EncState)` — hardware VAAPI encoder writing to an MP4 file
|
||||
/// - `WebRtc(SwEncState)` — software encoder feeding H.264 NALUs into a WebRTC channel
|
||||
pub enum StreamingEncoder {
|
||||
Mp4(EncState),
|
||||
WebRtc(SwEncState),
|
||||
}
|
||||
|
||||
impl StreamingEncoder {
|
||||
fn frames_rgb(&self) -> &crate::avhw::AvHwFrameCtx {
|
||||
match self {
|
||||
StreamingEncoder::Mp4(enc) => enc.frames_rgb(),
|
||||
StreamingEncoder::WebRtc(enc) => enc.frames_rgb(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_frame(
|
||||
&mut self,
|
||||
hw_frame: &ffmpeg_next::frame::Video,
|
||||
) -> anyhow::Result<crate::avhw::EncodeStages> {
|
||||
match self {
|
||||
StreamingEncoder::Mp4(enc) => enc.encode_frame(hw_frame),
|
||||
StreamingEncoder::WebRtc(enc) => enc.encode_frame(hw_frame),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> anyhow::Result<()> {
|
||||
match self {
|
||||
StreamingEncoder::Mp4(enc) => enc.flush(),
|
||||
StreamingEncoder::WebRtc(enc) => enc.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EncConstructionStage
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `pub(crate)` (not `pub`): this enum leaks the private `WlrHeadInfo` type via
|
||||
// its `wlr_heads` field, and the construction-stage state machine is an
|
||||
// internal implementation detail. Crate-internal consumers (main.rs) get there
|
||||
// via `crate::state::`; there is no need to expose this across the crate
|
||||
// boundary. See Oracle audit 2026-06-28.
|
||||
|
||||
pub(crate) enum EncConstructionStage<S: CaptureSource> {
|
||||
ProbingOutputs {
|
||||
outputs: Vec<PartialOutputInfo>,
|
||||
bound_outputs: Vec<WlOutput>,
|
||||
output_names: Vec<u32>,
|
||||
screencopy_manager: Option<ZwlrScreencopyManagerV1>,
|
||||
dmabuf: Option<ZwpLinuxDmabufV1>,
|
||||
dmabuf_feedback: Option<ZwpLinuxDmabufFeedbackV1>,
|
||||
xdg_output_manager: Option<ZxdgOutputManagerV1>,
|
||||
wlr_output_manager: Option<ZwlrOutputManagerV1>,
|
||||
wlr_manager_done: bool,
|
||||
wlr_heads: HashMap<String, WlrHeadInfo>,
|
||||
wlr_head_proxy_to_name: HashMap<ObjectId, String>,
|
||||
},
|
||||
EverythingButFmt {
|
||||
output_info: OutputInfo,
|
||||
output: WlOutput,
|
||||
hw_device_ctx: AvHwDevCtx,
|
||||
cap: S,
|
||||
screencopy_manager: ZwlrScreencopyManagerV1,
|
||||
dmabuf: ZwpLinuxDmabufV1,
|
||||
},
|
||||
Streaming {
|
||||
output: WlOutput,
|
||||
enc: StreamingEncoder,
|
||||
cap: S,
|
||||
screencopy_manager: ZwlrScreencopyManagerV1,
|
||||
dmabuf: ZwpLinuxDmabufV1,
|
||||
},
|
||||
Intermediate,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InFlightSurface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum InFlightSurface<S: CaptureSource> {
|
||||
None,
|
||||
AllocQueued,
|
||||
CopyQueued {
|
||||
surface: ff::frame::Video,
|
||||
// Boxed: AVDRMFrameDescriptor is ~592 bytes (4 objects + 4 layers),
|
||||
// which would balloon every InFlightSurface variant via enum alignment.
|
||||
// The box shrinks the enum to ~32 bytes regardless of variant.
|
||||
drm_map: Box<ff::ffi::AVDRMFrameDescriptor>,
|
||||
frame: S::Frame,
|
||||
buffer: WlBuffer,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct State<S: CaptureSource> {
|
||||
pub(crate) stage: EncConstructionStage<S>,
|
||||
pub in_flight_surface: InFlightSurface<S>,
|
||||
pub stats_start_time: Option<Instant>,
|
||||
pub stats_last_time: Option<Instant>,
|
||||
pub stats_frames: u64,
|
||||
pub first_frame: bool,
|
||||
pub args: Args,
|
||||
pub errored: bool,
|
||||
pub gm: GlobalList,
|
||||
pub fps_limit: FpsLimit<S::Frame>,
|
||||
pub qhandle: QueueHandle<State<S>>,
|
||||
pub drm_device: Option<PathBuf>,
|
||||
pub drm_device_from_compositor: Option<PathBuf>,
|
||||
pub webrtc: Option<WebRtcState>,
|
||||
pub webrtc_tx: Option<crossbeam_channel::Sender<EncodedH264Frame>>,
|
||||
webrtc_rx: Option<crossbeam_channel::Receiver<EncodedH264Frame>>,
|
||||
webrtc_frames_sent: u64,
|
||||
webrtc_paused: Option<Arc<AtomicBool>>,
|
||||
stats: PipelineStats,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan /dev/dri for all available DRM render nodes (renderD*), sorted by node number.
|
||||
pub(crate) fn find_drm_render_nodes() -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut nodes: Vec<(u32, PathBuf)> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let number = name.strip_prefix("renderD")?.parse::<u32>().ok()?;
|
||||
std::fs::metadata(&path).ok()?;
|
||||
Some((number, path))
|
||||
})
|
||||
.collect();
|
||||
nodes.sort_by_key(|(number, _)| *number);
|
||||
nodes.into_iter().map(|(_, path)| path).collect()
|
||||
}
|
||||
|
||||
/// Scan /dev/dri for the first available DRM render node (renderD*).
|
||||
fn find_drm_render_node() -> Option<PathBuf> {
|
||||
find_drm_render_nodes().into_iter().next()
|
||||
}
|
||||
|
||||
impl<S: CaptureSource> State<S> {
|
||||
fn resolve_drm_path(&self) -> PathBuf {
|
||||
self.drm_device
|
||||
.clone()
|
||||
.or_else(|| self.drm_device_from_compositor.clone())
|
||||
.or_else(find_drm_render_node)
|
||||
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State<S> methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl<S: CaptureSource> State<S> {
|
||||
pub fn new(gm: GlobalList, args: Args, qhandle: QueueHandle<State<S>>) -> Result<Self> {
|
||||
let fps = args.fps;
|
||||
let drm_device = args.drm_device.as_ref().map(PathBuf::from);
|
||||
|
||||
let (webrtc, webrtc_tx, webrtc_rx, webrtc_paused) = if args.port > 0 {
|
||||
let (tx, rx) = crossbeam_channel::bounded(32);
|
||||
let wrtc = WebRtcState::new(args.port, args.fps)?;
|
||||
// paused=true until first WebRTC client connects
|
||||
let paused = Arc::new(AtomicBool::new(true));
|
||||
(Some(wrtc), Some(tx), Some(rx), Some(paused))
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
let mut state = Self {
|
||||
stage: EncConstructionStage::ProbingOutputs {
|
||||
outputs: Vec::new(),
|
||||
bound_outputs: Vec::new(),
|
||||
output_names: Vec::new(),
|
||||
screencopy_manager: None,
|
||||
dmabuf: None,
|
||||
dmabuf_feedback: None,
|
||||
xdg_output_manager: None,
|
||||
wlr_output_manager: None,
|
||||
wlr_manager_done: false,
|
||||
wlr_heads: HashMap::new(),
|
||||
wlr_head_proxy_to_name: HashMap::new(),
|
||||
},
|
||||
in_flight_surface: InFlightSurface::None,
|
||||
stats_start_time: None,
|
||||
stats_last_time: None,
|
||||
stats_frames: 0,
|
||||
first_frame: true,
|
||||
fps_limit: FpsLimit::new(fps),
|
||||
args,
|
||||
errored: false,
|
||||
gm,
|
||||
qhandle,
|
||||
drm_device,
|
||||
drm_device_from_compositor: None,
|
||||
webrtc,
|
||||
webrtc_tx,
|
||||
webrtc_rx,
|
||||
webrtc_frames_sent: 0,
|
||||
webrtc_paused,
|
||||
stats: PipelineStats::new(),
|
||||
};
|
||||
|
||||
// registry_queue_init consumes registry events internally during its
|
||||
// initial roundtrip and does NOT forward them to our Dispatch impl.
|
||||
// We must manually bind the initial globals here.
|
||||
state.bind_initial_globals();
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Iterate over the GlobalList from registry_queue_init and bind all
|
||||
/// globals we care about. This is necessary because registry_queue_init
|
||||
/// consumes registry events during its internal roundtrip without forwarding
|
||||
/// them to our Dispatch<WlRegistry> handler.
|
||||
fn bind_initial_globals(&mut self) {
|
||||
use wayland_client::globals::Global;
|
||||
|
||||
let globals: Vec<Global> = self.gm.contents().clone_list();
|
||||
let registry = self.gm.registry();
|
||||
let qhandle = &self.qhandle;
|
||||
|
||||
// Sort globals so that managers are bound BEFORE wl_output.
|
||||
// This ensures xdg_output_manager and zwlr_output_manager are available
|
||||
// when we bind wl_output, so we can immediately get xdg_output / wlr head.
|
||||
let globals = {
|
||||
fn priority(interface: &str) -> u8 {
|
||||
match interface {
|
||||
"zwlr_screencopy_manager_v1" => 0,
|
||||
"zwp_linux_dmabuf_v1" => 0,
|
||||
"zxdg_output_manager_v1" => 1,
|
||||
"zwlr_output_manager_v1" => 1,
|
||||
"wl_output" => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
let mut g = globals;
|
||||
g.sort_by_key(|g| priority(&g.interface));
|
||||
g
|
||||
};
|
||||
|
||||
for Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} in globals
|
||||
{
|
||||
match interface.as_str() {
|
||||
"zwlr_screencopy_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Init: binding zwlr_screencopy_manager_v1 v{v} (name={name})");
|
||||
let mgr: ZwlrScreencopyManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
screencopy_manager, ..
|
||||
} = &mut self.stage
|
||||
{
|
||||
*screencopy_manager = Some(mgr);
|
||||
}
|
||||
}
|
||||
"zwp_linux_dmabuf_v1" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Init: binding zwp_linux_dmabuf_v1 v{v} (name={name})");
|
||||
let proxy: ZwpLinuxDmabufV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
..
|
||||
} = &mut self.stage
|
||||
{
|
||||
*dmabuf = Some(proxy.clone());
|
||||
if v >= 4 {
|
||||
let feedback = proxy.get_default_feedback(qhandle, ());
|
||||
*dmabuf_feedback = Some(feedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let v = version.min(3);
|
||||
tracing::debug!("Init: binding zxdg_output_manager_v1 v{v} (name={name})");
|
||||
let xdg_mgr: ZxdgOutputManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
bound_outputs,
|
||||
xdg_output_manager,
|
||||
output_names,
|
||||
..
|
||||
} = &mut self.stage
|
||||
{
|
||||
for (i, output) in bound_outputs.iter().enumerate() {
|
||||
let oname = output_names.get(i).copied().unwrap_or(0);
|
||||
let output_id = OutputId(oname);
|
||||
xdg_mgr.get_xdg_output(output, qhandle, output_id);
|
||||
}
|
||||
*xdg_output_manager = Some(xdg_mgr);
|
||||
}
|
||||
}
|
||||
"zwlr_output_manager_v1" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Init: binding zwlr_output_manager_v1 v{v} (name={name})");
|
||||
let mgr: ZwlrOutputManagerV1 = registry.bind(name, v, qhandle, ());
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
wlr_output_manager, ..
|
||||
} = &mut self.stage
|
||||
{
|
||||
*wlr_output_manager = Some(mgr);
|
||||
}
|
||||
}
|
||||
"wl_output" => {
|
||||
let v = version.min(4);
|
||||
tracing::debug!("Init: binding wl_output v{v} (name={name})");
|
||||
let output: WlOutput = registry.bind(name, v, qhandle, OutputId(name));
|
||||
if let EncConstructionStage::ProbingOutputs {
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
xdg_output_manager,
|
||||
..
|
||||
} = &mut self.stage
|
||||
{
|
||||
outputs.push(PartialOutputInfo::default());
|
||||
bound_outputs.push(output.clone());
|
||||
output_names.push(name);
|
||||
if let Some(xdg_mgr) = xdg_output_manager {
|
||||
let output_id = OutputId(name);
|
||||
xdg_mgr.get_xdg_output(&output, qhandle, output_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_alloc_frame(&mut self)
|
||||
where
|
||||
State<S>: Dispatch<ZwlrScreencopyFrameV1, ()>,
|
||||
{
|
||||
let (manager, output) = match &self.stage {
|
||||
EncConstructionStage::Streaming {
|
||||
screencopy_manager,
|
||||
output,
|
||||
..
|
||||
} => (screencopy_manager.clone(), output.clone()),
|
||||
EncConstructionStage::EverythingButFmt {
|
||||
screencopy_manager,
|
||||
output,
|
||||
..
|
||||
} => (screencopy_manager.clone(), output.clone()),
|
||||
_ => return,
|
||||
};
|
||||
match &self.in_flight_surface {
|
||||
InFlightSurface::None => {}
|
||||
_ => return,
|
||||
}
|
||||
let _frame_proxy = manager.capture_output(1, &output, &self.qhandle, ());
|
||||
self.in_flight_surface = InFlightSurface::AllocQueued;
|
||||
}
|
||||
|
||||
pub fn on_frame_allocd(&mut self, frame: S::Frame, format: u32, width: u32, height: u32) {
|
||||
let (frames_rgb_ctx, dmabuf, cap) = match &mut self.stage {
|
||||
EncConstructionStage::Streaming {
|
||||
output: _,
|
||||
enc,
|
||||
dmabuf,
|
||||
cap,
|
||||
screencopy_manager: _,
|
||||
} => (enc.frames_rgb().as_ptr(), dmabuf, cap),
|
||||
_ => {
|
||||
tracing::warn!("on_frame_allocd: not in Streaming stage");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut surface = ff::frame::Video::empty();
|
||||
// SAFETY: frames_rgb_ctx is a valid AVHWFramesContext pointer; surface
|
||||
// is a freshly allocated empty Video frame.
|
||||
let ret = unsafe { ffi::av_hwframe_get_buffer(frames_rgb_ctx, surface.as_mut_ptr(), 0) };
|
||||
if ret < 0 {
|
||||
tracing::error!("av_hwframe_get_buffer failed: {}", crate::avhw::ff_err(ret));
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut map_frame = ff::frame::Video::empty();
|
||||
// SAFETY: Setting format to DRM_PRIME and calling av_hwframe_map creates
|
||||
// a mapped view of the GPU surface with DMA-BUF file descriptors.
|
||||
unsafe {
|
||||
(*map_frame.as_mut_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||
}
|
||||
// SAFETY: map_frame and surface are valid, owned AVFrame pointers from
|
||||
// av_hwframe_get/surface.alloc above. AV_HWFRAME_MAP_READ flag (0 here)
|
||||
// requests a read-only mapping. The DRM_PRIME format set above instructs
|
||||
// FFmpeg to populate data[0] with an AVDRMFrameDescriptor on success.
|
||||
let ret = unsafe { ffi::av_hwframe_map(map_frame.as_mut_ptr(), surface.as_ptr(), 0) };
|
||||
if ret < 0 {
|
||||
tracing::error!("av_hwframe_map failed: {}", crate::avhw::ff_err(ret));
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: After av_hwframe_map with DRM_PRIME format, data[0] points to
|
||||
// a valid AVDRMFrameDescriptor.
|
||||
let desc: ff::ffi::AVDRMFrameDescriptor = unsafe {
|
||||
let desc_ptr = (*map_frame.as_ptr()).data[0] as *const ff::ffi::AVDRMFrameDescriptor;
|
||||
std::ptr::read(desc_ptr)
|
||||
};
|
||||
|
||||
let params = dmabuf.create_params(&self.qhandle, ());
|
||||
|
||||
for layer_idx in 0..desc.nb_layers as usize {
|
||||
let layer = &desc.layers[layer_idx];
|
||||
for p in 0..layer.nb_planes as usize {
|
||||
let plane = &layer.planes[p];
|
||||
let obj = &desc.objects[plane.object_index as usize];
|
||||
let mod_hi = (obj.format_modifier >> 32) as u32;
|
||||
let mod_lo = (obj.format_modifier & 0xFFFF_FFFF) as u32;
|
||||
// SAFETY: obj.fd is a valid DMA-BUF fd. We dup because params.add()
|
||||
// takes ownership of the fd, and the original fd is owned by map_frame.
|
||||
let fd_dup = unsafe { libc::dup(obj.fd) };
|
||||
if fd_dup < 0 {
|
||||
tracing::error!(
|
||||
"failed to dup dma-buf fd: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
// wayland-client does not auto-destroy params on Drop.
|
||||
params.destroy();
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
// SAFETY: fd_dup is valid freshly-duped fd.
|
||||
let fd_owned = unsafe { OwnedFd::from_raw_fd(fd_dup) };
|
||||
params.add(
|
||||
fd_owned.as_fd(),
|
||||
p as u32,
|
||||
plane.offset as u32,
|
||||
plane.pitch as u32,
|
||||
mod_hi,
|
||||
mod_lo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let wl_buffer = params.create_immed(
|
||||
width as i32,
|
||||
height as i32,
|
||||
format,
|
||||
BufferParamsFlags::empty(),
|
||||
&self.qhandle,
|
||||
(),
|
||||
);
|
||||
self.in_flight_surface = InFlightSurface::CopyQueued {
|
||||
surface,
|
||||
drm_map: Box::new(desc),
|
||||
frame,
|
||||
buffer: wl_buffer,
|
||||
};
|
||||
let buffer_ref = match &self.in_flight_surface {
|
||||
InFlightSurface::CopyQueued { buffer, .. } => buffer,
|
||||
_ => unreachable!("just set to CopyQueued"),
|
||||
};
|
||||
cap.queue_copy(buffer_ref, &self.qhandle);
|
||||
}
|
||||
|
||||
pub fn on_copy_complete(&mut self, tv_sec: u64, tv_usec: u32)
|
||||
where
|
||||
S::Frame: Default,
|
||||
{
|
||||
self.stats.record_capture();
|
||||
|
||||
let (mut surface, _drm_map, frame, buffer) =
|
||||
match mem::replace(&mut self.in_flight_surface, InFlightSurface::None) {
|
||||
InFlightSurface::CopyQueued {
|
||||
surface,
|
||||
drm_map,
|
||||
frame,
|
||||
buffer,
|
||||
} => (surface, drm_map, frame, buffer),
|
||||
other => {
|
||||
tracing::warn!("on_copy_complete: unexpected state");
|
||||
self.in_flight_surface = other;
|
||||
return;
|
||||
}
|
||||
};
|
||||
// PTS in 90kHz media-clock ticks (WebRTC encoder time_base = 1/90000).
|
||||
// Must match Portal path's compute_capture_pts unit. See issue #25.
|
||||
let pts = (tv_sec as i64) * 90_000 + (tv_usec as i64) * 90_000 / 1_000_000;
|
||||
surface.set_pts(Some(pts));
|
||||
drop(buffer);
|
||||
let cap = match &mut self.stage {
|
||||
EncConstructionStage::Streaming { cap, .. } => cap,
|
||||
_ => {
|
||||
tracing::warn!("on_copy_complete: not in Streaming stage");
|
||||
return;
|
||||
}
|
||||
};
|
||||
cap.on_done_with_frame(frame);
|
||||
let enc = match &mut self.stage {
|
||||
EncConstructionStage::Streaming { enc, .. } => enc,
|
||||
_ => unreachable!("already checked Streaming above"),
|
||||
};
|
||||
let should_encode = if self.first_frame {
|
||||
self.first_frame = false;
|
||||
true
|
||||
} else {
|
||||
self.fps_limit
|
||||
.on_new_frame(S::Frame::default(), Instant::now())
|
||||
.is_some()
|
||||
};
|
||||
if should_encode {
|
||||
let encode_start = Instant::now();
|
||||
match enc.encode_frame(&surface) {
|
||||
Ok(stages) => {
|
||||
let encode_elapsed = encode_start.elapsed().as_micros() as u64;
|
||||
self.stats.record_encode(&FrameTimings {
|
||||
scale_us: stages.scale_us,
|
||||
transfer_us: stages.transfer_us,
|
||||
encode_us: stages.encode_us,
|
||||
total_us: encode_elapsed,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("encode_frame failed: {}", e);
|
||||
self.errored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.stats_frames += 1;
|
||||
if let Some(last) = self.stats_last_time {
|
||||
if last.elapsed() >= std::time::Duration::from_secs(10) {
|
||||
let delta = self.stats_frames;
|
||||
let fps = delta as f64 / last.elapsed().as_secs_f64();
|
||||
tracing::info!(
|
||||
frames = self.stats_frames,
|
||||
fps = format!("{fps:.1}"),
|
||||
"encoding stats"
|
||||
);
|
||||
self.stats_last_time = Some(std::time::Instant::now());
|
||||
self.stats_frames = 0;
|
||||
}
|
||||
} else {
|
||||
self.stats_start_time = Some(std::time::Instant::now());
|
||||
self.stats_last_time = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_copy_fail(&mut self)
|
||||
where
|
||||
S::Frame: Default,
|
||||
{
|
||||
tracing::error!("compositor copy failed");
|
||||
let taken = mem::replace(&mut self.in_flight_surface, InFlightSurface::None);
|
||||
match taken {
|
||||
InFlightSurface::CopyQueued { buffer, frame, .. } => {
|
||||
drop(buffer);
|
||||
if let EncConstructionStage::Streaming { cap, .. } = &mut self.stage {
|
||||
cap.on_done_with_frame(frame);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.in_flight_surface = other;
|
||||
}
|
||||
}
|
||||
self.errored = true;
|
||||
}
|
||||
|
||||
pub fn poll_webrtc(&mut self) -> Result<()> {
|
||||
let Some(ref mut wrtc) = self.webrtc else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
wrtc.handle_signaling()?;
|
||||
wrtc.poll_and_feed()?;
|
||||
|
||||
let connected = wrtc.is_connected();
|
||||
|
||||
if let Some(ref paused) = self.webrtc_paused {
|
||||
let was_paused = paused.load(Ordering::Relaxed);
|
||||
let now_paused = !connected;
|
||||
if was_paused && !now_paused {
|
||||
tracing::info!("WebRTC client connected, resuming encoding");
|
||||
} else if !was_paused && now_paused {
|
||||
tracing::warn!("WebRTC client disconnected, pausing encoding");
|
||||
}
|
||||
paused.store(now_paused, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if let Some(ref rx) = self.webrtc_rx {
|
||||
let mut count = 0u32;
|
||||
while let Ok(enc_frame) = rx.try_recv() {
|
||||
if !connected {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
|
||||
tracing::debug!("WebRTC write frame error: {e}");
|
||||
}
|
||||
self.stats.record_send(0.0, None);
|
||||
self.webrtc_frames_sent = self.webrtc_frames_sent.saturating_add(1);
|
||||
}
|
||||
if count > 0 {
|
||||
tracing::debug!("WebRTC forwarded {count} frames from channel");
|
||||
}
|
||||
}
|
||||
|
||||
if self.args.stats && self.stats.should_snapshot() {
|
||||
self.stats
|
||||
.set_queue_depths(0, self.webrtc_rx.as_ref().map(|r| r.len()).unwrap_or(0));
|
||||
let snap = self.stats.snapshot_and_reset();
|
||||
tracing::info!("stats: {snap}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn negotiate_format(&mut self, format: u32, width: u32, height: u32) {
|
||||
let stage_data = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
|
||||
EncConstructionStage::EverythingButFmt {
|
||||
output_info,
|
||||
output,
|
||||
hw_device_ctx,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
} => (
|
||||
output_info,
|
||||
output,
|
||||
hw_device_ctx,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
),
|
||||
other => {
|
||||
tracing::warn!("negotiate_format: not in EverythingButFmt stage");
|
||||
self.stage = other;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (output_info, output, hw_device_ctx, cap, screencopy_manager, dmabuf) = stage_data;
|
||||
let drm_path = self.resolve_drm_path();
|
||||
let fps = self.args.fps;
|
||||
let bitrate = self
|
||||
.args
|
||||
.bitrate
|
||||
.unwrap_or_else(|| 2 * (width as u64) * (height as u64) * (fps as u64) / 100);
|
||||
|
||||
let enc = if let Some(ref tx) = self.webrtc_tx {
|
||||
let (enc_w, enc_h) = transpose_if_transform_transposed(
|
||||
output_info.transform,
|
||||
width as i32,
|
||||
height as i32,
|
||||
);
|
||||
let actual_gop_size = self.args.gop_size.unwrap_or((fps * 2).max(20));
|
||||
match SwEncState::new_webrtc(
|
||||
&drm_path,
|
||||
width,
|
||||
height,
|
||||
enc_w as u32,
|
||||
enc_h as u32,
|
||||
fps,
|
||||
bitrate,
|
||||
actual_gop_size,
|
||||
tx.clone(),
|
||||
self.webrtc_paused
|
||||
.as_ref()
|
||||
.expect("webrtc_paused must exist when webrtc_tx exists")
|
||||
.clone(),
|
||||
) {
|
||||
Ok(enc) => StreamingEncoder::WebRtc(enc),
|
||||
Err(e) => {
|
||||
tracing::error!("SwEncState::new_webrtc failed: {}", e);
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let output_path = self
|
||||
.args
|
||||
.output
|
||||
.as_deref()
|
||||
.expect("output required for MP4 mode");
|
||||
match crate::avhw::create_encoder(
|
||||
&drm_path,
|
||||
Path::new(output_path),
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
output_info.transform,
|
||||
self.args.bitrate,
|
||||
self.args.gop_size,
|
||||
Some(hw_device_ctx),
|
||||
) {
|
||||
Ok(enc) => StreamingEncoder::Mp4(enc),
|
||||
Err(e) => {
|
||||
tracing::error!("EncState::new failed: {}", e);
|
||||
self.errored = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
"Encoder initialized: {}x{} format={} bitrate={}",
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
bitrate
|
||||
);
|
||||
self.stage = EncConstructionStage::Streaming {
|
||||
output,
|
||||
enc,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
};
|
||||
}
|
||||
|
||||
fn try_finalize_output(&mut self, _idx: usize) -> bool {
|
||||
let (target_idx, output_count) = match &self.stage {
|
||||
EncConstructionStage::ProbingOutputs {
|
||||
outputs,
|
||||
xdg_output_manager,
|
||||
wlr_manager_done,
|
||||
..
|
||||
} => {
|
||||
let has_xdg = xdg_output_manager.is_some();
|
||||
let output_count = outputs.len();
|
||||
let idx = if let Some(ref name) = self.args.output_name {
|
||||
let pos = outputs
|
||||
.iter()
|
||||
.position(|o| o.name.as_deref() == Some(name.as_str()));
|
||||
match pos {
|
||||
Some(i) => Some(i),
|
||||
None => {
|
||||
let all_probed = outputs.iter().all(|o| o.done_count >= 1);
|
||||
if all_probed {
|
||||
let available: Vec<&str> =
|
||||
outputs.iter().filter_map(|o| o.name.as_deref()).collect();
|
||||
tracing::error!(
|
||||
"Output '{}' not found. Available outputs: {:?}",
|
||||
name,
|
||||
available
|
||||
);
|
||||
self.errored = true;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else if outputs.iter().all(|o| o.done_count >= 1) {
|
||||
if outputs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match idx {
|
||||
Some(i) => {
|
||||
let info = &outputs[i];
|
||||
if has_xdg {
|
||||
// done_count >= 2 implies physical_size and logical_position
|
||||
// already arrived (Wayland: Geometry/Mode/Position fire before Done).
|
||||
if info.done_count < 2
|
||||
|| info.name.is_none()
|
||||
|| info.transform.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// done_count >= 1 implies transform arrived (Geometry precedes Done).
|
||||
if info.done_count < 1 || !wlr_manager_done || info.transform.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
(i, output_count)
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let probing = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
|
||||
s @ EncConstructionStage::ProbingOutputs { .. } => s,
|
||||
other => {
|
||||
self.stage = other;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let (
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
_xdg_output_manager,
|
||||
_wlr_output_manager,
|
||||
_wlr_manager_done,
|
||||
_wlr_heads,
|
||||
_wlr_head_proxy_to_name,
|
||||
) = match probing {
|
||||
EncConstructionStage::ProbingOutputs {
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
xdg_output_manager,
|
||||
wlr_output_manager,
|
||||
wlr_manager_done,
|
||||
wlr_heads,
|
||||
wlr_head_proxy_to_name,
|
||||
} => (
|
||||
outputs,
|
||||
bound_outputs,
|
||||
output_names,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
dmabuf_feedback,
|
||||
xdg_output_manager,
|
||||
wlr_output_manager,
|
||||
wlr_manager_done,
|
||||
wlr_heads,
|
||||
wlr_head_proxy_to_name,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// Destroy feedback object — prevents server-side resource leak
|
||||
if let Some(feedback) = dmabuf_feedback {
|
||||
feedback.destroy();
|
||||
}
|
||||
|
||||
let info = &outputs[target_idx];
|
||||
let output_info = OutputInfo {
|
||||
name: info
|
||||
.name
|
||||
.clone()
|
||||
.or(info.wl_name.clone())
|
||||
.unwrap_or_else(|| format!("output-{}", output_names[target_idx])),
|
||||
transform: info.transform.unwrap(),
|
||||
};
|
||||
let output = bound_outputs[target_idx].clone();
|
||||
|
||||
let screencopy_manager = match screencopy_manager {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::error!("No screencopy manager bound");
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let dmabuf = match dmabuf {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
tracing::error!("No dmabuf manager bound");
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let drm_path = self.resolve_drm_path();
|
||||
|
||||
let hw_device_ctx = match AvHwDevCtx::new_vaapi(&drm_path) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create VAAPI device: {}", e);
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let cap = match S::new(&self.gm, &output, &output_info, &self.qhandle) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create capture source: {}", e);
|
||||
self.errored = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Selected output: {}", output_info.name);
|
||||
if self.args.output_name.is_none() && output_count > 1 {
|
||||
tracing::warn!(
|
||||
"Multiple outputs found, using '{}'. Use --output-name to select.",
|
||||
output_info.name
|
||||
);
|
||||
}
|
||||
self.stage = EncConstructionStage::EverythingButFmt {
|
||||
output_info,
|
||||
output,
|
||||
hw_device_ctx,
|
||||
cap,
|
||||
screencopy_manager,
|
||||
dmabuf,
|
||||
};
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
+584
-68
@@ -1,8 +1,33 @@
|
||||
//! Portal 后端的主状态机:通过 PipeWire + DMA-BUF 进行屏幕采集并软件编码。
|
||||
//!
|
||||
//! ## 整体角色
|
||||
//!
|
||||
//! `StatePortal` 与 `src/state.rs::State` 是平行的两条采集路径:
|
||||
//! - `state.rs`(wlroots 路径):由外层 `mio` 事件循环驱动(手工版 epoll),
|
||||
//! 通过 `zwlr_screencopy_manager_v1` 协议一帧一帧地拉取。
|
||||
//! - `state_portal.rs`(本文件,XDG Portal / PipeWire 路径):由 `CapPortal`
|
||||
//! 通过 `crossbeam_channel::Receiver<PwDmaBufFrame>` 推帧;本状态机只负责"消费"。
|
||||
//!
|
||||
//! ## 异步模型的真相
|
||||
//!
|
||||
//! 本文件**不**使用 `mio` 或 `tokio`——`CapPortal` 内部在独立线程跑 PipeWire
|
||||
//! asyncio loop,把 DMA-BUF 帧通过 crossbeam channel 投递出来;外层 `main.rs`
|
||||
//! 只需在 `while !is_errored()` 循环里轮询 `poll_and_encode(block)`。编码线程与
|
||||
//! WebRTC 线程通过 `std::thread::spawn`(不是 `tokio::spawn`)启动,再借助
|
||||
//! crossbeam channel 与主线程通信——类比 Go 的 `go func()` + channel。
|
||||
//!
|
||||
//! ## 阶段机
|
||||
//!
|
||||
//! `PortalStage::WaitingForFormat`(等首帧以确定格式)→ `Streaming`(持续编码)。
|
||||
//!
|
||||
//! ## 注意
|
||||
//!
|
||||
//! - T9a(本块)覆盖文件头 + struct 定义 + `impl StatePortal`(至 `fn encode_thread_loop` 之前);
|
||||
//! T9b 覆盖 `encode_thread_loop` / `webrtc_thread_loop` / `resolve_drm_device` 等自由函数。
|
||||
//! - 多处 `unsafe` 调用 FFmpeg/VAAPI FFI;现有英文 SAFETY 标记保留不动,
|
||||
//! 本任务在每个 unsafe 块上方加普通 `//` 中文概述(不新增 SAFETY 标记)。
|
||||
|
||||
// 采集门户状态模块 —— 通过 PipeWire/DMA-BUF 进行屏幕采集并编码
|
||||
// AsRawFd is required by frame.fd.as_raw_fd() in build_drm_descriptor below
|
||||
// but rustc emits a false "unused_imports" warning because OwnedFd also has
|
||||
// an inherent as_raw_fd — same quirk as avhw.rs. E0599 if removed → keep it.
|
||||
#[allow(unused_imports)]
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -13,21 +38,13 @@ use anyhow::{bail, Result}; // 错误处理工具
|
||||
|
||||
use crate::args::Args; // 命令行参数
|
||||
use crate::avhw::{
|
||||
self, BitrateCommand, CpuNv12Frame, ResolutionChange, SwEncEncode, SwEncImport, SwEncState,
|
||||
self, BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodedH264Frame, ResolutionChange,
|
||||
SwEncEncode, SwEncImport, SwEncState,
|
||||
}; // 软件编码器状态(VAAPI 导入 + H.264 编码)
|
||||
use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点
|
||||
use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照)
|
||||
use crate::webrtc::WebRtcState; // WebRTC 信令与媒体传输
|
||||
|
||||
mod bitrate;
|
||||
use bitrate::webrtc_startup_bitrate_bps;
|
||||
|
||||
mod threads;
|
||||
use threads::{
|
||||
encode_thread_loop, webrtc_thread_loop, EncodeThread, EncodeThreadTiming, WebRtcThreadChannels,
|
||||
WebRtcThreadConfig, WebrtcThread,
|
||||
};
|
||||
|
||||
/// 门户采集的阶段状态
|
||||
/// - WaitingForFormat: 等待接收到第一帧 DMA-BUF 以确定视频格式参数
|
||||
/// - Streaming: 已完成初始化,正在持续编码流
|
||||
@@ -36,6 +53,41 @@ enum PortalStage {
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// 编码线程单帧计时回执——由 `encode_thread_loop` 通过 `timing_tx` 发回主线程,
|
||||
/// 用于在 `PipelineStats` 中窗口化统计 `sws_us`(libswscale 缩放开销)和
|
||||
/// `encode_us`(H.264 软件编码开销)。类比 Go 的 `type EncodeThreadTiming struct`。
|
||||
struct EncodeThreadTiming {
|
||||
sws_us: u64,
|
||||
encode_us: u64,
|
||||
output_bytes: usize,
|
||||
}
|
||||
|
||||
/// 编码工作线程的句柄与通信端点。
|
||||
///
|
||||
/// 由主线程持有,负责把 NV12 帧 (`CpuNv12Frame`) 通过 `input_tx` 投递给
|
||||
/// `encode_thread_loop`;编码完成后通过 `timing_rx` 收回单帧计时;`duplicate_count`
|
||||
/// 是跨线程共享的 `Arc<AtomicU64>`(类比 Go 的 `*uint64` protected by atomic),
|
||||
/// 用于统计被去重跳过的帧数(影响 BWE 与丢弃策略)。
|
||||
///
|
||||
/// 字段全部用 `Option<...>`/`Sender`/`Receiver` 包装,是为了在 `shutdown` 时
|
||||
/// 能用 `Option::take()` 把所有权转移到本地变量、显式 drop `input_tx`、再 `join()`。
|
||||
struct EncodeThread {
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
|
||||
timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
|
||||
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// WebRTC 工作线程的句柄与单向上行通道。
|
||||
///
|
||||
/// 主线程只能通过 `sent_gap_rx` **被动接收** WebRTC 线程上报的"已发送帧间隔/老化"
|
||||
/// 指标(用于 `PipelineStats::record_send_from_thread`)。下行的码率 / 分辨率 / 暂停
|
||||
/// 控制走另一组 channel(`bitrate_tx` / `resolution_tx` / `webrtc_paused`),不在此处。
|
||||
struct WebrtcThread {
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
|
||||
}
|
||||
|
||||
/// 门户模式的主状态机
|
||||
///
|
||||
/// 负责管理从 PipeWire 采集屏幕帧、通过 VAAPI 硬件编码的完整生命周期。
|
||||
@@ -65,6 +117,14 @@ pub struct StatePortal {
|
||||
last_pts_emitted: Option<i64>,
|
||||
}
|
||||
|
||||
// `impl StatePortal` 块集中了门户路径的所有主线程逻辑:
|
||||
// - `new`:构造(DRM 设备探测 + CapPortal 初始化;编码器延后到首帧)。
|
||||
// - `poll_and_encode`:外层 main 循环每轮调用一次,处理 1 个 PipeWire 帧 / 控制事件。
|
||||
// - `shutdown`:幂等清理(编码线程 → WebRTC 线程 → MP4 flush)。
|
||||
// - 私有辅助:`record_capture_timeout` / `record_frame_arrival`(采集空闲日志节流)、
|
||||
// `resolve_drm_device_for_frame`(DMA-BUF 导入兼容性探测)、
|
||||
// `handle_pw_frame`(VAAPI 导入 + 软件编码)、`compute_capture_pts`(90kHz RTP PTS)。
|
||||
// 内部不使用任何锁——所有 `&mut self` 由外层 main 循环单线程串行化保证独占。
|
||||
impl StatePortal {
|
||||
/// 创建门户状态实例
|
||||
///
|
||||
@@ -213,6 +273,10 @@ impl StatePortal {
|
||||
if self.webrtc.is_some() {
|
||||
let paused = self.webrtc_paused.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal invariant broken: webrtc_paused missing while WebRTC mode is active"))?;
|
||||
// WebRTC 模式需要 6 路 crossbeam channel 协调主线程 ↔ 编码线程 ↔ WebRTC 线程。
|
||||
// `crossbeam_channel::bounded::<T>(n)` 类比 Go 的 `make(chan T, n)`——
|
||||
// 容量满时 `send` 阻塞、空时 `recv` 阻塞;返回的 `(Sender, Receiver)` 各占一份
|
||||
//所有权,可 move 到不同线程(前提是元素类型 `T: Send`)。
|
||||
let (resolution_tx, resolution_rx) =
|
||||
crossbeam_channel::bounded::<BitrateCommand>(4);
|
||||
let (encoder_resolution_tx, encoder_resolution_rx) =
|
||||
@@ -243,8 +307,22 @@ impl StatePortal {
|
||||
bitrate_rx,
|
||||
encoder_resolution_rx,
|
||||
)?;
|
||||
let duplicate_count = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let duplicate_count = std::sync::Arc::new(
|
||||
std::sync::atomic::AtomicU64::new(0),
|
||||
);
|
||||
// Arc 引用计数克隆(不是深拷贝)——`duplicate_count` 留在主线程,
|
||||
// `duplicate_count_for_thread` move 进编码线程;两者指向同一原子。
|
||||
// 类比 Go 的 `*uint64` + atomic.Store,但 Rust 用类型系统保证线程安全。
|
||||
let duplicate_count_for_thread = duplicate_count.clone();
|
||||
// `std::thread::Builder::new().name(...).spawn(move || {...})?`:
|
||||
// - 类比 Go 的 `go func() {...}()`,但返回 `JoinHandle<T>` 而非 fire-and-forget——
|
||||
// 主线程可在 shutdown 时 `handle.join()` 等待子线程退出。
|
||||
// - **不**用 `tokio::spawn`:编码是 CPU 密集 + 阻塞 FFmpeg 调用,
|
||||
// 不需要 async/await;标准线程更直接。
|
||||
// - `move ||` 闭包:把 `encode` / `input_rx` / `timing_tx` /
|
||||
// `duplicate_count_for_thread` 的所有权**转移**给子线程(类比 Go 里把变量
|
||||
// 显式传入 goroutine 闭包参数)。
|
||||
// - `?` 传播 `io::Error`——线程创建可能失败(资源限制)。
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("wl-webrtc-encode".into())
|
||||
.spawn(move || {
|
||||
@@ -275,24 +353,24 @@ impl StatePortal {
|
||||
let max_bitrate = self.args.max_bitrate;
|
||||
let (sent_gap_tx, sent_gap_rx) =
|
||||
crossbeam_channel::bounded::<(f64, Option<f64>)>(64);
|
||||
// WebRTC 工作线程:同上 `std::thread::spawn(move || ...)` 模式——
|
||||
// 内部跑 str0m 的 asyncio loop(`WebRtcState` 自己驱动),
|
||||
// 通过 `webrtc_rx` 接收 H.264 帧、通过 `bitrate_tx` / `resolution_tx`
|
||||
// 接收码率/分辨率指令、通过 `sent_gap_tx` 上报发送指标。
|
||||
let webrtc_handle = std::thread::Builder::new()
|
||||
.name("wl-webrtc-webrtc".into())
|
||||
.spawn(move || {
|
||||
webrtc_thread_loop(
|
||||
wrtc,
|
||||
WebRtcThreadConfig {
|
||||
fps,
|
||||
enc_width,
|
||||
enc_height,
|
||||
max_bitrate,
|
||||
},
|
||||
WebRtcThreadChannels {
|
||||
webrtc_rx,
|
||||
sent_gap_tx,
|
||||
bitrate_tx,
|
||||
resolution_tx,
|
||||
},
|
||||
webrtc_rx,
|
||||
fps,
|
||||
enc_width,
|
||||
enc_height,
|
||||
max_bitrate,
|
||||
paused,
|
||||
sent_gap_tx,
|
||||
bitrate_tx,
|
||||
resolution_tx,
|
||||
)
|
||||
})?;
|
||||
self.webrtc_thread = Some(WebrtcThread {
|
||||
@@ -334,17 +412,8 @@ impl StatePortal {
|
||||
|
||||
// 每秒输出一次结构化管道统计(仅 --stats 启用时记录日志)
|
||||
if self.args.stats && self.stats.should_snapshot() {
|
||||
// Wire PipeWire drop counter (delta-tracked via pw_dropped_prev) and
|
||||
// capture channel depth. Oracle audit 2026-06-28: previously hardcoded
|
||||
// (0, 0), which silently zeroed two real diagnostic fields.
|
||||
let total_dropped = self.cap.dropped_count();
|
||||
self.stats
|
||||
.set_pipewire_dropped(total_dropped, self.pw_dropped_prev);
|
||||
self.pw_dropped_prev = total_dropped;
|
||||
// capture queue depth is real; encoded side has no exposed depth — the
|
||||
// encoder thread publishes timings only, not a frame queue length.
|
||||
self.stats
|
||||
.set_queue_depths(self.cap.capture_queue_depth(), 0);
|
||||
self.stats.set_pipewire_dropped(0, 0);
|
||||
self.stats.set_queue_depths(0, 0);
|
||||
if let Some(ref enc_thread) = self.enc_thread {
|
||||
while let Ok(timing) = enc_thread.timing_rx.try_recv() {
|
||||
self.stats.record_encode_thread(
|
||||
@@ -371,6 +440,11 @@ impl StatePortal {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 记录"采集超时"——本次轮询未取到帧(PipeWire 队列空)。
|
||||
///
|
||||
/// 因为 Wayland 是 damage-driven(只有画面变化才推帧),静态画面下长时间无帧
|
||||
/// 是**正常**行为,不是 compositor 卡死。所以本函数只做"5 秒阈值后的 DEBUG 一次性日志",
|
||||
/// 用 `idle_log_start` 字段保证每次空闲区间只发一条日志(issue #15 / #18)。
|
||||
fn record_capture_timeout(&mut self) {
|
||||
let Some(last_capture_arrival) = self.last_capture_arrival else {
|
||||
return;
|
||||
@@ -396,6 +470,11 @@ impl StatePortal {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录"采集到达"——本次轮询成功取到一帧。
|
||||
///
|
||||
/// 与 `record_capture_timeout` 互补:若之前处于空闲区间,则通过 `Option::take()`
|
||||
/// 取出 `idle_log_start` 并发一条 "resumed after idle" DEBUG 日志;然后刷新
|
||||
/// `last_capture_arrival` 时间戳。两者共同实现"一次性空闲日志"语义。
|
||||
fn record_frame_arrival(&mut self) {
|
||||
if let Some(idle_start) = self.idle_log_start.take() {
|
||||
tracing::debug!(
|
||||
@@ -464,6 +543,10 @@ impl StatePortal {
|
||||
// processing — DMA-BUF import, VAAPI scale, NV12 clone, channel send, and
|
||||
// encode thread wakeup. This eliminates ~60fps of pointless work during
|
||||
// the pre-connect idle window. MP4 mode (webrtc_paused == None) is unaffected.
|
||||
// `Arc<AtomicBool>` 类比 Go 的 `*atomic.Bool`——`Arc` 提供跨线程共享所有权
|
||||
// (引用计数原子递增/递减),`AtomicBool` 提供无锁读/写。
|
||||
// `Ordering::Relaxed`:只保证单变量原子性,不建立与其他变量的 happens-before 关系——
|
||||
// 对"暂停标志"足够(不需要它做屏障同步)。
|
||||
if let Some(paused) = &self.webrtc_paused {
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
@@ -483,45 +566,61 @@ impl StatePortal {
|
||||
|
||||
if let Some(enc) = self.enc.as_mut() {
|
||||
// 将 DMA-BUF 帧零拷贝导入 VAAPI 硬件帧池
|
||||
// SAFETY: delegates to avhw::import_dma_buf_to_vaapi (itself an unsafe fn);
|
||||
// frames_rgb pointer is a valid AVBufferRef owned by enc, and `frame` is the
|
||||
// PipeWire-formatted PwDmaBufFrame whose metadata the function reads directly.
|
||||
// See that function's own SAFETY contract.
|
||||
let mut vaapi_frame =
|
||||
unsafe { avhw::import_dma_buf_to_vaapi(enc.frames_rgb().as_ptr(), &frame) }?;
|
||||
// unsafe:FFI 调用 FFmpeg `av_hwframe_ctx_init` / `av_hwframe_map` 系列,
|
||||
// 内部会读取 `enc.frames_rgb()` 指向的 `AVBufferRef`(硬件帧池),
|
||||
// 并把 `frame.fd.as_raw_fd()`(DMA-BUF dmabuf fd)注册到 VAAPI。
|
||||
// 安全性前提:`enc` 在本线程独占(main 串行化保证)、`frame.fd` 未被 close。
|
||||
let mut vaapi_frame = unsafe {
|
||||
avhw::import_dma_buf_to_vaapi(
|
||||
enc.frames_rgb().as_ptr(),
|
||||
frame.fd.as_raw_fd(),
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
frame.modifier,
|
||||
frame.stride,
|
||||
frame.offset,
|
||||
)
|
||||
}?;
|
||||
|
||||
let import_us = t_import_start.elapsed().as_micros() as u64;
|
||||
let t_encode_start = Instant::now();
|
||||
|
||||
// 设置帧的显示时间戳(PTS),基于已编码帧序号
|
||||
// SAFETY: vaapi_frame is the freshly-imported valid AVFrame returned by
|
||||
// import_dma_buf_to_vaapi above; pts is a plain i64 field on AVFrame.
|
||||
unsafe {
|
||||
(*vaapi_frame.as_mut_ptr()).pts = pts;
|
||||
}
|
||||
|
||||
// 送入编码器完成:缩放 → 回读 → 格式转换 → H.264 编码
|
||||
let stages = enc.encode_frame(&vaapi_frame)?;
|
||||
enc.encode_frame(&vaapi_frame)?;
|
||||
let total_us = t_import_start.elapsed().as_micros() as u64;
|
||||
let encode_us = stages.encode_us;
|
||||
let encode_us = t_encode_start.elapsed().as_micros() as u64;
|
||||
|
||||
self.frames_encoded += 1;
|
||||
|
||||
// 记录帧计时到管道统计(scale 来自 filter graph;transfer 在 HW 路径恒为 0)
|
||||
// 记录帧计时到管道统计(import + encode 内部各阶段暂不可分离,用 total 覆盖)
|
||||
let timings = FrameTimings {
|
||||
import_us,
|
||||
scale_us: stages.scale_us,
|
||||
transfer_us: stages.transfer_us,
|
||||
encode_us,
|
||||
total_us,
|
||||
..Default::default()
|
||||
};
|
||||
self.stats.record_encode(&timings);
|
||||
} else if let Some(import) = self.enc_import.as_mut() {
|
||||
// SAFETY: same contract as the enc branch above — frames_rgb owned by
|
||||
// import, `frame` carries the PipeWire DMA-BUF metadata.
|
||||
let mut vaapi_frame =
|
||||
unsafe { avhw::import_dma_buf_to_vaapi(import.frames_rgb().as_ptr(), &frame) }?;
|
||||
// SAFETY: vaapi_frame is the valid AVFrame returned above; pts is plain i64.
|
||||
// 同上 unsafe:DMA-BUF → VAAPI 导入;`import.frames_rgb()` 是与编码线程
|
||||
// **不共享**的独立硬件帧池(避免与 `import_and_scale` 的回读路径竞争)。
|
||||
let mut vaapi_frame = unsafe {
|
||||
avhw::import_dma_buf_to_vaapi(
|
||||
import.frames_rgb().as_ptr(),
|
||||
frame.fd.as_raw_fd(),
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.format,
|
||||
frame.modifier,
|
||||
frame.stride,
|
||||
frame.offset,
|
||||
)
|
||||
}?;
|
||||
unsafe {
|
||||
(*vaapi_frame.as_mut_ptr()).pts = pts;
|
||||
}
|
||||
@@ -535,6 +634,9 @@ impl StatePortal {
|
||||
"internal invariant broken: encode thread missing while async import is active"
|
||||
)
|
||||
})?;
|
||||
// `try_send` 类比 Go 的 `select { case ch <- v: default: }`——
|
||||
// 非阻塞投递;三种结果分别处理:成功递增、满了丢弃(DEBUG 日志)、
|
||||
// 对端关闭(致命,置 `errored=true` 让外层循环退出)。
|
||||
match enc_thread.input_tx.try_send(cpu_nv12) {
|
||||
Ok(()) => {
|
||||
self.frames_encoded += 1;
|
||||
@@ -603,6 +705,10 @@ impl StatePortal {
|
||||
self.shutdown_started = true;
|
||||
|
||||
// 1. Stop encode thread (drops webrtc_tx → signals WebRTC thread to exit)
|
||||
// `Option::take()` 把 `EncodeThread` 的所有权从 `self.enc_thread` 转移到本地 `enc_thread`,
|
||||
// 同时 `self.enc_thread` 变成 `None`——这是 Rust 里"消费字段但保留父结构体"的标准习语,
|
||||
// 类比 Go 里把字段设为 nil 但保留外层 struct。接下来显式 `drop(input_tx)` 关闭 channel,
|
||||
// 编码线程的 `input_rx.recv()` 会返回 `Err(Disconnected)` 从而退出循环。
|
||||
if let Some(mut enc_thread) = self.enc_thread.take() {
|
||||
drop(enc_thread.input_tx);
|
||||
if let Some(handle) = enc_thread.handle.take() {
|
||||
@@ -647,6 +753,292 @@ impl StatePortal {
|
||||
}
|
||||
}
|
||||
|
||||
// === 编码线程主循环(独立 std::thread,非 tokio) ===
|
||||
// 类比 Go:`go func(input <-chan Frame) { for f := range input { encode(f) } }`。
|
||||
// 线程持有 SwEncEncode 的所有权(move 语义),消费 input_rx 直到对端 drop 所有 Sender。
|
||||
// 编码结果通过 timing_tx(单帧耗时)+ duplicate_count(重复帧统计)回传主线程。
|
||||
fn encode_thread_loop(
|
||||
mut encode: SwEncEncode,
|
||||
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
|
||||
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
|
||||
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) {
|
||||
// 阻塞循环:`match input_rx.recv()` 类比 Go `for frame := range input_rx {}`。
|
||||
// - Ok(frame) → 调用 encode_cpu_frame;match EncodeOutcome 各 variant 分别处理;
|
||||
// timing_tx.try_send 非阻塞回执(满则丢,类比 Go `select { case ch <- v: default: }`);
|
||||
// 重复帧计数通过 Arc<AtomicU64>::fetch_add + Relaxed 累加——无锁、无需 happens-before。
|
||||
// - Err(_) → 所有 Sender 已 drop,flush 编码器后退出循环。
|
||||
loop {
|
||||
match input_rx.recv() {
|
||||
Ok(frame) => {
|
||||
match encode.encode_cpu_frame(&frame) {
|
||||
Ok(EncodeOutcome::Encoded) => {
|
||||
let t = encode.take_timing();
|
||||
let _ = timing_tx.try_send(EncodeThreadTiming {
|
||||
sws_us: t.sws_us,
|
||||
encode_us: t.encode_us,
|
||||
output_bytes: t.output_bytes,
|
||||
});
|
||||
}
|
||||
Ok(EncodeOutcome::SkippedDuplicate) => {
|
||||
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(_) => {
|
||||
// SkippedPaused / SkippedDisconnected — no counter needed
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Encode thread error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!("Encode thread input closed, flushing encoder");
|
||||
if let Err(e) = encode.flush() {
|
||||
tracing::error!("Encode thread flush error: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("Encode thread exiting");
|
||||
}
|
||||
|
||||
// === WebRTC 信令 + 帧发送主循环(独立 std::thread,非 tokio) ===
|
||||
// 该线程串行处理 4 件事:
|
||||
// 1. str0m 信令(ICE/DTLS)+ RTP 打包发送(wrtc.handle_signaling / poll_and_feed);
|
||||
// 2. 自适应码率(BWE)→ bitrate_tx 下发 UpdateBitrate/ForceKeyframe 给编码线程;
|
||||
// 3. 自适应分辨率(每 1s 评估)→ resolution_tx 下发 UpdateResolution;
|
||||
// 4. 从 webrtc_rx 取已编码 H264 帧,写入 str0m RTP sink。
|
||||
// 暂停状态由 Arc<AtomicBool> 跨线程共享:编码线程读,本线程写。
|
||||
fn webrtc_thread_loop(
|
||||
mut wrtc: WebRtcState,
|
||||
webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
|
||||
fps: u32,
|
||||
enc_width: u32,
|
||||
enc_height: u32,
|
||||
max_bitrate: u64,
|
||||
paused: Arc<AtomicBool>,
|
||||
sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
|
||||
bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
|
||||
resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
|
||||
) {
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut last_send: Option<std::time::Instant> = None;
|
||||
let mut last_sent_bitrate: Option<u64> = None;
|
||||
let initial_tier = (enc_width, enc_height);
|
||||
let mut current_tier = initial_tier;
|
||||
let mut upscale_counter = 0u32;
|
||||
let mut last_resolution_eval = Instant::now();
|
||||
// recv 超时 1ms——既能让循环周期性处理 str0m 信令,又能在帧到达时立即返回。
|
||||
let timeout = Duration::from_millis(1);
|
||||
|
||||
loop {
|
||||
if let Err(e) = wrtc.handle_signaling() {
|
||||
tracing::error!("WebRTC signaling error: {e}");
|
||||
break;
|
||||
}
|
||||
if let Err(e) = wrtc.poll_and_feed() {
|
||||
tracing::error!("WebRTC poll error: {e}");
|
||||
break;
|
||||
}
|
||||
|
||||
if wrtc.take_force_keyframe() {
|
||||
let _ = bitrate_tx.try_send(BitrateCommand::ForceKeyframe);
|
||||
}
|
||||
|
||||
let connected = wrtc.is_connected();
|
||||
// Arc<AtomicBool> 跨线程协调:编码线程 Relaxed 读 paused;本线程 Relaxed 写。
|
||||
// Relaxed 取舍:暂停标志无内存序需求(不保护其他共享数据),只需原子可见性。
|
||||
let was_paused = paused.load(Ordering::Relaxed);
|
||||
let now_paused = !connected;
|
||||
if was_paused && !now_paused {
|
||||
tracing::info!("WebRTC client connected, resuming encoding");
|
||||
} else if !was_paused && now_paused {
|
||||
tracing::warn!("WebRTC client disconnected, pausing encoding");
|
||||
}
|
||||
paused.store(now_paused, Ordering::Relaxed);
|
||||
|
||||
if let Some(bwe) = wrtc.get_bwe_estimate() {
|
||||
// #23: Cap BWE to prevent runaway bitrate escalation. Without this, BWE
|
||||
// estimates can rise to 10+ Mbps, causing IDR bursts and PLI storms.
|
||||
let effective_bwe = bwe.min(max_bitrate);
|
||||
if effective_bwe != bwe {
|
||||
tracing::debug!(
|
||||
bwe,
|
||||
effective_bwe,
|
||||
max_bitrate,
|
||||
"BWE exceeds --max-bitrate cap, clamping"
|
||||
);
|
||||
}
|
||||
let bwe = effective_bwe;
|
||||
|
||||
let should_send = match last_sent_bitrate {
|
||||
None => true,
|
||||
Some(last) => {
|
||||
let diff = if bwe > last { bwe - last } else { last - bwe };
|
||||
diff * 10 > last
|
||||
}
|
||||
};
|
||||
if should_send {
|
||||
let _ = bitrate_tx.try_send(BitrateCommand::UpdateBitrate { target_bps: bwe });
|
||||
last_sent_bitrate = Some(bwe);
|
||||
}
|
||||
|
||||
if last_resolution_eval.elapsed() >= Duration::from_secs(1) {
|
||||
last_resolution_eval = Instant::now();
|
||||
let selected = select_resolution(current_tier.0, current_tier.1, bwe, fps);
|
||||
if selected != current_tier {
|
||||
current_tier = selected;
|
||||
upscale_counter = 0;
|
||||
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
|
||||
width: current_tier.0,
|
||||
height: current_tier.1,
|
||||
});
|
||||
wrtc.set_need_keyframe();
|
||||
} else if let Some(next_tier) = next_upscale_tier(current_tier, initial_tier) {
|
||||
let needed = resolution_bitrate_bps(next_tier.0, next_tier.1, fps);
|
||||
if bwe > needed.saturating_mul(120) / 100 {
|
||||
upscale_counter = upscale_counter.saturating_add(1);
|
||||
if upscale_counter >= 10 {
|
||||
current_tier = next_tier;
|
||||
upscale_counter = 0;
|
||||
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
|
||||
width: current_tier.0,
|
||||
height: current_tier.1,
|
||||
});
|
||||
wrtc.set_need_keyframe();
|
||||
}
|
||||
} else {
|
||||
upscale_counter = 0;
|
||||
}
|
||||
} else {
|
||||
upscale_counter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if connected {
|
||||
// 已连接:批量 drain 已编码帧队列(类比 Go `for { select { case f := <-rx: send(f); default: break } }`)。
|
||||
// saturating_add 防止计数器溢出(Go 没有,Rust 默认 panic-on-overflow,debug 下尤其危险)。
|
||||
while let Ok(enc_frame) = webrtc_rx.try_recv() {
|
||||
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
|
||||
tracing::debug!("WebRTC write frame error: {e}");
|
||||
}
|
||||
frames_sent = frames_sent.saturating_add(1);
|
||||
let gap_ms = last_send
|
||||
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
// Compute capture-to-send age on the sending thread so the
|
||||
// frame_age stat stays accurate when batch-drained later.
|
||||
let age_ms =
|
||||
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
|
||||
last_send = Some(std::time::Instant::now());
|
||||
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
|
||||
}
|
||||
} else {
|
||||
// 未连接:丢弃积压帧防止 drain 时刻反向堆积(类比 Go `for { select { case <-rx: default: return } }`)。
|
||||
while webrtc_rx.try_recv().is_ok() {}
|
||||
}
|
||||
|
||||
// recv_timeout:阻塞至下一帧或最多 1ms——保证 str0m 信令循环周期性推进。
|
||||
// 三路 Result:Ok → 处理帧;Err(Timeout) → 继续下一轮循环处理信令;Err(Disconnected) → 编码线程已退出,本线程返回。
|
||||
match webrtc_rx.recv_timeout(timeout) {
|
||||
Ok(enc_frame) => {
|
||||
if wrtc.is_connected() {
|
||||
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks)
|
||||
{
|
||||
tracing::debug!("WebRTC write frame error: {e}");
|
||||
}
|
||||
frames_sent = frames_sent.saturating_add(1);
|
||||
let gap_ms = last_send
|
||||
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
let age_ms =
|
||||
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
|
||||
last_send = Some(std::time::Instant::now());
|
||||
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
|
||||
}
|
||||
}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
||||
tracing::info!("WebRTC channel disconnected, exiting thread");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("WebRTC thread exiting");
|
||||
}
|
||||
|
||||
// 自适应分辨率阶梯(从高到低)。下标 0 = 最高分辨率(2K),下标 2 = 最低(720p)。
|
||||
// BWE 不足时 select_resolution 从数组下标小的(高分辨率)向大的(低分辨率)切换;
|
||||
// 反向 upscale 由 next_upscale_tier 处理,受 initial_tier 上限约束(不会超过初始分辨率)。
|
||||
const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
|
||||
|
||||
// 启发式码率估算:`5 × W × H × fps / 100` 即 0.05 bits/pixel/frame。
|
||||
// 类似 H.264 平均量化参考值,作为 BWE 充分性判据(≥ 60% 认为可承载当前分辨率)。
|
||||
fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
|
||||
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
|
||||
}
|
||||
|
||||
// WebRTC 启动码率:按总像素数分 4 档(≤1M / ≤2.5M / ≤4.5M / 其他 → 1/2/4/8 Mbps)。
|
||||
// 仅影响客户端连接后第一个 IDR;BWE 估计(毫秒级到达)会覆盖此值。详见 issue #21。
|
||||
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
|
||||
/// BWE estimate arrives within milliseconds of client connect and overrides this;
|
||||
/// the startup value only affects the first IDR. See issue #21.
|
||||
fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
if pixels <= 1_000_000 {
|
||||
1_000_000
|
||||
} else if pixels <= 2_500_000 {
|
||||
2_000_000
|
||||
} else if pixels <= 4_500_000 {
|
||||
4_000_000
|
||||
} else {
|
||||
8_000_000
|
||||
}
|
||||
}
|
||||
|
||||
// 基于 BWE 选择分辨率阶梯。返回 (width, height)。
|
||||
// 决策逻辑:若 BWE ≥ 当前分辨率所需码率的 60%,保持不变;否则降到下一档(最低 720p)。
|
||||
/// Select resolution tier based on BWE estimate.
|
||||
/// Returns (width, height) for the selected tier.
|
||||
fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) -> (u32, u32) {
|
||||
let current = (current_w, current_h);
|
||||
let current_bitrate = resolution_bitrate_bps(current_w, current_h, fps);
|
||||
if bwe_bps >= current_bitrate.saturating_mul(60) / 100 {
|
||||
return current;
|
||||
}
|
||||
|
||||
// 在 RESOLUTION_TIERS 中找当前分辨率的位置;若不在表中(如 1366×768),
|
||||
// 用 unwrap_or_else 退回到第一个宽高都不超过 current 的档位,最终兜底取最小档(720p)。
|
||||
let current_index = RESOLUTION_TIERS
|
||||
.iter()
|
||||
.position(|&tier| tier == current)
|
||||
.unwrap_or_else(|| {
|
||||
RESOLUTION_TIERS
|
||||
.iter()
|
||||
.position(|&(w, h)| w <= current_w && h <= current_h)
|
||||
.unwrap_or(RESOLUTION_TIERS.len() - 1)
|
||||
});
|
||||
let next_index = (current_index + 1).min(RESOLUTION_TIERS.len() - 1);
|
||||
RESOLUTION_TIERS[next_index]
|
||||
}
|
||||
|
||||
// 反向 upscale:在 ceiling 上限内尝试升一档;若已在最高档或下一档超出 ceiling 则返回 None。
|
||||
// 调用方需要"连续 10 次 BWE 充足"才真正切换,避免 BWE 抖动导致频繁分辨率变化。
|
||||
fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
|
||||
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
|
||||
if current_index == 0 {
|
||||
return None;
|
||||
}
|
||||
let next = RESOLUTION_TIERS[current_index - 1];
|
||||
// bool::then_some(true → Some(next),false → None):将谓词结果转换为 Option,
|
||||
// 类比 Go `if ok { return &tier } else { return nil }`。
|
||||
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
|
||||
}
|
||||
|
||||
impl Drop for StatePortal {
|
||||
// 析构时自动调用 shutdown,确保编码器被刷新、资源被释放
|
||||
fn drop(&mut self) {
|
||||
@@ -694,12 +1086,11 @@ fn resolve_drm_device(args: &Args) -> Result<Option<PathBuf>> {
|
||||
/// 用于验证 DMA-BUF 元数据映射的正确性。
|
||||
#[cfg(test)]
|
||||
fn build_drm_descriptor(frame: &PwDmaBufFrame) -> ffmpeg_next::ffi::AVDRMFrameDescriptor {
|
||||
let mut desc: ffmpeg_next::ffi::AVDRMFrameDescriptor = {
|
||||
// SAFETY: AVDRMFrameDescriptor is a POD struct from FFmpeg's C API with no
|
||||
// pointers orDrop fields; all-zero is a valid initial state. Every field is
|
||||
// explicitly overwritten in the lines below before the descriptor is used.
|
||||
unsafe { std::mem::zeroed() }
|
||||
};
|
||||
// unsafe:调用 std::mem::zeroed() 对 #[repr(C)] 结构体进行零初始化——
|
||||
// AVDRMFrameDescriptor 是 FFmpeg C 结构体,零值是合法的"空"状态(nb_objects/nb_layers=0,
|
||||
// 后续字段在下方显式赋值)。`std::mem::zeroed` 对带指针字段的类型可能产生空悬指针(UB),
|
||||
// 此处安全:descriptor 的所有字段都是整数/数组,没有指针/引用。
|
||||
let mut desc: ffmpeg_next::ffi::AVDRMFrameDescriptor = unsafe { std::mem::zeroed() };
|
||||
desc.nb_objects = 1; // 单个 DMA-BUF 对象
|
||||
desc.objects[0].fd = frame.fd.as_raw_fd(); // DMA-BUF 文件描述符
|
||||
desc.objects[0].size = 0; // 大小设为 0(内核自动确定)
|
||||
@@ -722,9 +1113,8 @@ mod tests {
|
||||
fn make_test_frame() -> PwDmaBufFrame {
|
||||
// Create a dummy fd from stderr (always valid fd 2)
|
||||
// 使用 stderr(fd 2)的副本作为虚拟文件描述符
|
||||
// SAFETY: stderr (fd 2) is always-open in any process; libc::dup(2) returns
|
||||
// a fresh fd we solely own. OwnedFd::from_raw_fd takes ownership and closes
|
||||
// it on Drop. Test-only; the fd is never actually memory-mapped.
|
||||
// unsafe:libc::dup(2) 复制 stderr fd → 返回新整数 fd;OwnedFd::from_raw_fd 接管
|
||||
// 该 fd 的 close 责任(RAII)。前提:libc::dup 调用成功(fd 2 始终有效,不检查返回值是测试代码约定)。
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
|
||||
PwDmaBufFrame {
|
||||
fd,
|
||||
@@ -802,13 +1192,54 @@ mod tests {
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webrtc_startup_bitrate_tiers_by_pixel_count() {
|
||||
assert_eq!(webrtc_startup_bitrate_bps(1280, 720), 1_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(1920, 1080), 2_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(2560, 1440), 4_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(3840, 2160), 8_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_downscales_one_tier_below_sixty_percent() {
|
||||
let fps = 30;
|
||||
let current = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, current * 59 / 100, fps),
|
||||
(1280, 720)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_keeps_tier_at_sixty_percent() {
|
||||
let fps = 30;
|
||||
let current = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, current * 60 / 100, fps),
|
||||
(1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_never_goes_below_720p() {
|
||||
assert_eq!(select_resolution(1280, 720, 1, 30), (1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_respects_initial_ceiling() {
|
||||
assert_eq!(
|
||||
next_upscale_tier((1280, 720), (1920, 1080)),
|
||||
Some((1920, 1080))
|
||||
);
|
||||
assert_eq!(next_upscale_tier((1920, 1080), (1920, 1080)), None);
|
||||
}
|
||||
|
||||
/// 测试:使用自定义偏移量和 stride 构建 DRM 描述符
|
||||
#[test]
|
||||
fn build_drm_descriptor_custom_offset_and_stride() {
|
||||
// SAFETY: same as make_test_frame — dup of stderr (fd 2), test-only.
|
||||
let test_fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
|
||||
let frame = PwDmaBufFrame {
|
||||
fd: test_fd,
|
||||
// unsafe:同 make_test_frame——dup(2) 复制 stderr fd 并交给 OwnedFd 管理。
|
||||
fd: unsafe { OwnedFd::from_raw_fd(libc::dup(2)) },
|
||||
offset: 4096, // 4KB 对齐偏移
|
||||
stride: 3840 * 4, // 4K 宽度 × 4 字节
|
||||
modifier: 0x0100000000000001, // AMD modifiers
|
||||
@@ -826,4 +1257,89 @@ mod tests {
|
||||
}
|
||||
|
||||
// ── issue #8 regression ──
|
||||
|
||||
#[test]
|
||||
fn try_send_full_channel_returns_full_not_block() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
tx.send(vec![1]).unwrap();
|
||||
tx.send(vec![2]).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![3]),
|
||||
Err(crossbeam_channel::TrySendError::Full(_))
|
||||
));
|
||||
assert_eq!(rx.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_send_after_rx_dropped_returns_disconnected() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
drop(rx);
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![1]),
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_))
|
||||
));
|
||||
}
|
||||
|
||||
// given: full bounded channel
|
||||
// when: rx is dropped, then try_send
|
||||
// expect: Disconnected, not blocking
|
||||
#[test]
|
||||
fn shutdown_rx_drop_prevents_deadlock_on_full_channel() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
tx.send(vec![1]).unwrap();
|
||||
tx.send(vec![2]).unwrap();
|
||||
drop(rx);
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![3]),
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_))
|
||||
));
|
||||
}
|
||||
|
||||
// ── Task 7: Additional resolution tier edge cases ──
|
||||
|
||||
#[test]
|
||||
fn select_resolution_keeps_720p_when_bwe_sufficient() {
|
||||
let fps = 30;
|
||||
let bitrate_720 = resolution_bitrate_bps(1280, 720, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1280, 720, bitrate_720, fps),
|
||||
(1280, 720)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_downscales_1440p_to_1080p() {
|
||||
let fps = 30;
|
||||
let bitrate_1440 = resolution_bitrate_bps(2560, 1440, fps);
|
||||
assert_eq!(
|
||||
select_resolution(2560, 1440, bitrate_1440 * 59 / 100, fps),
|
||||
(1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_1080p_to_720p_at_very_low_bwe() {
|
||||
let fps = 30;
|
||||
let bitrate_1080 = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, bitrate_1080 / 10, fps),
|
||||
(1280, 720)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_from_720p_to_1080p() {
|
||||
assert_eq!(
|
||||
next_upscale_tier((1280, 720), (2560, 1440)),
|
||||
Some((1920, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_returns_none_at_highest() {
|
||||
assert_eq!(next_upscale_tier((2560, 1440), (2560, 1440)), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
pub(super) const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
|
||||
|
||||
pub(super) fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
|
||||
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
|
||||
}
|
||||
|
||||
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
|
||||
/// BWE estimate arrives within milliseconds of client connect and overrides this;
|
||||
/// the startup value only affects the first IDR. See issue #21.
|
||||
pub(super) fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
if pixels <= 1_000_000 {
|
||||
1_000_000
|
||||
} else if pixels <= 2_500_000 {
|
||||
2_000_000
|
||||
} else if pixels <= 4_500_000 {
|
||||
4_000_000
|
||||
} else {
|
||||
8_000_000
|
||||
}
|
||||
}
|
||||
|
||||
/// Select resolution tier based on BWE estimate.
|
||||
/// Returns (width, height) for the selected tier.
|
||||
pub(super) fn select_resolution(
|
||||
current_w: u32,
|
||||
current_h: u32,
|
||||
bwe_bps: u64,
|
||||
fps: u32,
|
||||
) -> (u32, u32) {
|
||||
let current = (current_w, current_h);
|
||||
let current_bitrate = resolution_bitrate_bps(current_w, current_h, fps);
|
||||
if bwe_bps >= current_bitrate.saturating_mul(60) / 100 {
|
||||
return current;
|
||||
}
|
||||
|
||||
let current_index = RESOLUTION_TIERS
|
||||
.iter()
|
||||
.position(|&tier| tier == current)
|
||||
.unwrap_or_else(|| {
|
||||
RESOLUTION_TIERS
|
||||
.iter()
|
||||
.position(|&(w, h)| w <= current_w && h <= current_h)
|
||||
.unwrap_or(RESOLUTION_TIERS.len() - 1)
|
||||
});
|
||||
let next_index = (current_index + 1).min(RESOLUTION_TIERS.len() - 1);
|
||||
RESOLUTION_TIERS[next_index]
|
||||
}
|
||||
|
||||
pub(super) fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
|
||||
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
|
||||
if current_index == 0 {
|
||||
return None;
|
||||
}
|
||||
let next = RESOLUTION_TIERS[current_index - 1];
|
||||
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn webrtc_startup_bitrate_tiers_by_pixel_count() {
|
||||
assert_eq!(webrtc_startup_bitrate_bps(1280, 720), 1_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(1920, 1080), 2_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(2560, 1440), 4_000_000);
|
||||
assert_eq!(webrtc_startup_bitrate_bps(3840, 2160), 8_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_downscales_one_tier_below_sixty_percent() {
|
||||
let fps = 30;
|
||||
let current = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, current * 59 / 100, fps),
|
||||
(1280, 720)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_keeps_tier_at_sixty_percent() {
|
||||
let fps = 30;
|
||||
let current = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, current * 60 / 100, fps),
|
||||
(1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_never_goes_below_720p() {
|
||||
assert_eq!(select_resolution(1280, 720, 1, 30), (1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_respects_initial_ceiling() {
|
||||
assert_eq!(
|
||||
next_upscale_tier((1280, 720), (1920, 1080)),
|
||||
Some((1920, 1080))
|
||||
);
|
||||
assert_eq!(next_upscale_tier((1920, 1080), (1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_keeps_720p_when_bwe_sufficient() {
|
||||
let fps = 30;
|
||||
let bitrate_720 = resolution_bitrate_bps(1280, 720, fps);
|
||||
assert_eq!(select_resolution(1280, 720, bitrate_720, fps), (1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_downscales_1440p_to_1080p() {
|
||||
let fps = 30;
|
||||
let bitrate_1440 = resolution_bitrate_bps(2560, 1440, fps);
|
||||
assert_eq!(
|
||||
select_resolution(2560, 1440, bitrate_1440 * 59 / 100, fps),
|
||||
(1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_resolution_1080p_to_720p_at_very_low_bwe() {
|
||||
let fps = 30;
|
||||
let bitrate_1080 = resolution_bitrate_bps(1920, 1080, fps);
|
||||
assert_eq!(
|
||||
select_resolution(1920, 1080, bitrate_1080 / 10, fps),
|
||||
(1280, 720)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_from_720p_to_1080p() {
|
||||
assert_eq!(
|
||||
next_upscale_tier((1280, 720), (2560, 1440)),
|
||||
Some((1920, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_upscale_tier_returns_none_at_highest() {
|
||||
assert_eq!(next_upscale_tier((2560, 1440), (2560, 1440)), None);
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::avhw::{BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodedH264Frame, SwEncEncode};
|
||||
use crate::webrtc::WebRtcState;
|
||||
|
||||
use super::bitrate::{next_upscale_tier, resolution_bitrate_bps, select_resolution};
|
||||
|
||||
pub(super) struct EncodeThreadTiming {
|
||||
pub(super) sws_us: u64,
|
||||
pub(super) encode_us: u64,
|
||||
pub(super) output_bytes: usize,
|
||||
}
|
||||
|
||||
pub(super) struct EncodeThread {
|
||||
pub(super) handle: Option<std::thread::JoinHandle<()>>,
|
||||
pub(super) input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
|
||||
pub(super) timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
|
||||
pub(super) duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
pub(super) struct WebrtcThread {
|
||||
pub(super) handle: Option<std::thread::JoinHandle<()>>,
|
||||
pub(super) sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
|
||||
}
|
||||
|
||||
/// Static configuration handed to the WebRTC sender thread. Immutable for the
|
||||
/// thread's lifetime; a resolution tier change rebuilds the whole pipeline
|
||||
/// (and spawns a new thread) rather than mutating this.
|
||||
pub(super) struct WebRtcThreadConfig {
|
||||
pub(super) fps: u32,
|
||||
pub(super) enc_width: u32,
|
||||
pub(super) enc_height: u32,
|
||||
pub(super) max_bitrate: u64,
|
||||
}
|
||||
|
||||
/// Channel endpoints owned exclusively by the WebRTC sender thread after spawn.
|
||||
/// The reverse endpoints stay with StatePortal (or the encode thread) for
|
||||
/// inbound/outbound traffic.
|
||||
pub(super) struct WebRtcThreadChannels {
|
||||
pub(super) webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
|
||||
pub(super) sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
|
||||
pub(super) bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
|
||||
pub(super) resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
|
||||
}
|
||||
|
||||
pub(super) fn encode_thread_loop(
|
||||
mut encode: SwEncEncode,
|
||||
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
|
||||
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
|
||||
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) {
|
||||
loop {
|
||||
match input_rx.recv() {
|
||||
Ok(frame) => {
|
||||
match encode.encode_cpu_frame(&frame) {
|
||||
Ok(EncodeOutcome::Encoded) => {
|
||||
let t = encode.take_timing();
|
||||
let _ = timing_tx.try_send(EncodeThreadTiming {
|
||||
sws_us: t.sws_us,
|
||||
encode_us: t.encode_us,
|
||||
output_bytes: t.output_bytes,
|
||||
});
|
||||
}
|
||||
Ok(EncodeOutcome::SkippedDuplicate) => {
|
||||
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
Ok(_) => {
|
||||
// SkippedPaused / SkippedDisconnected — no counter needed
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Encode thread error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!("Encode thread input closed, flushing encoder");
|
||||
if let Err(e) = encode.flush() {
|
||||
tracing::error!("Encode thread flush error: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("Encode thread exiting");
|
||||
}
|
||||
|
||||
pub(super) fn webrtc_thread_loop(
|
||||
mut wrtc: WebRtcState,
|
||||
config: WebRtcThreadConfig,
|
||||
channels: WebRtcThreadChannels,
|
||||
paused: Arc<AtomicBool>,
|
||||
) {
|
||||
let WebRtcThreadConfig {
|
||||
fps,
|
||||
enc_width,
|
||||
enc_height,
|
||||
max_bitrate,
|
||||
} = config;
|
||||
let WebRtcThreadChannels {
|
||||
webrtc_rx,
|
||||
sent_gap_tx,
|
||||
bitrate_tx,
|
||||
resolution_tx,
|
||||
} = channels;
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut last_send: Option<std::time::Instant> = None;
|
||||
let mut last_sent_bitrate: Option<u64> = None;
|
||||
let initial_tier = (enc_width, enc_height);
|
||||
let mut current_tier = initial_tier;
|
||||
let mut upscale_counter = 0u32;
|
||||
let mut last_resolution_eval = Instant::now();
|
||||
let timeout = Duration::from_millis(1);
|
||||
|
||||
loop {
|
||||
if let Err(e) = wrtc.handle_signaling() {
|
||||
tracing::error!("WebRTC signaling error: {e}");
|
||||
break;
|
||||
}
|
||||
if let Err(e) = wrtc.poll_and_feed() {
|
||||
tracing::error!("WebRTC poll error: {e}");
|
||||
break;
|
||||
}
|
||||
|
||||
if wrtc.take_force_keyframe() {
|
||||
let _ = bitrate_tx.try_send(BitrateCommand::ForceKeyframe);
|
||||
}
|
||||
|
||||
let connected = wrtc.is_connected();
|
||||
let was_paused = paused.load(Ordering::Relaxed);
|
||||
let now_paused = !connected;
|
||||
if was_paused && !now_paused {
|
||||
tracing::info!("WebRTC client connected, resuming encoding");
|
||||
} else if !was_paused && now_paused {
|
||||
tracing::warn!("WebRTC client disconnected, pausing encoding");
|
||||
}
|
||||
paused.store(now_paused, Ordering::Relaxed);
|
||||
|
||||
if let Some(bwe) = wrtc.get_bwe_estimate() {
|
||||
// #23: Cap BWE to prevent runaway bitrate escalation. Without this, BWE
|
||||
// estimates can rise to 10+ Mbps, causing IDR bursts and PLI storms.
|
||||
let effective_bwe = bwe.min(max_bitrate);
|
||||
if effective_bwe != bwe {
|
||||
tracing::debug!(
|
||||
bwe,
|
||||
effective_bwe,
|
||||
max_bitrate,
|
||||
"BWE exceeds --max-bitrate cap, clamping"
|
||||
);
|
||||
}
|
||||
let bwe = effective_bwe;
|
||||
|
||||
let should_send = match last_sent_bitrate {
|
||||
None => true,
|
||||
Some(last) => {
|
||||
let diff = bwe.abs_diff(last);
|
||||
diff * 10 > last
|
||||
}
|
||||
};
|
||||
if should_send {
|
||||
let _ = bitrate_tx.try_send(BitrateCommand::UpdateBitrate { target_bps: bwe });
|
||||
last_sent_bitrate = Some(bwe);
|
||||
}
|
||||
|
||||
if last_resolution_eval.elapsed() >= Duration::from_secs(1) {
|
||||
last_resolution_eval = Instant::now();
|
||||
let selected = select_resolution(current_tier.0, current_tier.1, bwe, fps);
|
||||
if selected != current_tier {
|
||||
current_tier = selected;
|
||||
upscale_counter = 0;
|
||||
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
|
||||
width: current_tier.0,
|
||||
height: current_tier.1,
|
||||
});
|
||||
wrtc.set_need_keyframe();
|
||||
} else if let Some(next_tier) = next_upscale_tier(current_tier, initial_tier) {
|
||||
let needed = resolution_bitrate_bps(next_tier.0, next_tier.1, fps);
|
||||
if bwe > needed.saturating_mul(120) / 100 {
|
||||
upscale_counter = upscale_counter.saturating_add(1);
|
||||
if upscale_counter >= 10 {
|
||||
current_tier = next_tier;
|
||||
upscale_counter = 0;
|
||||
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
|
||||
width: current_tier.0,
|
||||
height: current_tier.1,
|
||||
});
|
||||
wrtc.set_need_keyframe();
|
||||
}
|
||||
} else {
|
||||
upscale_counter = 0;
|
||||
}
|
||||
} else {
|
||||
upscale_counter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if connected {
|
||||
while let Ok(enc_frame) = webrtc_rx.try_recv() {
|
||||
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
|
||||
tracing::debug!("WebRTC write frame error: {e}");
|
||||
}
|
||||
frames_sent = frames_sent.saturating_add(1);
|
||||
let gap_ms = last_send
|
||||
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
// Compute capture-to-send age on the sending thread so the
|
||||
// frame_age stat stays accurate when batch-drained later.
|
||||
let age_ms = Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
|
||||
last_send = Some(std::time::Instant::now());
|
||||
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
|
||||
}
|
||||
} else {
|
||||
while webrtc_rx.try_recv().is_ok() {}
|
||||
}
|
||||
|
||||
match webrtc_rx.recv_timeout(timeout) {
|
||||
Ok(enc_frame) => {
|
||||
if wrtc.is_connected() {
|
||||
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
|
||||
tracing::debug!("WebRTC write frame error: {e}");
|
||||
}
|
||||
frames_sent = frames_sent.saturating_add(1);
|
||||
let gap_ms = last_send
|
||||
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
let age_ms = Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
|
||||
last_send = Some(std::time::Instant::now());
|
||||
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
|
||||
}
|
||||
}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
||||
tracing::info!("WebRTC channel disconnected, exiting thread");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("WebRTC thread exiting");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn try_send_full_channel_returns_full_not_block() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
tx.send(vec![1]).unwrap();
|
||||
tx.send(vec![2]).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![3]),
|
||||
Err(crossbeam_channel::TrySendError::Full(_))
|
||||
));
|
||||
assert_eq!(rx.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_send_after_rx_dropped_returns_disconnected() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
drop(rx);
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![1]),
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_))
|
||||
));
|
||||
}
|
||||
|
||||
// given: full bounded channel
|
||||
// when: rx is dropped, then try_send
|
||||
// expect: Disconnected, not blocking
|
||||
#[test]
|
||||
fn shutdown_rx_drop_prevents_deadlock_on_full_channel() {
|
||||
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
|
||||
tx.send(vec![1]).unwrap();
|
||||
tx.send(vec![2]).unwrap();
|
||||
drop(rx);
|
||||
|
||||
assert!(matches!(
|
||||
tx.try_send(vec![3]),
|
||||
Err(crossbeam_channel::TrySendError::Disconnected(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
+288
-49
@@ -1,3 +1,25 @@
|
||||
//! 管道性能统计模块 —— 用于卡顿诊断的轻量级滑动窗口统计。
|
||||
//!
|
||||
//! 本模块跟踪 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.
|
||||
@@ -6,6 +28,15 @@
|
||||
|
||||
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
|
||||
@@ -28,6 +59,27 @@ pub struct FrameTimings {
|
||||
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,
|
||||
@@ -38,6 +90,7 @@ pub struct PipelineStats {
|
||||
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.
|
||||
@@ -72,19 +125,19 @@ pub struct PipelineStats {
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
impl Default for PipelineStats {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -108,6 +161,21 @@ impl PipelineStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次来自 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();
|
||||
@@ -119,6 +187,14 @@ impl PipelineStats {
|
||||
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();
|
||||
@@ -138,10 +214,19 @@ impl PipelineStats {
|
||||
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 {
|
||||
@@ -157,6 +242,19 @@ impl PipelineStats {
|
||||
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).
|
||||
@@ -178,6 +276,18 @@ impl PipelineStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从后台 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).
|
||||
@@ -193,31 +303,82 @@ impl PipelineStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 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.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();
|
||||
@@ -230,6 +391,7 @@ impl PipelineStats {
|
||||
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,
|
||||
@@ -268,6 +430,7 @@ impl PipelineStats {
|
||||
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;
|
||||
@@ -289,6 +452,17 @@ impl PipelineStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一秒窗口的管道统计快照(不可变值对象,由 `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 {
|
||||
@@ -302,6 +476,7 @@ pub struct StatsSnapshot {
|
||||
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,
|
||||
@@ -341,74 +516,79 @@ pub struct StatsSnapshot {
|
||||
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 {
|
||||
// Layout note: each line answers one operational question.
|
||||
// - line 1: throughput (fps + frame counts + window length)
|
||||
// - line 2: drops (PipeWire backlog, encoder over-budget, frame-hash dedup)
|
||||
// - line 3: queue back-pressure (capture + encoded)
|
||||
// - line 4-6: gap timing (avg + p95 + max) — answers "is cadence stable?"
|
||||
// - line 7: capture-to-send age (avg + p95 + max) — answers "how stale?"
|
||||
// - line 8: per-stage encode timing (avg + p95) — answers "where is latency?"
|
||||
// - line 9: output bandwidth (bytes/sec + per-frame p95/max)
|
||||
//
|
||||
// The avg counterparts were computed but never displayed before Oracle
|
||||
// audit 2026-06-28; they pair with the existing p95/max to surface both
|
||||
// central tendency and tail behaviour in the same glance.
|
||||
write!(
|
||||
f,
|
||||
"elapsed={:.1}s capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
|
||||
capture_frames={} encoded_frames={} sent_frames={} \
|
||||
pw_dropped={} duplicate_frames_skipped={} \
|
||||
cap_q={} enc_q={} \
|
||||
cap_gap_avg={:.1}ms cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \
|
||||
enc_gap_avg={:.1}ms enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \
|
||||
sent_gap_avg={:.1}ms sent_gap_p95={:.1}ms sent_gap_max={:.1}ms \
|
||||
frame_age_avg={:.1}ms frame_age_p95={:.1}ms frame_age_max={:.1}ms \
|
||||
send_wait_p95={:.1}ms \
|
||||
import_avg={:.1}ms import_p95={:.1}ms \
|
||||
scale_avg={:.1}ms scale_p95={:.1}ms transfer_avg={:.1}ms transfer_p95={:.1}ms \
|
||||
sws_avg={:.1}ms sws_p95={:.1}ms \
|
||||
encode_avg={:.1}ms encode_p95={:.1}ms total_avg={:.1}ms total_p95={:.1}ms \
|
||||
output_bps={:.0} frame_bytes_p95={} frame_bytes_max={}",
|
||||
self.elapsed_secs,
|
||||
"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.capture_frames,
|
||||
self.encoded_frames,
|
||||
self.sent_frames,
|
||||
self.pipewire_dropped,
|
||||
self.over_budget_count,
|
||||
self.duplicate_frames_skipped,
|
||||
self.capture_queue_depth,
|
||||
self.encoded_queue_depth,
|
||||
self.capture_gap_avg_ms,
|
||||
self.capture_gap_p95_ms,
|
||||
self.capture_gap_max_ms,
|
||||
self.encoded_gap_avg_ms,
|
||||
self.encoded_gap_p95_ms,
|
||||
self.encoded_gap_max_ms,
|
||||
self.sent_gap_avg_ms,
|
||||
self.sent_gap_p95_ms,
|
||||
self.sent_gap_max_ms,
|
||||
self.frame_age_avg_ms,
|
||||
self.frame_age_p95_ms,
|
||||
self.frame_age_max_ms,
|
||||
self.send_wait_p95_ms,
|
||||
self.import_avg_ms,
|
||||
self.import_p95_ms,
|
||||
self.scale_avg_ms,
|
||||
self.scale_p95_ms,
|
||||
self.transfer_avg_ms,
|
||||
self.transfer_p95_ms,
|
||||
self.sws_avg_ms,
|
||||
self.sws_p95_ms,
|
||||
self.encode_avg_ms,
|
||||
self.encode_p95_ms,
|
||||
self.total_avg_ms,
|
||||
self.total_p95_ms,
|
||||
self.output_bytes_per_sec,
|
||||
self.output_frame_bytes_p95,
|
||||
self.output_frame_bytes_max,
|
||||
)
|
||||
}
|
||||
@@ -418,6 +598,13 @@ impl std::fmt::Display for StatsSnapshot {
|
||||
// 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;
|
||||
@@ -425,6 +612,21 @@ fn avg_f64(data: &[f64]) -> f64 {
|
||||
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;
|
||||
@@ -435,10 +637,22 @@ fn p95_f64(data: &[f64]) -> f64 {
|
||||
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;
|
||||
@@ -446,6 +660,10 @@ fn avg_ms(data: &[u64]) -> f64 {
|
||||
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;
|
||||
@@ -456,10 +674,15 @@ fn p95_ms(data: &[u64]) -> f64 {
|
||||
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;
|
||||
@@ -470,10 +693,26 @@ fn p95_usize(data: &[usize]) -> 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::*;
|
||||
|
||||
+410
-7
@@ -1,12 +1,38 @@
|
||||
//! Coordinate transformation module for Wayland output transforms.
|
||||
//! 图像几何变换模块(纯坐标运算,不涉及像素缓冲区)。
|
||||
//!
|
||||
//! Historically exposed a family of `Rect`/`screen_to_frame`/`fit_inside_bounds`
|
||||
//! helpers for ROI-based capture clipping. Those were never wired into the
|
||||
//! capture pipeline (we capture full frames and let FFmpeg's filter graph handle
|
||||
//! any scaling/rotation); they have been removed. Only `Transform` and the
|
||||
//! `transpose_if_transform_transposed` helper remain — both are actively used by
|
||||
//! `state.rs` and `avhw.rs`.
|
||||
//! 对应 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)
|
||||
/// and ROI clipping for screen capture.
|
||||
///
|
||||
/// Wayland output transform enum, matching `wl_output::Transform`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Transform {
|
||||
@@ -20,25 +46,288 @@ 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 {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
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
|
||||
/// frame coordinates:
|
||||
///
|
||||
/// ```text
|
||||
/// [new_x] [a b] [x]
|
||||
/// [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
|
||||
/// fits within the frame dimensions `(frame_w, frame_h)`.
|
||||
///
|
||||
/// ```text
|
||||
/// new_x = a * x + b * y + offset_x
|
||||
/// 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 };
|
||||
|
||||
let new_x = a * rect.x + b * rect.y + offset_x;
|
||||
let new_y = c * rect.x + d * rect.y + offset_y;
|
||||
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,
|
||||
w: new_w.abs(),
|
||||
h: new_h.abs(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── transform_basis ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn basis_normal_is_identity() {
|
||||
assert_eq!(transform_basis(Transform::Normal), (1, 0, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_90_cw_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal90), (0, 1, -1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_180_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal180), (-1, 0, 0, -1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_270_cw_rotation() {
|
||||
assert_eq!(transform_basis(Transform::Normal270), (0, -1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_horizontal() {
|
||||
assert_eq!(transform_basis(Transform::Flipped), (-1, 0, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_90() {
|
||||
assert_eq!(transform_basis(Transform::Flipped90), (0, 1, 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_180() {
|
||||
assert_eq!(transform_basis(Transform::Flipped180), (1, 0, 0, -1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_flipped_270() {
|
||||
assert_eq!(transform_basis(Transform::Flipped270), (0, -1, -1, 0));
|
||||
}
|
||||
|
||||
// ── screen_to_frame ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_identity_unchanged() {
|
||||
let rect = Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 100,
|
||||
h: 50,
|
||||
};
|
||||
let result = screen_to_frame(Transform::Normal, rect, 1920, 1080);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 100,
|
||||
h: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_90_rotates_origin() {
|
||||
// 90° CW: top-left (0,0) in screen should map to bottom-left in frame
|
||||
let rect = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 100,
|
||||
h: 50,
|
||||
};
|
||||
let result = screen_to_frame(Transform::Normal90, rect, 1080, 1920);
|
||||
// a=0,b=1,c=-1,d=0 => offset_x=0, offset_y=1920 (c+d=-1<0)
|
||||
// new_x = 0*0 + 1*0 + 0 = 0
|
||||
// new_y = -1*0 + 0*0 + 1920 = 1920
|
||||
assert_eq!(result.x, 0);
|
||||
assert_eq!(result.y, 1920);
|
||||
// w' = 0*100 + 1*50 = 50, h' = -1*100 + 0*50 = -100 -> abs=100
|
||||
assert_eq!(result.w, 50);
|
||||
assert_eq!(result.h, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_180_rotates() {
|
||||
let rect = Rect {
|
||||
x: 100,
|
||||
y: 200,
|
||||
w: 300,
|
||||
h: 400,
|
||||
};
|
||||
let result = screen_to_frame(Transform::Normal180, rect, 1920, 1080);
|
||||
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
|
||||
assert_eq!(result.x, -100 + 1920);
|
||||
assert_eq!(result.y, -200 + 1080);
|
||||
assert_eq!(result.w, 300);
|
||||
assert_eq!(result.h, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_to_frame_flipped_horizontal() {
|
||||
let rect = Rect {
|
||||
x: 50,
|
||||
y: 30,
|
||||
w: 200,
|
||||
h: 100,
|
||||
};
|
||||
let result = screen_to_frame(Transform::Flipped, rect, 1920, 1080);
|
||||
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
|
||||
assert_eq!(result.x, -50 + 1920);
|
||||
assert_eq!(result.y, 30);
|
||||
assert_eq!(result.w, 200);
|
||||
assert_eq!(result.h, 100);
|
||||
}
|
||||
|
||||
// ── transpose_if_transform_transposed ─────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -104,4 +393,118 @@ mod tests {
|
||||
(1080, 1920)
|
||||
);
|
||||
}
|
||||
|
||||
// ── fit_inside_bounds ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fit_inside_already_fits() {
|
||||
let rect = Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 100,
|
||||
h: 50,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(result, rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_clips_right_and_bottom() {
|
||||
let rect = Rect {
|
||||
x: 1800,
|
||||
y: 1000,
|
||||
w: 200,
|
||||
h: 200,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 1800,
|
||||
y: 1000,
|
||||
w: 120,
|
||||
h: 80
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_clips_negative_origin() {
|
||||
let rect = Rect {
|
||||
x: -50,
|
||||
y: -30,
|
||||
w: 200,
|
||||
h: 200,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 150,
|
||||
h: 170
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_completely_out_of_bounds() {
|
||||
let rect = Rect {
|
||||
x: 2000,
|
||||
y: 2000,
|
||||
w: 100,
|
||||
h: 100,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 1920,
|
||||
y: 1080,
|
||||
w: 0,
|
||||
h: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_zero_size_rect() {
|
||||
let rect = Rect {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 0,
|
||||
h: 0,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 0,
|
||||
h: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_inside_zero_bounds() {
|
||||
let rect = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 100,
|
||||
h: 100,
|
||||
};
|
||||
let result = fit_inside_bounds(rect, 0, 0);
|
||||
assert_eq!(
|
||||
result,
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+450
-3
@@ -1,3 +1,47 @@
|
||||
//! # WebRTC 传输模块 — str0m Sans-IO 信令服务器与媒体出口
|
||||
//!
|
||||
//! ## 模块定位
|
||||
//! 将 H.264 编码帧通过 WebRTC 推送到浏览器(替代文件输出)。仅在 `--port > 0` 时启用;
|
||||
//! `--port 0`(默认)走纯文件输出路径,本模块不会被实例化(见 `main.rs` 入口判断)。
|
||||
//!
|
||||
//! ## str0m 是 Sans-IO WebRTC 库
|
||||
//! 类比 Go 的 `net/http`,但 Sans-IO 哲学不同:
|
||||
//! - **没有 background goroutine**:str0m 不创建任何线程,所有进度都靠外部 poll 推动
|
||||
//! - **手动驱动 3 步循环**(见 `poll_and_feed`/`feed_network`/`poll_rtc`):
|
||||
//! 1. 读 UDP 包 → `Rtc::handle_input(Input::Receive(...))` 喂给 str0m
|
||||
//! 2. 调 `Rtc::poll_output()` 拿 `Output::Transmit` 包 → 写回 UDP socket
|
||||
//! 3. 定时喂 `Input::Timeout(Instant::now())` 推动内部时钟
|
||||
//! - **同步而非 async**:str0m 不是 async/await 库(与 `tokio::net::TcpListener` 等
|
||||
//! 异步运行时无关);本文件用 `std::net::TcpListener` + `UdpSocket`(手动
|
||||
//! `set_nonblocking(true)`),完全同步代码;上层 `main.rs` 在 mio 事件循环里
|
||||
//! 周期性调 `poll_and_feed()` 推动 RTC 状态机
|
||||
//! - **Go 等价物**:`github.com/pion/webrtc`(Go 主流 WebRTC 库)也是同步 + 手动驱动,
|
||||
//! 但 str0m 把 Sans-IO 推得更彻底——连 UDP socket 都不持有,所有 I/O 都由调用方管理
|
||||
//!
|
||||
//! ## 内嵌 HTTP 信令服务器
|
||||
//! 本模块自带一个极简 HTTP 服务器(`std::net::TcpListener`,非 tokio/axum),3 个端点:
|
||||
//! - `GET /` → 返回 `HTML_PAGE`(自带 SDP 协商 + `<video>` 播放 + 实时 stats 的测试页)
|
||||
//! - `POST /sdp`(Content-Type: application/json)→ 接收浏览器 `RTCPeerConnection`
|
||||
//! localDescription(Offer SDP),交给 `Rtc::sdp_api().accept_offer()` 生成 Answer,
|
||||
//! 返回 JSON body 给浏览器 `setRemoteDescription`
|
||||
//! - `GET /sdp`(无 JSON Content-Type)→ 与 `GET /` 同(兼容旧路径)
|
||||
//!
|
||||
//! ICE candidate 通过 SDP offer/answer 完成:浏览器等 `iceGatheringState == 'complete'`
|
||||
//! 才 POST(见 `HTML_PAGE` 的 `onicegatheringstatechange`),所以 candidate 已全在
|
||||
//! SDP 里,本服务端无需单独的 ICE endpoint(trickle ICE 关闭)。
|
||||
//!
|
||||
//! ## 关键不变量
|
||||
//! - **单连接**:`WebRtcState::inner: Option<WebRtcInner>` 只持有 1 个 peer;新连接
|
||||
//! POST 进来时,旧 `inner` 被 drop(旧 `Rtc` 析构,UDP socket 关闭)
|
||||
//! - **非阻塞 IO**:所有 socket `set_nonblocking(true)`,`WouldBlock` 是常态而非错误
|
||||
//! - **BWE 启动**:`RtcConfig::enable_bwe(Some(Bitrate::mbps(5)))` 启用带宽估计,
|
||||
//! 用于动态分辨率切换(见 `state_portal.rs::select_resolution`)
|
||||
//!
|
||||
//! ## 引用
|
||||
//! - `Cargo.toml`: `str0m = "0.20"`
|
||||
//! - git `727893f`: bitrate 修复(BWE 与 VBV 协同)
|
||||
//! - issue #23: PLI 节流(`FORCED_KEYFRAME_MIN_INTERVAL`)
|
||||
|
||||
// WebRTC 传输模块 — 使用 str0m (Sans-IO) 将 H.264 编码帧推送到浏览器
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, UdpSocket};
|
||||
@@ -17,33 +61,235 @@ use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig}
|
||||
/// bursts. See issue #23.
|
||||
const FORCED_KEYFRAME_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
mod html_page;
|
||||
use html_page::HTML_PAGE;
|
||||
// ── 嵌入式 HTML 测试页面 ──────────────────────────────────────────────────
|
||||
|
||||
const HTML_PAGE: &str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>wl-webrtc P0</title>
|
||||
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
video{max-width:90vw;max-height:80vh;border:1px solid #333}
|
||||
#status{margin:12px;font-size:14px;color:#aaa}
|
||||
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
|
||||
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="status">Connecting...</div>
|
||||
<video id="video" autoplay playsinline muted></video>
|
||||
<pre id="debug"></pre>
|
||||
<div id="stats-panel"></div>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const video = document.getElementById('video');
|
||||
const debug = document.getElementById('debug');
|
||||
let pc = null;
|
||||
|
||||
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
|
||||
|
||||
function preferH264(sdp) {
|
||||
const lines = sdp.split('\r\n');
|
||||
const h264Pts = lines
|
||||
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
|
||||
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
|
||||
.filter(Boolean);
|
||||
if (h264Pts.length === 0) return sdp;
|
||||
return lines.map(line => {
|
||||
if (!line.startsWith('m=video ')) return line;
|
||||
const parts = line.split(' ');
|
||||
const header = parts.slice(0, 3);
|
||||
const pts = parts.slice(3);
|
||||
const preferred = h264Pts.filter(pt => pts.includes(pt));
|
||||
const rest = pts.filter(pt => !preferred.includes(pt));
|
||||
return [...header, ...preferred, ...rest].join(' ');
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
function installStatsLogger(peer) {
|
||||
const panel = document.getElementById('stats-panel');
|
||||
let prev = null;
|
||||
const intervalSecs = 1;
|
||||
|
||||
setInterval(() => {
|
||||
if (peer !== pc) return;
|
||||
peer.getStats().then(stats => {
|
||||
let rtp = null, rtt = null, codecStr = '';
|
||||
let freezeCount = null, totalFreezesDuration = null;
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
|
||||
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
|
||||
codecStr = report.mimeType + ' ' + (report.payloadType || '');
|
||||
// candidate-pair: feature-detect 'selected' property
|
||||
if (report.type === 'candidate-pair') {
|
||||
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
|
||||
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Freeze stats (feature-detect)
|
||||
if (rtp && typeof rtp.freezeCount !== 'undefined') {
|
||||
freezeCount = rtp.freezeCount;
|
||||
totalFreezesDuration = rtp.totalFreezesDuration;
|
||||
}
|
||||
|
||||
if (!rtp) return;
|
||||
|
||||
const cur = {
|
||||
framesDecoded: rtp.framesDecoded || 0,
|
||||
framesDropped: rtp.framesDropped || 0,
|
||||
framesPerSecond: rtp.framesPerSecond || 0,
|
||||
packetsLost: rtp.packetsLost || 0,
|
||||
jitter: rtp.jitter || 0,
|
||||
bytesReceived: rtp.bytesReceived || 0,
|
||||
totalDecodeTime: rtp.totalDecodeTime || 0,
|
||||
jitterBufferDelay: rtp.jitterBufferDelay || 0,
|
||||
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
|
||||
freezeCount: freezeCount,
|
||||
totalFreezesDuration: totalFreezesDuration,
|
||||
rtt: rtt,
|
||||
};
|
||||
|
||||
// Raw log to debug element (backward compat)
|
||||
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
|
||||
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
|
||||
(codecStr ? ' codec=' + codecStr : ''));
|
||||
|
||||
if (!prev) { prev = cur; return; }
|
||||
|
||||
// Compute deltas
|
||||
const dFrames = cur.framesDecoded - prev.framesDecoded;
|
||||
const dDropped = cur.framesDropped - prev.framesDropped;
|
||||
const dLost = cur.packetsLost - prev.packetsLost;
|
||||
const dBytes = cur.bytesReceived - prev.bytesReceived;
|
||||
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
|
||||
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
|
||||
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
|
||||
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
|
||||
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
|
||||
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
|
||||
const jitterMs = (cur.jitter * 1000).toFixed(1);
|
||||
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
|
||||
|
||||
let line = 'FPS:' + cur.framesPerSecond +
|
||||
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
|
||||
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
|
||||
' Lost:' + dLost +
|
||||
' Jitter:' + jitterMs + 'ms' +
|
||||
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
|
||||
' Decode:' + decodeMs + 'ms' +
|
||||
' JBuf:' + jitterBufMs + 'ms';
|
||||
|
||||
if (freezeCount !== null) {
|
||||
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
|
||||
if (cur.freezeCount > 0 || dFreeze > 0)
|
||||
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
|
||||
}
|
||||
|
||||
line += ' ' + kbps + 'kbps';
|
||||
|
||||
panel.textContent = line;
|
||||
prev = cur;
|
||||
}).catch(() => {});
|
||||
}, intervalSecs * 1000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (pc) pc.close();
|
||||
pc = new RTCPeerConnection();
|
||||
const peer = pc;
|
||||
|
||||
peer.ontrack = e => {
|
||||
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
|
||||
video.srcObject = e.streams[0];
|
||||
status.textContent = 'Track received';
|
||||
};
|
||||
peer.oniceconnectionstatechange = () => {
|
||||
log('ICE: ' + peer.iceConnectionState);
|
||||
status.textContent = 'ICE: ' + peer.iceConnectionState;
|
||||
};
|
||||
|
||||
peer.addTransceiver('video', { direction: 'recvonly' });
|
||||
installStatsLogger(peer);
|
||||
|
||||
peer.createOffer().then(offer => {
|
||||
offer.sdp = preferH264(offer.sdp);
|
||||
return peer.setLocalDescription(offer);
|
||||
})
|
||||
.then(() => new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') resolve();
|
||||
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
|
||||
}))
|
||||
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
|
||||
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
|
||||
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
|
||||
.then(() => log('SDP answer set'))
|
||||
.catch(e => {
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
log('ERROR: ' + e.message + ' — retrying in 2s...');
|
||||
console.error(e);
|
||||
setTimeout(connect, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body></html>"#;
|
||||
|
||||
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
|
||||
|
||||
// 对外门面:持有 HTTP 信令监听器 + 当前唯一的 peer 连接(`inner`)。
|
||||
// 类比 Go 的 `*http.Server`,但 Sans-IO:所有推进都靠调用方主动 poll。
|
||||
pub struct WebRtcState {
|
||||
// HTTP 信令监听器(`POST /sdp` 协商;`GET /` 测试页面)。`set_nonblocking(true)`,
|
||||
// 由上层 mio 事件循环可读时调 `handle_signaling()` 接受连接。
|
||||
signal_listener: TcpListener,
|
||||
// 当前 peer。`None` = 尚无连接 / 上次连接已断开。新 `POST /sdp` 会整体替换此字段,
|
||||
// 旧 `Rtc` 实例被 drop(UDP socket 随之关闭)。
|
||||
inner: Option<WebRtcInner>,
|
||||
// 上层期望的帧率(来自 CLI `--fps`),用于初始化 `WebRtcInner`。
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
// 单个 WebRTC peer 的全部状态:str0m `Rtc` 实例 + 它专用的 UDP socket +
|
||||
// 编解码参数协商结果 + 关键帧请求/BWE 估计的运行时缓存。
|
||||
//
|
||||
// 字段访问路径(每帧一次,由 `main.rs` 的事件循环驱动):
|
||||
// 1. `feed_network()` 把 UDP 入包喂给 `Rtc::handle_input`
|
||||
// 2. `poll_rtc()` 取出 `Rtc::poll_output` 的 `Transmit` 包写回 UDP,并处理 `Event`
|
||||
// 3. `write_h264_frame()` 把编码后的 H.264 NALU 通过 `Rtc::writer(mid).write(...)` 发出
|
||||
struct WebRtcInner {
|
||||
// str0m `Rtc`:一个完整的 WebRTC peer connection(ICE / DTLS / SRTP / RTP / RTCP)。
|
||||
// Sans-IO:不持有任何 socket 或线程,只持有协议状态机。
|
||||
rtc: Rtc,
|
||||
// 本 peer 专用的 UDP socket(每连接一个,避免与不存在的其他 peer 串扰)。
|
||||
socket: UdpSocket,
|
||||
// 该 socket 绑定的本地地址(带随机端口),用作 `Candidate::host` 的发地址。
|
||||
udp_addr: SocketAddr,
|
||||
// 视频 Media ID(SDP 协商后从 `Event::MediaAdded` 捕获)。`None` = 尚未协商到。
|
||||
video_mid: Option<Mid>,
|
||||
// H.264 payload type(从 `Rtc::writer(mid).payload_params()` 扫描得到)。
|
||||
video_pt: Option<Pt>,
|
||||
// ICE+DTLS 是否已完成(`Event::Connected`)。未连接时 `write_h264_frame` 静默丢弃。
|
||||
connected: bool,
|
||||
// 等待下一个 IDR 关键帧(连接建立/分辨率切换时置 true,写帧时若非 IDR 则丢帧)。
|
||||
need_keyframe: bool,
|
||||
// 通知上游编码器下一次输出 IDR(`state.rs::State::take_force_keyframe` 拉取)。
|
||||
force_keyframe_to_encode: bool,
|
||||
// 最近一次强制关键帧时刻,用于 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(防 PLI 风暴)。
|
||||
last_forced_keyframe_at: Option<Instant>,
|
||||
// 最近一次 BWE 估计(来自 `Event::EgressBitrateEstimate`),用于上层动态分辨率选择。
|
||||
current_bwe_estimate: Option<Bitrate>,
|
||||
// 最近一次写入的 RTP 时间戳(90kHz),仅用于日志 trace,不参与协议正确性。
|
||||
rtp_clock: u32,
|
||||
// UDP 接收缓冲(重复利用以避免每包分配;65535 = max UDP payload)。
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WebRtcState {
|
||||
// 构造函数:绑定 HTTP 信令 TCP 监听器并设为非阻塞。`port` 来自 CLI `--port`,
|
||||
// `fps` 来自 CLI `--fps`,仅在 `--port > 0` 时被 `main.rs` 调用。
|
||||
//
|
||||
// 注意:本函数只创建信令监听器,**不**创建 UDP socket 或 `Rtc` 实例——
|
||||
// 那些在第一次 `POST /sdp` 时由 `WebRtcInner::new` 按需创建。
|
||||
pub fn new(port: u16, fps: u32) -> Result<Self> {
|
||||
let signal_listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
|
||||
signal_listener.set_nonblocking(true)?;
|
||||
@@ -56,18 +302,36 @@ impl WebRtcState {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理所有待接受的 HTTP 信令连接。上层 mio 循环在 `signal_listener` 可读时调用。
|
||||
//
|
||||
// 返回 `Ok(true)` 表示至少处理了一个请求(用于上层日志/计数)。
|
||||
// 单次调用 drain 当前 accept 队列里所有连接(`Err(WouldBlock)` 时退出循环)。
|
||||
//
|
||||
// 路由:
|
||||
// - `GET /` 或 `GET /sdp`(非 JSON)→ 返回 `HTML_PAGE`
|
||||
// - `POST /sdp` → 解析 body,构造新 `WebRtcInner` 并替换 `self.inner`
|
||||
// - 其他路径 → 404
|
||||
pub fn handle_signaling(&mut self) -> Result<bool> {
|
||||
let mut handled = false;
|
||||
loop {
|
||||
// `TcpListener::accept()` 类比 Go `ln.Accept()`;非阻塞模式下队列为空返回
|
||||
// `WouldBlock`,是 drain 完成的信号而非错误(类比 Go `accept` + nonblocking + EAGAIN)。
|
||||
let (mut stream, _addr) = match self.signal_listener.accept() {
|
||||
Ok(s) => s,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
// `bail!` 是 anyhow 提供的宏,等价于 `return Err(anyhow::anyhow!(...))`,
|
||||
// 类比 Go `return fmt.Errorf("TCP accept error: %w", err)`。
|
||||
Err(e) => bail!("TCP accept error: {e}"),
|
||||
};
|
||||
handled = true;
|
||||
// 设为非阻塞——类比 Go `syscall.SetNonblock(fd, true)`。后续 `stream.read`
|
||||
// 在没数据时返回 `WouldBlock`(用 `continue` 跳过本连接)。
|
||||
stream.set_nonblocking(true)?;
|
||||
|
||||
// 64KB 一次性读完:HTTP/1.0 客户端默认 `Connection: close`,浏览器 POST 整个
|
||||
// SDP offer 不会超过 64KB。`vec![0u8; N]` 类比 Go `make([]byte, N)`。
|
||||
let mut req = vec![0u8; 65536];
|
||||
// `stream.read(&mut req)` 类比 Go `conn.Read(buf)`——`Read` trait 即 Go `io.Reader`。
|
||||
let n = match stream.read(&mut req) {
|
||||
Ok(n) => n,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
@@ -76,6 +340,8 @@ impl WebRtcState {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// `String::from_utf8_lossy` 把字节转成字符串,无效 UTF-8 替换为 U+FFFD(HTTP 头都是 ASCII)。
|
||||
// 类比 Go `string(buf[:n])`(Go 字符串可包含任意字节,但后续 `starts_with` 也只看 ASCII)。
|
||||
let req_str = String::from_utf8_lossy(&req[..n]);
|
||||
|
||||
if req_str.starts_with("GET / ")
|
||||
@@ -100,12 +366,19 @@ impl WebRtcState {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `and_then`:Result 链式组合,类比 Go `if err != nil { return err }` 后继续。
|
||||
// `new_inner.handle_sdp_offer(...)?`:`?` 操作符传播 `Result::Err`,
|
||||
// 类比 Go `result, err := ...; if err != nil { return err }` 的简写。
|
||||
match WebRtcInner::new(self.fps).and_then(|mut new_inner| {
|
||||
let answer_json = new_inner.handle_sdp_offer(body.as_bytes())?;
|
||||
Ok((new_inner, answer_json))
|
||||
}) {
|
||||
Ok((new_inner, answer_json)) => {
|
||||
// `Option::is_some()` = Rust 检查 `Option` 是否为 `Some(_)`,
|
||||
// 类比 Go `if p != nil`。这里用于日志区分"替换"vs"首次"。
|
||||
let replacing = self.inner.is_some();
|
||||
// 整体替换 `self.inner`:旧 `Rtc` 实例 drop(UDP socket 关闭,
|
||||
// peer 连接断开)。这是单连接不变量的核心实现。
|
||||
self.inner = Some(new_inner);
|
||||
if replacing {
|
||||
tracing::info!("Replaced WebRTC connection (old dropped)");
|
||||
@@ -141,6 +414,10 @@ impl WebRtcState {
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
// 推动 str0m `Rtc` 状态机:取出 `poll_output` 的 `Transmit` 包写回 UDP,处理 `Event`。
|
||||
// 返回 `Ok(())`;若 `poll_rtc` 上报 peer 已断开,则清空 `self.inner`。
|
||||
//
|
||||
// 类比 Go pion/webrtc:没有 `go func()` 自动循环,必须由 main 线程显式调用。
|
||||
pub fn poll_rtc(&mut self) -> Result<()> {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
if inner.poll_rtc()? {
|
||||
@@ -151,6 +428,8 @@ impl WebRtcState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 从 UDP socket 读所有待处理包喂给 `Rtc::handle_input`。`WouldBlock` 退出循环。
|
||||
// Go 类比:`for { n, _ := conn.ReadFrom(buf); if errors.Is(err, EAGAIN) { break } }`。
|
||||
pub fn feed_network(&mut self) -> Result<()> {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.feed_network()?;
|
||||
@@ -158,12 +437,20 @@ impl WebRtcState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// `poll_rtc` → `feed_network` → `poll_rtc` 三明治。中间多一次 poll 是因为
|
||||
// `feed_network` 喂的入包可能触发 str0m 产生新的 `Transmit`(如 RTCP ACK),
|
||||
// 这些出包必须在同一轮循环里写回 UDP,避免延迟一帧。
|
||||
pub fn poll_and_feed(&mut self) -> Result<()> {
|
||||
self.poll_rtc()?;
|
||||
self.feed_network()?;
|
||||
self.poll_rtc()
|
||||
}
|
||||
|
||||
// 把一帧 H.264 NALU(已 annex-B 转码)写入 str0m `Rtc`,通过 RTP 发给 peer。
|
||||
// `pts_ticks` = 90kHz 时钟下的 PTS(编码器 time_base = 1/90000,等同 RTP 时间戳)。
|
||||
//
|
||||
// 返回 `Ok(())`;若 `WebRtcInner::write_h264_frame` 上报 peer 断开,则清空 `self.inner`。
|
||||
// 未连接 / 未协商到 mid/pt / 等待 IDR 时静默丢帧(`Ok(false)`)。
|
||||
pub fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<()> {
|
||||
let should_destroy = if let Some(inner) = self.inner.as_mut() {
|
||||
inner.write_h264_frame(data, pts_ticks)?
|
||||
@@ -177,10 +464,15 @@ impl WebRtcState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 是否有已连接的 peer。`Option::is_some_and` = Rust 短路求值,类比 Go
|
||||
// `if p != nil && p.connected { ... }`。
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.as_ref().is_some_and(WebRtcInner::is_connected)
|
||||
}
|
||||
|
||||
// 上层(`state_portal.rs::select_resolution`)查询最近一次 BWE 估计(bps)。
|
||||
// `None` = 尚未收到 `Event::EgressBitrateEstimate`;`Some(bps)` = str0m 推断的可用带宽。
|
||||
// 上层据此切换分辨率 tier(防止过载导致卡顿)。
|
||||
/// Returns the latest bandwidth estimation estimate in bits per second, if available.
|
||||
pub fn get_bwe_estimate(&self) -> Option<u64> {
|
||||
self.inner
|
||||
@@ -188,6 +480,9 @@ impl WebRtcState {
|
||||
.and_then(|inner| inner.current_bwe_estimate.map(|b| b.as_u64()))
|
||||
}
|
||||
|
||||
// 内部触发:连接刚建立或分辨率刚变化,需要立刻 IDR 以让对端解码器重置。
|
||||
// 不受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(本函数总是 honor),但会刷新
|
||||
// `last_forced_keyframe_at`,使紧接着的 1 秒内 viewer PLI 被丢弃。
|
||||
/// Internal keyframe request (connect, resolution change). Always honored,
|
||||
/// but updates last_forced_keyframe_at so a subsequent viewer PLI in the next
|
||||
/// second is throttled.
|
||||
@@ -197,6 +492,9 @@ impl WebRtcState {
|
||||
}
|
||||
}
|
||||
|
||||
// 外部触发:viewer 通过 RTCP PLI/FIR 主动请求关键帧(`Event::KeyframeRequest`)。
|
||||
// 受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(1 秒),防止恶意/频繁 PLI 触发 IDR 风暴
|
||||
// 撑爆上行带宽。See issue #23。
|
||||
/// External keyframe request from viewer (PLI/FIR via str0m
|
||||
/// `Event::KeyframeRequest`). Rate-limited to FORCED_KEYFRAME_MIN_INTERVAL
|
||||
/// to prevent PLI storms from swamping the network with IDR bursts.
|
||||
@@ -208,6 +506,8 @@ impl WebRtcState {
|
||||
}
|
||||
}
|
||||
|
||||
// 上层拉取"是否需要下一帧为 IDR"。返回 `true` 仅一次(取后自动复位),
|
||||
// 类比 Go `atomic.SwapInt32(&flag, 0)`。编码线程据此在下一帧 `force_idr=1`。
|
||||
pub fn take_force_keyframe(&mut self) -> bool {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
let v = inner.force_keyframe_to_encode;
|
||||
@@ -220,19 +520,46 @@ impl WebRtcState {
|
||||
}
|
||||
|
||||
impl WebRtcInner {
|
||||
// 构造一个全新的 WebRTC peer:创建 str0m `Rtc` 实例 + UDP socket + 候选地址。
|
||||
// 在 `handle_signaling` 接到 `POST /sdp` 时被调用——也就是说**每来一个 SDP offer
|
||||
// 都新建一个 peer**,旧 `Rtc` 实例随之 drop(UDP socket 关闭,连接断开)。
|
||||
//
|
||||
// 步骤:
|
||||
// 1. `RtcConfig::new().enable_bwe(...).build(...)`:str0m 构造器链式 Builder 模式,
|
||||
// 类比 Go `webrtc.NewAPI(webrtc.WithSettingEngine(...))`;启用 BWE(5 Mbps 初始)
|
||||
// 2. `UdpSocket::bind("0.0.0.0:0")`:OS 随机分配端口,类比 Go `net.ListenUDP("udp", nil)`
|
||||
// 3. `unsafe { libc::setsockopt(SO_SNDBUF) }`:扩大 UDP 发送缓冲到 2MB(默认 ~208KB
|
||||
// 在 IDR 突发下会 EAGAIN 丢包);英文 SAFETY 注释见下方
|
||||
// 4. `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用),
|
||||
// `Rtc::add_local_candidate` 注册到 str0m
|
||||
fn new(fps: u32) -> Result<Self> {
|
||||
// `let _ = fps;` 显式标记 fps 暂未使用(保留接口给未来 fps-based pacing)。
|
||||
// 类比 Go `_ = fps`。
|
||||
let _ = fps;
|
||||
// str0m `Rtc` 构造:Builder 模式 + 链式 setter。
|
||||
// - `RtcConfig::new()`:空配置
|
||||
// - `.enable_bwe(Some(Bitrate::mbps(5)))`:启用 bandwidth estimation,初始估 5 Mbps
|
||||
// - `.build(Instant::now())`:传入当前时刻作为 Rtc 内部时钟起点
|
||||
// 类比 Go pion/webrtc:`webrtc.NewAPI(webrtc.WithSettingEngine(...))`
|
||||
let mut rtc = RtcConfig::new()
|
||||
.enable_bwe(Some(Bitrate::mbps(5)))
|
||||
.build(Instant::now());
|
||||
|
||||
// `UdpSocket::bind("0.0.0.0:0")`:OS 随机分配端口(每 peer 独享一个 socket)。
|
||||
// 类比 Go `net.ListenUDP("udp", &net.UDPAddr{Port: 0})`。
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
socket.set_nonblocking(true)?;
|
||||
|
||||
// 中文概述:调大 UDP 发送缓冲到 2MB(默认 ~208KB),原因详见下方英文注释。
|
||||
// 然后用 `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且
|
||||
// 通常会翻倍)。Go 等价:`net.ListenConfig{Control: ...}`。
|
||||
// Increase UDP send buffer to absorb IDR frame bursts (256KB IDR → ~145 RTP
|
||||
// packets in a single poll_rtc loop). Default Linux wmem is ~208KB which
|
||||
// causes EAGAIN on large keyframes. 2MB comfortably buffers several IDRs.
|
||||
const SND_BUF_REQ: usize = 2 * 1024 * 1024;
|
||||
// 中文概述:调用 `setsockopt(SO_SNDBUF)` 调大 UDP 发送缓冲,然后用
|
||||
// `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且通常会
|
||||
// 翻倍)。FFI 安全性论证见下方英文 SAFETY 块。
|
||||
// SAFETY: fd is a valid UDP socket; setsockopt/getsockopt with SOL_SOCKET +
|
||||
// SO_SNDBUF are safe on Linux. We check the return value and log the actual
|
||||
// kernel-assigned buffer (Linux may cap at wmem_max and/or double the value).
|
||||
@@ -273,13 +600,22 @@ impl WebRtcInner {
|
||||
|
||||
let local_addr = socket.local_addr()?;
|
||||
|
||||
// `local_ip().unwrap_or_else(closure)`:`Option<T>::unwrap_or_else` 类比 Go
|
||||
// `if ip == "" { ip = "127.0.0.1" }`——`Option::None` 时执行闭包取兜底值。
|
||||
let lan_ip = local_ip().unwrap_or_else(|| {
|
||||
tracing::debug!("Failed to detect LAN IP, falling back to 127.0.0.1");
|
||||
"127.0.0.1".to_string()
|
||||
});
|
||||
// `format!("{lan_ip}:{}", port)`:Rust 格式化宏,类比 Go `fmt.Sprintf("%s:%d", ...)`.
|
||||
// `.parse::<SocketAddr>()`:字符串解析为 `SocketAddr`,`?` 自动传播 `AddrParseError`。
|
||||
let candidate_addr: SocketAddr = format!("{lan_ip}:{}", local_addr.port()).parse()?;
|
||||
// `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用,无 STUN/TURN)。
|
||||
// `.map_err(|e| anyhow::anyhow!(...))?`:把 str0m 自定义错误转成 `anyhow::Error`
|
||||
// 并传播,类比 Go `if err != nil { return fmt.Errorf("candidate: %w", err) }`。
|
||||
let candidate = Candidate::host(candidate_addr, "udp")
|
||||
.map_err(|e| anyhow::anyhow!("candidate: {e}"))?;
|
||||
// `Rtc::add_local_candidate`:把 candidate 注册到 str0m,之后 SDP 协商时它会被
|
||||
// 包含进 answer 的 `a=candidate:` 行。
|
||||
rtc.add_local_candidate(candidate);
|
||||
tracing::info!("WebRTC UDP: {candidate_addr} (bound 0.0.0.0)");
|
||||
|
||||
@@ -299,10 +635,27 @@ impl WebRtcInner {
|
||||
})
|
||||
}
|
||||
|
||||
// SDP offer/answer 交换:解析浏览器 POST 来的 SDP offer JSON → 喂给 str0m 协商 →
|
||||
// 返回 answer JSON。
|
||||
//
|
||||
// 关键步骤:
|
||||
// 1. `serde_json::from_slice`:反序列化 SDP offer(类比 Go `json.Unmarshal`)
|
||||
// 2. `self.rtc.sdp_api().accept_offer(offer)`:str0m 内部协商出 answer,
|
||||
// 副作用是设置 `Event::MediaAdded` 等待异步触发
|
||||
// 3. `self.need_keyframe = true; self.force_keyframe_to_encode = true;`:
|
||||
// 协商完成后立即请求 IDR,让对端尽快解码首帧
|
||||
// 4. `discover_video_params()`:扫描 str0m writer 找到 H.264 payload type
|
||||
// 5. `serde_json::to_vec`:序列化 answer(类比 Go `json.Marshal`)
|
||||
fn handle_sdp_offer(&mut self, body: &[u8]) -> Result<String> {
|
||||
// `serde_json::from_slice::<SdpOffer>(body)`:把浏览器 POST 的 JSON 反序列化成
|
||||
// str0m 的 `SdpOffer` 类型,类比 Go `json.Unmarshal(body, &offer)`。
|
||||
// `.map_err(...)?`:把 serde 错误包装成 anyhow 错误并传播。
|
||||
let offer: SdpOffer =
|
||||
serde_json::from_slice(body).map_err(|e| anyhow::anyhow!("parse SDP offer: {e}"))?;
|
||||
|
||||
// `Rtc::sdp_api().accept_offer(offer)`:str0m SDP 协商核心入口——
|
||||
// 解析 offer 中的 m= 行、codec 列表、ICE candidate,构造对应的 answer。
|
||||
// 副作用:触发后续 `Event::MediaAdded`(异步,要等 poll_rtc 才发)。
|
||||
let answer = self
|
||||
.rtc
|
||||
.sdp_api()
|
||||
@@ -321,6 +674,13 @@ impl WebRtcInner {
|
||||
String::from_utf8(answer_json).map_err(|e| anyhow::anyhow!("answer utf8: {e}"))
|
||||
}
|
||||
|
||||
// 扫描 str0m 内部协商出的 codec 列表,找到 H.264 payload type(`Pt`)。
|
||||
// 在 SDP 协商后、`Event::MediaAdded` 后、`Event::Connected` 后各调用一次
|
||||
// (三处调用是因为 str0m 的 codec 信息可能在不同时机可用——多保险)。
|
||||
//
|
||||
// 副作用:调用 `direct_api().stream_tx_by_mid(mid, None).set_unpaced(true)`
|
||||
// 关闭 str0m 的 LeakyBucketPacer(默认每包加 ~100ms pacing 延迟,与我们的 VBV
|
||||
// 8 Mbps 上限冲突;关掉后由编码器侧 VBV 做速率控制)。
|
||||
fn discover_video_params(&mut self) {
|
||||
let mid = match self.video_mid {
|
||||
Some(m) => m,
|
||||
@@ -333,12 +693,18 @@ impl WebRtcInner {
|
||||
// Disable str0m's LeakyBucketPacer for this video stream. Default pacing
|
||||
// adds ~100ms send latency per large IDR; our 8Mbps cap + VBV already
|
||||
// provide rate control. BWE stays enabled for adaptation feedback.
|
||||
// `direct_api()` 返回 str0m 内部 API(不公开稳定接口),`stream_tx_by_mid(mid, None)`
|
||||
// 取得该 mid 的发送流控制器;`set_unpaced(true)` 关闭 pacing。
|
||||
if let Some(stream_tx) = self.rtc.direct_api().stream_tx_by_mid(mid, None) {
|
||||
stream_tx.set_unpaced(true);
|
||||
}
|
||||
// `Rtc::writer(mid)` 返回媒体写入器,`payload_params()` 列出协商出的所有 codec。
|
||||
// 我们扫描找 H.264(`Codec::H264`)的 payload type,存入 `video_pt` 供后续 `write_h264_frame` 使用。
|
||||
if let Some(writer) = self.rtc.writer(mid) {
|
||||
for pp in writer.payload_params() {
|
||||
tracing::debug!("Codec: pt={:?} spec={:?}", pp.pt(), pp.spec());
|
||||
// `pp.spec().codec.is_video()`:先确认是视频 codec;
|
||||
// `pp.spec().codec == Codec::H264`:再确认是 H.264(非 VP8/VP9/AV1)。
|
||||
if pp.spec().codec.is_video() && pp.spec().codec == Codec::H264 {
|
||||
self.video_pt = Some(pp.pt());
|
||||
tracing::info!("H.264 payload type: {:?}", pp.pt());
|
||||
@@ -351,6 +717,8 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
|
||||
// 内部不节流版本:直接置位 `need_keyframe` + `force_keyframe_to_encode`,
|
||||
// 并刷新 `last_forced_keyframe_at`(防紧接着 1 秒内的 viewer PLI 重复触发 IDR)。
|
||||
/// Unthrottled keyframe trigger. Always sets the keyframe flags and refreshes
|
||||
/// `last_forced_keyframe_at` so a follow-up viewer PLI within the next
|
||||
/// `FORCED_KEYFRAME_MIN_INTERVAL` is dropped.
|
||||
@@ -360,13 +728,15 @@ impl WebRtcInner {
|
||||
self.last_forced_keyframe_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
// 节流版本:仅在距离 `last_forced_keyframe_at` 已过 `FORCED_KEYFRAME_MIN_INTERVAL`
|
||||
//(1 秒)时才 honor,否则记 warn 日志并丢弃。对应 `Event::KeyframeRequest`(PLI/FIR)。
|
||||
/// Throttled keyframe trigger used for viewer-originated PLI/FIR requests.
|
||||
/// Honored only if enough time has elapsed since the last forced keyframe.
|
||||
fn request_keyframe_from_viewer(&mut self) {
|
||||
let now = Instant::now();
|
||||
let should_honor = self
|
||||
.last_forced_keyframe_at
|
||||
.is_none_or(|last| now.duration_since(last) >= FORCED_KEYFRAME_MIN_INTERVAL);
|
||||
.map_or(true, |last| now.duration_since(last) >= FORCED_KEYFRAME_MIN_INTERVAL);
|
||||
if should_honor {
|
||||
self.last_forced_keyframe_at = Some(now);
|
||||
self.need_keyframe = true;
|
||||
@@ -380,11 +750,24 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
|
||||
// Sans-IO 推进主循环(出方向):取出 str0m 待发的 `Output::Transmit` 包写回 UDP,
|
||||
// 处理 `Output::Event`(Connected/Disconnected/MediaAdded/KeyframeRequest/BWE 等)。
|
||||
// 返回 `Ok(true)` 表示 peer 已断开(调用方应 drop `WebRtcInner`)。
|
||||
//
|
||||
// `Output::Timeout` 表示 str0m 需要在未来某时刻被再次唤醒——本实现简单 `break`,
|
||||
// 依赖上层 mio 循环的 1ms tick 重新进入;更高性能的做法是读取 `_t` 安排 timer。
|
||||
fn poll_rtc(&mut self) -> Result<bool> {
|
||||
loop {
|
||||
// `Rtc::poll_output()`:str0m 主推进入口,返回 `Output` 枚举(Transmit/Event/Timeout)
|
||||
// 或 `Err`。Sans-IO 设计:调用方必须循环 poll 直到拿到 `Timeout`(表示 str0m
|
||||
// 当前没活干了,等下一次外部输入)。
|
||||
match self.rtc.poll_output() {
|
||||
// `Output::Transmit`:str0m 想发的网络包(RTP/RTCP/DTLS/STUN)。
|
||||
// 我们写回 UDP socket——这就是 Sans-IO 的"输出"侧。
|
||||
Ok(Output::Transmit(t)) => {
|
||||
tracing::trace!("TX {} bytes -> {}", t.contents.len(), t.destination);
|
||||
// `UdpSocket::send_to` 类比 Go `conn.WriteToUDP(b, addr)`。
|
||||
// `WouldBlock` = 内核发送缓冲满(罕见,因为我们在 new() 里调大了)。
|
||||
if let Err(e) = self.socket.send_to(&t.contents, t.destination) {
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock {
|
||||
tracing::debug!(
|
||||
@@ -396,20 +779,28 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Output::Event`:str0m 内部状态变化通知(ICE 连接、媒体添加、keyframe 请求等)。
|
||||
// `Event` 是 enum,下方 `match &e` 对每种 variant 分发处理。
|
||||
Ok(Output::Event(e)) => {
|
||||
tracing::debug!("RTC event: {e:?}");
|
||||
match &e {
|
||||
// `Event::Connected`:ICE+DTLS 握手完成,可以发 RTP 了。
|
||||
// 立即触发 IDR 请求(让对端解码器拿到关键帧尽快起播)+ 重新扫 codec 参数。
|
||||
Event::Connected => {
|
||||
tracing::info!("WebRTC connected!");
|
||||
self.connected = true;
|
||||
self.set_need_keyframe();
|
||||
self.discover_video_params();
|
||||
}
|
||||
// `Event::IceConnectionStateChange`:ICE 状态变化。
|
||||
// `Disconnected` 视为连接已死,向上层返回 `Ok(true)` 触发 drop。
|
||||
Event::IceConnectionStateChange(IceConnectionState::Disconnected) => {
|
||||
tracing::warn!("WebRTC disconnected");
|
||||
self.connected = false;
|
||||
return Ok(true);
|
||||
}
|
||||
// `Event::MediaAdded`:SDP 协商后有新 m= 行就绪。
|
||||
// 捕获视频 mid(只取第一个 sending direction 的视频流)。
|
||||
Event::MediaAdded(ma) => {
|
||||
tracing::info!("Media added: mid={} kind={:?}", ma.mid, ma.kind);
|
||||
if ma.kind == MediaKind::Video {
|
||||
@@ -422,10 +813,15 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Event::KeyframeRequest`:对端发来 PLI/FIR,请求 IDR。
|
||||
// 转发到节流版本 `request_keyframe_from_viewer`(防止 PLI 风暴)。
|
||||
Event::KeyframeRequest(_) => {
|
||||
tracing::info!("received keyframe request from viewer");
|
||||
self.request_keyframe_from_viewer();
|
||||
}
|
||||
// `Event::EgressBitrateEstimate`:BWE 推断的可用上行带宽。
|
||||
// `BweKind::Twcc`(Transport-CC,新标准)或 `BweKind::Remb`(老标准)。
|
||||
// 提取数值存入 `current_bwe_estimate`,供 `state_portal.rs::select_resolution` 使用。
|
||||
Event::EgressBitrateEstimate(est) => {
|
||||
let bitrate = match est {
|
||||
BweKind::Twcc(b) => *b,
|
||||
@@ -443,6 +839,8 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Output::Timeout`:str0m 内部定时器到期点。本实现忽略 `_t`(即下次唤醒时刻),
|
||||
// 简单 `break`——上层 mio 循环 1ms tick 会很快再次调用 `poll_rtc`。
|
||||
Ok(Output::Timeout(_t)) => break,
|
||||
Err(e) => {
|
||||
tracing::error!("rtc.poll_output error: {e}");
|
||||
@@ -454,15 +852,27 @@ impl WebRtcInner {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Sans-IO 推进主循环(入方向):从 UDP socket 读所有待处理包,封装为
|
||||
// `Input::Receive` 喂给 str0m;最后喂一次 `Input::Timeout(now)` 推动内部时钟。
|
||||
// 类比 Go pion/webrtc:手动调用 `peerConnection.Receive(rtpPacket)` 而不是
|
||||
// 起 goroutine 监听 UDP。
|
||||
fn feed_network(&mut self) -> Result<()> {
|
||||
let mut recv_count = 0u32;
|
||||
loop {
|
||||
// `UdpSocket::recv_from(&mut self.buf)`:类比 Go `conn.ReadFrom(buf)`。
|
||||
// 返回 `(n_bytes_read, source_addr)`。`WouldBlock`/`Interrupted` 是常态,
|
||||
// 前者 break 出循环,后者重试(类比 Go EINTR 处理)。
|
||||
match self.socket.recv_from(&mut self.buf) {
|
||||
Ok((n, source)) => {
|
||||
recv_count += 1;
|
||||
if recv_count <= 5 {
|
||||
tracing::trace!("UDP recv {} bytes from {}", n, source);
|
||||
}
|
||||
// 构造 `Input::Receive`:str0m 的"入包"事件。
|
||||
// `Receive { proto, source, destination, contents }` 完整描述一个网络包:
|
||||
// - `proto: Protocol::Udp`(str0m 也支持 TCP,但 WebRTC 主流用 UDP)
|
||||
// - `source` / `destination`:ICE candidate 端点
|
||||
// - `contents`:`self.buf[..n]` 转 `Box<[u8]>`(`.try_into()` 因为 slice→Box 长度可能变化)
|
||||
let input = Input::Receive(
|
||||
Instant::now(),
|
||||
Receive {
|
||||
@@ -474,6 +884,8 @@ impl WebRtcInner {
|
||||
.map_err(|e| anyhow::anyhow!("receive contents: {e}"))?,
|
||||
},
|
||||
);
|
||||
// `Rtc::handle_input(input)`:把入包喂给 str0m 解析(ICE/DTLS/SRTP/RTP/RTCP)。
|
||||
// 这是 Sans-IO 的"输入"侧——str0m 不主动读 socket,全靠调用方喂。
|
||||
self.rtc.handle_input(input).map_err(|e| {
|
||||
anyhow::anyhow!("handle_input({n} bytes from {source}): {e}")
|
||||
})?;
|
||||
@@ -484,6 +896,8 @@ impl WebRtcInner {
|
||||
}
|
||||
}
|
||||
|
||||
// 喂一次 `Input::Timeout(now)`:让 str0m 推进内部定时器(重传、keepalive、BWE 周期等)。
|
||||
// 即使没有任何入包,也必须定期调用,否则 str0m 内部超时不会触发。
|
||||
self.rtc
|
||||
.handle_input(Input::Timeout(Instant::now()))
|
||||
.map_err(|e| anyhow::anyhow!("handle timeout: {e}"))?;
|
||||
@@ -491,6 +905,17 @@ impl WebRtcInner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 把一帧 H.264 NALU(annex-B 格式,含 0x000001 起始码)写入 str0m,转 RTP 发出。
|
||||
//
|
||||
// 5 步:
|
||||
// 1. 检查 `connected`、`video_mid`、`video_pt`,未就绪则 `Ok(false)` 静默丢帧
|
||||
// 2. 若 `need_keyframe`,校验此帧必须是 IDR(NAL type=5),否则丢帧等下一帧
|
||||
// 3. PTS 90kHz 时钟 → RTP 时间戳(直接复用,因编码器 time_base = 1/90000)
|
||||
// 4. `Rtc::writer(mid).write(pt, now, rtp_time, data)`:str0m 内部分包(>MTU 切片)
|
||||
// 并加密 SRTP,产生 `Output::Transmit` 包
|
||||
// 5. 立即 `poll_rtc()` 把 Transmit 包写回 UDP(同步发出,避免延迟)
|
||||
//
|
||||
// 返回 `Ok(true)` = peer 断开,调用方应 drop 本 `WebRtcInner`。
|
||||
fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<bool> {
|
||||
if !self.connected {
|
||||
return Ok(false);
|
||||
@@ -526,10 +951,16 @@ impl WebRtcInner {
|
||||
self.need_keyframe = false;
|
||||
}
|
||||
|
||||
// PTS 90kHz → RTP 时间戳。`rtp_timestamp_from_pts_ticks` 把 i64 clamp 到 u64
|
||||
//(见该函数文档)。`Frequency::NINETY_KHZ` 是视频 RTP 的标准时钟频率。
|
||||
let rtp_timestamp = rtp_timestamp_from_pts_ticks(pts_ticks);
|
||||
self.rtp_clock = rtp_timestamp as u32;
|
||||
// `MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ)`:构造 str0m 媒体时间戳,
|
||||
// 用于 RTP 头部 + jitter buffer 同步。
|
||||
let rtp_time = MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ);
|
||||
|
||||
// `Rtc::writer(mid)`:取得 mid 对应的媒体写入器(之前在 `discover_video_params` 用过)。
|
||||
// None 表示 mid 还没就绪(罕见,已在前面的 video_mid 检查里处理)。
|
||||
let writer = match self.rtc.writer(mid) {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
@@ -544,6 +975,9 @@ impl WebRtcInner {
|
||||
pt,
|
||||
self.rtp_clock
|
||||
);
|
||||
// `writer.write(pt, Instant::now(), rtp_time, data)`:媒体写入入口。
|
||||
// str0m 内部完成 (a) H.264 RTP 分包(FU-A for >MTU),(b) SRTP 加密,
|
||||
// (c) 产生 `Output::Transmit` 包供 `poll_rtc` 取出。
|
||||
writer
|
||||
.write(pt, Instant::now(), rtp_time, data)
|
||||
.map_err(|e| anyhow::anyhow!("writer.write: {e}"))?;
|
||||
@@ -553,11 +987,15 @@ impl WebRtcInner {
|
||||
Ok(should_destroy)
|
||||
}
|
||||
|
||||
// 简单 getter,对应 `Event::Connected` / `Event::IceConnectionStateChange(Disconnected)`。
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
}
|
||||
|
||||
// PTS→RTP 时间戳换算:编码器侧 time_base 已是 1/90000(与 RTP 视频时钟一致),
|
||||
// 因此 1:1 直接复用,无需 fps-based 换算(旧版本曾用 `90000 / fps` 误导致时间戳错乱)。
|
||||
// 返回 `u64` 喂 `MediaTime::new` 避免 u32 在 13.25 小时后过早回绕;str0m 内部处理 RTP u32 回绕。
|
||||
/// Convert PTS in 90kHz media-clock ticks to RTP MediaTime ticks (u64).
|
||||
///
|
||||
/// With WebRTC encoder time_base = 1/90000, pts_ticks ARE RTP timestamps.
|
||||
@@ -578,6 +1016,9 @@ fn extract_body(req: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
// 探测本机 LAN IP(用于 ICE host candidate)。Go 等价:`net.Dial("udp", "1.1.1.1:80")`
|
||||
// 后读 `LocalAddr()`——`connect` 不会发包,只设置路由表,从而选出默认网关对应的网卡 IP。
|
||||
// `127.x` / `0.0.0.0` 视为无 LAN IP,由调用方 fallback 到 127.0.0.1(loopback 调试用)。
|
||||
fn local_ip() -> Option<String> {
|
||||
std::net::UdpSocket::bind("0.0.0.0:0").ok().and_then(|s| {
|
||||
s.connect("1.1.1.1:80").ok()?;
|
||||
@@ -591,6 +1032,12 @@ fn local_ip() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
// 检测 H.264 NALU 流中是否含 IDR slice(NAL type=5)。两种起始码:
|
||||
// - 4 字节 `00 00 00 01`(AVCC boundary,主流)
|
||||
// - 3 字节 `00 00 01`( Annex-B inline,少见)
|
||||
// NAL header 低 5 位 = type;5 = IDR slice。SPS=7、PPS=8、SEI=6 等不算 IDR。
|
||||
//
|
||||
// 用于 `need_keyframe` 时丢非 IDR 帧——Go 等价:`bytes.Index(data, []byte{0,0,0,1})` 循环。
|
||||
fn is_idr_nalu(data: &[u8]) -> bool {
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
pub(super) const HTML_PAGE: &str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>wl-webrtc P0</title>
|
||||
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
video{max-width:90vw;max-height:80vh;border:1px solid #333}
|
||||
#status{margin:12px;font-size:14px;color:#aaa}
|
||||
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
|
||||
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="status">Connecting...</div>
|
||||
<video id="video" autoplay playsinline muted></video>
|
||||
<pre id="debug"></pre>
|
||||
<div id="stats-panel"></div>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const video = document.getElementById('video');
|
||||
const debug = document.getElementById('debug');
|
||||
let pc = null;
|
||||
|
||||
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
|
||||
|
||||
function preferH264(sdp) {
|
||||
const lines = sdp.split('\r\n');
|
||||
const h264Pts = lines
|
||||
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
|
||||
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
|
||||
.filter(Boolean);
|
||||
if (h264Pts.length === 0) return sdp;
|
||||
return lines.map(line => {
|
||||
if (!line.startsWith('m=video ')) return line;
|
||||
const parts = line.split(' ');
|
||||
const header = parts.slice(0, 3);
|
||||
const pts = parts.slice(3);
|
||||
const preferred = h264Pts.filter(pt => pts.includes(pt));
|
||||
const rest = pts.filter(pt => !preferred.includes(pt));
|
||||
return [...header, ...preferred, ...rest].join(' ');
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
function installStatsLogger(peer) {
|
||||
const panel = document.getElementById('stats-panel');
|
||||
let prev = null;
|
||||
const intervalSecs = 1;
|
||||
|
||||
setInterval(() => {
|
||||
if (peer !== pc) return;
|
||||
peer.getStats().then(stats => {
|
||||
let rtp = null, rtt = null, codecStr = '';
|
||||
let freezeCount = null, totalFreezesDuration = null;
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
|
||||
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
|
||||
codecStr = report.mimeType + ' ' + (report.payloadType || '');
|
||||
// candidate-pair: feature-detect 'selected' property
|
||||
if (report.type === 'candidate-pair') {
|
||||
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
|
||||
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Freeze stats (feature-detect)
|
||||
if (rtp && typeof rtp.freezeCount !== 'undefined') {
|
||||
freezeCount = rtp.freezeCount;
|
||||
totalFreezesDuration = rtp.totalFreezesDuration;
|
||||
}
|
||||
|
||||
if (!rtp) return;
|
||||
|
||||
const cur = {
|
||||
framesDecoded: rtp.framesDecoded || 0,
|
||||
framesDropped: rtp.framesDropped || 0,
|
||||
framesPerSecond: rtp.framesPerSecond || 0,
|
||||
packetsLost: rtp.packetsLost || 0,
|
||||
jitter: rtp.jitter || 0,
|
||||
bytesReceived: rtp.bytesReceived || 0,
|
||||
totalDecodeTime: rtp.totalDecodeTime || 0,
|
||||
jitterBufferDelay: rtp.jitterBufferDelay || 0,
|
||||
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
|
||||
freezeCount: freezeCount,
|
||||
totalFreezesDuration: totalFreezesDuration,
|
||||
rtt: rtt,
|
||||
};
|
||||
|
||||
// Raw log to debug element (backward compat)
|
||||
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
|
||||
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
|
||||
(codecStr ? ' codec=' + codecStr : ''));
|
||||
|
||||
if (!prev) { prev = cur; return; }
|
||||
|
||||
// Compute deltas
|
||||
const dFrames = cur.framesDecoded - prev.framesDecoded;
|
||||
const dDropped = cur.framesDropped - prev.framesDropped;
|
||||
const dLost = cur.packetsLost - prev.packetsLost;
|
||||
const dBytes = cur.bytesReceived - prev.bytesReceived;
|
||||
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
|
||||
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
|
||||
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
|
||||
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
|
||||
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
|
||||
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
|
||||
const jitterMs = (cur.jitter * 1000).toFixed(1);
|
||||
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
|
||||
|
||||
let line = 'FPS:' + cur.framesPerSecond +
|
||||
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
|
||||
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
|
||||
' Lost:' + dLost +
|
||||
' Jitter:' + jitterMs + 'ms' +
|
||||
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
|
||||
' Decode:' + decodeMs + 'ms' +
|
||||
' JBuf:' + jitterBufMs + 'ms';
|
||||
|
||||
if (freezeCount !== null) {
|
||||
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
|
||||
if (cur.freezeCount > 0 || dFreeze > 0)
|
||||
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
|
||||
}
|
||||
|
||||
line += ' ' + kbps + 'kbps';
|
||||
|
||||
panel.textContent = line;
|
||||
prev = cur;
|
||||
}).catch(() => {});
|
||||
}, intervalSecs * 1000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (pc) pc.close();
|
||||
pc = new RTCPeerConnection();
|
||||
const peer = pc;
|
||||
|
||||
peer.ontrack = e => {
|
||||
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
|
||||
video.srcObject = e.streams[0];
|
||||
status.textContent = 'Track received';
|
||||
};
|
||||
peer.oniceconnectionstatechange = () => {
|
||||
log('ICE: ' + peer.iceConnectionState);
|
||||
status.textContent = 'ICE: ' + peer.iceConnectionState;
|
||||
};
|
||||
|
||||
peer.addTransceiver('video', { direction: 'recvonly' });
|
||||
installStatsLogger(peer);
|
||||
|
||||
peer.createOffer().then(offer => {
|
||||
offer.sdp = preferH264(offer.sdp);
|
||||
return peer.setLocalDescription(offer);
|
||||
})
|
||||
.then(() => new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') resolve();
|
||||
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
|
||||
}))
|
||||
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
|
||||
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
|
||||
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
|
||||
.then(() => log('SDP answer set'))
|
||||
.catch(e => {
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
log('ERROR: ' + e.message + ' — retrying in 2s...');
|
||||
console.error(e);
|
||||
setTimeout(connect, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body></html>"#;
|
||||
@@ -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