Compare commits

..
Author SHA1 Message Date
dailz 4cf95ac521 Merge PR #27: fix CI — proxy + gitea.com mirrors + missing apt packages
CI / Build + Clippy + Test (push) Successful in 7m50s
CI / Security audit (RUSTSEC) (push) Successful in 1m28s
CI green for the first time (run 28). Fixes: gitea.com action mirrors, rustup install script, job-level proxy env, apt proxy config, drop --error-on=any, add libavdevice-dev.
2026-07-14 21:51:50 +08:00
dailz 027e7e02e0 ci: add missing libavdevice-dev (ffmpeg-sys-next requires it)
CI / Build + Clippy + Test (pull_request) Successful in 6m49s
CI / Security audit (RUSTSEC) (pull_request) Successful in 1m22s
CI progressed to cargo build for the first time, revealing that
libavdevice.pc was missing. The original yaml never installed
libavdevice-dev — this was never caught because CI always failed
at actions/checkout before reaching compilation.
2026-07-14 20:26:46 +08:00
dailz ebff43d9d6 ci: drop --error-on=any from apt-get update (microsoft repo unreachable)
CI / Build + Clippy + Test (pull_request) Failing after 5m26s
CI / Security audit (RUSTSEC) (pull_request) Successful in 1m19s
packages.microsoft.com fails through proxy after 5 retries. --error-on=any
causes the whole update to fail. Drop it so apt skips the microsoft repo
and continues with Ubuntu archives (which work fine via proxy).
2026-07-14 20:20:42 +08:00
dailz c000a2238d ci: configure apt proxy (apt doesn't respect HTTP_PROXY env var)
CI / Build + Clippy + Test (pull_request) Failing after 1m13s
CI / Security audit (RUSTSEC) (pull_request) Successful in 1m17s
apt-get ignores HTTP_PROXY/HTTPS_PROXY env vars and tries to connect
directly to archive.ubuntu.com. Clash DNS returns fake-ip (198.18.x.x)
which only works through TUN (disabled). Fix: write apt proxy config to
/etc/apt/apt.conf.d/99proxy before apt-get update.
2026-07-14 20:13:52 +08:00
dailz 17c01a040a ci: set proxy env at job level (container.env not supported by act_runner)
CI / Build + Clippy + Test (pull_request) Failing after 1m13s
CI / Security audit (RUSTSEC) (pull_request) Successful in 1m18s
act_runner does not support container.env in config.yaml — job containers
get NO proxy env vars. Set HTTP_PROXY/HTTPS_PROXY/NO_PROXY directly in
the workflow's job-level env block.

NO_PROXY includes gitea.com (actions checkout goes direct, faster) and
gitea.dailz.cn (self-hosted Gitea, internal).
2026-07-14 20:08:55 +08:00
dailz e5317bf4c8 ci: remove unreachable domestic mirrors, use official sources directly
CI / Build + Clippy + Test (pull_request) Failing after 2m17s
CI / Security audit (RUSTSEC) (pull_request) Failing after 2m15s
Network testing revealed:
- static.rust-lang.org: direct 200/0.24s (fast, no proxy needed)
- sh.rustup.rs: direct 200/0.11s (fast)
- gitea.com: direct 303/0.68s (fast)
- mirrors.ustc.edu.cn: UNREACHABLE (5s timeout)
- rsproxy.cn: UNREACHABLE (5s timeout)

The machine has good direct internet access to official Rust/crates servers.
Domestic mirrors are the ones that are blocked. Remove all mirror config and
use defaults.
2026-07-14 19:53:27 +08:00
dailz 56feb64b10 ci: use USTC mirror for rustup (rsproxy.cn 404 on dist path)
CI / Security audit (RUSTSEC) (pull_request) Failing after 2m15s
CI / Build + Clippy + Test (pull_request) Failing after 31m46s
rsproxy.cn/rustup returns 404 for /dist/x86_64-unknown-linux-gnu/
rustup-init — different path structure from static.rust-lang.org.
Switch to mirrors.ustc.edu.cn/rust-static which is a full mirror with
identical path structure.
2026-07-14 14:43:32 +08:00
dailz 3d00625518 ci: use rsproxy.cn for rustup component downloads
CI / Build + Clippy + Test (pull_request) Failing after 5s
CI / Security audit (RUSTSEC) (pull_request) Failing after 14m4s
rustup components (~60MB) from static.rust-lang.org are slow even
through proxy. Switch to rsproxy.cn/rustup mirror (domestic, direct
connection via NO_PROXY).
2026-07-14 14:39:40 +08:00
dailz 985c8f9cd2 ci: trigger rerun after config.yaml container.env proxy
CI / Security audit (RUSTSEC) (pull_request) Failing after 2s
CI / Build + Clippy + Test (pull_request) Failing after 28m48s
2026-07-14 14:25:10 +08:00
dailz 586b043267 ci: trigger rerun after proxy configuration
CI / Security audit (RUSTSEC) (pull_request) Failing after 2s
CI / Build + Clippy + Test (pull_request) Failing after 20m43s
2026-07-14 14:18:14 +08:00
dailz b6e4ed1956 ci: install rustup via official script (not pre-installed in image)
CI / Security audit (RUSTSEC) (pull_request) Failing after 3s
CI / Build + Clippy + Test (pull_request) Failing after 44m45s
The catthehacker/ubuntu:act-latest image does not have rustup in PATH
('rustup: command not found', exit 127). Replace the direct rustup
invocation with the official install script from sh.rustup.rs, which
installs rustup + the stable toolchain in one step.

Also adds ~/.cargo/bin to GITHUB_PATH so subsequent steps (cargo clippy,
cargo build, cargo test, cargo audit) can find cargo/rustc.
2026-07-14 13:44:11 +08:00
dailz 93c331f11e ci: remove actions/cache step (gitea.com clone hangs 12+ min)
CI / Build + Clippy + Test (pull_request) Failing after 7s
CI / Security audit (RUSTSEC) (pull_request) Failing after 3s
The actions/cache@v4 clone from gitea.com has been hanging for 12+
minutes in run 17. The actions/checkout clone took 2.5 min (slow but
completed), but actions/cache is stuck indefinitely.

Removing the cache step entirely. Trade-off: CI recompiles from scratch
each run (slower), but actually progresses past the action-clone phase.
Can re-add once gitea.com access is faster or actions are pre-cached
on the runner.
2026-07-14 13:38:48 +08:00
dailz cbe410534e ci: add rsproxy.cn cargo mirror for crates.io index access
CI / Build + Clippy + Test (pull_request) Failing after 14m11s
CI / Security audit (RUSTSEC) (pull_request) Failing after 4s
The act_runner network also blocks crates.io index access (both sparse
and git protocols). Previous workaround used git protocol to github.com,
which is also blocked. Replace with rsproxy.cn sparse mirror, accessible
from China networks.

Added to both build-test and audit jobs. Config is written to
~/.cargo/config.toml at runtime (CI-only; does not affect local dev).
2026-07-14 13:17:17 +08:00
dailz a20b2ad3c6 ci: switch to gitea.com action mirrors + rustup inline
CI / Security audit (RUSTSEC) (pull_request) Has been cancelled
CI / Build + Clippy + Test (pull_request) Has been cancelled
The self-hosted act_runner cannot reach github.com (network timeout
on actions/checkout clone). Replace:

- actions/checkout@v4 -> https://gitea.com/actions/checkout@v4 (3 sites)
- actions/cache@v4    -> https://gitea.com/actions/cache@v4
- dtolnay/rust-toolchain@stable -> rustup toolchain install (inline run)

gitea.com maintains official mirrors of the actions/* org. dtolnay's
rust-toolchain is third-party (no gitea.com mirror), so replaced with
a direct rustup invocation — the act_runner ubuntu image has rustup
pre-installed.

This unblocks CI which has been red since the original PR #26 was
opened 6 weeks ago. No code changes.
2026-07-14 13:10:21 +08:00
dailz 5902df63c2 Merge PR #26: decompose oversized modules into directory form
CI / Security audit (RUSTSEC) (push) Has been cancelled
CI / Build + Clippy + Test (push) Has been cancelled
Two stacked refactors merged as one PR:

Part 1 (June 2026, original scope): avhw module split + cargo-audit fixes
Part 2 (July 2026): file-level decomposition of state / cap_portal /
state_portal / webrtc + bench binary cleanup

Verification: 82 tests pass, clippy clean, fmt clean, all 3 binaries
smoke-tested. See PR #26 description for full details.

Pre-refactor baseline tag: 75ad4bb (covers Part 2 only).
2026-07-14 11:44:56 +08:00
dailz e49339bdab docs(agents): update module paths after directory-form refactor
CI / Security audit (RUSTSEC) (pull_request) Has been cancelled
CI / Build + Clippy + Test (pull_request) Has been cancelled
Update the 'Runtime architecture' section to reflect that state.rs /
cap_portal.rs / state_portal.rs / webrtc.rs are now parent modules of
directory trees:

- src/state.rs -> src/state/mod.rs (+ src/state/dispatch/ for the 13
  Wayland Dispatch impls)
- src/state_portal.rs still exists; helpers split into
  src/state_portal/{bitrate,threads}.rs
- src/cap_portal.rs holds the struct; setup/token_fs/pipewire_thread
  split into src/cap_portal/
- src/webrtc.rs gains src/webrtc/html_page.rs sibling

No content changes beyond the path references; the rest of AGENTS.md
remains accurate.
2026-07-14 10:32:43 +08:00
dailz 1d1b5db3c2 refactor(bin): convert vaapi_import_bench + sw_encode_bench to directory form
Step 5 + 6: split two bench binaries into directory form with sibling
helper modules. Cargo auto-discovers src/bin/<name>/main.rs as binary
<name>; no Cargo.toml change needed.

vaapi_import_bench (973 LOC) -> 6 files:
- main.rs           main() + mod declarations
- stats.rs          BenchArgs + PipelineMode + FrameStats + impl
- software.rs       SoftwareEncoder + SwsContext (with Drop) + create_*
                    + encode_yuv_frame + finish_encoder
- pipeline_cpu.rs   run_cpu_pipeline
- pipeline_gpu.rs   import_frame + build_gpu_filter_graph + run_gpu_pipeline
- util.rs           output_for_mode + print_detailed_results + print_comparison

sw_encode_bench (547 LOC) -> 2 files:
- main.rs           main() + mod declarations (main is ~480 LOC and stays
                    intact per Oracle/Momis risk note on function
                    decomposition)
- stats.rs          BenchArgs + FrameStats + impl + pix_fmt helper

Both main.rs files use #[path = "../common/mod.rs"] mod common; to keep
sharing src/bin/common/mod.rs (path adjusted for the new directory depth).

DEVATION NOTE on visibility:
The original single-file binaries accessed struct fields across what
became module boundaries (70+ accesses, e.g. encoder.yuv_frame in
run_cpu_pipeline, sws_ctx.0 in run_gpu_pipeline, stats.frames_encoded
in main, stats.mmap_us in main). Rule 2 forbids widening visibility on
struct fields. After 2 build attempts confirmed there is no way to
perform the specified split without widening, the minimum necessary
pub(crate) was applied to:
  - vaapi_import_bench/stats.rs: BenchArgs fields, PipelineMode (type
    only), FrameStats fields, FrameStats::{avg_ms, avg_total_ms,
    achieved_fps, theoretical_fps}
  - vaapi_import_bench/software.rs: SoftwareEncoder fields (enc_video,
    octx, yuv_frame, codec_name), SwsContext.0, all four functions
  - vaapi_import_bench/pipeline_*.rs: run_cpu_pipeline, run_gpu_pipeline,
    import_frame (build_gpu_filter_graph kept private)
  - vaapi_import_bench/util.rs: output_for_mode, print_detailed_results,
    print_comparison
  - sw_encode_bench/stats.rs: BenchArgs fields, FrameStats fields,
    FrameStats::avg_ms, pix_fmt

No pub (truly public) was used anywhere. All widening is to pub(crate),
keeping these symbols private outside the binary crate.

Verification (all green):
- cargo build --bins / cargo build --release --bins
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
- --help smoke test on both binaries
2026-07-13 19:33:07 +08:00
dailz a17f809d9f refactor(state): split 1598-LOC state.rs into directory + extract 13 Dispatch impls
Step 4a + 4b combined.

- src/state.rs (1594 LOC) -> src/state/mod.rs (struct + inherent methods +
  types + helpers; 999 LOC) + src/state/dispatch/ (13 Dispatch impls across
  6 files: registry.rs / wl_output.rs / dmabuf.rs / screencopy.rs /
  output_mgr.rs / buffer.rs).

Per Oracle audit: orphan rule permits Dispatch impls in submodules because
Dispatch is a foreign trait on local type State<S>. All State fields the
impls touch are already pub/pub(crate) — no visibility widening needed.

Verification (all green):
- cargo build / cargo build --release
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
- cargo check --bin vaapi_import_bench --bin sw_encode_bench
2026-07-13 19:09:39 +08:00
dailz bcfbd93f5a refactor(state_portal): extract bitrate helpers + thread loops to submodules
Step 3: split state_portal.rs (1241 -> 829 LOC) into three modules.

- src/state_portal.rs (829 LOC): keeps StatePortal struct + impl (with
  poll_and_encode / handle_pw_frame / shutdown / etc.) + Drop + PortalStage
  enum + DRM helpers + DRM tests. Per Oracle/Explore audit, all 21
  StatePortal fields are private and poll_and_encode interleaves three
  channel reads with state-machine transitions; moving it would force
  pub(crate) on every field, so it stays in mod.rs.
- src/state_portal/bitrate.rs (144 LOC): RESOLUTION_TIERS + 4 pure fns
  (resolution_bitrate_bps / webrtc_startup_bitrate_bps / select_resolution /
  next_upscale_tier) + 10 tests that exercise them. Pure fns with no
  StatePortal field access — the cleanest possible extract.
- src/state_portal/threads.rs (287 LOC): the 5 thread-related types
  (EncodeThreadTiming / EncodeThread / WebrtcThread / WebRtcThreadConfig /
  WebRtcThreadChannels) + the two free fns encode_thread_loop /
  webrtc_thread_loop + the 3 channel-semantics regression tests
  (try_send_* / shutdown_rx_drop_*) that document crossbeam invariants
  the shutdown logic relies on. Struct fields widened to pub(super) so
  StatePortal in mod.rs can construct and join them.

Test preservation:
- state_portal test count: 17 (mod.rs=4 drm tests + bitrate.rs=10 +
  threads.rs=3 channel tests) — matches baseline.

Verification (all green):
- cargo build / cargo build --release
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
2026-07-13 16:35:44 +08:00
dailz 60d6e7f046 refactor(cap_portal): split 1313-LOC file into 7 submodules
Step 2b.1: structural split (no function decomposition — that's 2b.2).

src/cap_portal.rs (1313 -> 176 LOC) now contains only the CapPortal struct,
its constructor (new), accessors (frame_receiver/event_receiver/dropped_count/
capture_queue_depth), and Drop impl. Six new sibling submodules under
src/cap_portal/:

- types.rs       (79 LOC)  timeout constants, PortalPhaseTimeout enum,
                            pub types PwDmaBufFrame / PortalFormatInfo /
                            PwCtrlEvent
- logging.rs     (18 LOC)  log_portal_phase_timeout helper
- fourcc.rs      (73 LOC)  spa_to_drm_fourcc + its 2 tests
- token_fs.rs    (362 LOC) 8 restore-token fs helpers + 11 security tests
- setup.rs       (192 LOC) impl CapPortal { setup_portal + _setup_portal_inner }
                            (associated fns; no self access — clean extract)
- pipewire_thread.rs (446 LOC) PwThreadCtx (now private to this file),
                            pipewire_thread body (verbatim, 18 SAFETY
                            comments preserved), new spawn_pipewire_thread
                            helper that constructs PwThreadCtx internally
                            and returns JoinHandle. CapPortal::new now calls
                            pipewire_thread::spawn_pipewire_thread(...) instead
                            of inlining the PwThreadCtx construction.

Oracle audit points honored:
- PwThreadCtx moved as a whole; Drop in mod.rs and pipewire_thread in
  pipewire_thread.rs share zero state through it (PwThreadCtx consumed
  by-value inside pipewire_thread; spawn helper owns the construction).
- All // SAFETY comments travel verbatim with their unsafe blocks.
- The 18 SAFETY comments in pipewire_thread are intact; clippy
  undocumented_unsafe_blocks=deny still passes.

API stability:
- pub use types::{PwCtrlEvent, PwDmaBufFrame} preserves the existing
  wl_webrtc::cap_portal::{PwCtrlEvent, PwDmaBufFrame} paths used by
  both bench binaries (verified by cargo check --bin vaapi_import_bench
  --bin sw_encode_bench).
- PortalFormatInfo was nominally pub in the original file but never
  referenced outside cap_portal; kept pub in types.rs (for cross-
  submodule access) but not re-exported from cap_portal.rs, so the
  accidental over-exposure is now scoped back.

Verification (all green):
- cargo build / cargo build --release
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cap_portal test count: 13 (fourcc=2 + token_fs=11) — matches baseline
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
- cargo check --bin vaapi_import_bench --bin sw_encode_bench
2026-07-13 16:20:56 +08:00
dailz 51f6649159 refactor(bin): dedupe av_err_to_string + receive_first_frame + drain_encoder via shared src/bin/common/mod.rs
Step 2a: eliminate cross-bench duplication identified by the Explore audit.

Changes:
- src/avhw/util.rs: av_err_to_string promoted pub(crate) -> pub (the only
  change to src/avhw/ in this whole refactor plan).
- src/avhw/mod.rs: re-export av_err_to_string; #[allow(unused_imports)]
  silences rustc's per-bin unused-import false positive (the pub use is
  consumed by the bench bins, not by the main bin).
- src/bin/common/mod.rs (new): shared receive_first_frame + drain_encoder.
  These were byte-identical between the two bench binaries modulo a type-path
  alias (ff::codec::encoder::video::Video vs ff::encoder::video::Video) and
  SAFETY-comment line wrapping. Both binaries now wire it via
  #[path = "common/mod.rs"] mod common;.
- src/bin/vaapi_import_bench.rs: 1039 -> 947 LOC (av_err_to_string,
  receive_first_frame, drain_encoder all removed; 3 call sites updated).
- src/bin/sw_encode_bench.rs: 614 -> 545 LOC (receive_first_frame,
  drain_encoder removed; 3 call sites updated).
- use ffmpeg_next::packet::Mut moved to common/mod.rs (was needed only for
  pkt.as_mut_ptr() inside drain_encoder).

Verification (all green):
- cargo build --bins / cargo build --release
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
- Test counts unchanged from baseline
2026-07-13 16:04:30 +08:00
dailz bc405c6d16 refactor(webrtc): extract HTML_PAGE const to src/webrtc/html_page.rs
Step 1 of file-level refactor: prove the file->directory pattern with the
cleanest possible extraction.

- src/webrtc.rs: 913 -> 741 LOC
- New src/webrtc/html_page.rs: 170-line HTML test page as pub(super) const
- Parent module re-exports via `mod html_page; use html_page::HTML_PAGE;`
  so all references in handle_signaling stay unchanged.

Verification (all green):
- cargo build / cargo build --release
- cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged)
- cargo clippy --all-targets -- -D warnings
- cargo fmt --check
- cargo check --bin vaapi_import_bench --bin sw_encode_bench
- Test count in webrtc.rs: 18 (unchanged from baseline)

Oracle audit note: HTML_PAGE had a single use site (handle_signaling L257-258)
and zero #[cfg(test)] references, so the extraction is provably behavior-
preserving.
2026-07-13 15:47:42 +08:00
dailz 75ad4bba78 style: apply rustfmt to establish clean baseline before refactor
Pre-refactor baseline state:
- 79 lib tests + 3 integration tests pass (1 integration test #[ignore])
- cargo clippy --all-targets -- -D warnings clean
- cargo build --release clean

No semantic changes; only rustfmt drift correction across 6 files.
2026-07-13 15:40:54 +08:00
dailz fed8c2dcfd docs(avhw): fix misleading Send soundness reasoning
CI / Build + Clippy + Test (pull_request) Failing after 30s
CI / Security audit (RUSTSEC) (pull_request) Failing after 30s
Oracle audit of all 5 `unsafe impl Send` in src/avhw/ found soundness
intact but reasoning wrong in 3 of 5:

- AvHwDevCtx: claimed '&mut self ensures exclusive access' — false,
  ref_clone() hands raw pointers to other threads / FFmpeg-internal
  codec workers. Real basis is AVBufferRef atomic_uint refcount +
  libva VADisplay thread safety.
- AvHwFrameCtx: claimed 'send/receive pattern is thread-safe' —
  misdirection. Real basis is AVBufferPool atomic get/put.
- EncState: claimed 'raw pointers not shared across threads' — false
  when FFmpeg frame/slice threading is enabled. Real basis is the
  hw device/frames contexts being designed for such sharing.

SwEncState and SwEncEncode comments were acceptable; improved for
clarity (note that contained FFmpeg handles are non-thread-safe but
Send-sound under exclusive access, and that crossbeam/Arc fields are
already Send by design).

Added module-level convention doc to src/avhw/mod.rs centralizing
the C-API-level justification rule and explicitly calling out the
'&mut self as Send basis' anti-pattern so future contributors don't
repeat the category error.

Fixed AGENTS.md:
- Stale claim that Cargo.toml 'only warns' on undocumented_unsafe_blocks
  (it's been 'deny' for a while)
- Stale path src/avhw.rs → src/avhw/ (split in d53e881)
- Stale 'avoid moving wrappers across threads' guidance — Send is sound,
  the audit just confirmed why

No code behavior change. Verified: cargo build --release,
cargo clippy --release --all-targets (0 warnings),
cargo test --release (79 unit + 3 integration, 1 ignored).
2026-07-09 15:20:16 +08:00
dailz 9a7b745a0e refactor(state): make output probe readiness transform-only
CI / Build + Clippy + Test (pull_request) Failing after 11s
CI / Security audit (RUSTSEC) (pull_request) Failing after 31s
Drops PartialOutputInfo.physical_size and .logical_position. After the
warning cleanup in 6332472 these fields were probe-time-only gates with
no downstream consumer (encoder reads geometry from the dmabuf frame
itself, not from wl_output description).

Readiness simplification (try_finalize_output):
- xdg-output path (Sway/Hyprland): done_count >= 2 + name + transform
- wlr-output-management path (niri): done_count >= 1 + wlr_manager_done + transform

Safe because Wayland protocol guarantees Geometry/Mode/Position events
fire before Done, so done_count >= N implies the prior events arrived.
done_count is the real signal; the per-field .is_none() checks were
redundant belt-and-suspenders.

Cascading cleanup of writers that only fed the deleted fields:
- WlOutput::Geometry handler — drop physical_size write, keep transform
- XdgOutputEvent::LogicalPosition arm — deleted
- WlrHeadEvent::Position arm — deleted (was the only reader of
  wlr_head_proxy_to_name; both maps now write-only markers)
- WlrHeadInfo.position field — deleted; struct becomes empty marker

Behavior change risk: probe may finalize slightly earlier in cases where
a compositor fires Done before physical_size/logical_position events
(protocol violation, but possible). Verified with cargo test --release
(79 unit + 3 integration, 1 ignored). Hardware/Wayland-session test
deferred to user.

Net: -48 lines.
2026-07-09 14:48:10 +08:00
dailz 633247201c refactor: clear remaining clippy dead-code and cast warnings
CI / Build + Clippy + Test (pull_request) Failing after 38m47s
CI / Security audit (RUSTSEC) (pull_request) Failing after 1m30s
Brings `cargo clippy --release --all-targets` and `cargo build --release`
to zero warnings. Three categories:

Truly dead code (deleted):
- OutputInfo.physical_size / .logical_position — copied from PartialOutputInfo
  at construction but never read on OutputInfo; PartialOutputInfo still uses
  them as probe-completion gates
- EncConstructionStage::Streaming.output_info — stored at ->Streaming
  transition, all 9 match arms discard via `..` or `output_info: _`
- State.starting_timestamp — vestigial Phase 1 stub; PTS normalization lives
  in EncState / SwEncState instead (commit 079611a)
- cap_portal::fourcc() const fn — superseded by drm_fourcc::DrmFourcc
- PwThreadCtx.fps — destructured as `fps: _`, never consumed

Lifetime/ownership invariants (kept with #[allow(dead_code)] + reason):
- CapPortal.rt — ashpd caches a zbus::Connection in a process-global
  OnceCell; runtime must outlive CapPortal or the connection hangs
- EncState.hw_device_ctx — root AVHWDeviceContext; consumers hold their
  own ref_clone() but the root ref must stay alive for ownership

False-positive warning (annotated):
- state_portal use AsRawFd — required at FFI boundary but rustc mis-attributes
  the call to OwnedFd's inherent method; E0599 if removed

Cosmetic:
- drop 5 redundant `as *const i32` casts on linesize.as_ptr() in
  avhw/encode.rs and bin/vaapi_import_bench.rs
2026-07-09 14:36:10 +08:00
dailz 9829a1728b ci(gitea): retry apt setup
CI / Build + Clippy + Test (pull_request) Failing after 31s
CI / Security audit (RUSTSEC) (pull_request) Failing after 3h10m23s
2026-07-03 13:55:46 +08:00
dailz eca8032bcc ci(gitea): use cargo git registry index
CI / Build + Clippy + Test (pull_request) Failing after 55s
CI / Security audit (RUSTSEC) (pull_request) Failing after 1h57m35s
2026-07-03 13:39:07 +08:00
dailzandSisyphus b96b99fc9c ci(gitea): install libavfilter dev package
CI / Build + Clippy + Test (pull_request) Failing after 2m47s
CI / Security audit (RUSTSEC) (pull_request) Failing after 16m56s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 13:10:15 +08:00
dailzandSisyphus 823dd53745 chore(deps): address cargo audit findings
CI / Build + Clippy + Test (pull_request) Failing after 6m5s
CI / Security audit (RUSTSEC) (pull_request) Successful in 10m32s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 11:23:48 +08:00
dailzandSisyphus d53e881496 refactor(avhw): split encoder module
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-03 10:57:05 +08:00
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
68 changed files with 7840 additions and 10624 deletions
+14
View File
@@ -0,0 +1,14 @@
# quick-xml is pulled in only through wayland-scanner's build-time Wayland XML
# code generation path:
#
# wayland-scanner v0.31.10 -> quick-xml v0.39.x
#
# The current wayland-scanner release requires quick-xml ^0.39, so it cannot
# accept the fixed quick-xml >=0.41.0 line yet. This project does not parse
# attacker-controlled XML at runtime through quick-xml. Remove these ignores as
# soon as wayland-scanner or the wayland-* crates release a compatible fix.
[advisories]
ignore = [
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
+101
View File
@@ -0,0 +1,101 @@
# Continuous integration for wl-webrtc.
#
# Triggered on push/PR to master. Runs the full quality gate that the recent
# audit baselined:
# - clippy: 0 errors (undocumented_unsafe_blocks is deny in Cargo.toml; other
# warnings are advisory for now).
# - build --release: integration tests in tests/integration_test.rs shell out
# to target/release/wl-webrtc, so the release binary must exist before tests
# run.
# - test --release: 79 unit + 3 integration; the 1 hardware-ignored test
# stays ignored in CI (needs Wayland session + VAAPI GPU).
# - cargo audit: separate job so a RUSTSEC advisory fails the build without
# conflating with compile errors.
#
# The job pins Linux only — the project is Wayland/VAAPI-specific and has no
# macOS/Windows story. Oracle audit 2026-06-28 P2 plan.
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
CARGO_TERM_COLOR: always
jobs:
build-test:
name: Build + Clippy + Test
runs-on: ubuntu-latest
env:
HTTP_PROXY: http://172.17.0.1:7897
HTTPS_PROXY: http://172.17.0.1:7897
NO_PROXY: localhost,127.0.0.1,server,db,gitea.dailz.cn,gitea.com
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install Rust toolchain (stable)
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install system dependencies
run: |
echo 'Acquire::http::Proxy "http://172.17.0.1:7897";' | sudo tee /etc/apt/apt.conf.d/99proxy
echo 'Acquire::https::Proxy "http://172.17.0.1:7897";' | sudo tee -a /etc/apt/apt.conf.d/99proxy
APT_OPTS=(-o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
sudo apt-get "${APT_OPTS[@]}" update
# NOTE: do NOT use --no-install-recommends for libclang-dev — on
# Debian Bookworm (the node:20-bookworm image used by act_runner
# under ubuntu-latest) the recommended toolchain bits are needed
# by bindgen. The pkg-config based deps (pipewire/wayland/etc)
# are also more reliable without the flag.
sudo apt-get "${APT_OPTS[@]}" install -y \
ffmpeg \
libavcodec-dev libavdevice-dev libavfilter-dev libavformat-dev libavutil-dev libswscale-dev libva-dev \
libwayland-dev wayland-protocols \
libdrm-dev \
libpipewire-0.3-dev \
libclang-dev clang
- name: Resolve LIBCLANG_PATH
run: |
set -e
LIBCL=$(find /usr -name 'libclang*.so*' 2>/dev/null | head -1)
test -n "$LIBCL" || { echo "ERROR: no libclang shared lib found under /usr"; exit 1; }
LIBDIR=$(dirname "$LIBCL")
echo "Resolved LIBCLANG_PATH=$LIBDIR (found $LIBCL)"
echo "LIBCLANG_PATH=$LIBDIR" >> "$GITHUB_ENV"
- name: Clippy (release, all targets)
run: cargo clippy --release --all-targets
- name: Build release (required before tests)
run: cargo build --release --all-targets
- name: Test (release)
run: cargo test --release
audit:
name: Security audit (RUSTSEC)
runs-on: ubuntu-latest
env:
HTTP_PROXY: http://172.17.0.1:7897
HTTPS_PROXY: http://172.17.0.1:7897
NO_PROXY: localhost,127.0.0.1,server,db,gitea.dailz.cn,gitea.com
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install Rust toolchain (stable)
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit dependencies
run: cargo audit --deny warnings
+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
+4 -4
View File
@@ -9,7 +9,7 @@
- Native prerequisites are FFmpeg 6+ dev libs with VAAPI, Wayland protocols/libs, libdrm, PipeWire, and libclang. `shell.nix` provides FFmpeg/Wayland/libdrm/Mesa/libva/clang and `LIBCLANG_PATH`, but does not currently list PipeWire.
- Normal build: `cargo build`. Release binary required by README and integration tests: `cargo build --release`.
- `Cargo.toml` only warns on `clippy::undocumented_unsafe_blocks`; do not assume a broader clippy policy exists unless you add one.
- `Cargo.toml` sets `clippy::undocumented_unsafe_blocks = "deny"`; every `unsafe` block and `unsafe impl` must carry a `// SAFETY:` comment or the build fails. For `unsafe impl Send` on FFmpeg wrappers, see the convention in `src/avhw/mod.rs` — justification must be at the C-API level (atomic refcounts, libva `VADisplay` thread safety), not Rust borrow level.
## Testing and verification
@@ -24,13 +24,13 @@
- `src/main.rs` is the real entrypoint: parse `Args`, initialize tracing from `RUST_LOG` or `-v`, reject non-H.264, require `--output` or `--port`, detect backend, then run one of two loops.
- Backend detection in `src/backend_detect.rs`: explicit `--backend portal|screencopy` wins; otherwise wlr-screencopy is preferred when the Wayland global `zwlr_screencopy_manager_v1` exists, else Portal/PipeWire is used if D-Bus ScreenCast is available.
- Do not use `ashpd` for backend availability checks; `backend_detect.rs` intentionally uses raw `zbus` because `ashpd` caches a `zbus::Connection` in a global and can hang after its owning Tokio runtime is dropped.
- `src/state.rs` drives the wlroots path using a mio Wayland fd loop and `State<CapWlrScreencopy>`; `src/state_portal.rs` drives the Portal/PipeWire path through `CapPortal` frame channels.
- `src/webrtc.rs` is a small embedded HTTP/WebRTC signaling server using `str0m`; `--port 0` means file-output mode, `--port > 0` enables WebRTC mode.
- `src/state/mod.rs` drives the wlroots path using a mio Wayland fd loop and `State<CapWlrScreencopy>` (Wayland `Dispatch` impls live under `src/state/dispatch/`); `src/state_portal.rs` drives the Portal/PipeWire path through `CapPortal` frame channels (bitrate helpers and encode/webrtc thread loops are split into `src/state_portal/{bitrate,threads}.rs`). `src/cap_portal.rs` holds the `CapPortal` struct itself; its setup logic, token filesystem helpers, and PipeWire capture thread live under `src/cap_portal/`.
- `src/webrtc.rs` is a small embedded HTTP/WebRTC signaling server using `str0m`; the embedded HTML test page is in `src/webrtc/html_page.rs`. `--port 0` means file-output mode, `--port > 0` enables WebRTC mode.
## Unsafe and FFI work
- FFmpeg/VAAPI/PipeWire code relies on raw FFI and many `unsafe` blocks. Preserve nearby `// SAFETY:` explanations and add one for any new unsafe block.
- `src/avhw.rs` owns FFmpeg `AVBufferRef`/frame contexts and has explicit `unsafe impl Send`; avoid moving those wrappers across threads without rechecking the documented exclusivity assumptions.
- `src/avhw/` (split from the former `src/avhw.rs` in commit d53e881) owns FFmpeg `AVBufferRef` / frame / codec contexts. Five types (`AvHwDevCtx`, `AvHwFrameCtx`, `EncState`, `SwEncState`, `SwEncEncode`) carry `unsafe impl Send`; soundness was Oracle-audited on 2026-07-09 against FFmpeg/libva threading semantics. Moving them across threads is sound *because the C APIs use atomic refcounts*, not because of any Rust-side exclusivity — see `src/avhw/mod.rs` for the full convention.
- `CapPortal` stores the portal restore token under the user cache directory (`wl-webrtc/portal-restore-token`); use `--no-persist` when manually testing fresh authorization behavior.
## Useful manual commands
Generated
+2 -2
View File
@@ -94,9 +94,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arrayvec"
+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,
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
}
-2726
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use super::util::ff_err;
pub struct AvHwDevCtx {
ptr: *mut ffi::AVBufferRef,
}
// SAFETY: AVBufferRef's refcount is atomic (atomic_uint in libavutil/buffer.c);
// av_buffer_ref / av_buffer_unref are safe to call concurrently from different
// threads on the same buffer. The underlying AVHWDeviceContext (VAAPI VADisplay)
// is designed by FFmpeg/libva to be shared across codec and filter contexts,
// including across FFmpeg-internal codec threads. Raw refs returned by
// ref_clone() may outlive this wrapper and be consumed by other threads; this
// is the intended usage pattern and is sound because refcount management is
// atomic. The &mut self on Rust methods is an API convenience, not the basis
// for soundness.
unsafe impl Send for AvHwDevCtx {}
impl AvHwDevCtx {
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
// SAFETY: device_cstr is a valid C string for the duration of the call;
// p is a valid out-pointer that FFmpeg initializes on success.
let ret = unsafe {
ffi::av_hwdevice_ctx_create(
&mut p,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
device_cstr.as_ptr(),
ptr::null_mut(),
0,
)
};
if ret < 0 {
bail!(
"Failed to create VAAPI device context from {}: {}",
drm_device.display(),
ff_err(ret)
);
}
Ok(Self { ptr: p })
}
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
impl Drop for AvHwDevCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
pub struct AvHwFrameCtx {
ptr: *mut ffi::AVBufferRef,
}
// SAFETY: AVBufferRef's refcount is atomic (see AvHwDevCtx). The underlying
// AVHWFramesContext allocates from an AVBufferPool, whose get/put operations
// are atomic and thread-safe. av_hwframe_get_buffer and av_hwframe_transfer_data
// are safe to call concurrently on distinct AVFrames. Cloned refs are typically
// attached to AVCodecContext.hw_frames_ctx and accessed by FFmpeg-internal codec
// threads; this is the designed usage. The &mut self on Rust methods is not the
// basis for soundness.
unsafe impl Send for AvHwFrameCtx {}
impl AvHwFrameCtx {
fn new_inner(hw_dev: &AvHwDevCtx, w: u32, h: u32, sw_fmt: ff::format::Pixel) -> Result<Self> {
// SAFETY: hw_dev is a live AVHWDeviceContext; FFmpeg returns either a valid
// frames context ref or null (checked below).
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
if p.is_null() {
bail!("av_hwframe_ctx_alloc returned null");
}
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
// Its .data field points to an AVHWFramesContext that we must configure.
unsafe {
let fc = (*p).data as *mut ffi::AVHWFramesContext;
(*fc).format = ff::format::Pixel::VAAPI.into();
(*fc).sw_format = sw_fmt.into();
(*fc).width = w as i32;
(*fc).height = h as i32;
(*fc).initial_pool_size = 4;
}
// SAFETY: p is a valid AVHWFramesContext ref configured above and not yet
// transferred or freed.
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
if ret < 0 {
// SAFETY: p is valid but init failed; clean up.
unsafe { ffi::av_buffer_unref(&mut p) };
bail!("av_hwframe_ctx_init failed: {}", ff_err(ret));
}
Ok(Self { ptr: p })
}
pub fn for_capture(
hw_dev: &AvHwDevCtx,
w: u32,
h: u32,
sw_fmt: ff::format::Pixel,
) -> Result<Self> {
Self::new_inner(hw_dev, w, h, sw_fmt)
}
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
impl Drop for AvHwFrameCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::mem;
// AsRawFd is required by `frame.fd.as_raw_fd()` below but rustc emits a false
// "unused_imports" warning because OwnedFd also has an inherent `as_raw_fd`.
// E0599 if removed -> must stay; warning is a known rustc quirk.
use std::os::fd::AsRawFd;
use std::os::raw::c_void;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use crate::cap_portal::PwDmaBufFrame;
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
/// Test whether `drm_device` can import the PipeWire DMA-BUF frame via VAAPI.
pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<()> {
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
let frames =
AvHwFrameCtx::for_capture(&hw_dev, frame.width, frame.height, ff::format::Pixel::BGRA)?;
// SAFETY: frames is a live VAAPI frames context; frame carries valid DMA-BUF metadata.
unsafe { import_dma_buf_to_vaapi(frames.as_ptr(), frame) }?;
Ok(())
}
/// Import a DMA-BUF into a VAAPI hardware frame via zero-copy `av_hwframe_map`.
///
/// # Safety
/// Imports a DMA-BUF frame into a VAAPI hardware frame pool for GPU-side processing.
///
/// Takes the negotiated format/geometry from `frame` (a `PwDmaBufFrame` from
/// PipeWire capture) plus the target `frames_ctx` (VAAPI frame pool from
/// `AvHwFrameCtx`) and returns an `ff::frame::Video` whose data[3] points to
/// the hardware frame.
///
/// # Safety
///
/// - `frames_ctx` must point to an initialized AVHWCramesContext for VAAPI
/// - `frame.fd` must be a valid DMA-BUF file descriptor
pub unsafe fn import_dma_buf_to_vaapi(
frames_ctx: *mut ffi::AVBufferRef,
frame: &PwDmaBufFrame,
) -> Result<ff::frame::Video> {
let duped_fd = libc::dup(frame.fd.as_raw_fd());
if duped_fd < 0 {
bail!("dup(fd) failed: {}", std::io::Error::last_os_error());
}
let mut desc: ffi::AVDRMFrameDescriptor = mem::zeroed();
desc.nb_objects = 1;
desc.objects[0].fd = duped_fd;
desc.objects[0].size = (frame.height as usize) * (frame.stride as usize);
desc.objects[0].format_modifier = frame.modifier;
desc.nb_layers = 1;
desc.layers[0].format = frame.format;
desc.layers[0].nb_planes = 1;
desc.layers[0].planes[0].object_index = 0;
desc.layers[0].planes[0].offset = frame.offset as isize;
desc.layers[0].planes[0].pitch = frame.stride as isize;
let desc_box = Box::new(desc);
let desc_ptr = Box::into_raw(desc_box);
let buf_ref = ffi::av_buffer_create(
desc_ptr as *mut u8,
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
Some(cleanup_drm_descriptor),
ptr::null_mut(),
0,
);
if buf_ref.is_null() {
let desc_box = Box::from_raw(desc_ptr);
libc::close(desc_box.objects[0].fd);
bail!("av_buffer_create returned null for DRM descriptor");
}
let mut src = ff::frame::Video::empty();
{
let sp = src.as_mut_ptr();
(*sp).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
(*sp).width = frame.width as i32;
(*sp).height = frame.height as i32;
(*sp).data[0] = (*buf_ref).data;
(*sp).buf[0] = buf_ref;
}
let mut dst = ff::frame::Video::empty();
// SAFETY: frames_ctx is guaranteed by this unsafe function's contract to be a
// valid initialized VAAPI frames context; we set format/hw_frames_ctx on a
// freshly allocated dst frame.
unsafe {
let dp = dst.as_mut_ptr();
(*dp).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32;
(*dp).hw_frames_ctx = ffi::av_buffer_ref(frames_ctx);
if (*dp).hw_frames_ctx.is_null() {
bail!("av_buffer_ref(frames_ctx) returned null");
}
}
// SAFETY: src and dst are initialized AVFrames; dst has a valid hw_frames_ctx
// ref and av_hwframe_map fills dst from src.
let ret = unsafe {
ffi::av_hwframe_map(
dst.as_mut_ptr(),
src.as_ptr(),
ffi::AV_HWFRAME_MAP_READ as i32,
)
};
if ret < 0 {
bail!("av_hwframe_map failed: {}", ff_err(ret));
}
Ok(dst)
}
unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8) {
let desc = data as *mut ffi::AVDRMFrameDescriptor;
if !desc.is_null() && (*desc).nb_objects > 0 && (*desc).objects[0].fd >= 0 {
libc::close((*desc).objects[0].fd);
}
let _ = Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor);
}
+305
View File
@@ -0,0 +1,305 @@
use std::mem;
use std::ptr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut as _;
use super::encode_output::{self, FrameOutput, PacketOutput};
use super::hash::hash_sampled_y_plane;
use super::{
ff_err, BitrateCommand, CpuNv12Frame, EncodeOutcome, ResolutionChange, SwEncodeTiming,
};
pub struct SwEncEncode {
pub(super) sws_ctx: *mut ffi::SwsContext,
pub(super) enc_video: ff::codec::encoder::video::Video,
pub(super) output: Option<FrameOutput>,
pub(super) yuv_frame: *mut ffi::AVFrame,
pub(super) last_frame_hash: u64,
pub(super) frame_count: u64,
pub(super) starting_timestamp: Option<i64>,
pub(super) frames_written: bool,
pub(super) webrtc_disconnected: bool,
pub(super) webrtc_paused: Option<Arc<AtomicBool>>,
pub(super) bitrate_rx: crossbeam_channel::Receiver<BitrateCommand>,
pub(super) resolution_rx: crossbeam_channel::Receiver<ResolutionChange>,
pub(super) enc_width: u32,
pub(super) enc_height: u32,
pub(super) fps: u32,
pub(super) bitrate: u64,
pub(super) gop_size: u32,
/// Set true when WebRTC requests a keyframe. Forces the next frame to
/// `AV_PICTURE_TYPE_I` and bypasses the dedup hash check. Cleared only
/// after `avcodec_send_frame` accepts the forced frame.
pub(super) force_keyframe_pending: bool,
/// Last per-frame timing snapshot. Reset to `Default` at the start of
/// every `encode_cpu_frame` call (even on early returns) so stale values
/// from a previous frame can never leak out.
pub(super) last_timing: SwEncodeTiming,
/// Capture time of the frame currently being encoded. Saved from the
/// input `CpuNv12Frame` so `drain_encoder` can propagate it into the
/// emitted `EncodedH264Frame` for the frame_age stat (issue #20).
pub(super) last_capture_time: Option<Instant>,
}
/// WebRTC media clock frequency in Hz. Matches RTP clock for video (RFC 3551).
/// Used as encoder time_base denominator for WebRTC mode (1/90000) so that
/// PTS values directly become RTP timestamps with microsecond precision.
/// MP4 mode keeps 1/fps time_base for file output simplicity.
pub const WEBRTC_RTP_CLOCK_HZ: i128 = 90_000;
// SAFETY: SwEncEncode is moved to a single encode thread and accessed only there
// via &mut self. SwsContext, AVFrame, and AVCodecContext are NOT thread-safe for
// concurrent access but are Send-sound under single-thread exclusive use, which
// the encode worker invariant provides. crossbeam Receiver and Arc<AtomicBool>
// are Send by design.
unsafe impl Send for SwEncEncode {}
impl SwEncEncode {
pub fn flush(&mut self) -> Result<()> {
// SAFETY: Sending a null frame flushes the opened software encoder;
// no frame data is dereferenced. enc_video is exclusively borrowed via &mut self.
unsafe {
let ret = ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
if ret < 0 && ret != ffi::AVERROR_EOF {
bail!("software encoder flush send failed: {}", ff_err(ret));
}
}
let start_ts = self.starting_timestamp.unwrap_or(0);
let _ = self.drain_encoder(start_ts)?;
Ok(())
}
pub fn take_timing(&mut self) -> SwEncodeTiming {
mem::take(&mut self.last_timing)
}
pub fn encode_cpu_frame(&mut self, frame: &CpuNv12Frame) -> Result<EncodeOutcome> {
self.last_timing = SwEncodeTiming::default();
// Save capture_time so drain_encoder can propagate it into the
// EncodedH264Frame emitted via the WebRTC channel (issue #20).
self.last_capture_time = Some(frame.capture_time);
if self.webrtc_disconnected {
return Ok(EncodeOutcome::SkippedDisconnected);
}
// Must drain before the stride check: the import thread emits
// ResolutionChange before the new (smaller-stride) frame arrives.
while let Ok(cmd) = self.bitrate_rx.try_recv() {
match cmd {
BitrateCommand::UpdateBitrate { target_bps } => {
// #23 defensive guardrail: clamp to reasonable max even if policy layer
// is bypassed. 50 Mbps is a hard ceiling; primary cap is enforced in
// state_portal.rs webrtc_thread_loop via --max-bitrate flag.
const ENCODER_BITRATE_HARD_CAP: u64 = 50_000_000;
let target_bps = target_bps.min(ENCODER_BITRATE_HARD_CAP);
tracing::info!(target_bps, "updating encoder bitrate from BWE feedback");
self.bitrate = target_bps;
// SAFETY: enc_video is an opened AVCodecContext exclusively owned by &mut self.
unsafe {
let ctx = self.enc_video.as_mut_ptr();
(*ctx).bit_rate = target_bps as i64;
}
}
BitrateCommand::UpdateResolution { .. } => {}
BitrateCommand::ForceKeyframe => {
self.force_keyframe_pending = true;
tracing::debug!("encode thread: ForceKeyframe requested");
}
}
}
let force_this_frame = self.force_keyframe_pending;
while let Ok(change) = self.resolution_rx.try_recv() {
self.recreate_encoder(change.width, change.height)?;
}
if frame.y_stride < self.enc_width as usize || frame.uv_stride < self.enc_width as usize {
bail!("CPU NV12 frame stride is smaller than encoder width");
}
if let Some(ref paused) = self.webrtc_paused {
if paused.load(Ordering::Relaxed) {
return Ok(EncodeOutcome::SkippedPaused);
}
}
let width = self.enc_width as usize;
let height = self.enc_height as usize;
let required_y_len = frame.y_stride * height.saturating_sub(1) + width;
if frame.y_data.len() < required_y_len {
bail!("CPU NV12 frame Y plane is smaller than encoder dimensions");
}
let frame_index = self.frame_count;
self.frame_count = self.frame_count.saturating_add(1);
let current_hash = hash_sampled_y_plane(&frame.y_data, width, height, frame.y_stride);
let force_gop_frame =
self.gop_size > 0 && frame_index.is_multiple_of(u64::from(self.gop_size));
if frame_index > 0
&& !force_gop_frame
&& !force_this_frame
&& current_hash == self.last_frame_hash
{
tracing::debug!(frame_index, "skipping duplicate frame");
self.last_frame_hash = current_hash;
return Ok(EncodeOutcome::SkippedDuplicate);
}
self.last_frame_hash = current_hash;
let sws_start = Instant::now();
// SAFETY: yuv_frame is an owned reusable YUV420P frame at the same dimensions as sw_nv12;
// sws_ctx was created for NV12 -> YUV420P with no resize, so sws_scale only converts format.
unsafe {
let ret = ffi::av_frame_make_writable(self.yuv_frame);
if ret < 0 {
bail!("av_frame_make_writable failed: {}", ff_err(ret));
}
let src_slices = [
frame.y_data.as_ptr(),
frame.uv_data.as_ptr(),
ptr::null(),
ptr::null(),
];
let src_strides = [frame.y_stride as i32, frame.uv_stride as i32, 0, 0];
let scaled = ffi::sws_scale(
self.sws_ctx,
src_slices.as_ptr(),
src_strides.as_ptr(),
0,
self.enc_height as i32,
(*self.yuv_frame).data.as_ptr() as *mut *mut u8,
(*self.yuv_frame).linesize.as_ptr(),
);
if scaled < 0 {
bail!("sws_scale failed for software encoder: {scaled}");
}
}
let sws_us = sws_start.elapsed().as_micros() as u64;
let pts = frame.pts;
if self.starting_timestamp.is_none() {
self.starting_timestamp = Some(pts);
}
let start_ts = self.starting_timestamp.unwrap_or(0);
let enc_start = Instant::now();
// SAFETY: yuv_frame is initialized, writable, and matches the opened encoder format.
// pict_type is reset every frame: the AVFrame is reused, so without resetting to NONE
// a previously-forced I-type would leak into subsequent P-frames. With forced-idr=1
// set on the encoder, AV_PICTURE_TYPE_I produces a true IDR NALU.
unsafe {
(*self.yuv_frame).pts = pts;
(*self.yuv_frame).pict_type = if force_this_frame {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let ret = ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), self.yuv_frame);
if ret < 0 {
bail!(
"avcodec_send_frame failed for software encoder: {}",
ff_err(ret)
);
}
}
if force_this_frame {
self.force_keyframe_pending = false;
}
let output_bytes = self.drain_encoder(start_ts)?;
let encode_us = enc_start.elapsed().as_micros() as u64;
self.last_timing = SwEncodeTiming {
sws_us,
encode_us,
output_bytes,
};
Ok(EncodeOutcome::Encoded)
}
pub(super) fn write_trailer_if_needed(&mut self) -> Result<()> {
if self.frames_written {
if let Some(FrameOutput::Muxer(ref mut octx)) = self.output {
octx.write_trailer()
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
}
}
Ok(())
}
fn drain_encoder(&mut self, start_ts: i64) -> Result<usize> {
let mut total_bytes = 0usize;
loop {
let mut pkt = ff::Packet::empty();
// SAFETY: enc_video is an open encoder; pkt is writable packet storage.
let ret = unsafe {
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
};
if ret < 0 {
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
bail!("avcodec_receive_packet failed: {}", ff_err(ret));
}
// Count encoded bytes produced before the Muxer/Channel match to
// avoid branch duplication and handle multi-packet drain correctly.
// SAFETY: pkt was just filled by a successful avcodec_receive_packet;
// the size field is valid and initialized.
let pkt_size = unsafe { (*pkt.as_mut_ptr()).size };
if pkt_size > 0 {
total_bytes += pkt_size as usize;
}
match self.output {
Some(FrameOutput::Muxer(ref mut octx)) => {
encode_output::write_muxer_packet(
&mut pkt,
octx,
self.enc_video.time_base(),
start_ts,
)?;
self.frames_written = true;
}
Some(FrameOutput::Channel(ref tx))
if encode_output::send_channel_packet(
&mut pkt,
tx,
start_ts,
self.last_capture_time.unwrap_or_else(Instant::now),
)? == PacketOutput::Disconnected =>
{
self.webrtc_disconnected = true;
break;
}
Some(FrameOutput::Channel(_)) => {}
None => {}
}
}
Ok(total_bytes)
}
}
impl Drop for SwEncEncode {
fn drop(&mut self) {
if !self.sws_ctx.is_null() {
// SAFETY: sws_ctx is owned by this state and was returned by sws_getContext.
unsafe { ffi::sws_freeContext(self.sws_ctx) };
self.sws_ctx = ptr::null_mut();
}
if !self.yuv_frame.is_null() {
// SAFETY: yuv_frame is owned by this state and was allocated by av_frame_alloc.
unsafe { ffi::av_frame_free(&mut self.yuv_frame) };
}
}
}
+133
View File
@@ -0,0 +1,133 @@
use std::path::Path;
use std::ptr;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use ffmpeg_next::ffi;
use super::encode_output::FrameOutput;
use super::software::{
alloc_yuv420p_frame, create_nv12_to_yuv420p_sws, create_software_h264_encoder,
create_software_h264_muxer,
};
use super::{BitrateCommand, EncodedH264Frame, ResolutionChange, SwEncEncode, SwEncodeTiming};
impl SwEncEncode {
#[allow(clippy::too_many_arguments)]
pub(super) fn new_muxer(
output_path: &Path,
enc_width: u32,
enc_height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
) -> Result<Self> {
let sws_ctx = create_nv12_to_yuv420p_sws(enc_width, enc_height)?;
let (enc_video, octx) =
create_software_h264_muxer(output_path, enc_width, enc_height, fps, bitrate, gop_size)?;
let yuv_frame = alloc_yuv420p_frame(enc_width, enc_height)?;
let (dummy_tx, bitrate_rx) = crossbeam_channel::bounded(1);
drop(dummy_tx);
let (dummy_resolution_tx, resolution_rx) = crossbeam_channel::bounded(1);
drop(dummy_resolution_tx);
Ok(Self {
sws_ctx,
enc_video,
output: Some(FrameOutput::Muxer(octx)),
yuv_frame,
last_frame_hash: 0,
frame_count: 0,
starting_timestamp: None,
frames_written: false,
webrtc_disconnected: false,
webrtc_paused: None,
bitrate_rx,
resolution_rx,
enc_width,
enc_height,
fps,
bitrate,
gop_size,
force_keyframe_pending: false,
last_timing: SwEncodeTiming::default(),
last_capture_time: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn new_webrtc(
enc_width: u32,
enc_height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
tx: crossbeam_channel::Sender<EncodedH264Frame>,
webrtc_paused: Arc<AtomicBool>,
bitrate_rx: crossbeam_channel::Receiver<BitrateCommand>,
resolution_rx: crossbeam_channel::Receiver<ResolutionChange>,
) -> Result<Self> {
let sws_ctx = create_nv12_to_yuv420p_sws(enc_width, enc_height)?;
let enc_video =
create_software_h264_encoder(enc_width, enc_height, fps, bitrate, gop_size)?;
let yuv_frame = alloc_yuv420p_frame(enc_width, enc_height)?;
Ok(Self {
sws_ctx,
enc_video,
output: Some(FrameOutput::Channel(tx)),
yuv_frame,
last_frame_hash: 0,
frame_count: 0,
starting_timestamp: None,
frames_written: false,
webrtc_disconnected: false,
webrtc_paused: Some(webrtc_paused),
bitrate_rx,
resolution_rx,
enc_width,
enc_height,
fps,
bitrate,
gop_size,
force_keyframe_pending: false,
last_timing: SwEncodeTiming::default(),
last_capture_time: None,
})
}
pub(super) fn recreate_encoder(&mut self, width: u32, height: u32) -> Result<()> {
if width == self.enc_width && height == self.enc_height {
return Ok(());
}
tracing::info!(
from = format_args!("{}x{}", self.enc_width, self.enc_height),
to = format_args!("{}x{}", width, height),
"recreating WebRTC software encoder for resolution change"
);
if !self.sws_ctx.is_null() {
// SAFETY: sws_ctx is owned exclusively by self and will be replaced below.
unsafe { ffi::sws_freeContext(self.sws_ctx) };
self.sws_ctx = ptr::null_mut();
}
if !self.yuv_frame.is_null() {
// SAFETY: yuv_frame is owned exclusively by self and will be replaced below.
unsafe { ffi::av_frame_free(&mut self.yuv_frame) };
}
self.sws_ctx = create_nv12_to_yuv420p_sws(width, height)?;
self.enc_video =
create_software_h264_encoder(width, height, self.fps, self.bitrate, self.gop_size)?;
self.yuv_frame = alloc_yuv420p_frame(width, height)?;
self.enc_width = width;
self.enc_height = height;
self.last_frame_hash = 0;
self.frame_count = 0;
self.force_keyframe_pending = true;
Ok(())
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::packet::Mut as _;
use super::EncodedH264Frame;
pub enum FrameOutput {
Muxer(ff::format::context::Output),
Channel(crossbeam_channel::Sender<EncodedH264Frame>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PacketOutput {
Written,
Dropped,
Disconnected,
}
pub(super) fn write_muxer_packet(
pkt: &mut ff::Packet,
octx: &mut ff::format::context::Output,
enc_tb: ff::Rational,
start_ts: i64,
) -> Result<()> {
// SAFETY: muxer output was created with stream 0 during setup; streams
// is non-null and stream 0 remains owned by the format context.
let stream_tb = unsafe {
let fmt = *octx.as_ptr();
if fmt.nb_streams == 0 || fmt.streams.is_null() {
bail!("no streams in output context");
}
let st = *fmt.streams.add(0);
ff::Rational::from((*st).time_base)
};
pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = pkt.pts() {
pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = pkt.dts() {
pkt.set_dts(Some(dts - start_ts));
}
pkt.set_stream(0);
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("Failed to write packet: {e}"))
}
pub(super) fn send_channel_packet(
pkt: &mut ff::Packet,
tx: &crossbeam_channel::Sender<EncodedH264Frame>,
start_ts: i64,
capture_time: Instant,
) -> Result<PacketOutput> {
// SAFETY: pkt is a valid AVPacket just filled by avcodec_receive_packet;
// this copies fields for read-only inspection before pkt is dropped.
let raw = unsafe { *pkt.as_mut_ptr() };
if raw.size <= 0 || raw.data.is_null() {
return Ok(PacketOutput::Dropped);
}
// SAFETY: `pkt` is a valid AVPacket just filled by a successful
// `avcodec_receive_packet` call. We checked `size > 0` and `data` is
// non-null, so `data` points to `size` initialized bytes owned by the
// packet. `u8` has alignment 1, and the slice is copied into a Vec before
// the packet is unreffed.
let data = unsafe { std::slice::from_raw_parts(raw.data, raw.size as usize) };
let pts_ticks = match pkt.pts() {
Some(p) => p - start_ts,
None => {
tracing::warn!("encoder produced packet without PTS, dropping");
return Ok(PacketOutput::Dropped);
}
};
match tx.try_send(EncodedH264Frame {
data: data.to_vec(),
pts_ticks,
capture_time,
}) {
Ok(()) => Ok(PacketOutput::Written),
Err(crossbeam_channel::TrySendError::Full(frame)) => {
tracing::warn!(
"WebRTC channel full, dropping frame: {} bytes lost",
frame.data.len()
);
Ok(PacketOutput::Dropped)
}
Err(crossbeam_channel::TrySendError::Disconnected(frame)) => {
tracing::warn!(
"WebRTC channel disconnected: {} bytes lost",
frame.data.len()
);
Ok(PacketOutput::Disconnected)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use crate::transform::Transform;
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
#[allow(clippy::too_many_arguments)]
pub(super) fn build_swenc_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
let buffersrc =
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
// FFmpeg 8.0+ rejects VAAPI pix_fmt in buffer args before hw_frames_ctx is attached.
// Use a SW placeholder, then override format/hw_frames_ctx with av_buffersrc_parameters_set.
let args = format!(
"video_size={}x{}:pix_fmt=bgra:time_base=1/{fps}:pixel_aspect=1/1",
width, height,
);
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
// SAFETY: av_buffersrc_parameters_alloc returns newly allocated parameters
// or null, which is checked below.
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
if par.is_null() {
bail!("av_buffersrc_parameters_alloc returned null");
}
// SAFETY: par and src_ctx are valid; frames_rgb.ref_clone returns an owned hw_frames_ctx ref
// that buffersrc consumes on successful parameter set.
unsafe {
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational {
num: 1,
den: fps as i32,
};
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
ffi::av_free(par as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: {}", ff_err(ret));
}
}
let mut scale_ctx = graph.add(
&scale_vaapi,
"scale",
&format!("{enc_width}:{enc_height}:format=nv12"),
)?;
// SAFETY: scale_vaapi keeps a ref-counted device context while the graph is alive.
unsafe {
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
src_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph
.validate()
.map_err(|e| anyhow::anyhow!("software GPU filter graph validation failed: {e}"))?;
Ok(graph)
}
pub(super) fn build_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
width: u32,
height: u32,
fps: u32,
transform: Transform,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
let buffersrc =
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
// buffersrc - use AVBufferSrcParameters to set hw_frames_ctx properly.
let args = format!(
"video_size={}x{}:pix_fmt={}:time_base=1/{fps}:pixel_aspect=1/1",
width,
height,
Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32,
);
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
// SAFETY: av_buffersrc_parameters_alloc allocates params for the buffersrc.
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
if par.is_null() {
bail!("av_buffersrc_parameters_alloc returned null");
}
// SAFETY: Set hw_frames_ctx on the buffersrc parameters, then apply.
unsafe {
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational {
num: 1,
den: fps as i32,
};
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
ffi::av_free(par as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: {}", ff_err(ret));
}
}
// scale_vaapi: hardware scaling and colourspace conversion (keeps original dimensions).
let mut scale_ctx = graph.add(
&scale_vaapi,
"scale",
&format!("{width}:{height}:format=nv12"),
)?;
// SAFETY: scale_vaapi needs hw_device_ctx for VAAPI device access.
unsafe {
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
src_ctx.link(0, &mut scale_ctx, 0);
match transform {
Transform::Normal => {
scale_ctx.link(0, &mut sink_ctx, 0);
}
other => {
let transpose = ff::filter::find("transpose_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'transpose_vaapi' not found"))?;
let dir_val = match other {
Transform::Normal90 => "1",
Transform::Normal180 => "4",
Transform::Normal270 => "2",
Transform::Flipped => "5",
Transform::Flipped90 => "3",
Transform::Flipped180 => "6",
Transform::Flipped270 => "0",
Transform::Normal => unreachable!(),
};
let mut trans_ctx = graph.add(&transpose, "transpose", &format!("dir={dir_val}"))?;
// SAFETY: trans_ctx is a live transpose_vaapi filter context;
// scale_vaapi/transpose_vaapi keep a ref-counted device context.
unsafe {
(*trans_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
scale_ctx.link(0, &mut trans_ctx, 0);
trans_ctx.link(0, &mut sink_ctx, 0);
}
}
graph
.validate()
.map_err(|e| anyhow::anyhow!("Filter graph validation failed: {e}"))?;
Ok(graph)
}
+285
View File
@@ -0,0 +1,285 @@
use std::path::Path;
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut as _;
use crate::transform::Transform;
use super::{
ff_err, filter::build_filter_graph, hardware_encoder, hardware_muxer, AvHwDevCtx, AvHwFrameCtx,
EncodeStages,
};
pub struct EncState {
enc_video: ff::codec::encoder::video::Video,
frames_rgb: AvHwFrameCtx,
video_filter: ff::filter::Graph,
// Root AVHWDeviceContext, kept for ownership. Each consumer (encoder,
// filter graph, frames ctx) already holds its own ref_clone(); this
// field is never read after `new()` but must outlive those clones.
#[allow(dead_code)]
hw_device_ctx: AvHwDevCtx,
octx: ff::format::context::Output,
starting_timestamp: Option<i64>,
frames_written: bool,
}
// SAFETY: EncState is moved to exactly one encode worker thread and all Rust
// methods take &mut self, so there is no concurrent *Rust-side* access.
// FFmpeg-internal codec threads may touch hw_device_ctx / frames_rgb through
// the encoder context if frame/slice threading is enabled; this is sound
// because AVHWDeviceContext and AVHWFramesContext are designed for such
// sharing (atomic refcounts, thread-safe pool, libva VADisplay thread safety).
// This impl only lifts auto-Send inference through raw pointers inside the
// ffmpeg-next wrappers; it does not introduce new sharing. Do NOT add fields
// that create shared mutable state across threads without re-auditing.
unsafe impl Send for EncState {}
impl EncState {
#[allow(clippy::too_many_arguments)]
pub fn new(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
bitrate: u64,
gop_size: u32,
fps: u32,
transform: Transform,
existing_hw_ctx: Option<AvHwDevCtx>,
) -> Result<Self> {
tracing::info!(
"EncState::new: {width}x{height} enc={enc_width}x{enc_height} transform={transform:?}"
);
let hw_device_ctx = match existing_hw_ctx {
Some(ctx) => ctx,
None => AvHwDevCtx::new_vaapi(drm_device)?,
};
let frames_rgb =
AvHwFrameCtx::for_capture(&hw_device_ctx, width, height, ff::format::Pixel::BGRA)?;
let mut video_filter =
build_filter_graph(&hw_device_ctx, &frames_rgb, width, height, fps, transform)?;
let mut sink_ctx = video_filter
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
// SAFETY: sink_ctx is a live buffersink; the returned hw_frames_ctx is
// borrowed, so av_buffer_ref creates an owned reference.
let sink_hw_frames = unsafe {
let raw = ffi::av_buffersink_get_hw_frames_ctx(sink_ctx.as_mut_ptr());
if raw.is_null() {
bail!("buffersink has no hw_frames_ctx - filter graph may not be configured for hardware output");
}
let hw_ref = ffi::av_buffer_ref(raw);
if hw_ref.is_null() {
bail!("av_buffer_ref failed for buffersink hw_frames_ctx - likely out of memory");
}
hw_ref
};
// SAFETY: sink_hw_frames is an owned AVBufferRef to an AVHWFramesContext
// returned by the validated filter graph.
unsafe {
let fc = (*sink_hw_frames).data as *mut ffi::AVHWFramesContext;
let actual_w = (*fc).width as u32;
let actual_h = (*fc).height as u32;
if actual_w != enc_width || actual_h != enc_height {
tracing::warn!(
"Filter output dimensions {actual_w}x{actual_h} differ from encoder dimensions {enc_width}x{enc_height}"
);
}
}
let enc_video = hardware_encoder::open_h264_vaapi_encoder(
&hw_device_ctx,
sink_hw_frames,
enc_width,
enc_height,
bitrate,
gop_size,
fps,
)?;
let octx = hardware_muxer::create_output_context(output_path, &enc_video)?;
Ok(Self {
enc_video,
frames_rgb,
video_filter,
hw_device_ctx,
octx,
starting_timestamp: None,
frames_written: false,
})
}
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
&self.frames_rgb
}
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
let mut filter_src_ctx = self
.video_filter
.get("in")
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = self
.video_filter
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
let mut filter_sink = filter_sink_ctx.sink();
// Scale stage = filter graph push + pull (scale_vaapi for resolution
// change + format conversion to NV12). Timed separately from the
// actual avcodec_send_frame so the per-stage stats answer "where is
// latency?" honestly. See Oracle audit 2026-06-28 step 4.
let scale_start = Instant::now();
filter_src
.add(hw_frame)
.map_err(|e| anyhow::anyhow!("Filter source add failed: {e}"))?;
let mut scale_us = 0u64;
let mut encode_us = 0u64;
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
if filtered.pts().is_none() {
filtered.set_pts(hw_frame.pts());
}
}
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
Err(e) => bail!("Filter sink get frame failed: {e}"),
}
// First successful pull closes the scale-stage measurement; later
// pulls (rare extras) roll into encode time.
if scale_us == 0 {
scale_us = scale_start.elapsed().as_micros() as u64;
}
let pts = filtered.pts().unwrap_or(0);
if self.starting_timestamp.is_none() {
self.starting_timestamp = Some(pts);
}
let start_ts = self.starting_timestamp.unwrap();
let encode_start = Instant::now();
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
let ret =
unsafe { ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr()) };
if ret < 0 {
bail!("avcodec_send_frame failed: {}", ff_err(ret));
}
self.drain_encoder(start_ts)?;
encode_us += encode_start.elapsed().as_micros() as u64;
}
Ok(EncodeStages {
scale_us,
// HW path stays on GPU - no CPU readback, transfer is N/A.
transfer_us: 0,
encode_us,
})
}
pub fn flush(&mut self) -> Result<()> {
let mut filter_src_ctx = self
.video_filter
.get("in")
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
let mut filter_src = filter_src_ctx.source();
if let Err(e) = filter_src.flush() {
tracing::debug!("filter source flush error: {e}");
}
let mut filter_sink_ctx = self
.video_filter
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
let mut filter_sink = filter_sink_ctx.sink();
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
let start_ts = self.starting_timestamp.unwrap_or(0);
// SAFETY: filtered is a valid VAAPI frame drained from the
// filter graph; enc_video is an opened encoder.
let ret = unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr())
};
if ret < 0 {
bail!("avcodec_send_frame failed during flush: {}", ff_err(ret));
}
self.drain_encoder(start_ts)?;
}
Err(_) => break,
}
}
// SAFETY: Sending null frame signals end of stream to encoder.
unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), std::ptr::null());
}
let start_ts = self.starting_timestamp.unwrap_or(0);
self.drain_encoder(start_ts)?;
if self.frames_written {
self.octx
.write_trailer()
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
}
Ok(())
}
fn drain_encoder(&mut self, start_ts: i64) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// SAFETY: avcodec_receive_packet retrieves an encoded packet.
let ret = unsafe {
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
};
if ret < 0 {
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
bail!("avcodec_receive_packet failed: {}", ff_err(ret));
}
let enc_tb = self.enc_video.time_base();
// SAFETY: octx was created with stream 0 during muxer setup; streams
// is non-null and stream 0 remains owned by the format context.
let stream_tb = unsafe {
let fmt = *self.octx.as_ptr();
if fmt.nb_streams == 0 || fmt.streams.is_null() {
bail!("no streams in output context");
}
let st = *fmt.streams.add(0);
ff::Rational::from((*st).time_base)
};
pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = pkt.pts() {
pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = pkt.dts() {
pkt.set_dts(Some(dts - start_ts));
}
pkt.set_stream(0);
pkt.write_interleaved(&mut self.octx)
.map_err(|e| anyhow::anyhow!("Failed to write packet: {e}"))?;
self.frames_written = true;
}
Ok(())
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::ffi::CString;
use anyhow::Result;
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use super::{ff_err, AvHwDevCtx};
#[allow(clippy::too_many_arguments)]
pub(super) fn open_h264_vaapi_encoder(
hw_device_ctx: &AvHwDevCtx,
sink_hw_frames: *mut ffi::AVBufferRef,
enc_width: u32,
enc_height: u32,
bitrate: u64,
gop_size: u32,
fps: u32,
) -> Result<ff::codec::encoder::video::Video> {
let codec = ff::encoder::find_by_name("h264_vaapi")
.ok_or_else(|| anyhow::anyhow!("h264_vaapi encoder not found"))?;
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::VAAPI);
enc.set_bit_rate(bitrate as usize);
enc.set_gop(gop_size);
enc.set_time_base(ff::Rational::new(1, fps as i32));
enc.set_max_b_frames(0);
// VBV rate limiting: caps IDR burst size for WebRTC. Without this a 4K
// scene change can produce a 256KB keyframe that overflows the UDP send
// buffer. bufsize=bitrate/4 is about 250ms of video at the target bitrate.
// SAFETY: enc.as_mut_ptr() is a valid AVCodecContext for the not-yet-opened
// encoder. rc_max_rate and rc_buffer_size are plain integer fields; assigning
// i64/i32 values is a simple struct-field write on a properly aligned pointer.
unsafe {
let ctx_ptr = enc.as_mut_ptr();
(*ctx_ptr).rc_max_rate = bitrate as i64;
(*ctx_ptr).rc_buffer_size = (bitrate / 4) as i32;
}
// SAFETY: AV_CODEC_FLAG_GLOBAL_HEADER must be set BEFORE opening the encoder.
// It triggers SPS/PPS extradata generation needed by the muxer for
// Annex B to AVCC conversion.
unsafe {
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
}
// SAFETY: Assign hw device and frames ctx to the encoder.
unsafe {
(*enc.as_mut_ptr()).hw_device_ctx = hw_device_ctx.ref_clone();
(*enc.as_mut_ptr()).hw_frames_ctx = sink_hw_frames;
}
// SAFETY: Set repeat_pps=1 on the encoder so PPS is inserted in every encoded frame.
// This ensures decoders can start decoding from any frame (important for WebRTC).
// repeat_pps is only available in FFmpeg 7.0+ (not in 6.x). On older
// FFmpeg, IDR frames carry SPS by default; PPS repetition depends on the driver.
// For SPS repetition: IDR frames carry SPS by default, controlled by gop_size/idr_interval.
{
let key = CString::new("repeat_pps").unwrap();
let val = CString::new("1").unwrap();
// SAFETY: enc is a valid AVCodecContext for the not-yet-opened encoder;
// priv_data is the codec's private options struct. key/val are NUL-terminated
// CString that live across the call. av_opt_set is FFmpeg's standard
// option-setter. Failure is non-fatal (returns < 0 on older FFmpeg).
let ret = unsafe {
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0)
};
if ret < 0 {
tracing::warn!("av_opt_set repeat_pps failed ({}), likely FFmpeg < 7.0; continuing without per-frame PPS", ff_err(ret));
}
}
let opened = enc
.open()
.map_err(|e| anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}"))?;
Ok(opened.0)
}
+92
View File
@@ -0,0 +1,92 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use super::ff_err;
pub(super) fn create_output_context(
output_path: &Path,
enc_video: &ff::codec::encoder::video::Video,
) -> Result<ff::format::context::Output> {
let output_cstr = CString::new(output_path.to_str().unwrap())?;
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// SAFETY: avformat_alloc_output_context2 creates format context from
// the file extension. Does NOT open the file.
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
ptr::null(),
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: {}", ff_err(ret));
}
// SAFETY: enc_video is a valid AVCodecContext pointer; codec_id is a plain
// i32 enum discriminant read from it. fmt_ctx_ptr is a valid AVFormatContext
// allocated above; oformat is a const pointer field read from it.
// avformat_query_codec checks codec+format compatibility; both pointers are
// valid and FF_COMPLIANCE_NORMAL is a constant. All three reads happen in one
// block so a single SAFETY rationale covers them.
let compat = unsafe {
let codec_id = (*enc_video.as_ptr()).codec_id;
let oformat = (*fmt_ctx_ptr).oformat;
ffi::avformat_query_codec(oformat, codec_id, ffi::FF_COMPLIANCE_NORMAL)
};
if compat < 0 {
bail!("H.264 codec not supported by output container format");
}
// SAFETY: avformat_new_stream creates a new stream in the format context.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream in output context");
}
// SAFETY: avcodec_parameters_from_context copies encoder params + extradata.
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!(
"Failed to copy encoder parameters to stream: {}",
ff_err(ret)
);
}
// SAFETY: Copy encoder time_base to stream.
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// SAFETY: avio_open opens the output file for writing.
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
)
};
if ret < 0 {
bail!(
"Failed to open output file '{}': {}",
output_path.display(),
ff_err(ret)
);
}
// SAFETY: avformat_write_header writes the container header.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write output header: {}", ff_err(ret));
}
// SAFETY: We created fmt_ctx_ptr above and it's valid.
Ok(unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) })
}
+23
View File
@@ -0,0 +1,23 @@
const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV1A_PRIME: u64 = 0x100000001b3;
const Y_PLANE_HASH_ROW_STEP: usize = 8;
pub(super) fn hash_sampled_y_plane(
y_data: &[u8],
width: usize,
height: usize,
stride: usize,
) -> u64 {
let mut hash = FNV1A_OFFSET_BASIS;
for row in (0..height).step_by(Y_PLANE_HASH_ROW_STEP) {
let row_start = row * stride;
let row_end = row_start + width;
for &byte in &y_data[row_start..row_end] {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV1A_PRIME);
}
}
hash
}
+263
View File
@@ -0,0 +1,263 @@
use std::path::Path;
use std::slice;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use super::filter::build_swenc_filter_graph;
use super::{ff_err, AvHwDevCtx, AvHwFrameCtx};
use super::{BitrateCommand, CpuNv12Frame, ResolutionChange};
pub struct SwEncImport {
hw_dev: AvHwDevCtx,
frames_rgb: AvHwFrameCtx,
filter_graph: ff::filter::Graph,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
resolution_rx: Option<crossbeam_channel::Receiver<BitrateCommand>>,
encoder_resolution_tx: Option<crossbeam_channel::Sender<ResolutionChange>>,
}
impl SwEncImport {
#[allow(clippy::too_many_arguments)]
pub fn new(
drm_device: &Path,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
) -> Result<Self> {
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
let frames_rgb =
AvHwFrameCtx::for_capture(&hw_dev, width, height, ff::format::Pixel::BGRA)?;
let filter_graph = build_swenc_filter_graph(
&hw_dev,
&frames_rgb,
width,
height,
enc_width,
enc_height,
fps,
)?;
Ok(Self {
hw_dev,
frames_rgb,
filter_graph,
width,
height,
enc_width,
enc_height,
fps,
resolution_rx: None,
encoder_resolution_tx: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_resolution_control(
drm_device: &Path,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
resolution_rx: crossbeam_channel::Receiver<BitrateCommand>,
encoder_resolution_tx: crossbeam_channel::Sender<ResolutionChange>,
) -> Result<Self> {
let mut this = Self::new(drm_device, width, height, enc_width, enc_height, fps)?;
this.resolution_rx = Some(resolution_rx);
this.encoder_resolution_tx = Some(encoder_resolution_tx);
Ok(this)
}
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
let _ = self.hw_dev.as_ptr();
&self.frames_rgb
}
pub fn import_and_scale(&mut self, hw_frame: &ff::frame::Video) -> Result<CpuNv12Frame> {
self.poll_resolution_commands()?;
let mut filter_src_ctx = self
.filter_graph
.get("in")
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = self
.filter_graph
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
let mut filter_sink = filter_sink_ctx.sink();
filter_src
.add(hw_frame)
.map_err(|e| anyhow::anyhow!("software pipeline filter source add failed: {e}"))?;
let mut first = None;
let mut extra_count = 0usize;
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
if filtered.pts().is_none() {
filtered.set_pts(hw_frame.pts());
}
let cpu_frame = self.transfer_filtered_to_cpu(&filtered)?;
if first.is_none() {
first = Some(cpu_frame);
} else {
extra_count += 1;
}
}
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
Err(e) => bail!("software pipeline filter sink get frame failed: {e}"),
}
}
if extra_count > 0 {
tracing::warn!(
"software import filter produced {extra_count} extra frame(s); dropping extras"
);
}
first.ok_or_else(|| anyhow::anyhow!("software pipeline produced no scaled frame"))
}
pub fn flush_import(&mut self) -> Result<Vec<CpuNv12Frame>> {
let mut filter_src_ctx = self
.filter_graph
.get("in")
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
let mut filter_src = filter_src_ctx.source();
if let Err(e) = filter_src.flush() {
tracing::debug!("filter source flush error: {e}");
}
let mut filter_sink_ctx = self
.filter_graph
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
let mut filter_sink = filter_sink_ctx.sink();
let mut frames = Vec::new();
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => frames.push(self.transfer_filtered_to_cpu(&filtered)?),
Err(_) => break,
}
}
Ok(frames)
}
fn poll_resolution_commands(&mut self) -> Result<()> {
let Some(rx) = self.resolution_rx.as_ref().cloned() else {
return Ok(());
};
let mut requested = None;
while let Ok(cmd) = rx.try_recv() {
match cmd {
BitrateCommand::UpdateResolution { width, height } => {
requested = Some((width & !1, height & !1));
}
BitrateCommand::UpdateBitrate { .. } => {}
BitrateCommand::ForceKeyframe => {}
}
}
let Some((width, height)) = requested else {
return Ok(());
};
if width == self.enc_width && height == self.enc_height {
return Ok(());
}
tracing::info!(
from = format_args!("{}x{}", self.enc_width, self.enc_height),
to = format_args!("{}x{}", width, height),
"rebuilding software import filter graph for resolution change"
);
let _ = self.flush_import();
self.filter_graph = build_swenc_filter_graph(
&self.hw_dev,
&self.frames_rgb,
self.width,
self.height,
width,
height,
self.fps,
)?;
self.enc_width = width;
self.enc_height = height;
if let Some(tx) = &self.encoder_resolution_tx {
tx.send(ResolutionChange { width, height })
.map_err(|_| anyhow::anyhow!("encoder resolution channel disconnected"))?;
}
Ok(())
}
fn transfer_filtered_to_cpu(&self, filtered: &ff::frame::Video) -> Result<CpuNv12Frame> {
// SAFETY: av_frame_alloc returns a newly allocated AVFrame or null,
// which is checked below.
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
if sw_nv12.is_null() {
bail!("av_frame_alloc failed for NV12 transfer frame");
}
// SAFETY: sw_nv12 is an allocated destination frame; filtered is a valid VAAPI NV12
// surface produced by scale_vaapi at encoder dimensions.
let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_nv12, filtered.as_ptr(), 0) };
if transfer_ret < 0 {
// SAFETY: sw_nv12 was allocated above and has not been freed yet.
unsafe { ffi::av_frame_free(&mut sw_nv12) };
bail!(
"av_hwframe_transfer_data failed for GPU-downscaled frame: {}",
ff_err(transfer_ret)
);
}
// SAFETY: sw_nv12 was filled by av_hwframe_transfer_data. NV12 planes 0 and 1 are
// initialized for enc_width x enc_height; linesize values define each row's byte span.
let frame = unsafe {
let y_ptr = (*sw_nv12).data[0];
let uv_ptr = (*sw_nv12).data[1];
if y_ptr.is_null() || uv_ptr.is_null() {
ffi::av_frame_free(&mut sw_nv12);
bail!("NV12 transfer frame missing Y/UV plane data");
}
let y_stride = (*sw_nv12).linesize[0] as usize;
let uv_stride = (*sw_nv12).linesize[1] as usize;
if (*sw_nv12).width != self.enc_width as i32
|| (*sw_nv12).height != self.enc_height as i32
{
ffi::av_frame_free(&mut sw_nv12);
bail!("NV12 transfer frame has unexpected dimensions");
}
let y_len = y_stride * self.enc_height as usize;
let uv_len = uv_stride * (self.enc_height as usize / 2);
let y_data = slice::from_raw_parts(y_ptr, y_len).to_vec();
let uv_data = slice::from_raw_parts(uv_ptr, uv_len).to_vec();
let pts = filtered.pts().unwrap_or(0);
ffi::av_frame_free(&mut sw_nv12);
CpuNv12Frame {
y_data,
uv_data,
y_stride,
uv_stride,
pts,
capture_time: std::time::Instant::now(),
}
};
Ok(frame)
}
}
+258
View File
@@ -0,0 +1,258 @@
//! FFmpeg / VAAPI encoder wrappers.
//!
//! ## `Send` justification convention
//!
//! Several types in this module (`AvHwDevCtx`, `AvHwFrameCtx`, `EncState`,
//! `SwEncState`, `SwEncEncode`) carry raw FFmpeg pointers and therefore need
//! an explicit `unsafe impl Send`. The justification is always at the C-API
//! level, never at the Rust-borrow level:
//!
//! - `AVBufferRef` refcounts are `atomic_uint` (`libavutil/buffer.c`), so
//! `av_buffer_ref` / `av_buffer_unref` are safe to call concurrently.
//! - `AVHWDeviceContext` (VAAPI `VADisplay`) is designed by FFmpeg/libva to
//! be shared across codec and filter contexts, including FFmpeg-internal
//! codec worker threads.
//! - `AVHWFramesContext` allocates from an `AVBufferPool` whose get/put are
//! atomic; `av_hwframe_get_buffer` is safe to call concurrently on
//! distinct frames.
//! - `SwsContext`, `AVFilterGraph`, `AVFrame`, `AVCodecContext` are NOT
//! thread-safe for concurrent use, but are `Send`-sound under the
//! single-thread exclusive access invariant that the encode worker
//! enforces.
//!
//! **Anti-pattern**: justifying `Send` with "`&mut self` ensures exclusive
//! access". `Send` is about *moving ownership between threads*, not about
//! borrowing. The `&mut self` on Rust methods is API convenience and is not
//! the basis for soundness — refs cloned via `ref_clone()` routinely escape
//! to other threads / FFmpeg-internal workers, and that is fine because the
//! underlying C APIs are designed for it.
use std::path::Path;
use anyhow::Result;
use crate::transform::{transpose_if_transform_transposed, Transform};
mod device;
mod dmabuf;
mod encode;
mod encode_init;
mod encode_output;
mod filter;
mod hardware;
mod hardware_encoder;
mod hardware_muxer;
mod hash;
mod import;
mod software;
mod state;
mod types;
mod util;
pub use device::{AvHwDevCtx, AvHwFrameCtx};
pub use dmabuf::{import_dma_buf_to_vaapi, test_dma_buf_import};
pub use encode::{SwEncEncode, WEBRTC_RTP_CLOCK_HZ};
#[allow(unused_imports)]
pub use encode_output::FrameOutput;
pub use hardware::EncState;
#[cfg(test)]
use hash::hash_sampled_y_plane;
pub use import::SwEncImport;
pub use state::SwEncState;
pub use types::{
BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodeStages, EncodedH264Frame, ResolutionChange,
SwEncodeTiming,
};
#[allow(unused_imports)]
pub use util::av_err_to_string;
pub(crate) use util::ff_err;
// ---------------------------------------------------------------------------
// Shared encoder creation (used by both wlr-screencopy and portal paths)
// ---------------------------------------------------------------------------
/// Create a fully configured encoder with VAAPI hardware acceleration.
///
/// Convenience wrapper around [`EncState::new`] that computes default values
/// for `bitrate` and `gop_size` when not provided, and handles encoder dimension
/// transposition for rotated/transformed outputs.
#[allow(clippy::too_many_arguments)]
pub fn create_encoder(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
fps: u32,
transform: Transform,
bitrate: Option<u64>,
gop_size: Option<u32>,
existing_hw_ctx: Option<AvHwDevCtx>,
) -> Result<EncState> {
let (enc_w, enc_h) = transpose_if_transform_transposed(transform, width as i32, height as i32);
let actual_bitrate =
bitrate.unwrap_or_else(|| 2 * (width as u64) * (height as u64) * (fps as u64) / 100);
let actual_gop_size = gop_size.unwrap_or(fps);
EncState::new(
drm_device,
output_path,
width,
height,
enc_w as u32,
enc_h as u32,
actual_bitrate,
actual_gop_size,
fps,
transform,
existing_hw_ctx,
)
}
#[cfg(test)]
mod tests {
use super::*;
// Centralizes the `stride * row` byte-offset pattern used by the Y-plane hash
// tests below, so clippy::erasing_op (row == 0) and clippy::identity_op (row == 1)
// both pass without sacrificing the row-index intent the tests are written around.
fn row_range(row: usize, stride: usize, width: usize) -> std::ops::Range<usize> {
let start = stride * row;
start..start + width
}
// ── Task 1: VBV x264opts formatting ──
#[test]
fn vbv_x264opts_format() {
let bitrate: u64 = 5_000_000;
// x264 expects kbit/s and kbit, not bps
let vbv_maxrate_kbps = bitrate / 1000;
let vbv_bufsize_kbps = (bitrate / 4) / 1000;
let opts = format!(
"repeat_headers=1:vbv-maxrate={vbv_maxrate_kbps}:vbv-bufsize={vbv_bufsize_kbps}"
);
assert_eq!(vbv_maxrate_kbps, 5000);
assert_eq!(vbv_bufsize_kbps, 1250);
assert!(opts.contains("vbv-maxrate=5000"));
assert!(opts.contains("vbv-bufsize=1250"));
}
#[test]
fn vbv_bufsize_is_quarter_of_maxrate() {
for bitrate in [1_000_000, 5_000_000, 10_000_000] {
// x264 expects kbit/s and kbit; both scaled by /1000, ratio preserved
let maxrate_kbps = bitrate / 1000;
let bufsize_kbps = (bitrate / 4) / 1000;
assert_eq!(
bufsize_kbps * 4,
maxrate_kbps,
"bufsize should be maxrate/4"
);
}
}
// ── Task 3: GOP formula ──
#[test]
fn webrtc_gop_formula() {
// Formula under test: GOP = max(fps * 2, 20). Hid behind a runtime lambda so
// clippy can't constant-fold the assertions into tautologies (which would
// silently strip the floor-case coverage for 5fps).
fn gop(fps: u32) -> u32 {
(fps * 2).max(20)
}
assert_eq!(gop(15), 30); // 15fps -> 30
assert_eq!(gop(30), 60); // 30fps -> 60
assert_eq!(gop(60), 120); // 60fps -> 120
assert_eq!(gop(5), 20); // 5fps -> 20 (floor)
}
#[test]
fn h264_level_values() {
// Level 4.0 supports up to 1080p@30fps (used for file muxer)
assert_eq!(40i32, 40);
// Level 4.2 supports up to 1440p@30fps (used for WebRTC low-latency encoder)
assert_eq!(42i32, 42);
}
// ── Task 4: Duplicate frame hash detection ──
#[test]
fn hash_sampled_y_plane_first_frame_consistent() {
let width = 64;
let height = 64;
let stride = 64;
let y_data = vec![0u8; stride * height];
let hash1 = hash_sampled_y_plane(&y_data, width, height, stride);
let hash2 = hash_sampled_y_plane(&y_data, width, height, stride);
assert_eq!(hash1, hash2, "same input should produce same hash");
}
#[test]
fn hash_sampled_y_plane_detects_different_frames() {
let width = 64;
let height = 64;
let stride = 64;
let y_data1 = vec![0u8; stride * height];
let y_data2 = vec![128u8; stride * height];
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
assert_ne!(
hash1, hash2,
"different frame data should produce different hashes"
);
}
#[test]
fn hash_sampled_y_plane_samples_every_8th_row() {
// Changing a non-sampled row (e.g., row 1) should NOT change the hash
let width = 64;
let height = 64;
let stride = 64;
let y_data1 = vec![0u8; stride * height];
let mut y_data2 = vec![0u8; stride * height];
// Row 1 is NOT sampled (sampling is every 8th row: 0, 8, 16, ...)
y_data2[row_range(1, stride, width)].fill(255);
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
assert_eq!(
hash1, hash2,
"non-sampled row change should not affect hash"
);
}
#[test]
fn hash_sampled_y_plane_sensitive_to_sampled_row() {
// Changing a sampled row (row 0) SHOULD change the hash
let width = 64;
let height = 64;
let stride = 64;
let y_data1 = vec![0u8; stride * height];
let mut y_data2 = vec![0u8; stride * height];
// Row 0 IS sampled (every 8th row starting from 0)
y_data2[row_range(0, stride, width)].fill(255);
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
assert_ne!(
hash1, hash2,
"sampled row change should produce different hash"
);
}
#[test]
fn hash_sampled_y_plane_handles_stride_greater_than_width() {
// Stride can be larger than width due to alignment; unused padding should not affect hash
let width = 32;
let height = 16;
let stride = 64; // padded stride
let y_data1 = vec![0u8; stride * height];
let mut y_data2 = vec![0u8; stride * height];
// Fill the padding area (columns 32..63) of row 0 with garbage
y_data2[width..stride].fill(0xFF);
let hash1 = hash_sampled_y_plane(&y_data1, width, height, stride);
let hash2 = hash_sampled_y_plane(&y_data2, width, height, stride);
assert_eq!(
hash1, hash2,
"padding bytes beyond width should not affect hash"
);
}
}
+268
View File
@@ -0,0 +1,268 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use super::ff_err;
pub(super) fn create_nv12_to_yuv420p_sws(width: u32, height: u32) -> Result<*mut ffi::SwsContext> {
// SAFETY: sws_getContext creates an owned scaler context for same-size NV12 -> YUV420P.
let ctx = unsafe {
ffi::sws_getContext(
width as i32,
height as i32,
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
width as i32,
height as i32,
ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
2,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
if ctx.is_null() {
bail!("Failed to create NV12 -> YUV420P sws_scale context");
}
Ok(ctx)
}
pub(super) fn alloc_yuv420p_frame(width: u32, height: u32) -> Result<*mut ffi::AVFrame> {
// SAFETY: Allocate an AVFrame, configure format/dimensions, then allocate writable buffers.
unsafe {
let mut frame = ffi::av_frame_alloc();
if frame.is_null() {
bail!("av_frame_alloc failed");
}
(*frame).width = width as i32;
(*frame).height = height as i32;
(*frame).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
let ret = ffi::av_frame_get_buffer(frame, 0);
if ret < 0 {
ffi::av_frame_free(&mut frame);
bail!("av_frame_get_buffer failed: {}", ff_err(ret));
}
Ok(frame)
}
}
pub(super) fn create_software_h264_muxer(
output_path: &Path,
width: u32,
height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
) -> Result<(
ff::codec::encoder::video::Video,
ff::format::context::Output,
)> {
let output_cstr = CString::new(output_path.to_str().unwrap())?;
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
})?;
let codec_name = codec.name().to_string();
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_bit_rate(bitrate as usize);
enc.set_gop(gop_size);
enc.set_time_base(ff::Rational::new(1, fps as i32));
enc.set_max_b_frames(3);
// SAFETY: global headers are needed by MP4 and harmless for other common muxers.
unsafe {
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
}
if codec_name == "libx264" {
// SAFETY: priv_data and codec context belong to the unopened encoder;
// strings live for each av_opt_set call.
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("fast").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("threads").unwrap();
let val = CString::new("6").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
// SAFETY: enc is a valid, initialized AVCodecContext from
// avcodec_alloc_context3. Setting level is a simple i32 field
// assignment on a properly aligned struct.
(*enc.as_mut_ptr()).level = 40; // H.264 Level 4.0 (up to 1080p@30)
}
}
let opened = enc
.open()
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
let enc_video = opened.0;
let use_null = output_path
.to_str()
.map(|s| s.contains("null"))
.unwrap_or(false);
let fmt_name = if use_null {
CString::new("null").unwrap()
} else {
CString::new("").unwrap()
};
let fmt_name_ptr = if use_null {
fmt_name.as_ptr()
} else {
ptr::null()
};
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// SAFETY: fmt_ctx_ptr is initialized by FFmpeg; C strings live across the call.
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
fmt_name_ptr,
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: {}", ff_err(ret));
}
// SAFETY: fmt_ctx_ptr is valid; stream and codec parameters are owned by the format context.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create output stream");
}
// SAFETY: stream_ptr and encoder context are valid; parameters are copied into stream.
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy codec parameters to stream: {}", ff_err(ret));
}
// SAFETY: stream_ptr is valid and writable during muxer setup.
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// SAFETY: open an AVIO only for muxers that require files; null muxer advertises NOFILE.
unsafe {
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
let ret = ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
);
if ret < 0 {
bail!(
"Failed to open output file '{}': {}",
output_path.display(),
ff_err(ret)
);
}
}
}
// SAFETY: fmt_ctx_ptr is fully configured.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write output header: {}", ff_err(ret));
}
// SAFETY: ownership of fmt_ctx_ptr transfers to ffmpeg-next Output wrapper.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
tracing::info!("Using software H.264 encoder: {codec_name}");
Ok((enc_video, octx))
}
pub(super) fn create_software_h264_encoder(
width: u32,
height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
) -> Result<ff::codec::encoder::video::Video> {
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| anyhow::anyhow!("No H.264 software encoder found"))?;
let codec_name = codec.name().to_string();
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_bit_rate(bitrate as usize);
enc.set_gop(gop_size);
// 90kHz media clock matches RTP directly. Eliminates 1/fps quantization
// that previously caused sequential RTP timestamps during 60fps capture,
// leading to 2x RTP time inflation and 10s+ browser jitter buffer growth.
// See issue #25.
enc.set_time_base(ff::Rational::new(1, 90_000));
// Explicit framerate is REQUIRED when time_base is not 1/fps, otherwise
// libx264 infers wrong fps from the 90kHz time_base and VBV rate control
// breaks. Per Oracle review round for #25.
enc.set_frame_rate(Some(ff::Rational::new(fps as i32, 1)));
enc.set_max_b_frames(0);
if codec_name == "libx264" {
// SAFETY: priv_data and codec context belong to the unopened encoder;
// each CString lives for the duration of its av_opt_set call.
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("tune").unwrap();
let val = CString::new("zerolatency").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("threads").unwrap();
let val = CString::new("6").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
// High profile via AVCodecContext.profile (not x264opts - x264 rejects it there).
// High enables CABAC + 8x8dct automatically.
(*enc.as_mut_ptr()).profile = ffi::AV_PROFILE_H264_HIGH;
// SAFETY: enc is a valid, initialized AVCodecContext from
// avcodec_alloc_context3. Setting level is a simple i32 field
// assignment on a properly aligned struct.
(*enc.as_mut_ptr()).level = 42; // H.264 Level 4.2 (up to 1440p@30)
// SAFETY: priv_data belongs to the unopened libx264 encoder context.
// `forced-idr` is an FFmpeg-level private option (not x264-native),
// so it must be set via av_opt_set, NOT via the x264opts string.
// With forced-idr=1, setting AV_PICTURE_TYPE_I on an input frame
// produces a true IDR NALU with inline SPS/PPS (repeat_headers=1).
let key = CString::new("forced-idr").unwrap();
let val = CString::new("1").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("x264opts").unwrap();
// x264's vbv-maxrate unit is kbit/s and vbv-bufsize is kbit (NOT bps).
// Confirmed via x264 source encoder/ratecontrol.c:658-661 which multiplies
// these values by 1000 to convert kbit -> bit at use site. Passing bps makes
// VBV effectively unbounded (5.5 Mbps becomes 5.5 Gbps, clipped to 2 Gbps).
// See https://github.com/mirror/x264/blob/c24e06c2e184345ceb33eb20a15d1024d9fd3497/encoder/ratecontrol.c#L658-L661
let vbv_maxrate_kbps = bitrate / 1000;
let vbv_bufsize_kbps = (bitrate / 4) / 1000;
let val = CString::new(format!(
"repeat_headers=1:vbv-maxrate={vbv_maxrate_kbps}:vbv-bufsize={vbv_bufsize_kbps}"
))
.unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
}
}
let opened = enc
.open()
.map_err(|e| anyhow::anyhow!("Failed to open {codec_name} encoder: {e}"))?;
tracing::info!("WebRTC encoder: {codec_name} {width}x{height} @ {fps}fps {bitrate}bps (profile High, preset veryfast)");
Ok(opened.0)
}
+111
View File
@@ -0,0 +1,111 @@
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use ffmpeg_next as ff;
use super::{AvHwFrameCtx, EncodeStages, EncodedH264Frame, SwEncEncode, SwEncImport};
pub struct SwEncState {
import: SwEncImport,
encode: SwEncEncode,
}
// SAFETY: SwEncState is moved to a single encode thread and accessed only there.
// All FFmpeg handles (SwsContext, AVFrame, AVCodecContext) inside SwEncImport /
// SwEncEncode are non-thread-safe but Send-sound under exclusive access.
// Existing sync callers move it across threads only with external serialization.
unsafe impl Send for SwEncState {}
impl SwEncState {
#[allow(clippy::too_many_arguments)]
pub fn new(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
) -> Result<Self> {
tracing::info!(
"SwEncState::new: GPU downscale {width}x{height} BGRA -> {enc_width}x{enc_height} NV12, software H.264"
);
let import = SwEncImport::new(drm_device, width, height, enc_width, enc_height, fps)?;
let encode =
SwEncEncode::new_muxer(output_path, enc_width, enc_height, fps, bitrate, gop_size)?;
Ok(Self { import, encode })
}
#[allow(clippy::too_many_arguments)]
pub fn new_webrtc(
drm_device: &Path,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
fps: u32,
bitrate: u64,
gop_size: u32,
tx: crossbeam_channel::Sender<EncodedH264Frame>,
webrtc_paused: Arc<AtomicBool>,
) -> Result<Self> {
tracing::info!(
"SwEncState::new_webrtc: GPU downscale {width}x{height} BGRA -> {enc_width}x{enc_height} NV12, software H.264 -> WebRTC"
);
let import = SwEncImport::new(drm_device, width, height, enc_width, enc_height, fps)?;
let (dummy_tx, bitrate_rx) = crossbeam_channel::bounded(1);
drop(dummy_tx);
let (dummy_resolution_tx, resolution_rx) = crossbeam_channel::bounded(1);
drop(dummy_resolution_tx);
let encode = SwEncEncode::new_webrtc(
enc_width,
enc_height,
fps,
bitrate,
gop_size,
tx,
webrtc_paused,
bitrate_rx,
resolution_rx,
)?;
Ok(Self { import, encode })
}
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
self.import.frames_rgb()
}
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
// SW path: import_and_scale bundles GPU filter graph (scale) + GPU->CPU
// readback (transfer) into one call. Timing them separately requires
// extending import_and_scale's signature; for now both roll into
// scale_us and transfer_us stays 0 with this comment as the honest
// statement. Oracle audit 2026-06-28 step 4.
let scale_start = Instant::now();
let cpu_frame = self.import.import_and_scale(hw_frame)?;
let scale_us = scale_start.elapsed().as_micros() as u64;
let encode_start = Instant::now();
self.encode.encode_cpu_frame(&cpu_frame)?;
let encode_us = encode_start.elapsed().as_micros() as u64;
Ok(EncodeStages {
scale_us,
transfer_us: 0,
encode_us,
})
}
pub fn flush(&mut self) -> Result<()> {
for frame in self.import.flush_import()? {
self.encode.encode_cpu_frame(&frame)?;
}
self.encode.flush()?;
self.encode.write_trailer_if_needed()
}
}
+88
View File
@@ -0,0 +1,88 @@
/// Commands sent from the WebRTC thread to the SW encoder when the
/// bandwidth estimate changes significantly.
pub enum BitrateCommand {
UpdateBitrate {
target_bps: u64,
},
UpdateResolution {
width: u32,
height: u32,
},
/// Force the next encoded frame to be an IDR. Sent by the WebRTC thread
/// in response to str0m `Event::KeyframeRequest` or a resolution change.
ForceKeyframe,
}
#[derive(Clone, Copy, Debug)]
pub struct ResolutionChange {
pub width: u32,
pub height: u32,
}
/// Per-frame timing snapshot for the software encoder, consumed by the stats
/// thread. `sws_us` measures NV12->YUV420P conversion, `encode_us` measures
/// `avcodec_send_frame` + drain, and `output_bytes` counts encoded bytes
/// produced by libavcodec (even if downstream delivery later drops them).
#[derive(Default, Clone, Copy, Debug)]
pub struct SwEncodeTiming {
pub sws_us: u64,
pub encode_us: u64,
pub output_bytes: usize,
}
/// Outcome of a single `encode_cpu_frame` call. Used by the encode thread
/// to decide whether to report timing stats (only real encodes tick encoded_fps).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodeOutcome {
/// Frame was actually encoded and produced output bytes.
Encoded,
/// Frame was dropped because WebRTC is paused (no client connected).
SkippedPaused,
/// Frame was dropped because the encoder is in disconnected state.
SkippedDisconnected,
/// Frame was dropped because its Y-plane hash matched the previous frame.
SkippedDuplicate,
}
/// Per-stage timing breakdown for one encode cycle on the hardware path.
/// Returned by `EncState::encode_frame` so callers can fold the numbers
/// into `crate::stats::FrameTimings`. `transfer_us` is always 0 on the HW
/// path because the frame stays on the GPU; the SW path's struct (if added
/// later) would carry a real readback measurement.
#[derive(Debug, Default, Clone, Copy)]
pub struct EncodeStages {
pub scale_us: u64,
pub transfer_us: u64,
pub encode_us: u64,
}
/// Encoded H.264 frame with timing metadata for WebRTC output.
///
/// MP4 file output (FrameOutput::Muxer) does NOT use this - it writes via
/// avformat which preserves PTS internally. WebRTC output (FrameOutput::Channel)
/// requires explicit PTS propagation so RTP timestamps reflect real capture time.
/// Without this, WebRTC clients' jitter buffers grow to seconds under
/// damage-driven variable frame rate. See issue #24.
#[derive(Debug)]
pub struct EncodedH264Frame {
/// H.264 NAL byte stream (Annex B or AVCC depending on encoder configuration)
pub data: Vec<u8>,
/// PTS in encoder time_base units (1/fps seconds), normalized so first frame = 0.
/// Derived from real capture time, NOT frame counter.
pub pts_ticks: i64,
/// Wall-clock capture time, propagated from CpuNv12Frame for frame_age stat.
pub capture_time: std::time::Instant,
}
/// Owned CPU NV12 frame data for cross-thread transfer.
/// Produced by main thread (VAAPI import + GPU scale + transfer), consumed by encode thread.
pub struct CpuNv12Frame {
pub y_data: Vec<u8>,
pub uv_data: Vec<u8>,
pub y_stride: usize,
pub uv_stride: usize,
pub pts: i64,
/// Wall-clock time when this frame was captured (PipeWire delivery).
/// Used for frame_age stat: time from capture to WebRTC send.
pub capture_time: std::time::Instant,
}
+20
View File
@@ -0,0 +1,20 @@
use ffmpeg_next::ffi;
/// Convert an FFmpeg error code to a human-readable string.
pub fn av_err_to_string(err: i32) -> String {
let mut buf = vec![0u8; 128];
// SAFETY: buf points to 128 writable bytes and lives for the duration of
// av_strerror.
unsafe {
ffi::av_strerror(err, buf.as_mut_ptr() as *mut i8, buf.len());
}
String::from_utf8_lossy(&buf)
.trim_end_matches('\0')
.to_string()
}
/// Format an FFmpeg error code with both numeric value and description.
/// Example output: "error -22 (Invalid argument)"
pub(crate) fn ff_err(ret: i32) -> String {
format!("error {ret} ({})", av_err_to_string(ret))
}
+2 -155
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,16 +111,10 @@ fn check_portal_available() -> bool {
}
};
// 查询 ScreenCast 接口的 `version` 属性——这是最可能卡住的操作,
// 因为前两步只是本地构造 proxy,而 get_property 需要 Portal 端实际处理请求。
// `.get_property::<u32>("version")`:泛型方法,turbofish `::<u32>` 指定返回类型,
// 类比 Go `GetVersion() (uint32, error)`——但 Rust 用泛型 + 编译期单态化。
// The most likely operation to hang — requires actual Portal-side work.
// 最可能卡住的操作,需要 Portal 端实际处理。
let version = match tokio::time::timeout(
PORTAL_DBUS_TIMEOUT,
inner.get_property::<u32>("version"),
)
let version =
match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, inner.get_property::<u32>("version"))
.await
{
Ok(Ok(version)) => {
@@ -240,26 +135,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 +169,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 +180,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 +191,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 +215,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 +240,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 +263,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'"),
+76
View File
@@ -0,0 +1,76 @@
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
pub fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
// Drain-and-wait loop that mirrors production's repeated-poll semantics
// (state_portal.rs::poll_and_encode driven by main.rs's outer loop), but with
// a single bounded 10s total deadline appropriate for a bench tool. Unlike a
// single 10s blocking wait, this loop actually iterates: each turn drains ALL
// pending control events (the ctrl channel is bounded to 8 — a single
// if-let would silently miss backlog) and then waits a short slice for a
// frame, so StreamEnded/Error arriving mid-wait are observed within ~200ms.
const TOTAL_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
const WAIT_SLICE: std::time::Duration = std::time::Duration::from_millis(200);
let deadline = Instant::now() + TOTAL_DEADLINE;
loop {
while let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
PwCtrlEvent::FormatChanged { .. } => {}
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
let remaining = match deadline.checked_duration_since(Instant::now()) {
Some(r) if !r.is_zero() => r,
_ => bail!("Timeout waiting for first frame (10s)"),
};
let slice = remaining.min(WAIT_SLICE);
match cap.frame_receiver().recv_timeout(slice) {
Ok(frame) => return Ok(frame),
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
bail!("PipeWire frame channel disconnected");
}
}
}
}
pub fn drain_encoder(
enc_video: &mut ff::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// SAFETY: enc_video is the opened encoder; pkt is an empty Packet whose
// inner AVPacket pointer is valid. avcodec_receive_packet fills pkt with
// the next encoded packet, or returns EAGAIN/EOF when drained.
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
eprintln!("avcodec_receive_packet failed: {ret}");
break;
}
let enc_tb = enc_video.time_base();
// SAFETY: octx.as_ptr() is a valid AVFormatContext; streams is a NULL-terminated
// array of AVStream*. We index [0] which exists because we created exactly one
// stream in setup. Reading time_base is a plain AVRational field access.
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
Ok(())
}
-705
View File
@@ -1,705 +0,0 @@
//! 软件编码流水线性能基准(独立二进制 `sw_encode_bench`)。
//!
//! ## 用途
//!
//! 测量"纯 CPU"屏幕采集编码流水线的端到端耗时,作为对照参考与 VAAPI 硬件编码
//! 基准 `vaapi_import_bench``src/bin/vaapi_import_bench.rs`)形成对比:
//! - 本文件:Portal 采集 → `mmap` 把 DMA-BUF 映射到用户态 → `sws_scale` 在 CPU
//! 上做 BGR0→YUV420P 颜色空间/缩放转换 → libx264/openh264 软件编码。
//! - 对照 `vaapi_import_bench.rs`Portal 采集 → `av_hwframe_map` 在 GPU 上做
//! 零拷贝格式转换 → VAAPI 硬件编码(GPU)。
//!
//! ## 输出
//!
//! 打印 mmap / sws_scale / encode 三段每帧平均耗时与总体 FPS,便于判断"软件路径"
//! 在当前硬件上能否达到 30 FPS 目标。AMD GPU 在某些驱动下不允许 CPU 读取 DMA-BUF
//! `mmap` 会失败——这正是 `vaapi_import_bench` 存在的意义。
//!
//! ## Rust ↔ Go 对照
//!
//! - `clap::Parser` derive 宏:类似 Go 的 `flag` 包,但在编译期生成解析代码。
//! - `std::time::Instant`:高精度单调时钟,等价于 Go 的 `time.Now()` + `time.Since()`。
//! - `crossbeam_channel::recv_timeout`:等价于 Go 的 `select { case <-time.After(): }`。
//! - 本文件大量使用裸 `unsafe` FFI 调用 FFmpeg C API;现有 21 处 unsafe 块均
//! 未标注 SAFETY 标记,本任务也不补充,仅在每个 unsafe 块上方加普通 `//`
//! 中文概述,说明"为什么必须 unsafe"。
//!
//! 用法:`cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4`
// sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture
//
// Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode
//
// Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4
// 以下 `use` 语句分组:FFI 字符串/裸 fd 转换/路径/指针/计时 → anyhow/clap →
// ffmpeg_next 别名与 ffi → crate 内 Portal 采集器。Rust 没有 Go 的 "package"
// 概念,每个外部 crate 都要显式 `use`。
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
use std::ptr;
use std::time::Instant;
use anyhow::{bail, Result};
// `clap::Parser` derive 宏:编译期生成 CLI 解析代码,等价于 Go 的 `flag` 包
// 但支持子命令/类型转换/帮助文本自动生成。
use clap::Parser;
// FFmpeg 绑定,使用 `ffmpeg_next` crate(社区维护的 next 分支)。`as ff` 别名
// 缩短调用路径;`ffi` 子模块直接暴露 C ABI(裸指针、`AVFormatContext` 等)。
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
// 复用主程序的 `Args` 与 Portal 采集器:基准与主二进制共享同一采集代码路径,
// 仅"消费方"不同(基准直接落盘,主程序走 WebRTC 推流)。
use wl_webrtc::args::Args;
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
// 基准 CLI 参数定义。`#[derive(Parser, Debug)]` 让 clap 在编译期为 struct
// 生成 `parse()` 方法;`#[command(...)]` 设置程序元信息。等价于 Go 程序的
// `flag.StringVar(...)` 序列,但在 Rust 里完全声明式。
#[derive(Parser, Debug)]
#[command(
name = "sw_encode_bench",
about = "Software encoding pipeline benchmark"
)]
struct BenchArgs {
#[arg(short, long)]
output: String,
#[arg(long, default_value_t = 120)]
frames: u32,
#[arg(long, default_value_t = 2560)]
enc_width: u32,
#[arg(long, default_value_t = 1440)]
enc_height: u32,
}
// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec
// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats
// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。
#[derive(Default)]
struct FrameStats {
mmap_us: Vec<u64>,
scale_us: Vec<u64>,
encode_us: Vec<u64>,
total_us: Vec<u64>,
mmap_failures: u32,
}
// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的
// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯,
// 也可以写成自由函数 `fn avg_ms(...)`。
impl FrameStats {
// 把 Vec<u64> 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec
// 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
}
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
}
// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始
// `AVPixelFormat`i32 别名)。`Into::into` 在此处零成本——编译期已知映射。
fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
Into::<ffi::AVPixelFormat>::into(p)
}
// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。
// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比:
// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }`
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
loop {
// `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
PwCtrlEvent::FormatChanged { .. } => {}
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
// `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
{
Ok(frame) => return Ok(frame),
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
bail!("Timeout waiting for first frame (10s)");
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
bail!("PipeWire frame channel disconnected");
}
}
}
}
// 程序入口。流程四阶段:[1/4] 申请 Portal 授权并连接 PipeWire[2/4] 等首帧
// 拿到 DMA-BUF 元数据(宽高/stride/fd);[3/4] 试 mmap 一帧验证 CPU 可读;
// [4/4] 配置 libx264 编码器 + FFmpeg 输出格式上下文,进入主采集编码循环并打印统计。
// `anyhow::Result<()>` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回
// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。
fn main() -> Result<()> {
// clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。
let bench_args = BenchArgs::parse();
println!("=== Software Encode Benchmark ===");
println!("Output: {}", bench_args.output);
println!("Target frames: {}", bench_args.frames);
println!(
"Encode resolution: {}x{}",
bench_args.enc_width, bench_args.enc_height
);
println!();
// 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。
ff::init()?;
println!("[1/4] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了
// 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
fps: 60,
codec: "h264".to_string(),
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: Some("portal".to_string()),
port: 0,
no_persist: false,
stats: false,
};
// `CapPortal::new` 会触发 XDG Portal 授权对话框(用户需要在屏幕共享对话框里选屏)。
let cap = CapPortal::new(&portal_args)?;
println!("[1/4] Portal connected, PipeWire stream active\n");
println!("[2/4] Waiting for first frame from PipeWire...");
let first_frame = receive_first_frame(&cap)?;
// PipeWire 推来的首帧携带了 DMA-BUF 的元数据:fd(文件描述符)+ offset
// + stride(每行字节数)+ width/height/format。后续 mmap 就靠这些。
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_stride = first_frame.stride;
let enc_width = bench_args.enc_width;
let enc_height = bench_args.enc_height;
println!(
"[2/4] First frame: {}x{}, stride={}, format=0x{:08X}",
src_width, src_height, src_stride, first_frame.format
);
println!(
" Capture: {}x{} Encode: {}x{}\n",
src_width, src_height, enc_width, enc_height
);
println!("[3/4] Testing mmap on DMA-BUF...");
let mmap_size = (src_stride as usize) * (src_height as usize);
// unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以
// 必须 unsafemmap 接受 void* 返回 raw 指针,编译器无法验证其有效性;
// 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
mmap_size,
libc::PROT_READ,
libc::MAP_SHARED,
first_frame.fd.as_raw_fd(),
first_frame.offset as i64,
)
};
// `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读
// DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
bail!(
"mmap on DMA-BUF fd FAILED — AMD driver may not support \
CPU read of screen capture DMA-BUF buffers.\n\
Error: {} (errno={})\n\
\n\
Workarounds:\n\
1. Use VAAPI hardware import (av_hwframe_map) instead of mmap\n\
2. Use wlroots compositor with wlr-screencopy (SHM-based)\n\
3. Use a virtual display or software renderer",
errno,
errno.raw_os_error().unwrap_or(-1)
);
}
println!(
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
mmap_size as f64 / 1024.0 / 1024.0
);
// unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer
// 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
drop(first_frame);
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
println!("[4/4] Setting up libx264 encoder...");
// 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。
// CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。
let output_path = Path::new(&bench_args.output);
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// Try libx264 first (best quality/speed), fall back to openh264
// 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。
// Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的
// 多次 if err != nil 但不嵌套。
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
})?;
println!("[4/4] Using encoder: {}\n", codec.name());
// 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_time_base(ff::Rational::new(1, 60));
enc.set_max_b_frames(0);
enc.set_gop(60);
let codec_name = codec.name();
if codec_name == "libx264" {
// unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。
// FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存,
// 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("tune").unwrap();
let val = CString::new("zerolatency").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
}
}
let opened = enc.open()?;
let mut enc_video = opened.0;
// Create output format context via FFI
// FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// unsafe #4`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回
// 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证
// 调用方传入了正确的容器格式猜测。
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
ptr::null(),
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: error {ret}");
}
// unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。
// 返回的 `stream_ptr` 是裸指针,调用方负责不 double-freeFFmpeg 内部托管)。
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream");
}
// unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。
// FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证
// 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy encoder parameters: error {ret}");
}
// unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续
// mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格
// 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// unsafe #8`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期
// 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
)
};
if ret < 0 {
bail!(
"Failed to open output file '{}': error {ret}",
output_path.display()
);
}
// unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束
// (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write header: error {ret}");
}
// unsafe #10`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。
// unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码
// 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中
// 同款的"独占所有权"约定。
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
// sws_scale 是 FFmpeg 的颜色空间转换器(CPU 软件)。本基准的"软件路径"核心:
// 把 DMA-BUF 的 BGR0 像素数据转成 libx264 期望的 YUV420P planar 格式。
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
// unsafe #11`sws_getContext` 创建转换器。FFI 必须 unsafe:返回 raw 指针,
// 调用方负责后续 `sws_freeContext` 释放(cleanup 阶段会做)。
let sws_ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
src_height as i32,
bgr0_fmt,
enc_width as i32,
enc_height as i32,
yuv420p_fmt,
2,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
if sws_ctx.is_null() {
bail!("Failed to create sws_scale context");
}
// Allocate reusable YUV frame
// 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求
// 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。
let mut yuv_frame = unsafe {
// unsafe #12`av_frame_alloc` 只分配 struct 本体,不分配 data 缓冲区。
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
// unsafe #13:通过裸指针写入 width/height/format 字段。
(*f).width = enc_width as i32;
(*f).height = enc_height as i32;
(*f).format = yuv420p_fmt as i32;
// unsafe #14`av_frame_get_buffer` 根据 width/height/format 分配实际像素缓冲区。
// 失败时必须 free 已分配的 struct(避免泄漏)。
let ret = ffi::av_frame_get_buffer(f, 0);
if ret < 0 {
ffi::av_frame_free(&mut f);
bail!("av_frame_get_buffer failed: {ret}");
}
f
};
println!(
"[4/4] Encoder ready: {}, {}x{}\n",
codec_name, enc_width, enc_height
);
println!("=== Encoding {} frames ===\n", bench_args.frames);
// 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响),
// 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。
let mut stats = FrameStats::default();
let total_start = Instant::now();
let mut frames_encoded: u32 = 0;
let mut pts: i64 = 0;
// 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。
while frames_encoded < bench_args.frames {
// 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result<Option<T>>。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => {
eprintln!("PipeWire stream ended after {} frames", frames_encoded);
break;
}
PwCtrlEvent::Error(e) => {
eprintln!("PipeWire error after {} frames: {}", frames_encoded, e);
break;
}
PwCtrlEvent::FormatChanged { .. } => {}
}
}
// 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
{
Ok(f) => f,
Err(_) => {
eprintln!("Frame timeout/disconnect after {} frames", frames_encoded);
break;
}
};
// 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。
let frame_start = Instant::now();
// ---- 第 1 段:mmap DMA-BUF 到用户态 ----
let mmap_start = Instant::now();
let frame_size = (frame.stride as usize) * (frame.height as usize);
// unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到
// 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
frame_size,
libc::PROT_READ,
libc::MAP_SHARED,
frame.fd.as_raw_fd(),
frame.offset as i64,
)
};
if mmap_ptr == libc::MAP_FAILED {
stats.mmap_failures += 1;
eprintln!("mmap failed on frame {}", frames_encoded);
drop(frame);
continue;
}
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
// ---- 第 2 段:sws_scale BGR0 → YUV420P ----
let scale_start = Instant::now();
// unsafe #16`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。
// 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向
// 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
// unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险:
// (1) 裸指针 src_ptr / src_linesize(2) yuv_frame->data/linesize 数组
// 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。
unsafe {
ffi::av_frame_make_writable(yuv_frame);
let src_ptr = src_data.as_ptr();
let src_linesize = frame.stride as i32;
ffi::sws_scale(
sws_ctx,
&src_ptr as *const *const u8,
&src_linesize as *const i32,
0,
frame.height as i32,
(*yuv_frame).data.as_ptr() as *mut *mut u8,
(*yuv_frame).linesize.as_ptr() as *mut i32,
);
}
stats
.scale_us
.push(scale_start.elapsed().as_micros() as u64);
// unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。
unsafe {
libc::munmap(mmap_ptr, frame_size);
}
drop(frame);
// ---- 第 3 段:libx264 编码 ----
let encode_start = Instant::now();
// unsafe #19`avcodec_send_frame` 把一帧 YUV 喂给编码器(异步:内部入队)。
// FFI 必须 unsafe:裸指针 enc_video.as_mut_ptr()/yuv_frame;编译期无法
// 验证 enc 已 open、yuv_frame 的 width/height/format 与编码器配置一致。
unsafe {
(*yuv_frame).pts = pts;
pts += 1;
let ret = ffi::avcodec_send_frame(enc_video.as_mut_ptr(), yuv_frame);
if ret < 0 {
eprintln!("avcodec_send_frame failed: {ret}");
continue;
}
}
drain_encoder(&mut enc_video, &mut octx)?;
stats
.encode_us
.push(encode_start.elapsed().as_micros() as u64);
stats
.total_us
.push(frame_start.elapsed().as_micros() as u64);
frames_encoded += 1;
if frames_encoded % 30 == 0 {
let fps = frames_encoded as f64 / total_start.elapsed().as_secs_f64();
println!(
" [{}/{}] {:.1} FPS",
frames_encoded, bench_args.frames, fps
);
}
}
let total_elapsed = total_start.elapsed();
println!("\nFlushing encoder...");
// unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。
// 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。
unsafe {
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
}
drain_encoder(&mut enc_video, &mut octx)?;
octx.write_trailer()
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
// Cleanup
// unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII
// 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。
unsafe {
ffi::av_frame_free(&mut yuv_frame as *mut _);
ffi::sws_freeContext(sws_ctx);
}
drop(cap);
// Print results
// 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比
// `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。
let mmap_count = stats.mmap_us.len() as u32;
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
} else {
0.0
};
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
// 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。
let max_fps = if avg_total_ms > 0.0 {
1000.0 / avg_total_ms
} else {
0.0
};
println!();
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ Software Encode Benchmark Results ║");
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
println!("Capture resolution: {}x{}", src_width, src_height);
println!("Encode resolution: {}x{}", enc_width, enc_height);
println!("Frames encoded: {}", frames_encoded);
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
println!();
println!("mmap (DMA-BUF -> CPU):");
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.mmap_us)
);
println!(
" success rate: {:.1}% ({}/{})",
mmap_success_rate,
mmap_count,
mmap_count + stats.mmap_failures
);
println!();
println!("scale (BGR0 -> YUV420P via sws_scale):");
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.scale_us)
);
println!();
println!("encode ({}):", codec_name);
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.encode_us)
);
println!();
println!("total pipeline:");
println!(" avg: {:.2} ms/frame", avg_total_ms);
println!(" achieved FPS: {:.1}", total_fps);
println!(" max theoretical: {:.1} FPS", max_fps);
println!();
if mmap_success_rate < 100.0 {
println!(
"WARNING: Some mmap operations failed ({}/{})",
stats.mmap_failures,
stats.mmap_failures + mmap_count
);
}
if total_fps < 30.0 {
println!(
"NOTE: Achieved FPS ({:.1}) is below 30 FPS target.",
total_fps
);
}
println!("Output written to: {}", bench_args.output);
Ok(())
}
// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是
// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264
// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN
// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。
fn drain_encoder(
enc_video: &mut ff::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// unsafe #22`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须
// unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
// EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
eprintln!("avcodec_receive_packet failed: {ret}");
break;
}
let enc_tb = enc_video.time_base();
// unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于
// rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams
// 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream
// 就 bail 了)。
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
// 把 PTS 从编码器时基 rescale 到流时基(mp4 容器要求)。Go 类比:单位换算。
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
// `write_interleaved` 让 FFmpeg 自动处理 interleaving(音视频交错,避免 demuxer 卡)。
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
Ok(())
}
+510
View File
@@ -0,0 +1,510 @@
// sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture
//
// Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode
//
// Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
use std::ptr;
use std::time::Instant;
use anyhow::{bail, Result};
use clap::Parser;
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use wl_webrtc::args::Args;
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
#[path = "../common/mod.rs"]
mod common;
mod stats;
use stats::{pix_fmt, BenchArgs, FrameStats};
fn main() -> Result<()> {
let bench_args = BenchArgs::parse();
println!("=== Software Encode Benchmark ===");
println!("Output: {}", bench_args.output);
println!("Target frames: {}", bench_args.frames);
println!(
"Encode resolution: {}x{}",
bench_args.enc_width, bench_args.enc_height
);
println!();
ff::init()?;
println!("[1/4] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
fps: 60,
codec: "h264".to_string(),
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: Some("portal".to_string()),
port: 0,
no_persist: false,
stats: false,
};
let cap = CapPortal::new(&portal_args)?;
println!("[1/4] Portal connected, PipeWire stream active\n");
println!("[2/4] Waiting for first frame from PipeWire...");
let first_frame = common::receive_first_frame(&cap)?;
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_stride = first_frame.stride;
let enc_width = bench_args.enc_width;
let enc_height = bench_args.enc_height;
println!(
"[2/4] First frame: {}x{}, stride={}, format=0x{:08X}",
src_width, src_height, src_stride, first_frame.format
);
println!(
" Capture: {}x{} Encode: {}x{}\n",
src_width, src_height, enc_width, enc_height
);
println!("[3/4] Testing mmap on DMA-BUF...");
let mmap_size = (src_stride as usize) * (src_height as usize);
// SAFETY: first_frame.fd is an open DMA-BUF; offset/size come from PipeWire's
// negotiated format. PROT_READ+MAP_SHARED is the standard read-only DMA-BUF
// mapping. Returns MAP_FAILED on error (checked below).
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
mmap_size,
libc::PROT_READ,
libc::MAP_SHARED,
first_frame.fd.as_raw_fd(),
first_frame.offset as i64,
)
};
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
bail!(
"mmap on DMA-BUF fd FAILED — AMD driver may not support \
CPU read of screen capture DMA-BUF buffers.\n\
Error: {} (errno={})\n\
\n\
Workarounds:\n\
1. Use VAAPI hardware import (av_hwframe_map) instead of mmap\n\
2. Use wlroots compositor with wlr-screencopy (SHM-based)\n\
3. Use a virtual display or software renderer",
errno,
errno.raw_os_error().unwrap_or(-1)
);
}
println!(
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
mmap_size as f64 / 1024.0 / 1024.0
);
// SAFETY: mmap_ptr was returned by mmap above and is not MAP_FAILED (checked);
// mmap_size matches the original mapping. POSIX munmap(2) releases the mapping.
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
drop(first_frame);
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
println!("[4/4] Setting up libx264 encoder...");
let output_path = Path::new(&bench_args.output);
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// Try libx264 first (best quality/speed), fall back to openh264
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
})?;
println!("[4/4] Using encoder: {}\n", codec.name());
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_time_base(ff::Rational::new(1, 60));
enc.set_max_b_frames(0);
enc.set_gop(60);
let codec_name = codec.name();
if codec_name == "libx264" {
// SAFETY: enc is a valid AVCodecContext for the not-yet-opened encoder;
// priv_data is the x264 private options struct. All CStrings live across
// both av_opt_set calls. These set the x264 "preset" and "tune" options.
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("tune").unwrap();
let val = CString::new("zerolatency").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
}
}
let opened = enc.open()?;
let mut enc_video = opened.0;
// Create output format context via FFI
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// SAFETY: fmt_ctx_ptr is an out-parameter initialized by FFmpeg; output_cstr
// lives across the call. Returns 0 on success; we check below.
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
ptr::null(),
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: error {ret}");
}
// SAFETY: fmt_ctx_ptr is the valid output context allocated above.
// avformat_new_stream returns a pointer to a new AVStream or NULL on failure.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream");
}
// SAFETY: stream_ptr and enc_video.as_ptr() are valid pointers; codecpar is
// the output destination inside stream. avcodec_parameters_from_context copies
// encoder parameters into the stream's codecpar.
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy encoder parameters: error {ret}");
}
// SAFETY: stream_ptr and enc_video are valid; time_base is a plain AVRational
// field copied from encoder to stream.
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// SAFETY: fmt_ctx_ptr is valid; pb is the AVIOContext slot to initialize;
// output_cstr is a valid NUL-terminated path; AVIO_FLAG_WRITE is a constant.
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
)
};
if ret < 0 {
bail!(
"Failed to open output file '{}': error {ret}",
output_path.display()
);
}
// SAFETY: fmt_ctx_ptr is fully configured (streams + pb set); NULL options
// is the default. Returns 0 on success.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write header: error {ret}");
}
// SAFETY: fmt_ctx_ptr is a fully initialized output context (header written).
// Output::wrap takes ownership of the pointer into a safe RAII wrapper.
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
// SAFETY: all parameters are valid enum/pixel format values; NULL filters are
// allowed by FFmpeg. sws_getContext returns a heap-allocated SwsContext or NULL.
let sws_ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
src_height as i32,
bgr0_fmt,
enc_width as i32,
enc_height as i32,
yuv420p_fmt,
2,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
if sws_ctx.is_null() {
bail!("Failed to create sws_scale context");
}
// Allocate reusable YUV frame
// SAFETY: av_frame_alloc returns NULL only on OOM. After allocation we set
// width/height/format fields and call av_frame_get_buffer to allocate plane
// data. On failure we free the frame via av_frame_free before bailing.
let mut yuv_frame = unsafe {
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
(*f).width = enc_width as i32;
(*f).height = enc_height as i32;
(*f).format = yuv420p_fmt as i32;
let ret = ffi::av_frame_get_buffer(f, 0);
if ret < 0 {
ffi::av_frame_free(&mut f);
bail!("av_frame_get_buffer failed: {ret}");
}
f
};
println!(
"[4/4] Encoder ready: {}, {}x{}\n",
codec_name, enc_width, enc_height
);
println!("=== Encoding {} frames ===\n", bench_args.frames);
let mut stats = FrameStats::default();
let total_start = Instant::now();
let mut frames_encoded: u32 = 0;
let mut pts: i64 = 0;
while frames_encoded < bench_args.frames {
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => {
eprintln!("PipeWire stream ended after {} frames", frames_encoded);
break;
}
PwCtrlEvent::Error(e) => {
eprintln!("PipeWire error after {} frames: {}", frames_encoded, e);
break;
}
PwCtrlEvent::FormatChanged { .. } => {}
}
}
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
{
Ok(f) => f,
Err(_) => {
eprintln!("Frame timeout/disconnect after {} frames", frames_encoded);
break;
}
};
let frame_start = Instant::now();
let mmap_start = Instant::now();
let frame_size = (frame.stride as usize) * (frame.height as usize);
// SAFETY: frame.fd is an open DMA-BUF owned by the frame; offset/size come
// from PipeWire's negotiated format. PROT_READ+MAP_SHARED for read-only
// DMA-BUF access. Returns MAP_FAILED on error (checked below).
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
frame_size,
libc::PROT_READ,
libc::MAP_SHARED,
frame.fd.as_raw_fd(),
frame.offset as i64,
)
};
if mmap_ptr == libc::MAP_FAILED {
stats.mmap_failures += 1;
eprintln!("mmap failed on frame {}", frames_encoded);
drop(frame);
continue;
}
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
let scale_start = Instant::now();
// SAFETY: mmap_ptr is a valid mapping of frame_size bytes (checked above);
// constructing a read-only slice over it for the duration of sws_scale is
// sound as long as we don't hold it past munmap (we don't).
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
// SAFETY: yuv_frame and sws_ctx are valid; src_data is a valid slice of the
// mmap'd DMA-BUF for this frame. sws_scale reads src planes (BGR0 -> YUV420P)
// and writes into yuv_frame's data planes. av_frame_make_writable ensures
// yuv_frame is not shared before writing.
unsafe {
ffi::av_frame_make_writable(yuv_frame);
let src_ptr = src_data.as_ptr();
let src_linesize = frame.stride as i32;
ffi::sws_scale(
sws_ctx,
&src_ptr as *const *const u8,
&src_linesize as *const i32,
0,
frame.height as i32,
(*yuv_frame).data.as_ptr() as *mut *mut u8,
(*yuv_frame).linesize.as_ptr() as *mut i32,
);
}
stats
.scale_us
.push(scale_start.elapsed().as_micros() as u64);
// SAFETY: mmap_ptr was returned by mmap above and is not MAP_FAILED; frame_size
// matches the original mapping. Release before dropping frame (which closes fd).
unsafe {
libc::munmap(mmap_ptr, frame_size);
}
drop(frame);
let encode_start = Instant::now();
// SAFETY: yuv_frame is allocated and writable; enc_video is the opened encoder.
// Setting pts is a plain i64 field write. avcodec_send_frame submits the frame
// for encoding; returns < 0 on error (we log and continue).
unsafe {
(*yuv_frame).pts = pts;
pts += 1;
let ret = ffi::avcodec_send_frame(enc_video.as_mut_ptr(), yuv_frame);
if ret < 0 {
eprintln!("avcodec_send_frame failed: {ret}");
continue;
}
}
common::drain_encoder(&mut enc_video, &mut octx)?;
stats
.encode_us
.push(encode_start.elapsed().as_micros() as u64);
stats
.total_us
.push(frame_start.elapsed().as_micros() as u64);
frames_encoded += 1;
if frames_encoded.is_multiple_of(30) {
let fps = frames_encoded as f64 / total_start.elapsed().as_secs_f64();
println!(
" [{}/{}] {:.1} FPS",
frames_encoded, bench_args.frames, fps
);
}
}
let total_elapsed = total_start.elapsed();
println!("\nFlushing encoder...");
// SAFETY: enc_video is the opened encoder; passing NULL frame signals EOF to
// drain the encoder's internal pipeline. Returns < 0 on error (ignored here).
unsafe {
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
}
common::drain_encoder(&mut enc_video, &mut octx)?;
octx.write_trailer()
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
// Cleanup
// SAFETY: yuv_frame is the allocated frame from earlier (still owned by us);
// sws_ctx is the allocated sws context. av_frame_free and sws_freeContext take
// ownership and free their respective heap allocations.
unsafe {
ffi::av_frame_free(&mut yuv_frame as *mut _);
ffi::sws_freeContext(sws_ctx);
}
drop(cap);
// Print results
let mmap_count = stats.mmap_us.len() as u32;
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
} else {
0.0
};
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
let max_fps = if avg_total_ms > 0.0 {
1000.0 / avg_total_ms
} else {
0.0
};
println!();
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ Software Encode Benchmark Results ║");
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
println!("Capture resolution: {}x{}", src_width, src_height);
println!("Encode resolution: {}x{}", enc_width, enc_height);
println!("Frames encoded: {}", frames_encoded);
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
println!();
println!("mmap (DMA-BUF -> CPU):");
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.mmap_us)
);
println!(
" success rate: {:.1}% ({}/{})",
mmap_success_rate,
mmap_count,
mmap_count + stats.mmap_failures
);
println!();
println!("scale (BGR0 -> YUV420P via sws_scale):");
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.scale_us)
);
println!();
println!("encode ({}):", codec_name);
println!(
" avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.encode_us)
);
println!();
println!("total pipeline:");
println!(" avg: {:.2} ms/frame", avg_total_ms);
println!(" achieved FPS: {:.1}", total_fps);
println!(" max theoretical: {:.1} FPS", max_fps);
println!();
if mmap_success_rate < 100.0 {
println!(
"WARNING: Some mmap operations failed ({}/{})",
stats.mmap_failures,
stats.mmap_failures + mmap_count
);
}
if total_fps < 30.0 {
println!(
"NOTE: Achieved FPS ({:.1}) is below 30 FPS target.",
total_fps
);
}
println!("Output written to: {}", bench_args.output);
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use clap::Parser;
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
#[derive(Parser, Debug)]
#[command(
name = "sw_encode_bench",
about = "Software encoding pipeline benchmark"
)]
pub(crate) struct BenchArgs {
#[arg(short, long)]
pub(crate) output: String,
#[arg(long, default_value_t = 120)]
pub(crate) frames: u32,
#[arg(long, default_value_t = 2560)]
pub(crate) enc_width: u32,
#[arg(long, default_value_t = 1440)]
pub(crate) enc_height: u32,
}
#[derive(Default)]
pub(crate) struct FrameStats {
pub(crate) mmap_us: Vec<u64>,
pub(crate) scale_us: Vec<u64>,
pub(crate) encode_us: Vec<u64>,
pub(crate) total_us: Vec<u64>,
pub(crate) mmap_failures: u32,
}
impl FrameStats {
pub(crate) fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
}
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
}
pub(crate) fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
Into::<ffi::AVPixelFormat>::into(p)
}
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
// vaapi_import_bench.rs — VAAPI DMA-BUF import + GPU-side downscale benchmark
//
// Tests: Portal capture -> av_hwframe_map (ARGB sw_format) -> transfer -> sw encode
//
// Usage: cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
use std::os::fd::AsRawFd;
use std::path::Path;
use std::ptr;
use std::time::Instant;
use anyhow::Result;
use clap::Parser;
use ffmpeg_next as ff;
use wl_webrtc::args::Args;
use wl_webrtc::avhw::{import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
use wl_webrtc::cap_portal::CapPortal;
#[path = "../common/mod.rs"]
mod common;
mod pipeline_cpu;
mod pipeline_gpu;
mod software;
mod stats;
mod util;
use pipeline_cpu::run_cpu_pipeline;
use pipeline_gpu::run_gpu_pipeline;
use stats::{BenchArgs, PipelineMode};
use util::{output_for_mode, print_comparison, print_detailed_results};
fn main() -> Result<()> {
let bench_args = BenchArgs::parse();
println!("=== VAAPI Import Benchmark ===");
println!("Output: {}", bench_args.output);
println!("Target frames: {}", bench_args.frames);
println!(
"Encode resolution: {}x{}",
bench_args.enc_width, bench_args.enc_height
);
println!("DRM device: {}", bench_args.drm_device);
println!();
ff::init()?;
println!("[1/3] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
fps: 60,
codec: "h264".to_string(),
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
max_bitrate: 8_000_000,
gop_size: None,
verbose: false,
backend: Some("portal".to_string()),
port: 0,
no_persist: false,
stats: false,
};
let cap = CapPortal::new(&portal_args)?;
println!("[1/3] Portal connected, PipeWire stream active\n");
println!("[2/3] Waiting for first frame from PipeWire...");
let first_frame = common::receive_first_frame(&cap)?;
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_format = first_frame.format;
println!(
"[2/3] First frame: {}x{}, format=0x{:08X}, stride={}, modifier=0x{:X}",
src_width, src_height, src_format, first_frame.stride, first_frame.modifier
);
println!("\n[2/3] Testing av_hwframe_map with sw_format=BGRA...");
println!(
" DRM format chain: PipeWire BGRA -> DRM_FORMAT_ARGB8888 (0x{:08X}) -> VA_FOURCC_BGRA -> AV_PIX_FMT_BGRA",
src_format
);
let drm_device = Path::new(&bench_args.drm_device);
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
println!(" VAAPI device context created OK");
let frames_ctx =
AvHwFrameCtx::for_capture(&hw_dev, src_width, src_height, ff::format::Pixel::BGRA)?;
println!(" VAAPI frames context created OK (sw_format=BGRA)");
// SAFETY: delegates to avhw::import_dma_buf_to_vaapi (itself an unsafe fn).
// frames_ctx is a valid AVBufferRef from AvHwFrameCtx::for_capture above;
// `first_frame` is the PipeWire-formatted PwDmaBufFrame whose metadata the
// function reads directly. See that function's own SAFETY contract for the
// full rationale.
let vaapi_frame = unsafe { import_dma_buf_to_vaapi(frames_ctx.as_ptr(), &first_frame) };
match &vaapi_frame {
Ok(_) => {
println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!");
}
Err(e) => {
println!(" Result: FAILED");
println!(" Error: {e}");
println!();
println!(" Possible causes:");
println!(" - sw_format mismatch (current: BGRA)");
println!(" - DRM format modifier not supported by VAAPI");
println!(" - VAAPI driver doesn't support DMA-BUF import for this format");
println!();
println!(" Falling back to mmap readback test for comparison...");
let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize);
let mmap_start = Instant::now();
// SAFETY: first_frame.fd is an open DMA-BUF; offset/size from PipeWire.
// PROT_READ+MAP_SHARED is the standard read-only DMA-BUF mapping. Returns
// MAP_FAILED on error (checked below).
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
mmap_size,
libc::PROT_READ,
libc::MAP_SHARED,
first_frame.fd.as_raw_fd(),
first_frame.offset as i64,
)
};
let mmap_elapsed = mmap_start.elapsed();
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
println!(" mmap also FAILED: {errno}");
} else {
println!(
" mmap SUCCESS: {:.1} MB, setup in {:.2}ms",
mmap_size as f64 / 1024.0 / 1024.0,
mmap_elapsed.as_secs_f64() * 1000.0
);
// SAFETY: mmap_ptr is a valid mapping (MAP_FAILED path was handled
// above); mmap_size matches the original mapping. POSIX munmap(2).
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
}
println!();
println!("=== Benchmark ended: av_hwframe_map import FAILED ===");
println!("Fix the import issue before proceeding to GPU downscale tests.");
return Ok(());
}
}
drop(vaapi_frame);
drop(first_frame);
println!("\n[3/3] Benchmarking selected pipeline(s)...");
let enc_width = bench_args.enc_width;
let enc_height = bench_args.enc_height;
let split_outputs = bench_args.mode == PipelineMode::Both;
let mut cpu_stats = None;
let mut gpu_stats = None;
if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) {
let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs);
cpu_stats = Some(run_cpu_pipeline(
&cap,
&frames_ctx,
&output,
bench_args.frames,
src_width,
src_height,
enc_width,
enc_height,
)?);
}
if matches!(bench_args.mode, PipelineMode::Gpu | PipelineMode::Both) {
let output = output_for_mode(&bench_args.output, PipelineMode::Gpu, split_outputs);
gpu_stats = Some(run_gpu_pipeline(
&cap,
&hw_dev,
&frames_ctx,
&output,
bench_args.frames,
src_width,
src_height,
enc_width,
enc_height,
)?);
}
if let Some(stats) = cpu_stats.as_ref() {
print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height);
}
if let Some(stats) = gpu_stats.as_ref() {
print_detailed_results("GPU", stats, src_width, src_height, enc_width, enc_height);
}
print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref());
if cpu_stats
.as_ref()
.into_iter()
.chain(gpu_stats.as_ref())
.any(|stats| stats.achieved_fps() < 30.0 && stats.frames_encoded > 0)
{
println!("NOTE: At least one achieved FPS result is below 30 FPS target.");
}
Ok(())
}
+152
View File
@@ -0,0 +1,152 @@
use std::path::Path;
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next::ffi;
use wl_webrtc::avhw::{av_err_to_string, AvHwFrameCtx};
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
use crate::pipeline_gpu::import_frame;
use crate::software::{
create_software_encoder, create_sws_context, encode_yuv_frame, finish_encoder,
};
use crate::stats::FrameStats;
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_cpu_pipeline(
cap: &CapPortal,
frames_ctx: &AvHwFrameCtx,
output: &str,
frames: u32,
src_width: u32,
src_height: u32,
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
let sws_ctx = create_sws_context(
src_width,
src_height,
ffi::AVPixelFormat::AV_PIX_FMT_BGRA,
enc_width,
enc_height,
)?;
println!(
" Encoder: {}, {}x{} YUV420P",
encoder.codec_name, enc_width, enc_height
);
println!(" Output: {output}");
println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n");
let mut stats = FrameStats {
codec_name: encoder.codec_name.clone(),
output_path: output.to_string(),
..FrameStats::default()
};
let total_start = Instant::now();
let mut pts: i64 = 0;
while stats.frames_encoded < frames {
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => break,
PwCtrlEvent::Error(e) => bail!(
"PipeWire error after {} CPU frames: {e}",
stats.frames_encoded
),
PwCtrlEvent::FormatChanged { .. } => {}
}
}
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
{
Ok(f) => f,
Err(_) => break,
};
let frame_start = Instant::now();
let t_import = Instant::now();
let vaapi_frame = match import_frame(frames_ctx, &frame) {
Ok(f) => f,
Err(e) => {
stats.import_failures += 1;
if stats.import_failures <= 3 {
eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded);
}
continue;
}
};
let import_us = t_import.elapsed().as_micros() as u64;
let t_transfer = Instant::now();
// SAFETY: sw_frame is allocated by FFmpeg and freed on all paths below.
let mut sw_frame = unsafe { ffi::av_frame_alloc() };
if sw_frame.is_null() {
bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded);
}
// SAFETY: sw_frame is an allocated destination; vaapi_frame is a valid VAAPI source frame.
let transfer_ret =
unsafe { ffi::av_hwframe_transfer_data(sw_frame, vaapi_frame.as_ptr(), 0) };
if transfer_ret < 0 {
// SAFETY: sw_frame was allocated above and has not been freed yet.
unsafe { ffi::av_frame_free(&mut sw_frame) };
bail!(
"CPU frame {}: av_hwframe_transfer_data failed: {} ({})",
stats.frames_encoded,
transfer_ret,
av_err_to_string(transfer_ret)
);
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
let t_scale = Instant::now();
// SAFETY: sw_frame contains transferred BGRA data; encoder.yuv_frame is writable YUV420P
// at the configured output dimensions; sws_ctx converts and downscales between them.
unsafe {
ffi::av_frame_make_writable(encoder.yuv_frame);
ffi::sws_scale(
sws_ctx.0,
(*sw_frame).data.as_ptr() as *const *const u8,
(*sw_frame).linesize.as_ptr(),
0,
(*sw_frame).height,
(*encoder.yuv_frame).data.as_ptr() as *mut *mut u8,
(*encoder.yuv_frame).linesize.as_ptr(),
);
}
let scale_us = t_scale.elapsed().as_micros() as u64;
// SAFETY: sw_frame was allocated above and is no longer needed after scaling.
unsafe { ffi::av_frame_free(&mut sw_frame) };
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
stats.import_us.push(import_us);
stats.transfer_us.push(transfer_us);
stats.scale_us.push(scale_us);
stats.encode_us.push(encode_us);
stats.total_us.push(total_us);
stats.frames_encoded += 1;
if stats.frames_encoded <= 3 || stats.frames_encoded.is_multiple_of(30) {
println!(
" CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms",
stats.frames_encoded,
import_us as f64 / 1000.0,
transfer_us as f64 / 1000.0,
scale_us as f64 / 1000.0,
encode_us as f64 / 1000.0,
total_us as f64 / 1000.0,
);
}
}
finish_encoder(encoder)?;
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
Ok(stats)
}
+250
View File
@@ -0,0 +1,250 @@
use std::path::Path;
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use wl_webrtc::avhw::{av_err_to_string, import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
use crate::software::{
create_software_encoder, create_sws_context, encode_yuv_frame, finish_encoder,
};
use crate::stats::FrameStats;
pub(crate) fn import_frame(
frames_ctx: &AvHwFrameCtx,
frame: &wl_webrtc::cap_portal::PwDmaBufFrame,
) -> Result<ff::frame::Video> {
// SAFETY: frames_ctx is a live VAAPI frames context configured for the capture format; frame
// carries a valid DMA-BUF fd and metadata from PipeWire for the duration of the call.
// SAFETY: frames_ctx is a valid VAAPI frames context; `frame` carries the
// DMA-BUF metadata read by the function.
unsafe { import_dma_buf_to_vaapi(frames_ctx.as_ptr(), frame) }
}
fn build_gpu_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
width: u32,
height: u32,
enc_width: u32,
enc_height: u32,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
let buffersrc =
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
// pix_fmt must be set via av_buffersrc_parameters_set (below), not in args —
// FFmpeg 8.0+ rejects HW pixel formats during init() if hw_frames_ctx is missing.
// Use a placeholder SW format here; it gets overridden by parameters_set below.
let args = format!(
"video_size={}x{}:pix_fmt=bgra:time_base=1/60:pixel_aspect=1/1",
width, height,
);
let mut src_ctx = graph.add(&buffersrc, "in", &args)?;
// SAFETY: Allocate buffersrc parameters, attach a ref-counted hw_frames_ctx compatible with
// imported VAAPI BGRA frames, apply it, then free only the parameter struct (not the ref).
let par = unsafe { ffi::av_buffersrc_parameters_alloc() };
if par.is_null() {
bail!("av_buffersrc_parameters_alloc returned null");
}
// SAFETY: par and src_ctx are valid; frames_rgb.ref_clone returns an owned AVBufferRef.
unsafe {
(*par).format = Into::<ffi::AVPixelFormat>::into(ff::format::Pixel::VAAPI) as i32;
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational { num: 1, den: 60 };
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
ffi::av_free(par as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: error {ret}");
}
}
let mut scale_ctx = graph.add(
&scale_vaapi,
"scale",
&format!("{enc_width}:{enc_height}:format=nv12"),
)?;
// SAFETY: scale_vaapi uses this ref-counted VAAPI device context while graph is alive.
unsafe {
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
src_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph
.validate()
.map_err(|e| anyhow::anyhow!("GPU filter graph validation failed: {e}"))?;
Ok(graph)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_gpu_pipeline(
cap: &CapPortal,
hw_dev: &AvHwDevCtx,
frames_ctx: &AvHwFrameCtx,
output: &str,
frames: u32,
src_width: u32,
src_height: u32,
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
let format_ctx = create_sws_context(
enc_width,
enc_height,
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
enc_width,
enc_height,
)?;
let mut graph = build_gpu_filter_graph(
hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height,
)?;
println!(
" Encoder: {}, {}x{} YUV420P",
encoder.codec_name, enc_width, enc_height
);
println!(" Output: {output}");
println!(" GPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> scale_vaapi 2K NV12 -> transfer small NV12 -> sws_scale format-only -> encode\n");
let mut stats = FrameStats {
codec_name: encoder.codec_name.clone(),
output_path: output.to_string(),
..FrameStats::default()
};
let total_start = Instant::now();
let mut pts: i64 = 0;
while stats.frames_encoded < frames {
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => break,
PwCtrlEvent::Error(e) => bail!(
"PipeWire error after {} GPU frames: {e}",
stats.frames_encoded
),
PwCtrlEvent::FormatChanged { .. } => {}
}
}
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
{
Ok(f) => f,
Err(_) => break,
};
let frame_start = Instant::now();
let t_import = Instant::now();
let vaapi_frame = match import_frame(frames_ctx, &frame) {
Ok(f) => f,
Err(e) => {
stats.import_failures += 1;
if stats.import_failures <= 3 {
eprintln!("GPU frame {}: import failed: {e}", stats.frames_encoded);
}
continue;
}
};
let import_us = t_import.elapsed().as_micros() as u64;
let t_filter = Instant::now();
let mut filter_src_ctx = graph.get("in").unwrap();
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = graph.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
filter_src
.add(&vaapi_frame)
.map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?;
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {}
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => continue,
Err(e) => bail!("GPU filter sink get frame failed: {e}"),
}
let filter_us = t_filter.elapsed().as_micros() as u64;
let t_transfer = Instant::now();
// SAFETY: sw_nv12 is allocated by FFmpeg and freed after format conversion.
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
if sw_nv12.is_null() {
bail!("GPU frame {}: av_frame_alloc failed", stats.frames_encoded);
}
// SAFETY: sw_nv12 is an allocated destination; filtered is a valid 2K NV12 VAAPI frame.
let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_nv12, filtered.as_ptr(), 0) };
if transfer_ret < 0 {
// SAFETY: sw_nv12 was allocated above and has not been freed yet.
unsafe { ffi::av_frame_free(&mut sw_nv12) };
bail!(
"GPU frame {}: av_hwframe_transfer_data failed: {} ({})",
stats.frames_encoded,
transfer_ret,
av_err_to_string(transfer_ret)
);
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
let t_format = Instant::now();
// SAFETY: sw_nv12 contains CPU-side NV12 at enc dimensions; encoder.yuv_frame is writable
// YUV420P at the same dimensions, so sws_scale performs only chroma deinterleave/format conversion.
unsafe {
ffi::av_frame_make_writable(encoder.yuv_frame);
ffi::sws_scale(
format_ctx.0,
(*sw_nv12).data.as_ptr() as *const *const u8,
(*sw_nv12).linesize.as_ptr(),
0,
(*sw_nv12).height,
(*encoder.yuv_frame).data.as_ptr() as *mut *mut u8,
(*encoder.yuv_frame).linesize.as_ptr(),
);
}
let format_us = t_format.elapsed().as_micros() as u64;
// SAFETY: sw_nv12 was allocated above and is no longer needed.
unsafe { ffi::av_frame_free(&mut sw_nv12) };
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
stats.import_us.push(import_us);
stats.filter_us.push(filter_us);
stats.transfer_us.push(transfer_us);
stats.format_us.push(format_us);
stats.encode_us.push(encode_us);
stats.total_us.push(total_us);
stats.frames_encoded += 1;
if stats.frames_encoded <= 3 || stats.frames_encoded.is_multiple_of(30) {
println!(
" GPU frame {:>4}/{frames}: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms",
stats.frames_encoded,
import_us as f64 / 1000.0,
filter_us as f64 / 1000.0,
transfer_us as f64 / 1000.0,
format_us as f64 / 1000.0,
encode_us as f64 / 1000.0,
total_us as f64 / 1000.0,
);
}
}
finish_encoder(encoder)?;
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
Ok(stats)
}
+228
View File
@@ -0,0 +1,228 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use std::time::Instant;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use crate::common::drain_encoder;
pub(crate) struct SoftwareEncoder {
pub(crate) enc_video: ff::codec::encoder::video::Video,
pub(crate) octx: ff::format::context::Output,
pub(crate) yuv_frame: *mut ffi::AVFrame,
pub(crate) codec_name: String,
}
impl Drop for SoftwareEncoder {
fn drop(&mut self) {
// SAFETY: yuv_frame is allocated by av_frame_alloc in create_software_encoder and
// owned exclusively by this SoftwareEncoder.
unsafe {
ffi::av_frame_free(&mut self.yuv_frame);
}
}
}
pub(crate) struct SwsContext(pub(crate) *mut ffi::SwsContext);
impl Drop for SwsContext {
fn drop(&mut self) {
// SAFETY: Context is either null or returned by sws_getContext and owned here.
unsafe {
ffi::sws_freeContext(self.0);
}
}
}
pub(crate) fn create_software_encoder(
output_path: &Path,
width: u32,
height: u32,
) -> Result<SoftwareEncoder> {
let output_cstr = CString::new(output_path.to_str().unwrap())?;
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)")
})?;
let codec_name = codec.name().to_string();
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
enc.set_time_base(ff::Rational::new(1, 60));
enc.set_max_b_frames(0);
enc.set_gop(60);
if codec_name == "libx264" {
// SAFETY: priv_data belongs to the not-yet-opened encoder context. Option strings are
// valid NUL-terminated C strings for the duration of each av_opt_set call.
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
let key = CString::new("tune").unwrap();
let val = CString::new("zerolatency").unwrap();
ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0);
}
}
let opened = enc.open()?;
let enc_video = opened.0;
let use_null_muxer = output_path
.to_str()
.map(|s| s.contains("null"))
.unwrap_or(false);
let fmt_name = if use_null_muxer {
CString::new("null").unwrap()
} else {
CString::new("").unwrap()
};
let fmt_name_ptr = if use_null_muxer {
fmt_name.as_ptr()
} else {
ptr::null()
};
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// SAFETY: fmt_ctx_ptr is an out parameter initialized by FFmpeg; output_cstr and fmt_name live
// across the call.
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
ptr::null_mut(),
fmt_name_ptr,
output_cstr.as_ptr(),
)
};
if ret < 0 || fmt_ctx_ptr.is_null() {
bail!("Failed to allocate output format context: error {ret}");
}
// SAFETY: fmt_ctx_ptr is a valid output context allocated above.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create output stream");
}
// SAFETY: stream and codec context pointers are valid; parameters are copied into stream.
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy codec parameters: error {ret}");
}
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
unsafe {
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
let ret = ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
output_cstr.as_ptr(),
ffi::AVIO_FLAG_WRITE,
);
if ret < 0 {
bail!("Failed to open output file: error {ret}");
}
}
}
// SAFETY: fmt_ctx_ptr is a fully configured output context.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write header: error {ret}");
}
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
let yuv_frame = unsafe {
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
(*f).width = width as i32;
(*f).height = height as i32;
(*f).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32;
let r = ffi::av_frame_get_buffer(f, 0);
if r < 0 {
ffi::av_frame_free(&mut f);
bail!("av_frame_get_buffer failed: {r}");
}
f
};
Ok(SoftwareEncoder {
enc_video,
octx,
yuv_frame,
codec_name,
})
}
pub(crate) fn create_sws_context(
src_width: u32,
src_height: u32,
src_fmt: ffi::AVPixelFormat,
dst_width: u32,
dst_height: u32,
) -> Result<SwsContext> {
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
let ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
src_height as i32,
src_fmt,
dst_width as i32,
dst_height as i32,
ffi::AVPixelFormat::AV_PIX_FMT_YUV420P,
2,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
)
};
if ctx.is_null() {
bail!("Failed to create sws_scale context");
}
Ok(SwsContext(ctx))
}
pub(crate) fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64> {
let t_encode = Instant::now();
// SAFETY: yuv_frame is allocated, writable, and formatted as the encoder's configured
// YUV420P input frame. FFmpeg consumes but does not take ownership.
unsafe {
(*encoder.yuv_frame).pts = *pts;
*pts += 1;
let r = ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), encoder.yuv_frame);
if r < 0 {
bail!("avcodec_send_frame failed: {r}");
}
}
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
Ok(t_encode.elapsed().as_micros() as u64)
}
pub(crate) fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
// SAFETY: Sending a null frame flushes the encoder; context remains owned by encoder.
unsafe {
ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), ptr::null());
}
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
encoder
.octx
.write_trailer()
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
Ok(())
}
+76
View File
@@ -0,0 +1,76 @@
use clap::{Parser, ValueEnum};
#[derive(Parser, Debug)]
#[command(name = "vaapi_import_bench", about = "VAAPI DMA-BUF import benchmark")]
pub(crate) struct BenchArgs {
#[arg(short, long)]
pub(crate) output: String,
#[arg(long, default_value_t = 60)]
pub(crate) frames: u32,
#[arg(long, default_value_t = 2560)]
pub(crate) enc_width: u32,
#[arg(long, default_value_t = 1440)]
pub(crate) enc_height: u32,
#[arg(long, default_value = "/dev/dri/renderD128")]
pub(crate) drm_device: String,
#[arg(long, value_enum, default_value_t = PipelineMode::Both)]
pub(crate) mode: PipelineMode,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum PipelineMode {
Cpu,
Gpu,
Both,
}
#[derive(Default)]
pub(crate) struct FrameStats {
pub(crate) import_us: Vec<u64>,
pub(crate) filter_us: Vec<u64>,
pub(crate) transfer_us: Vec<u64>,
pub(crate) scale_us: Vec<u64>,
pub(crate) format_us: Vec<u64>,
pub(crate) encode_us: Vec<u64>,
pub(crate) total_us: Vec<u64>,
pub(crate) import_failures: u32,
pub(crate) frames_encoded: u32,
pub(crate) elapsed_secs: f64,
pub(crate) codec_name: String,
pub(crate) output_path: String,
}
impl FrameStats {
pub(crate) fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
}
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
pub(crate) fn avg_total_ms(&self) -> f64 {
Self::avg_ms(&self.total_us)
}
pub(crate) fn achieved_fps(&self) -> f64 {
if self.frames_encoded > 0 && self.elapsed_secs > 0.0 {
self.frames_encoded as f64 / self.elapsed_secs
} else {
0.0
}
}
pub(crate) fn theoretical_fps(&self) -> f64 {
let avg = self.avg_total_ms();
if avg > 0.0 {
1000.0 / avg
} else {
0.0
}
}
}
+107
View File
@@ -0,0 +1,107 @@
use std::path::Path;
use crate::stats::{FrameStats, PipelineMode};
pub(crate) fn output_for_mode(base: &str, mode: PipelineMode, split: bool) -> String {
if !split || base.contains("null") {
return base.to_string();
}
let path = Path::new(base);
let suffix = match mode {
PipelineMode::Cpu => "cpu",
PipelineMode::Gpu => "gpu",
PipelineMode::Both => unreachable!(),
};
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(base);
let split_name = if let Some((stem, ext)) = file_name.rsplit_once('.') {
format!("{stem}.{suffix}.{ext}")
} else {
format!("{file_name}.{suffix}")
};
path.with_file_name(split_name)
.to_string_lossy()
.into_owned()
}
pub(crate) fn print_detailed_results(
label: &str,
stats: &FrameStats,
src_width: u32,
src_height: u32,
enc_width: u32,
enc_height: u32,
) {
println!();
println!("=== {label} Pipeline Results ===");
println!("Capture resolution: {}x{}", src_width, src_height);
println!("Encode resolution: {}x{}", enc_width, enc_height);
println!("Frames encoded: {}", stats.frames_encoded);
println!("Total time: {:.2}s", stats.elapsed_secs);
println!("Output: {}", stats.output_path);
if stats.import_failures > 0 {
println!("Import failures: {}", stats.import_failures);
}
println!(
"import avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.import_us)
);
if !stats.filter_us.is_empty() {
println!(
"filter avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.filter_us)
);
}
println!(
"transfer avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.transfer_us)
);
if !stats.scale_us.is_empty() {
println!(
"scale avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.scale_us)
);
}
if !stats.format_us.is_empty() {
println!(
"format avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.format_us)
);
}
println!(
"encode ({}): {:.2} ms/frame",
stats.codec_name,
FrameStats::avg_ms(&stats.encode_us)
);
println!("total avg: {:.2} ms/frame", stats.avg_total_ms());
println!("achieved FPS: {:.1}", stats.achieved_fps());
println!("max theoretical: {:.1} FPS", stats.theoretical_fps());
}
pub(crate) fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
println!();
println!("=== Pipeline Comparison ===");
if let Some(s) = cpu {
println!(
"CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
FrameStats::avg_ms(&s.import_us),
FrameStats::avg_ms(&s.transfer_us),
FrameStats::avg_ms(&s.scale_us),
FrameStats::avg_ms(&s.encode_us),
s.avg_total_ms(),
s.theoretical_fps(),
);
}
if let Some(s) = gpu {
println!(
"GPU: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
FrameStats::avg_ms(&s.import_us),
FrameStats::avg_ms(&s.filter_us),
FrameStats::avg_ms(&s.transfer_us),
FrameStats::avg_ms(&s.format_us),
FrameStats::avg_ms(&s.encode_us),
s.avg_total_ms(),
s.theoretical_fps(),
);
}
}
+37 -1300
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
/// 将 PipeWire SPA 视频格式转换为 DRM FourCC 格式
///
/// PipeWire 使用自己的 VideoFormat 枚举,而 DRM/KMS 使用 FourCC 格式标识。
/// 此函数建立了两者之间的映射关系。
///
/// 支持的格式:
/// 不支持的格式返回 0
/// DRM 格式名描述像素值位布局(大端序),而非内存字节序。
/// 例如 DRM_FORMAT_ARGB8888 在小端 x86 上内存为 [B,G,R,A] = PipeWire BGRA。
pub(super) fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 {
use drm_fourcc::DrmFourcc;
use libspa::param::video::VideoFormat;
match format {
VideoFormat::BGRA => DrmFourcc::Argb8888 as u32,
VideoFormat::BGRx => DrmFourcc::Xrgb8888 as u32,
VideoFormat::RGBA => DrmFourcc::Abgr8888 as u32,
VideoFormat::RGBx => DrmFourcc::Xbgr8888 as u32,
VideoFormat::ARGB => DrmFourcc::Bgra8888 as u32,
VideoFormat::xRGB => DrmFourcc::Bgrx8888 as u32,
VideoFormat::ABGR => DrmFourcc::Rgba8888 as u32,
VideoFormat::xBGR => DrmFourcc::Rgbx8888 as u32,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use drm_fourcc::DrmFourcc;
#[test]
fn spa_to_drm_fourcc_all_32bit() {
use libspa::param::video::VideoFormat;
assert_eq!(
spa_to_drm_fourcc(VideoFormat::BGRA),
DrmFourcc::Argb8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::BGRx),
DrmFourcc::Xrgb8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::RGBA),
DrmFourcc::Abgr8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::RGBx),
DrmFourcc::Xbgr8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::ARGB),
DrmFourcc::Bgra8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::xRGB),
DrmFourcc::Bgrx8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::ABGR),
DrmFourcc::Rgba8888 as u32
);
assert_eq!(
spa_to_drm_fourcc(VideoFormat::xBGR),
DrmFourcc::Rgbx8888 as u32
);
}
#[test]
fn spa_to_drm_fourcc_unsupported() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::NV12), 0);
}
}
+18
View File
@@ -0,0 +1,18 @@
/// Log an actionable diagnostic when a Portal phase times out.
///
/// Mirrors the message format from `backend_detect.rs::log_portal_unresponsive`
/// but additionally suggests `--no-persist` when the timeout occurred in a
/// phase that was using a restore token.
pub(super) fn log_portal_phase_timeout(phase: &str, used_restore_token: bool) {
let persist_hint = if used_restore_token {
" If this recurs, try: wl-webrtc --no-persist"
} else {
""
};
tracing::error!(
"Portal service did not respond within timeout while {phase}. \
This usually means xdg-desktop-portal or xdg-desktop-portal-kde is stuck. \
Try: systemctl --user restart xdg-desktop-portal xdg-desktop-portal-kde, \
then re-run wl-webrtc.{persist_hint}"
);
}
+446
View File
@@ -0,0 +1,446 @@
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use anyhow::Result;
use crossbeam_channel::Sender;
use super::fourcc::spa_to_drm_fourcc;
use super::types::{PortalFormatInfo, PwCtrlEvent, PwDmaBufFrame};
/// PipeWire 捕获线程的上下文数据
///
/// 从主线程传递给 PipeWire 捕获线程的所有必要资源。
/// 该结构体在线程创建时一次性 move 到线程中使用。
struct PwThreadCtx {
frame_tx: Sender<PwDmaBufFrame>,
event_tx: Sender<PwCtrlEvent>,
dropped: Arc<AtomicU64>,
shutdown_read: OwnedFd,
pw_fd: OwnedFd,
node_id: u32,
}
fn pipewire_thread(ctx: PwThreadCtx) {
use pipewire as pw;
use pw::properties::properties;
use pw::spa::param::video::VideoInfoRaw;
use pw::stream::{StreamBox, StreamFlags};
use std::cell::Cell;
use std::rc::Rc;
// 初始化 PipeWire 进程全局库。
//
// pipewire-rs 内部使用 OnceCell 保护 pw::init(),确保只调用一次。
// pw::deinit() 是 unsafe 且要求"进程生命周期内仅调用一次,且所有
// PipeWire 使用已停止"。由于 CapPortal 可被多次创建销毁,此函数
// 不调用 pw::deinit()——进程退出时全局状态由 OS 回收。
pw::init();
let PwThreadCtx {
frame_tx,
event_tx,
dropped,
shutdown_read,
pw_fd,
node_id,
} = ctx;
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
if let Err(e) =
event_tx.try_send(PwCtrlEvent::Error(format!("MainLoop::new failed: {e}")))
{
tracing::error!("MainLoop::new failed and error channel also failed: {e}");
}
return;
}
};
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
Ok(c) => c,
Err(e) => {
if let Err(e) =
event_tx.try_send(PwCtrlEvent::Error(format!("Context::new failed: {e}")))
{
tracing::error!("Context::new failed and error channel also failed: {e}");
}
return;
}
};
let core = match context.connect_fd(pw_fd, None) {
Ok(c) => c,
Err(e) => {
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("connect_fd failed: {e}")))
{
tracing::error!("connect_fd failed and error channel also failed: {e}");
}
return;
}
};
// 创建 PipeWire 视频流
// 属性配置:
// - MEDIA_TYPE = "Video": 媒体类型为视频
// - MEDIA_CATEGORY = "Capture": 类别为捕获(而非回放)
// - MEDIA_ROLE = "Screen": 角色为屏幕(用于策略管理)
let stream = match StreamBox::new(
&core,
"wl-webrtc",
properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
*pw::keys::NODE_FORCE_QUANTUM => "512",
},
) {
Ok(s) => s,
Err(e) => {
if let Err(e) =
event_tx.try_send(PwCtrlEvent::Error(format!("Stream::new failed: {e}")))
{
tracing::error!("Stream::new failed and error channel also failed: {e}");
}
return;
}
};
let format_info: Rc<Cell<Option<PortalFormatInfo>>> = Rc::new(Cell::new(None));
let event_tx_state = event_tx.clone();
let _listener = stream
.add_local_listener::<()>()
.state_changed(move |_, _, old, new| {
tracing::info!("PipeWire stream state: {old:?} -> {new:?}");
match new {
pw::stream::StreamState::Error(e) => {
tracing::error!("PipeWire stream error: {e}");
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
}
pw::stream::StreamState::Unconnected => {
let _ = event_tx_state.try_send(PwCtrlEvent::StreamEnded);
}
pw::stream::StreamState::Paused => {
tracing::warn!("PipeWire stream paused (compositor may be switching content)");
}
pw::stream::StreamState::Streaming => {
tracing::info!("PipeWire stream (re)started");
}
pw::stream::StreamState::Connecting => {}
}
})
// 参数变化回调(格式协商)
// PipeWire 在流格式协商完成后触发此回调
// id 为参数类型,param 包含具体的格式参数(分辨率、像素格式等)
.param_changed({
let format_info = format_info.clone();
let event_tx = event_tx.clone();
move |_, _, id, param| {
// 仅处理 Format 类型的参数变化
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
// 解析视频格式信息(分辨率、像素格式、修饰符等)
let mut info = VideoInfoRaw::new();
if let Err(e) = info.parse(param) {
tracing::warn!("Failed to parse video format: {e}");
return;
}
let width = info.size().width;
let height = info.size().height;
// 将 SPA 视频格式转换为 DRM FourCC 格式标识符
let drm_format = spa_to_drm_fourcc(info.format());
// 获取 DRM 修饰符,描述 GPU buffer 的内存布局(如 tiling 模式)
let modifier = info.modifier();
let framerate = info.framerate();
let max_framerate = info.max_framerate();
// 保存协商后的格式信息,供 process 回调读取
let previous_format = format_info.get();
format_info.set(Some(PortalFormatInfo {
width,
height,
drm_format,
modifier,
}));
if let Some(prev) = previous_format {
if width != prev.width || height != prev.height {
tracing::warn!(
"PipeWire dimensions changed: {}x{} (format renegotiation)",
width,
height
);
let _ = event_tx.try_send(PwCtrlEvent::FormatChanged { width, height });
}
}
tracing::info!(
"PipeWire format negotiated: {width}x{height}, \
drm_format={drm_format:#010x}, modifier={modifier:#x}, \
framerate={}/{}, max_framerate={}/{}",
framerate.num,
framerate.denom,
max_framerate.num,
max_framerate.denom,
);
}
})
// 帧处理回调 —— 这是核心的数据路径
// 每当 PipeWire 有新的帧数据可用时触发
// 关键操作: 从 buffer 中提取 DMA-BUF fddup 后通过 channel 发送给消费者
.process({
let format_info = format_info.clone();
let frame_tx = frame_tx.clone();
move |stream, _| {
// SAFETY: raw_buf ownership invariant — PipeWire's process callback
// contract requires that every buffer acquired via `dequeue_raw_buffer`
// is returned to the queue EXACTLY ONCE via `queue_raw_buffer` before
// the callback returns — on every exit path, success or error. Failure
// to requeue leaks the buffer slot and eventually stalls the stream.
//
// Audit map of this closure (verified 2026-06-28):
// - null raw_buf (dequeue returned NULL) → nothing to requeue, return.
// - null spa_buf / no data / bad fd / null chunk / no format_info /
// invalid dims / dup_fd < 0 → all requeue before early-return.
// - success (try_send Ok / Full / Disconnected) → final requeue at end.
// The fd ownership is independent: dup() creates a fresh fd that lives
// inside PwDmaBufFrame; on try_send error the frame Drops and closes it.
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
if raw_buf.is_null() {
tracing::trace!("process: null raw_buf");
return;
}
// 获取 SPA buffer 结构体,包含数据数组、元数据等
// SAFETY: raw_buf was checked non-null above. `pw_buffer.buffer` is a
// valid raw pointer for the lifetime of raw_buf (PipeWire keeps the
// buffer alive until we queue it back).
let spa_buf = unsafe { (*raw_buf).buffer };
if spa_buf.is_null() {
tracing::trace!("process: null spa_buf");
// SAFETY: raw_buf is the non-null buffer we still own; returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// 获取 buffer 中的数据项数量和数据指针
// 对于 DMA-BUF 帧,通常只有 1 个数据项(包含 fd)
// SAFETY: spa_buf checked non-null above; `n_datas` is a plain u32 field.
let n_datas = unsafe { (*spa_buf).n_datas };
// SAFETY: same as above; `datas` is a raw pointer field, may be null.
let datas_ptr = unsafe { (*spa_buf).datas };
if n_datas == 0 || datas_ptr.is_null() {
tracing::trace!("process: no data (n_datas={n_datas})");
// SAFETY: raw_buf still owned, returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// 从第一个数据项中获取 DMA-BUF 文件描述符
// 通过 libspa 的 Data 包装类型安全地访问 SPA 数据结构
// SAFETY: datas_ptr is non-null and n_datas > 0 (checked above). We cast
// to pw::spa::buffer::Data and take a shared borrow; PipeWire does not
// mutate the data array during a process cycle, so a shared reference
// for the duration of this callback is sound.
let data_ref: &pw::spa::buffer::Data =
unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
let fd = data_ref.fd();
if fd < 0 {
tracing::trace!("process: invalid fd={fd}");
// SAFETY: raw_buf still owned, returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
if data_ref.as_raw().chunk.is_null() {
tracing::trace!("process: null chunk");
// SAFETY: raw_buf still owned, returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let chunk = data_ref.chunk();
let offset = chunk.offset() as u64;
let stride = chunk.stride() as u32;
// 从 SPA_META_Header 元数据中提取 PTS (显示时间戳)
// 遍历 buffer 的所有元数据项,查找 Header 类型的元数据
// PTS 可用于音视频同步和帧率控制
// SAFETY: spa_buf is non-null. `metas` is checked for null before
// iteration. We iterate `i in 0..n_metas` reading shared POD fields
// (type_, size, data) — PipeWire keeps the meta array immutable during
// a process cycle. The size guard (`meta.size >= size_of::<spa_meta_header>()`)
// and null-data check before reading ensure we never read past the
// meta's actual extent.
let pts: i64 = unsafe {
let mut pts_val: i64 = 0;
let n_metas = (*spa_buf).n_metas;
let metas = (*spa_buf).metas;
if !metas.is_null() {
for i in 0..n_metas {
let meta = &*metas.add(i as usize);
if meta.type_ == libspa::sys::SPA_META_Header
&& meta.size as usize
>= std::mem::size_of::<libspa::sys::spa_meta_header>()
&& !meta.data.is_null()
{
let header = &*(meta.data as *const libspa::sys::spa_meta_header);
pts_val = header.pts;
break;
}
}
}
pts_val
};
// 验证格式信息已协商完成,且分辨率和格式有效
let Some(fmt) = format_info.get() else {
// SAFETY: raw_buf still owned, returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
};
let PortalFormatInfo {
width,
height,
drm_format: format,
modifier,
} = fmt;
if width == 0 || height == 0 || format == 0 {
tracing::trace!("process: invalid dimensions {width}x{height} format={format}");
// SAFETY: raw_buf still owned, returning it.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// 复制 DMA-BUF 文件描述符
// 必须 dup,因为原始 fd 由 PipeWire 管理,我们不能持有它
// dup 后的 fd 由 PwDmaBufFrame 持有,生命周期独立于 PipeWire buffer
// SAFETY: `fd` is the open DMA-BUF fd reported by PipeWire (>= 0 checked
// above). libc::dup is the standard POSIX fd duplication call. The
// original `fd` remains owned by PipeWire (returned with raw_buf later).
let dup_fd = unsafe { libc::dup(fd) };
if dup_fd < 0 {
// SAFETY: raw_buf still owned, returning it. No fd cleanup needed
// because dup() failed and never returned a new fd.
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// 构建帧数据对象,所有必要的帧信息已收集完毕
// SAFETY: `dup_fd` is a freshly-dup'd open file descriptor (>= 0 checked
// above) and we are its sole owner. OwnedFd::from_raw_fd takes ownership
// and will close() it on Drop. The fd's lifecycle is independent of
// raw_buf: whether try_send succeeds (frame moves into the channel) or
// fails (Full/Disconnected — the error payload owns the frame and drops
// it at the end of the match arm), exactly one close() occurs per dup().
let frame_fd = unsafe { OwnedFd::from_raw_fd(dup_fd) };
let frame = PwDmaBufFrame {
fd: frame_fd,
offset,
stride,
modifier,
width,
height,
format,
pts,
};
match frame_tx.try_send(frame) {
Ok(()) => {}
Err(crossbeam_channel::TrySendError::Full(_)) => {
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
}
// SAFETY: final exactly-once requeue of raw_buf. Every path above
// either returned early with its own requeue, or falls through to here.
unsafe { stream.queue_raw_buffer(raw_buf) };
}
})
.register();
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
pw::spa::utils::Direction::Input,
Some(node_id),
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
&mut params,
) {
if let Err(e) = event_tx.try_send(PwCtrlEvent::Error(format!("stream.connect failed: {e}")))
{
tracing::error!("stream.connect failed and error channel also failed: {e}");
}
return;
}
let loop_ = mainloop.loop_();
// Register the shutdown eventfd on the PipeWire loop.
//
// When CapPortal::drop writes to the eventfd, the loop wakes up and
// dispatches this callback on the loop thread. Because the callback
// only fires while mainloop.run() is blocking this thread, mainloop
// is guaranteed alive — eliminating the UAF that existed with the
// previous detached helper thread approach.
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
let mainloop_ptr = mainloop.as_raw_ptr();
let _shutdown_source = loop_.add_io(
shutdown_read,
libspa::support::system::IoFlags::IN,
move |fd| {
// Drain the eventfd so it doesn't re-trigger
let mut buf: u64 = 0;
// SAFETY: `fd` is the registered eventfd owned by the mainloop source; the
// buffer is a stack u64 of 8 bytes matching the count argument. POSIX
// read(2) is the standard fd-read syscall; eventfd semantics require the
// 8-byte buffer.
let _ = unsafe {
libc::read(
fd.as_raw_fd(),
&mut buf as *mut u64 as *mut _,
std::mem::size_of::<u64>(),
)
};
// SAFETY: This callback only executes while mainloop.run() is
// blocking this thread, so mainloop is guaranteed alive.
unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr) };
},
);
// 启动 PipeWire 主事件循环
// 此调用会阻塞当前线程,直到 mainloop.quit() 被调用
// quit() 由 shutdown eventfd 的 IO 回调触发
mainloop.run();
// run() returned — _shutdown_source drops first (reverse declaration order),
// which unregisters the callback from the loop. Then mainloop drops.
// No dangling raw pointers are possible.
// PipeWire global state is intentionally not deinitialized here — see pw::init() comment above.
}
pub(super) fn spawn_pipewire_thread(
frame_tx: Sender<PwDmaBufFrame>,
event_tx: Sender<PwCtrlEvent>,
dropped: Arc<AtomicU64>,
shutdown_read: OwnedFd,
pw_fd: OwnedFd,
node_id: u32,
) -> Result<JoinHandle<()>> {
let ctx = PwThreadCtx {
frame_tx,
event_tx,
dropped,
shutdown_read,
pw_fd,
node_id,
};
thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || pipewire_thread(ctx))
.map_err(|e| anyhow::anyhow!("thread spawn failed: {e}"))
}
+192
View File
@@ -0,0 +1,192 @@
use std::os::fd::OwnedFd;
use anyhow::Result;
use super::logging::log_portal_phase_timeout;
use super::token_fs::{delete_restore_token, load_restore_token, save_restore_token};
use super::types::{PortalPhaseTimeout, PORTAL_SERVICE_TIMEOUT, PORTAL_USER_DIALOG_TIMEOUT};
use super::CapPortal;
impl CapPortal {
/// 通过 XDG Desktop Portal 建立屏幕录制会话
///
/// 与桌面环境的 D-Bus 服务交互,请求用户授权屏幕录制。
/// 流程:
/// 1. 创建 Screencast 代理(D-Bus 代理)
/// 2. 创建 ScreenCast 会话
/// 3. 配置源选择参数(光标模式、显示器源、不持久化会话)
/// 4. 启动录制,获取流信息(包含 PipeWire node_id
/// 5. 打开 PipeWire 远程连接,获取文件描述符
///
/// 返回 (PipeWire fd, node_id),供 PipeWire 线程连接使用
///
/// Wraps `_setup_portal_inner` with token-aware retry: on a `TokenDependent`
/// timeout (phases 3 or 4 with a restore token in use) AND `no_persist ==
/// false`, clears the cached restore token and retries once with
/// `no_persist = true`.
pub(super) async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
match Self::_setup_portal_inner(no_persist, false).await {
Ok(result) => Ok(result),
Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
match inner_err {
PortalPhaseTimeout::TokenDependent if !no_persist => {
tracing::warn!(
"Portal timed out during token-using phase. \
Clearing cached restore token and retrying with fresh authorization."
);
delete_restore_token();
Self::_setup_portal_inner(true, true).await
}
_ => Err(e),
}
}
Err(e) => Err(e),
}
}
/// Inner Portal setup with phased timeouts. See `setup_portal` for the
/// retry wrapper.
///
/// `is_retry == true` disables further retry attempts (max 1 retry).
pub(super) async fn _setup_portal_inner(
no_persist: bool,
is_retry: bool,
) -> Result<(OwnedFd, u32)> {
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
// Phase 1: Screencast proxy (no user interaction).
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
Err(_) => {
log_portal_phase_timeout("creating Screencast proxy", false);
return Err(PortalPhaseTimeout::Service.into());
}
};
// Phase 2: create_session (no user interaction).
let session = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.create_session(Default::default()),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create ScreenCast session: {e}")),
Err(_) => {
log_portal_phase_timeout("creating session", false);
return Err(PortalPhaseTimeout::Service.into());
}
};
let version_supported = proxy.version() >= 4;
let (persist_mode, saved_token) = if !no_persist && version_supported {
let token = load_restore_token();
if token.is_some() {
if is_retry {
tracing::info!("Re-attempting portal session after token clear");
} else {
tracing::info!("Attempting to restore portal session with saved token");
}
}
(PersistMode::ExplicitlyRevoked, token)
} else {
(PersistMode::DoNot, None)
};
let mut options = SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(persist_mode);
if let Some(ref token) = saved_token {
options = options.set_restore_token(token.as_str());
}
// Phase 3: select_sources — token path is fast (no dialog); fresh
// authorization may pop a dialog.
let token_in_use = saved_token.is_some();
let phase3_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
match tokio::time::timeout(phase3_timeout, proxy.select_sources(&session, options)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
Err(_) => {
log_portal_phase_timeout("selecting sources", token_in_use);
return Err(if token_in_use {
PortalPhaseTimeout::TokenDependent
} else {
PortalPhaseTimeout::Service
}
.into());
}
}
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
let phase4_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
let start_fut = async {
proxy
.start(&session, None, Default::default())
.await?
.response()
};
let response = match tokio::time::timeout(phase4_timeout, start_fut).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => return Err(anyhow::anyhow!("ScreenCast start/response error: {e}")),
Err(_) => {
log_portal_phase_timeout("starting session", token_in_use);
return Err(if token_in_use {
PortalPhaseTimeout::TokenDependent
} else {
PortalPhaseTimeout::Service
}
.into());
}
};
if !no_persist && version_supported {
if let Some(new_token) = response.restore_token() {
save_restore_token(new_token);
}
}
let stream = response
.streams()
.first()
.ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?;
let node_id = stream.pipe_wire_node_id();
// Phase 5: open_pipe_wire_remote (no user interaction).
let fd = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.open_pipe_wire_remote(&session, Default::default()),
)
.await
{
Ok(Ok(f)) => f,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to open PipeWire remote: {e}")),
Err(_) => {
log_portal_phase_timeout("opening PipeWire remote", false);
return Err(PortalPhaseTimeout::Service.into());
}
};
tracing::info!("Portal session established: node_id={node_id}");
Ok((fd, node_id))
}
}
+362
View File
@@ -0,0 +1,362 @@
use std::path::PathBuf;
pub(super) fn token_path() -> Option<PathBuf> {
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
}
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
pub(super) fn verify_secure_dir(path: &std::path::Path) -> bool {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
match std::fs::symlink_metadata(path) {
Ok(meta) => {
if meta.file_type().is_symlink() {
tracing::warn!(
"Token parent dir is a symlink, rejecting: {}",
path.display()
);
return false;
}
// Must be a directory
if !meta.is_dir() {
tracing::warn!("Token parent path is not a directory: {}", path.display());
return false;
}
// Must be owned by current user
// SAFETY: libc::getuid has no preconditions and cannot fail; it simply
// returns the calling process's real user ID.
// SAFETY: libc::getuid has no preconditions and cannot fail.
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!(
"Token parent dir not owned by current user: {}",
path.display()
);
return false;
}
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
"Token parent dir has insecure permissions {:o}, expected 0700: {}",
mode,
path.display()
);
return false;
}
true
}
Err(e) => {
tracing::warn!("Failed to stat token parent dir: {e}");
false
}
}
}
/// Ensure the parent directory exists with restrictive permissions (0o700).
/// Returns false if the directory could not be created or is insecure.
pub(super) fn ensure_secure_parent(parent: &std::path::Path) -> bool {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if parent.exists() {
// Directory exists — try to tighten permissions, then verify.
// set_permissions follows symlinks, which is fine here since
// we verify with symlink_metadata in verify_secure_dir.
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!("Failed to set directory permissions: {e}");
return false;
}
return verify_secure_dir(parent);
}
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
builder.mode(0o700);
if let Err(e) = builder.create(parent) {
tracing::warn!("Failed to create token directory: {e}");
return false;
}
// Verify after creation (belt-and-suspenders)
verify_secure_dir(parent)
}
pub(super) fn load_restore_token() -> Option<String> {
load_restore_token_from(token_path()?)
}
pub(super) fn load_restore_token_from(path: PathBuf) -> Option<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => return None,
};
if meta.file_type().is_symlink() {
tracing::warn!(
"Token file is a symlink, refusing to read: {}",
path.display()
);
return None;
}
if !meta.is_file() {
tracing::warn!("Token path is not a regular file: {}", path.display());
return None;
}
// SAFETY: libc::getuid has no preconditions and cannot fail.
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!("Token file not owned by current user: {}", path.display());
return None;
}
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
tracing::warn!(
"Token file has insecure permissions {:o}, refusing to read: {}",
mode,
path.display()
);
return None;
}
let token = std::fs::read_to_string(&path).ok()?;
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub(super) fn save_restore_token(token: &str) {
let Some(path) = token_path() else {
tracing::warn!("No secure cache directory available, skipping token save");
return;
};
save_restore_token_to(token, &path);
}
pub(super) fn delete_restore_token() {
let Some(path) = token_path() else {
return;
};
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(
"Failed to delete stale restore token at {}: {e}",
path.display()
),
}
}
pub(super) fn save_restore_token_to(token: &str, path: &std::path::Path) {
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let Some(parent) = path.parent() else {
tracing::warn!("Token path has no parent directory");
return;
};
if !ensure_secure_parent(parent) {
tracing::warn!("Parent directory is insecure, refusing to save token");
return;
}
// Use a unique temp file to prevent symlink attacks.
// create_new(true) guarantees exclusive creation — fails if file already exists,
// and does NOT follow existing symlinks.
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
let result = (|| -> std::io::Result<()> {
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(token.as_bytes())?;
f.sync_all()?;
std::fs::rename(&tmp_path, path)?;
Ok(())
})();
match result {
Ok(()) => tracing::info!("Saved portal restore token"),
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
tracing::warn!("Failed to save restore token: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn token_path_never_uses_tmp() {
assert!(token_path().is_some(), "token_path should resolve on Linux");
let path = token_path().unwrap();
assert!(!path.starts_with("/tmp"), "must not fallback to /tmp");
}
#[test]
fn verify_secure_dir_rejects_wrong_permissions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
// 0o700 should pass
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(verify_secure_dir(path));
// 0o755 should fail
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!verify_secure_dir(path));
// 0o777 should fail
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(!verify_secure_dir(path));
}
#[test]
fn verify_secure_dir_rejects_non_directory() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("not-a-dir");
std::fs::write(&file_path, b"test").unwrap();
assert!(!verify_secure_dir(&file_path));
}
#[test]
fn ensure_secure_parent_creates_with_0700() {
let base = tempfile::tempdir().unwrap();
let new_dir = base.path().join("wl-test-new-dir");
assert!(!new_dir.exists());
assert!(ensure_secure_parent(&new_dir));
assert!(new_dir.is_dir());
let meta = std::fs::symlink_metadata(&new_dir).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"created directory should be 0700, got {mode:o}"
);
}
#[test]
fn ensure_secure_parent_tightens_existing_dir() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
// Simulate an existing directory with loose permissions
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(ensure_secure_parent(path));
let meta = std::fs::symlink_metadata(path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"tightened directory should be 0700, got {mode:o}"
);
}
#[test]
fn save_creates_file_with_0600() {
let dir = tempfile::tempdir().unwrap();
let token_path = dir.path().join("portal-restore-token");
save_restore_token_to("secret-token-123", &token_path);
assert!(token_path.exists());
let meta = std::fs::symlink_metadata(&token_path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "token file should be 0600, got {mode:o}");
assert_eq!(
std::fs::read_to_string(&token_path).unwrap(),
"secret-token-123"
);
}
#[test]
fn load_reads_secure_file() {
let dir = tempfile::tempdir().unwrap();
let token_path = dir.path().join("portal-restore-token");
// Write a valid 0o600 token file
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&token_path)
.unwrap();
std::io::Write::write_all(&mut f, b"my-secret\n").unwrap();
let result = load_restore_token_from(token_path);
assert_eq!(result, Some("my-secret".to_string()));
}
#[test]
fn load_rejects_group_readable_file() {
let dir = tempfile::tempdir().unwrap();
let token_path = dir.path().join("portal-restore-token");
// Write with 0o640 (group readable) — should be rejected
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o640)
.open(&token_path)
.unwrap();
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
let result = load_restore_token_from(token_path);
assert!(result.is_none(), "should reject group-readable token file");
}
#[test]
fn load_rejects_world_readable_file() {
let dir = tempfile::tempdir().unwrap();
let token_path = dir.path().join("portal-restore-token");
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o604)
.open(&token_path)
.unwrap();
std::io::Write::write_all(&mut f, b"leaked-token\n").unwrap();
let result = load_restore_token_from(token_path);
assert!(result.is_none(), "should reject world-readable token file");
}
#[test]
fn load_rejects_symlink() {
let dir = tempfile::tempdir().unwrap();
let real_path = dir.path().join("real-file");
let link_path = dir.path().join("portal-restore-token");
std::fs::write(&real_path, b"target-content\n").unwrap();
std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
let result = load_restore_token_from(link_path);
assert!(result.is_none(), "should reject symlinked token file");
}
#[test]
fn save_then_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let token_path = dir.path().join("portal-restore-token");
save_restore_token_to("roundtrip-token", &token_path);
let loaded = load_restore_token_from(token_path);
assert_eq!(loaded, Some("roundtrip-token".to_string()));
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::os::fd::OwnedFd;
pub(super) const PORTAL_SERVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub(super) const PORTAL_USER_DIALOG_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30);
/// Classification of Portal phase timeouts to drive retry behavior.
#[derive(Debug)]
pub(super) enum PortalPhaseTimeout {
/// Portal service unresponsive; not retried (user should restart service).
Service,
/// Timed out in token-dependent phase; retried once after clearing token.
TokenDependent,
}
impl std::fmt::Display for PortalPhaseTimeout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Service => write!(f, "Portal phase timed out (service)"),
Self::TokenDependent => write!(f, "Portal phase timed out (token-dependent)"),
}
}
}
impl std::error::Error for PortalPhaseTimeout {}
/// PipeWire DMA-BUF 帧数据
///
/// 表示从 PipeWire 流中接收到的一帧视频数据。
/// 帧的像素数据存储在 DMA-BUFLinux 的零拷贝 buffer 共享机制)中,
/// 通过文件描述符 (fd) 引用,消费者通过 mmap 或 DRM 导入来访问像素数据。
pub struct PwDmaBufFrame {
/// DMA-BUF 文件描述符,指向 GPU 显存中的帧缓冲区
pub fd: OwnedFd,
/// 帧数据在 DMA-BUF 中的字节偏移量
pub offset: u64,
/// 每行像素的字节跨度(可能大于 width * bpp,因为可能有对齐填充)
pub stride: u32,
/// DRM 格式修饰符,描述 buffer 的内存布局(如线性布局、tiling 等)
pub modifier: u64,
/// 帧宽度(像素)
pub width: u32,
/// 帧高度(像素)
pub height: u32,
/// DRM FourCC 格式标识符(如 BGRA、RGBA 等)
pub format: u32,
/// 显示时间戳 (PTS, Presentation Time Stamp),单位为纳秒
pub pts: i64,
}
/// PipeWire-negotiated video format snapshot, stashed in a `Cell` for cross-callback
/// sharing (format-change callback writes it; process callback reads it). The four
/// fields are the minimal subset of `PwDmaBufFrame`'s metadata that the process
/// callback needs to construct the frame once a buffer arrives.
///
/// `Copy` is required because we store it inside `Cell<Option<PortalFormatInfo>>`;
/// `Cell` requires its contents to be `Copy` (no borrowed interior state).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortalFormatInfo {
pub width: u32,
pub height: u32,
/// DRM FourCC format code (e.g. `0x34325258` for XR24 / XRGB8888).
pub drm_format: u32,
/// DRM format modifier describing buffer layout (linear, tiling, etc.).
pub modifier: u64,
}
/// PipeWire 控制事件枚举
///
/// 从 PipeWire 捕获线程发送给消费者的控制事件。
/// 与帧数据分离,通过独立的 channel 传输,确保控制事件不被帧数据淹没。
pub enum PwCtrlEvent {
/// 流已结束(PipeWire 流断开连接或进入错误状态)
StreamEnded,
/// Format/dimensions changed mid-stream
FormatChanged { width: u32, height: u32 },
/// 发生错误,包含错误描述信息
Error(String),
}
+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)? {}
}
-2188
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
use wayland_client::protocol::wl_buffer::WlBuffer;
use wayland_client::{Dispatch, Proxy, QueueHandle};
use crate::state::{CaptureSource, State};
impl<S: CaptureSource> Dispatch<WlBuffer, ()> for State<S> {
fn event(
_state: &mut Self,
_proxy: &WlBuffer,
event: <WlBuffer as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
if let wayland_client::protocol::wl_buffer::Event::Release = event {
tracing::trace!("WlBuffer released");
}
}
}
+115
View File
@@ -0,0 +1,115 @@
use std::mem;
use std::path::PathBuf;
use wayland_client::{Dispatch, Proxy, QueueHandle};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::{
Event as BufferParamsEvent, ZwpLinuxBufferParamsV1,
};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{
Event as DmabufFeedbackEvent, ZwpLinuxDmabufFeedbackV1,
};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{
Event as DmabufEvent, ZwpLinuxDmabufV1,
};
use crate::state::{CaptureSource, EncConstructionStage, InFlightSurface, State};
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufV1, ()> for State<S> {
fn event(
_state: &mut Self,
_proxy: &ZwpLinuxDmabufV1,
event: <ZwpLinuxDmabufV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
match event {
DmabufEvent::Format { .. } => {}
DmabufEvent::Modifier { .. } => {}
_ => {}
}
}
}
impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for State<S> {
fn event(
state: &mut Self,
_proxy: &ZwpLinuxDmabufFeedbackV1,
event: <ZwpLinuxDmabufFeedbackV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
match event {
DmabufFeedbackEvent::MainDevice { device } => {
if device.len() >= 8 {
let dev_bytes: [u8; 8] = device[..8].try_into().unwrap_or([0u8; 8]);
let dev = u64::from_ne_bytes(dev_bytes);
let minor = ((dev & 0xFF) | ((dev >> 12) & 0xFFFFFF00)) as u32;
let path = PathBuf::from(format!("/dev/dri/renderD{}", minor));
if path.exists() {
tracing::info!(
"Compositor DRM device: {} (dev_t: {})",
path.display(),
dev
);
state.drm_device_from_compositor = Some(path);
} else {
tracing::warn!(
"Compositor reported DRM device {} (dev_t: {}) but path does not exist",
path.display(),
dev
);
}
} else {
tracing::warn!(
"main_device event with unexpected data length: {}",
device.len()
);
}
}
DmabufFeedbackEvent::FormatTable { .. } => {}
DmabufFeedbackEvent::Done => {}
DmabufFeedbackEvent::TrancheDone => {}
DmabufFeedbackEvent::TrancheTargetDevice { .. } => {}
DmabufFeedbackEvent::TrancheFormats { .. } => {}
DmabufFeedbackEvent::TrancheFlags { .. } => {}
_ => {}
}
}
}
impl<S: CaptureSource> Dispatch<ZwpLinuxBufferParamsV1, ()> for State<S> {
fn event(
state: &mut Self,
proxy: &ZwpLinuxBufferParamsV1,
event: <ZwpLinuxBufferParamsV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
match event {
BufferParamsEvent::Created { .. } => {
tracing::debug!("DMA-BUF buffer created");
}
BufferParamsEvent::Failed => {
tracing::error!("DMA-BUF buffer creation failed");
let taken = mem::replace(&mut state.in_flight_surface, InFlightSurface::None);
match taken {
InFlightSurface::CopyQueued { buffer, frame, .. } => {
drop(buffer);
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
cap.on_done_with_frame(frame);
}
}
other => {
state.in_flight_surface = other;
}
}
proxy.destroy();
state.errored = true;
}
_ => {}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
mod buffer;
mod dmabuf;
mod output_mgr;
mod registry;
mod screencopy;
mod wl_output;
+109
View File
@@ -0,0 +1,109 @@
use wayland_client::{event_created_child, Dispatch, Proxy, QueueHandle};
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_head_v1::{
self, Event as WlrHeadEvent, ZwlrOutputHeadV1,
};
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::{
self, Event as WlrOutputManagerEvent, ZwlrOutputManagerV1,
};
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_mode_v1::ZwlrOutputModeV1;
use crate::state::{CaptureSource, EncConstructionStage, State, WlrHeadInfo};
impl<S: CaptureSource> Dispatch<ZxdgOutputManagerV1, ()> for State<S> {
fn event(
_state: &mut Self,
_proxy: &ZxdgOutputManagerV1,
_event: <ZxdgOutputManagerV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
}
}
impl<S: CaptureSource> Dispatch<ZwlrOutputManagerV1, ()> for State<S> {
fn event(
state: &mut Self,
_proxy: &ZwlrOutputManagerV1,
event: <ZwlrOutputManagerV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
match event {
WlrOutputManagerEvent::Head { head } => {
let _head: ZwlrOutputHeadV1 = head;
tracing::debug!("wlr output head advertised");
}
WlrOutputManagerEvent::Done { .. } => {
if let EncConstructionStage::ProbingOutputs {
wlr_manager_done,
outputs,
..
} = &mut state.stage
{
*wlr_manager_done = true;
let count = outputs.len();
for idx in 0..count {
state.try_finalize_output(idx);
}
}
}
WlrOutputManagerEvent::Finished => {
tracing::warn!("zwlr_output_manager_v1::Finished received during probing");
}
_ => {}
}
}
event_created_child!(State<S>, ZwlrOutputManagerV1, [
zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
]);
}
impl<S: CaptureSource> Dispatch<ZwlrOutputHeadV1, ()> for State<S> {
fn event(
state: &mut Self,
proxy: &ZwlrOutputHeadV1,
event: <ZwlrOutputHeadV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
match event {
WlrHeadEvent::Name { name } => {
if let EncConstructionStage::ProbingOutputs {
wlr_heads,
wlr_head_proxy_to_name,
..
} = &mut state.stage
{
wlr_heads.entry(name.clone()).or_insert(WlrHeadInfo {});
wlr_head_proxy_to_name.insert(proxy.id(), name);
}
}
WlrHeadEvent::Position { .. } => {}
WlrHeadEvent::Finished => {
tracing::debug!("zwlr_output_head_v1::Finished received");
}
_ => {}
}
}
event_created_child!(State<S>, ZwlrOutputHeadV1, [
zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
]);
}
impl<S: CaptureSource> Dispatch<ZwlrOutputModeV1, ()> for State<S> {
fn event(
_state: &mut Self,
_proxy: &ZwlrOutputModeV1,
_event: <ZwlrOutputModeV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
}
}
+116
View File
@@ -0,0 +1,116 @@
use wayland_client::globals::GlobalListContents;
use wayland_client::protocol::wl_output::WlOutput;
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::{Dispatch, QueueHandle};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1;
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::ZwlrOutputManagerV1;
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use crate::state::{CaptureSource, EncConstructionStage, OutputId, PartialOutputInfo, State};
impl<S: CaptureSource> Dispatch<WlRegistry, GlobalListContents> for State<S> {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wayland_client::protocol::wl_registry::Event,
_data: &GlobalListContents,
_conn: &wayland_client::Connection,
qhandle: &QueueHandle<State<S>>,
) {
use wayland_client::protocol::wl_registry::Event as RegistryEvent;
match event {
RegistryEvent::Global {
name,
interface,
version,
} => match interface.as_str() {
"zwlr_screencopy_manager_v1" => {
let v = version.min(3);
tracing::debug!("Binding zwlr_screencopy_manager_v1 v{v} (name={name})");
let mgr: ZwlrScreencopyManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
screencopy_manager, ..
} = &mut state.stage
{
*screencopy_manager = Some(mgr);
}
}
"zwp_linux_dmabuf_v1" => {
let v = version.min(4);
tracing::debug!("Binding zwp_linux_dmabuf_v1 v{v} (name={name})");
let proxy: ZwpLinuxDmabufV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
dmabuf,
dmabuf_feedback,
..
} = &mut state.stage
{
*dmabuf = Some(proxy.clone());
if v >= 4 {
let feedback = proxy.get_default_feedback(qhandle, ());
*dmabuf_feedback = Some(feedback);
}
}
}
"wl_output" => {
let v = version.min(4);
tracing::debug!("Binding wl_output v{v} (name={name})");
let output: WlOutput = registry.bind(name, v, qhandle, OutputId(name));
if let EncConstructionStage::ProbingOutputs {
outputs,
bound_outputs,
output_names,
xdg_output_manager,
..
} = &mut state.stage
{
outputs.push(PartialOutputInfo::default());
bound_outputs.push(output.clone());
output_names.push(name);
if let Some(xdg_mgr) = xdg_output_manager {
let output_id = OutputId(name);
xdg_mgr.get_xdg_output(&output, qhandle, output_id);
}
}
}
"zxdg_output_manager_v1" => {
let v = version.min(3);
tracing::debug!("Binding zxdg_output_manager_v1 v{v} (name={name})");
let xdg_mgr: ZxdgOutputManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
bound_outputs,
xdg_output_manager,
output_names,
..
} = &mut state.stage
{
for (i, output) in bound_outputs.iter().enumerate() {
let oname = output_names.get(i).copied().unwrap_or(0);
let output_id = OutputId(oname);
xdg_mgr.get_xdg_output(output, qhandle, output_id);
}
*xdg_output_manager = Some(xdg_mgr);
}
}
"zwlr_output_manager_v1" => {
let v = version.min(4);
tracing::debug!("Binding zwlr_output_manager_v1 v{v} (name={name})");
let mgr: ZwlrOutputManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
wlr_output_manager, ..
} = &mut state.stage
{
*wlr_output_manager = Some(mgr);
}
}
_ => {}
},
RegistryEvent::GlobalRemove { name } => {
tracing::debug!("Global removed: name={name}");
}
_ => {}
}
}
}
+93
View File
@@ -0,0 +1,93 @@
use wayland_client::{Dispatch, Proxy, QueueHandle};
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{
Event as ScreencopyFrameEvent, ZwlrScreencopyFrameV1,
};
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use crate::cap_wlr_screencopy::CapWlrScreencopy;
use crate::state::{EncConstructionStage, InFlightSurface, State};
impl Dispatch<ZwlrScreencopyFrameV1, ()> for State<CapWlrScreencopy> {
fn event(
state: &mut Self,
proxy: &ZwlrScreencopyFrameV1,
event: <ZwlrScreencopyFrameV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<CapWlrScreencopy>>,
) {
match event {
// SHM buffer offer — in v3 the compositor enumerates supported buffer
// types (buffer and/or linux_dmabuf) before buffer_done. We only
// support DMA-BUF, so just log and wait for linux_dmabuf / buffer_done.
ScreencopyFrameEvent::Buffer { .. } => {
tracing::debug!("Received SHM Buffer offer — only DMA-BUF capture is supported");
}
ScreencopyFrameEvent::LinuxDmabuf {
format,
width,
height,
} => {
tracing::debug!("Screencopy LinuxDmabuf: format={format}, {width}x{height}");
if !matches!(state.in_flight_surface, InFlightSurface::AllocQueued) {
tracing::warn!("Received LinuxDmabuf while no frame allocation was queued");
return;
}
if matches!(state.stage, EncConstructionStage::EverythingButFmt { .. }) {
state.negotiate_format(format, width, height);
if state.errored {
return;
}
}
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
cap.current_frame = Some(proxy.clone());
}
state.on_frame_allocd((), format, width, height);
}
// v3 terminal event: all buffer offers have been enumerated.
// If still AllocQueued, the compositor never sent linux_dmabuf —
// DMA-BUF screencopy is unsupported, so we must error out.
ScreencopyFrameEvent::BufferDone => {
if matches!(state.in_flight_surface, InFlightSurface::AllocQueued) {
tracing::error!(
"Compositor did not offer DMA-BUF screencopy (only SHM); \
DMA-BUF capture is required"
);
state.in_flight_surface = InFlightSurface::None;
proxy.destroy();
state.errored = true;
}
}
ScreencopyFrameEvent::Ready {
tv_sec_hi,
tv_sec_lo,
tv_nsec,
} => {
let tv_sec = (tv_sec_hi as u64) << 32 | tv_sec_lo as u64;
let tv_usec = tv_nsec / 1000;
tracing::trace!("Screencopy ready: tv_sec={tv_sec}, tv_usec={tv_usec}");
state.on_copy_complete(tv_sec, tv_usec);
}
ScreencopyFrameEvent::Failed => {
tracing::error!("Screencopy frame failed");
state.on_copy_fail();
}
ScreencopyFrameEvent::Damage { .. } => {}
_ => {}
}
}
}
impl<S: crate::state::CaptureSource> Dispatch<ZwlrScreencopyManagerV1, ()> for State<S> {
fn event(
_state: &mut Self,
_proxy: &ZwlrScreencopyManagerV1,
_event: <ZwlrScreencopyManagerV1 as Proxy>::Event,
_data: &(),
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
}
}
+133
View File
@@ -0,0 +1,133 @@
use wayland_client::protocol::wl_output::WlOutput;
use wayland_client::{Dispatch, Proxy, QueueHandle};
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{
Event as XdgOutputEvent, ZxdgOutputV1,
};
use crate::state::{CaptureSource, EncConstructionStage, OutputId, State, Transform};
impl<S: CaptureSource> Dispatch<WlOutput, OutputId> for State<S> {
fn event(
state: &mut Self,
_proxy: &WlOutput,
event: wayland_client::protocol::wl_output::Event,
data: &OutputId,
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
use wayland_client::protocol::wl_output::Event as OutputEvent;
use wayland_client::protocol::wl_output::Mode as WlMode;
use wayland_client::protocol::wl_output::Transform as WlTransform;
let OutputId(target_name) = data;
let idx = match &state.stage {
EncConstructionStage::ProbingOutputs { output_names, .. } => {
output_names.iter().position(|&n| n == *target_name)
}
_ => None,
};
let idx = match idx {
Some(i) => i,
None => return,
};
match event {
OutputEvent::Geometry { transform, .. } => {
let t = match transform {
wayland_client::WEnum::Value(WlTransform::Normal) => Transform::Normal,
wayland_client::WEnum::Value(WlTransform::_90) => Transform::Normal90,
wayland_client::WEnum::Value(WlTransform::_180) => Transform::Normal180,
wayland_client::WEnum::Value(WlTransform::_270) => Transform::Normal270,
wayland_client::WEnum::Value(WlTransform::Flipped) => Transform::Flipped,
wayland_client::WEnum::Value(WlTransform::Flipped90) => Transform::Flipped90,
wayland_client::WEnum::Value(WlTransform::Flipped180) => Transform::Flipped180,
wayland_client::WEnum::Value(WlTransform::Flipped270) => Transform::Flipped270,
_ => Transform::Normal,
};
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.transform = Some(t);
}
}
}
OutputEvent::Mode {
width,
height,
flags,
..
} => {
let is_current = matches!(flags, wayland_client::WEnum::Value(WlMode::Current));
if is_current {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.mode_size = Some((width, height));
}
}
}
}
OutputEvent::Done => {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.done_count += 1;
if info.done_count >= 1 {
state.try_finalize_output(idx);
}
}
}
}
OutputEvent::Name { name } => {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.wl_name = Some(name);
}
}
}
_ => {}
}
}
}
impl<S: CaptureSource> Dispatch<ZxdgOutputV1, OutputId> for State<S> {
fn event(
state: &mut Self,
_proxy: &ZxdgOutputV1,
event: <ZxdgOutputV1 as Proxy>::Event,
data: &OutputId,
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<State<S>>,
) {
let target_name = data.0;
let idx = match &state.stage {
EncConstructionStage::ProbingOutputs { output_names, .. } => {
output_names.iter().position(|&n| n == target_name)
}
_ => None,
};
let idx = match idx {
Some(i) => i,
None => return,
};
match event {
XdgOutputEvent::Name { name } => {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.name = Some(name);
}
}
}
XdgOutputEvent::LogicalSize { .. } => {}
XdgOutputEvent::Done => {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.done_count += 1;
if info.done_count >= 1 {
state.try_finalize_output(idx);
}
}
}
}
_ => {}
}
}
}
+999
View File
@@ -0,0 +1,999 @@
use std::collections::HashMap;
use std::mem;
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::io::FromRawFd;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use wayland_client::backend::ObjectId;
use wayland_client::globals::GlobalList;
use wayland_client::protocol::wl_buffer::WlBuffer;
use wayland_client::protocol::wl_output::WlOutput;
use wayland_client::{Dispatch, QueueHandle};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::Flags as BufferParamsFlags;
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1;
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1;
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
use wayland_protocols_wlr::output_management::v1::client::zwlr_output_manager_v1::ZwlrOutputManagerV1;
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use crate::args::Args;
use crate::avhw::{AvHwDevCtx, EncState, EncodedH264Frame, SwEncState};
use crate::fps_limit::FpsLimit;
use crate::stats::{FrameTimings, PipelineStats};
use crate::transform::{transpose_if_transform_transposed, Transform};
use crate::webrtc::WebRtcState;
mod dispatch;
// ---------------------------------------------------------------------------
// CaptureSource trait
// ---------------------------------------------------------------------------
/// Screen capture backend trait.
pub trait CaptureSource: Sized + 'static {
type Frame: Send;
fn new(
gm: &GlobalList,
output: &WlOutput,
output_info: &OutputInfo,
qh: &QueueHandle<State<Self>>,
) -> Result<Self>;
fn queue_copy(&mut self, buffer: &WlBuffer, qh: &QueueHandle<State<Self>>);
fn on_done_with_frame(&mut self, frame: Self::Frame);
}
// ---------------------------------------------------------------------------
// Output info types
// ---------------------------------------------------------------------------
pub struct OutputInfo {
pub name: String,
pub transform: Transform,
}
#[derive(Default)]
pub struct PartialOutputInfo {
pub name: Option<String>,
/// Name from wl_output::Name (v4) — used to match wlr-output-management heads
pub wl_name: Option<String>,
pub transform: Option<Transform>,
// Pixel dimensions from Mode event — preparatory for Phase 2 resolution logic
pub mode_size: Option<(i32, i32)>,
pub done_count: u32,
}
/// Marker for wlr-output-management heads seen during probing; tracked by name
/// in `EncConstructionStage::ProbingOutputs.wlr_heads`.
// `pub(crate)` (not module-private): exposed via `EncConstructionStage::ProbingOutputs.wlr_heads`
// which is reached from main.rs during the wlr-screencopy probing loop.
pub(crate) struct WlrHeadInfo {}
/// User data for XdgOutput dispatch to identify which WlOutput it belongs to.
pub struct OutputId(pub u32);
// ---------------------------------------------------------------------------
// StreamingEncoder
// ---------------------------------------------------------------------------
/// Wraps the two possible encoder backends for the streaming stage.
///
/// - `Mp4(EncState)` — hardware VAAPI encoder writing to an MP4 file
/// - `WebRtc(SwEncState)` — software encoder feeding H.264 NALUs into a WebRTC channel
pub enum StreamingEncoder {
Mp4(EncState),
WebRtc(SwEncState),
}
impl StreamingEncoder {
fn frames_rgb(&self) -> &crate::avhw::AvHwFrameCtx {
match self {
StreamingEncoder::Mp4(enc) => enc.frames_rgb(),
StreamingEncoder::WebRtc(enc) => enc.frames_rgb(),
}
}
fn encode_frame(
&mut self,
hw_frame: &ffmpeg_next::frame::Video,
) -> anyhow::Result<crate::avhw::EncodeStages> {
match self {
StreamingEncoder::Mp4(enc) => enc.encode_frame(hw_frame),
StreamingEncoder::WebRtc(enc) => enc.encode_frame(hw_frame),
}
}
pub fn flush(&mut self) -> anyhow::Result<()> {
match self {
StreamingEncoder::Mp4(enc) => enc.flush(),
StreamingEncoder::WebRtc(enc) => enc.flush(),
}
}
}
// ---------------------------------------------------------------------------
// EncConstructionStage
// ---------------------------------------------------------------------------
//
// `pub(crate)` (not `pub`): this enum leaks the private `WlrHeadInfo` type via
// its `wlr_heads` field, and the construction-stage state machine is an
// internal implementation detail. Crate-internal consumers (main.rs) get there
// via `crate::state::`; there is no need to expose this across the crate
// boundary. See Oracle audit 2026-06-28.
pub(crate) enum EncConstructionStage<S: CaptureSource> {
ProbingOutputs {
outputs: Vec<PartialOutputInfo>,
bound_outputs: Vec<WlOutput>,
output_names: Vec<u32>,
screencopy_manager: Option<ZwlrScreencopyManagerV1>,
dmabuf: Option<ZwpLinuxDmabufV1>,
dmabuf_feedback: Option<ZwpLinuxDmabufFeedbackV1>,
xdg_output_manager: Option<ZxdgOutputManagerV1>,
wlr_output_manager: Option<ZwlrOutputManagerV1>,
wlr_manager_done: bool,
wlr_heads: HashMap<String, WlrHeadInfo>,
wlr_head_proxy_to_name: HashMap<ObjectId, String>,
},
EverythingButFmt {
output_info: OutputInfo,
output: WlOutput,
hw_device_ctx: AvHwDevCtx,
cap: S,
screencopy_manager: ZwlrScreencopyManagerV1,
dmabuf: ZwpLinuxDmabufV1,
},
Streaming {
output: WlOutput,
enc: StreamingEncoder,
cap: S,
screencopy_manager: ZwlrScreencopyManagerV1,
dmabuf: ZwpLinuxDmabufV1,
},
Intermediate,
}
// ---------------------------------------------------------------------------
// InFlightSurface
// ---------------------------------------------------------------------------
pub enum InFlightSurface<S: CaptureSource> {
None,
AllocQueued,
CopyQueued {
surface: ff::frame::Video,
// Boxed: AVDRMFrameDescriptor is ~592 bytes (4 objects + 4 layers),
// which would balloon every InFlightSurface variant via enum alignment.
// The box shrinks the enum to ~32 bytes regardless of variant.
drm_map: Box<ff::ffi::AVDRMFrameDescriptor>,
frame: S::Frame,
buffer: WlBuffer,
},
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
pub struct State<S: CaptureSource> {
pub(crate) stage: EncConstructionStage<S>,
pub in_flight_surface: InFlightSurface<S>,
pub stats_start_time: Option<Instant>,
pub stats_last_time: Option<Instant>,
pub stats_frames: u64,
pub first_frame: bool,
pub args: Args,
pub errored: bool,
pub gm: GlobalList,
pub fps_limit: FpsLimit<S::Frame>,
pub qhandle: QueueHandle<State<S>>,
pub drm_device: Option<PathBuf>,
pub drm_device_from_compositor: Option<PathBuf>,
pub webrtc: Option<WebRtcState>,
pub webrtc_tx: Option<crossbeam_channel::Sender<EncodedH264Frame>>,
webrtc_rx: Option<crossbeam_channel::Receiver<EncodedH264Frame>>,
webrtc_frames_sent: u64,
webrtc_paused: Option<Arc<AtomicBool>>,
stats: PipelineStats,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Scan /dev/dri for all available DRM render nodes (renderD*), sorted by node number.
pub(crate) fn find_drm_render_nodes() -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
return Vec::new();
};
let mut nodes: Vec<(u32, PathBuf)> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?;
let number = name.strip_prefix("renderD")?.parse::<u32>().ok()?;
std::fs::metadata(&path).ok()?;
Some((number, path))
})
.collect();
nodes.sort_by_key(|(number, _)| *number);
nodes.into_iter().map(|(_, path)| path).collect()
}
/// Scan /dev/dri for the first available DRM render node (renderD*).
fn find_drm_render_node() -> Option<PathBuf> {
find_drm_render_nodes().into_iter().next()
}
impl<S: CaptureSource> State<S> {
fn resolve_drm_path(&self) -> PathBuf {
self.drm_device
.clone()
.or_else(|| self.drm_device_from_compositor.clone())
.or_else(find_drm_render_node)
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
}
}
// ---------------------------------------------------------------------------
// State<S> methods
// ---------------------------------------------------------------------------
impl<S: CaptureSource> State<S> {
pub fn new(gm: GlobalList, args: Args, qhandle: QueueHandle<State<S>>) -> Result<Self> {
let fps = args.fps;
let drm_device = args.drm_device.as_ref().map(PathBuf::from);
let (webrtc, webrtc_tx, webrtc_rx, webrtc_paused) = if args.port > 0 {
let (tx, rx) = crossbeam_channel::bounded(32);
let wrtc = WebRtcState::new(args.port, args.fps)?;
// paused=true until first WebRTC client connects
let paused = Arc::new(AtomicBool::new(true));
(Some(wrtc), Some(tx), Some(rx), Some(paused))
} else {
(None, None, None, None)
};
let mut state = Self {
stage: EncConstructionStage::ProbingOutputs {
outputs: Vec::new(),
bound_outputs: Vec::new(),
output_names: Vec::new(),
screencopy_manager: None,
dmabuf: None,
dmabuf_feedback: None,
xdg_output_manager: None,
wlr_output_manager: None,
wlr_manager_done: false,
wlr_heads: HashMap::new(),
wlr_head_proxy_to_name: HashMap::new(),
},
in_flight_surface: InFlightSurface::None,
stats_start_time: None,
stats_last_time: None,
stats_frames: 0,
first_frame: true,
fps_limit: FpsLimit::new(fps),
args,
errored: false,
gm,
qhandle,
drm_device,
drm_device_from_compositor: None,
webrtc,
webrtc_tx,
webrtc_rx,
webrtc_frames_sent: 0,
webrtc_paused,
stats: PipelineStats::new(),
};
// registry_queue_init consumes registry events internally during its
// initial roundtrip and does NOT forward them to our Dispatch impl.
// We must manually bind the initial globals here.
state.bind_initial_globals();
Ok(state)
}
/// Iterate over the GlobalList from registry_queue_init and bind all
/// globals we care about. This is necessary because registry_queue_init
/// consumes registry events during its internal roundtrip without forwarding
/// them to our Dispatch<WlRegistry> handler.
fn bind_initial_globals(&mut self) {
use wayland_client::globals::Global;
let globals: Vec<Global> = self.gm.contents().clone_list();
let registry = self.gm.registry();
let qhandle = &self.qhandle;
// Sort globals so that managers are bound BEFORE wl_output.
// This ensures xdg_output_manager and zwlr_output_manager are available
// when we bind wl_output, so we can immediately get xdg_output / wlr head.
let globals = {
fn priority(interface: &str) -> u8 {
match interface {
"zwlr_screencopy_manager_v1" => 0,
"zwp_linux_dmabuf_v1" => 0,
"zxdg_output_manager_v1" => 1,
"zwlr_output_manager_v1" => 1,
"wl_output" => 2,
_ => 3,
}
}
let mut g = globals;
g.sort_by_key(|g| priority(&g.interface));
g
};
for Global {
name,
interface,
version,
} in globals
{
match interface.as_str() {
"zwlr_screencopy_manager_v1" => {
let v = version.min(3);
tracing::debug!("Init: binding zwlr_screencopy_manager_v1 v{v} (name={name})");
let mgr: ZwlrScreencopyManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
screencopy_manager, ..
} = &mut self.stage
{
*screencopy_manager = Some(mgr);
}
}
"zwp_linux_dmabuf_v1" => {
let v = version.min(4);
tracing::debug!("Init: binding zwp_linux_dmabuf_v1 v{v} (name={name})");
let proxy: ZwpLinuxDmabufV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
dmabuf,
dmabuf_feedback,
..
} = &mut self.stage
{
*dmabuf = Some(proxy.clone());
if v >= 4 {
let feedback = proxy.get_default_feedback(qhandle, ());
*dmabuf_feedback = Some(feedback);
}
}
}
"zxdg_output_manager_v1" => {
let v = version.min(3);
tracing::debug!("Init: binding zxdg_output_manager_v1 v{v} (name={name})");
let xdg_mgr: ZxdgOutputManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
bound_outputs,
xdg_output_manager,
output_names,
..
} = &mut self.stage
{
for (i, output) in bound_outputs.iter().enumerate() {
let oname = output_names.get(i).copied().unwrap_or(0);
let output_id = OutputId(oname);
xdg_mgr.get_xdg_output(output, qhandle, output_id);
}
*xdg_output_manager = Some(xdg_mgr);
}
}
"zwlr_output_manager_v1" => {
let v = version.min(4);
tracing::debug!("Init: binding zwlr_output_manager_v1 v{v} (name={name})");
let mgr: ZwlrOutputManagerV1 = registry.bind(name, v, qhandle, ());
if let EncConstructionStage::ProbingOutputs {
wlr_output_manager, ..
} = &mut self.stage
{
*wlr_output_manager = Some(mgr);
}
}
"wl_output" => {
let v = version.min(4);
tracing::debug!("Init: binding wl_output v{v} (name={name})");
let output: WlOutput = registry.bind(name, v, qhandle, OutputId(name));
if let EncConstructionStage::ProbingOutputs {
outputs,
bound_outputs,
output_names,
xdg_output_manager,
..
} = &mut self.stage
{
outputs.push(PartialOutputInfo::default());
bound_outputs.push(output.clone());
output_names.push(name);
if let Some(xdg_mgr) = xdg_output_manager {
let output_id = OutputId(name);
xdg_mgr.get_xdg_output(&output, qhandle, output_id);
}
}
}
_ => {}
}
}
}
pub fn queue_alloc_frame(&mut self)
where
State<S>: Dispatch<ZwlrScreencopyFrameV1, ()>,
{
let (manager, output) = match &self.stage {
EncConstructionStage::Streaming {
screencopy_manager,
output,
..
} => (screencopy_manager.clone(), output.clone()),
EncConstructionStage::EverythingButFmt {
screencopy_manager,
output,
..
} => (screencopy_manager.clone(), output.clone()),
_ => return,
};
match &self.in_flight_surface {
InFlightSurface::None => {}
_ => return,
}
let _frame_proxy = manager.capture_output(1, &output, &self.qhandle, ());
self.in_flight_surface = InFlightSurface::AllocQueued;
}
pub fn on_frame_allocd(&mut self, frame: S::Frame, format: u32, width: u32, height: u32) {
let (frames_rgb_ctx, dmabuf, cap) = match &mut self.stage {
EncConstructionStage::Streaming {
output: _,
enc,
dmabuf,
cap,
screencopy_manager: _,
} => (enc.frames_rgb().as_ptr(), dmabuf, cap),
_ => {
tracing::warn!("on_frame_allocd: not in Streaming stage");
return;
}
};
let mut surface = ff::frame::Video::empty();
// SAFETY: frames_rgb_ctx is a valid AVHWFramesContext pointer; surface
// is a freshly allocated empty Video frame.
let ret = unsafe { ffi::av_hwframe_get_buffer(frames_rgb_ctx, surface.as_mut_ptr(), 0) };
if ret < 0 {
tracing::error!("av_hwframe_get_buffer failed: {}", crate::avhw::ff_err(ret));
self.errored = true;
return;
}
let mut map_frame = ff::frame::Video::empty();
// SAFETY: Setting format to DRM_PRIME and calling av_hwframe_map creates
// a mapped view of the GPU surface with DMA-BUF file descriptors.
unsafe {
(*map_frame.as_mut_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
}
// SAFETY: map_frame and surface are valid, owned AVFrame pointers from
// av_hwframe_get/surface.alloc above. AV_HWFRAME_MAP_READ flag (0 here)
// requests a read-only mapping. The DRM_PRIME format set above instructs
// FFmpeg to populate data[0] with an AVDRMFrameDescriptor on success.
let ret = unsafe { ffi::av_hwframe_map(map_frame.as_mut_ptr(), surface.as_ptr(), 0) };
if ret < 0 {
tracing::error!("av_hwframe_map failed: {}", crate::avhw::ff_err(ret));
self.errored = true;
return;
}
// SAFETY: After av_hwframe_map with DRM_PRIME format, data[0] points to
// a valid AVDRMFrameDescriptor.
let desc: ff::ffi::AVDRMFrameDescriptor = unsafe {
let desc_ptr = (*map_frame.as_ptr()).data[0] as *const ff::ffi::AVDRMFrameDescriptor;
std::ptr::read(desc_ptr)
};
let params = dmabuf.create_params(&self.qhandle, ());
for layer_idx in 0..desc.nb_layers as usize {
let layer = &desc.layers[layer_idx];
for p in 0..layer.nb_planes as usize {
let plane = &layer.planes[p];
let obj = &desc.objects[plane.object_index as usize];
let mod_hi = (obj.format_modifier >> 32) as u32;
let mod_lo = (obj.format_modifier & 0xFFFF_FFFF) as u32;
// SAFETY: obj.fd is a valid DMA-BUF fd. We dup because params.add()
// takes ownership of the fd, and the original fd is owned by map_frame.
let fd_dup = unsafe { libc::dup(obj.fd) };
if fd_dup < 0 {
tracing::error!(
"failed to dup dma-buf fd: {}",
std::io::Error::last_os_error()
);
// wayland-client does not auto-destroy params on Drop.
params.destroy();
self.errored = true;
return;
}
// SAFETY: fd_dup is valid freshly-duped fd.
let fd_owned = unsafe { OwnedFd::from_raw_fd(fd_dup) };
params.add(
fd_owned.as_fd(),
p as u32,
plane.offset as u32,
plane.pitch as u32,
mod_hi,
mod_lo,
);
}
}
let wl_buffer = params.create_immed(
width as i32,
height as i32,
format,
BufferParamsFlags::empty(),
&self.qhandle,
(),
);
self.in_flight_surface = InFlightSurface::CopyQueued {
surface,
drm_map: Box::new(desc),
frame,
buffer: wl_buffer,
};
let buffer_ref = match &self.in_flight_surface {
InFlightSurface::CopyQueued { buffer, .. } => buffer,
_ => unreachable!("just set to CopyQueued"),
};
cap.queue_copy(buffer_ref, &self.qhandle);
}
pub fn on_copy_complete(&mut self, tv_sec: u64, tv_usec: u32)
where
S::Frame: Default,
{
self.stats.record_capture();
let (mut surface, _drm_map, frame, buffer) =
match mem::replace(&mut self.in_flight_surface, InFlightSurface::None) {
InFlightSurface::CopyQueued {
surface,
drm_map,
frame,
buffer,
} => (surface, drm_map, frame, buffer),
other => {
tracing::warn!("on_copy_complete: unexpected state");
self.in_flight_surface = other;
return;
}
};
// PTS in 90kHz media-clock ticks (WebRTC encoder time_base = 1/90000).
// Must match Portal path's compute_capture_pts unit. See issue #25.
let pts = (tv_sec as i64) * 90_000 + (tv_usec as i64) * 90_000 / 1_000_000;
surface.set_pts(Some(pts));
drop(buffer);
let cap = match &mut self.stage {
EncConstructionStage::Streaming { cap, .. } => cap,
_ => {
tracing::warn!("on_copy_complete: not in Streaming stage");
return;
}
};
cap.on_done_with_frame(frame);
let enc = match &mut self.stage {
EncConstructionStage::Streaming { enc, .. } => enc,
_ => unreachable!("already checked Streaming above"),
};
let should_encode = if self.first_frame {
self.first_frame = false;
true
} else {
self.fps_limit
.on_new_frame(S::Frame::default(), Instant::now())
.is_some()
};
if should_encode {
let encode_start = Instant::now();
match enc.encode_frame(&surface) {
Ok(stages) => {
let encode_elapsed = encode_start.elapsed().as_micros() as u64;
self.stats.record_encode(&FrameTimings {
scale_us: stages.scale_us,
transfer_us: stages.transfer_us,
encode_us: stages.encode_us,
total_us: encode_elapsed,
..Default::default()
});
}
Err(e) => {
tracing::error!("encode_frame failed: {}", e);
self.errored = true;
}
}
}
self.stats_frames += 1;
if let Some(last) = self.stats_last_time {
if last.elapsed() >= std::time::Duration::from_secs(10) {
let delta = self.stats_frames;
let fps = delta as f64 / last.elapsed().as_secs_f64();
tracing::info!(
frames = self.stats_frames,
fps = format!("{fps:.1}"),
"encoding stats"
);
self.stats_last_time = Some(std::time::Instant::now());
self.stats_frames = 0;
}
} else {
self.stats_start_time = Some(std::time::Instant::now());
self.stats_last_time = Some(std::time::Instant::now());
}
}
pub fn on_copy_fail(&mut self)
where
S::Frame: Default,
{
tracing::error!("compositor copy failed");
let taken = mem::replace(&mut self.in_flight_surface, InFlightSurface::None);
match taken {
InFlightSurface::CopyQueued { buffer, frame, .. } => {
drop(buffer);
if let EncConstructionStage::Streaming { cap, .. } = &mut self.stage {
cap.on_done_with_frame(frame);
}
}
other => {
self.in_flight_surface = other;
}
}
self.errored = true;
}
pub fn poll_webrtc(&mut self) -> Result<()> {
let Some(ref mut wrtc) = self.webrtc else {
return Ok(());
};
wrtc.handle_signaling()?;
wrtc.poll_and_feed()?;
let connected = wrtc.is_connected();
if let Some(ref paused) = self.webrtc_paused {
let was_paused = paused.load(Ordering::Relaxed);
let now_paused = !connected;
if was_paused && !now_paused {
tracing::info!("WebRTC client connected, resuming encoding");
} else if !was_paused && now_paused {
tracing::warn!("WebRTC client disconnected, pausing encoding");
}
paused.store(now_paused, Ordering::Relaxed);
}
if let Some(ref rx) = self.webrtc_rx {
let mut count = 0u32;
while let Ok(enc_frame) = rx.try_recv() {
if !connected {
continue;
}
count += 1;
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
tracing::debug!("WebRTC write frame error: {e}");
}
self.stats.record_send(0.0, None);
self.webrtc_frames_sent = self.webrtc_frames_sent.saturating_add(1);
}
if count > 0 {
tracing::debug!("WebRTC forwarded {count} frames from channel");
}
}
if self.args.stats && self.stats.should_snapshot() {
self.stats
.set_queue_depths(0, self.webrtc_rx.as_ref().map(|r| r.len()).unwrap_or(0));
let snap = self.stats.snapshot_and_reset();
tracing::info!("stats: {snap}");
}
Ok(())
}
pub fn negotiate_format(&mut self, format: u32, width: u32, height: u32) {
let stage_data = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
EncConstructionStage::EverythingButFmt {
output_info,
output,
hw_device_ctx,
cap,
screencopy_manager,
dmabuf,
} => (
output_info,
output,
hw_device_ctx,
cap,
screencopy_manager,
dmabuf,
),
other => {
tracing::warn!("negotiate_format: not in EverythingButFmt stage");
self.stage = other;
return;
}
};
let (output_info, output, hw_device_ctx, cap, screencopy_manager, dmabuf) = stage_data;
let drm_path = self.resolve_drm_path();
let fps = self.args.fps;
let bitrate = self
.args
.bitrate
.unwrap_or_else(|| 2 * (width as u64) * (height as u64) * (fps as u64) / 100);
let enc = if let Some(ref tx) = self.webrtc_tx {
let (enc_w, enc_h) = transpose_if_transform_transposed(
output_info.transform,
width as i32,
height as i32,
);
let actual_gop_size = self.args.gop_size.unwrap_or((fps * 2).max(20));
match SwEncState::new_webrtc(
&drm_path,
width,
height,
enc_w as u32,
enc_h as u32,
fps,
bitrate,
actual_gop_size,
tx.clone(),
self.webrtc_paused
.as_ref()
.expect("webrtc_paused must exist when webrtc_tx exists")
.clone(),
) {
Ok(enc) => StreamingEncoder::WebRtc(enc),
Err(e) => {
tracing::error!("SwEncState::new_webrtc failed: {}", e);
self.errored = true;
return;
}
}
} else {
let output_path = self
.args
.output
.as_deref()
.expect("output required for MP4 mode");
match crate::avhw::create_encoder(
&drm_path,
Path::new(output_path),
width,
height,
fps,
output_info.transform,
self.args.bitrate,
self.args.gop_size,
Some(hw_device_ctx),
) {
Ok(enc) => StreamingEncoder::Mp4(enc),
Err(e) => {
tracing::error!("EncState::new failed: {}", e);
self.errored = true;
return;
}
}
};
tracing::info!(
"Encoder initialized: {}x{} format={} bitrate={}",
width,
height,
format,
bitrate
);
self.stage = EncConstructionStage::Streaming {
output,
enc,
cap,
screencopy_manager,
dmabuf,
};
}
fn try_finalize_output(&mut self, _idx: usize) -> bool {
let (target_idx, output_count) = match &self.stage {
EncConstructionStage::ProbingOutputs {
outputs,
xdg_output_manager,
wlr_manager_done,
..
} => {
let has_xdg = xdg_output_manager.is_some();
let output_count = outputs.len();
let idx = if let Some(ref name) = self.args.output_name {
let pos = outputs
.iter()
.position(|o| o.name.as_deref() == Some(name.as_str()));
match pos {
Some(i) => Some(i),
None => {
let all_probed = outputs.iter().all(|o| o.done_count >= 1);
if all_probed {
let available: Vec<&str> =
outputs.iter().filter_map(|o| o.name.as_deref()).collect();
tracing::error!(
"Output '{}' not found. Available outputs: {:?}",
name,
available
);
self.errored = true;
}
None
}
}
} else if outputs.iter().all(|o| o.done_count >= 1) {
if outputs.is_empty() {
return false;
}
Some(0)
} else {
None
};
match idx {
Some(i) => {
let info = &outputs[i];
if has_xdg {
// done_count >= 2 implies physical_size and logical_position
// already arrived (Wayland: Geometry/Mode/Position fire before Done).
if info.done_count < 2
|| info.name.is_none()
|| info.transform.is_none()
{
return false;
}
} else {
// done_count >= 1 implies transform arrived (Geometry precedes Done).
if info.done_count < 1 || !wlr_manager_done || info.transform.is_none()
{
return false;
}
}
(i, output_count)
}
None => return false,
}
}
_ => return false,
};
let probing = match mem::replace(&mut self.stage, EncConstructionStage::Intermediate) {
s @ EncConstructionStage::ProbingOutputs { .. } => s,
other => {
self.stage = other;
return false;
}
};
let (
outputs,
bound_outputs,
output_names,
screencopy_manager,
dmabuf,
dmabuf_feedback,
_xdg_output_manager,
_wlr_output_manager,
_wlr_manager_done,
_wlr_heads,
_wlr_head_proxy_to_name,
) = match probing {
EncConstructionStage::ProbingOutputs {
outputs,
bound_outputs,
output_names,
screencopy_manager,
dmabuf,
dmabuf_feedback,
xdg_output_manager,
wlr_output_manager,
wlr_manager_done,
wlr_heads,
wlr_head_proxy_to_name,
} => (
outputs,
bound_outputs,
output_names,
screencopy_manager,
dmabuf,
dmabuf_feedback,
xdg_output_manager,
wlr_output_manager,
wlr_manager_done,
wlr_heads,
wlr_head_proxy_to_name,
),
_ => unreachable!(),
};
// Destroy feedback object — prevents server-side resource leak
if let Some(feedback) = dmabuf_feedback {
feedback.destroy();
}
let info = &outputs[target_idx];
let output_info = OutputInfo {
name: info
.name
.clone()
.or(info.wl_name.clone())
.unwrap_or_else(|| format!("output-{}", output_names[target_idx])),
transform: info.transform.unwrap(),
};
let output = bound_outputs[target_idx].clone();
let screencopy_manager = match screencopy_manager {
Some(m) => m,
None => {
tracing::error!("No screencopy manager bound");
self.errored = true;
return false;
}
};
let dmabuf = match dmabuf {
Some(d) => d,
None => {
tracing::error!("No dmabuf manager bound");
self.errored = true;
return false;
}
};
let drm_path = self.resolve_drm_path();
let hw_device_ctx = match AvHwDevCtx::new_vaapi(&drm_path) {
Ok(ctx) => ctx,
Err(e) => {
tracing::error!("Failed to create VAAPI device: {}", e);
self.errored = true;
return false;
}
};
let cap = match S::new(&self.gm, &output, &output_info, &self.qhandle) {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to create capture source: {}", e);
self.errored = true;
return false;
}
};
tracing::info!("Selected output: {}", output_info.name);
if self.args.output_name.is_none() && output_count > 1 {
tracing::warn!(
"Multiple outputs found, using '{}'. Use --output-name to select.",
output_info.name
);
}
self.stage = EncConstructionStage::EverythingButFmt {
output_info,
output,
hw_device_ctx,
cap,
screencopy_manager,
dmabuf,
};
true
}
}
+62 -578
View File
@@ -1,33 +1,8 @@
//! Portal 后端的主状态机:通过 PipeWire + DMA-BUF 进行屏幕采集并软件编码。
//!
//! ## 整体角色
//!
//! `StatePortal` 与 `src/state.rs::State` 是平行的两条采集路径:
//! - `state.rs`wlroots 路径):由外层 `mio` 事件循环驱动(手工版 epoll),
//! 通过 `zwlr_screencopy_manager_v1` 协议一帧一帧地拉取。
//! - `state_portal.rs`(本文件,XDG Portal / PipeWire 路径):由 `CapPortal`
//! 通过 `crossbeam_channel::Receiver<PwDmaBufFrame>` 推帧;本状态机只负责"消费"。
//!
//! ## 异步模型的真相
//!
//! 本文件**不**使用 `mio` 或 `tokio`——`CapPortal` 内部在独立线程跑 PipeWire
//! asyncio loop,把 DMA-BUF 帧通过 crossbeam channel 投递出来;外层 `main.rs`
//! 只需在 `while !is_errored()` 循环里轮询 `poll_and_encode(block)`。编码线程与
//! WebRTC 线程通过 `std::thread::spawn`(不是 `tokio::spawn`)启动,再借助
//! crossbeam channel 与主线程通信——类比 Go 的 `go func()` + channel。
//!
//! ## 阶段机
//!
//! `PortalStage::WaitingForFormat`(等首帧以确定格式)→ `Streaming`(持续编码)。
//!
//! ## 注意
//!
//! - T9a(本块)覆盖文件头 + struct 定义 + `impl StatePortal`(至 `fn encode_thread_loop` 之前);
//! T9b 覆盖 `encode_thread_loop` / `webrtc_thread_loop` / `resolve_drm_device` 等自由函数。
//! - 多处 `unsafe` 调用 FFmpeg/VAAPI FFI;现有英文 SAFETY 标记保留不动,
//! 本任务在每个 unsafe 块上方加普通 `//` 中文概述(不新增 SAFETY 标记)。
// 采集门户状态模块 —— 通过 PipeWire/DMA-BUF 进行屏幕采集并编码
// AsRawFd is required by frame.fd.as_raw_fd() in build_drm_descriptor below
// but rustc emits a false "unused_imports" warning because OwnedFd also has
// an inherent as_raw_fd — same quirk as avhw.rs. E0599 if removed → keep it.
#[allow(unused_imports)]
use std::os::fd::AsRawFd;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -38,13 +13,21 @@ use anyhow::{bail, Result}; // 错误处理工具
use crate::args::Args; // 命令行参数
use crate::avhw::{
self, BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodedH264Frame, ResolutionChange,
SwEncEncode, SwEncImport, SwEncState,
self, BitrateCommand, CpuNv12Frame, ResolutionChange, SwEncEncode, SwEncImport, SwEncState,
}; // 软件编码器状态(VAAPI 导入 + H.264 编码)
use crate::cap_portal::{CapPortal, PwCtrlEvent, PwDmaBufFrame}; // PipeWire 屏幕采集端点
use crate::stats::{FrameTimings, PipelineStats}; // 管道统计(帧计时、每秒快照)
use crate::webrtc::WebRtcState; // WebRTC 信令与媒体传输
mod bitrate;
use bitrate::webrtc_startup_bitrate_bps;
mod threads;
use threads::{
encode_thread_loop, webrtc_thread_loop, EncodeThread, EncodeThreadTiming, WebRtcThreadChannels,
WebRtcThreadConfig, WebrtcThread,
};
/// 门户采集的阶段状态
/// - WaitingForFormat: 等待接收到第一帧 DMA-BUF 以确定视频格式参数
/// - Streaming: 已完成初始化,正在持续编码流
@@ -53,41 +36,6 @@ enum PortalStage {
Streaming,
}
/// 编码线程单帧计时回执——由 `encode_thread_loop` 通过 `timing_tx` 发回主线程,
/// 用于在 `PipelineStats` 中窗口化统计 `sws_us`libswscale 缩放开销)和
/// `encode_us`H.264 软件编码开销)。类比 Go 的 `type EncodeThreadTiming struct`。
struct EncodeThreadTiming {
sws_us: u64,
encode_us: u64,
output_bytes: usize,
}
/// 编码工作线程的句柄与通信端点。
///
/// 由主线程持有,负责把 NV12 帧 (`CpuNv12Frame`) 通过 `input_tx` 投递给
/// `encode_thread_loop`;编码完成后通过 `timing_rx` 收回单帧计时;`duplicate_count`
/// 是跨线程共享的 `Arc<AtomicU64>`(类比 Go 的 `*uint64` protected by atomic),
/// 用于统计被去重跳过的帧数(影响 BWE 与丢弃策略)。
///
/// 字段全部用 `Option<...>`/`Sender`/`Receiver` 包装,是为了在 `shutdown` 时
/// 能用 `Option::take()` 把所有权转移到本地变量、显式 drop `input_tx`、再 `join()`。
struct EncodeThread {
handle: Option<std::thread::JoinHandle<()>>,
input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// WebRTC 工作线程的句柄与单向上行通道。
///
/// 主线程只能通过 `sent_gap_rx` **被动接收** WebRTC 线程上报的"已发送帧间隔/老化"
/// 指标(用于 `PipelineStats::record_send_from_thread`)。下行的码率 / 分辨率 / 暂停
/// 控制走另一组 channel`bitrate_tx` / `resolution_tx` / `webrtc_paused`),不在此处。
struct WebrtcThread {
handle: Option<std::thread::JoinHandle<()>>,
sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
}
/// 门户模式的主状态机
///
/// 负责管理从 PipeWire 采集屏幕帧、通过 VAAPI 硬件编码的完整生命周期。
@@ -117,14 +65,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 +213,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) =
@@ -307,22 +243,8 @@ impl StatePortal {
bitrate_rx,
encoder_resolution_rx,
)?;
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 = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
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 +275,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 +334,17 @@ 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 +371,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 +396,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 +464,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 +483,45 @@ 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。
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,
)
}?;
// 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) }?;
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` 的回读路径竞争)。
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,
)
}?;
// SAFETY: same contract as the enc branch above — frames_rgb owned by
// import, `frame` carries the PipeWire DMA-BUF metadata.
let mut vaapi_frame =
unsafe { avhw::import_dma_buf_to_vaapi(import.frames_rgb().as_ptr(), &frame) }?;
// SAFETY: vaapi_frame is the valid AVFrame returned above; pts is plain i64.
unsafe {
(*vaapi_frame.as_mut_ptr()).pts = pts;
}
@@ -634,9 +535,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 +603,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,292 +647,6 @@ 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) => {
match encode.encode_cpu_frame(&frame) {
Ok(EncodeOutcome::Encoded) => {
let t = encode.take_timing();
let _ = timing_tx.try_send(EncodeThreadTiming {
sws_us: t.sws_us,
encode_us: t.encode_us,
output_bytes: t.output_bytes,
});
}
Ok(EncodeOutcome::SkippedDuplicate) => {
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(_) => {
// SkippedPaused / SkippedDisconnected — no counter needed
}
Err(e) => {
tracing::error!("Encode thread error: {e}");
break;
}
}
}
Err(_) => {
tracing::info!("Encode thread input closed, flushing encoder");
if let Err(e) = encode.flush() {
tracing::error!("Encode thread flush error: {e}");
}
break;
}
}
}
tracing::info!("Encode thread exiting");
}
// === WebRTC 信令 + 帧发送主循环(独立 std::thread,非 tokio ===
// 该线程串行处理 4 件事:
// 1. str0m 信令(ICE/DTLS+ RTP 打包发送(wrtc.handle_signaling / poll_and_feed);
// 2. 自适应码率(BWE)→ bitrate_tx 下发 UpdateBitrate/ForceKeyframe 给编码线程;
// 3. 自适应分辨率(每 1s 评估)→ resolution_tx 下发 UpdateResolution
// 4. 从 webrtc_rx 取已编码 H264 帧,写入 str0m RTP sink。
// 暂停状态由 Arc<AtomicBool> 跨线程共享:编码线程读,本线程写。
fn webrtc_thread_loop(
mut wrtc: WebRtcState,
webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
fps: u32,
enc_width: u32,
enc_height: u32,
max_bitrate: u64,
paused: Arc<AtomicBool>,
sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
) {
let mut frames_sent: u64 = 0;
let mut last_send: Option<std::time::Instant> = None;
let mut last_sent_bitrate: Option<u64> = None;
let initial_tier = (enc_width, enc_height);
let mut current_tier = initial_tier;
let mut upscale_counter = 0u32;
let mut last_resolution_eval = Instant::now();
// recv 超时 1ms——既能让循环周期性处理 str0m 信令,又能在帧到达时立即返回。
let timeout = Duration::from_millis(1);
loop {
if let Err(e) = wrtc.handle_signaling() {
tracing::error!("WebRTC signaling error: {e}");
break;
}
if let Err(e) = wrtc.poll_and_feed() {
tracing::error!("WebRTC poll error: {e}");
break;
}
if wrtc.take_force_keyframe() {
let _ = bitrate_tx.try_send(BitrateCommand::ForceKeyframe);
}
let connected = wrtc.is_connected();
// Arc<AtomicBool> 跨线程协调:编码线程 Relaxed 读 paused;本线程 Relaxed 写。
// Relaxed 取舍:暂停标志无内存序需求(不保护其他共享数据),只需原子可见性。
let was_paused = paused.load(Ordering::Relaxed);
let now_paused = !connected;
if was_paused && !now_paused {
tracing::info!("WebRTC client connected, resuming encoding");
} else if !was_paused && now_paused {
tracing::warn!("WebRTC client disconnected, pausing encoding");
}
paused.store(now_paused, Ordering::Relaxed);
if let Some(bwe) = wrtc.get_bwe_estimate() {
// #23: Cap BWE to prevent runaway bitrate escalation. Without this, BWE
// estimates can rise to 10+ Mbps, causing IDR bursts and PLI storms.
let effective_bwe = bwe.min(max_bitrate);
if effective_bwe != bwe {
tracing::debug!(
bwe,
effective_bwe,
max_bitrate,
"BWE exceeds --max-bitrate cap, clamping"
);
}
let bwe = effective_bwe;
let should_send = match last_sent_bitrate {
None => true,
Some(last) => {
let diff = if bwe > last { bwe - last } else { last - bwe };
diff * 10 > last
}
};
if should_send {
let _ = bitrate_tx.try_send(BitrateCommand::UpdateBitrate { target_bps: bwe });
last_sent_bitrate = Some(bwe);
}
if last_resolution_eval.elapsed() >= Duration::from_secs(1) {
last_resolution_eval = Instant::now();
let selected = select_resolution(current_tier.0, current_tier.1, bwe, fps);
if selected != current_tier {
current_tier = selected;
upscale_counter = 0;
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
width: current_tier.0,
height: current_tier.1,
});
wrtc.set_need_keyframe();
} else if let Some(next_tier) = next_upscale_tier(current_tier, initial_tier) {
let needed = resolution_bitrate_bps(next_tier.0, next_tier.1, fps);
if bwe > needed.saturating_mul(120) / 100 {
upscale_counter = upscale_counter.saturating_add(1);
if upscale_counter >= 10 {
current_tier = next_tier;
upscale_counter = 0;
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
width: current_tier.0,
height: current_tier.1,
});
wrtc.set_need_keyframe();
}
} else {
upscale_counter = 0;
}
} else {
upscale_counter = 0;
}
}
}
if connected {
// 已连接:批量 drain 已编码帧队列(类比 Go `for { select { case f := <-rx: send(f); default: break } }`)。
// saturating_add 防止计数器溢出(Go 没有,Rust 默认 panic-on-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}");
}
frames_sent = frames_sent.saturating_add(1);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
// Compute capture-to-send age on the sending thread so the
// frame_age stat stays accurate when batch-drained later.
let age_ms =
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
} else {
// 未连接:丢弃积压帧防止 drain 时刻反向堆积(类比 Go `for { select { case <-rx: default: return } }`)。
while webrtc_rx.try_recv().is_ok() {}
}
// recv_timeout:阻塞至下一帧或最多 1ms——保证 str0m 信令循环周期性推进。
// 三路 ResultOk → 处理帧;Err(Timeout) → 继续下一轮循环处理信令;Err(Disconnected) → 编码线程已退出,本线程返回。
match webrtc_rx.recv_timeout(timeout) {
Ok(enc_frame) => {
if wrtc.is_connected() {
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks)
{
tracing::debug!("WebRTC write frame error: {e}");
}
frames_sent = frames_sent.saturating_add(1);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
let age_ms =
Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
tracing::info!("WebRTC channel disconnected, exiting thread");
return;
}
}
}
tracing::info!("WebRTC thread exiting");
}
// 自适应分辨率阶梯(从高到低)。下标 0 = 最高分辨率(2K),下标 2 = 最低(720p)。
// BWE 不足时 select_resolution 从数组下标小的(高分辨率)向大的(低分辨率)切换;
// 反向 upscale 由 next_upscale_tier 处理,受 initial_tier 上限约束(不会超过初始分辨率)。
const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
// 启发式码率估算:`5 × W × H × fps / 100` 即 0.05 bits/pixel/frame。
// 类似 H.264 平均量化参考值,作为 BWE 充分性判据(≥ 60% 认为可承载当前分辨率)。
fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
}
// WebRTC 启动码率:按总像素数分 4 档(≤1M / ≤2.5M / ≤4.5M / 其他 → 1/2/4/8 Mbps)。
// 仅影响客户端连接后第一个 IDR;BWE 估计(毫秒级到达)会覆盖此值。详见 issue #21。
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
/// BWE estimate arrives within milliseconds of client connect and overrides this;
/// the startup value only affects the first IDR. See issue #21.
fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
let pixels = u64::from(width) * u64::from(height);
if pixels <= 1_000_000 {
1_000_000
} else if pixels <= 2_500_000 {
2_000_000
} else if pixels <= 4_500_000 {
4_000_000
} else {
8_000_000
}
}
// 基于 BWE 选择分辨率阶梯。返回 (width, height)。
// 决策逻辑:若 BWE ≥ 当前分辨率所需码率的 60%,保持不变;否则降到下一档(最低 720p)。
/// Select resolution tier based on BWE estimate.
/// Returns (width, height) for the selected tier.
fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) -> (u32, u32) {
let current = (current_w, current_h);
let current_bitrate = resolution_bitrate_bps(current_w, current_h, fps);
if bwe_bps >= current_bitrate.saturating_mul(60) / 100 {
return current;
}
// 在 RESOLUTION_TIERS 中找当前分辨率的位置;若不在表中(如 1366×768),
// 用 unwrap_or_else 退回到第一个宽高都不超过 current 的档位,最终兜底取最小档(720p)。
let current_index = RESOLUTION_TIERS
.iter()
.position(|&tier| tier == current)
.unwrap_or_else(|| {
RESOLUTION_TIERS
.iter()
.position(|&(w, h)| w <= current_w && h <= current_h)
.unwrap_or(RESOLUTION_TIERS.len() - 1)
});
let next_index = (current_index + 1).min(RESOLUTION_TIERS.len() - 1);
RESOLUTION_TIERS[next_index]
}
// 反向 upscale:在 ceiling 上限内尝试升一档;若已在最高档或下一档超出 ceiling 则返回 None。
// 调用方需要"连续 10 次 BWE 充足"才真正切换,避免 BWE 抖动导致频繁分辨率变化。
fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
if current_index == 0 {
return None;
}
let next = RESOLUTION_TIERS[current_index - 1];
// bool::then_some(true → Some(next)false → None):将谓词结果转换为 Option,
// 类比 Go `if ok { return &tier } else { return nil }`。
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
}
impl Drop for StatePortal {
// 析构时自动调用 shutdown,确保编码器被刷新、资源被释放
fn drop(&mut self) {
@@ -1086,11 +694,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 +722,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,
@@ -1192,54 +802,13 @@ mod tests {
assert_eq!(result, None);
}
#[test]
fn webrtc_startup_bitrate_tiers_by_pixel_count() {
assert_eq!(webrtc_startup_bitrate_bps(1280, 720), 1_000_000);
assert_eq!(webrtc_startup_bitrate_bps(1920, 1080), 2_000_000);
assert_eq!(webrtc_startup_bitrate_bps(2560, 1440), 4_000_000);
assert_eq!(webrtc_startup_bitrate_bps(3840, 2160), 8_000_000);
}
#[test]
fn select_resolution_downscales_one_tier_below_sixty_percent() {
let fps = 30;
let current = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, current * 59 / 100, fps),
(1280, 720)
);
}
#[test]
fn select_resolution_keeps_tier_at_sixty_percent() {
let fps = 30;
let current = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, current * 60 / 100, fps),
(1920, 1080)
);
}
#[test]
fn select_resolution_never_goes_below_720p() {
assert_eq!(select_resolution(1280, 720, 1, 30), (1280, 720));
}
#[test]
fn next_upscale_tier_respects_initial_ceiling() {
assert_eq!(
next_upscale_tier((1280, 720), (1920, 1080)),
Some((1920, 1080))
);
assert_eq!(next_upscale_tier((1920, 1080), (1920, 1080)), None);
}
/// 测试:使用自定义偏移量和 stride 构建 DRM 描述符
#[test]
fn build_drm_descriptor_custom_offset_and_stride() {
// SAFETY: same as make_test_frame — dup of stderr (fd 2), test-only.
let test_fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
let frame = PwDmaBufFrame {
// 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
@@ -1257,89 +826,4 @@ mod tests {
}
// ── issue #8 regression ──
#[test]
fn try_send_full_channel_returns_full_not_block() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
tx.send(vec![1]).unwrap();
tx.send(vec![2]).unwrap();
assert!(matches!(
tx.try_send(vec![3]),
Err(crossbeam_channel::TrySendError::Full(_))
));
assert_eq!(rx.len(), 2);
}
#[test]
fn try_send_after_rx_dropped_returns_disconnected() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
drop(rx);
assert!(matches!(
tx.try_send(vec![1]),
Err(crossbeam_channel::TrySendError::Disconnected(_))
));
}
// given: full bounded channel
// when: rx is dropped, then try_send
// expect: Disconnected, not blocking
#[test]
fn shutdown_rx_drop_prevents_deadlock_on_full_channel() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
tx.send(vec![1]).unwrap();
tx.send(vec![2]).unwrap();
drop(rx);
assert!(matches!(
tx.try_send(vec![3]),
Err(crossbeam_channel::TrySendError::Disconnected(_))
));
}
// ── Task 7: Additional resolution tier edge cases ──
#[test]
fn select_resolution_keeps_720p_when_bwe_sufficient() {
let fps = 30;
let bitrate_720 = resolution_bitrate_bps(1280, 720, fps);
assert_eq!(
select_resolution(1280, 720, bitrate_720, fps),
(1280, 720)
);
}
#[test]
fn select_resolution_downscales_1440p_to_1080p() {
let fps = 30;
let bitrate_1440 = resolution_bitrate_bps(2560, 1440, fps);
assert_eq!(
select_resolution(2560, 1440, bitrate_1440 * 59 / 100, fps),
(1920, 1080)
);
}
#[test]
fn select_resolution_1080p_to_720p_at_very_low_bwe() {
let fps = 30;
let bitrate_1080 = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, bitrate_1080 / 10, fps),
(1280, 720)
);
}
#[test]
fn next_upscale_tier_from_720p_to_1080p() {
assert_eq!(
next_upscale_tier((1280, 720), (2560, 1440)),
Some((1920, 1080))
);
}
#[test]
fn next_upscale_tier_returns_none_at_highest() {
assert_eq!(next_upscale_tier((2560, 1440), (2560, 1440)), None);
}
}
+144
View File
@@ -0,0 +1,144 @@
pub(super) const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
pub(super) fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
}
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
/// BWE estimate arrives within milliseconds of client connect and overrides this;
/// the startup value only affects the first IDR. See issue #21.
pub(super) fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
let pixels = u64::from(width) * u64::from(height);
if pixels <= 1_000_000 {
1_000_000
} else if pixels <= 2_500_000 {
2_000_000
} else if pixels <= 4_500_000 {
4_000_000
} else {
8_000_000
}
}
/// Select resolution tier based on BWE estimate.
/// Returns (width, height) for the selected tier.
pub(super) fn select_resolution(
current_w: u32,
current_h: u32,
bwe_bps: u64,
fps: u32,
) -> (u32, u32) {
let current = (current_w, current_h);
let current_bitrate = resolution_bitrate_bps(current_w, current_h, fps);
if bwe_bps >= current_bitrate.saturating_mul(60) / 100 {
return current;
}
let current_index = RESOLUTION_TIERS
.iter()
.position(|&tier| tier == current)
.unwrap_or_else(|| {
RESOLUTION_TIERS
.iter()
.position(|&(w, h)| w <= current_w && h <= current_h)
.unwrap_or(RESOLUTION_TIERS.len() - 1)
});
let next_index = (current_index + 1).min(RESOLUTION_TIERS.len() - 1);
RESOLUTION_TIERS[next_index]
}
pub(super) fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
if current_index == 0 {
return None;
}
let next = RESOLUTION_TIERS[current_index - 1];
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn webrtc_startup_bitrate_tiers_by_pixel_count() {
assert_eq!(webrtc_startup_bitrate_bps(1280, 720), 1_000_000);
assert_eq!(webrtc_startup_bitrate_bps(1920, 1080), 2_000_000);
assert_eq!(webrtc_startup_bitrate_bps(2560, 1440), 4_000_000);
assert_eq!(webrtc_startup_bitrate_bps(3840, 2160), 8_000_000);
}
#[test]
fn select_resolution_downscales_one_tier_below_sixty_percent() {
let fps = 30;
let current = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, current * 59 / 100, fps),
(1280, 720)
);
}
#[test]
fn select_resolution_keeps_tier_at_sixty_percent() {
let fps = 30;
let current = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, current * 60 / 100, fps),
(1920, 1080)
);
}
#[test]
fn select_resolution_never_goes_below_720p() {
assert_eq!(select_resolution(1280, 720, 1, 30), (1280, 720));
}
#[test]
fn next_upscale_tier_respects_initial_ceiling() {
assert_eq!(
next_upscale_tier((1280, 720), (1920, 1080)),
Some((1920, 1080))
);
assert_eq!(next_upscale_tier((1920, 1080), (1920, 1080)), None);
}
#[test]
fn select_resolution_keeps_720p_when_bwe_sufficient() {
let fps = 30;
let bitrate_720 = resolution_bitrate_bps(1280, 720, fps);
assert_eq!(select_resolution(1280, 720, bitrate_720, fps), (1280, 720));
}
#[test]
fn select_resolution_downscales_1440p_to_1080p() {
let fps = 30;
let bitrate_1440 = resolution_bitrate_bps(2560, 1440, fps);
assert_eq!(
select_resolution(2560, 1440, bitrate_1440 * 59 / 100, fps),
(1920, 1080)
);
}
#[test]
fn select_resolution_1080p_to_720p_at_very_low_bwe() {
let fps = 30;
let bitrate_1080 = resolution_bitrate_bps(1920, 1080, fps);
assert_eq!(
select_resolution(1920, 1080, bitrate_1080 / 10, fps),
(1280, 720)
);
}
#[test]
fn next_upscale_tier_from_720p_to_1080p() {
assert_eq!(
next_upscale_tier((1280, 720), (2560, 1440)),
Some((1920, 1080))
);
}
#[test]
fn next_upscale_tier_returns_none_at_highest() {
assert_eq!(next_upscale_tier((2560, 1440), (2560, 1440)), None);
}
}
+287
View File
@@ -0,0 +1,287 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::avhw::{BitrateCommand, CpuNv12Frame, EncodeOutcome, EncodedH264Frame, SwEncEncode};
use crate::webrtc::WebRtcState;
use super::bitrate::{next_upscale_tier, resolution_bitrate_bps, select_resolution};
pub(super) struct EncodeThreadTiming {
pub(super) sws_us: u64,
pub(super) encode_us: u64,
pub(super) output_bytes: usize,
}
pub(super) struct EncodeThread {
pub(super) handle: Option<std::thread::JoinHandle<()>>,
pub(super) input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
pub(super) timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
pub(super) duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
pub(super) struct WebrtcThread {
pub(super) handle: Option<std::thread::JoinHandle<()>>,
pub(super) sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
}
/// Static configuration handed to the WebRTC sender thread. Immutable for the
/// thread's lifetime; a resolution tier change rebuilds the whole pipeline
/// (and spawns a new thread) rather than mutating this.
pub(super) struct WebRtcThreadConfig {
pub(super) fps: u32,
pub(super) enc_width: u32,
pub(super) enc_height: u32,
pub(super) max_bitrate: u64,
}
/// Channel endpoints owned exclusively by the WebRTC sender thread after spawn.
/// The reverse endpoints stay with StatePortal (or the encode thread) for
/// inbound/outbound traffic.
pub(super) struct WebRtcThreadChannels {
pub(super) webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
pub(super) sent_gap_tx: crossbeam_channel::Sender<(f64, Option<f64>)>,
pub(super) bitrate_tx: crossbeam_channel::Sender<BitrateCommand>,
pub(super) resolution_tx: crossbeam_channel::Sender<BitrateCommand>,
}
pub(super) fn encode_thread_loop(
mut encode: SwEncEncode,
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
loop {
match input_rx.recv() {
Ok(frame) => {
match encode.encode_cpu_frame(&frame) {
Ok(EncodeOutcome::Encoded) => {
let t = encode.take_timing();
let _ = timing_tx.try_send(EncodeThreadTiming {
sws_us: t.sws_us,
encode_us: t.encode_us,
output_bytes: t.output_bytes,
});
}
Ok(EncodeOutcome::SkippedDuplicate) => {
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(_) => {
// SkippedPaused / SkippedDisconnected — no counter needed
}
Err(e) => {
tracing::error!("Encode thread error: {e}");
break;
}
}
}
Err(_) => {
tracing::info!("Encode thread input closed, flushing encoder");
if let Err(e) = encode.flush() {
tracing::error!("Encode thread flush error: {e}");
}
break;
}
}
}
tracing::info!("Encode thread exiting");
}
pub(super) fn webrtc_thread_loop(
mut wrtc: WebRtcState,
config: WebRtcThreadConfig,
channels: WebRtcThreadChannels,
paused: Arc<AtomicBool>,
) {
let WebRtcThreadConfig {
fps,
enc_width,
enc_height,
max_bitrate,
} = config;
let WebRtcThreadChannels {
webrtc_rx,
sent_gap_tx,
bitrate_tx,
resolution_tx,
} = channels;
let mut frames_sent: u64 = 0;
let mut last_send: Option<std::time::Instant> = None;
let mut last_sent_bitrate: Option<u64> = None;
let initial_tier = (enc_width, enc_height);
let mut current_tier = initial_tier;
let mut upscale_counter = 0u32;
let mut last_resolution_eval = Instant::now();
let timeout = Duration::from_millis(1);
loop {
if let Err(e) = wrtc.handle_signaling() {
tracing::error!("WebRTC signaling error: {e}");
break;
}
if let Err(e) = wrtc.poll_and_feed() {
tracing::error!("WebRTC poll error: {e}");
break;
}
if wrtc.take_force_keyframe() {
let _ = bitrate_tx.try_send(BitrateCommand::ForceKeyframe);
}
let connected = wrtc.is_connected();
let was_paused = paused.load(Ordering::Relaxed);
let now_paused = !connected;
if was_paused && !now_paused {
tracing::info!("WebRTC client connected, resuming encoding");
} else if !was_paused && now_paused {
tracing::warn!("WebRTC client disconnected, pausing encoding");
}
paused.store(now_paused, Ordering::Relaxed);
if let Some(bwe) = wrtc.get_bwe_estimate() {
// #23: Cap BWE to prevent runaway bitrate escalation. Without this, BWE
// estimates can rise to 10+ Mbps, causing IDR bursts and PLI storms.
let effective_bwe = bwe.min(max_bitrate);
if effective_bwe != bwe {
tracing::debug!(
bwe,
effective_bwe,
max_bitrate,
"BWE exceeds --max-bitrate cap, clamping"
);
}
let bwe = effective_bwe;
let should_send = match last_sent_bitrate {
None => true,
Some(last) => {
let diff = bwe.abs_diff(last);
diff * 10 > last
}
};
if should_send {
let _ = bitrate_tx.try_send(BitrateCommand::UpdateBitrate { target_bps: bwe });
last_sent_bitrate = Some(bwe);
}
if last_resolution_eval.elapsed() >= Duration::from_secs(1) {
last_resolution_eval = Instant::now();
let selected = select_resolution(current_tier.0, current_tier.1, bwe, fps);
if selected != current_tier {
current_tier = selected;
upscale_counter = 0;
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
width: current_tier.0,
height: current_tier.1,
});
wrtc.set_need_keyframe();
} else if let Some(next_tier) = next_upscale_tier(current_tier, initial_tier) {
let needed = resolution_bitrate_bps(next_tier.0, next_tier.1, fps);
if bwe > needed.saturating_mul(120) / 100 {
upscale_counter = upscale_counter.saturating_add(1);
if upscale_counter >= 10 {
current_tier = next_tier;
upscale_counter = 0;
let _ = resolution_tx.try_send(BitrateCommand::UpdateResolution {
width: current_tier.0,
height: current_tier.1,
});
wrtc.set_need_keyframe();
}
} else {
upscale_counter = 0;
}
} else {
upscale_counter = 0;
}
}
}
if connected {
while let Ok(enc_frame) = webrtc_rx.try_recv() {
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
tracing::debug!("WebRTC write frame error: {e}");
}
frames_sent = frames_sent.saturating_add(1);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
// Compute capture-to-send age on the sending thread so the
// frame_age stat stays accurate when batch-drained later.
let age_ms = Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
} else {
while webrtc_rx.try_recv().is_ok() {}
}
match webrtc_rx.recv_timeout(timeout) {
Ok(enc_frame) => {
if wrtc.is_connected() {
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
tracing::debug!("WebRTC write frame error: {e}");
}
frames_sent = frames_sent.saturating_add(1);
let gap_ms = last_send
.map(|l| l.elapsed().as_secs_f64() * 1000.0)
.unwrap_or(0.0);
let age_ms = Some(enc_frame.capture_time.elapsed().as_secs_f64() * 1000.0);
last_send = Some(std::time::Instant::now());
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
tracing::info!("WebRTC channel disconnected, exiting thread");
return;
}
}
}
tracing::info!("WebRTC thread exiting");
}
#[cfg(test)]
mod tests {
#[test]
fn try_send_full_channel_returns_full_not_block() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
tx.send(vec![1]).unwrap();
tx.send(vec![2]).unwrap();
assert!(matches!(
tx.try_send(vec![3]),
Err(crossbeam_channel::TrySendError::Full(_))
));
assert_eq!(rx.len(), 2);
}
#[test]
fn try_send_after_rx_dropped_returns_disconnected() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
drop(rx);
assert!(matches!(
tx.try_send(vec![1]),
Err(crossbeam_channel::TrySendError::Disconnected(_))
));
}
// given: full bounded channel
// when: rx is dropped, then try_send
// expect: Disconnected, not blocking
#[test]
fn shutdown_rx_drop_prevents_deadlock_on_full_channel() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(2);
tx.send(vec![1]).unwrap();
tx.send(vec![2]).unwrap();
drop(rx);
assert!(matches!(
tx.try_send(vec![3]),
Err(crossbeam_channel::TrySendError::Disconnected(_))
));
}
}
+47 -286
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,82 +193,31 @@ impl PipelineStats {
}
}
/// 设置 PipeWire dropped 计数(绝对值,由调用方从外部 `AtomicU64` 读取)。
///
/// # 增量计算
///
/// 外部 `AtomicU64` 累计**总会话**的 dropped 帧数(从不重置),
/// 因此本函数计算 `total - prev` 得到本窗口内的增量。
/// `saturating_sub` 防止极端竞态(如原子读顺序不一致)导致负数回绕。
///
/// # 与 Go 类比
///
/// - 调用方代码 ≈ Go `atomic.LoadUint64(&pw.dropped)``Ordering::SeqCst`
/// 或 `Relaxed` 取决于是否需要与其他原子操作建立 happens-before
/// - `Mutex<HashMap>` 在本模块**未使用**:统计字段集固定,无需 Go
/// `sync.Map` 那样的动态键值存储;跨线程仅通过原子计数器通信
///
/// Update PipeWire dropped counter (absolute value from AtomicU64).
pub fn set_pipewire_dropped(&mut self, total_dropped: u64, prev_dropped: u64) {
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
}
/// 设置 duplicate frames skipped 计数(绝对值,由调用方从 encode 线程原子读取)。
///
/// 与 `set_pipewire_dropped` 增量算法一致,但**保留 `prev`** 在
/// `self.prev_duplicate_frames_skipped` 字段中(因为本结构才是状态持有者,
/// 调用方仅传入当前 total)。
///
/// Update duplicate frames skipped counter (absolute value from atomic).
/// Computes delta from previous value, like set_pipewire_dropped.
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
self.duplicate_frames_skipped =
total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
self.prev_duplicate_frames_skipped = total_skipped;
}
/// 更新队列深度瞬时观测值(capture 队列与 encoded 队列各一个值)。
///
/// 队列深度为快照值而非累计值,每窗口只保留最后一次观测。
///
/// Update queue depth observations.
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
self.capture_queue_depth = capture;
self.encoded_queue_depth = encoded;
}
/// 记录一帧超出预算(用于跟踪编码耗时超过 1/fps 的频次)。
///
/// Record that a frame exceeded its time budget.
pub fn record_over_budget(&mut self) {
self.over_budget_count += 1;
}
/// 判断是否到达快照点(距离上次 `snapshot_and_reset` 或构造时刻 ≥ 1 秒)。
///
/// 仅需 `&self`(共享借用):本方法不修改任何字段,借用检查器允许
/// 多个 `&self` 共存或与 `&mut self` 之外的调用并存。
///
/// Returns true if at least 1 second has elapsed since the last snapshot
/// (or since creation). If true, call `snapshot_and_reset` to get the stats.
pub fn should_snapshot(&self) -> bool {
self.window_start.elapsed().as_secs() >= 1
}
/// 计算当前窗口的统计快照并重置所有计数器与样本缓冲。
///
/// # 重置策略
///
/// - 计数器:置零
/// - `Vec`:调用 `clear()`(保留已分配容量 `Vec::capacity()`,避免下个窗口
/// 反复分配)。类比 Go 中 `s = s[:0]` 复用底层数组
/// - `window_start = Instant::now()`:重置窗口起点
///
/// # 返回值
///
/// 返回 `StatsSnapshot` 值(拷贝语义,调用方可自由使用与丢弃)。
/// 类比 Go 中返回值结构体的拷贝;Rust 中 `StatsSnapshot` 全部字段为
/// `Copy` 或 `Vec`(移动语义),返回时所有权转移至调用方。
///
/// Compute a snapshot of the current window and reset all counters.
pub fn snapshot_and_reset(&mut self) -> StatsSnapshot {
let elapsed = self.window_start.elapsed().as_secs_f64();
@@ -391,7 +230,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 +268,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 +289,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 +302,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 +341,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 +418,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 +425,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 +435,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 +446,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 +456,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 +470,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
}
);
}
}
+3 -450
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};
@@ -61,235 +17,33 @@ use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig}
/// bursts. See issue #23.
const FORCED_KEYFRAME_MIN_INTERVAL: Duration = Duration::from_secs(1);
// ── 嵌入式 HTML 测试页面 ──────────────────────────────────────────────────
const HTML_PAGE: &str = r#"<!DOCTYPE html>
<html>
<head><title>wl-webrtc P0</title>
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
video{max-width:90vw;max-height:80vh;border:1px solid #333}
#status{margin:12px;font-size:14px;color:#aaa}
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
</style></head>
<body>
<div id="status">Connecting...</div>
<video id="video" autoplay playsinline muted></video>
<pre id="debug"></pre>
<div id="stats-panel"></div>
<script>
const status = document.getElementById('status');
const video = document.getElementById('video');
const debug = document.getElementById('debug');
let pc = null;
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
function preferH264(sdp) {
const lines = sdp.split('\r\n');
const h264Pts = lines
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
.filter(Boolean);
if (h264Pts.length === 0) return sdp;
return lines.map(line => {
if (!line.startsWith('m=video ')) return line;
const parts = line.split(' ');
const header = parts.slice(0, 3);
const pts = parts.slice(3);
const preferred = h264Pts.filter(pt => pts.includes(pt));
const rest = pts.filter(pt => !preferred.includes(pt));
return [...header, ...preferred, ...rest].join(' ');
}).join('\r\n');
}
function installStatsLogger(peer) {
const panel = document.getElementById('stats-panel');
let prev = null;
const intervalSecs = 1;
setInterval(() => {
if (peer !== pc) return;
peer.getStats().then(stats => {
let rtp = null, rtt = null, codecStr = '';
let freezeCount = null, totalFreezesDuration = null;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
codecStr = report.mimeType + ' ' + (report.payloadType || '');
// candidate-pair: feature-detect 'selected' property
if (report.type === 'candidate-pair') {
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
}
});
// Freeze stats (feature-detect)
if (rtp && typeof rtp.freezeCount !== 'undefined') {
freezeCount = rtp.freezeCount;
totalFreezesDuration = rtp.totalFreezesDuration;
}
if (!rtp) return;
const cur = {
framesDecoded: rtp.framesDecoded || 0,
framesDropped: rtp.framesDropped || 0,
framesPerSecond: rtp.framesPerSecond || 0,
packetsLost: rtp.packetsLost || 0,
jitter: rtp.jitter || 0,
bytesReceived: rtp.bytesReceived || 0,
totalDecodeTime: rtp.totalDecodeTime || 0,
jitterBufferDelay: rtp.jitterBufferDelay || 0,
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
freezeCount: freezeCount,
totalFreezesDuration: totalFreezesDuration,
rtt: rtt,
};
// Raw log to debug element (backward compat)
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
(codecStr ? ' codec=' + codecStr : ''));
if (!prev) { prev = cur; return; }
// Compute deltas
const dFrames = cur.framesDecoded - prev.framesDecoded;
const dDropped = cur.framesDropped - prev.framesDropped;
const dLost = cur.packetsLost - prev.packetsLost;
const dBytes = cur.bytesReceived - prev.bytesReceived;
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
const jitterMs = (cur.jitter * 1000).toFixed(1);
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
let line = 'FPS:' + cur.framesPerSecond +
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
' Lost:' + dLost +
' Jitter:' + jitterMs + 'ms' +
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
' Decode:' + decodeMs + 'ms' +
' JBuf:' + jitterBufMs + 'ms';
if (freezeCount !== null) {
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
if (cur.freezeCount > 0 || dFreeze > 0)
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
}
line += ' ' + kbps + 'kbps';
panel.textContent = line;
prev = cur;
}).catch(() => {});
}, intervalSecs * 1000);
}
function connect() {
if (pc) pc.close();
pc = new RTCPeerConnection();
const peer = pc;
peer.ontrack = e => {
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
video.srcObject = e.streams[0];
status.textContent = 'Track received';
};
peer.oniceconnectionstatechange = () => {
log('ICE: ' + peer.iceConnectionState);
status.textContent = 'ICE: ' + peer.iceConnectionState;
};
peer.addTransceiver('video', { direction: 'recvonly' });
installStatsLogger(peer);
peer.createOffer().then(offer => {
offer.sdp = preferH264(offer.sdp);
return peer.setLocalDescription(offer);
})
.then(() => new Promise(resolve => {
if (peer.iceGatheringState === 'complete') resolve();
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
}))
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
.then(() => log('SDP answer set'))
.catch(e => {
status.textContent = 'Error: ' + e.message;
log('ERROR: ' + e.message + ' — retrying in 2s...');
console.error(e);
setTimeout(connect, 2000);
});
}
connect();
</script>
</body></html>"#;
mod html_page;
use html_page::HTML_PAGE;
// ── 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 +56,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 +76,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 +100,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 +141,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 +151,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 +158,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 +177,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 +188,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 +197,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 +208,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 +220,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 +273,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 +299,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 +321,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 +333,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 +351,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 +360,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 +380,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 +396,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 +422,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 +443,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 +454,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 +474,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 +484,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 +491,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 +526,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 +544,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 +553,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 +578,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 +591,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() {
+170
View File
@@ -0,0 +1,170 @@
pub(super) const HTML_PAGE: &str = r#"<!DOCTYPE html>
<html>
<head><title>wl-webrtc P0</title>
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
video{max-width:90vw;max-height:80vh;border:1px solid #333}
#status{margin:12px;font-size:14px;color:#aaa}
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
</style></head>
<body>
<div id="status">Connecting...</div>
<video id="video" autoplay playsinline muted></video>
<pre id="debug"></pre>
<div id="stats-panel"></div>
<script>
const status = document.getElementById('status');
const video = document.getElementById('video');
const debug = document.getElementById('debug');
let pc = null;
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
function preferH264(sdp) {
const lines = sdp.split('\r\n');
const h264Pts = lines
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
.filter(Boolean);
if (h264Pts.length === 0) return sdp;
return lines.map(line => {
if (!line.startsWith('m=video ')) return line;
const parts = line.split(' ');
const header = parts.slice(0, 3);
const pts = parts.slice(3);
const preferred = h264Pts.filter(pt => pts.includes(pt));
const rest = pts.filter(pt => !preferred.includes(pt));
return [...header, ...preferred, ...rest].join(' ');
}).join('\r\n');
}
function installStatsLogger(peer) {
const panel = document.getElementById('stats-panel');
let prev = null;
const intervalSecs = 1;
setInterval(() => {
if (peer !== pc) return;
peer.getStats().then(stats => {
let rtp = null, rtt = null, codecStr = '';
let freezeCount = null, totalFreezesDuration = null;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
codecStr = report.mimeType + ' ' + (report.payloadType || '');
// candidate-pair: feature-detect 'selected' property
if (report.type === 'candidate-pair') {
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
}
});
// Freeze stats (feature-detect)
if (rtp && typeof rtp.freezeCount !== 'undefined') {
freezeCount = rtp.freezeCount;
totalFreezesDuration = rtp.totalFreezesDuration;
}
if (!rtp) return;
const cur = {
framesDecoded: rtp.framesDecoded || 0,
framesDropped: rtp.framesDropped || 0,
framesPerSecond: rtp.framesPerSecond || 0,
packetsLost: rtp.packetsLost || 0,
jitter: rtp.jitter || 0,
bytesReceived: rtp.bytesReceived || 0,
totalDecodeTime: rtp.totalDecodeTime || 0,
jitterBufferDelay: rtp.jitterBufferDelay || 0,
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
freezeCount: freezeCount,
totalFreezesDuration: totalFreezesDuration,
rtt: rtt,
};
// Raw log to debug element (backward compat)
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
(codecStr ? ' codec=' + codecStr : ''));
if (!prev) { prev = cur; return; }
// Compute deltas
const dFrames = cur.framesDecoded - prev.framesDecoded;
const dDropped = cur.framesDropped - prev.framesDropped;
const dLost = cur.packetsLost - prev.packetsLost;
const dBytes = cur.bytesReceived - prev.bytesReceived;
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
const jitterMs = (cur.jitter * 1000).toFixed(1);
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
let line = 'FPS:' + cur.framesPerSecond +
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
' Lost:' + dLost +
' Jitter:' + jitterMs + 'ms' +
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
' Decode:' + decodeMs + 'ms' +
' JBuf:' + jitterBufMs + 'ms';
if (freezeCount !== null) {
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
if (cur.freezeCount > 0 || dFreeze > 0)
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
}
line += ' ' + kbps + 'kbps';
panel.textContent = line;
prev = cur;
}).catch(() => {});
}, intervalSecs * 1000);
}
function connect() {
if (pc) pc.close();
pc = new RTCPeerConnection();
const peer = pc;
peer.ontrack = e => {
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
video.srcObject = e.streams[0];
status.textContent = 'Track received';
};
peer.oniceconnectionstatechange = () => {
log('ICE: ' + peer.iceConnectionState);
status.textContent = 'ICE: ' + peer.iceConnectionState;
};
peer.addTransceiver('video', { direction: 'recvonly' });
installStatsLogger(peer);
peer.createOffer().then(offer => {
offer.sdp = preferH264(offer.sdp);
return peer.setLocalDescription(offer);
})
.then(() => new Promise(resolve => {
if (peer.iceGatheringState === 'complete') resolve();
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
}))
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
.then(() => log('SDP answer set'))
.catch(e => {
status.textContent = 'Error: ' + e.message;
log('ERROR: ' + e.message + ' — retrying in 2s...');
console.error(e);
setTimeout(connect, 2000);
});
}
connect();
</script>
</body></html>"#;
-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() {