Compare commits

..
Author SHA1 Message Date
dailz 17f5e235a9 ci(gitea): broaden libclang find pattern to match versioned sonames
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
CI / Build + Clippy + Test (push) Failing after 4m18s
Second LIBCLANG_PATH failure mode: my 'libclang.so.*' with -type f
pattern returned nothing because Debian Bookworm's runtime library is
'libclang-14.so.1' (versioned, with no plain libclang.so.* symlink),
and the .so symlink is itself a symlink not a regular file (-type f
excludes it).

bindgen accepts any of: libclang.so, libclang-*.so, libclang.so.*,
libclang-*.so.* — so the broader 'libclang*.so*' (no -type filter)
catches every variant. Also broadened search root from /usr/lib to
/usr to cover both /usr/lib/x86_64-linux-gnu/ (runtime lib) and
/usr/lib/llvm-*/lib/ (dev symlink).

Log line now includes the actual matched path so future debugging
is one glance.
2026-06-28 17:20:02 +08:00
dailz 4e65f4175b ci(gitea): dynamically resolve LIBCLANG_PATH for act_runner container
CI / Build + Clippy + Test (push) Failing after 20m22s
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
First real CI run on the Gitea Actions runner hit the predicted
LIBCLANG_PATH issue but for an unexpected reason: workflow-level
'env:' does not reliably propagate into act_runner's Docker executor
(bindgen received empty LIBCLANG_PATH despite /usr/lib/llvm-*/lib
being correct on the runner).

Two changes:

  1. Drop the hardcoded workflow-level 'env: LIBCLANG_PATH' and add a
     dedicated 'Resolve LIBCLANG_PATH' step that does:
         find /usr/lib -name 'libclang.so.*' | head -1 | xargs dirname
     and writes the result to $GITHUB_ENV. Dynamic discovery also
     future-proofs against Debian/Ubuntu version drift (llvm-14 today,
     llvm-18 tomorrow). The $GITHUB_ENV mechanism is reliably visible
     across step boundaries inside the act_runner Docker container,
     whereas workflow-level env: is not.

  2. apt install: drop '--no-install-recommends' on libclang-dev
     (Debian Bookworm's metapackage uses Recommends to pull in
     versioned toolchain bits bindgen needs). Also install 'clang'
     (not just llvm-14) to get version-agnostic libclang shared lib.

Comments inline in ci.yml document both decisions to prevent future
regression.

The 'Resolve LIBCLANG_PATH' step fails fast with a clear message if
libclang isn't installed, instead of letting the clippy/build steps
fail cryptically later.
2026-06-28 16:58:04 +08:00
dailz c772e4eb0b chore: bump MSRV to 1.87 to match actual API usage
CI / Build + Clippy + Test (push) Failing after 1h10m8s
CI / Security audit (RUSTSEC) (push) Failing after 1m31s
Oracle P2 follow-up. The clippy --fix autofixes earlier in this branch
silently introduced dependencies on APIs newer than the README's
1.70+ claim:
  - u32::is_multiple_of  (stable 1.87)
  - Option::is_none_or   (stable 1.82)

clippy::incompatible_msrv flagged the mismatch once rust-version was
pinned. Bumping the floor to 1.87 is the honest fix — the codebase
genuinely depends on 1.87 features now, and 1.87 has been stable
long enough (current stable is 1.96) that desktop CLI users on stable
Rust already have it.

  - Cargo.toml: rust-version '1.70' -> '1.87'. Comment lists the specific
    APIs that drove the bump and notes that further bumps need to be
    validated against clippy::incompatible_msrv.
  - README.md: Prerequisites line updated to 1.87+ with a brief why.
  - src/state_portal.rs: added the AsRawFd rustc-quirk comment that was
    already in avhw.rs (rustc emits a false 'unused_imports' warning;
    removing it produces E0599). Same known quirk, same documentation
    pattern.
  - src/transform.rs: fixed empty_line_after_doc_comments warning by
    converting the leading // doc-style comment to a //! module-level
    doc comment (which is what it should have been when I rewrote the
    file in commit 145b5d3).

All 79 unit tests + 3 integration tests pass. clippy: 0 errors,
0 incompatible_msrv warnings, 0 empty_line_after_doc_comments warnings.
Remaining warnings are: 1 AsRawFd rustc false-positive (documented),
5 unnecessary_cast FFI false-positives (rustc quirk on pointer casts),
and 8 dead-code items that need product decisions.
2026-06-28 14:39:00 +08:00
dailz 86a8b61b07 refactor: clear too_many_arguments and large_enum_variant warnings
Oracle P2 batch 2 (refactor items). Drops both remaining design-shape
clippy warnings to zero without behavior change.

  - avhw.rs build_filter_graph: drop unused _enc_width/_enc_height params
    (Oracle caught them during P2 review — passed by EncState::new but
    never read inside the function; the filter graph uses width/height
    only). Signature: 8 args -> 6 args (under clippy's 7 threshold).

  - state.rs InFlightSurface::CopyQueued: Box the drm_map field.
    AVDRMFrameDescriptor is ~592 bytes (4 objects + 4 layers); the enum
    size was being dominated by this variant, ballooning every
    InFlightSurface value to 592 bytes even for the None/AllocQueued
    variants. Box<AVDRMFrameDescriptor> shrinks the enum to ~32 bytes
    regardless of variant. The drm_map field is currently destructured
    under _drm_map (unused), so the boxing has no consumer-side impact.

  - state_portal.rs webrtc_thread_loop: 10 args -> 4 args via two new
    structs:
      * WebRtcThreadConfig { fps, enc_width, enc_height, max_bitrate }
        — immutable for the thread's lifetime; a tier change spawns a
        new thread rather than mutating.
      * WebRtcThreadChannels { webrtc_rx, sent_gap_tx, bitrate_tx,
        resolution_tx } — channel endpoints owned exclusively by the
        sender thread after spawn.
    wrtc (WebRtcState) and paused (Arc<AtomicBool>) stay as separate
    args because they have different ownership semantics (moved-in
    state vs shared atomic). Documented as doc comments on the new
    types so the next reader understands the bundle rationale.

All 79 unit tests + 3 integration tests pass. clippy: 0 errors.
Per-file warning counts: state_portal.rs down from 3 to 0; state.rs
down from 8 to 4 (remaining are unrelated dead-code on OutputInfo /
starting_timestamp).
2026-06-28 14:35:35 +08:00
dailz ed39d3d873 ci: add build/test/clippy gate + cargo audit; pin rust-version
Oracle P2 plan step 1+2+missed-fields. Locks in the audit cleanup so
future PRs can't regress the 0-errors / deny-unsafe / 79-tests baseline.

  - .github/workflows/ci.yml: two jobs on ubuntu-latest (Linux only —
    project is Wayland/VAAPI-specific, no macOS/Windows story).
      * build-test: installs ffmpeg + libavcodec/libavformat/libavutil/
        libswscale/libva dev + libwayland + libdrm + libpipewire-0.3-dev
        + libclang-dev/llvm-14 (LIBCLANG_PATH pinned); caches cargo +
        target; runs clippy -> build --release -> test --release.
        Release build before tests is mandatory because
        tests/integration_test.rs shells out to target/release/wl-webrtc.
      * audit: installs cargo-audit and runs 'cargo audit --deny warnings'
        as a separate job so a RUSTSEC advisory fails the build
        independently of compile state.
    No -D warnings on clippy yet — undocumented_unsafe_blocks is already
    deny via Cargo.toml; remaining warnings are advisory and can be
    tightened later.

  - Cargo.toml: pin rust-version = '1.70' to match README's claim.
    Without this, cargo builds silently on older toolchains and surfaces
    errors as cryptic parse failures instead of a clean version-mismatch
    message. Oracle flagged this as a missing field during P2 review.

License field intentionally omitted — repo has no LICENSE file and no
publication plan yet. Add when publication becomes a goal.

Verified locally: YAML parses, cargo build --release Finished in 9.82s,
cargo test --release 79 passed, cargo clippy 0 errors.
2026-06-28 14:31:42 +08:00
dailz a6560cff6c feat(stats): wire real scale/transfer/encode timing from EncState
Oracle step 4 (option A) — give the scale_*, transfer_*, encode_* stats
fields real producers instead of misleading zeros. The fields existed in
FrameTimings and PipelineStats already; producers just weren't passing
non-zero values.

  - avhw.rs: new EncodeStages { scale_us, transfer_us, encode_us } struct.
    EncState::encode_frame (HW VAAPI path) now times the filter graph
    separately from avcodec_send_frame, returning EncodeStages. transfer_us
    is honestly 0 because the HW path never reads back to CPU.
    SwEncState::encode_frame (SW fallback path) returns EncodeStages too;
    there import_and_scale bundles GPU scale + GPU→CPU readback into one
    call, so scale_us includes transfer for SW. Documented inline.

  - state.rs: StreamingEncoder::encode_frame return type bumps from
    Result<()> to Result<EncodeStages>; wlr-screencopy path now feeds
    real per-stage timings into FrameTimings instead of just total_us.

  - state_portal.rs: HW portal path (enc.encode_frame) now extracts
    stages.scale_us / stages.transfer_us / stages.encode_us into
    FrameTimings. Removed the now-unused t_encode_start binding.

Deferred (documented):
  - state_portal.rs SW portal path (line 525) calls import_and_scale +
    enc_thread separately and bypasses SwEncState::encode_frame. To wire
    scale/transfer timing there too, either route through SwEncState or
    thread timing out of import_and_scale. Out of scope for this commit.
  - SW path lumps transfer into scale_us. Splitting requires extending
    import_and_scale's return type — left as a follow-up if operational
    need arises (current default is HW VAAPI).

Oracle audit 2026-06-28 step 4 (option A: integrate, not delete).

All 79 unit tests + 3 integration tests pass. clippy: 0 errors.
2026-06-28 14:22:45 +08:00
dailz 2ac37a1dd1 fix(stats): wire PipeWire drops, expand Display, purge dead residue
Oracle-driven P1 fix plan. Resolves the StatsSnapshot 'computed but never
consumed' debt that was silently zeroing two real diagnostic fields and
leaving a dozen more unreported.

Bug fix (Oracle step 2):
  - state_portal.rs: set_pipewire_dropped(0, 0) and set_queue_depths(0, 0)
    were hardcoded, silently discarding real PipeWire diagnostics. Now wires
    to self.cap.dropped_count() (with pw_dropped_prev delta tracking) and
    self.cap.capture_queue_depth(). The encoded side stays 0 because the
    encoder thread exposes no queue-depth API.

Display expansion (Oracle step 1):
  - stats.rs: StatsSnapshot::Display now reports 12 previously-silent fields
    paired with their existing p95/max counterparts — capture/encoded/sent
    frame counts, elapsed_secs, *_avg_ms gap timing, frame_age_avg_ms,
    per-stage import/sws/encode/total avg_ms, output_frame_bytes_p95. Each
    line of the format string maps to one operational question (cadence,
    drops, queue pressure, latency, bandwidth); layout note added.

Dead residue purge (Oracle steps 5 + 6):
  - stats.rs: removed record_over_budget method + over_budget_count field
    (no caller; total_p95_ms answers the useful question without an
    arbitrary budget threshold).
  - state.rs: removed InFlightSurface::Allocd variant (never constructed)
    and CaptureSource::alloc_frame trait method (prototype leftover; the
    sole impl in cap_wlr_screencopy.rs returned None unconditionally).
  - cap_wlr_screencopy.rs: removed the alloc_frame stub; updated the
    unit-type Frame doc to reference the asynchronicity rationale without
    the deleted method.
  - cap_portal.rs: removed redundant 'let dropped = dropped;' shadowing
    flagged by clippy::redundant_locals (line 849).

Deferred (Oracle step 4 — needs product decision):
  - scale_avg/scale_p95/transfer_avg/transfer_p95/send_wait_p95 fields
    still appear in Display but producers in the live encode path don't
    record them, so they often show misleading zeros. Either add real
    EncState timing for scale/transfer stages, or remove the fields from
    Display until then.

All 79 unit tests + 3 integration tests still pass. clippy: 0 errors.
Warning count: multiple_fields_never_read on StatsSnapshot,
method_never_used on record_over_budget/dropped_count/capture_queue_depth/
alloc_frame, variant_never_constructed on Allocd, redundant_locals on
dropped — all gone.
2026-06-28 14:15:55 +08:00
dailz 145b5d3e7e chore: design cleanup, dead-code purge, README/doc refresh
Audit-driven follow-up after the SAFETY-debt commit (Oracle steps 6-7).
End state: cargo clippy --release --all-targets still 0 errors; private_interfaces
and type_complexity warnings cleared.

Design cleanups (Oracle step 6):
  - cap_portal.rs: introduce PortalFormatInfo struct to replace the
    Rc<Cell<Option<(u32,u32,u32,u64)>>> cross-callback hand-off. Self-
    documenting struct fields replace positional tuple access at the
    format-change and process callbacks.
  - avhw.rs: import_dma_buf_to_vaapi signature collapses from 8 args
    (fd/width/height/drm_format/modifier/stride/offset) to
    (*mut AVBufferRef, &PwDmaBufFrame). Callers in avhw.rs,
    state_portal.rs, and vaapi_import_bench.rs now pass the frame by
    reference instead of unpacking 7 fields just to repack them. Drops
    the unused width parameter and the too_many_arguments(8/7) warning.
  - state.rs: visibility hygiene. EncConstructionStage and WlrHeadInfo
    downgrade pub -> pub(crate); State.stage field downgrades to
    pub(crate). These are internal state-machine types not exposed
    across the crate boundary; making them pub(crate) clears all
    private_interfaces warnings without leaking more types.

Dead-code purge (Oracle step 7):
  - transform.rs: remove unused Rect struct, transform_basis,
    screen_to_frame, fit_inside_bounds helpers and their 18 dedicated
    tests. Transform enum and transpose_if_transform_transposed remain
    (both are actively used by state.rs and avhw.rs). File shrinks
    from 409 -> 109 lines.

Repository housekeeping (Oracle step 7):
  - .gitignore: add review.json (stray review-tool output that
    regenerates per run).
  - README.md: refresh CLI table to match src/args.rs (now lists
    --backend, --no-persist, --port-as-WebRTC-signaling, --max-bitrate,
    --stats). Add capture-backend explainer + 4 new usage examples.
    Note in README points readers at src/args.rs as the authoritative
    source. Remove stale 'WebTransport, unused in MVP' description.

avhw.rs: AsRawFd import annotated with a rustc-quirk explanation — the
import triggers a false 'unused_imports' warning but E0599 if removed.
Left as-is with explanatory comment rather than chasing the lint.

All 79 remaining unit tests + 3 integration tests still pass. Cargo
build --release clean.
2026-06-28 13:58:52 +08:00
dailz 30f8fe51f2 chore: clear clippy errors, document all unsafe blocks, deny new SAFETY debt
Audit-driven cleanup pass. End state:
  - cargo clippy --release --all-targets: 0 errors (was 4)
  - undocumented_unsafe_blocks warnings: 0 (was 67)
  - Cargo.toml: undocumented_unsafe_blocks escalated warn -> deny

Clippy correctness errors fixed:
  - src/bin/{sw_encode_bench,vaapi_import_bench}.rs: receive_first_frame
    rewritten per Oracle plan with total 10s deadline + 200ms wait slice +
    while-let drain of all control events. The previous loop body always
    exited on first iteration (never_loop); the new version actually retries
    and matches production's repeated-poll semantics in state_portal.rs.
  - src/avhw.rs: hash_sampled_y_plane tests now use a row_range(row, stride,
    width) helper instead of inline stride * N. Preserves the row-index
    intent across all sibling tests without tripping erasing_op (row==0) or
    identity_op (row==1).

Machine-applicable clippy autofixes applied via 'cargo clippy --fix':
  - unnecessary_cast, manual_is_multiple_of, needless_borrows_for_generic_args
  - manual_abs_diff, derivable_impls, new_without_default
  - unnecessary_map_or, unneeded_struct_pattern, redundant_locals

webrtc_gop_formula test rewritten to wrap the (fps * 2).max(20) formula in
a runtime lambda. The previous clippy --fix pass had constant-folded the
5fps case into assert_eq!(20, 20), silently stripping the floor-case
coverage. The lambda blocks the fold while keeping the formula exercisable.

67 SAFETY comments added across 7 files (cap_portal.rs 26, sw_encode_bench
21, state_portal.rs 7, vaapi_import_bench.rs 6, avhw.rs 5, state.rs 1,
main.rs 1). Two sites carry load-bearing invariant documentation:
  - cap_portal.rs:806 process callback documents the PipeWire raw_buf
    ownership contract across all 10 exit paths (audited: every path
    correctly requeues; fd ownership via dup() is independent and also
    exactly-once closed).
  - avhw.rs:341 unsafe impl Send for EncState documents the single-thread
    exclusivity assumption referenced by AGENTS.md.

All 97 unit tests + 3 integration tests still pass; cargo build --release
finishes clean. Lint escalation to deny freezes the SAFETY baseline: any
future patch adding an unsafe block without a // SAFETY: comment will fail
clippy at compile time.
2026-06-28 13:44:27 +08:00
23 changed files with 687 additions and 3669 deletions
+113
View File
@@ -0,0 +1,113 @@
# 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
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain (stable)
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install system dependencies
run: |
sudo apt-get 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 install -y \
ffmpeg \
libavcodec-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
# bindgen needs libclang on the LD path. The actual install location
# varies by Debian/Ubuntu version (llvm-14, llvm-15, ...), so we
# discover it dynamically instead of hardcoding /usr/lib/llvm-14/lib.
# Writing to $GITHUB_ENV propagates the value to subsequent steps
# inside the act_runner Docker container; the workflow-level `env:`
# block is not reliably visible there.
#
# Find pattern note: Debian Bookworm installs the runtime library as
# `libclang-14.so.1` (versioned, no plain libclang.so.* symlink),
# so a strict 'libclang.so.*' pattern returns nothing. The broader
# 'libclang*.so*' matches every bindgen-compatible filename variant:
# libclang.so, libclang-14.so, libclang.so.1, libclang-14.so.1.
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: Cache cargo registry + build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- 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
# Keep separate from build-test so a vulnerability advisory fails the
# check independently of compile state.
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain (stable)
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit dependencies
run: cargo audit --deny warnings
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
.playwright-mcp/
wl-webrtc.log
webrtc-p0-success.png
# Stray review-tool output (regenerated per review run)
review.json
+7 -1
View File
@@ -2,6 +2,12 @@
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]
@@ -33,4 +39,4 @@ dirs = "6"
tempfile = "3.27.0"
[lints.clippy]
undocumented_unsafe_blocks = "warn"
undocumented_unsafe_blocks = "deny"
+30 -3
View File
@@ -4,7 +4,7 @@ Wayland screen capture and encoding tool.
## Prerequisites
- **Rust toolchain** (1.70+): `rustup default stable`
- **Rust toolchain** (1.87+; MSRV pinned to match `u32::is_multiple_of` / `Option::is_none_or` usage): `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,19 +38,46 @@ 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` | (required) | Output file path (e.g., output.mp4) |
| `-o`, `--output` | (optional) | Output file path (e.g. output.mp4). Optional when using `--port` for WebRTC mode. |
| `--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 |
| `--port` | 0 | WebTransport server port (unused in MVP) |
| `--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).
-5
View File
@@ -1,6 +1 @@
//! Cargo build script(编译前钩子)。
//!
//! 当前为空:本项目直接复用 `wayland-client`、`pipewire`、`ffmpeg-sys` 等现成 crate
//! 不需要在编译前跑 wayland-scanner 或 bindgen 生成代码。类比 Go 无 `//go:generate`。
// Cargo 编译前不需要生成任何代码(无 wayland-scanner / bindgen),因此 build script 留空。
fn main() {}
-47
View File
@@ -1,43 +1,11 @@
//! 列出当前 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 要求即便不处理事件也必须
// 实现 Dispatchtrait 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,
@@ -49,25 +17,10 @@ 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` traitname 是 u32、interface 是 String、version 是 u32。
println!("{}: {} v{}", g.name, g.interface, g.version);
}
}
-45
View File
@@ -1,41 +1,11 @@
//! XDG Portal 权限冒烟测试示例。
//!
//! 本示例演示完整的 Portal ScreenCast 授权流程,分四步:
//! 创建 Screencast proxy,再创建 session,然后选择源(显示器/窗口),
//! 最后 start() 触发系统授权对话框(用户点击"共享"后返回流信息)。
//!
//! 运行:`cargo run --example test_portal`(参见 AGENTS.md "Useful manual commands")。
//!
//! Rust 异步模型(与 Go 对比):
//! - Go 用 goroutine + channelasync 函数本身**惰性**,需 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");
@@ -43,14 +13,11 @@ 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");
@@ -63,15 +30,7 @@ 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,
@@ -91,8 +50,6 @@ 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");
@@ -103,8 +60,6 @@ fn main() {
return;
}
};
// Portal D-Bus 响应是双层结构:外层是 Request::responseOk/Err),
// 内层才是 ScreenCast 流信息(streams() 返回 PipeWire 节点 + dmabuf 信息列表)
match response.response() {
Ok(r) => eprintln!(" Got {} stream(s)", r.streams().len()),
Err(e) => eprintln!(" Response error: {e}"),
-47
View File
@@ -1,75 +1,35 @@
//! 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
@@ -77,35 +37,28 @@ 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,
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
}
+122 -605
View File
File diff suppressed because it is too large Load Diff
-151
View File
@@ -1,45 +1,3 @@
//! # Wayland 截屏后端自动检测(`src/backend_detect.rs`
//!
//! 本文件负责检测当前 Wayland 桌面支持哪种屏幕捕获后端,由 [`detect_backend`]
//! 返回 [`CaptureBackend::WlrScreencopy`]wlroots 合成器:Sway/Hyprland 等,
//! 通过 `zwlr_screencopy_manager_v1` 协议直接交付 dmabuf,性能最好)或
//! [`CaptureBackend::PortalPipeWire`]XDG Portal + PipeWireKDE/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;
@@ -68,15 +26,8 @@ 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,
@@ -96,10 +47,6 @@ 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}. \
@@ -109,54 +56,20 @@ 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)
@@ -165,29 +78,17 @@ 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"))
@@ -210,10 +111,6 @@ 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(
@@ -240,26 +137,10 @@ fn check_portal_available() -> bool {
}
// 通过 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()
@@ -290,14 +171,7 @@ 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");
@@ -308,10 +182,7 @@ 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);
}
};
@@ -322,18 +193,11 @@ 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");
@@ -353,18 +217,11 @@ 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()),
@@ -385,17 +242,11 @@ 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);
}
@@ -414,8 +265,6 @@ 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'"),
+73 -164
View File
@@ -1,40 +1,9 @@
//! 软件编码流水线性能基准(独立二进制 `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;
@@ -42,24 +11,15 @@ 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",
@@ -79,9 +39,6 @@ struct BenchArgs {
enc_height: u32,
}
// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec
// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats
// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。
#[derive(Default)]
struct FrameStats {
mmap_us: Vec<u64>,
@@ -91,12 +48,7 @@ struct FrameStats {
mmap_failures: u32,
}
// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的
// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯,
// 也可以写成自由函数 `fn avg_ms(...)`。
impl FrameStats {
// 把 Vec<u64> 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec
// 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -105,34 +57,37 @@ impl FrameStats {
}
}
// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始
// `AVPixelFormat`i32 别名)。`Into::into` 在此处零成本——编译期已知映射。
fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
Into::<ffi::AVPixelFormat>::into(p)
}
// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。
// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比:
// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }`
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
// 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 {
// `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
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}"),
}
}
// `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
{
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) => {
bail!("Timeout waiting for first frame (10s)");
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
bail!("PipeWire frame channel disconnected");
}
@@ -140,13 +95,7 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
}
}
// 程序入口。流程四阶段:[1/4] 申请 Portal 授权并连接 PipeWire[2/4] 等首帧
// 拿到 DMA-BUF 元数据(宽高/stride/fd);[3/4] 试 mmap 一帧验证 CPU 可读;
// [4/4] 配置 libx264 编码器 + FFmpeg 输出格式上下文,进入主采集编码循环并打印统计。
// `anyhow::Result<()>` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回
// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。
fn main() -> Result<()> {
// clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。
let bench_args = BenchArgs::parse();
println!("=== Software Encode Benchmark ===");
@@ -158,14 +107,11 @@ fn main() -> Result<()> {
);
println!();
// 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。
ff::init()?;
println!("[1/4] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了
// 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
@@ -183,15 +129,12 @@ fn main() -> Result<()> {
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;
@@ -209,9 +152,9 @@ fn main() -> Result<()> {
println!("[3/4] Testing mmap on DMA-BUF...");
let mmap_size = (src_stride as usize) * (src_height as usize);
// unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以
// 必须 unsafemmap 接受 void* 返回 raw 指针,编译器无法验证其有效性;
// 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。
// 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(),
@@ -223,8 +166,6 @@ fn main() -> Result<()> {
)
};
// `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读
// DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
bail!(
@@ -245,8 +186,8 @@ fn main() -> Result<()> {
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
mmap_size as f64 / 1024.0 / 1024.0
);
// unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer
// 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。
// 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);
}
@@ -254,15 +195,10 @@ fn main() -> Result<()> {
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
println!("[4/4] Setting up libx264 encoder...");
// 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。
// CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。
let output_path = Path::new(&bench_args.output);
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// Try libx264 first (best quality/speed), fall back to openh264
// 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。
// Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的
// 多次 if err != nil 但不嵌套。
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
@@ -270,13 +206,11 @@ fn main() -> Result<()> {
})?;
println!("[4/4] Using encoder: {}\n", codec.name());
// 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::YUV420P);
@@ -286,9 +220,9 @@ fn main() -> Result<()> {
let codec_name = codec.name();
if codec_name == "libx264" {
// unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。
// FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存,
// 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。
// 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();
@@ -303,11 +237,9 @@ fn main() -> Result<()> {
let mut enc_video = opened.0;
// Create output format context via FFI
// FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// unsafe #4`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回
// 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证
// 调用方传入了正确的容器格式猜测。
// 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,
@@ -320,31 +252,30 @@ fn main() -> Result<()> {
bail!("Failed to allocate output format context: error {ret}");
}
// unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。
// 返回的 `stream_ptr` 是裸指针,调用方负责不 double-freeFFmpeg 内部托管)。
// 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");
}
// unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。
// FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证
// 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。
// 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}");
}
// unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续
// mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格
// 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。
// 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;
}
// unsafe #8`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期
// 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。
// 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,
@@ -359,27 +290,23 @@ fn main() -> Result<()> {
);
}
// unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束
// (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。
// 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}");
}
// unsafe #10`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。
// unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码
// 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中
// 同款的"独占所有权"约定。
// 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
// 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 阶段会做)。
// 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,
@@ -399,20 +326,17 @@ fn main() -> Result<()> {
}
// Allocate reusable YUV frame
// 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求
// 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。
// 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 {
// 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);
@@ -428,16 +352,12 @@ fn main() -> Result<()> {
println!("=== Encoding {} frames ===\n", bench_args.frames);
// 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响),
// 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。
let mut stats = FrameStats::default();
let total_start = Instant::now();
let mut frames_encoded: u32 = 0;
let mut pts: i64 = 0;
// 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。
while frames_encoded < bench_args.frames {
// 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result<Option<T>>。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => {
@@ -452,7 +372,6 @@ fn main() -> Result<()> {
}
}
// 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
@@ -464,14 +383,13 @@ fn main() -> Result<()> {
}
};
// 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。
let frame_start = Instant::now();
// ---- 第 1 段:mmap DMA-BUF 到用户态 ----
let mmap_start = Instant::now();
let frame_size = (frame.stride as usize) * (frame.height as usize);
// unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到
// 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。
// 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(),
@@ -491,16 +409,16 @@ fn main() -> Result<()> {
}
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
// ---- 第 2 段:sws_scale BGR0 → YUV420P ----
let scale_start = Instant::now();
// unsafe #16`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。
// 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向
// 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。
// 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) };
// unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险:
// (1) 裸指针 src_ptr / src_linesize(2) yuv_frame->data/linesize 数组
// 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。
// 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);
@@ -521,18 +439,18 @@ fn main() -> Result<()> {
.scale_us
.push(scale_start.elapsed().as_micros() as u64);
// unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。
// 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);
// ---- 第 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 与编码器配置一致。
// 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;
@@ -554,7 +472,7 @@ fn main() -> Result<()> {
.push(frame_start.elapsed().as_micros() as u64);
frames_encoded += 1;
if frames_encoded % 30 == 0 {
if frames_encoded.is_multiple_of(30) {
let fps = frames_encoded as f64 / total_start.elapsed().as_secs_f64();
println!(
" [{}/{}] {:.1} FPS",
@@ -566,8 +484,8 @@ fn main() -> Result<()> {
let total_elapsed = total_start.elapsed();
println!("\nFlushing encoder...");
// unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。
// 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。
// 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());
}
@@ -577,8 +495,9 @@ fn main() -> Result<()> {
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
// Cleanup
// unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII
// 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。
// 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);
@@ -587,8 +506,6 @@ fn main() -> Result<()> {
drop(cap);
// Print results
// 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比
// `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。
let mmap_count = stats.mmap_us.len() as u32;
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
@@ -597,7 +514,6 @@ fn main() -> Result<()> {
};
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
// 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。
let max_fps = if avg_total_ms > 0.0 {
1000.0 / avg_total_ms
} else {
@@ -662,21 +578,17 @@ fn main() -> Result<()> {
Ok(())
}
// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是
// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264
// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN
// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。
fn drain_encoder(
enc_video: &mut ff::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// unsafe #22`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须
// unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。
// 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 {
// EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
@@ -685,19 +597,16 @@ fn drain_encoder(
}
let enc_tb = enc_video.time_base();
// unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于
// rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams
// 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream
// 就 bail 了)。
// 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)
};
// 把 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}"))?;
}
File diff suppressed because it is too large Load Diff
+105 -224
View File
@@ -1,23 +1,3 @@
//! XDG Desktop Portal + PipeWire 截屏后端。
//!
//! 本模块实现 `CaptureBackend::PortalPipeWire` 路径:通过 XDG Portal 的
//! ScreenCast 接口请求用户授权,拿到 PipeWire 远程 fd 与 node_id 后,在专用
//! 线程里跑 PipeWire 事件循环接收 DMA-BUF 帧。
//!
//! 关键设计:
//! - 使用 `ashpd` crate 走 XDG Portal 协议(高层 Rust 绑定,封装 D-Bus 调用)。
//! - `CapPortal` 在用户 cache 目录(`wl-webrtc/portal-restore-token`)缓存 Portal
//! restore token,下次启动可跳过用户授权对话框(token 有效时)。
//! - `--no-persist` 标志:跳过 restore token 读写,每次启动都弹授权对话框;测试
//! fresh authorization 时使用。
//! - 与 `backend_detect.rs` 的差异:检测阶段刻意用 raw `zbus` 避免 `ashpd` 缓存
//! `zbus::Connection` 到全局 OnceLockruntime drop 后变僵尸 connection)。本
//! 模块只在 Portal 路径使用 `ashpd`,且 Tokio runtime 由 `CapPortal` 自己拥有
//! `rt` 字段),生命周期与 `CapPortal` 一致,无跨实例复用问题。
//!
//! 分阶段超时(git 68a6eec):`Service`(无用户交互,5s)与 `TokenDependent`
//! (可能弹对话框,30s)两类,前者直接失败、后者清 token 后重试一次。
// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
//
// 整体架构:
@@ -115,6 +95,23 @@ pub struct PwDmaBufFrame {
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 捕获线程发送给消费者的控制事件。
@@ -178,10 +175,9 @@ impl CapPortal {
let (frame_tx, frame_rx) = bounded(1);
let (event_tx, event_rx) = bounded(8);
// 创建 eventfd 对(Linux 特有的进程内事件通知机制)。
// EFD_CLOEXEC: exec() 时自动关闭 fd,避免泄露给子进程。
// EFD_NONBLOCK: 读取时非阻塞,配合 epoll/poll 使用。
// unsafe: libc::eventfd 是 C FFI,返回值 < 0 表示 errno 错误。
// SAFETY: eventfd(2) is a POSIX syscall with no preconditions; the init value
// and flags (CLOEXEC + NONBLOCK) are valid. Returns either a fresh fd (>= 0)
// or -1 on error, which we check immediately below.
let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
if efd < 0 {
return Err(anyhow::anyhow!(
@@ -189,54 +185,49 @@ impl CapPortal {
std::io::Error::last_os_error()
));
}
// 复制 fd 得到独立的两端(读端 efd 给 PipeWire 线程,写端 write_fd 留给 Drop)。
// dup 返回的是新的 fd(最小可用整数),与原 fd 共享同一打开文件描述。
// unsafe: libc::dup 是 C FFI< 0 表示失败;失败时必须 close 原来的 efd 防止泄露。
// SAFETY: `efd` is the open eventfd we just created (>= 0 checked above) and
// own. dup(2) returns either a fresh fd or -1.
let write_fd = unsafe { libc::dup(efd) };
if write_fd < 0 {
let err = std::io::Error::last_os_error();
// unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。
// SAFETY: `efd` is still the open eventfd we own; closing on the error
// path before returning to avoid fd leak.
unsafe { libc::close(efd) };
return Err(anyhow::anyhow!("dup eventfd failed: {err}"));
}
// Arc<AtomicU64> 跨线程共享的丢弃计数器(Arc 提供线程安全引用计数,
// 类似 Go 的 sync/atomic.Value 但带引用语义)。PipeWire 线程在 channel
// 满导致丢帧时原子递增它,主线程通过 dropped_count() 读取统计。
// Ordering::Relaxed:仅用于统计,不需要跨线程内存顺序保证。
let pw_dropped = Arc::new(AtomicU64::new(0));
// PwThreadCtx 聚合所有要 move 进 PipeWire 线程的资源。
// shutdown_read / pw_fd 用 OwnedFd 包装(Drop 时自动 close),
// 这避免手动管理 fd 生命周期。frame_tx / event_tx 是 crossbeam
// channel 的发送端(多生产者单消费者,Clone + Send)。
let ctx = PwThreadCtx {
frame_tx,
event_tx,
dropped: pw_dropped.clone(),
// unsafe: OwnedFd::from_raw_fd 接管 efd 的所有权(保证 RAII 关闭)。
// 之前 libc::eventfd 返回的 efd 没有 Owner,必须用 from_raw_fd 包一下。
// SAFETY: `efd` is the freshly-created eventfd (>= 0 checked above) and we
// are its sole owner. OwnedFd::from_raw_fd takes ownership and will close()
// it on Drop. Ownership transfers into PwThreadCtx and then into the
// PipeWire thread via pipewire_thread.
shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
pw_fd,
node_id,
fps: args.fps,
};
// thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。
// move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。
let pw_thread = thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || {
pipewire_thread(ctx);
})
.map_err(|e| {
// unsafe: spawn 失败时清理 write_fd 防止泄漏。
// SAFETY: `write_fd` is the open dup'd eventfd we own (>= 0 checked
// above); closing on thread-spawn failure to avoid fd leak.
unsafe { libc::close(write_fd) };
anyhow::anyhow!("thread spawn failed: {e}")
})?;
Ok(Self {
// unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。
// SAFETY: `write_fd` is the freshly-dup'd eventfd (>= 0 checked above) and
// we are its sole owner. OwnedFd::from_raw_fd takes ownership and will
// close() it on Drop (which fires when CapPortal is dropped).
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
frame_rx,
event_rx,
@@ -281,25 +272,17 @@ impl CapPortal {
/// false`, clears the cached restore token and retries once with
/// `no_persist = true`.
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
// 首次尝试:使用缓存的 restore token(若存在且 no_persist=false)。
// _setup_portal_inner 内部根据 phase 失败分类返回 PortalPhaseTimeout。
match Self::_setup_portal_inner(no_persist, false).await {
Ok(result) => Ok(result),
// 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。
// anyhow 包装动态类型错误,e.is::<T>() 检查,downcast_ref::<T>() 取引用。
Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
match inner_err {
// 仅当 token-dependent phase 超时且原本允许 persist 时才重试。
// 重试策略:删除缓存的 token,强制 fresh authorization。
PortalPhaseTimeout::TokenDependent if !no_persist => {
tracing::warn!(
"Portal timed out during token-using phase. \
Clearing cached restore token and retrying with fresh authorization."
);
delete_restore_token();
// is_retry=true 阻止 _setup_portal_inner 再次进入重试分支
// (最多重试一次,避免无限循环)。
Self::_setup_portal_inner(true, true).await
}
_ => Err(e),
@@ -317,31 +300,22 @@ impl CapPortal {
no_persist: bool,
is_retry: bool,
) -> Result<(OwnedFd, u32)> {
// 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。
// CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
// Phase 1: Screencast proxy (no user interaction).
// D-Bus 代理对象,对应 XDG Portal ScreenCast 接口。
// tokio::time::timeout(dur, fut) 包装一个 future,超过 dur 返回 Err(Elapsed)。
// 返回 Result<Result<T, ashpd::Error>, Elapsed>,外层是 timeout,内层是 Portal 调用。
// 三路 matchOk(Ok) 成功 / Ok(Err) Portal 报错 / Err(_) 超时。
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
Err(_) => {
log_portal_phase_timeout("creating Screencast proxy", false);
// .into() 把 PortalPhaseTimeout 转换为 anyhow::Errordyn Error trait object)。
return Err(PortalPhaseTimeout::Service.into());
}
};
// Phase 2: create_session (no user interaction).
// 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。
// Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。
let session = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.create_session(Default::default()),
@@ -356,13 +330,8 @@ impl CapPortal {
}
};
// Portal 协议版本 ≥4 才支持 persist_mode 与 restore_token。
// version 由 Screencast proxy 在 D-Bus 属性中暴露。
let version_supported = proxy.version() >= 4;
// 决定 persist_mode 与已缓存的 token
// - no_persist=true 或版本不支持 → PersistMode::DoNot,不读 token。
// - 否则 → PersistMode::ExplicitlyRevoked(显式可撤销,配合 token 重用)。
let (persist_mode, saved_token) = if !no_persist && version_supported {
let token = load_restore_token();
if token.is_some() {
@@ -377,26 +346,18 @@ impl CapPortal {
(PersistMode::DoNot, None)
};
// Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。
// CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。
// BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。
// set_multiple(false):单流(不开启多显示器拼接)。
let mut options = SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(persist_mode);
// 若有缓存的 token,附加到 options 实现免对话框恢复。
// if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。
if let Some(ref token) = saved_token {
options = options.set_restore_token(token.as_str());
}
// Phase 3: select_sources — token path is fast (no dialog); fresh
// authorization may pop a dialog.
// 双超时策略:token_in_use=true 时无对话框(5s service timeout),
// false 时用户需要点 Allow30s user-dialog timeout)。
let token_in_use = saved_token.is_some();
let phase3_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
@@ -408,7 +369,6 @@ impl CapPortal {
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
Err(_) => {
log_portal_phase_timeout("selecting sources", token_in_use);
// 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。
return Err(
if token_in_use {
PortalPhaseTimeout::TokenDependent
@@ -421,15 +381,11 @@ impl CapPortal {
}
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
// start 返回一个 futureresponse 解析 PortalDbus 返回值。
// 这里把两个 await 串起来放进 async 块,整体受 phase4_timeout 包裹。
let phase4_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
// 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。
// ? 在 async 块里传播 ashpd::Error,外层 match 处理。
let start_fut = async {
proxy
.start(&session, None, Default::default())
@@ -452,15 +408,12 @@ impl CapPortal {
}
};
// 持久化新颁发的 restore tokenPortal 可能返回与之前不同的 token)。
if !no_persist && version_supported {
if let Some(new_token) = response.restore_token() {
save_restore_token(new_token);
}
}
// 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。
// ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。
let stream = response
.streams()
.first()
@@ -469,8 +422,6 @@ impl CapPortal {
let node_id = stream.pipe_wire_node_id();
// Phase 5: open_pipe_wire_remote (no user interaction).
// 请求 PipeWire 服务端 fd。返回的 OwnedFd 是 Portal 通过 D-Bus fd-passing
// 传过来的 PipeWire socketPipeWire 线程用它连接到 compositor 的 PipeWire 实例。
let fd = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.open_pipe_wire_remote(&session, Default::default()),
@@ -491,32 +442,17 @@ impl CapPortal {
}
}
/// 计算 Portal restore token 的持久化路径(用户 cache 目录下 `wl-webrtc/portal-restore-token`)。
///
/// 返回 `Option<PathBuf>` 因为某些系统无合法 cache 目录(如 `$XDG_CACHE_HOME` 未设置
/// 且无 HOME),此时返回 None,调用方应跳过 token 持久化。
///
/// 路径布局:`$XDG_CACHE_HOME/wl-webrtc/portal-restore-token` 或 `~/.cache/wl-webrtc/portal-restore-token`。
fn token_path() -> Option<PathBuf> {
// dirs::cache_dir() 返回 Option<PathBuf>(无 cache 目录时为 None)。
// .map(|base| base.join("wl-webrtc").join("portal-restore-token"))
// 类似 Go 的 filepath.Join,跨平台路径拼接。
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
}
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
fn verify_secure_dir(path: &std::path::Path) -> bool {
// use 内导入 unix-only trait 扩展(Linux 特有的 stat/mode 字段)。
// 这些 trait 让 std::fs::Metadata 暴露 .uid()/.gid()/.mode() 等 Unix 字段。
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。
// 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录
// 让我们以为权限正确(实际指向 /etc 之类)。
match std::fs::symlink_metadata(path) {
Ok(meta) => {
// 第一道防线:拒绝任何 symlink,即使权限看起来正确。
if meta.file_type().is_symlink() {
tracing::warn!(
"Token parent dir is a symlink, rejecting: {}",
@@ -530,8 +466,9 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// Must be owned by current user
// unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式),
// 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID
// 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: {}",
@@ -540,8 +477,6 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
// mode & 0o777:剥离文件类型位(st_mode 高位),只保留 rwx 权限位。
// 要求严格 0o700owner rwxgroup 与 other 全无(防止其他用户读 token)。
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
@@ -563,14 +498,12 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
/// Ensure the parent directory exists with restrictive permissions (0o700).
/// Returns false if the directory could not be created or is insecure.
fn ensure_secure_parent(parent: &std::path::Path) -> bool {
// DirBuilderExt 扩展 DirBuilder::mode()Unix-only),OpenOptionsExt 用于后续步骤。
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if parent.exists() {
// Directory exists — try to tighten permissions, then verify.
// set_permissions follows symlinks, which is fine here since
// we verify with symlink_metadata in verify_secure_dir.
// 收紧模式:把已存在目录强行改为 0700,然后 verify_secure_dir 校验最终状态。
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!("Failed to set directory permissions: {e}");
return false;
@@ -579,8 +512,6 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
// 关键:标准 create_dir 受 umask 影响(如 022 → 实际 0755)。
// DirBuilderExt::mode(0o700) 直接设置 inode mode,绕过 umask,保证 0700。
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
builder.mode(0o700);
@@ -590,35 +521,18 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Verify after creation (belt-and-suspenders)
// 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。
verify_secure_dir(parent)
}
/// 加载已缓存的 Portal restore token(默认路径)。
///
/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。
/// 失败原因由 tracing::warn! 记录,便于排查。
fn load_restore_token() -> Option<String> {
// ? 在 Option 上传播:token_path() 返回 None 时直接 return None。
load_restore_token_from(token_path()?)
}
/// 从指定路径加载 token,附带严格的安全校验。
///
/// 校验规则(任一不满足返回 None):
/// 1. 必须是 regular file(拒绝 directory / fifo / socket
/// 2. 不能是 symlink(防 symlink attack
/// 3. owner 必须是当前用户
/// 4. group/other 不可读写(mode & 0o077 == 0
///
/// 这些校验防止攻击者通过预创建文件或符号链接窃取 token。
fn load_restore_token_from(path: PathBuf) -> Option<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadatalstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
// 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。
Err(_) => return None,
};
@@ -633,14 +547,11 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
tracing::warn!("Token path is not a regular file: {}", path.display());
return None;
}
// unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。
// 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。
// 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;
}
// 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。
// 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
tracing::warn!(
@@ -651,9 +562,6 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
return None;
}
// .ok()? :把 std::io::Result<String> 转 Option<String>Err 变 None。
// 然后 trim 去掉首尾空白(Portal 返回的 token 可能带换行)。
// 若 trim 后为空字符串,返回 None(视为无 token)。
let token = std::fs::read_to_string(&path).ok()?;
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
@@ -663,13 +571,7 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
}
}
/// 保存 Portal 颁发的 restore token 到默认 cache 路径。
///
/// 失败(无 cache 目录、目录权限不合规、磁盘满等)不返回错误,
/// 仅 tracing::warn!,下次启动会重新走对话框授权流程。
fn save_restore_token(token: &str) {
// let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。
// 无 cache 目录时早退,避免后续无谓 IO。
let Some(path) = token_path() else {
tracing::warn!("No secure cache directory available, skipping token save");
return;
@@ -677,16 +579,10 @@ fn save_restore_token(token: &str) {
save_restore_token_to(token, &path);
}
/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。
///
/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。
fn delete_restore_token() {
// let-else 早退模式(与 save_restore_token 一致)。
let Some(path) = token_path() else {
return;
};
// match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。
// 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -694,21 +590,11 @@ fn delete_restore_token() {
}
}
/// 把 token 原子写入指定路径(temp file + rename 模式)。
///
/// 原子性:通过临时文件 + rename(2) 实现,确保读到完整 token 或读到旧 token
/// 永远不会读到部分写入。这是 Linux/Unix 文件系统 rename 的保证。
///
/// 安全性:
/// - 父目录必须 0o700 且 owner = current userensure_secure_parent 校验)
/// - temp file 用 create_new + mode 0o600(不覆盖现有文件,不跟随 symlink)
/// - rename 是原子操作,但仅在同 filesystem 下保证
fn save_restore_token_to(token: &str, path: &std::path::Path) {
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
// path.parent() 返回 Option<&Path>root 路径无 parent)。
let Some(parent) = path.parent() else {
tracing::warn!("Token path has no parent directory");
return;
@@ -722,31 +608,21 @@ fn save_restore_token_to(token: &str, path: &std::path::Path) {
// Use a unique temp file to prevent symlink attacks.
// create_new(true) guarantees exclusive creation — fails if file already exists,
// and does NOT follow existing symlinks.
// temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
// IIFE (immediately-invoked closure) 把多步 IO 组合成单一 Result。
// ? 在闭包内传播 std::io::Error,外层统一 match 处理。
let result = (|| -> std::io::Result<()> {
// OpenOptions builderwrite + create_new = O_WRONLY | O_CREAT | O_EXCL。
// mode(0o600)owner rwgroup/other 无权限(绕过 umask)。
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(token.as_bytes())?;
// sync_allfsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。
// 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。
f.sync_all()?;
// rename(2):原子替换。Linux 同 filesystem 下原子保证。
std::fs::rename(&tmp_path, path)?;
Ok(())
})();
match result {
Ok(()) => tracing::info!("Saved portal restore token"),
Err(e) => {
// 失败时清理 temp(避免遗留垃圾文件)。
// let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。
let _ = std::fs::remove_file(&tmp_path);
tracing::warn!("Failed to save restore token: {e}");
}
@@ -764,14 +640,10 @@ impl Drop for CapPortal {
fn drop(&mut self) {
// Signal the PipeWire loop to quit via eventfd.
// eventfd write is a kernel syscall — thread-safe and lock-free.
// 写入 8 字节(u64)到 eventfdPipeWire 线程 epoll_wait 立即返回。
// val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。
let val: u64 = 1u64;
// unsafe: libc::write 是 C FFI。签名:write(fd, buf, count) → ssize_t。
// - self.shutdown_fd.as_raw_fd():取出 OwnedFd 内部的 raw int fd。
// - &val as *const u64 as *const _:把 Rust 引用强转成 *const c_void。
// - std::mem::size_of::<u64>()8 字节(eventfd 必须写 8 字节)。
// 返回值是写入字节数或 -1(错误),用 let _ = 忽略(Drop 不能 panic)。
// SAFETY: shutdown_fd is a valid open eventfd (owned by Self); the buffer is
// a stack u64 of size 8 bytes which matches the count argument. POSIX write(2)
// is the standard fd-write syscall; eventfd writes must be exactly 8 bytes.
let _ = unsafe {
libc::write(
self.shutdown_fd.as_raw_fd(),
@@ -782,10 +654,6 @@ impl Drop for CapPortal {
// 等待 PipeWire 线程完全退出
// 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续
// Option::take():把 Option<JoinHandle> 里的值 move 出来,留下 None。
// 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。
// handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。
// let _ = 忽略 panic 错误(Drop 中无法恢复)。
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}
@@ -829,16 +697,9 @@ fn pipewire_thread(ctx: PwThreadCtx) {
shutdown_read,
pw_fd,
node_id,
fps,
fps: _,
} = ctx;
// PipeWire 三件套初始化(典型 PW 客户端架构):
// MainLoop —— 事件循环(epoll 后端),所有回调都在此线程派发。
// Context —— 加载 PW 模块、管理代理对象的上下文,挂在 MainLoop 上。
// Core —— 与 PipeWire daemon 的连接(此处用 connect_fd 走 Portal
// 下发的 socket fd 而非默认的 `pipewire-0`)。
// 任一初始化失败都通过 event_tx 上报 PwCtrlEvent::Error 并退出本线程,
// 让主线程的 select 报告具体阶段错误。
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
@@ -900,17 +761,8 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
};
// 共享的可变格式信息容器:Rc<Cell<Option<(w, h, drm_fmt, modifier)>>>。
// - Rc 单线程引用计数(PipeWire 回调全在同一线程),类比 Go 中"通过指针
// 共享的可变全局变量"但带编译期 Send 约束。
// - Cell<Option<...>> 提供内部可变性(无需 Mutex),通过 .get()/.set()
// 整体替换值——比 RefCell 更轻,因为这里值是 Copy 的元组。
// - 类比 Go: var formatInfo = *(u32,u32,u32,u64) // 取地址 + atomic 赋值。
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> = Rc::new(Cell::new(None));
let format_info: Rc<Cell<Option<PortalFormatInfo>>> = Rc::new(Cell::new(None));
// crossbeam channel 的 Sender 是 Clone + Send,每次 clone 给一个回调
// 捕获,多回调可并发往同一 channel 投递事件。类比 Go: ch := make(chan T, 8)
// 各 goroutine 持有 ch 共享发送端。
let event_tx_state = event_tx.clone();
let _listener = stream
.add_local_listener::<()>()
@@ -961,9 +813,14 @@ fn pipewire_thread(ctx: PwThreadCtx) {
let max_framerate = info.max_framerate();
// 保存协商后的格式信息,供 process 回调读取
let previous_format = format_info.get();
format_info.set(Some((width, height, drm_format, modifier)));
if let Some((previous_width, previous_height, _, _)) = previous_format {
if width != previous_width || height != previous_height {
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,
@@ -989,15 +846,20 @@ fn pipewire_thread(ctx: PwThreadCtx) {
.process({
let format_info = format_info.clone();
let frame_tx = frame_tx.clone();
let dropped = dropped;
move |stream, _| {
// 以下大量 unsafe 块均为对 PipeWire/libspa C API 的直接访问。
// pipewire-rs 的 stream 类型只暴露 `dequeue_raw_buffer` /
// `queue_raw_buffer` 这类 unsafe 接口,因为返回的是 C 分配的
// 裸 `*mut spa_buffer`,其生命周期由 PipeWire 控制(在
// dequeue 与下一次 queue 之间稳定),Rust 类型系统无法表达。
// 调用约定:每个 dequeue 必须恰好配一次 queue(包括所有错误
// 退出路径),否则 PipeWire 会认为该 buffer 仍被使用而耗尽池。
// 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");
@@ -1005,36 +867,49 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
// 获取 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;
}
@@ -1045,6 +920,12 @@ fn pipewire_thread(ctx: PwThreadCtx) {
// 从 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;
@@ -1067,12 +948,15 @@ fn pipewire_thread(ctx: PwThreadCtx) {
};
// 验证格式信息已协商完成,且分辨率和格式有效
let Some((width, height, format, modifier)) = format_info.get() else {
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;
}
@@ -1080,20 +964,27 @@ fn pipewire_thread(ctx: PwThreadCtx) {
// 复制 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;
}
// 构建帧数据对象,所有必要的帧信息已收集完毕
// unsafe: OwnedFd::from_raw_fd 把刚刚 dup 出的 fd 所有权移交给
// Rust 的 RAII 包装。此后 dup_fd 的关闭由 PwDmaBufFrame::Drop
// 负责,不能再在外部 close 它。from_raw_fd 之所以 unsafe,是
// 因为调用方必须保证传入的 fd 此前没有任何 Owner(否则会 double
// close)。这里 libc::dup 刚返回的新 fd 满足该前提。
// 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: unsafe { OwnedFd::from_raw_fd(dup_fd) },
fd: frame_fd,
offset,
stride,
modifier,
@@ -1103,24 +994,20 @@ fn pipewire_thread(ctx: PwThreadCtx) {
pts,
};
// try_send 非阻塞投递;channel 容量=1(见 CapPortal::new),
// 当下游编码器落后时立刻返回 Full。
// 类比 Go: select { case ch <- frame: default: /* drop */ }
match frame_tx.try_send(frame) {
Ok(()) => {}
Err(crossbeam_channel::TrySendError::Full(_)) => {
// 丢帧计数(Relaxed 序,仅做统计;不要求与其他线程同步)。
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
}
// 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();
// 空的 SPA POD 参数数组——之前已在 param_changed 回调中接受了 PipeWire
// 推送的格式,这里不需要主动声明格式约束。`&mut [...]` 借用切片给 C API。
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
@@ -1147,24 +1034,18 @@ fn pipewire_thread(ctx: PwThreadCtx) {
// previous detached helper thread approach.
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
//
// `as_raw_ptr()` 返回 `*mut pw_main_loop`(裸指针,不带生命周期),
// 取裸指针本身是 safe 的——风险在使用它。下面 `pw_main_loop_quit` 的
// unsafe 块依赖"回调仅在 run() 期间触发"这一 PipeWire 协议保证。
let mainloop_ptr = mainloop.as_raw_ptr();
// 把 shutdown_read 的可读事件注册到 PipeWire loop 的 epoll/win32 等价物。
// 每次 fd 变可读(CapPortal::drop 写入 8 字节触发),loop 在同一线程
// 调用此闭包。返回的 _shutdown_source 在 drop 时自动从 loop 注销。
let _shutdown_source = loop_.add_io(
shutdown_read,
libspa::support::system::IoFlags::IN,
move |fd| {
// Drain the eventfd so it doesn't re-trigger
let mut buf: u64 = 0;
// unsafe: libc::read 是 C 标准库 FFI。eventfd 语义保证 8 字节
// 整数读,因此 &mut u64 转 *mut void + size_of::<u64>() 安全。
// 返回值忽略——即使读失败也无法在此回调中做有意义处理。
// 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(),
+3 -54
View File
@@ -1,20 +1,3 @@
//! 文件: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;
@@ -24,9 +7,6 @@ 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
@@ -37,27 +17,15 @@ pub struct CapWlrScreencopy {
/// The active frame object for the current capture cycle.
/// Set by Dispatch impls after `manager.capture_output()`, cleared
/// by `on_done_with_frame()`.
// L3: `Option<T>` 类似 Go 的 `*T`(指针)——要么持有 T 的值,要么是 None(空)。
pub current_frame: Option<ZwlrScreencopyFrameV1>,
}
// L2: 为 `CapWlrScreencopy` 实现 `CaptureSource` trait。
// Rust 的 `impl Trait for Type` 块类比 Go 的 method receiver——
// Go: `func (r *Type) Method(args)`receiver 作为第一个参数显式声明)
// Rust: `fn method(&self, args)``&self` 是 `self: &Self` 的语法糖,等价 Go receiver
// trait impl 要求方法签名与 trait 定义严格一致,编译器会校验。
impl CaptureSource for CapWlrScreencopy {
/// Unit type: wlr-screencopy is fully asynchronous — `alloc_frame()`
/// always returns `None`. The frame object is created by Dispatch
/// impls calling `manager.capture_output()`, not by this method.
// L3: `type Frame = ();` 关联类型(associated type):将"帧"的具体类型延迟到
// impl 处决定。wlr-screencopy 用 unit `()` 因为帧对象生命周期由 Dispatch 控制。
/// 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.
type Frame = ();
// L3: 构造函数。`Self` 在 impl 块内是 `CapWlrScreencopy` 的类型别名。
// 参数名以 `_` 前缀表示"有意未使用"——manager 绑定不在此处发生,故这些
// 参数(GlobalList/WlOutput/OutputInfo/QueueHandle)暂未消费。返回
// `Result<Self>`,失败由调用方用 `?` 操作符传播(类比 Go 的 `if err != nil`)。
fn new(
_gm: &GlobalList,
_output: &WlOutput,
@@ -67,40 +35,21 @@ 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();
}
}
-60
View File
@@ -1,89 +1,39 @@
//! 帧率限制器(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()
}
@@ -96,7 +46,6 @@ 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));
@@ -107,8 +56,6 @@ 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());
}
@@ -118,7 +65,6 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// 34ms > 33.33ms30fps 的 min_interval),应放行。
let result = limiter.on_new_frame(2, now + Duration::from_millis(34));
assert_eq!(result, Some(2));
}
@@ -129,11 +75,8 @@ 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);
}
@@ -153,11 +96,8 @@ 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
View File
@@ -1,29 +1,11 @@
//! `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;
+5 -85
View File
@@ -1,48 +1,11 @@
//! # 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_intPOSIX 文件描述符),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)
// - Pollepoll/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;
// 各功能模块声明
@@ -58,8 +21,6 @@ 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;
@@ -85,11 +46,6 @@ 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")
@@ -97,11 +53,6 @@ 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)
@@ -118,8 +69,6 @@ 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");
}
@@ -130,14 +79,9 @@ 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),
@@ -160,13 +104,9 @@ 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();
@@ -179,19 +119,14 @@ 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
};
@@ -213,9 +148,9 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
revents: 0,
};
// timeout=0 表示非阻塞,立即返回当前 fd 状态
// unsafe { ... } 是 Rust 的不安全块:内部调用 C 库 libc::poll,需要程序员
// 手动保证 &mut pfd 是有效的可变引用、fd 合法、不并发访问等不变量。
// unsafe 不关闭 Rust 借用检查,只是声明"我对外部 FFI 调用负责"。
// 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.
let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
tracing::info!(
"Raw poll on wayland fd={wayland_fd}: ret={ret}, revents={}",
@@ -241,7 +176,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 命令默认信号
])?;
@@ -293,8 +228,6 @@ 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");
@@ -304,12 +237,8 @@ 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 会将合成器事件
@@ -350,10 +279,7 @@ 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}");
}
@@ -374,8 +300,6 @@ 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)");
@@ -384,13 +308,12 @@ 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,
])?;
@@ -432,9 +355,6 @@ 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)? {}
}
+36 -570
View File
File diff suppressed because it is too large Load Diff
+81 -186
View File
@@ -1,33 +1,7 @@
//! 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.
use std::os::fd::AsRawFd;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -53,24 +27,12 @@ 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>,
@@ -78,16 +40,31 @@ struct EncodeThread {
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>)>,
}
/// 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.
struct WebRtcThreadConfig {
fps: u32,
enc_width: u32,
enc_height: u32,
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.
struct WebRtcThreadChannels {
webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
}
/// 门户模式的主状态机
///
/// 负责管理从 PipeWire 采集屏幕帧、通过 VAAPI 硬件编码的完整生命周期。
@@ -117,14 +94,6 @@ 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 {
/// 创建门户状态实例
///
@@ -273,10 +242,6 @@ 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) =
@@ -310,19 +275,7 @@ impl StatePortal {
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 || {
@@ -353,24 +306,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,
webrtc_rx,
WebRtcThreadConfig {
fps,
enc_width,
enc_height,
max_bitrate,
paused,
},
WebRtcThreadChannels {
webrtc_rx,
sent_gap_tx,
bitrate_tx,
resolution_tx,
},
paused,
)
})?;
self.webrtc_thread = Some(WebrtcThread {
@@ -412,8 +365,15 @@ impl StatePortal {
// 每秒输出一次结构化管道统计(仅 --stats 启用时记录日志)
if self.args.stats && self.stats.should_snapshot() {
self.stats.set_pipewire_dropped(0, 0);
self.stats.set_queue_depths(0, 0);
// 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);
if let Some(ref enc_thread) = self.enc_thread {
while let Ok(timing) = enc_thread.timing_rx.try_recv() {
self.stats.record_encode_thread(
@@ -440,11 +400,6 @@ 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;
@@ -470,11 +425,6 @@ 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!(
@@ -543,10 +493,6 @@ 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(());
@@ -566,61 +512,47 @@ impl StatePortal {
if let Some(enc) = self.enc.as_mut() {
// 将 DMA-BUF 帧零拷贝导入 VAAPI 硬件帧池
// unsafeFFI 调用 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。
// 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.fd.as_raw_fd(),
frame.width,
frame.height,
frame.format,
frame.modifier,
frame.stride,
frame.offset,
)
avhw::import_dma_buf_to_vaapi(enc.frames_rgb().as_ptr(), &frame)
}?;
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 编码
enc.encode_frame(&vaapi_frame)?;
let stages = enc.encode_frame(&vaapi_frame)?;
let total_us = t_import_start.elapsed().as_micros() as u64;
let encode_us = t_encode_start.elapsed().as_micros() as u64;
let encode_us = stages.encode_us;
self.frames_encoded += 1;
// 记录帧计时到管道统计(import + encode 内部各阶段暂不可分离,用 total 覆盖
// 记录帧计时到管道统计(scale 来自 filter graphtransfer 在 HW 路径恒为 0
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() {
// 同上 unsafeDMA-BUF → VAAPI 导入;`import.frames_rgb()` 是与编码线程
// **不共享**的独立硬件帧池(避免与 `import_and_scale` 的回读路径竞争)。
// 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.fd.as_raw_fd(),
frame.width,
frame.height,
frame.format,
frame.modifier,
frame.stride,
frame.offset,
)
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 {
(*vaapi_frame.as_mut_ptr()).pts = pts;
}
@@ -634,9 +566,6 @@ 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;
@@ -705,10 +634,6 @@ 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() {
@@ -753,21 +678,12 @@ 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_framematch EncodeOutcome 各 variant 分别处理;
// timing_tx.try_send 非阻塞回执(满则丢,类比 Go `select { case ch <- v: default: }`);
// 重复帧计数通过 Arc<AtomicU64>::fetch_add + Relaxed 累加——无锁、无需 happens-before。
// - Err(_) → 所有 Sender 已 dropflush 编码器后退出循环。
loop {
match input_rx.recv() {
Ok(frame) => {
@@ -804,25 +720,24 @@ fn encode_thread_loop(
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,
config: WebRtcThreadConfig,
channels: WebRtcThreadChannels,
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 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;
@@ -830,7 +745,6 @@ fn webrtc_thread_loop(
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 {
@@ -848,8 +762,6 @@ fn webrtc_thread_loop(
}
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 {
@@ -876,7 +788,7 @@ fn webrtc_thread_loop(
let should_send = match last_sent_bitrate {
None => true,
Some(last) => {
let diff = if bwe > last { bwe - last } else { last - bwe };
let diff = bwe.abs_diff(last);
diff * 10 > last
}
};
@@ -919,8 +831,6 @@ fn webrtc_thread_loop(
}
if connected {
// 已连接:批量 drain 已编码帧队列(类比 Go `for { select { case f := <-rx: send(f); default: break } }`)。
// saturating_add 防止计数器溢出(Go 没有,Rust 默认 panic-on-overflowdebug 下尤其危险)。
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}");
@@ -937,12 +847,9 @@ fn webrtc_thread_loop(
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 信令循环周期性推进。
// 三路 ResultOk → 处理帧;Err(Timeout) → 继续下一轮循环处理信令;Err(Disconnected) → 编码线程已退出,本线程返回。
match webrtc_rx.recv_timeout(timeout) {
Ok(enc_frame) => {
if wrtc.is_connected() {
@@ -971,19 +878,12 @@ fn webrtc_thread_loop(
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.
@@ -1000,8 +900,6 @@ fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
}
}
// 基于 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) {
@@ -1011,8 +909,6 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
return current;
}
// 在 RESOLUTION_TIERS 中找当前分辨率的位置;若不在表中(如 1366×768),
// 用 unwrap_or_else 退回到第一个宽高都不超过 current 的档位,最终兜底取最小档(720p)。
let current_index = RESOLUTION_TIERS
.iter()
.position(|&tier| tier == current)
@@ -1026,16 +922,12 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
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)
}
@@ -1086,11 +978,12 @@ fn resolve_drm_device(args: &Args) -> Result<Option<PathBuf>> {
/// 用于验证 DMA-BUF 元数据映射的正确性。
#[cfg(test)]
fn build_drm_descriptor(frame: &PwDmaBufFrame) -> ffmpeg_next::ffi::AVDRMFrameDescriptor {
// 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() };
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() }
};
desc.nb_objects = 1; // 单个 DMA-BUF 对象
desc.objects[0].fd = frame.fd.as_raw_fd(); // DMA-BUF 文件描述符
desc.objects[0].size = 0; // 大小设为 0(内核自动确定)
@@ -1113,8 +1006,9 @@ mod tests {
fn make_test_frame() -> PwDmaBufFrame {
// Create a dummy fd from stderr (always valid fd 2)
// 使用 stderr(fd 2)的副本作为虚拟文件描述符
// unsafelibc::dup(2) 复制 stderr fd → 返回新整数 fdOwnedFd::from_raw_fd 接管
// 该 fd 的 close 责任(RAII)。前提:libc::dup 调用成功(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.
let fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
PwDmaBufFrame {
fd,
@@ -1237,9 +1131,10 @@ mod tests {
/// 测试:使用自定义偏移量和 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 {
// unsafe:同 make_test_frame——dup(2) 复制 stderr fd 并交给 OwnedFd 管理。
fd: unsafe { OwnedFd::from_raw_fd(libc::dup(2)) },
fd: test_fd,
offset: 4096, // 4KB 对齐偏移
stride: 3840 * 4, // 4K 宽度 × 4 字节
modifier: 0x0100000000000001, // AMD modifiers
+45 -285
View File
@@ -1,25 +1,3 @@
//! 管道性能统计模块 —— 用于卡顿诊断的轻量级滑动窗口统计。
//!
//! 本模块跟踪 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.
@@ -28,15 +6,6 @@
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
@@ -59,27 +28,6 @@ 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,
@@ -90,7 +38,6 @@ 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.
@@ -125,19 +72,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,
@@ -161,21 +108,6 @@ 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();
@@ -187,14 +119,6 @@ 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();
@@ -214,19 +138,10 @@ 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 {
@@ -242,19 +157,6 @@ 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).
@@ -276,18 +178,6 @@ 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).
@@ -303,32 +193,11 @@ 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) {
@@ -336,49 +205,18 @@ impl PipelineStats {
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();
@@ -391,7 +229,6 @@ 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,
@@ -430,7 +267,6 @@ 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;
@@ -452,17 +288,6 @@ 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 {
@@ -476,7 +301,6 @@ 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,
@@ -516,79 +340,74 @@ 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,
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
pw_dropped={} over_budget={} duplicate_frames_skipped={} \
"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_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 \
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_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={}",
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,
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,
)
}
@@ -598,13 +417,6 @@ 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;
@@ -612,21 +424,6 @@ 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;
@@ -637,22 +434,10 @@ 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;
@@ -660,10 +445,6 @@ 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;
@@ -674,15 +455,10 @@ 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;
@@ -693,26 +469,10 @@ 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::*;
+7 -410
View File
@@ -1,38 +1,12 @@
//! 图像几何变换模块(纯坐标运算,不涉及像素缓冲区)。
//! Coordinate transformation module for Wayland output transforms.
//!
//! 对应 Wayland `wl_output::Transform` 的 8 种旋转变体(旋转 + 翻转),
//! 为屏幕捕获提供 ROIRegion 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()`)。
//! 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 种变体: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 {
@@ -46,288 +20,25 @@ pub enum Transform {
Flipped270,
}
// 轴对齐矩形(Axis-Aligned Bounding BoxAABB)。
//
// 所有字段 `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]
@@ -393,118 +104,4 @@ 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
}
);
}
}
+1 -278
View File
@@ -1,47 +1,3 @@
//! # 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`
//! localDescriptionOffer 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 endpointtrickle 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};
@@ -236,60 +192,28 @@ connect();
// ── 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` 实例被 dropUDP 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 connectionICE / DTLS / SRTP / RTP / RTCP)。
// Sans-IO:不持有任何 socket 或线程,只持有协议状态机。
rtc: Rtc,
// 本 peer 专用的 UDP socket(每连接一个,避免与不存在的其他 peer 串扰)。
socket: UdpSocket,
// 该 socket 绑定的本地地址(带随机端口),用作 `Candidate::host` 的发地址。
udp_addr: SocketAddr,
// 视频 Media IDSDP 协商后从 `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)?;
@@ -302,36 +226,18 @@ 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,
@@ -340,8 +246,6 @@ impl WebRtcState {
continue;
}
};
// `String::from_utf8_lossy` 把字节转成字符串,无效 UTF-8 替换为 U+FFFDHTTP 头都是 ASCII)。
// 类比 Go `string(buf[:n])`Go 字符串可包含任意字节,但后续 `starts_with` 也只看 ASCII)。
let req_str = String::from_utf8_lossy(&req[..n]);
if req_str.starts_with("GET / ")
@@ -366,19 +270,12 @@ 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` 实例 dropUDP socket 关闭,
// peer 连接断开)。这是单连接不变量的核心实现。
self.inner = Some(new_inner);
if replacing {
tracing::info!("Replaced WebRTC connection (old dropped)");
@@ -414,10 +311,6 @@ 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()? {
@@ -428,8 +321,6 @@ 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()?;
@@ -437,20 +328,12 @@ 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)?
@@ -464,15 +347,10 @@ 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
@@ -480,9 +358,6 @@ 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.
@@ -492,9 +367,6 @@ 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.
@@ -506,8 +378,6 @@ 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;
@@ -520,46 +390,19 @@ impl WebRtcState {
}
impl WebRtcInner {
// 构造一个全新的 WebRTC peer:创建 str0m `Rtc` 实例 + UDP socket + 候选地址。
// 在 `handle_signaling` 接到 `POST /sdp` 时被调用——也就是说**每来一个 SDP offer
// 都新建一个 peer**,旧 `Rtc` 实例随之 dropUDP socket 关闭,连接断开)。
//
// 步骤:
// 1. `RtcConfig::new().enable_bwe(...).build(...)`str0m 构造器链式 Builder 模式,
// 类比 Go `webrtc.NewAPI(webrtc.WithSettingEngine(...))`;启用 BWE5 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).
@@ -600,22 +443,13 @@ 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)");
@@ -635,27 +469,10 @@ 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()
@@ -674,13 +491,6 @@ 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,
@@ -693,18 +503,12 @@ 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());
@@ -717,8 +521,6 @@ 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.
@@ -728,15 +530,13 @@ 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
.map_or(true, |last| now.duration_since(last) >= FORCED_KEYFRAME_MIN_INTERVAL);
.is_none_or(|last| now.duration_since(last) >= FORCED_KEYFRAME_MIN_INTERVAL);
if should_honor {
self.last_forced_keyframe_at = Some(now);
self.need_keyframe = true;
@@ -750,24 +550,11 @@ 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!(
@@ -779,28 +566,20 @@ 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 {
@@ -813,15 +592,10 @@ 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,
@@ -839,8 +613,6 @@ 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}");
@@ -852,27 +624,15 @@ 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 {
@@ -884,8 +644,6 @@ 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}")
})?;
@@ -896,8 +654,6 @@ 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}"))?;
@@ -905,17 +661,6 @@ impl WebRtcInner {
Ok(())
}
// 把一帧 H.264 NALUannex-B 格式,含 0x000001 起始码)写入 str0m,转 RTP 发出。
//
// 5 步:
// 1. 检查 `connected`、`video_mid`、`video_pt`,未就绪则 `Ok(false)` 静默丢帧
// 2. 若 `need_keyframe`,校验此帧必须是 IDRNAL 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);
@@ -951,16 +696,10 @@ 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 => {
@@ -975,9 +714,6 @@ 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}"))?;
@@ -987,15 +723,11 @@ 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.
@@ -1016,9 +748,6 @@ 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.1loopback 调试用)。
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()?;
@@ -1032,12 +761,6 @@ fn local_ip() -> Option<String> {
})
}
// 检测 H.264 NALU 流中是否含 IDR sliceNAL type=5)。两种起始码:
// - 4 字节 `00 00 00 01`AVCC boundary,主流)
// - 3 字节 `00 00 01` Annex-B inline,少见)
// NAL header 低 5 位 = type5 = 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() {
-78
View File
@@ -1,77 +1,19 @@
//! 集成测试:通过 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()` 检查子进程退出码是否为 0Unix 上即 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'"
@@ -95,11 +37,6 @@ 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())
@@ -107,13 +44,8 @@ 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")
@@ -122,11 +54,8 @@ 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")
@@ -141,13 +70,6 @@ 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() {