feat: Phase 1 MVP with audit fixes — Wayland screen capture + VAAPI encoding

Phase 1 MVP implementation of wl-webrtc: Wayland screen capture tool
with hardware-accelerated VAAPI H.264 encoding and WebTransport output.

Includes all 9 runtime bug fixes from code audit (fix-audit-issues plan):

CRITICAL:
- C2: h264_metadata BSF with repeat_sps/repeat_pps in encode pipeline
- C4: FpsLimit wired as timing gate in on_copy_complete

HIGH:
- C3+A2: DRM device discovery via dmabuf feedback MainDevice event,
  unified resolve_drm_path() helper (CLI > compositor > auto > fallback)
- H2: Separate physical_size (mm) from mode_size (pixels) in wl_output
- H1+A3: Multi-output warning + named-output-not-found error

MEDIUM:
- M5: tv_sec u32->u64 to avoid Y2106 timestamp truncation
- M4: Guard against SHM Buffer event (DMA-BUF only)

Key components:
- src/avhw.rs: FFmpeg VAAPI encoder + filter graph + BSF pipeline
- src/state.rs: Wayland event loop + output negotiation + screencopy
- src/cap_wlr_screencopy.rs: wlr-screencopy capture source
- src/fps_limit.rs: Frame rate limiting with configurable target
- src/transform.rs: Frame format conversion utilities
This commit is contained in:
dailz
2026-04-05 23:35:00 +08:00
commit 6d49222de8
17 changed files with 6964 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Rust build artifacts
/target/
# Nix
/result
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Sisyphus orchestration artifacts
.sisyphus/

873
Cargo.lock generated Normal file
View File

@@ -0,0 +1,873 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "cc"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "drm"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1"
dependencies = [
"bitflags",
"bytemuck",
"drm-ffi",
"drm-fourcc",
"rustix 0.38.44",
]
[[package]]
name = "drm-ffi"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53"
dependencies = [
"drm-sys",
"rustix 0.38.44",
]
[[package]]
name = "drm-fourcc"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
[[package]]
name = "drm-sys"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986"
dependencies = [
"libc",
"linux-raw-sys 0.6.5",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "ffmpeg-next"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
dependencies = [
"bitflags",
"ffmpeg-sys-next",
"libc",
]
[[package]]
name = "ffmpeg-sys-next"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
dependencies = [
"bindgen",
"cc",
"libc",
"num_cpus",
"pkg-config",
"vcpkg",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.4",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags",
"rustix 1.1.4",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wl-webrtc"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"drm",
"drm-fourcc",
"ffmpeg-next",
"libc",
"mio",
"signal-hook",
"signal-hook-mio",
"tracing",
"tracing-subscriber",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]

21
Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "wl-webrtc"
version = "0.1.0"
edition = "2021"
description = "Wayland screen capture and encoding tool"
[dependencies]
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
ffmpeg-next = "8"
mio = { version = "1.0", features = ["os-poll", "os-ext"] }
signal-hook = "0.3"
signal-hook-mio = { version = "0.2", features = ["support-v1_0"] }
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
drm = "0.12"
drm-fourcc = "2"
libc = "0.2"

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# wl-webrtc
Wayland screen capture and encoding tool.
## Prerequisites
- **Rust toolchain** (1.70+): `rustup default stable`
- **FFmpeg 6.0+** dev libraries with VAAPI support:
- Arch: `pacman -S ffmpeg`
- Ubuntu/Debian: `apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libva-dev`
- Fedora: `dnf install ffmpeg-devel libva-devel`
- **Wayland** dev libraries:
- Arch: `pacman -S wayland-protocols`
- Ubuntu/Debian: `apt install libwayland-dev wayland-protocols`
- Fedora: `dnf install wayland-devel wayland-protocols-devel`
- **DRM** dev libraries:
- Arch: `pacman -S libdrm`
- Ubuntu/Debian: `apt install libdrm-dev`
- Fedora: `dnf install libdrm-devel`
## Build
```bash
cargo build --release
```
## Run
```bash
# Basic capture to file
wl-webrtc --output output.mp4
# With custom FPS and bitrate
wl-webrtc --output output.mp4 --fps 60 --bitrate 8000000
# Specify DRM device for hardware encoding
wl-webrtc --output output.mp4 --drm-device /dev/dri/renderD128
# Verbose mode
wl-webrtc --output output.mp4 -v
```
## CLI Arguments
| Argument | Default | Description |
|---|---|---|
| `-o`, `--output` | (required) | Output file path (e.g., output.mp4) |
| `--output-name` | auto | Wayland output name to capture |
| `--fps` | 30 | Target frames per second |
| `--codec` | h264 | Video codec (h264 only for MVP) |
| `--hw-accel` | vaapi | Hardware acceleration method |
| `--drm-device` | auto | DRM render device path |
| `--bitrate` | auto | Target bitrate in bps |
| `--gop-size` | auto | Group of Pictures size |
| `-v`, `--verbose` | false | Enable verbose logging |
| `--port` | 0 | WebTransport server port (unused in MVP) |

443
analysis.md Normal file
View File

@@ -0,0 +1,443 @@
# wl-screenrec 源码分析
## 1. 项目概述
wl-screenrec 是一个高性能 Wayland 屏幕录制器,核心特性:原始视频数据不经过 CPU全程在 GPU 上完成捕获、格式转换和编码。
**技术栈**Rust + FFmpeg硬件加速+ Wayland 协议wayland-client crate+ mio 事件循环
**9 个源文件**
| 文件 | 行数 | 职责 |
|------|------|------|
| `main.rs` | ~2430 | 状态机、事件循环、编码管线编排 |
| `avhw.rs` | ~444 | FFmpeg 硬件设备/帧上下文VAAPI/Vulkan |
| `audio.rs` | ~405 | 音频捕获→解码→重采样→编码(独立线程) |
| `cap_ext_image_copy.rs` | ~237 | ext-image-copy-capture 协议后端 |
| `filter.rs` | ~194 | FFmpeg 视频滤镜图crop+scale+transpose |
| `transform.rs` | ~215 | 坐标系变换(处理旋转/翻转) |
| `cap_wlr_screencopy.rs` | ~165 | wlr-screencopy 协议后端 |
| `fps_limit.rs` | ~130 | 帧率限制器VRR 感知) |
| `fifo.rs` | ~60 | FFmpeg AVAudioFifo 安全封装 |
---
## 2. 架构概览
### 2.1 模块依赖
```
┌──────────────────────────────┐
│ main.rs │
└──┬──┬──┬──┬──┬──┬──┬─────────┘
│ │ │ │ │ │ │
┌───────────┘ │ │ │ │ │ └──────────┐
▼ ▼ │ ▼ │ ▼ ▼
┌──────────┐ ┌────────┐│┌───────┐ ┌────────┐ ┌──────────┐
│ avhw.rs │ │filter.rs│││audio.rs│ │fps_limit│ │transform │
└──────────┘ └───┬────┘│└───┬───┘ └────────┘ └──────────┘
│ │ │
┌───────┤ │ ▼
▼ ▼ │ ┌──────┐
┌────────────┐┌─────┴┐│fifo.rs│
│cap_wlr_ ││cap_ │└──────┘
│screencopy ││ext_* │
└────────────┘└──────┘
```
**依赖层次**`fifo/fps_limit/transform`(叶节点)→ `avhw/audio``filter``cap_*``main.rs`核心。main.rs 与 cap_*.rs 之间存在**双向依赖**main 定义 `CaptureSource` trait 和 `State<S>`cap_* 为 `State<Cap*>` 实现 `Dispatch`
### 2.2 State\<S\> 全局状态
核心状态结构体持有以下关键字段:
- **in_flight_surface**:帧在途状态,跟踪当前帧的捕获生命周期
- **dma**DMA-BUF 协议对象,用于 GPU 缓冲区共享
- **enc**:编码器构造状态机(`EncConstructionStage`),管理从探测到就绪的全过程
- **starting_timestamp**:首帧时间戳(纳秒),用于音视频同步
- **args**:命令行参数
- **errored**:致命错误标志
- **gm**Wayland 全局对象列表
- **xdg_output_manager**:输出几何信息管理器
泛型 `S: CaptureSource` 使同一个 State 支持两种截屏后端,无需运行时动态分发。
### 2.3 CaptureSource Trait
定义了屏幕捕获后端的统一接口契约:
- **关联类型 Frame**:每种后端有自己的帧类型
- **new()**:从 Wayland 全局对象和输出创建后端实例
- **alloc_frame()**:分配捕获帧,返回 `Option<Frame>` 统一同步/异步两种模式
- **queue_copy()**:提交 DMA-BUF 缓冲区给合成器,请求捕获
- **on_done_with_frame()**:帧使用完毕后的回收回调
| 实现者 | 协议 | 文件 |
|--------|------|------|
| `CapWlrScreencopy` | zwlr-screencopy-unstable-v1 | `cap_wlr_screencopy.rs` |
| `CapExtImageCopy` | ext-image-copy-capture-v1 | `cap_ext_image_copy.rs` |
关键差异wlr-screencopy 的 `alloc_frame()` 返回 `None`异步ext-image-copy 直接返回 `Some(frame)`(同步)。
### 2.4 事件循环
采用 mio polling + Wayland 事件队列 + Unix 信号三合一架构:
- **Token(0)** = 信号SIGINT/SIGTERM/SIGHUP 退出SIGUSR1 触发 history flush
- **Token(1)** = Wayland 连接 fd → `queue.dispatch_pending(&mut state)`
- 超时由 FPS 报告周期驱动
- 退出时仅 `Complete` 状态才 flush 编码器
后端自动选择:探测全局列表,优先 ext-image-copy-capture跨桌面标准否则回退 wlr-screencopy。
---
## 3. Wayland 协议交互层
### 3.1 两个后端的事件流对比
**wlr-screencopy**
```
capture_output() [异步]
→ LinuxDmabuf { format, w, h } × N → 收集格式(仅 LINEAR
→ BufferDone → negotiate_format() + on_frame_allocd()
queue_copy(WlBuffer)
→ Ready { timestamp } → on_copy_complete()
```
**ext-image-copy-capture**
```
[会话初始化]
→ BufferSize / DmabufDevice / DmabufFormat × N → 收集约束
→ Done → negotiate_format()
[每帧]
create_frame() [同步,直接返回 Some]
queue_copy(WlBuffer)
→ PresentationTime { timestamp } → 暂存
→ Ready → on_copy_complete()
```
核心差异wlr 每帧触发格式协商帧级别ext 在会话建立时完成会话级别。ext 提供真实 modifier 列表wlr 硬编码 `DrmModifier::LINEAR`
### 3.2 DMA-BUF 缓冲区创建
零拷贝路径:
```
AV HW Surface → av_hwframe_map → DRM PRIME 描述符 (DMA-BUF fd)
→ zwp_linux_dmabuf.create_params → add(planes) → create_immed → WlBuffer
→ cap.queue_copy(WlBuffer)
→ 合成器直接写入 GPU Surface零拷贝
```
### 3.3 格式协商
格式优先级:`XRGB8888` > `XBGR8888` > `XRGB2101010`。VAAPI 模式仅接受 LINEAR modifierVulkan 模式接受任意 modifier。
DRM 设备发现两条路径wlr 的 `MainDevice` / ext 的 `DmabufDevice`),核心逻辑相同:`dev_t``DrmNode` → Render 节点路径。回退 `/dev/dri/renderD128`
### 3.4 Dispatch 泛型分发模式
三种模式:
- **A. 完全泛型**`Dispatch<ZwpLinuxDmabufV1, ()> for State<S>` — 共享协议,通常空实现
- **B. 带状态回调的泛型**`Dispatch<WlOutput, ()> for State<S>` — 需要 `'static`,含实质逻辑
- **C. 后端专用**`Dispatch<ZwlrScreencopyFrameV1, ()> for State<CapWlrScreencopy>` — 非泛型,含后端特有逻辑
输出探测通过 `WlOutput` + `ZxdgOutputV1` 协作完成,`PartialOutputInfo` 增量收集直到所有字段填充。每个输出收到两次 `Done` 事件,忽略第一次。
---
## 4. GPU 编码管道
### 4.1 零拷贝数据流
```
GPU 帧池 ─alloc()→ HW Surface
av_hwframe_map → DMA-BUF fd
zwp_linux_dmabuf → WlBuffer (fd 共享)
合成器直接写入 GPU Surface
buffersrc → GPU 滤镜 (crop/scale/transpose)
buffersink → 编码器 (send_frame)
receive_packet → Muxer → 文件
```
**整条链路中原始帧数据始终在 GPU 内存,不经过 CPU。**
### 4.2 硬件设备上下文
两种硬件加速路径:
- **VAAPI**:一步创建,直接从 DRM 设备创建 VAAPI 硬件设备上下文
- **Vulkan**:两步创建,先创建 DRM 上下文,再派生 Vulkan 上下文,中间 DRM 上下文立即释放
帧上下文两种用途:
- **Capture**Vulkan flags = `SAMPLED | TRANSFER_DST`tiling = `Drm(modifiers)`
- **Enc**Vulkan flags = `VIDEO_ENCODE_SRC_KHR | TRANSFER_DST`tiling = `Optimal`
### 4.3 Vulkan 自引用 Pin 模式
`AvHwDevCtxVulkanBuffers` 包含自引用 C 指针链drm_info → image_fmt_list_info → 内部数组),通过 `Pin<Box<Self>>` 解决。`PhantomPinned` 标记 `!Unpin``'static` 是对 FFmpeg C API 的"善意谎言"。
### 4.4 FFmpeg 滤镜图
```
buffersrc (HW) → crop → scale → [transpose] → [hwdownload] → buffersink
```
- `hw_frames_ctx` 绑定是零拷贝的关键
- crop 使用 `exact=1` workaround
- scale/transpose 按硬件类型选择:`scale_vaapi`/`scale_vulkan``transpose_vaapi`/`transpose_vulkan`
- `hwdownload` 仅在软件编码路径添加
### 4.5 编码器选择
| Codec | VAAPI | Vulkan |
|-------|-------|--------|
| H.264 | `h264_vaapi` | `h264_vulkan` |
| HEVC | `hevc_vaapi` | `hevc_vulkan` |
| VP8/VP9 | `vp8/vp9_vaapi` | 不支持 |
| AV1 | `av1_vaapi` | `av1_vulkan` |
选择优先级:`--ffmpeg-encoder` 显式指定 > 硬件编码器(尝试 `low_power=1` 后回退)> 通用编码器。
### 4.6 EncodePixelFormat 三路派发
`Vaapi(Pixel) / Vulkan(Pixel) / Sw(Pixel)` 在编码器格式设置、硬件上下文绑定、滤镜图构建、帧上下文创建四处做三路匹配。
`--no-hw` 路径:捕获仍用 GPUDMA-BUF编码前 `hwdownload` 到 CPU软件编码器x264 自动 `ultrafast`)。
---
## 5. 状态机与帧生命周期
### 5.1 EncConstructionStage 状态机
```
┌──────────────────┐
应用启动 │ ProbingOutputs │
│ └────────┬─────────┘
▼ │ 所有输出探测完毕
┌───────────────┐ ▼
│ ProbingOutputs├──→ ┌──────────────────┐
└───────────────┘ │EverythingButFmt │
└────────┬─────────┘
│ negotiate_format()
┌───────────┐ 输出断开
┌─────→│ Complete │──────────┐
│ └─────┬─────┘ │
│ │ ▼
│ 格式变化 │ ┌──────────────┐
│ on_new_ │ │OutputWentAway│
│ capture_ │ └──────┬───────┘
│ format() │ │ 同名输出重连
└────────────┘ │
←───────────────────────┘
Intermediate 瞬态存在于所有转换箭头处mem::replace
```
关键转换点:
- **ProbingOutputs → EverythingButFormat**:所有输出探测完毕
- **EverythingButFormat → Complete**`negotiate_format()` 创建 EncState
- **Complete → OutputWentAway**`on_copy_fail()` 检测到输出断开,**保留 enc 丢弃 cap**
- **OutputWentAway → Complete**:同名输出重新出现时创建新 cap 复用旧 enc
`Intermediate` 瞬态通过 `mem::replace` + `take_enc()` 实现安全所有权转移。`take_enc()` 只允许从 `Complete`/`OutputWentAway` 提取编码器。
### 5.2 InFlightSurface 帧生命周期
```
┌──────┐ queue_alloc_frame() ┌─────────────┐
│ None │ ───────────────────→ │ AllocQueued │
└──────┘ └──────┬───────┘
↑ │ on_frame_allocd()
│ ▼
│ ┌───────────┐
│ │ Allocd │
│ └─────┬─────┘
│ │ queue_frame_capture()
│ ▼
│ ┌──────────────┐
└────── on_copy_complete ─│ CopyQueued │
/ on_copy_fail └──────────────┘
```
帧级串行化:同一时间只有一帧在途,通过 `assert!` 强制执行。`CopyQueued` 持有 GPU surface、DRM 映射、Wayland 帧和 buffer 四个资源的所有权,拷贝完成后全部释放并启动下一帧。
### 5.3 HistoryState 双模式
- **RecordingHistory(Duration, VecDeque\<Packet\>)**:环形缓冲,以关键帧为边界裁剪,确保回放可解码
- **Recording(i64)**正常写入PTS 减去偏移量保证起始对齐
SIGUSR1 触发 `RecordingHistory → Recording` 转换:先转换状态,再将历史包通过正常录制路径写出。
### 5.4 错误恢复
`on_copy_fail()` 三个分支按优先级判断:
1. `output_went_away == true` → 保留编码器,进入 `OutputWentAway` 等待重连
2. `format_change == true` → 预期失败,重置标志后重新分配帧
3. 其他 → 未知错误,记录日志后重试
### 5.5 动态格式切换
捕获格式变化时重建 `frames_rgb``video_filter``enc_video``frames_yuv`,但保留 `octx``hw_device_ctx``audio``history_state`
---
## 6. 音频管道与辅助模块
### 6.1 音频管道
独立线程运行,三阶段构造:
1. **IncompleteAudioState**:完成编码器选择、设备打开、解码/编码器创建
2. **AudioHandle**:主线程句柄,含 `Receiver<Packet>` + `AtomicBool` 控制标志
3. **AudioState**音频线程内部状态move 到独立线程
数据流:`音频设备 → 解码 → audio_filter(aformat) → AudioFifo(可选) → 编码 → mpsc → 主线程`
同步机制:`started` 原子标志在视频首帧时间戳获得后才置 true确保音视频起点对齐。
AudioFifo解决变长帧编码器需要固定 `frame_size` 的问题。条件判断:编码器不支持 `VARIABLE_FRAME_SIZE` 时创建。
### 6.2 帧率限制器
VRR 感知设计:引入一帧缓冲延迟判定,避免在 VRR 场景下丢弃"更长时间显示"的帧。
```
on_new_frame(frame, ts):
第1帧 → 直接通过
第2帧 → 存入 on_deck 缓冲
第N帧 → 比较缓冲帧与新帧时间戳:
新帧太近 → 丢弃缓冲帧
间隔足够 → 输出缓冲帧,新帧存入 on_deck
```
### 6.3 坐标变换
处理 Wayland 输出变换(旋转/翻转)对坐标系的影响:
- **transform_basis()**8 种变换的基矩阵映射
- **screen_to_frame()**:矩形从屏幕空间到帧空间
- **transpose_if_transform_transposed()**90° 旋转时交换宽高
- **fit_inside_bounds()**ROI 越界时安全裁剪
### 6.4 主线程事件循环集成
主循环中音频包在视频帧处理间隙通过 `try_recv` 非阻塞收取,无需额外事件源。
退出时 `EncState::flush()` 依次:刷出 FPS 限制器缓冲帧 → flush 音频线程 → 刷视频过滤器 → 发送编码器 EOF → 写容器 trailer。
---
## 7. 可移植设计模式
从代码库中提取的 10 个可复用设计模式,按复杂度从低到高排列。
### 7.1 策略 Trait + 泛型状态CaptureSource
**问题**:多后端系统如何在避免运行时动态分发(`dyn Trait`)开销的同时保持类型安全和可扩展性?
**方案**:定义 `CaptureSource` trait 带关联类型 `Frame`,将整个状态 `State<S: CaptureSource>` 泛型参数化。`State<CapWlrScreencopy>``State<CapExtImageCopy>` 编译为两个独立单态化类型,后端选择在启动时确定。`alloc_frame()` 返回 `Option<Self::Frame>` 统一了同步和异步两种帧分配模式。
**移植要点**
- 适用后端数量有限2-5 个)且进程生命周期内不变的场景;需运行时热切换则改用 trait object
- `State<S>``Sized` 约束必须,因为 `S` 作为字段存储;编译膨胀需注意大型 State 的泛型实例化
- main.rs 定义 trait 而 cap_*.rs 实现它,形成双向依赖,大型项目应将 trait 提取到独立模块
### 7.2 多态枚举状态机EncConstructionStage
**问题**Rust 中如何以零开销实现状态机,同时保证状态转换的类型安全?
**方案**`EncConstructionStage<S>` 有 5 个枚举变体(`ProbingOutputs``EverythingButFormat``Complete``OutputWentAway``Intermediate`),每个携带该状态所需的数据载荷。`Intermediate` 瞬态 + `mem::replace` 组合解决了部分借用限制match 解构 `&mut self.enc` 同时给 `self.enc` 赋新值。`take_enc()` 通过消费 `self` 确保只有含编码器的状态才能被提取。
**移植要点**
- 3-7 个状态是 enum 状态机甜蜜点;优势是编译期穷尽检查,添加新状态时所有 match 报编译错误
- `Intermediate` 瞬态必须存在,否则 `mem::replace` 无法满足类型系统要求
- `Complete``OutputWentAway` 都持有 `EncState` 但后者丢弃 `cap`,体现"保留昂贵资源、丢弃可重建资源"
### 7.3 类型安全帧生命周期InFlightSurface
**问题**:异步 DMA-BUF 传输涉及多个阶段,如何防止在错误阶段执行操作?
**方案**`InFlightSurface<S>` 是 4 状态枚举 `None → AllocQueued → Allocd(S::Frame) → CopyQueued{...} → None`。每个状态携带该阶段特有的资源(`CopyQueued` 持有 GPU surface、DRM 映射、Wayland 帧和 buffer。状态转换通过 `assert!(matches!(...))` 运行时守护,`take()` 方法(`mem::replace`)提供安全取出并自动重置为 `None`。同一时间只有一帧在途。
**移植要点**
- 适用任何"请求→资源就绪→提交操作→操作完成"的异步 I/O 管道
- 运行时 assert 而非编译期 typestate 是合理权衡:回调驱动的异步场景中编译期类型状态过于复杂
- RAII 确保 `CopyQueued → None` 路径释放所有资源DRM 映射、Wayland buffer、帧对象
### 7.4 Pin\<Box\> 自引用结构Vulkan Buffers
**问题**C 库中的链式结构体Vulkan pNext 链内部指针指向同结构其他字段Rust 中移动会使指针失效,如何安全构建?
**方案**`AvHwDevCtxVulkanBuffers` 通过 `PhantomPinned` 标记 `!Unpin``Box::pin` 在堆上分配并固定,`get_unchecked_mut` 设置自引用指针。Vulkan 结构体的生命周期标记为 `'static` 作为对 C API 的"善意谎言",实际受 `Pin<Box<>>` 控制。unsafe 代码集中在 `new()` 中,使用方完全安全。`chain_ptr()` 根据有无 DRM modifier 返回不同链头。
**移植要点**
- 通用模式,适用于 Vulkan、FFmpeg 硬件加速、内核 IOCTL 等涉及自引用 C 结构的场景
- `'static` 不是真正静态生命周期,而是向 C API 表达"指针在使用期间有效";确保持有者比 C API 使用时间更长
- 优于 `ouroboros` crate手写 `Pin<Box<>>` 逻辑清晰可控,生成的代码可调试
### 7.5 独立线程管道 + 原子标志(音频线程)
**问题**:音频需要持续低延迟处理,视频帧率不固定且受 VRR 影响,如何设计无锁跨线程协作?
**方案**:音频处理完全隔离在独立线程。`mpsc::channel` 传递已编码 `Packet`,主线程在视频帧处理间隙通过 `try_recv()` 非阻塞收取。`Arc<AtomicBool>` + `SeqCst` 实现两个控制信号:`started`(视频首帧时间戳获得后置 true音视频起点对齐`flush_flag`(退出通知)。音频线程主循环为 pull 模型,生命周期由输入设备驱动。
**移植要点**
- `AtomicBool` 适用于简单布尔信号,比 `Mutex<bool>` 高效且不死锁;不适用于需要等待/通知的场景
- 主循环间隙调用 `try_recv` 是经典的"顺便收取"模式,避免为音频注册额外事件源
- 适用任何生产者-消费者跨线程场景:传感器采集、网络 I/O 卸载、日志异步写入
### 7.6 VRR 感知帧率控制FpsLimit
**问题**VRR 显示器上帧时间戳极不规则,简单"距上帧太近就丢弃"会产生错误决策,如何在不确定的时间戳流中做出正确帧选择?
**方案**`FpsLimit<T>` 引入一帧延迟:第一帧直接通过,第二帧存入 `on_deck` 缓冲,从第三帧起用新帧时间戳判断旧帧是否保留。新帧太近则丢弃缓冲帧,间隔足够则输出缓冲帧。目标时间计算中使用 `max` 防止回退,正确处理帧跳跃后恢复。零项目内依赖,可直接复制使用。
**移植要点**
- 泛型 `T` 无约束,只做保留/丢弃决策,调用者完全控制帧生命周期
- 结束时必须调用 `flush()` 取出缓冲中的最后一帧,否则丢帧
- 一帧延迟对录屏/编码场景可接受,实时交互场景(如游戏输入)需评估
### 7.7 泛型 Dispatch 三层分发Wayland 协议)
**问题**:多后端 Wayland 客户端如何组织 Dispatch 实现,使共享协议代码只写一次、后端专用代码各自独立?
**方案**:三层分发模式。**A. 完全泛型**`impl<S: CaptureSource> Dispatch<ZwpLinuxDmabufV1, ()> for State<S>`,共享协议,通常空实现。**B. 带状态回调泛型**`impl<S: CaptureSource + 'static> Dispatch<WlOutput, ()> for State<S>`,需 `'static` 约束,含实质状态更新逻辑。**C. 后端专用**`impl Dispatch<ZwlrScreencopyFrameV1, ()> for State<CapWlrScreencopy>`非泛型在各自后端文件中。Rust trait 系统根据代理类型 × 状态泛型参数 × UserData 自动路由。
**移植要点**
- 适用于所有 wayland-client 项目;共享 Dispatch 放 main.rs专用 Dispatch 放各自后端文件
- 需关联信息时(如 xdg-output 关联 WlOutput`TypedObjectId<T>` 作为 UserData
- `'static` 约束源自事件循环要求,状态类型必须满足因为回调可能在任意时刻触发
### 7.8 三阶段安全构造IncompleteAudioState
**问题**:对象需分多阶段初始化且后续阶段依赖前阶段资源,如何在类型系统中安全表达?
**方案**:三个不同类型表示三个阶段。`IncompleteAudioState` 持有输入设备、解码器、编码器(完成 FFmpeg 流创建)。`finish(self)` 消费不完整状态创建过滤器、FIFO、通道组装 `AudioState` 并启动线程,返回 `AudioHandle`(主线程句柄,含 `Receiver<Packet>` + `AtomicBool`)。`AudioState` 通过 move 语义进入线程。`finish(self)` 而非 `finish(&mut self)` 保证不完整状态被消费后不再存在。
**移植要点**
- typestate pattern 变体,用不同类型(非泛型参数)编码状态,优势是不需要泛型
- 每阶段恰好分配该阶段所需资源;第一阶段打开设备(可能失败)不浪费线程资源
- 适用 FFmpeg 管线构建、数据库连接池、GPU 资源分配等"先收集信息、再一次性创建"的场景
### 7.9 显示器热插拔自动恢复OutputWentAway
**问题**:长时间录屏中显示器断连/重连,如何保持编码上下文不丢失并自动恢复录制?
**方案**`OutputWentAway` 状态机变体实现完整断连恢复。`wl_registry``GlobalRemove` 设置 `output_went_away` 标志(延迟到 `on_copy_fail` 时再切换,避免事件处理中途转状态)。转换时通过 `Intermediate` 取出 `enc`(保留编码器),丢弃 `cap`(协议对象已失效),记录等待的输出名称并重新探测。重连时按名称匹配创建新 `CaptureSource`,复用旧编码器继续录制。
**移植要点**
- 核心策略:保留昂贵资源(编码器、文件句柄)、丢弃可重建资源(协议对象、设备句柄)
- 名称匹配(如 "DP-1")而非序号或指针,因为重连后 Wayland 对象 ID 会变化;稳定标识符是热插拔场景关键
- 适用 USB 摄像头、音频设备、网络连接等可热插拔设备的应用
### 7.10 零拷贝 GPU 管道DMA-BUF → HW Frame → Filter → Encoder
**问题**:传统录屏将 GPU 帧下载到 CPU 再上传回 GPU 编码,如何实现全程不离开 GPU 内存的零拷贝管线?
**方案**GPU 帧池分配硬件表面,`av_hwframe_map` 映射为 DRM PRIME 描述符获取 DMA-BUF fd注册为 `WlBuffer` 后合成器直接写入 GPU 表面。滤镜图 `buffersrc → crop → scale → [transpose] → buffersink` 全部在 GPU 执行,`hw_frames_ctx` 绑定确保 FFmpeg 识别 GPU 帧。编码器VAAPI/Vulkan直接消费 GPU 帧。`EncodePixelFormat` 三路枚举在编码器选择、滤镜构建、帧上下文创建处统一派发,仅 `Sw` 路径添加 `hwdownload`
**移植要点**
- DMA-BUF 桥接是 Linux 特有的Windows/macOS 需用 D3D11 共享句柄或 IOSurface
- 捕获帧上下文用 `Drm(modifiers)` 匹配合成器,编码帧上下文用 `Optimal` 获最佳性能,滤镜图做格式转换
- 零拷贝路径失败应降级到 CPU 路径或重试,而非直接崩溃
### 模式总结与关联
| # | 模式 | 核心机制 | 复杂度 |
|--|------|---------|--------|
| 1 | 策略 Trait + 泛型状态 | `trait + State<S>` 单态化 | 中 |
| 2 | 多态枚举状态机 | `enum + mem::replace + Intermediate` | 中高 |
| 3 | 类型安全帧生命周期 | 4 状态 enum + assert 守护 | 低中 |
| 4 | Pin\<Box\> 自引用结构 | `PhantomPinned + Box::pin + unsafe` | 高 |
| 5 | 独立线程管道 + 原子标志 | `mpsc::channel + AtomicBool` | 低 |
| 6 | VRR 感知帧率控制 | 一帧缓冲延迟决策 | 低 |
| 7 | 泛型 Dispatch 三层分发 | `impl<S: Trait> Dispatch for State<S>` | 中 |
| 8 | 三阶段安全构造 | 不同类型 × 消费 self | 低中 |
| 9 | 显示器热插拔恢复 | 标志延迟 + 资源分类 + 名称匹配 | 中 |
| 10 | 零拷贝 GPU 管道 | DMA-BUF + HW Frame + GPU Filter | 高 |
模式围绕"GPU 加速屏幕录制"协同工作:模式 1策略 Trait是架构骨架模式 2状态机是运行时驱动核心模式 10零拷贝管道是性能关键路径。模式 1 被模式 2/3/7/9 使用,模式 5音频线程使用模式 8三阶段构造模式 10 使用模式 4Pin\<Box\>)并被模式 6帧率控制调节。

1
build.rs Normal file
View File

@@ -0,0 +1 @@
fn main() {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,619 @@
# wl-webrtc Architecture Design
**Date**: 2026-04-03
**Status**: Draft
**Author**: Sisyphus (AI-assisted design)
---
## 1. Overview
### 1.1 Problem Statement
Build a low-latency Wayland screen sharing server that captures the desktop via GPU, encodes with hardware acceleration (VAAPI/Vulkan), and streams to a browser for remote viewing and eventual remote control.
### 1.2 Goals
- **Glass-to-glass latency < 50ms** on LAN
- **GPU-accelerated pipeline**: capture + encode entirely on GPU, only encoded bitstream crosses to CPU
- **Browser-only client**: no native app installation required
- **Single binary deployment**: embedded web UI, no external dependencies
- **Linux Wayland only**: no cross-platform abstraction needed
- **Annex B mode**: encoder must emit in-band SPS/PPS with every keyframe via the `h264_metadata` bitstream filter (`repeat_sps=1` `repeat_pps=1`) — NOT `repeat_headers=1` (that option is libx264-only and does NOT exist for `h264_vaapi`)
- **Annex B streaming**: encoder outputs Annex B (start-code-prefixed) NAL units with SPS/PPS injected per-IDR via `h264_metadata` BSF (`repeat_sps=1` `repeat_pps=1`), browser decodes in Annex B mode via WebCodecs. Note: `repeat_headers=1` is a libx264-only option, NOT available for `h264_vaapi`.
### 1.3 Non-Goals (Phase 1)
- Multi-client support (Phase 2)
- Audio streaming (Phase 3)
- Remote input injection (Phase 2)
- Firefox support (Phase 3 — WebRTC fallback)
- Adaptive bitrate (Phase 3)
### 1.4 Technology Stack
| Component | Technology | Rationale |
|-----------|-----------|-----------|
| Screen capture | wayland-client + DMA-BUF | Zero-copy GPU capture via DMA-BUF |
| GPU encoding | FFmpeg (ffmpeg-next) VAAPI/Vulkan | H.264/HEVC hardware encoding |
| Transport | wtransport (WebTransport over HTTP/3) | Full HTTP/3 + WebTransport protocol, built on quinn + rustls |
| Browser decode | WebCodecs VideoDecoder | Direct decode control, no MSE buffering |
| Web UI | axum + rust-embed | Single binary, compile-time embedded static files |
| Event loop | mio | Proven with Wayland file descriptor callbacks |
| Async runtime | tokio | Required by wtransport, also powers axum |
| Sync/async bridge | async_channel | Both sync send() and async recv(), bridges mio → tokio naturally |
### 1.5 Transport Decision: Why Not WebRTC
WebRTC was evaluated and rejected as the primary transport for this use case:
| Factor | WebRTC (webrtc-rs) | WebTransport + WebCodecs |
|--------|-------------------|-------------------------|
| Glass-to-glass latency | 30-110ms (unavoidable 20-60ms jitter buffer) | 12-38ms (no jitter buffer) |
| Rust ecosystem | webrtc-rs v0.20.0-alpha, mid-rewrite | wtransport production-grade, built on quinn |
| Protocol overhead | ICE/DTLS/SRTP/SDP — designed for P2P NAT traversal | QUIC TLS 1.3 — server-to-client, simpler |
| Decode control | Browser controls jitter buffer, cannot opt out | Application controls every frame decode |
| GPU data path | Sample { data: Bytes }, must copy to CPU | Same copy, but shorter pipeline |
| Browser support | All browsers | Chrome/Edge only (Firefox lacks WebCodecs) |
**Transport library choice**: We use the `wtransport` crate (v0.7) instead of raw `quinn` + `h3`. The browser's `WebTransport` API requires a full HTTP/3 server with the WebTransport extension (RFC 9297). Raw QUIC is NOT sufficient — there is no browser API for raw QUIC connections. The `wtransport` crate provides the complete protocol stack (HTTP/3 + WebTransport) built on top of `quinn` 0.11 and `rustls` 0.23, with support for datagrams, unidirectional streams, and bidirectional streams.
WebRTC will be added as a Phase 3 fallback for Firefox compatibility.
---
## 2. Architecture
### 2.1 Thread Model
```
┌─────────────────────────────────────────────────────────────┐
│ wl-webrtc process │
│ │
│ Main Thread (mio event loop) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Wayland event queue dispatch │ │
│ │ Screen capture (DMA-BUF, zero-copy from compositor) │ │
│ │ GPU encode (FFmpeg VAAPI/Vulkan, sync calls) │ │
│ │ State machine transitions │ │
│ │ FPS limiting │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ async_channel::bounded<16>(EncodedFrame) │
│ │ │
│ Tokio Runtime Thread Pool (2+ threads) │
│ ┌──────────────────────▼───────────────────────────────┐ │
│ │ wtransport WebTransport server │ │
│ │ HTTP/3 + WebTransport session management │ │
│ │ Frame distribution to connected clients │ │
│ │ axum HTTP server (Web UI + control API) │ │
│ │ rust-embed static file serving │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**Design rationale**:
- **Capture + encode on main thread**: GPU encoding is synchronous (3-8ms per frame at 30-60fps), doesn't block the mio event loop at these frame rates. This avoids cross-thread synchronization for the GPU pipeline.
- **wtransport on tokio**: wtransport is built on quinn and tokio. axum requires tokio. Both coexist naturally. Both the WebTransport server and the HTTP static file server share the same tokio runtime.
- **async_channel::bounded(16)**: Channel capacity of 16 frames provides ~260ms of buffer at 60fps — enough to absorb transport jitter without excessive latency. The sender uses `try_send()`: if the channel is full, the frame is dropped and logged. This is standard practice in real-time streaming — newer frames are always more valuable than older ones. `try_send()` returns `Err(TrySendError::Full(_))` on a full channel, which the main loop handles by discarding the frame. This avoids blocking the main mio event loop, which must remain responsive for Wayland event dispatch. **Do NOT use `send_blocking()`** on the mio thread — it would stall the capture pipeline if the transport consumer falls behind.
### 2.2 Module Dependency Graph
```
┌──────────┐
│ main.rs │ entry point, CLI, orchestration
└──┬──┬─┬──┘
│ │ │
┌────────────┘ │ └────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────────┐
│ state.rs │ │ avhw.rs │ │ transport.rs│
│ StateMachine │ HW ctx │ │ QUIC server │
│ CaptureSource │ │ │ Sessions │
└──┬───┬────┘ └────┬─────┘ └────────────┘
│ │ │
┌─────┘ └──────┐ │
▼ ▼ ▼
┌─────────┐ ┌────────────┐ ┌────────┐
│cap_wlr_ │ │cap_ext_ │ │filter.rs│
│screen │ │image_copy │ │ crop/ │
│copy │ │ │ │ scale/ │
└─────────┘ └────────────┘ │transpose│
└────────┘
┌────────────┐ ┌──────────────┐
│transform.rs│ │signaling.rs │
│ coordinate │ │ axum + embed │
│ transform │ │ Web UI serve │
└────────────┘ └──────────────┘
┌────────────┐
│fps_limit.rs│
└────────────┘
```
**Dependency layers** (bottom-up):
1. `transform.rs`, `fps_limit.rs` — leaf modules, zero internal dependencies
2. `avhw.rs`, `filter.rs` — FFmpeg wrapper layer
3. `cap_wlr_screencopy.rs`, `cap_ext_image_copy.rs` — capture backends, depend on state + avhw
4. `state.rs` — state machine + CaptureSource trait
5. `transport.rs`, `signaling.rs` — network layer
6. `main.rs` — orchestration
### 2.3 Project File Structure
```
wl-webrtc/
├── Cargo.toml
├── README.md
├── src/
│ ├── main.rs # ~300 lines — CLI, startup, orchestration
│ ├── state.rs # ~600 lines — State<S>, EncConstructionStage, InFlightSurface
│ ├── avhw.rs # ~450 lines — FFmpeg HW device/frame contexts
│ ├── filter.rs # ~200 lines — FFmpeg video filter graph
│ ├── cap_wlr_screencopy.rs # ~170 lines — wlr-screencopy backend
│ ├── cap_ext_image_copy.rs # ~240 lines — ext-image-copy-capture backend
│ ├── transform.rs # ~220 lines — coordinate transforms
│ ├── fps_limit.rs # ~130 lines — VRR-aware frame rate limiter
│ ├── transport.rs # ~400 lines — QUIC/WebTransport server
│ ├── signaling.rs # ~200 lines — axum HTTP + WebSocket control
│ └── nalu.rs # ~150 lines — Annex B NAL unit splitting, framing protocol
├── static/
│ ├── index.html # Web UI shell
│ ├── player.js # WebCodecs decoder + Canvas renderer
│ └── style.css # Minimal styling
└── protocols/ # Wayland protocol XML files
```
---
## 3. Data Flow
### 3.1 Zero-Copy Capture Pipeline
```
GPU Frame Pool ─alloc()→ HW Surface
av_hwframe_map → DMA-BUF fd
zwp_linux_dmabuf → WlBuffer (fd shared)
Compositor writes directly to GPU Surface
FFmpeg VAAPI/Vulkan encode (GPU-internal)
AVPacket.data (Annex B with 00 00 00 01 start codes)
↓ ← GPU→CPU copy via vaMapBuffer (unavoidable)
Bytes::from(Vec<u8>) wrapper
async_channel::bounded::send(EncodedFrame) // sync, non-blocking on main thread
```
### 3.2 Transport Pipeline
```
async_channel::bounded::recv(EncodedFrame)
Frame byte-splitting at MTU boundaries (not NAL-aligned)
┌─ Keyframe → QUIC reliable stream (guaranteed delivery)
└─ Delta frame → QUIC datagram (unreliable, low latency)
Quinn WebTransport send
Browser WebTransport.receive()
Frame reassembly (if fragmented)
WebCodecs VideoDecoder.decode(EncodedVideoChunk)
Canvas.drawImage(VideoFrame)
```
### 3.3 Latency Budget
| Stage | Latency | Notes |
|-------|---------|-------|
| Wayland capture (KMS/dmabuf) | 1-3ms | Zero-copy from compositor |
| GPU encode (VAAPI H.264) | 3-8ms | Synchronous, main thread |
| vaMapBuffer CPU copy | <1ms | Unavoidable GPU→CPU |
| async_channel | <0.1ms | In-process |
| QUIC datagram (LAN) | 1-10ms | LAN transit, merged with network |
| WebCodecs decode | 2-5ms | Browser hardware decode |
| Canvas render | 1-2ms | requestAnimationFrame |
| **Total (LAN)** | **9-29ms** | Well under 50ms target (corrected: removed double-counted network transit) |
### 3.4 EncodedFrame Structure
```rust
#[derive(Clone)]
struct EncodedFrame {
data: Bytes, // Annex B NALUs with start codes
pts_us: i64, // Presentation timestamp (microseconds, for WebCodecs)
duration: Duration, // Frame duration for timestamp calculation
frame_type: FrameType, // Keyframe or Delta (matches transport framing)
width: u32, // Frame width (may differ from capture on ROI)
height: u32, // Frame height
}
```
**Timestamp convention**: `pts_us` is in **microseconds** (not nanoseconds), matching WebCodecs' `EncodedVideoChunk.timestamp` requirement. The server tracks a monotonic PTS starting from 0, incrementing by `1_000_000 / fps` per frame.
---
## 4. State Machine
### 4.1 EncConstructionStage
```
┌──────────────────┐
App start │ ProbingOutputs │ Discover Wayland outputs,
│ └────────┬─────────┘ collect geometry info
▼ │ All outputs probed
┌───────────────┐ ▼
│ ProbingOutputs├──→ ┌──────────────────┐
└───────────────┘ │EverythingButFmt │ HW device ctx created,
└────────┬─────────┘ encoder initialized
│ negotiate_format()
┌───────────┐
┌─────→│ Streaming │──── Active capture + encode + transport
│ └─────┬─────┘
│ │ Output disconnected
│ Format │ ┌──────────────┐
│ changed │ │OutputWentAway│ Keep enc + transport,
│ │ └──────┬───────┘ drop capture objects
└────────────┘ │ Same output reconnects
←───────────────────────┘
Intermediate transient exists at all transition arrows (mem::replace)
```
**Key design choice**: `Streaming` state holds both `EncState` (encoding pipeline) AND `TransportState` (active WebTransport sessions). On `OutputWentAway`, both are preserved — only capture objects are discarded.
### 4.2 InFlightSurface
```
None → AllocQueued → Allocd(Frame) → CopyQueued { surface, drm_map, frame, buffer } → None
```
4-state enum with `assert!(matches!(...))` runtime guards. RAII cleanup on each state transition. Single-frame-in-flight constraint prevents buffer exhaustion.
### 4.3 TransportSessionState (new)
```
┌───────────┐ connect ┌───────────┐ disconnect ┌───────────┐
│ Listening │ ──────────────→ │ Active │ ──────────────→ │ Closed │
│ (quinn │ │ (sending │ │ (cleanup) │
│ endpoint)│ │ frames) │ │ │
└───────────┘ └───────────┘ └───────────┘
```
Multiple sessions can be `Active` simultaneously (Phase 2). Phase 1 supports exactly one.
---
## 5. Design Patterns
The architecture employs several established software design patterns for managing complexity:
| # | Pattern | Usage in wl-webrtc |
|---|---------|-------------------|
| 1 | Strategy Trait + Generic State | `CaptureSource` trait with `CapWlrScreencopy` / `CapExtImageCopy` backends |
| 2 | Polymorphic Enum State Machine | `EncConstructionStage` — 5 variants with type-safe transitions |
| 3 | Type-Safe Frame Lifecycle | `InFlightSurface` — 4-state enum with runtime guards |
| 4 | Pin\<Box\> Self-Referential | Vulkan device context — for self-referential FFmpeg structs |
| 5 | Independent Thread Pipe | tokio runtime replaces mpsc audio thread; same atomic flag pattern |
| 6 | VRR-Aware Frame Rate Control | `FpsLimit<T>` — one-frame-buffer delay for correct drop decisions |
| 7 | Generic Dispatch 3-Layer | Wayland protocol dispatch — generic event handling |
| 8 | Three-Stage Safe Construction | Incremental resource acquisition with partial state rollback |
| 9 | Hot-Plug Auto-Recovery | `OutputWentAway` — preserve encoder/transport, rebuild capture |
| 10 | Zero-Copy GPU Pipeline | DMA-BUF capture + GPU-internal encode, minimal CPU involvement |
---
## 6. Transport Protocol Design
### 6.1 WebTransport Connection Setup
```
Server generates self-signed TLS certificate (via wtransport built-in rcgen support)
→ wtransport::Endpoint::server(server_config, addr)
→ Browser: new WebTransport("https://server:PORT/wt")
→ wtransport handles full HTTP/3 + WebTransport handshake internally
→ Session established (datagrams + streams available)
```
**Transport library**: We use `wtransport` crate (v0.7) which provides a complete WebTransport-over-HTTP/3 server implementation built on top of `quinn` 0.11 and `rustls` 0.23. This handles all protocol details (HTTP/3 SETTINGS, CONNECT method with `:protocol = webtransport`, session management, datagram framing per RFC 9297). Raw `quinn` or `h3` would require building this protocol stack manually.
### 6.2 Frame Framing Protocol
QUIC datagrams have a practical MTU of ~1200 bytes. A 1080p H.264 frame is typically 10KB-200KB. Application-level framing:
```
Datagram format:
┌──────────┬──────────┬──────────┬──────────┬──────────┬─────────────┐
│ type (1) │ frame_id │ pts_us │ seq_num │ total │ payload │
│ │ (4 bytes)│ (8 bytes)│ (2 bytes)│ (2 bytes)│ (variable) │
└──────────┴──────────┴──────────┴──────────┴──────────┴─────────────┘
type:
0x01 = Keyframe fragment (sent via reliable stream, not datagram)
0x02 = Delta frame fragment (sent via datagram)
0x03 = Keyframe complete (small enough for single datagram)
0x04 = Delta frame complete
0x10 = Codec config (SPS/PPS for H.264, VPS/SPS/PPS for HEVC)
pts_us: Presentation timestamp in microseconds (i64, big-endian).
Passed directly to WebCodecs EncodedVideoChunk.timestamp.
For fragmented frames, every fragment carries the same pts_us.
```
**Key design decisions**:
- **Keyframes via reliable WebTransport stream**: SPS/PPS + IDR data must not be lost. Use `session.open_uni().await` for reliable delivery.
- **Delta frames via datagram**: Loss-tolerant. If a delta frame is lost, the decoder waits for the next keyframe. This avoids accumulated corruption.
- **Frame reassembly in browser**: Buffer fragments by `frame_id`, reassemble when all `total` fragments arrive, decode complete frame.
- **Timestamp in microseconds**: The fragment header carries `pts_us: i64` (presentation timestamp in microseconds) so the browser can pass it directly to `EncodedVideoChunk.timestamp`. This is required by WebCodecs — a sequential frame_id counter is NOT a valid timestamp.
### 6.3 Codec Configuration Exchange
The encoder MUST be configured with the `h264_metadata` bitstream filter (`repeat_sps=1` `repeat_pps=1`) to guarantee SPS/PPS are injected into every IDR frame. Note: `repeat_headers=1` is a libx264-only option and does NOT exist for `h264_vaapi`. The browser configures the decoder in **Annex B mode** (no `description` at `configure()` time), and SPS/PPS arrive in-band with each keyframe.
On session establishment, the server sends a codec configuration message over the reliable QUIC stream to inform the browser of the codec and dimensions:
```json
{
"type": "codec_config",
"codec": "avc1.42E01F",
"width": 1920,
"height": 1080,
"framerate": 60
}
```
Browser uses this to configure `VideoDecoder` — without `description`, which activates Annex B mode:
```javascript
decoder.configure({
codec: config.codec,
codedWidth: config.width,
codedHeight: config.height,
// NO description — Annex B mode. SPS/PPS arrive in-band with each keyframe.
});
```
**Why no AVCC description?** Per the WebCodecs AVC registration spec, providing `description` forces the decoder into AVC (length-prefixed) mode for ALL frames. Since our encoder outputs Annex B (start-code-prefixed), we must omit `description` and rely on in-band parameter sets guaranteed by the `h264_metadata` BSF (`repeat_sps=1` `repeat_pps=1`). Note: `repeat_headers=1` is a libx264-only option — it does NOT work with `h264_vaapi`.
**Timestamp handling**: The `FragmentHeader` carries both a `frame_id` (u32) for reassembly ordering and `pts_us` (i64) — the presentation timestamp in microseconds. The browser uses `pts_us` directly as `EncodedVideoChunk.timestamp`. This is required by WebCodecs — a sequential frame_id counter is NOT a valid timestamp. Every fragment of a frame carries the same `pts_us` value so the browser can extract it from any fragment during reassembly.
---
## 7. Browser-Side Design
### 7.1 Web UI (static/index.html + player.js)
Single-page application with minimal dependencies:
```
┌──────────────────────────────────────┐
│ wl-webrtc │
│ ┌──────────────────────────────┐ │
│ │ │ │
│ │ <canvas> (video) │ │
│ │ WebCodecs → drawImage │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ Status: Connected | Latency: 23ms │
│ Resolution: 1920x1080 @ 60fps │
│ [Fullscreen] [Disconnect] │
└──────────────────────────────────────┘
```
### 7.2 WebCodecs Decoder Pipeline
**CRITICAL: Annex B mode only.** Per the [W3C AVC WebCodecs Registration](https://w3c.github.io/webcodecs/avc_codec_registration.html#videodecoderconfig-description), if `description` is provided at `configure()` time, ALL subsequent `EncodedVideoChunk` data must be in AVC format (4-byte length-prefixed). If `description` is **absent**, the bitstream is assumed to be in Annex B format (start-code-prefixed). Since our encoder outputs Annex B, we must NOT provide `description`.
The encoder MUST be configured with the `h264_metadata` bitstream filter (`repeat_sps=1` `repeat_pps=1`) to guarantee SPS/PPS are injected into every IDR frame. Note: `repeat_headers=1` is a libx264-only option and does NOT exist for `h264_vaapi`. This enables the decoder to initialize from keyframe data alone.
```javascript
// Simplified player.js flow
const transport = new WebTransport("https://server:PORT/wt");
const decoder = new VideoDecoder({
output: (frame) => {
ctx.drawImage(frame, 0, 0);
frame.close();
},
error: (e) => console.error(e),
});
// Configure WITHOUT description → Annex B mode.
// SPS/PPS are delivered in-band with each keyframe (via h264_metadata BSF repeat_sps=1 repeat_pps=1 on encoder).
decoder.configure({
codec: "avc1.42E01F",
codedWidth: 1920,
codedHeight: 1080,
// NO description field — Annex B mode
});
// Receive frames
const reader = transport.datagrams.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const frame = reassembleFrame(value);
if (frame.complete) {
decoder.decode(new EncodedVideoChunk({
type: frame.isKeyframe ? "key" : "delta",
timestamp: Number(frame.ptsUs),
data: frame.data, // Annex B — valid because no description was provided
}));
}
}
```
### 7.3 No Annex B → AVCC Conversion Needed
Because we configure the decoder in Annex B mode (no `description`), no format conversion is needed on the browser side. The server sends raw Annex B NAL units with start codes (`00 00 00 01`), and the decoder accepts them directly.
The encoder MUST be configured with the `h264_metadata` bitstream filter (`repeat_sps=1` `repeat_pps=1`) to guarantee SPS/PPS are included in every IDR frame. Note: `repeat_headers=1` (and `-flags2 +repeat_headers`) are libx264-only options — they do NOT work with `h264_vaapi`. The BSF approach is encoder-agnostic and works with all FFmpeg hardware encoders. This ensures the decoder can re-initialize after any keyframe, even if it missed earlier configuration data.
---
## 8. Error Handling & Recovery
### 8.1 Display Hot-Plug
1. `wl_registry.global_remove` → set `output_went_away` flag
2. `on_copy_fail()` detects flag → transition to `OutputWentAway`
3. Preserve: encoder context, transport sessions, WebRTC connections
4. Discard: Wayland protocol objects (invalidated)
5. Wait for same-name output ("DP-1") to reappear
6. Create new `CaptureSource`, reuse old encoder, continue streaming
### 8.2 Network Disconnection
- QUIC handles keepalive and retransmission internally
- Client page refresh → new WebTransport session → server auto-starts sending current frame stream
- Server is stateless per session — no recovery needed, just reconnect
### 8.3 Dynamic Format Change
Capture format changes (resolution, rotation):
1. Rebuild: `frames_rgb`, `video_filter`, `enc_video`, `frames_yuv`
2. Preserve: `hw_device_ctx`, `transport_state`
3. Send new codec configuration to browser via reliable stream
4. Browser reconfigures `VideoDecoder` with new SPS/PPS and dimensions
### 8.4 Frame Loss Handling
- Lost delta frame → decoder continues, minor artifact until next keyframe
- Lost keyframe → decoder cannot continue → request keyframe from server via reliable stream
- Server receives keyframe request → sets next input frame to `AV_PICTURE_TYPE_I`
### 8.5 Graceful Shutdown
Shutdown is triggered by SIGINT/SIGTERM via `signal-hook` + `mio` integration:
1. Main loop sets `running = false` flag → stops queuing new captures
2. Wait for in-flight frame to complete (drain `InFlightSurface`)
3. Flush encoder (`avcodec_flush_buffers`) → drain remaining packets
4. Send final frames through channel
5. Drop `frame_tx` sender → signals EOF to transport
6. Transport server drains pending frames, sends GOAWAY to clients
7. `tokio::runtime::shutdown_background()` terminates async tasks
8. Drop Wayland protocol objects (compositor handles cleanup)
9. FFmpeg contexts freed via `Drop` implementations
**Key concern**: Do NOT use blocking `send_blocking()` on the main thread — use `try_send()` so the main loop never stalls during shutdown. If the channel is full, the frame is dropped (acceptable during shutdown).
**NOTE**: wayland-client 0.31 uses `Connection::connect_to_env()` and `GlobalList` instead of the old 0.29 API (`Display::connect_to_env()` / `GlobalManager::new()`). See plan Task 11 for correct API usage.
### 8.6 First Keyframe Delivery
When a new WebTransport session is established, the client needs a keyframe before it can decode any delta frames. Two strategies:
1. **Force IDR on connect**: Set `AV_PICTURE_TYPE_I` on the next encoded frame when a new session is detected
2. **Buffer last keyframe**: Store the most recent keyframe in `TransportServer`, resend to new clients
Phase 1 uses strategy 1 (force IDR) for simplicity. The transport server sets a `needs_keyframe: bool` flag on new sessions, which the encode loop checks.
---
## 9. Dependencies
```toml
[dependencies]
# Wayland screen capture
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
drm-fourcc = "2"
# GPU encoding
ffmpeg-next = "8"
# WebTransport (HTTP/3 + WebTransport protocol, built on quinn + rustls)
wtransport = { version = "0.7", features = ["self-signed"] }
# Web UI
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
rust-embed = { version = "8", features = ["mime-guess"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
# Sync/async bridge (sync send() on mio thread, async recv() on tokio)
async-channel = "2"
# Event loop
mio = "1"
# Utilities
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
bytes = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
signal-hook = { version = "0.3", features = ["iterator"] }
base64 = "0.22"
mime_guess = "2"
```
**Encoder configuration note**: The VAAPI H.264 encoder MUST be configured with the `h264_metadata` bitstream filter (`repeat_sps=1` `repeat_pps=1`) to guarantee SPS/PPS parameter sets are emitted in-band with every IDR frame. This is required for WebCodecs Annex B decode mode on the browser side. **Important**: `repeat_headers=1` and `-flags2 +repeat_headers` are libx264-only options — they do NOT work with `h264_vaapi`. The BSF approach is encoder-agnostic and works with all FFmpeg hardware encoders.
---
## 10. Implementation Phases
### Phase 1 — MVP: Screen → Browser Streaming
| # | Module | Description | Estimated Effort |
|---|--------|-------------|------------------|
| 1 | `main.rs` | CLI args, startup sequence | Small |
| 2 | `cap_*.rs` | Implement capture backends (wlr-screencopy + ext-image-copy) | Medium |
| 3 | `avhw.rs` | Implement FFmpeg HW device/frame context management | Medium |
| 4 | `filter.rs` | Implement GPU video filter graph | Small |
| 5 | `transform.rs` | Implement coordinate transforms for Wayland outputs | Small |
| 6 | `fps_limit.rs` | Implement VRR-aware frame rate limiter | Small |
| 7 | `state.rs` | State machine adapted for transport | Medium |
| 8 | `transport.rs` | QUIC server + frame distribution | Large (new code) |
| 9 | `nalu.rs` | Annex B framing protocol | Small (new code) |
| 10 | `signaling.rs` | axum server + static files | Small (new code) |
| 11 | `static/*` | Browser Web UI + WebCodecs player | Medium (new code) |
**Deliverable**: Run `wl-webrtc`, open `https://localhost:PORT` in Chrome, see live screen at <50ms latency.
### Phase 2 — Remote Input + Stability
| # | Feature | Description |
|---|---------|-------------|
| 12 | Remote input | Browser mouse/keyboard → wlr-virtual-pointer/virtual-keyboard |
| 13 | Hot-plug recovery | Display disconnect/reconnect |
| 14 | Dynamic format | Resolution/rotation change handling |
| 15 | Multi-client | Multiple simultaneous browser viewers |
### Phase 3 — Optimization + Compatibility
| # | Feature | Description |
|---|---------|-------------|
| 16 | Adaptive bitrate | Network-aware VAAPI bit_rate adjustment |
| 17 | Audio pipeline | Synchronous audio capture + encoding + transport |
| 18 | WebRTC fallback | webrtc-rs path for Firefox compatibility |
| 19 | Performance dashboard | Real-time stats in Web UI |
---
## 11. Open Questions
1. **ffmpeg-next vs direct VAAPI bindings**: ffmpeg-next adds FFI overhead but provides mature encoding pipeline. Direct vaapi-dmabuf bindings would be more Rust-native but much more implementation work. **Decision: ffmpeg-next for Phase 1, evaluate direct bindings in Phase 3.** NOTE: `ffmpeg-next` safe API does NOT wrap hardware contexts (`AVBufferRef`, `AVHWFramesContext`). Use raw `ffmpeg_next::ffi` directly for all HW context operations — see `wl-screenrec/src/avhw.rs` for the reference pattern.
2. **Frame fragmentation strategy**: Current design fragments large frames across QUIC datagrams at byte boundaries (not NAL-aligned). The framing protocol reassembles by `frame_id`, so a lost fragment invalidates the entire frame. Alternative: send all frames via reliable QUIC streams and accept slightly higher latency. **Decision: Start with datagrams for delta frames, measure latency, evaluate.**
3. **Self-signed certificate UX**: Browser will show SSL warning. Options: (a) accept for LAN, (b) guide user to trust CA, (c) use HTTP/2 prior knowledge. **Decision: Accept for Phase 1, add CA trust guide in Phase 2.**
4. **HEVC vs H.264 default**: H.264 has universal browser support. HEVC has better compression but spotty browser support. **Decision: H.264 default, HEVC as option flag.**
5. **WebCodecs bitstream format**: **Decision: Annex B mode (no `description` at configure time).** SPS/PPS are guaranteed in-band via the `h264_metadata` BSF (`repeat_sps=1` `repeat_pps=1`). **Important**: The `repeat_headers=1` encoder option is libx264-only — it does NOT work with `h264_vaapi`. The BSF approach is encoder-agnostic and works with all FFmpeg hardware encoders. Per the W3C AVC WebCodecs Registration, providing `description` forces AVC (length-prefixed) mode for ALL subsequent frames. Since our encoder outputs Annex B, we must omit `description`.

18
shell.nix Normal file
View File

@@ -0,0 +1,18 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
pkg-config
rustc
cargo
clang
ffmpeg_6-full
wayland
wayland-protocols
libdrm
mesa
libva
];
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
}

45
src/args.rs Normal file
View File

@@ -0,0 +1,45 @@
use clap::Parser;
#[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)
#[arg(short, long)]
pub output: String,
/// Wayland output name to capture
#[arg(long)]
pub output_name: Option<String>,
/// Target frames per second
#[arg(long, default_value_t = 30)]
pub fps: u32,
/// Video codec (h264 only for MVP)
#[arg(long, default_value = "h264")]
pub codec: String,
/// Hardware acceleration method (vaapi only for MVP)
#[arg(long, default_value = "vaapi")]
pub hw_accel: String,
/// DRM render device path (e.g., /dev/dri/renderD128)
#[arg(long)]
pub drm_device: Option<String>,
/// Target bitrate in bits per second
#[arg(long)]
pub bitrate: Option<u64>,
/// Group of Pictures (GOP) size
#[arg(long)]
pub gop_size: Option<u32>,
/// Enable verbose logging
#[arg(short, long)]
pub verbose: bool,
/// Port for WebTransport server (Phase 2, unused in MVP)
#[arg(long, default_value_t = 0)]
pub port: u16,
}

672
src/avhw.rs Normal file
View File

@@ -0,0 +1,672 @@
use std::ffi::CString;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi as ffi;
use ffmpeg_next::packet::Mut as _;
// ---------------------------------------------------------------------------
// BSF FFI — ffmpeg-sys-next does not expose the BSF API; declare manually.
// Linked from libavcodec (always present when avcodec feature is enabled).
// ---------------------------------------------------------------------------
#[repr(C)]
pub struct AVBitStreamFilter {
_opaque: [u8; 0],
}
#[repr(C)]
pub struct AVBSFContext {
av_class: *const ffi::AVClass,
filter: *const AVBitStreamFilter,
priv_data: *mut libc::c_void,
par_in: *mut ffi::AVCodecParameters,
par_out: *mut ffi::AVCodecParameters,
time_base_in: ffi::AVRational,
time_base_out: ffi::AVRational,
}
extern "C" {
pub fn av_bsf_get_by_name(name: *const libc::c_char) -> *const AVBitStreamFilter;
pub fn av_bsf_alloc(
filter: *const AVBitStreamFilter,
ctx: *mut *mut AVBSFContext,
) -> libc::c_int;
pub fn av_bsf_init(ctx: *mut AVBSFContext) -> libc::c_int;
pub fn av_bsf_send_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
pub fn av_bsf_receive_packet(ctx: *mut AVBSFContext, pkt: *mut ffi::AVPacket) -> libc::c_int;
pub fn av_bsf_free(ctx: *mut *mut AVBSFContext);
}
// ---------------------------------------------------------------------------
// AvHwDevCtx
// ---------------------------------------------------------------------------
pub struct AvHwDevCtx {
ptr: *mut ffi::AVBufferRef,
}
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();
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 {}: error {ret}",
drm_device.display()
);
}
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) };
}
}
}
// ---------------------------------------------------------------------------
// AvHwFrameCtx
// ---------------------------------------------------------------------------
pub struct AvHwFrameCtx {
ptr: *mut ffi::AVBufferRef,
}
unsafe impl Send for AvHwFrameCtx {}
impl AvHwFrameCtx {
fn new_inner(
hw_dev: &AvHwDevCtx,
w: u32,
h: u32,
sw_fmt: ff::format::Pixel,
) -> Result<Self> {
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;
}
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: error {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 for_encode(
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) };
}
}
}
// ---------------------------------------------------------------------------
// EncState
// ---------------------------------------------------------------------------
pub struct EncState {
enc_video: ff::codec::encoder::video::Video,
bsf_ctx: *mut AVBSFContext,
frames_rgb: AvHwFrameCtx,
frames_yuv: AvHwFrameCtx,
video_filter: ff::filter::Graph,
hw_device_ctx: AvHwDevCtx,
octx: ff::format::context::Output,
starting_timestamp: Option<i64>,
frames_written: bool,
}
unsafe impl Send for EncState {}
#[allow(clippy::too_many_arguments)]
impl EncState {
pub fn new(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
bitrate: u64,
gop_size: u32,
fps: u32,
) -> Result<Self> {
// 1. VAAPI device
let hw_device_ctx = AvHwDevCtx::new_vaapi(drm_device)?;
// 2. Frame contexts (capture=XRGB/RGBZ, encode=NV12)
let frames_rgb = AvHwFrameCtx::for_capture(
&hw_device_ctx,
width,
height,
ff::format::Pixel::RGBZ,
)?;
let frames_yuv = AvHwFrameCtx::for_encode(
&hw_device_ctx,
width,
height,
ff::format::Pixel::NV12,
)?;
// 3. Find h264_vaapi encoder
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(width);
enc.set_height(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);
// 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 = frames_yuv.ref_clone();
}
// 4. Open encoder. Video::open() returns Encoder(Video); .0 extracts the Video.
let opened = enc.open().map_err(|e| {
anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}")
})?;
let enc_video = opened.0;
// --- BSF init (after encoder open, before filter graph) ---
// SAFETY: av_bsf_get_by_name returns a pointer to a static filter definition.
let bsf_name = CString::new("h264_metadata").unwrap();
let filter = unsafe { av_bsf_get_by_name(bsf_name.as_ptr()) };
if filter.is_null() {
bail!("h264_metadata BSF not found in FFmpeg build");
}
let mut bsf_ctx: *mut AVBSFContext = ptr::null_mut();
let ret = unsafe { av_bsf_alloc(filter, &mut bsf_ctx) };
if ret < 0 {
bail!("av_bsf_alloc failed: error {ret}");
}
// SAFETY: avcodec_parameters_from_context copies FROM AVCodecContext TO AVCodecParameters.
let ret = unsafe {
ffi::avcodec_parameters_from_context((*bsf_ctx).par_in, enc_video.as_ptr())
};
if ret < 0 {
// SAFETY: bsf_ctx was allocated but not yet initialized — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("avcodec_parameters_from_context for BSF failed: error {ret}");
}
// SAFETY: time_base_in is a plain AVRational field, safe to write
unsafe {
(*bsf_ctx).time_base_in = (*enc_video.as_ptr()).time_base;
}
// Set repeat_sps=1
let key_sps = CString::new("repeat_sps").unwrap();
let val_one = CString::new("1").unwrap();
let ret = unsafe {
ffi::av_opt_set((*bsf_ctx).priv_data, key_sps.as_ptr(), val_one.as_ptr(), 0)
};
if ret < 0 {
// SAFETY: bsf_ctx allocated but not fully initialized — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_opt_set repeat_sps failed: error {ret}");
}
// Set repeat_pps=1
let key_pps = CString::new("repeat_pps").unwrap();
let ret = unsafe {
ffi::av_opt_set((*bsf_ctx).priv_data, key_pps.as_ptr(), val_one.as_ptr(), 0)
};
if ret < 0 {
// SAFETY: bsf_ctx allocated, repeat_sps set but not init'd — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_opt_set repeat_pps failed: error {ret}");
}
// Initialize BSF
let ret = unsafe { av_bsf_init(bsf_ctx) };
if ret < 0 {
// SAFETY: bsf_ctx allocated, params set but init failed — safe to free
unsafe { av_bsf_free(&mut bsf_ctx) };
bail!("av_bsf_init failed: error {ret}");
}
// 5. Filter graph (inline)
let video_filter =
build_filter_graph(&hw_device_ctx, &frames_rgb, width, height, fps)?;
// 6. Muxer setup (strict order)
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: error {ret}");
}
// SAFETY: avformat_query_codec checks codec+format compatibility.
let codec_id = unsafe { (*enc_video.as_ptr()).codec_id };
let oformat = unsafe { (*fmt_ctx_ptr).oformat };
let compat = unsafe {
ffi::avformat_query_codec(oformat, codec_id, ffi::FF_COMPLIANCE_NORMAL as i32)
};
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: error {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 '{}': error {ret}",
output_path.display()
);
}
// 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: error {ret}");
}
// SAFETY: We created fmt_ctx_ptr above and it's valid.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
Ok(Self {
enc_video,
bsf_ctx,
frames_rgb,
frames_yuv,
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<()> {
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
// SAFETY: hw_frame is a valid VAAPI hardware frame from capture.
filter_src.add(hw_frame).map_err(|e| {
anyhow::anyhow!("Filter source add failed: {e}")
})?;
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}"),
}
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();
// 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: error {ret}");
}
self.drain_encoder(start_ts)?;
}
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
// Flush filter graph
let mut filter_src_ctx = self.video_filter.get("in").unwrap();
let mut filter_src = filter_src_ctx.source();
let _ = filter_src.flush();
// Drain filter
let mut filter_sink_ctx = self.video_filter.get("out").unwrap();
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);
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: error {ret}");
}
self.drain_encoder(start_ts)?;
}
Err(_) => break,
}
}
// SAFETY: Sending null frame signals end of stream.
unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
}
let start_ts = self.starting_timestamp.unwrap_or(0);
self.drain_encoder(start_ts)?;
// SAFETY: Sending null packet signals end-of-stream to BSF
unsafe { av_bsf_send_packet(self.bsf_ctx, ptr::null_mut()) };
loop {
let mut bsf_pkt = ff::Packet::empty();
let ret = unsafe {
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
};
if ret < 0 { break; }
let enc_tb = self.enc_video.time_base();
let stream_tb = unsafe {
let streams = (*self.octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
bsf_pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = bsf_pkt.pts() {
bsf_pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = bsf_pkt.dts() {
bsf_pkt.set_dts(Some(dts - start_ts));
}
bsf_pkt.set_stream(0);
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
anyhow::anyhow!("Failed to write BSF flush packet: {e}")
})?;
self.frames_written = true;
}
// Write trailer only if at least one frame was encoded.
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<()> {
let stream_index: i32 = 0;
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: error {ret}");
}
// SAFETY: av_bsf_send_packet sends the encoded packet through the BSF filter.
// On success, the BSF takes ownership of the packet data (via av_packet_move_ref).
let ret = unsafe { av_bsf_send_packet(self.bsf_ctx, pkt.as_mut_ptr()) };
if ret == ffi::AVERROR(ffi::EAGAIN) {
// BSF buffer full — break and retry next drain cycle
break;
}
if ret < 0 {
bail!("av_bsf_send_packet failed: error {ret}");
}
// Drain all BSF output packets
loop {
let mut bsf_pkt = ff::Packet::empty();
// SAFETY: av_bsf_receive_packet retrieves a BSF-processed packet.
let ret = unsafe {
av_bsf_receive_packet(self.bsf_ctx, bsf_pkt.as_mut_ptr())
};
if ret == ffi::AVERROR(ffi::EAGAIN) {
break; // No more output yet
}
if ret == ffi::AVERROR_EOF {
break; // BSF drained
}
if ret < 0 {
bail!("av_bsf_receive_packet failed: error {ret}");
}
// Rescale and offset on BSF output packet (NOT original pkt)
let enc_tb = self.enc_video.time_base();
let stream_tb = unsafe {
let streams = (*self.octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
bsf_pkt.rescale_ts(enc_tb, stream_tb);
if let Some(pts) = bsf_pkt.pts() {
bsf_pkt.set_pts(Some(pts - start_ts));
}
if let Some(dts) = bsf_pkt.dts() {
bsf_pkt.set_dts(Some(dts - start_ts));
}
bsf_pkt.set_stream(stream_index as usize);
bsf_pkt.write_interleaved(&mut self.octx).map_err(|e| {
anyhow::anyhow!("Failed to write packet: {e}")
})?;
self.frames_written = true;
}
}
Ok(())
}
}
impl Drop for EncState {
fn drop(&mut self) {
// SAFETY: av_bsf_free releases the BSF context and all associated resources.
// It handles null safely (returns immediately if *pctx is null).
if !self.bsf_ctx.is_null() {
unsafe { av_bsf_free(&mut self.bsf_ctx) };
}
}
}
// ---------------------------------------------------------------------------
// Filter graph (inline)
// ---------------------------------------------------------------------------
fn build_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
width: u32,
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 format_filter = ff::filter::find("format")
.ok_or_else(|| anyhow::anyhow!("filter 'format' 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_freep(par as *mut _ as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: error {ret}");
}
}
// format filter: negotiate pixel format to NV12
let mut fmt_ctx = graph.add(&format_filter, "fmt", "pix_fmts=nv12")?;
// scale_vaapi: hardware scaling and colourspace conversion
let mut scale_ctx = graph.add(&scale_vaapi, "scale", &format!("{width}:{height}"))?;
// SAFETY: scale_vaapi needs hw_device_ctx for VAAPI device access.
unsafe {
(*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
// buffersink
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
// Link: src -> format -> scale -> sink
src_ctx.link(0, &mut fmt_ctx, 0);
fmt_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph.validate().map_err(|e| {
anyhow::anyhow!("Filter graph validation failed: {e}")
})?;
Ok(graph)
}

64
src/cap_wlr_screencopy.rs Normal file
View File

@@ -0,0 +1,64 @@
use anyhow::Result;
use wayland_client::globals::GlobalList;
use wayland_client::protocol::wl_buffer::WlBuffer;
use wayland_client::protocol::wl_output::WlOutput;
use wayland_client::QueueHandle;
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use crate::state::{CaptureSource, OutputInfo, State};
/// wlr-screencopy capture backend.
///
/// Holds the current in-flight frame protocol object. The
/// `ZwlrScreencopyManagerV1` is stored separately in
/// `State::EverythingButFmt` because binding it requires a `Dispatch`
/// impl that lives in state.rs (T6b).
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()`.
pub current_frame: Option<ZwlrScreencopyFrameV1>,
}
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.
type Frame = ();
fn new(
_gm: &GlobalList,
_output: &WlOutput,
_output_info: &OutputInfo,
_qh: &QueueHandle<State<Self>>,
) -> Result<Self> {
// 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(Self {
current_frame: None,
})
}
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
}
fn queue_copy(&mut self, buffer: &WlBuffer, _qh: &QueueHandle<State<Self>>) {
if let Some(frame) = &self.current_frame {
frame.copy(buffer);
} else {
tracing::warn!("queue_copy: no current wlr-screencopy frame");
}
}
fn on_done_with_frame(&mut self, _frame: Self::Frame) {
if let Some(frame) = self.current_frame.take() {
frame.destroy();
}
}
}

77
src/fps_limit.rs Normal file
View File

@@ -0,0 +1,77 @@
use std::time::{Duration, Instant};
pub struct FpsLimit<T> {
on_deck: Option<(T, Instant)>,
min_interval: Duration,
}
impl<T> FpsLimit<T> {
pub fn new(fps: u32) -> Self {
Self {
on_deck: None,
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
}
}
/// Feed a new frame. Returns:
/// - Some(previous_frame) if enough time elapsed since previous frame
/// - None if frame is buffered (first frame) or previous is dropped (too close)
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
let old = self.on_deck.replace((frame, timestamp));
match old {
None => None, // First frame — buffer it
Some((old_frame, old_ts)) => {
if timestamp.duration_since(old_ts) >= self.min_interval {
Some(old_frame) // Enough time — output previous
} else {
None // Too close — discard previous, keep new
}
}
}
}
/// Flush the last buffered frame at end of stream
pub fn flush(&mut self) -> Option<T> {
self.on_deck.take().map(|(frame, _ts)| frame)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_frame_is_buffered() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
let result = limiter.on_new_frame(1u32, now);
assert!(result.is_none());
}
#[test]
fn frames_too_close_drops_old() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
assert!(result.is_none());
}
#[test]
fn frames_far_enough_output_old() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
let result = limiter.on_new_frame(2, now + Duration::from_millis(40));
assert_eq!(result, Some(1));
}
#[test]
fn flush_returns_last_buffered() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
assert_eq!(limiter.flush(), Some(1));
assert_eq!(limiter.flush(), None);
}
}

148
src/main.rs Normal file
View File

@@ -0,0 +1,148 @@
use std::os::unix::io::AsRawFd;
use anyhow::Result;
use clap::Parser;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token};
use wayland_client::globals::registry_queue_init;
use wayland_client::Connection;
mod args;
mod avhw;
mod cap_wlr_screencopy;
mod fps_limit;
mod state;
mod transform;
use crate::args::Args;
use crate::cap_wlr_screencopy::CapWlrScreencopy;
use crate::state::State;
const TOKEN_WAYLAND: Token = Token(0);
const TOKEN_QUIT: Token = Token(1);
fn main() -> Result<()> {
let args = Args::parse();
tracing_subscriber::fmt()
.with_max_level(if args.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
})
.init();
tracing::info!("wl-webrtc starting");
tracing::debug!("Args: {:?}", args);
if args.codec != "h264" {
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
}
// Connect to Wayland compositor
let conn = Connection::connect_to_env()?;
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
// Get the Wayland socket fd for mio polling.
// Use prepare_read() once to obtain the fd, then immediately drop the guard.
let wayland_fd = {
let guard = queue
.prepare_read()
.ok_or_else(|| anyhow::anyhow!("Failed to prepare Wayland read"))?;
guard.connection_fd().as_raw_fd()
};
// Create initial state
let qhandle = queue.handle();
let mut state = State::new(gm, args, qhandle);
// Dispatch initial round to bind all globals (screencopy manager, dmabuf, outputs)
queue.blocking_dispatch(&mut state)?;
// Set up mio event loop
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
// Register Wayland fd with mio
poll.registry().register(
&mut SourceFd(&wayland_fd),
TOKEN_WAYLAND,
Interest::READABLE,
)?;
// Register signal handler
let mut signals = signal_hook_mio::v1_0::Signals::new(&[
signal_hook::consts::SIGINT,
signal_hook::consts::SIGTERM,
])?;
poll.registry()
.register(&mut signals, TOKEN_QUIT, Interest::READABLE)?;
tracing::info!("Event loop started");
// Main event loop
let mut running = true;
while running {
// Wayland read pattern:
// 1. prepare_read() marks intent to read (also flushes outgoing)
// 2. poll() waits for data on Wayland fd or signals
// 3. If Wayland readable: read() consumes the guard, then dispatch_pending()
// 4. Dropping the guard without read() cancels the prepared read
let read_guard = queue.prepare_read();
poll.poll(&mut events, Some(std::time::Duration::from_millis(100)))
.unwrap_or_else(|e| {
tracing::error!("poll failed: {e}");
running = false;
});
for event in &events {
if event.token() == TOKEN_QUIT {
tracing::info!("Received quit signal");
running = false;
}
}
if events.iter().any(|e| e.token() == TOKEN_WAYLAND) {
if let Some(guard) = read_guard {
match guard.read() {
Ok(_) => {
queue.dispatch_pending(&mut state)?;
}
Err(e) => {
tracing::error!("Wayland read error: {e}");
running = false;
}
}
}
}
// If we didn't consume the read guard (no WAYLAND event), it drops here
// and cancels the prepared read. That's fine — we'll retry next iteration.
// After dispatch, try to start a new capture frame if we're in Streaming
// with no in-flight surface.
state.queue_alloc_frame();
// Check for fatal errors from the state machine
if state.errored {
tracing::error!("Fatal error in state machine, exiting");
running = false;
}
// Flush outgoing Wayland messages
conn.flush()?;
}
// Clean shutdown: flush encoder and write MP4 trailer
tracing::info!("Shutting down, flushing encoder...");
if let crate::state::EncConstructionStage::Streaming { enc, .. } = &mut state.stage {
if let Err(e) = enc.flush() {
tracing::error!("Failed to flush encoder: {e}");
}
}
tracing::info!("Done");
Ok(())
}

996
src/state.rs Normal file
View File

@@ -0,0 +1,996 @@
use std::mem;
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::io::FromRawFd;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::Result;
use wayland_client::globals::{GlobalList, GlobalListContents};
use wayland_client::protocol::wl_buffer::WlBuffer;
use wayland_client::protocol::wl_output::WlOutput;
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::{Dispatch, Proxy, QueueHandle};
use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_buffer_params_v1::{
Event as BufferParamsEvent, Flags as BufferParamsFlags, 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 wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{
Event as XdgOutputEvent, ZxdgOutputV1,
};
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 ffmpeg_next as ff;
use ffmpeg_next::ffi as ffi;
use crate::args::Args;
use crate::avhw::{AvHwDevCtx, EncState};
use crate::cap_wlr_screencopy::CapWlrScreencopy;
use crate::fps_limit::FpsLimit;
use crate::transform::Transform;
// ---------------------------------------------------------------------------
// 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 alloc_frame(&mut self) -> Option<Self::Frame>;
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,
pub physical_size: (i32, i32),
pub logical_position: (i32, i32),
}
pub struct PartialOutputInfo {
pub name: Option<String>,
pub transform: Option<Transform>,
pub physical_size: Option<(i32, i32)>,
pub logical_position: Option<(i32, i32)>,
// Pixel dimensions from Mode event — preparatory for Phase 2 resolution logic
pub mode_size: Option<(i32, i32)>,
pub done_count: u32,
}
impl Default for PartialOutputInfo {
fn default() -> Self {
Self {
name: None,
transform: None,
physical_size: None,
logical_position: None,
mode_size: None,
done_count: 0,
}
}
}
/// User data for XdgOutput dispatch to identify which WlOutput it belongs to.
pub struct OutputId(pub u32);
// ---------------------------------------------------------------------------
// EncConstructionStage
// ---------------------------------------------------------------------------
pub 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>,
},
EverythingButFmt {
output_info: OutputInfo,
output: WlOutput,
hw_device_ctx: AvHwDevCtx,
cap: S,
screencopy_manager: ZwlrScreencopyManagerV1,
dmabuf: ZwpLinuxDmabufV1,
},
Streaming {
output_info: OutputInfo,
output: WlOutput,
enc: EncState,
cap: S,
screencopy_manager: ZwlrScreencopyManagerV1,
dmabuf: ZwpLinuxDmabufV1,
},
Intermediate,
}
// ---------------------------------------------------------------------------
// InFlightSurface
// ---------------------------------------------------------------------------
pub enum InFlightSurface<S: CaptureSource> {
None,
AllocQueued,
Allocd(S::Frame),
CopyQueued {
surface: ff::frame::Video,
drm_map: ff::ffi::AVDRMFrameDescriptor,
frame: S::Frame,
buffer: WlBuffer,
},
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
pub struct State<S: CaptureSource> {
pub stage: EncConstructionStage<S>,
pub in_flight_surface: InFlightSurface<S>,
pub starting_timestamp: Option<i64>,
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>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Scan /dev/dri for the first available DRM render node (renderD*).
fn find_drm_render_node() -> Option<PathBuf> {
std::fs::read_dir("/dev/dri").ok()?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.starts_with("renderD"))
.unwrap_or(false)
})
.filter_map(|e| {
let path = e.path();
std::fs::metadata(&path).ok()?;
Some(path)
})
.min_by_key(|e| e.to_path_buf())
}
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>>) -> Self {
let fps = args.fps;
let drm_device = args.drm_device.as_ref().map(PathBuf::from);
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,
},
in_flight_surface: InFlightSurface::None,
starting_timestamp: None,
fps_limit: FpsLimit::new(fps),
args,
errored: false,
gm,
qhandle,
drm_device,
drm_device_from_compositor: None,
}
}
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_info: _,
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: error {}", 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;
}
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: error {}", 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");
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: 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,
{
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;
}
};
let pts = (tv_sec as i64) * 1_000_000 + (tv_usec as i64);
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 = self.fps_limit.on_new_frame(S::Frame::default(), Instant::now()).is_some();
if should_encode {
if let Err(e) = enc.encode_frame(&surface) {
tracing::error!("encode_frame failed: {}", e);
self.errored = true;
}
}
}
pub fn on_copy_fail(&mut self) {
tracing::error!("compositor copy failed");
self.errored = true;
}
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: _hw_device_ctx,
cap,
screencopy_manager,
dmabuf,
} => (output_info, output, cap, screencopy_manager, dmabuf),
other => {
tracing::warn!("negotiate_format: not in EverythingButFmt stage");
self.stage = other;
return;
}
};
let (output_info, output, cap, screencopy_manager, dmabuf) = stage_data;
let drm_path = self.resolve_drm_path();
let bitrate = self.args.bitrate.unwrap_or_else(|| {
let fps = self.args.fps as u64;
2 * (width as u64) * (height as u64) * fps / 100
});
let gop_size = self.args.gop_size.unwrap_or(self.args.fps);
let fps = self.args.fps;
let enc = match EncState::new(
&drm_path,
Path::new(&self.args.output),
width,
height,
bitrate,
gop_size,
fps,
) {
Ok(enc) => 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_info,
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, .. } => {
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 >= 2);
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 >= 2) {
if outputs.is_empty() {
return false;
}
Some(0)
} else {
None
};
match idx {
Some(i) => {
let info = &outputs[i];
if info.done_count < 2
|| info.name.is_none()
|| info.transform.is_none()
|| info.physical_size.is_none()
|| info.logical_position.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,
) = match probing {
EncConstructionStage::ProbingOutputs {
outputs,
bound_outputs,
output_names,
screencopy_manager,
dmabuf,
dmabuf_feedback,
xdg_output_manager,
} => (
outputs,
bound_outputs,
output_names,
screencopy_manager,
dmabuf,
dmabuf_feedback,
xdg_output_manager,
),
_ => 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().unwrap(),
transform: info.transform.unwrap(),
physical_size: info.physical_size.unwrap(),
logical_position: info.logical_position.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
}
}
// ---------------------------------------------------------------------------
// Dispatch<WlRegistry, GlobalListContents>
// ---------------------------------------------------------------------------
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, ());
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);
}
}
_ => {}
}
}
RegistryEvent::GlobalRemove { name } => {
tracing::debug!("Global removed: name={name}");
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<WlOutput, ()>
// ---------------------------------------------------------------------------
impl<S: CaptureSource> Dispatch<WlOutput, ()> for State<S> {
fn event(
state: &mut Self,
_proxy: &WlOutput,
event: wayland_client::protocol::wl_output::Event,
_data: &(),
_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 idx = match &mut state.stage {
EncConstructionStage::ProbingOutputs { outputs, .. } => {
outputs.len().saturating_sub(1)
}
_ => return,
};
match event {
OutputEvent::Geometry { transform, physical_width, physical_height, .. } => {
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);
info.physical_size = Some((physical_width, physical_height));
}
}
}
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 >= 2 {
state.try_finalize_output(idx);
}
}
}
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZxdgOutputV1, OutputId>
// ---------------------------------------------------------------------------
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::LogicalPosition { x, y } => {
if let EncConstructionStage::ProbingOutputs { outputs, .. } = &mut state.stage {
if let Some(info) = outputs.get_mut(idx) {
info.logical_position = Some((x, y));
}
}
}
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 >= 2 {
state.try_finalize_output(idx);
}
}
}
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZwpLinuxDmabufV1, ()>
// ---------------------------------------------------------------------------
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_t = u64::from_ne_bytes(dev_bytes);
let minor = (dev_t as u32) & 0xFFFFF;
let path = PathBuf::from(format!("/dev/dri/renderD{}", minor));
if path.exists() {
tracing::info!("Compositor DRM device: {} (dev_t: {})", path.display(), dev_t);
state.drm_device_from_compositor = Some(path);
} else {
tracing::warn!(
"Compositor reported DRM device {} (dev_t: {}) but path does not exist",
path.display(), dev_t
);
}
} else {
tracing::warn!("main_device event with unexpected data length: {}", device.len());
}
}
DmabufFeedbackEvent::FormatTable { .. } => {}
DmabufFeedbackEvent::Done => {}
DmabufFeedbackEvent::TrancheDone => {}
DmabufFeedbackEvent::TrancheTargetDevice { .. } => {}
DmabufFeedbackEvent::TrancheFormats { .. } => {}
DmabufFeedbackEvent::TrancheFlags { .. } => {}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZwpLinuxBufferParamsV1, ()>
// ---------------------------------------------------------------------------
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");
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZwlrScreencopyFrameV1, ()> for CapWlrScreencopy
// ---------------------------------------------------------------------------
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 {
ScreencopyFrameEvent::Buffer { .. } => {
tracing::warn!("Received SHM Buffer event — only DMA-BUF capture is supported. Ignoring.");
proxy.destroy();
state.errored = true;
return;
}
ScreencopyFrameEvent::LinuxDmabuf { format, width, height } => {
tracing::debug!("Screencopy LinuxDmabuf: format={format}, {width}x{height}");
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);
}
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 { .. } => {}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZxdgOutputManagerV1, ()>
// ---------------------------------------------------------------------------
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>>,
) {
}
}
// ---------------------------------------------------------------------------
// Dispatch<ZwlrScreencopyManagerV1, ()>
// ---------------------------------------------------------------------------
impl<S: 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>>,
) {
}
}
// ---------------------------------------------------------------------------
// Dispatch<WlBuffer, ()>
// ---------------------------------------------------------------------------
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");
}
}
}

291
src/transform.rs Normal file
View File

@@ -0,0 +1,291 @@
/// 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 {
Normal,
Normal90,
Normal180,
Normal270,
Flipped,
Flipped90,
Flipped180,
Flipped270,
}
/// 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,
}
/// 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 transform {
Transform::Normal => (1, 0, 0, 1),
Transform::Normal90 => (0, 1, -1, 0),
Transform::Normal180 => (-1, 0, 0, -1),
Transform::Normal270 => (0, -1, 1, 0),
Transform::Flipped => (-1, 0, 0, 1),
Transform::Flipped90 => (0, 1, 1, 0),
Transform::Flipped180 => (1, 0, 0, -1),
Transform::Flipped270 => (0, -1, -1, 0),
}
}
/// 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 {
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).
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;
Rect {
x: new_x,
y: new_y,
w: new_w.abs(),
h: new_h.abs(),
}
}
/// 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 transform {
Transform::Normal90 | Transform::Normal270 | Transform::Flipped90 | Transform::Flipped270 => {
(h, w)
}
_ => (w, h),
}
}
/// 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 {
let x = rect.x.clamp(0, bounds_w);
let y = rect.y.clamp(0, bounds_h);
let right = (rect.x + rect.w).min(bounds_w);
let bottom = (rect.y + rect.h).min(bounds_h);
let w = (right - x).max(0);
let h = (bottom - y).max(0);
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]
fn transpose_normal_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal, 1920, 1080), (1920, 1080));
}
#[test]
fn transpose_90_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal90, 1920, 1080), (1080, 1920));
}
#[test]
fn transpose_180_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal180, 1920, 1080), (1920, 1080));
}
#[test]
fn transpose_270_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal270, 1920, 1080), (1080, 1920));
}
#[test]
fn transpose_flipped_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped, 1920, 1080), (1920, 1080));
}
#[test]
fn transpose_flipped90_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped90, 1920, 1080), (1080, 1920));
}
#[test]
fn transpose_flipped180_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped180, 1920, 1080), (1920, 1080));
}
#[test]
fn transpose_flipped270_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped270, 1920, 1080), (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 });
}
}

103
tests/integration_test.rs Normal file
View File

@@ -0,0 +1,103 @@
use std::process::Command;
/// Helper: get the binary path. Uses the release build if available.
fn bin_path() -> &'static str {
"target/release/wl-webrtc"
}
#[test]
fn test_help_flag() {
let output = Command::new(bin_path())
.arg("--help")
.output()
.expect("failed to execute wl-webrtc --help");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "--help should exit 0");
assert!(
stdout.contains("output"),
"help output should mention 'output'"
);
assert!(stdout.contains("fps"), "help output should mention 'fps'");
assert!(
stdout.contains("codec"),
"help output should mention 'codec'"
);
assert!(
stdout.contains("bitrate"),
"help output should mention 'bitrate'"
);
assert!(
stdout.contains("gop-size"),
"help output should mention 'gop-size'"
);
assert!(
stdout.contains("drm-device"),
"help output should mention 'drm-device'"
);
}
#[test]
fn test_rejects_invalid_args() {
let output = Command::new(bin_path())
.arg("--nonexistent-flag-xyz")
.output()
.expect("failed to execute wl-webrtc with invalid args");
assert!(
!output.status.success(),
"should reject unrecognized flag"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.to_lowercase().contains("error")
|| stderr.to_lowercase().contains("unexpected")
|| stderr.to_lowercase().contains("unrecognized"),
"stderr should mention the error, got: {stderr}"
);
}
#[test]
fn test_rejects_hevc_codec() {
let output = Command::new(bin_path())
.arg("--output")
.arg("/dev/null")
.arg("--codec")
.arg("hevc")
.output()
.expect("failed to execute wl-webrtc --codec hevc");
// MVP only supports h264; hevc should be rejected.
assert!(
!output.status.success(),
"should reject hevc codec in MVP"
);
}
/// Tests requiring a live Wayland compositor and VAAPI hardware.
/// Run with: cargo test -- --ignored
#[test]
#[ignore]
fn test_capture_starts_with_valid_output() {
// This test requires:
// - A running Wayland compositor
// - VAAPI-compatible GPU hardware
// - A writable output path
let output = Command::new(bin_path())
.arg("--output")
.arg("/tmp/wl-webrtc-test-capture.mp4")
.arg("--fps")
.arg("10")
.output()
.expect("failed to start wl-webrtc");
// If we get here, the process ran and exited.
// In a real test, we'd give it time to capture a few frames
// and then send SIGINT, but for now just check it didn't
// immediately fail with an argument error.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("error: unexpected") && !stderr.contains("error: invalid"),
"should not have argument parsing errors, got: {stderr}"
);
}