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:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
873
Cargo.lock
generated
Normal 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
21
Cargo.toml
Normal 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
56
README.md
Normal 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
443
analysis.md
Normal 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 modifier,Vulkan 模式接受任意 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` 路径:捕获仍用 GPU(DMA-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 使用模式 4(Pin\<Box\>)并被模式 6(帧率控制)调节。
|
||||||
2518
docs/superpowers/plans/2026-04-03-wl-webrtc-phase1.md
Normal file
2518
docs/superpowers/plans/2026-04-03-wl-webrtc-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
18
shell.nix
Normal 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
45
src/args.rs
Normal 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
672
src/avhw.rs
Normal 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
64
src/cap_wlr_screencopy.rs
Normal 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
77
src/fps_limit.rs
Normal 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
148
src/main.rs
Normal 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
996
src/state.rs
Normal 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
291
src/transform.rs
Normal 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
103
tests/integration_test.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user