lamco-pipewire-0.4.0/.cargo_vcs_info.json0000644000000001631046102023000137740ustar { "git": { "sha1": "74b10c51f90821808b03c411c54848ddbf8b425c" }, "path_in_vcs": "crates/lamco-pipewire" }lamco-pipewire-0.4.0/CHANGELOG.md000064400000000000000000000210671046102023000143610ustar 00000000000000# Changelog All notable changes to lamco-pipewire will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.4.0] - 2026-03-26 ### Added - **DMA-BUF zero-copy types**: `FrameBuffer` enum with `Memory` and `DmaBuf` variants - `DmaBufDescriptor` and `DmaBufPlane` types with `OwnedFd` for GPU buffer passthrough - `dmabuf_passthrough` config flag for opting into zero-copy frame delivery - `VideoFrame::data()` accessor method for backward-compatible CPU pixel access - `VideoFrame::is_dmabuf()` convenience check ### Changed - **Breaking**: `VideoFrame.data: Arc>` replaced with `VideoFrame.buffer: FrameBuffer` - Consumers accessing pixel data should use `frame.data()` (returns `Option<&Arc>>`) or match on `frame.buffer` directly for DMA-BUF handling ### Fixed - Pre-existing clippy warnings: added safety comments to unsafe blocks in process callback - Replaced deprecated `map_or` with `is_none_or` in damage detector ## [0.3.3] - 2026-03-15 ### Changed - Bump to Rust edition 2024, minimum supported Rust version 1.85 ### Fixed - **DmaBuf format negotiation restored**: The dual-pod MANDATORY|DONT_FIXATE pattern (introduced in 0.1.6) was lost during the 0.3.x rewrite when `build_stream_parameters()` was simplified from `Vec>` to `Vec`. Restored proper negotiation: first pod with VideoModifier and MANDATORY|DONT_FIXATE for DmaBuf, second pod as SHM fallback without modifier. - Enabled `v0_3_33` feature on pipewire/libspa deps for `PropertyFlags::DONT_FIXATE` ## [0.3.2] - 2026-03-12 ### Fixed - **SIGSEGV on MemFd buffer copy**: PipeWire's MAP_BUFFERS auto-mapping can produce stale pointers for MemFd buffers received via portal FD connections (observed with XDPH on PipeWire 1.6.1). MemFd handler now always uses manual `mmap_fd_buffer()` instead of relying on `data.data()`. Affects any portal backend that provides MemFd buffers (not compositor-specific). - Fixed clippy `similar_names` warning in process callback variable naming ## [0.3.1] - 2026-03-08 ### Fixed - Add DRIVER stream flag for proper PipeWire scheduling - Parse negotiated format from `param_changed` callback for accurate buffer handling ## [0.3.0] - 2026-03-04 ### Changed - **BREAKING**: Upgrade to PipeWire 0.9 / libspa 0.9 bindings - **BREAKING**: Upgrade to zbus 5 for D-Bus integration - StreamTime FFI improvements - Audio capture support (behind `audio` feature) - Direct frame channel adapter for non-PipeWire capture paths ## [0.2.0] - 2026-02-26 ### Changed - **BREAKING**: Public API now takes `OwnedFd` instead of `RawFd` - `PipeWireThreadManager::new(fd: OwnedFd)` (was `RawFd`) - `PipeWireConnection::new(fd: OwnedFd)` (was `RawFd`) - `PipeWireManager::connect(&mut self, fd: OwnedFd)` (was `RawFd`) - `PipeWireConnection::fd()` now returns `Option` (None after connect consumes it) - Removed all internal `unsafe { OwnedFd::from_raw_fd() }` — callers own the FD from the start - Internal buffer FDs remain `RawFd` (borrowed from PipeWire, not owned by us) ### Migration Callers that previously passed a raw integer now pass an `OwnedFd`: ```rust // Before (0.1.x) manager.connect(portal_fd).await?; // After (0.2.0) use std::os::fd::OwnedFd; manager.connect(owned_fd).await?; ``` ## [0.1.6] - 2026-02-26 ### Changed - **PipeWire 1.x MANDATORY flag for DmaBuf negotiation** - Format negotiation now produces two `EnumFormat` params when `use_dmabuf=true`: first with `MANDATORY | DONT_FIXATE` modifier property for DmaBuf, second without modifier for SHM fallback - PipeWire tries DmaBuf first and falls back to SHM if hardware can't satisfy it - Existing behavior preserved when `use_dmabuf=false` (SHM-only param) - Enabled `pipewire/v0_3_33` and `libspa/v0_3_33` features for `PropertyFlags::DONT_FIXATE` ## [0.1.5] - 2026-02-26 ### Changed - Downgraded per-frame logging from `info!` to `trace!` in the process() callback - `mmap_fd_buffer()` entry/exit logging → `trace!` - `process() callback fired` → `trace!` - `Got buffer from stream` → `trace!` - Per-buffer type/size/offset/fd logging → `trace!` - MemPtr/MemFd copy logging → `trace!` - MemFd manual mmap logging → `trace!` - DMA-BUF first-time mmap and cache logging → `debug!` - Main loop heartbeat (every 1000 iterations) → `debug!` - One-time messages (stream created, format negotiated, state changes) remain at `info!` ### Added - **Stream state push notifications** via `StreamStateEvent` channel - `PipeWireThreadManager::try_recv_state_event()` — non-blocking state poll - `PipeWireThreadManager::drain_state_events()` — drain all pending events - `StreamStateSnapshot` — Send-safe enum mirroring PipeWire stream states - Enables health monitoring without polling via `GetStreamState` commands - Events pushed from PipeWire thread's `state_changed` callback ## [0.1.4] - 2026-01-15 ### Fixed - Handle PipeWire size=0 "skip" frames gracefully - MemFd buffers with size=0 now logged and ignored instead of causing mmap failures - DmaBuf buffers with size=0 now logged and ignored instead of causing mmap failures - Eliminates "Invalid map size" errors during periods of no screen activity ### Changed - Removed emojis from log messages for professional consistency ## [0.1.3] - 2025-12-23 ### Changed - Removed `stream.set_active()` call - let AUTOCONNECT flag handle activation - Use `PW_ID_ANY` (None) instead of explicit node_id for portal streams ### Added - Enhanced debug logging throughout stream lifecycle - Periodic heartbeat logging (every 1000 iterations) - Comprehensive stream state change logging ## [0.1.2] - 2025-12-17 ### Fixed - Added `#![cfg_attr(docsrs, feature(doc_cfg))]` for proper docs.rs conditional documentation - Converted to workspace package inheritance (edition, rust-version, license, homepage, repository, authors) - Fixed code formatting across the crate ### Added - Added LICENSE-MIT and LICENSE-APACHE files to crate directory - Added CHANGELOG.md ### Note - docs.rs builds will fail for this crate because it requires `libpipewire-0.3` system library which is not available in the docs.rs build environment. This is expected and unavoidable. ## [0.1.1] - 2025-12-15 ### Added - Initial release on crates.io - **`PipeWireManager`** - High-level Send + Sync wrapper for PipeWire - Stream creation and lifecycle management - Frame receiver channels for async frame access - Multi-stream support with coordinator - Automatic reconnection and error recovery - **`PipeWireConfig`** - Configuration builder - Buffer count and format preferences - DMA-BUF enable/disable - Cursor and damage tracking options - Quality presets for different use cases - **`VideoFrame`** - Captured frame with metadata - DMA-BUF and memory-mapped buffer support - Pixel format and stride information - Timestamp and damage regions - **`MultiStreamCoordinator`** - Multi-monitor handling - Concurrent stream management - Frame synchronization - Monitor hotplug detection - **`FrameDispatcher`** - Priority-based frame routing - Backpressure handling - Load balancing across streams - **YUV conversion utilities** (with `yuv` feature) - NV12, I420, YUY2 to BGRA conversion - **Hardware cursor extraction** (with `cursor` feature) - **Damage region tracking** (with `damage` feature) - **Adaptive bitrate control** (with `adaptive` feature) - Typed error handling with `PipeWireError` - Error classification for recovery decisions ### Architecture - Dedicated PipeWire thread for non-Send types - Command-based communication with async runtime - Channel-based frame delivery ### Platform Support - Linux only (Wayland required, PipeWire required) - Tested on GNOME, KDE Plasma, Sway [Unreleased]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.2.0...HEAD [0.2.0]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.6...lamco-pipewire-v0.2.0 [0.1.6]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.5...lamco-pipewire-v0.1.6 [0.1.5]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.4...lamco-pipewire-v0.1.5 [0.1.4]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.3...lamco-pipewire-v0.1.4 [0.1.3]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.2...lamco-pipewire-v0.1.3 [0.1.2]: https://github.com/lamco-admin/lamco-wayland/compare/lamco-pipewire-v0.1.1...lamco-pipewire-v0.1.2 [0.1.1]: https://github.com/lamco-admin/lamco-wayland/releases/tag/lamco-pipewire-v0.1.1 lamco-pipewire-0.4.0/Cargo.lock0000644000000526751046102023000117660ustar # 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 = "annotate-snippets" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" dependencies = [ "anstyle", "unicode-width", ] [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", "bitflags", "cexpr", "clang-sys", "itertools", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn", ] [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "cc" version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" 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 7.1.3", ] [[package]] name = "cfg-expr" version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[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 = "convert_case" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ "unicode-segmentation", ] [[package]] name = "cookie-factory" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown", ] [[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "lamco-pipewire" version = "0.4.0" dependencies = [ "anyhow", "futures", "libc", "libspa", "libspa-sys", "nix", "parking_lot", "pipewire", "thiserror 1.0.69", "tokio", "tracing", "tracing-subscriber", ] [[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.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[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 = "libspa" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" dependencies = [ "bitflags", "cc", "convert_case", "cookie-factory", "libc", "libspa-sys", "nix", "nom 8.0.0", "system-deps", ] [[package]] name = "libspa-sys" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" dependencies = [ "bindgen", "cc", "system-deps", ] [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags", "cfg-if", "cfg_aliases", "libc", ] [[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 = "nom" version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", ] [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ "windows-sys", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-link", ] [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipewire" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" dependencies = [ "anyhow", "bitflags", "libc", "libspa", "libspa-sys", "nix", "once_cell", "pipewire-sys", "thiserror 2.0.17", ] [[package]] name = "pipewire-sys" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" dependencies = [ "bindgen", "libspa-sys", "system-deps", ] [[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.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] [[package]] name = "regex" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_spanned" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] [[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 = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "syn" version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "system-deps" version = "7.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" dependencies = [ "cfg-expr", "heck", "pkg-config", "toml", "version-compare", ] [[package]] name = "target-lexicon" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl 2.0.17", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thiserror-impl" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", "syn", ] [[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 = "tokio" version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "pin-project-lite", "tokio-macros", ] [[package]] name = "tokio-macros" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "toml" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", "toml_writer", "winnow", ] [[package]] name = "toml_datetime" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tracing" version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" 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.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "sharded-slab", "smallvec", "thread_local", "tracing-core", "tracing-log", ] [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[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.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" lamco-pipewire-0.4.0/Cargo.toml0000644000000070261046102023000117770ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2024" rust-version = "1.85" name = "lamco-pipewire" version = "0.4.0" authors = ["Greg Lamberson "] build = false exclude = [ "/.github/", "/tests/", "*.orig", "*.original", "*.bak", ] autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "High-performance PipeWire screen capture for Wayland with DMA-BUF support, by Lamco Development" homepage = "https://lamco.ai" documentation = "https://docs.rs/lamco-pipewire" readme = "README.md" keywords = [ "pipewire", "wayland", "screen-capture", "dmabuf", "video", ] categories = [ "multimedia::video", "os::unix-apis", "asynchronous", "api-bindings", ] license = "MIT OR Apache-2.0" repository = "https://github.com/lamco-admin/lamco-wayland" resolver = "2" [package.metadata.docs.rs] all-features = true targets = ["x86_64-unknown-linux-gnu"] rustdoc-args = [ "--cfg", "docsrs", ] [badges.maintenance] status = "actively-developed" [features] adaptive = [] audio = ["dep:anyhow"] cursor = [] damage = [] default = ["dmabuf"] dmabuf = [] full = [ "dmabuf", "yuv", "cursor", "damage", "adaptive", "audio", ] yuv = [] [lib] name = "lamco_pipewire" path = "src/lib.rs" [[example]] name = "basic" path = "examples/basic.rs" [[example]] name = "multi_monitor" path = "examples/multi_monitor.rs" [[example]] name = "streaming" path = "examples/streaming.rs" [[example]] name = "with_damage" path = "examples/with_damage.rs" [dependencies.anyhow] version = "1.0" optional = true [dependencies.futures] version = "0.3" [dependencies.libc] version = "0.2" [dependencies.libspa] version = "0.9" features = ["v0_3_33"] [dependencies.libspa-sys] version = "0.9" [dependencies.nix] version = "0.30" features = ["mman"] [dependencies.parking_lot] version = "0.12" [dependencies.pipewire] version = "0.9" features = ["v0_3_33"] [dependencies.thiserror] version = "1.0" [dependencies.tokio] version = "1" features = [ "sync", "rt", "time", ] [dependencies.tracing] version = "0.1" [dev-dependencies.tokio] version = "1" features = [ "sync", "rt", "time", "macros", "rt-multi-thread", ] [dev-dependencies.tracing-subscriber] version = "0.3" [lints.clippy] arc_with_non_send_sync = "allow" as_conversions = "allow" cast_lossless = "allow" cast_possible_truncation = "allow" cast_possible_wrap = "allow" large_futures = "warn" manual_div_ceil = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" missing_safety_doc = "warn" module_name_repetitions = "allow" multiple_unsafe_ops_per_block = "allow" must_use_candidate = "allow" or_fun_call = "warn" panic = "warn" rc_buffer = "warn" should_implement_trait = "allow" similar_names = "warn" too_many_arguments = "allow" undocumented_unsafe_blocks = "warn" unwrap_used = "allow" wildcard_imports = "warn" [lints.rust] elided_lifetimes_in_paths = "warn" invalid_reference_casting = "warn" single_use_lifetimes = "warn" unreachable_pub = "warn" unsafe_code = "allow" unsafe_op_in_unsafe_fn = "warn" unused_unsafe = "warn" lamco-pipewire-0.4.0/Cargo.toml.orig000064400000000000000000000136631046102023000154420ustar 00000000000000[package] # ============================================================================ # PACKAGE IDENTITY # ============================================================================ name = "lamco-pipewire" version = "0.4.0" # ============================================================================ # PACKAGE CONFIGURATION (inherited from workspace) # ============================================================================ edition.workspace = true rust-version.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true authors.workspace = true # ============================================================================ # CRATE-SPECIFIC METADATA # ============================================================================ description = "High-performance PipeWire screen capture for Wayland with DMA-BUF support, by Lamco Development" documentation = "https://docs.rs/lamco-pipewire" readme = "README.md" # Keywords for crates.io search (max 5, max 20 chars each) keywords = ["pipewire", "wayland", "screen-capture", "dmabuf", "video"] # Categories for crates.io browsing (max 5) # See: https://crates.io/category_slugs categories = [ "multimedia::video", # Video capture and processing "os::unix-apis", # Linux/Unix specific functionality "asynchronous", # Async/await API design "api-bindings", # Wrapper around PipeWire C library ] # ============================================================================ # PUBLISHING CONFIGURATION # ============================================================================ # Files to exclude from the published crate (reduces download size) exclude = [ "/.github/", "/tests/", "*.orig", "*.original", "*.bak", ] # ============================================================================ # BADGES (maintenance status) # ============================================================================ [badges] maintenance = { status = "actively-developed" } # ============================================================================ # DOCS.RS CONFIGURATION # ============================================================================ [package.metadata.docs.rs] # Build docs with all features enabled for comprehensive documentation all-features = true # docs.rs is Linux-only, which matches our target targets = ["x86_64-unknown-linux-gnu"] # Enable docsrs cfg for conditional documentation rustdoc-args = ["--cfg", "docsrs"] # ============================================================================ # FEATURES # ============================================================================ [features] default = ["dmabuf"] # DMA-BUF zero-copy buffer support (requires compatible GPU) dmabuf = [] # YUV format conversion utilities (NV12, I420, YUY2 to BGRA) yuv = [] # Hardware cursor extraction from PipeWire metadata cursor = [] # Region damage tracking for efficient encoding damage = [] # Audio capture via PipeWire audio = ["dep:anyhow"] # Adaptive bitrate helpers for streaming scenarios adaptive = [] # Enable all optional features full = ["dmabuf", "yuv", "cursor", "damage", "adaptive", "audio"] # ============================================================================ # LINTS (manual - requires numeric cast overrides for low-level code) # PipeWire integration requires extensive numeric conversions for buffer # sizes, strides, formats, and coordinates. # ============================================================================ [lints.rust] # Unsafe code is expected and extensively used in this low-level PipeWire crate # for FFI bindings, buffer management, and mmap operations unsafe_code = "allow" unsafe_op_in_unsafe_fn = "warn" invalid_reference_casting = "warn" unused_unsafe = "warn" elided_lifetimes_in_paths = "warn" single_use_lifetimes = "warn" unreachable_pub = "warn" [lints.clippy] # Unsafe code documentation - allow multiple ops per block for closely related operations undocumented_unsafe_blocks = "warn" multiple_unsafe_ops_per_block = "allow" missing_safety_doc = "warn" # Numeric casts - ALLOWED for low-level PipeWire code as_conversions = "allow" cast_lossless = "allow" cast_possible_truncation = "allow" cast_possible_wrap = "allow" # Correctness - unwrap_used allowed because all uses are on mutex locks # which only fail when poisoned (thread panicked while holding lock) unwrap_used = "allow" panic = "warn" # Style and readability similar_names = "warn" wildcard_imports = "warn" # Performance large_futures = "warn" rc_buffer = "warn" or_fun_call = "warn" # Relaxed lints missing_errors_doc = "allow" missing_panics_doc = "allow" module_name_repetitions = "allow" must_use_candidate = "allow" # PipeWire intentionally uses Arc with non-Send types due to thread architecture arc_with_non_send_sync = "allow" # Functions with many args are acceptable for low-level FFI-style code too_many_arguments = "allow" # Manual div_ceil for compatibility (stdlib version requires Rust 1.73+) manual_div_ceil = "allow" # Allow methods named 'clone' in builder pattern contexts should_implement_trait = "allow" # ============================================================================ # DEPENDENCIES # ============================================================================ [dependencies] # PipeWire bindings # v0_3_33 enables PropertyFlags::DONT_FIXATE for DmaBuf modifier negotiation pipewire = { version = "0.9", features = ["v0_3_33"] } libspa = { version = "0.9", features = ["v0_3_33"] } libspa-sys = "0.9" # Async runtime tokio = { version = "1", features = ["sync", "rt", "time"] } # Futures utilities (for block_on in sync contexts) futures = "0.3" # Logging tracing = "0.1" # Error handling thiserror = "1.0" anyhow = { version = "1.0", optional = true } # Concurrency parking_lot = "0.12" # Low-level operations libc = "0.2" nix = { version = "0.30", features = ["mman"] } [dev-dependencies] tokio = { version = "1", features = ["sync", "rt", "time", "macros", "rt-multi-thread"] } tracing-subscriber = "0.3" lamco-pipewire-0.4.0/LICENSE-APACHE000064400000000000000000000013361046102023000144710ustar 00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ Copyright 2025 Lamco Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. lamco-pipewire-0.4.0/LICENSE-MIT000064400000000000000000000020461046102023000142000ustar 00000000000000MIT License Copyright (c) 2025 Lamco Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. lamco-pipewire-0.4.0/PUBLICATION_PLAN.md000064400000000000000000000204451046102023000154540ustar 00000000000000# lamco-pipewire Publication Plan ## Current Status: Implementation Complete All planned features have been implemented. **74 unit tests + 6 doc tests passing.** --- ## Completion Summary ### Phase 1: Foundation - COMPLETE | Task | Status | Notes | |------|--------|-------| | Linting alignment | Done | Workspace-compatible lints in Cargo.toml | | Config builder pattern | Done | `PipeWireConfig::builder()` API | | Unified PipeWireManager | Done | Single entry point with thread abstraction | ### Phase 2: Features - COMPLETE | Feature | Status | Module | Notes | |---------|--------|--------|-------| | DMA-BUF support | Done | (default) | Zero-copy frame transfer | | YUV conversion | Done | `src/yuv.rs` | NV12, I420, YUY2 to BGRA | | Cursor extraction | Done | `src/cursor.rs` | Hardware cursor with stats | | Damage tracking | Done | `src/damage.rs` | Region-based change detection | | Adaptive bitrate | Done | `src/bitrate.rs` | Network-aware bitrate control | ### Phase 3: Polish - COMPLETE | Task | Status | Notes | |------|--------|-------| | Documentation | Done | Comprehensive lib.rs with architecture diagram | | Examples | Done | 4 examples created | | String renaming | Done | "wrd-capture" → "lamco-pw" | | Test fixes | Done | All tests passing | --- ## Feature Flags ```toml [features] default = ["dmabuf"] dmabuf = [] # DMA-BUF zero-copy support yuv = [] # YUV format conversion utilities cursor = [] # Hardware cursor extraction damage = [] # Region damage tracking adaptive = [] # Adaptive bitrate helpers full = ["dmabuf", "yuv", "cursor", "damage", "adaptive"] ``` ### Feature Descriptions #### `dmabuf` (default) Zero-copy frame transfer using DMA-BUF file descriptors. When available, frames are passed directly from the compositor's GPU buffer to your application without CPU-side memory copies. This is the primary performance optimization for screen capture. **Use case:** Any screen capture application wanting best performance. #### `yuv` YUV to RGB color format conversion utilities. PipeWire may provide frames in compressed YUV formats (NV12, I420, YUY2) depending on the compositor and hardware encoder. These utilities convert to BGRA for display or further processing. **Functions:** - `nv12_to_bgra()` - YUV 4:2:0 with interleaved UV - `i420_to_bgra()` - YUV 4:2:0 with separate U/V planes - `yuy2_to_bgra()` - YUV 4:2:2 packed - `YuvConverter` - Format-detecting converter **Use case:** Applications that receive YUV frames and need RGB for display/encoding. #### `cursor` Hardware cursor extraction from PipeWire streams. The compositor provides cursor metadata separately from the frame buffer, allowing efficient cursor handling without re-encoding the entire frame. **Types:** - `CursorInfo` - Position, hotspot, size, bitmap, visibility - `CursorExtractor` - Stateful extractor with caching - `CursorStats` - Update/change tracking **Use case:** Remote desktop applications that need to track/transmit cursor separately. #### `damage` Region-based change detection. PipeWire can report which regions of the screen changed between frames, allowing partial updates instead of full-frame encoding. **Types:** - `DamageRegion` - Rectangle with x, y, width, height - `DamageTracker` - Accumulates and merges damage regions - `DamageStats` - Region count and area tracking **Methods:** - `should_full_update()` - Returns true if damage exceeds threshold - `bounding_box()` - Single rectangle encompassing all damage - `damage_ratio()` - Fraction of frame that changed **Use case:** Video encoders that support partial updates (H.264 ROI, etc.) #### `adaptive` Network-aware bitrate control for streaming scenarios. Tracks frame encoding times and network feedback to recommend bitrate adjustments. **Types:** - `BitrateController` - Main controller with recommendation logic - `BitrateStats` - Frames recorded, dropped, skipped - `QualityPreset` - LowLatency, Balanced, HighQuality **Methods:** - `record_frame()` - Log encode time and frame size - `record_network_feedback()` - Log packet loss and RTT - `recommended_bitrate()` - Current recommended bitrate - `should_skip_frame()` - True if congestion detected **Use case:** Streaming applications that need to adapt to network conditions. --- ## Files Created ### New Modules | File | Purpose | |------|---------| | `src/config.rs` | Configuration structs and builders | | `src/manager.rs` | Unified PipeWireManager API | | `src/yuv.rs` | YUV format conversion | | `src/cursor.rs` | Hardware cursor extraction | | `src/damage.rs` | Region damage tracking | | `src/bitrate.rs` | Adaptive bitrate control | ### Examples | File | Purpose | |------|---------| | `examples/basic.rs` | Simple single-stream capture | | `examples/multi_monitor.rs` | Multi-monitor coordination | | `examples/with_damage.rs` | Damage tracking demonstration | | `examples/streaming.rs` | Adaptive bitrate for streaming | ### Modified Files - `Cargo.toml` - Lints, features, dependencies - `src/lib.rs` - Documentation, module declarations, re-exports - `src/stream.rs` - Renamed "wrd-capture" → "lamco-pw" - `src/pw_thread.rs` - Renamed "wrd-capture" → "lamco-pw" - `src/buffer.rs` - Fixed unsafe block warnings --- ## Test Results ``` test result: ok. 74 passed; 0 failed; 1 ignored; 0 measured Doc-tests: test result: ok. 6 passed; 0 failed; 8 ignored; 0 measured ``` --- ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────┐ │ Tokio Async Runtime │ │ │ │ Your Application → PipeWireManager │ │ (Send + Sync wrapper) │ │ │ │ │ │ Commands via mpsc │ │ ▼ │ └───────────────────────────┼─────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────┐ │ Dedicated PipeWire Thread │ │ (std::thread - owns all non-Send types) │ │ │ │ MainLoop (Rc) ─> Context (Rc) ─> Core (Rc) │ │ │ │ │ ▼ │ │ Streams (NonNull) │ │ │ │ │ ▼ │ │ Frame Callbacks │ │ │ │ │ │ Frames via mpsc │ └──────────────────────────────────────┼──────────────────┘ │ ▼ Your application receives frames ``` --- ## Outstanding Questions for Discussion 1. **Feature granularity**: Are these the right feature boundaries? Should any be combined or split further? 2. **Default features**: Currently only `dmabuf` is default. Should `yuv` be default since YUV frames are common? 3. **Performance vs. correctness**: The YUV conversion is reference implementation (correct but not optimized). Should we add SIMD or note this more prominently? 4. **Cursor feature scope**: Currently basic extraction. Should it include cursor shape caching across sessions? 5. **Damage tracking threshold**: Default is 40% for full-frame fallback. Is this the right default? 6. **Adaptive bitrate presets**: Are LowLatency/Balanced/HighQuality the right preset names? --- ## Next Steps - [ ] Feature discussion with maintainer - [ ] Review API surface for publication - [ ] Final documentation review - [ ] Publish to crates.io lamco-pipewire-0.4.0/PUBLICATION_STATUS.md000064400000000000000000000031161046102023000157410ustar 00000000000000# lamco-pipewire Publication Status ## Published **Version:** 0.1.0 **Date:** 2025-12-15 **Registry:** crates.io ## Links - **Crate:** https://crates.io/crates/lamco-pipewire - **Documentation:** https://docs.rs/lamco-pipewire - **Repository:** https://github.com/lamco-admin/lamco-wayland ## Package Metrics | Metric | Value | |--------|-------| | Files packaged | 26 | | Uncompressed size | 290 KB | | Compressed size | 69 KB | | Unit tests | 74 | | Doc tests | 6 | | Warnings | 24 (all unsafe-related, expected) | ## Features | Feature | Default | Description | |---------|---------|-------------| | `dmabuf` | Yes | DMA-BUF zero-copy support | | `yuv` | No | YUV format conversion utilities | | `cursor` | No | Hardware cursor extraction | | `damage` | No | Region damage tracking | | `adaptive` | No | Adaptive bitrate control | | `full` | No | All features enabled | ## New Modules Created - `src/config.rs` - Configuration structs and builders - `src/manager.rs` - Unified PipeWireManager API - `src/yuv.rs` - YUV format conversion - `src/cursor.rs` - Hardware cursor extraction - `src/damage.rs` - Region damage tracking - `src/bitrate.rs` - Adaptive bitrate control ## Examples - `examples/basic.rs` - Simple single-stream capture - `examples/multi_monitor.rs` - Multi-monitor coordination - `examples/with_damage.rs` - Damage tracking demonstration - `examples/streaming.rs` - Adaptive bitrate for streaming ## Related Crates | Crate | Version | Status | |-------|---------|--------| | lamco-portal | 0.1.0 | Published | | lamco-pipewire | 0.1.0 | Published | | lamco-video | - | Pending | lamco-pipewire-0.4.0/README.md000064400000000000000000000140061046102023000140220ustar 00000000000000# lamco-pipewire High-performance PipeWire integration for Wayland screen capture with DMA-BUF support. [![Crates.io](https://img.shields.io/crates/v/lamco-pipewire.svg)](https://crates.io/crates/lamco-pipewire) [![Documentation](https://docs.rs/lamco-pipewire/badge.svg)](https://docs.rs/lamco-pipewire) [![License](https://img.shields.io/crates/l/lamco-pipewire.svg)](LICENSE-MIT) ## Features - **Zero-Copy DMA-BUF**: Hardware-accelerated frame transfer when available - **Multi-Monitor**: Concurrent handling of multiple monitor streams - **Format Negotiation**: Automatic format selection with fallbacks - **YUV Conversion**: Built-in NV12, I420, YUY2 to BGRA conversion - **Cursor Extraction**: Separate cursor tracking for remote desktop - **Damage Tracking**: Region-based change detection for efficient encoding - **Adaptive Bitrate**: Network-aware bitrate control for streaming - **Error Recovery**: Automatic reconnection and stream recovery ## Quick Start ```rust,ignore use lamco_pipewire::{PipeWireManager, PipeWireConfig, StreamInfo, SourceType}; // Create manager with default configuration let mut manager = PipeWireManager::with_default()?; // Connect using portal-provided file descriptor (from lamco-portal) manager.connect(fd).await?; // Create stream for a monitor let stream_info = StreamInfo { node_id: 42, position: (0, 0), size: (1920, 1080), source_type: SourceType::Monitor, }; let handle = manager.create_stream(&stream_info).await?; // Receive frames if let Some(mut rx) = manager.frame_receiver(handle.id).await { while let Some(frame) = rx.recv().await { println!("Frame: {}x{}", frame.width, frame.height); } } manager.shutdown().await?; ``` ## Configuration ```rust use lamco_pipewire::{PipeWireConfig, PixelFormat}; let config = PipeWireConfig::builder() .buffer_count(4) // More buffers for high refresh .preferred_format(PixelFormat::BGRA) // Preferred pixel format .use_dmabuf(true) // Enable zero-copy .max_streams(4) // Limit concurrent streams .enable_cursor(true) // Extract cursor separately .enable_damage_tracking(true) // Track changed regions .build(); let manager = PipeWireManager::new(config)?; ``` ## Feature Flags | Feature | Default | Description | |---------|---------|-------------| | `dmabuf` | Yes | DMA-BUF zero-copy support | | `yuv` | No | YUV format conversion utilities | | `cursor` | No | Hardware cursor extraction | | `damage` | No | Region damage tracking | | `adaptive` | No | Adaptive bitrate control | | `full` | No | All features enabled | ```toml [dependencies] lamco-pipewire = { version = "0.1", features = ["full"] } ``` ## Architecture PipeWire's Rust bindings use `Rc<>` and `NonNull<>` internally, making them **not Send**. This crate solves this with a dedicated thread architecture: ```text ┌─────────────────────────────────────────────────────────┐ │ Tokio Async Runtime │ │ │ │ Your Application → PipeWireManager │ │ (Send + Sync wrapper) │ │ │ │ │ │ Commands via mpsc │ │ ▼ │ └───────────────────────────┼─────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────┐ │ Dedicated PipeWire Thread │ │ (std::thread - owns all non-Send types) │ │ │ │ MainLoop (Rc) ─> Context (Rc) ─> Core (Rc) │ │ │ │ │ ▼ │ │ Streams (NonNull) │ │ │ │ │ │ Frames via mpsc │ └──────────────────────────────────────┼──────────────────┘ │ ▼ Your application receives frames ``` ## Performance - **Frame latency**: < 2ms (with DMA-BUF) - **Memory usage**: < 100MB per stream - **CPU usage**: < 5% per stream (1080p @ 60Hz) - **Refresh rates**: Tested up to 144Hz ## Requirements - **Linux** with a Wayland compositor - **PipeWire** installed and running - **PipeWire development libraries**: `libpipewire-0.3-dev` (Debian/Ubuntu) or `pipewire-devel` (Fedora) - **Rust 1.77+** ## Platform Compatibility | Compositor | Portal Package | Status | |------------|----------------|--------| | GNOME | `xdg-desktop-portal-gnome` | ✅ Tested | | KDE Plasma | `xdg-desktop-portal-kde` | ✅ Tested | | wlroots (Sway, Hyprland) | `xdg-desktop-portal-wlr` | ✅ Tested | | X11 | N/A | ❌ Not supported | ## Related Crates - [`lamco-portal`](https://crates.io/crates/lamco-portal) - XDG Desktop Portal integration for obtaining PipeWire file descriptors ## About Developed by [Lamco Development](https://lamco.ai). Part of the lamco-wayland ecosystem for building Wayland-native applications in Rust. ## License Licensed under either of: - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) at your option. lamco-pipewire-0.4.0/examples/basic.rs000064400000000000000000000071601046102023000160130ustar 00000000000000//! Basic PipeWire Screen Capture Example //! //! This example demonstrates the simplest usage of lamco-pipewire //! for capturing screen content from a single monitor. //! //! # Prerequisites //! //! - PipeWire must be installed and running //! - You need a portal-provided file descriptor (typically from lamco-portal) //! - This example uses a mock FD for demonstration //! //! # Running //! //! ```bash //! cargo run --example basic //! ``` use lamco_pipewire::{PipeWireConfig, PipeWireManager, PixelFormat, SourceType, StreamInfo}; #[tokio::main] async fn main() -> Result<(), Box> { // Initialize tracing for debug output tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init(); println!("lamco-pipewire Basic Example"); println!("============================"); // Create manager with custom configuration let config = PipeWireConfig::builder() .buffer_count(3) .preferred_format(PixelFormat::BGRA) .use_dmabuf(true) .max_streams(4) .build(); println!("Configuration:"); println!(" Buffer count: {}", config.buffer_count); println!(" Preferred format: {:?}", config.preferred_format); println!(" Use DMA-BUF: {}", config.use_dmabuf); println!(" Max streams: {}", config.max_streams); let manager = PipeWireManager::new(config)?; println!("\nManager created successfully!"); println!("State: {:?}", manager.state().await); // In a real application, you would: // 1. Use lamco-portal to get a PipeWire file descriptor // 2. Call manager.connect(fd).await // 3. Create streams for each monitor // 4. Receive frames via frame_receiver() // // Example (requires actual portal session): // // ``` // use lamco_portal::PortalManager; // // let portal = PortalManager::with_default().await?; // let session = portal.create_session("example".to_string(), None).await?; // // manager.connect(session.pipewire_fd()).await?; // // for stream in session.streams() { // let info = StreamInfo { // node_id: stream.node_id, // position: stream.position, // size: stream.size, // source_type: SourceType::Monitor, // }; // let handle = manager.create_stream(&info).await?; // println!("Created stream: {}", handle.id); // } // ``` // For this example, we'll just show the manager is working println!("\nTo use this in a real application:"); println!("1. Add lamco-portal as a dependency"); println!("2. Create a portal session to get a PipeWire FD"); println!("3. Connect the manager to the FD"); println!("4. Create streams for each monitor"); println!("5. Receive frames via frame_receiver()"); // Demonstrate StreamInfo structure let _demo_stream_info = StreamInfo { node_id: 42, position: (0, 0), size: (1920, 1080), source_type: SourceType::Monitor, }; println!("\nDemo StreamInfo:"); println!(" Node ID: {}", _demo_stream_info.node_id); println!(" Position: {:?}", _demo_stream_info.position); println!(" Size: {:?}", _demo_stream_info.size); println!(" Source type: {:?}", _demo_stream_info.source_type); // Check DMA-BUF support println!("\nSystem capabilities:"); println!(" DMA-BUF likely supported: {}", lamco_pipewire::is_dmabuf_supported()); println!("\nSupported formats:"); for (i, format) in lamco_pipewire::supported_formats().iter().enumerate() { println!(" {}. {:?}", i + 1, format); } println!("\nExample completed successfully!"); Ok(()) } lamco-pipewire-0.4.0/examples/multi_monitor.rs000064400000000000000000000104601046102023000176300ustar 00000000000000//! Multi-Monitor Screen Capture Example //! //! This example demonstrates capturing from multiple monitors simultaneously //! using the PipeWire coordinator. //! //! # Prerequisites //! //! - PipeWire must be installed and running //! - Multiple monitors configured (or simulation) //! - Portal-provided file descriptor //! //! # Running //! //! ```bash //! cargo run --example multi_monitor //! ``` use lamco_pipewire::{MonitorInfo, MultiStreamConfig, PipeWireConfig, PipeWireManager}; #[tokio::main] async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); println!("lamco-pipewire Multi-Monitor Example"); println!("===================================="); // Create manager with configuration for multiple streams let config = PipeWireConfig::builder() .buffer_count(4) // More buffers for multiple streams .max_streams(8) .frame_buffer_size(60) // Buffer 2 seconds at 30fps .build(); let _manager = PipeWireManager::new(config)?; println!("Manager created for multi-monitor capture"); // Simulate monitor configuration (in real usage, this comes from portal) let monitors = vec![ MonitorInfo { id: 0, name: "Primary Monitor".to_string(), position: (0, 0), size: (2560, 1440), refresh_rate: 144, node_id: 100, }, MonitorInfo { id: 1, name: "Secondary Monitor".to_string(), position: (2560, 0), size: (1920, 1080), refresh_rate: 60, node_id: 101, }, MonitorInfo { id: 2, name: "Vertical Monitor".to_string(), position: (-1080, 180), size: (1080, 1920), // Portrait orientation refresh_rate: 60, node_id: 102, }, ]; println!("\nDetected {} monitors:", monitors.len()); for monitor in &monitors { println!( " {} (ID: {}): {}x{} @ {}Hz at position {:?}", monitor.name, monitor.id, monitor.size.0, monitor.size.1, monitor.refresh_rate, monitor.position ); } // Calculate combined resolution let total_width: u32 = monitors.iter().map(|m| m.size.0).sum(); let max_height: u32 = monitors.iter().map(|m| m.size.1).max().unwrap_or(0); println!("\nCombined desktop: {}x{}", total_width, max_height); // Calculate recommended settings per monitor println!("\nRecommended settings per monitor:"); for monitor in &monitors { let buffers = lamco_pipewire::recommended_buffer_count(monitor.refresh_rate); let frame_buffer = lamco_pipewire::recommended_frame_buffer_size(monitor.refresh_rate); println!(" {}: {} buffers, {} frame buffer", monitor.name, buffers, frame_buffer); } // Show MultiStreamConfig defaults let multi_config = MultiStreamConfig::default(); println!("\nMultiStreamConfig defaults:"); println!(" Max streams: {}", multi_config.max_streams); println!(" Enable sync: {}", multi_config.enable_sync); println!(" Retry attempts: {}", multi_config.retry_attempts); // In a real application, you would connect and create streams: // // ``` // manager.connect(fd).await?; // // for monitor in &monitors { // let info = StreamInfo { // node_id: monitor.node_id, // position: monitor.position, // size: monitor.size, // source_type: SourceType::Monitor, // }; // // let handle = manager.create_stream(&info).await?; // // // Spawn task to process frames for this monitor // let rx = manager.frame_receiver(handle.id).await.unwrap(); // tokio::spawn(async move { // while let Some(frame) = rx.recv().await { // // Process frame... // } // }); // } // ``` println!("\nMulti-monitor architecture:"); println!(" - Each monitor gets its own PipeWire stream"); println!(" - Frames are delivered via separate channels"); println!(" - Frame timestamps enable cross-monitor sync"); println!(" - Position info enables virtual desktop reconstruction"); println!("\nExample completed successfully!"); Ok(()) } lamco-pipewire-0.4.0/examples/streaming.rs000064400000000000000000000112041046102023000167150ustar 00000000000000//! Adaptive Bitrate Streaming Example //! //! This example demonstrates using the adaptive bitrate controller //! for network-aware streaming of captured content. //! //! # Prerequisites //! //! - Requires the `adaptive` feature //! //! # Running //! //! ```bash //! cargo run --example streaming --features adaptive //! ``` #[cfg(feature = "adaptive")] use lamco_pipewire::{ bitrate::BitrateController, config::{AdaptiveBitrateConfig, QualityPreset}, }; #[cfg(feature = "adaptive")] fn main() { println!("lamco-pipewire Adaptive Bitrate Example"); println!("======================================="); // Create a bitrate controller for low-latency streaming let config = AdaptiveBitrateConfig::builder() .min_bitrate_kbps(500) .max_bitrate_kbps(20000) .target_fps(60) .quality_preset(QualityPreset::LowLatency) .calculation_window(30) .build(); println!("\nConfiguration:"); println!( " Bitrate range: {} - {} kbps", config.min_bitrate_kbps, config.max_bitrate_kbps ); println!(" Target FPS: {}", config.target_fps); println!(" Quality preset: {:?}", config.quality_preset); println!(" Calculation window: {} frames", config.calculation_window); let mut controller = BitrateController::new(config); println!("\nInitial state:"); println!(" Recommended bitrate: {} kbps", controller.recommended_bitrate()); println!(" Recommended quality: {}", controller.recommended_quality()); println!(" Congestion level: {:.2}", controller.congestion_level()); // Simulate encoding some frames println!("\n--- Simulating good network conditions ---"); for i in 0..20 { // Simulate fast encoding (2ms) with reasonable frame size controller.record_frame(2000, 30000 + i * 1000); } println!("After 20 frames:"); println!(" Recommended bitrate: {} kbps", controller.recommended_bitrate()); println!(" Congestion level: {:.2}", controller.congestion_level()); // Simulate network feedback with some packet loss println!("\n--- Simulating packet loss ---"); controller.record_network_feedback(0.05, 100); // 5% loss, 100ms RTT println!("After packet loss:"); println!(" Recommended bitrate: {} kbps", controller.recommended_bitrate()); println!(" Recommended quality: {}", controller.recommended_quality()); println!(" Congestion level: {:.2}", controller.congestion_level()); // Simulate severe congestion println!("\n--- Simulating severe congestion ---"); controller.record_network_feedback(0.15, 300); // 15% loss, 300ms RTT println!("During congestion:"); println!(" Recommended bitrate: {} kbps", controller.recommended_bitrate()); println!(" Congestion level: {:.2}", controller.congestion_level()); // Check frame skipping let mut skipped = 0; let mut sent = 0; for _ in 0..30 { if controller.should_skip_frame() { skipped += 1; } else { sent += 1; } } println!(" Frame decisions: {} sent, {} skipped", sent, skipped); // Show statistics let stats = controller.stats(); println!("\nStatistics:"); println!(" Frames recorded: {}", stats.frames_recorded); println!(" Frames dropped: {}", stats.frames_dropped); println!(" Frames skipped: {}", stats.frames_skipped); println!(" Bitrate increases: {}", stats.bitrate_increases); println!(" Bitrate decreases: {}", stats.bitrate_decreases); println!(" Drop rate: {:.2}%", stats.drop_rate() * 100.0); // Demonstrate different presets println!("\n--- Quality Presets Comparison ---"); let presets = [ ("Low Latency", AdaptiveBitrateConfig::low_latency()), ("Balanced", AdaptiveBitrateConfig::default()), ("High Quality", AdaptiveBitrateConfig::high_quality()), ]; for (name, preset_config) in &presets { let preset_controller = BitrateController::new(preset_config.clone()); println!( " {}: {} kbps initial, quality {}", name, preset_controller.recommended_bitrate(), preset_controller.recommended_quality() ); } // Reset and show recovered state println!("\n--- After network recovery ---"); controller.reset(); println!(" Recommended bitrate: {} kbps", controller.recommended_bitrate()); println!(" Congestion level: {:.2}", controller.congestion_level()); println!("\nExample completed!"); } #[cfg(not(feature = "adaptive"))] fn main() { println!("This example requires the 'adaptive' feature."); println!("Run with: cargo run --example streaming --features adaptive"); } lamco-pipewire-0.4.0/examples/with_damage.rs000064400000000000000000000072041046102023000172020ustar 00000000000000//! Damage Tracking Example //! //! This example demonstrates using damage tracking to efficiently //! encode only the regions of the screen that have changed. //! //! # Prerequisites //! //! - Requires the `damage` feature //! //! # Running //! //! ```bash //! cargo run --example with_damage --features damage //! ``` #[cfg(feature = "damage")] use lamco_pipewire::damage::{DamageRegion, DamageTracker}; #[cfg(feature = "damage")] fn main() { println!("lamco-pipewire Damage Tracking Example"); println!("======================================"); // Create a damage tracker let mut tracker = DamageTracker::with_threshold(0.4); // Simulate some damaged regions from PipeWire metadata let regions = vec![ DamageRegion::new(100, 100, 200, 150), // Window update DamageRegion::new(500, 300, 50, 30), // Cursor area DamageRegion::new(120, 120, 100, 80), // Overlapping with first ]; println!("\nAdding {} damage regions...", regions.len()); for region in ®ions { println!( " Region: x={}, y={}, {}x{}", region.x, region.y, region.width, region.height ); tracker.add_region(*region); } // Check merged regions println!("\nAfter merging: {} regions", tracker.region_count()); for (i, region) in tracker.damaged_regions().iter().enumerate() { println!( " Region {}: x={}, y={}, {}x{} (area: {})", i, region.x, region.y, region.width, region.height, region.area() ); } // Calculate damage statistics let frame_size = (1920u32, 1080u32); let total_pixels = u64::from(frame_size.0) * u64::from(frame_size.1); let damage_ratio = tracker.damage_ratio(frame_size); println!("\nDamage statistics:"); println!( " Frame size: {}x{} ({} pixels)", frame_size.0, frame_size.1, total_pixels ); println!(" Total damaged area: {} pixels", tracker.total_damaged_area()); println!(" Damage ratio: {:.2}%", damage_ratio * 100.0); // Encoding decision if tracker.should_full_update(frame_size) { println!("\nDecision: FULL FRAME UPDATE"); println!(" Reason: Damage ratio exceeds threshold or too many regions"); } else { println!("\nDecision: PARTIAL UPDATE"); println!(" Encoding only {} damaged region(s)", tracker.region_count()); // Get bounding box for single-region optimization if let Some(bbox) = tracker.bounding_box() { println!( " Bounding box: x={}, y={}, {}x{}", bbox.x, bbox.y, bbox.width, bbox.height ); } } // Simulate another frame with full damage tracker.clear(); tracker.mark_full_damage(frame_size.0, frame_size.1); println!("\n--- After full damage frame ---"); println!(" Should full update: {}", tracker.should_full_update(frame_size)); println!(" Damage ratio: {:.2}%", tracker.damage_ratio(frame_size) * 100.0); // Demonstrate region clipping println!("\n--- Region Clipping ---"); let oversized = DamageRegion::new(1800, 900, 300, 300); println!(" Original: x=1800, y=900, 300x300"); if let Some(clipped) = oversized.clip(frame_size.0, frame_size.1) { println!( " Clipped to frame: x={}, y={}, {}x{}", clipped.x, clipped.y, clipped.width, clipped.height ); } println!("\nExample completed!"); } #[cfg(not(feature = "damage"))] fn main() { println!("This example requires the 'damage' feature."); println!("Run with: cargo run --example with_damage --features damage"); } lamco-pipewire-0.4.0/src/audio.rs000064400000000000000000000434701046102023000150100ustar 00000000000000//! PipeWire Audio Capture //! //! Desktop audio capture via PipeWire, delivering PCM samples through //! a channel. Runs on a dedicated thread since PipeWire types are not Send. //! //! # Usage //! //! ```rust,ignore //! use lamco_pipewire::audio::{spawn_audio_capture, CaptureConfig, AudioFormat}; //! //! let config = CaptureConfig { //! format: AudioFormat::F32, //! ..Default::default() //! }; //! //! let handle = spawn_audio_capture(config, None, 64)?; //! //! while let Some(samples) = handle.receiver.recv().await { //! // Process samples //! } //! ``` use std::convert::TryInto; use std::mem; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use anyhow::{Context, Result}; use pipewire as pw; use pw::spa; use pw::spa::param::format::{MediaSubtype, MediaType}; use pw::spa::param::format_utils; use pw::spa::pod::Pod; use tokio::sync::mpsc; use tracing::{debug, error, info, trace, warn}; /// Audio sample format #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AudioFormat { /// 32-bit float (native endian) F32, /// 16-bit signed integer (native endian) I16, } impl AudioFormat { fn to_spa_format(self) -> spa::param::audio::AudioFormat { match self { Self::F32 => spa::param::audio::AudioFormat::F32LE, Self::I16 => spa::param::audio::AudioFormat::S16LE, } } /// Bytes per single sample (one channel) pub fn bytes_per_sample(self) -> usize { match self { Self::F32 => mem::size_of::(), Self::I16 => mem::size_of::(), } } } /// Audio capture configuration #[derive(Debug, Clone)] pub struct CaptureConfig { /// Sample rate in Hz (default: 48000) pub sample_rate: u32, /// Number of channels (default: 2) pub channels: u32, /// Output sample format pub format: AudioFormat, /// Frames per buffer (default: 1024, ~21ms at 48kHz) pub buffer_frames: u32, } impl Default for CaptureConfig { fn default() -> Self { Self { sample_rate: 48000, channels: 2, format: AudioFormat::F32, buffer_frames: 1024, } } } /// Typed audio sample buffer #[derive(Debug, Clone)] pub enum AudioSamples { /// 32-bit float samples F32(Vec), /// 16-bit signed integer samples I16(Vec), } impl AudioSamples { /// Number of samples (all channels combined) pub fn len(&self) -> usize { match self { Self::F32(s) => s.len(), Self::I16(s) => s.len(), } } /// Check if empty pub fn is_empty(&self) -> bool { self.len() == 0 } /// Convert to i16 samples pub fn to_i16(&self) -> Vec { match self { Self::F32(samples) => samples.iter().map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16).collect(), Self::I16(samples) => samples.clone(), } } /// Convert to f32 samples pub fn to_f32(&self) -> Vec { match self { Self::F32(samples) => samples.clone(), Self::I16(samples) => samples.iter().map(|&s| s as f32 / 32768.0).collect(), } } } /// Handle to a running audio capture session pub struct AudioCaptureHandle { /// Receiver for captured audio samples pub receiver: mpsc::Receiver, stop_signal: Arc, } impl AudioCaptureHandle { /// Signal the capture thread to stop pub fn stop(&self) { self.stop_signal.store(true, Ordering::SeqCst); } /// Check if capture has been stopped pub fn is_stopped(&self) -> bool { self.stop_signal.load(Ordering::SeqCst) } } struct CaptureUserData { format: spa::param::audio::AudioInfoRaw, output_format: AudioFormat, sender: mpsc::Sender, stop_signal: Arc, samples_captured: u64, samples_dropped: u64, } /// PipeWire audio capture engine /// /// Captures desktop audio via PipeWire and sends PCM samples through a channel. /// Must be run on a dedicated thread via [`spawn_audio_capture`]. pub struct AudioCapture { config: CaptureConfig, sender: mpsc::Sender, stop_signal: Arc, } impl AudioCapture { /// Create a new capture instance and its handle pub fn new(config: CaptureConfig, channel_size: usize) -> (Self, AudioCaptureHandle) { let (sender, receiver) = mpsc::channel(channel_size); let stop_signal = Arc::new(AtomicBool::new(false)); let capture = Self { config, sender, stop_signal: Arc::clone(&stop_signal), }; let handle = AudioCaptureHandle { receiver, stop_signal }; (capture, handle) } /// Run the PipeWire main loop for audio capture (blocking). /// /// Call from a dedicated thread. Connects to the PipeWire daemon, /// negotiates audio format, and delivers samples until stopped. pub fn start_capture(&self, node_id: Option) -> Result<()> { info!( "Starting audio capture: {}Hz, {} channels, format={:?}, node_id={:?}", self.config.sample_rate, self.config.channels, self.config.format, node_id ); // PipeWire 0.9 Box types for owned resources let mainloop = pw::main_loop::MainLoopBox::new(None).context("Failed to create PipeWire MainLoop")?; let context = pw::context::ContextBox::new(mainloop.loop_(), None).context("Failed to create PipeWire Context")?; let core = context.connect(None).context("Failed to connect to PipeWire daemon")?; let mut props = pw::properties::properties! { *pw::keys::MEDIA_TYPE => "Audio", *pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_ROLE => "Screen", *pw::keys::NODE_NAME => "lamco-audio-capture", *pw::keys::APP_NAME => "lamco-pipewire", }; if let Some(id) = node_id { props.insert("target.object", id.to_string()); } props.insert("stream.capture.sink", "true"); let stream = pw::stream::StreamBox::new(&core, "lamco-audio-capture", props) .context("Failed to create PipeWire stream")?; let user_data = CaptureUserData { format: spa::param::audio::AudioInfoRaw::default(), output_format: self.config.format, sender: self.sender.clone(), stop_signal: Arc::clone(&self.stop_signal), samples_captured: 0, samples_dropped: 0, }; let stop_signal_for_callback = Arc::clone(&self.stop_signal); let _listener = stream .add_local_listener_with_user_data(user_data) .state_changed(move |_stream, _user_data, old, new| { debug!("Audio stream state: {:?} -> {:?}", old, new); match new { pw::stream::StreamState::Error(err) => { error!("Audio stream error: {}", err); stop_signal_for_callback.store(true, Ordering::SeqCst); } pw::stream::StreamState::Streaming => { info!("Audio capture streaming started"); } pw::stream::StreamState::Paused => { debug!("Audio stream paused"); } _ => {} } }) .param_changed(|_stream, user_data, id, param| { let Some(param) = param else { return; }; if id != spa::param::ParamType::Format.as_raw() { return; } let (media_type, media_subtype) = match format_utils::parse_format(param) { Ok(v) => v, Err(e) => { warn!("Failed to parse audio format: {:?}", e); return; } }; if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw { debug!("Ignoring non-raw audio format: {:?}/{:?}", media_type, media_subtype); return; } if let Err(e) = user_data.format.parse(param) { warn!("Failed to parse audio info: {:?}", e); return; } info!( "Audio format negotiated: rate={}, channels={}, format={:?}", user_data.format.rate(), user_data.format.channels(), user_data.format.format() ); }) .process(|stream, user_data| { if user_data.stop_signal.load(Ordering::Relaxed) { return; } let Some(mut buffer) = stream.dequeue_buffer() else { trace!("No buffer available"); return; }; let datas = buffer.datas_mut(); if datas.is_empty() { return; } let data = &mut datas[0]; let chunk = data.chunk(); let size = chunk.size() as usize; if size == 0 { return; } let Some(slice) = data.data() else { return; }; let n_channels = user_data.format.channels() as usize; if n_channels == 0 { return; } let samples = match user_data.format.format() { spa::param::audio::AudioFormat::F32LE | spa::param::audio::AudioFormat::F32BE => { let byte_count = size.min(slice.len()); let sample_count = byte_count / mem::size_of::(); let mut f32_samples = Vec::with_capacity(sample_count); for i in 0..sample_count { let start = i * mem::size_of::(); let end = start + mem::size_of::(); if end <= slice.len() { let bytes: [u8; 4] = slice[start..end].try_into().unwrap_or([0; 4]); let sample = if user_data.format.format() == spa::param::audio::AudioFormat::F32LE { f32::from_le_bytes(bytes) } else { f32::from_be_bytes(bytes) }; f32_samples.push(sample); } } match user_data.output_format { AudioFormat::F32 => AudioSamples::F32(f32_samples), AudioFormat::I16 => { let i16_samples: Vec = f32_samples .iter() .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16) .collect(); AudioSamples::I16(i16_samples) } } } spa::param::audio::AudioFormat::S16LE | spa::param::audio::AudioFormat::S16BE => { let byte_count = size.min(slice.len()); let sample_count = byte_count / mem::size_of::(); let mut i16_samples = Vec::with_capacity(sample_count); for i in 0..sample_count { let start = i * mem::size_of::(); let end = start + mem::size_of::(); if end <= slice.len() { let bytes: [u8; 2] = slice[start..end].try_into().unwrap_or([0; 2]); let sample = if user_data.format.format() == spa::param::audio::AudioFormat::S16LE { i16::from_le_bytes(bytes) } else { i16::from_be_bytes(bytes) }; i16_samples.push(sample); } } match user_data.output_format { AudioFormat::I16 => AudioSamples::I16(i16_samples), AudioFormat::F32 => { let f32_samples: Vec = i16_samples.iter().map(|&s| s as f32 / 32768.0).collect(); AudioSamples::F32(f32_samples) } } } other => { trace!("Unsupported audio format: {:?}", other); return; } }; let sample_count = samples.len(); // Non-blocking send to maintain realtime performance match user_data.sender.try_send(samples) { Ok(()) => { user_data.samples_captured += sample_count as u64; trace!("Captured {} samples", sample_count); } Err(mpsc::error::TrySendError::Full(_)) => { user_data.samples_dropped += sample_count as u64; trace!("Dropped {} samples (channel full)", sample_count); } Err(mpsc::error::TrySendError::Closed(_)) => { user_data.stop_signal.store(true, Ordering::SeqCst); debug!("Audio sample channel closed"); } } }) .register() .context("Failed to register stream listener")?; // Build format parameters for negotiation let mut audio_info = spa::param::audio::AudioInfoRaw::new(); audio_info.set_format(self.config.format.to_spa_format()); let obj = spa::pod::Object { type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(), id: spa::param::ParamType::EnumFormat.as_raw(), properties: audio_info.into(), }; let pod_bytes: Vec = spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &spa::pod::Value::Object(obj), ) .context("Failed to serialize audio format pod")? .0 .into_inner(); let pod = Pod::from_bytes(&pod_bytes).context("Failed to create pod from bytes")?; let mut params = [pod]; let flags = pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::RT_PROCESS; stream .connect(spa::utils::Direction::Input, node_id, flags, &mut params) .context("Failed to connect PipeWire stream")?; info!("Audio capture stream connected, starting main loop"); let loop_ref = mainloop.loop_(); while !self.stop_signal.load(Ordering::Relaxed) { loop_ref.iterate(std::time::Duration::from_millis(100)); } info!("Audio capture stopped"); Ok(()) } /// Signal the capture to stop pub fn stop(&self) { self.stop_signal.store(true, Ordering::SeqCst); } } /// Spawn audio capture on a dedicated thread /// /// Returns a handle with a receiver for audio samples. The capture runs /// until the handle is dropped or `stop()` is called. /// /// # Arguments /// /// * `config` - Audio capture configuration /// * `node_id` - Optional PipeWire node ID (from portal session) /// * `channel_size` - Bounded channel capacity for sample buffers pub fn spawn_audio_capture( config: CaptureConfig, node_id: Option, channel_size: usize, ) -> Result { let (capture, handle) = AudioCapture::new(config, channel_size); std::thread::Builder::new() .name("pipewire-audio".into()) .spawn(move || { pw::init(); if let Err(e) = capture.start_capture(node_id) { error!("Audio capture error: {:#}", e); } // SAFETY: Called once per init(), after all PipeWire resources dropped unsafe { pw::deinit(); } }) .context("Failed to spawn audio capture thread")?; Ok(handle) } #[cfg(test)] mod tests { use super::*; #[test] fn test_capture_config_default() { let config = CaptureConfig::default(); assert_eq!(config.sample_rate, 48000); assert_eq!(config.channels, 2); assert_eq!(config.format, AudioFormat::F32); } #[test] fn test_audio_samples_conversion() { let f32_samples = AudioSamples::F32(vec![0.0, 0.5, -0.5, 1.0, -1.0]); let i16_converted = f32_samples.to_i16(); assert_eq!(i16_converted.len(), 5); assert_eq!(i16_converted[0], 0); assert!((i16_converted[1] - 16383).abs() <= 1); let i16_samples = AudioSamples::I16(vec![0, 16384, -16384, 32767, -32768]); let f32_converted = i16_samples.to_f32(); assert_eq!(f32_converted.len(), 5); assert!((f32_converted[0] - 0.0).abs() < 0.001); } #[test] fn test_audio_format_bytes_per_sample() { assert_eq!(AudioFormat::F32.bytes_per_sample(), 4); assert_eq!(AudioFormat::I16.bytes_per_sample(), 2); } #[test] fn test_audio_samples_empty() { let empty = AudioSamples::F32(vec![]); assert!(empty.is_empty()); assert_eq!(empty.len(), 0); } #[test] fn test_audio_capture_handle_stop() { let config = CaptureConfig::default(); let (_capture, handle) = AudioCapture::new(config, 10); assert!(!handle.is_stopped()); handle.stop(); assert!(handle.is_stopped()); } } lamco-pipewire-0.4.0/src/bitrate.rs000064400000000000000000000333531046102023000153400ustar 00000000000000//! Adaptive Bitrate Control //! //! Provides bitrate control helpers for streaming scenarios where //! network conditions may vary. //! //! # Overview //! //! When streaming screen content over a network, the encoder bitrate //! needs to adapt to: //! - Available bandwidth //! - Frame complexity (more detail = more bits) //! - Latency requirements //! - Frame drops/congestion signals //! //! This module provides a controller that tracks frame statistics and //! recommends bitrate adjustments. //! //! # Usage //! //! ```rust //! use lamco_pipewire::bitrate::BitrateController; //! use lamco_pipewire::config::{AdaptiveBitrateConfig, QualityPreset}; //! //! let config = AdaptiveBitrateConfig::builder() //! .min_bitrate_kbps(500) //! .max_bitrate_kbps(10000) //! .target_fps(60) //! .quality_preset(QualityPreset::LowLatency) //! .build(); //! //! let mut controller = BitrateController::new(config); //! //! // After encoding each frame, record timing //! controller.record_frame(5000, 50000); // 5ms encode, 50KB frame //! //! // Get recommendations //! let bitrate = controller.recommended_bitrate(); //! let quality = controller.recommended_quality(); //! //! if controller.should_skip_frame() { //! // Network congested, skip this frame //! } //! ``` use std::collections::VecDeque; use std::time::Instant; use crate::config::{AdaptiveBitrateConfig, QualityPreset}; /// Frame timing record for bitrate calculations #[derive(Debug, Clone)] struct FrameRecord { /// Time to encode the frame (microseconds) encode_time_us: u64, /// Encoded frame size (bytes) frame_size: usize, /// Timestamp when frame was recorded (retained for future analysis) #[allow(dead_code)] timestamp: Instant, } /// Bitrate controller for adaptive streaming pub struct BitrateController { /// Configuration config: AdaptiveBitrateConfig, /// Current bitrate in kbps current_bitrate: u32, /// Frame history for calculations frame_history: VecDeque, /// Congestion indicator (0.0 = clear, 1.0 = severe) congestion_level: f64, /// Skip counter (for frame skipping) skip_counter: u32, /// Statistics stats: BitrateStats, /// Last adjustment time last_adjustment: Instant, /// Minimum time between adjustments (ms) adjustment_interval_ms: u64, } impl BitrateController { /// Create a new bitrate controller #[must_use] pub fn new(config: AdaptiveBitrateConfig) -> Self { let initial_bitrate = (config.min_bitrate_kbps + config.max_bitrate_kbps) / 2; Self { config, current_bitrate: initial_bitrate, frame_history: VecDeque::with_capacity(120), congestion_level: 0.0, skip_counter: 0, stats: BitrateStats::default(), last_adjustment: Instant::now(), adjustment_interval_ms: 100, // Adjust at most every 100ms } } /// Record frame encoding statistics /// /// Call this after encoding each frame to update the controller's /// model of current conditions. /// /// # Arguments /// /// * `encode_time_us` - Time spent encoding in microseconds /// * `frame_size` - Encoded frame size in bytes pub fn record_frame(&mut self, encode_time_us: u64, frame_size: usize) { let record = FrameRecord { encode_time_us, frame_size, timestamp: Instant::now(), }; self.frame_history.push_back(record); // Keep only recent history while self.frame_history.len() > self.config.calculation_window { self.frame_history.pop_front(); } self.stats.frames_recorded += 1; self.stats.total_bytes += frame_size as u64; // Update bitrate if enough time has passed if self.last_adjustment.elapsed().as_millis() >= u128::from(self.adjustment_interval_ms) { self.adjust_bitrate(); } } /// Record a dropped/skipped frame pub fn record_dropped_frame(&mut self) { self.stats.frames_dropped += 1; self.congestion_level = (self.congestion_level + 0.2).min(1.0); } /// Record network feedback (e.g., from RTCP) /// /// # Arguments /// /// * `packet_loss_ratio` - Fraction of packets lost (0.0-1.0) /// * `rtt_ms` - Round-trip time in milliseconds pub fn record_network_feedback(&mut self, packet_loss_ratio: f64, rtt_ms: u32) { // Increase congestion if packet loss is high if packet_loss_ratio > 0.05 { self.congestion_level = (self.congestion_level + packet_loss_ratio).min(1.0); } // High RTT also indicates congestion let target_rtt = match self.config.quality_preset { QualityPreset::LowLatency => 50, QualityPreset::Balanced => 150, QualityPreset::HighQuality => 300, }; if rtt_ms > target_rtt { let rtt_factor = f64::from(rtt_ms - target_rtt) / f64::from(target_rtt); self.congestion_level = (self.congestion_level + rtt_factor * 0.1).min(1.0); } // Decay congestion over time when conditions improve if packet_loss_ratio < 0.01 && rtt_ms < target_rtt { self.congestion_level = (self.congestion_level - 0.05).max(0.0); } } /// Get recommended bitrate based on current conditions #[must_use] pub fn recommended_bitrate(&self) -> u32 { self.current_bitrate } /// Get recommended quality level (0-100) /// /// Higher values = higher quality, lower compression #[must_use] pub fn recommended_quality(&self) -> u8 { let base_quality = match self.config.quality_preset { QualityPreset::LowLatency => 30, QualityPreset::Balanced => 50, QualityPreset::HighQuality => 80, }; // Adjust based on congestion let adjusted = base_quality as f64 * (1.0 - self.congestion_level * 0.5); adjusted.clamp(10.0, 100.0) as u8 } /// Check if current frame should be skipped due to congestion /// /// Returns true if frame should be skipped to reduce load. #[must_use] pub fn should_skip_frame(&mut self) -> bool { if self.congestion_level < 0.5 { self.skip_counter = 0; return false; } // Skip more frames at higher congestion let skip_threshold = match self.config.quality_preset { QualityPreset::LowLatency => 2, // Skip every 2nd frame at high congestion QualityPreset::Balanced => 3, // Skip every 3rd frame QualityPreset::HighQuality => 4, // Skip every 4th frame }; self.skip_counter += 1; if self.skip_counter >= skip_threshold { self.skip_counter = 0; self.stats.frames_skipped += 1; true } else { false } } /// Get current congestion level (0.0-1.0) #[must_use] pub fn congestion_level(&self) -> f64 { self.congestion_level } /// Get statistics #[must_use] pub fn stats(&self) -> &BitrateStats { &self.stats } /// Reset controller state pub fn reset(&mut self) { self.current_bitrate = (self.config.min_bitrate_kbps + self.config.max_bitrate_kbps) / 2; self.frame_history.clear(); self.congestion_level = 0.0; self.skip_counter = 0; self.stats = BitrateStats::default(); } /// Internal bitrate adjustment logic fn adjust_bitrate(&mut self) { if self.frame_history.is_empty() { return; } // Calculate average encode time and frame size let (total_time, total_size) = self.frame_history.iter().fold((0u64, 0usize), |acc, r| { (acc.0 + r.encode_time_us, acc.1 + r.frame_size) }); let count = self.frame_history.len() as u64; let avg_encode_us = total_time / count; let avg_frame_bytes = total_size / count as usize; // Target encode time based on FPS let target_frame_time_us = 1_000_000 / u64::from(self.config.target_fps); // Calculate how much of frame budget we're using for encoding let encode_budget_ratio = avg_encode_us as f64 / target_frame_time_us as f64; // Estimate current bitrate from frame sizes let estimated_bitrate_kbps = (avg_frame_bytes * 8 * self.config.target_fps as usize) / 1000; // Adjust bitrate let mut new_bitrate = self.current_bitrate; // If congested, reduce bitrate if self.congestion_level > 0.3 { let reduction = (self.congestion_level * 0.2) as f32; new_bitrate = (new_bitrate as f32 * (1.0 - reduction)) as u32; self.stats.bitrate_decreases += 1; } // If encode is fast and no congestion, can increase else if encode_budget_ratio < 0.5 && self.congestion_level < 0.1 { new_bitrate = (new_bitrate as f32 * 1.1) as u32; self.stats.bitrate_increases += 1; } // Clamp to configured range new_bitrate = new_bitrate.clamp(self.config.min_bitrate_kbps, self.config.max_bitrate_kbps); if new_bitrate != self.current_bitrate { self.current_bitrate = new_bitrate; } self.last_adjustment = Instant::now(); // Update stats self.stats.avg_encode_time_us = avg_encode_us; self.stats.avg_frame_size = avg_frame_bytes; self.stats.estimated_bitrate_kbps = estimated_bitrate_kbps as u32; } } /// Bitrate control statistics #[derive(Debug, Clone, Default)] pub struct BitrateStats { /// Frames recorded pub frames_recorded: u64, /// Frames dropped due to encoder overload pub frames_dropped: u64, /// Frames skipped due to congestion pub frames_skipped: u64, /// Total bytes encoded pub total_bytes: u64, /// Number of bitrate increases pub bitrate_increases: u64, /// Number of bitrate decreases pub bitrate_decreases: u64, /// Average encode time (microseconds) pub avg_encode_time_us: u64, /// Average frame size (bytes) pub avg_frame_size: usize, /// Estimated actual bitrate (kbps) pub estimated_bitrate_kbps: u32, } impl BitrateStats { /// Calculate effective FPS #[must_use] pub fn effective_fps(&self, target_fps: u32) -> f64 { if self.frames_recorded == 0 { return 0.0; } let total = self.frames_recorded + self.frames_dropped + self.frames_skipped; (self.frames_recorded as f64 / total as f64) * f64::from(target_fps) } /// Calculate drop rate #[must_use] pub fn drop_rate(&self) -> f64 { let total = self.frames_recorded + self.frames_dropped + self.frames_skipped; if total == 0 { return 0.0; } (self.frames_dropped + self.frames_skipped) as f64 / total as f64 } } #[cfg(test)] mod tests { use super::*; fn test_config() -> AdaptiveBitrateConfig { AdaptiveBitrateConfig { min_bitrate_kbps: 500, max_bitrate_kbps: 10000, target_fps: 30, quality_preset: QualityPreset::Balanced, calculation_window: 10, } } #[test] fn test_controller_creation() { let controller = BitrateController::new(test_config()); // Should start at midpoint assert_eq!(controller.recommended_bitrate(), 5250); assert_eq!(controller.congestion_level(), 0.0); } #[test] fn test_frame_recording() { let mut controller = BitrateController::new(test_config()); // Record some frames for _ in 0..5 { controller.record_frame(5000, 50000); // 5ms, 50KB } assert_eq!(controller.stats().frames_recorded, 5); assert!(controller.stats().total_bytes > 0); } #[test] fn test_congestion_response() { let mut controller = BitrateController::new(test_config()); // Simulate packet loss controller.record_network_feedback(0.1, 200); assert!(controller.congestion_level() > 0.0); // Should recommend lower quality let quality = controller.recommended_quality(); assert!(quality < 50); } #[test] fn test_frame_skipping() { let mut controller = BitrateController::new(test_config()); // Low congestion - no skipping assert!(!controller.should_skip_frame()); // High congestion - should skip controller.congestion_level = 0.8; let mut skipped = false; for _ in 0..10 { if controller.should_skip_frame() { skipped = true; break; } } assert!(skipped); } #[test] fn test_quality_presets() { let mut config = test_config(); config.quality_preset = QualityPreset::LowLatency; let low_latency = BitrateController::new(config.clone()); config.quality_preset = QualityPreset::HighQuality; let high_quality = BitrateController::new(config); assert!(low_latency.recommended_quality() < high_quality.recommended_quality()); } #[test] fn test_stats() { let mut controller = BitrateController::new(test_config()); controller.record_frame(5000, 50000); controller.record_dropped_frame(); let stats = controller.stats(); assert_eq!(stats.frames_recorded, 1); assert_eq!(stats.frames_dropped, 1); assert!(stats.drop_rate() > 0.0); } #[test] fn test_reset() { let mut controller = BitrateController::new(test_config()); controller.record_frame(5000, 50000); controller.congestion_level = 0.5; controller.reset(); assert_eq!(controller.congestion_level(), 0.0); assert_eq!(controller.stats().frames_recorded, 0); } } lamco-pipewire-0.4.0/src/buffer.rs000064400000000000000000000362221046102023000151550ustar 00000000000000//! Buffer Management //! //! Manages PipeWire buffers including DMA-BUF and memory-mapped buffers. use std::collections::{HashMap, HashSet, VecDeque}; use std::os::fd::RawFd; use std::sync::Arc; use std::time::SystemTime; use tokio::sync::Mutex; use crate::error::{PipeWireError, Result}; use crate::ffi::SpaDataType; /// Safe wrapper for raw pointer that implements Send+Sync pub(crate) struct SendPtr(*mut u8); // SAFETY: SendPtr wraps a memory-mapped buffer pointer that is: // 1. Allocated via mmap with MAP_SHARED, making it accessible across threads // 2. Managed by BufferManager which ensures exclusive access during mutations // 3. Only accessed through ManagedBuffer which tracks lifetime and use count unsafe impl Send for SendPtr {} // SAFETY: SendPtr is Sync because: // 1. The underlying pointer is to shared memory (mmap MAP_SHARED) // 2. All access is coordinated through BufferManager's acquire/release // 3. No unsynchronized mutations occur - buffer state is protected by locks unsafe impl Sync for SendPtr {} impl SendPtr { fn new(ptr: *mut u8) -> Self { Self(ptr) } fn as_ptr(&self) -> *mut u8 { self.0 } } /// Buffer type #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BufferType { /// DMA-BUF (zero-copy) DmaBuf, /// Memory file descriptor MemFd, /// Memory pointer MemPtr, } impl BufferType { pub fn from_spa_type(spa_type: SpaDataType) -> Option { match spa_type { SpaDataType::DmaBuf => Some(Self::DmaBuf), SpaDataType::MemFd => Some(Self::MemFd), SpaDataType::MemPtr => Some(Self::MemPtr), _ => None, } } pub fn is_dmabuf(&self) -> bool { matches!(self, Self::DmaBuf) } } /// Managed buffer pub struct ManagedBuffer { /// Buffer ID pub id: u32, /// Buffer type pub buffer_type: BufferType, /// File descriptor (for DMA-BUF and MemFd) pub fd: Option, /// Buffer size pub size: usize, /// Memory mapping (for MemFd and MemPtr) /// Safety: This pointer is only valid while the buffer exists pub(crate) mapped: Option, /// Mapped size pub mapped_size: usize, /// In use flag pub in_use: bool, /// Use count pub use_count: u64, /// Last used time pub last_used: SystemTime, /// DMA-BUF modifier (for DMA-BUF only) pub modifier: u64, } impl ManagedBuffer { /// Create new managed buffer pub fn new(id: u32, buffer_type: BufferType, size: usize) -> Self { Self { id, buffer_type, fd: None, size, mapped: None, mapped_size: 0, in_use: false, use_count: 0, last_used: SystemTime::now(), modifier: 0, } } /// Mark buffer as in use pub fn acquire(&mut self) { self.in_use = true; self.use_count += 1; self.last_used = SystemTime::now(); } /// Mark buffer as free pub fn release(&mut self) { self.in_use = false; self.last_used = SystemTime::now(); } /// Get mapped data as slice /// /// # Safety /// /// Caller must ensure: /// - The buffer has not been unmapped /// - No mutable references exist to the same memory region /// - The returned slice is not held past the buffer's lifetime pub unsafe fn as_slice(&self) -> Option<&[u8]> { // SAFETY: Caller guarantees the preconditions above. // The pointer was obtained from mmap and mapped_size was verified at allocation. self.mapped.as_ref().map(|send_ptr| { // SAFETY: send_ptr is valid from mmap, mapped_size is the allocation size unsafe { std::slice::from_raw_parts(send_ptr.as_ptr(), self.mapped_size) } }) } /// Get mapped data as mutable slice /// /// # Safety /// /// Caller must ensure: /// - The buffer has not been unmapped /// - No other references (mutable or immutable) exist to the same memory region /// - The returned slice is not held past the buffer's lifetime pub unsafe fn as_mut_slice(&mut self) -> Option<&mut [u8]> { // SAFETY: Caller guarantees the preconditions above. // Mutable access is exclusive due to &mut self requirement. self.mapped.as_mut().map(|send_ptr| { // SAFETY: send_ptr is valid from mmap, mapped_size is the allocation size, // and &mut self guarantees exclusive access unsafe { std::slice::from_raw_parts_mut(send_ptr.as_ptr(), self.mapped_size) } }) } } impl Drop for ManagedBuffer { fn drop(&mut self) { // Unmap memory if mapped if let Some(send_ptr) = self.mapped.take() { if self.mapped_size > 0 { // SAFETY: The pointer was obtained from a successful mmap call // and mapped_size was recorded at that time. The .take() ensures // we only unmap once. unsafe { libc::munmap(send_ptr.as_ptr() as *mut libc::c_void, self.mapped_size); } } } // Note: We don't close the FD here as it's managed by PipeWire } } /// Buffer manager pub struct BufferManager { /// Buffers indexed by ID buffers: HashMap, /// Free buffer queue free_buffers: VecDeque, /// In-use buffer set in_use_buffers: HashSet, /// Maximum buffers max_buffers: usize, /// Next buffer ID next_id: u32, /// Statistics stats: BufferStats, } impl BufferManager { /// Create new buffer manager pub fn new(max_buffers: usize) -> Self { Self { buffers: HashMap::new(), free_buffers: VecDeque::new(), in_use_buffers: HashSet::new(), max_buffers, next_id: 0, stats: BufferStats::default(), } } /// Register a new buffer pub fn register_buffer( &mut self, buffer_type: BufferType, size: usize, fd: Option, modifier: u64, ) -> Result { if self.buffers.len() >= self.max_buffers { return Err(PipeWireError::BufferAllocationFailed( "Maximum buffer count reached".to_string(), )); } let id = self.next_id; self.next_id += 1; let mut buffer = ManagedBuffer::new(id, buffer_type, size); buffer.fd = fd; buffer.modifier = modifier; // Try to map memory if needed if let Some(fd) = fd { if buffer_type != BufferType::DmaBuf { // SAFETY: mmap is safe to call with: // - NULL hint address (kernel chooses location) // - Size validated as > 0 before this point // - Valid fd from PipeWire buffer allocation // - MAP_SHARED for shared memory access // We check for MAP_FAILED and propagate error appropriately. buffer.mapped = Some(unsafe { let ptr = libc::mmap( std::ptr::null_mut(), size, libc::PROT_READ | libc::PROT_WRITE, libc::MAP_SHARED, fd, 0, ); if ptr == libc::MAP_FAILED { return Err(PipeWireError::BufferAllocationFailed(format!( "Failed to mmap buffer: {}", std::io::Error::last_os_error() ))); } SendPtr::new(ptr as *mut u8) }); buffer.mapped_size = size; } } self.buffers.insert(id, buffer); self.free_buffers.push_back(id); self.stats.total_allocated += 1; Ok(id) } /// Acquire a buffer for use pub fn acquire_buffer(&mut self) -> Option { if let Some(id) = self.free_buffers.pop_front() { if let Some(buffer) = self.buffers.get_mut(&id) { buffer.acquire(); self.in_use_buffers.insert(id); self.stats.acquisitions += 1; return Some(id); } } self.stats.acquisition_failures += 1; None } /// Release a buffer pub fn release_buffer(&mut self, id: u32) -> Result<()> { if let Some(buffer) = self.buffers.get_mut(&id) { buffer.release(); self.in_use_buffers.remove(&id); self.free_buffers.push_back(id); self.stats.releases += 1; Ok(()) } else { Err(PipeWireError::InvalidParameter(format!("Buffer {} not found", id))) } } /// Get buffer by ID pub fn get_buffer(&self, id: u32) -> Option<&ManagedBuffer> { self.buffers.get(&id) } /// Get mutable buffer by ID pub fn get_buffer_mut(&mut self, id: u32) -> Option<&mut ManagedBuffer> { self.buffers.get_mut(&id) } /// Unregister a buffer pub fn unregister_buffer(&mut self, id: u32) -> Result<()> { if let Some(_buffer) = self.buffers.remove(&id) { self.free_buffers.retain(|&bid| bid != id); self.in_use_buffers.remove(&id); self.stats.total_freed += 1; Ok(()) } else { Err(PipeWireError::InvalidParameter(format!("Buffer {} not found", id))) } } /// Get number of free buffers pub fn free_count(&self) -> usize { self.free_buffers.len() } /// Get number of in-use buffers pub fn in_use_count(&self) -> usize { self.in_use_buffers.len() } /// Get total buffer count pub fn total_count(&self) -> usize { self.buffers.len() } /// Get statistics pub fn stats(&self) -> &BufferStats { &self.stats } /// Clear all buffers pub fn clear(&mut self) { self.buffers.clear(); self.free_buffers.clear(); self.in_use_buffers.clear(); self.next_id = 0; } } /// Buffer statistics #[derive(Debug, Clone, Default)] pub struct BufferStats { /// Total buffers allocated pub total_allocated: u64, /// Total buffers freed pub total_freed: u64, /// Total acquisitions pub acquisitions: u64, /// Total releases pub releases: u64, /// Acquisition failures pub acquisition_failures: u64, } impl BufferStats { /// Get allocation rate pub fn allocation_rate(&self) -> f64 { if self.total_freed == 0 { self.total_allocated as f64 } else { self.total_allocated as f64 / self.total_freed as f64 } } /// Get failure rate pub fn failure_rate(&self) -> f64 { if self.acquisitions == 0 { 0.0 } else { self.acquisition_failures as f64 / (self.acquisitions + self.acquisition_failures) as f64 } } } /// Thread-safe buffer manager wrapper pub struct SharedBufferManager { inner: Arc>, } impl SharedBufferManager { /// Create new shared buffer manager pub fn new(max_buffers: usize) -> Self { Self { inner: Arc::new(Mutex::new(BufferManager::new(max_buffers))), } } /// Register a buffer pub async fn register_buffer( &self, buffer_type: BufferType, size: usize, fd: Option, modifier: u64, ) -> Result { self.inner.lock().await.register_buffer(buffer_type, size, fd, modifier) } /// Acquire a buffer pub async fn acquire_buffer(&self) -> Option { self.inner.lock().await.acquire_buffer() } /// Release a buffer pub async fn release_buffer(&self, id: u32) -> Result<()> { self.inner.lock().await.release_buffer(id) } /// Get buffer (requires holding lock) pub async fn with_buffer(&self, id: u32, f: F) -> Option where F: FnOnce(&ManagedBuffer) -> R, { let mgr = self.inner.lock().await; mgr.get_buffer(id).map(f) } /// Get mutable buffer (requires holding lock) pub async fn with_buffer_mut(&self, id: u32, f: F) -> Option where F: FnOnce(&mut ManagedBuffer) -> R, { let mut mgr = self.inner.lock().await; mgr.get_buffer_mut(id).map(f) } /// Unregister a buffer pub async fn unregister_buffer(&self, id: u32) -> Result<()> { self.inner.lock().await.unregister_buffer(id) } /// Get statistics pub async fn stats(&self) -> BufferStats { self.inner.lock().await.stats().clone() } /// Get free count pub async fn free_count(&self) -> usize { self.inner.lock().await.free_count() } /// Get in-use count pub async fn in_use_count(&self) -> usize { self.inner.lock().await.in_use_count() } /// Clone the Arc pub fn clone(&self) -> Self { Self { inner: Arc::clone(&self.inner), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_buffer_creation() { let buffer = ManagedBuffer::new(0, BufferType::MemPtr, 1024); assert_eq!(buffer.id, 0); assert_eq!(buffer.buffer_type, BufferType::MemPtr); assert_eq!(buffer.size, 1024); assert!(!buffer.in_use); } #[test] fn test_buffer_acquire_release() { let mut buffer = ManagedBuffer::new(0, BufferType::MemPtr, 1024); buffer.acquire(); assert!(buffer.in_use); assert_eq!(buffer.use_count, 1); buffer.release(); assert!(!buffer.in_use); assert_eq!(buffer.use_count, 1); } #[test] fn test_buffer_manager() { let mut mgr = BufferManager::new(5); // Register buffers let _id1 = mgr.register_buffer(BufferType::MemPtr, 1024, None, 0).unwrap(); let _id2 = mgr.register_buffer(BufferType::MemPtr, 2048, None, 0).unwrap(); assert_eq!(mgr.total_count(), 2); assert_eq!(mgr.free_count(), 2); // Acquire buffer let acquired = mgr.acquire_buffer().unwrap(); assert_eq!(mgr.free_count(), 1); assert_eq!(mgr.in_use_count(), 1); // Release buffer mgr.release_buffer(acquired).unwrap(); assert_eq!(mgr.free_count(), 2); assert_eq!(mgr.in_use_count(), 0); } #[test] fn test_buffer_limit() { let mut mgr = BufferManager::new(2); mgr.register_buffer(BufferType::MemPtr, 1024, None, 0).unwrap(); mgr.register_buffer(BufferType::MemPtr, 1024, None, 0).unwrap(); // Should fail due to limit let result = mgr.register_buffer(BufferType::MemPtr, 1024, None, 0); assert!(result.is_err()); } #[tokio::test] async fn test_shared_buffer_manager() { let mgr = SharedBufferManager::new(5); let id = mgr.register_buffer(BufferType::MemPtr, 1024, None, 0).await.unwrap(); assert_eq!(mgr.free_count().await, 1); let acquired = mgr.acquire_buffer().await.unwrap(); assert_eq!(acquired, id); assert_eq!(mgr.in_use_count().await, 1); mgr.release_buffer(acquired).await.unwrap(); assert_eq!(mgr.free_count().await, 1); } } lamco-pipewire-0.4.0/src/config.rs000064400000000000000000000374241046102023000151560ustar 00000000000000//! PipeWire Configuration //! //! Provides configuration options for PipeWire screen capture with a builder pattern //! for ergonomic construction. //! //! # Examples //! //! ```rust //! use lamco_pipewire::{PipeWireConfig, PixelFormat}; //! //! // Using builder pattern //! let config = PipeWireConfig::builder() //! .buffer_count(4) //! .preferred_format(PixelFormat::BGRA) //! .use_dmabuf(true) //! .max_streams(4) //! .build(); //! //! // Using struct literal with defaults //! let config = PipeWireConfig { //! buffer_count: 4, //! ..Default::default() //! }; //! ``` use crate::format::PixelFormat; /// Configuration for PipeWire screen capture /// /// This struct contains all configuration options for the PipeWire integration. /// Use [`PipeWireConfig::builder()`] for ergonomic construction or struct literal /// syntax with [`Default::default()`]. #[derive(Debug, Clone)] pub struct PipeWireConfig { /// Number of buffers to allocate per stream (default: 3) /// /// Higher values reduce frame drops at the cost of memory and latency. /// Recommended: 2-3 for low latency, 4-5 for high refresh rates. pub buffer_count: u32, /// Preferred pixel format for capture (default: BGRA) /// /// The actual format may differ based on compositor capabilities. /// Format negotiation will fall back to available formats. pub preferred_format: Option, /// Whether to use DMA-BUF for zero-copy transfer (default: true) /// /// DMA-BUF provides hardware-accelerated, zero-copy frame transfer when /// supported by the GPU and compositor. Falls back to memory copy if unavailable. pub use_dmabuf: bool, /// When true AND the buffer is DMA-BUF, pass the FD directly to the /// consumer instead of mmap+copying pixels to CPU memory. /// /// Requires the downstream encoder to support DMA-BUF import (e.g., /// Vulkan Video via VK_EXT_external_memory_dma_buf). Falls back to /// mmap+copy when the encoder doesn't support it. /// /// Default: false (conservative — existing mmap path is well-tested) pub dmabuf_passthrough: bool, /// Maximum number of concurrent streams (default: 8) /// /// Limits resource usage in multi-monitor scenarios. pub max_streams: usize, /// Frame buffer size for the receiver channel (default: 30) /// /// Number of frames that can be buffered before dropping. /// Higher values handle burst traffic but increase memory usage. pub frame_buffer_size: usize, /// Enable hardware cursor extraction (default: false) /// /// When enabled, cursor position and bitmap are extracted separately /// from the video stream. Requires the `cursor` feature. pub enable_cursor: bool, /// Enable region damage tracking (default: false) /// /// When enabled, tracks which regions of the frame changed between /// captures for efficient encoding. Requires the `damage` feature. pub enable_damage_tracking: bool, /// Adaptive bitrate configuration (default: None) /// /// When set, enables adaptive bitrate control for streaming scenarios. /// Requires the `adaptive` feature. pub adaptive_bitrate: Option, /// Stream name prefix (default: "lamco-pw") /// /// Prefix used for PipeWire stream names. The stream ID is appended. pub stream_name_prefix: String, /// Connection timeout in milliseconds (default: 5000) /// /// Maximum time to wait for PipeWire connection to establish. pub connection_timeout_ms: u64, /// Enable automatic reconnection on disconnect (default: true) pub auto_reconnect: bool, /// Maximum reconnection attempts (default: 3) pub max_reconnect_attempts: u32, } impl Default for PipeWireConfig { fn default() -> Self { Self { buffer_count: 3, preferred_format: Some(PixelFormat::BGRA), use_dmabuf: true, dmabuf_passthrough: false, max_streams: 8, frame_buffer_size: 30, enable_cursor: false, enable_damage_tracking: false, adaptive_bitrate: None, stream_name_prefix: "lamco-pw".to_string(), connection_timeout_ms: 5000, auto_reconnect: true, max_reconnect_attempts: 3, } } } impl PipeWireConfig { /// Create a new configuration builder /// /// # Examples /// /// ```rust /// use lamco_pipewire::PipeWireConfig; /// /// let config = PipeWireConfig::builder() /// .buffer_count(4) /// .use_dmabuf(true) /// .build(); /// ``` #[must_use] pub fn builder() -> PipeWireConfigBuilder { PipeWireConfigBuilder::default() } /// Validate configuration and return any issues /// /// Returns `Ok(())` if configuration is valid, or a list of issues. pub fn validate(&self) -> Result<(), Vec> { let mut issues = Vec::new(); if self.buffer_count == 0 { issues.push("buffer_count must be at least 1".to_string()); } if self.buffer_count > 16 { issues.push("buffer_count should not exceed 16".to_string()); } if self.max_streams == 0 { issues.push("max_streams must be at least 1".to_string()); } if self.frame_buffer_size == 0 { issues.push("frame_buffer_size must be at least 1".to_string()); } if self.connection_timeout_ms < 100 { issues.push("connection_timeout_ms should be at least 100ms".to_string()); } if self.stream_name_prefix.is_empty() { issues.push("stream_name_prefix cannot be empty".to_string()); } if issues.is_empty() { Ok(()) } else { Err(issues) } } } /// Builder for [`PipeWireConfig`] /// /// Provides a fluent interface for constructing configuration. #[derive(Debug, Clone, Default)] pub struct PipeWireConfigBuilder { buffer_count: Option, preferred_format: Option, use_dmabuf: Option, max_streams: Option, frame_buffer_size: Option, enable_cursor: Option, enable_damage_tracking: Option, adaptive_bitrate: Option, stream_name_prefix: Option, connection_timeout_ms: Option, auto_reconnect: Option, max_reconnect_attempts: Option, } impl PipeWireConfigBuilder { /// Set number of buffers per stream #[must_use] pub fn buffer_count(mut self, count: u32) -> Self { self.buffer_count = Some(count); self } /// Set preferred pixel format #[must_use] pub fn preferred_format(mut self, format: PixelFormat) -> Self { self.preferred_format = Some(format); self } /// Set whether to use DMA-BUF #[must_use] pub fn use_dmabuf(mut self, enable: bool) -> Self { self.use_dmabuf = Some(enable); self } /// Set maximum concurrent streams #[must_use] pub fn max_streams(mut self, max: usize) -> Self { self.max_streams = Some(max); self } /// Set frame buffer size #[must_use] pub fn frame_buffer_size(mut self, size: usize) -> Self { self.frame_buffer_size = Some(size); self } /// Enable hardware cursor extraction #[must_use] pub fn enable_cursor(mut self, enable: bool) -> Self { self.enable_cursor = Some(enable); self } /// Enable region damage tracking #[must_use] pub fn enable_damage_tracking(mut self, enable: bool) -> Self { self.enable_damage_tracking = Some(enable); self } /// Set adaptive bitrate configuration #[must_use] pub fn adaptive_bitrate(mut self, config: AdaptiveBitrateConfig) -> Self { self.adaptive_bitrate = Some(config); self } /// Set stream name prefix #[must_use] pub fn stream_name_prefix(mut self, prefix: impl Into) -> Self { self.stream_name_prefix = Some(prefix.into()); self } /// Set connection timeout in milliseconds #[must_use] pub fn connection_timeout_ms(mut self, timeout: u64) -> Self { self.connection_timeout_ms = Some(timeout); self } /// Set whether to auto-reconnect on disconnect #[must_use] pub fn auto_reconnect(mut self, enable: bool) -> Self { self.auto_reconnect = Some(enable); self } /// Set maximum reconnection attempts #[must_use] pub fn max_reconnect_attempts(mut self, attempts: u32) -> Self { self.max_reconnect_attempts = Some(attempts); self } /// Build the configuration /// /// Returns a [`PipeWireConfig`] with builder values overriding defaults. #[must_use] pub fn build(self) -> PipeWireConfig { let defaults = PipeWireConfig::default(); PipeWireConfig { buffer_count: self.buffer_count.unwrap_or(defaults.buffer_count), preferred_format: self.preferred_format.or(defaults.preferred_format), use_dmabuf: self.use_dmabuf.unwrap_or(defaults.use_dmabuf), dmabuf_passthrough: defaults.dmabuf_passthrough, max_streams: self.max_streams.unwrap_or(defaults.max_streams), frame_buffer_size: self.frame_buffer_size.unwrap_or(defaults.frame_buffer_size), enable_cursor: self.enable_cursor.unwrap_or(defaults.enable_cursor), enable_damage_tracking: self.enable_damage_tracking.unwrap_or(defaults.enable_damage_tracking), adaptive_bitrate: self.adaptive_bitrate.or(defaults.adaptive_bitrate), stream_name_prefix: self.stream_name_prefix.unwrap_or(defaults.stream_name_prefix), connection_timeout_ms: self.connection_timeout_ms.unwrap_or(defaults.connection_timeout_ms), auto_reconnect: self.auto_reconnect.unwrap_or(defaults.auto_reconnect), max_reconnect_attempts: self.max_reconnect_attempts.unwrap_or(defaults.max_reconnect_attempts), } } } /// Configuration for adaptive bitrate control /// /// Used for streaming scenarios where bandwidth may vary. #[derive(Debug, Clone)] pub struct AdaptiveBitrateConfig { /// Minimum bitrate in kbps (default: 500) pub min_bitrate_kbps: u32, /// Maximum bitrate in kbps (default: 50000) pub max_bitrate_kbps: u32, /// Target frames per second (default: 30) pub target_fps: u32, /// Quality preset (default: Balanced) pub quality_preset: QualityPreset, /// Window size for bitrate calculations in frames (default: 30) pub calculation_window: usize, } impl Default for AdaptiveBitrateConfig { fn default() -> Self { Self { min_bitrate_kbps: 500, max_bitrate_kbps: 50000, target_fps: 30, quality_preset: QualityPreset::Balanced, calculation_window: 30, } } } impl AdaptiveBitrateConfig { /// Create a new builder #[must_use] pub fn builder() -> AdaptiveBitrateConfigBuilder { AdaptiveBitrateConfigBuilder::default() } /// Create configuration optimized for low latency #[must_use] pub fn low_latency() -> Self { Self { min_bitrate_kbps: 1000, max_bitrate_kbps: 20000, target_fps: 60, quality_preset: QualityPreset::LowLatency, calculation_window: 15, } } /// Create configuration optimized for high quality #[must_use] pub fn high_quality() -> Self { Self { min_bitrate_kbps: 5000, max_bitrate_kbps: 100000, target_fps: 30, quality_preset: QualityPreset::HighQuality, calculation_window: 60, } } } /// Builder for [`AdaptiveBitrateConfig`] #[derive(Debug, Clone, Default)] pub struct AdaptiveBitrateConfigBuilder { min_bitrate_kbps: Option, max_bitrate_kbps: Option, target_fps: Option, quality_preset: Option, calculation_window: Option, } impl AdaptiveBitrateConfigBuilder { /// Set minimum bitrate in kbps #[must_use] pub fn min_bitrate_kbps(mut self, kbps: u32) -> Self { self.min_bitrate_kbps = Some(kbps); self } /// Set maximum bitrate in kbps #[must_use] pub fn max_bitrate_kbps(mut self, kbps: u32) -> Self { self.max_bitrate_kbps = Some(kbps); self } /// Set target FPS #[must_use] pub fn target_fps(mut self, fps: u32) -> Self { self.target_fps = Some(fps); self } /// Set quality preset #[must_use] pub fn quality_preset(mut self, preset: QualityPreset) -> Self { self.quality_preset = Some(preset); self } /// Set calculation window size #[must_use] pub fn calculation_window(mut self, frames: usize) -> Self { self.calculation_window = Some(frames); self } /// Build the configuration #[must_use] pub fn build(self) -> AdaptiveBitrateConfig { let defaults = AdaptiveBitrateConfig::default(); AdaptiveBitrateConfig { min_bitrate_kbps: self.min_bitrate_kbps.unwrap_or(defaults.min_bitrate_kbps), max_bitrate_kbps: self.max_bitrate_kbps.unwrap_or(defaults.max_bitrate_kbps), target_fps: self.target_fps.unwrap_or(defaults.target_fps), quality_preset: self.quality_preset.unwrap_or(defaults.quality_preset), calculation_window: self.calculation_window.unwrap_or(defaults.calculation_window), } } } /// Quality preset for adaptive bitrate control #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum QualityPreset { /// Optimize for lowest latency (faster encoding, lower quality) LowLatency, /// Balance between latency and quality (default) #[default] Balanced, /// Optimize for highest quality (slower encoding, higher quality) HighQuality, } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config() { let config = PipeWireConfig::default(); assert_eq!(config.buffer_count, 3); assert!(config.use_dmabuf); assert_eq!(config.max_streams, 8); assert_eq!(config.stream_name_prefix, "lamco-pw"); } #[test] fn test_builder_pattern() { let config = PipeWireConfig::builder() .buffer_count(5) .use_dmabuf(false) .max_streams(4) .stream_name_prefix("test-capture") .build(); assert_eq!(config.buffer_count, 5); assert!(!config.use_dmabuf); assert_eq!(config.max_streams, 4); assert_eq!(config.stream_name_prefix, "test-capture"); } #[test] fn test_config_validation() { let valid_config = PipeWireConfig::default(); assert!(valid_config.validate().is_ok()); let invalid_config = PipeWireConfig { buffer_count: 0, ..Default::default() }; assert!(invalid_config.validate().is_err()); } #[test] fn test_adaptive_bitrate_presets() { let low_latency = AdaptiveBitrateConfig::low_latency(); assert_eq!(low_latency.quality_preset, QualityPreset::LowLatency); assert_eq!(low_latency.target_fps, 60); let high_quality = AdaptiveBitrateConfig::high_quality(); assert_eq!(high_quality.quality_preset, QualityPreset::HighQuality); assert!(high_quality.max_bitrate_kbps > low_latency.max_bitrate_kbps); } #[test] fn test_adaptive_bitrate_builder() { let config = AdaptiveBitrateConfig::builder() .min_bitrate_kbps(1000) .max_bitrate_kbps(30000) .target_fps(60) .quality_preset(QualityPreset::LowLatency) .build(); assert_eq!(config.min_bitrate_kbps, 1000); assert_eq!(config.max_bitrate_kbps, 30000); assert_eq!(config.target_fps, 60); } } lamco-pipewire-0.4.0/src/connection.rs000064400000000000000000000367361046102023000160550ustar 00000000000000//! PipeWire Connection Management //! //! Handles connection to PipeWire daemon via portal file descriptor with //! complete MainLoop integration, proper threading, and robust error handling. use std::collections::HashMap; use std::os::fd::{FromRawFd, OwnedFd, RawFd}; use std::sync::Arc; use std::thread; use pipewire::context::ContextBox; use pipewire::main_loop::MainLoopBox; use tokio::sync::{Mutex, RwLock, mpsc}; use tracing::{debug, error, info, warn}; use crate::error::{PipeWireError, Result}; use crate::stream::{PipeWireStream, StreamConfig}; /// Connection state #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionState { /// Not connected Disconnected, /// Connecting Connecting, /// Connected and ready Connected, /// Connection error Error, } /// PipeWire connection statistics #[derive(Debug, Clone, Default)] pub struct ConnectionStats { /// Number of streams created pub streams_created: u64, /// Number of streams destroyed pub streams_destroyed: u64, /// Total frames processed pub total_frames: u64, /// Total bytes processed pub total_bytes: u64, /// Connection uptime (seconds) pub uptime_secs: u64, /// Number of reconnections pub reconnections: u64, } /// PipeWire connection events #[derive(Debug, Clone)] pub enum PipeWireEvent { /// Connection established Connected, /// Connection lost Disconnected, /// Stream added StreamAdded(u32), /// Stream removed StreamRemoved(u32), /// Stream error StreamError(u32, String), /// Core error CoreError(String), } /// PipeWire connection manager /// /// Manages the PipeWire connection using a dedicated thread for the MainLoop. /// This is necessary because PipeWire's types (MainLoop, Context, Core) use `Rc` /// and `NonNull` which are not `Send`, so they must live on a single thread. pub struct PipeWireConnection { /// File descriptor from portal fd: RawFd, /// Active streams streams: Arc>>>>, /// Connection state state: Arc>, /// Event sender event_tx: Option>, /// Statistics stats: Arc>, /// Next stream ID next_stream_id: Arc>, /// Thread handle for PipeWire main loop /// PipeWire must run on its own thread because its types are not Send thread_handle: Option>, /// Shutdown signal shutdown_tx: Option>, } impl PipeWireConnection { /// Create new PipeWire connection /// /// This initializes the connection manager but does not start the MainLoop. /// Call `connect()` to establish the connection and start processing. pub fn new(fd: RawFd) -> Result { debug!("Creating PipeWire connection with FD {}", fd); Ok(Self { fd, streams: Arc::new(Mutex::new(HashMap::new())), state: Arc::new(RwLock::new(ConnectionState::Disconnected)), event_tx: None, stats: Arc::new(Mutex::new(ConnectionStats::default())), next_stream_id: Arc::new(Mutex::new(0)), thread_handle: None, shutdown_tx: None, }) } /// Initialize PipeWire connection and start MainLoop /// /// This spawns a dedicated thread for the PipeWire MainLoop since PipeWire /// types are not Send and must live on a single thread. /// /// # Errors /// /// Returns error if PipeWire initialization fails or connection cannot be established pub async fn connect(&mut self) -> Result<()> { *self.state.write().await = ConnectionState::Connecting; info!("Connecting to PipeWire with FD {}", self.fd); // Create shutdown channel let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); self.shutdown_tx = Some(shutdown_tx); let fd = self.fd; let state = Arc::clone(&self.state); let event_tx = self.event_tx.clone(); // Spawn dedicated thread for PipeWire MainLoop // This is REQUIRED because PipeWire types use Rc<> and are not Send let thread_handle = thread::spawn(move || { debug!("PipeWire thread started"); // Initialize PipeWire library pipewire::init(); // Create main loop let main_loop = match MainLoopBox::new(None) { Ok(ml) => ml, Err(e) => { error!("Failed to create PipeWire MainLoop: {}", e); return; } }; // Create context (0.9 API: takes &Loop + optional properties) let context = match ContextBox::new(main_loop.loop_(), None) { Ok(ctx) => ctx, Err(e) => { error!("Failed to create PipeWire context: {}", e); return; } }; // Connect core using the portal-provided FD // SAFETY: The FD was provided by XDG Desktop Portal via lamco-portal. // We take exclusive ownership here - the FD is not used anywhere else. // OwnedFd will close the FD when dropped or transferred to PipeWire. let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) }; let core = match context.connect_fd(owned_fd, None) { Ok(c) => c, Err(e) => { error!("Failed to connect PipeWire core with FD {}: {}", fd, e); return; } }; info!("PipeWire connected successfully on FD {}", fd); // Update state to connected *futures::executor::block_on(state.write()) = ConnectionState::Connected; // Notify connected if let Some(tx) = event_tx { let _ = futures::executor::block_on(tx.send(PipeWireEvent::Connected)); } // Run the main loop // We need to integrate shutdown signaling loop { // Check for shutdown signal (non-blocking) if shutdown_rx.try_recv().is_ok() { info!("Shutdown signal received, stopping PipeWire main loop"); break; } // Run one iteration of the main loop // pipewire-rs MainLoop doesn't have loop_iterate, use the Loop directly let loop_ref = main_loop.loop_(); loop_ref.iterate(std::time::Duration::from_millis(10)); } debug!("PipeWire thread exiting"); // Cleanup drop(core); drop(context); drop(main_loop); // SAFETY: pipewire::deinit() must be called once per pipewire::init(). // This is called at thread exit after all PipeWire resources (core, // context, main_loop) have been dropped. This thread is the only one // that called init(), so deinit() here is safe. unsafe { pipewire::deinit(); } }); self.thread_handle = Some(thread_handle); // Wait for connection to be established let mut attempts = 0; while attempts < 50 { // Wait up to 5 seconds (50 * 100ms) tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; if *self.state.read().await == ConnectionState::Connected { info!("PipeWire connection established"); return Ok(()); } attempts += 1; } *self.state.write().await = ConnectionState::Error; Err(PipeWireError::ConnectionFailed( "Timeout waiting for PipeWire connection".to_string(), )) } /// Disconnect from PipeWire /// /// Stops all streams, signals the MainLoop thread to exit, and cleans up resources. pub async fn disconnect(&mut self) -> Result<()> { info!("Disconnecting from PipeWire"); *self.state.write().await = ConnectionState::Disconnected; // Stop all streams first let stream_ids: Vec = self.streams.lock().await.keys().copied().collect(); for id in stream_ids { if let Err(e) = self.remove_stream(id).await { warn!("Error removing stream {}: {}", id, e); } } // Signal shutdown to PipeWire thread if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()).await; } // Wait for thread to finish if let Some(handle) = self.thread_handle.take() { if handle.join().is_err() { error!("PipeWire thread panicked during shutdown"); } } if let Some(ref tx) = self.event_tx { let _ = tx.send(PipeWireEvent::Disconnected).await; } info!("PipeWire disconnected"); Ok(()) } /// Get connection state pub async fn state(&self) -> ConnectionState { *self.state.read().await } /// Check if connected pub async fn is_connected(&self) -> bool { *self.state.read().await == ConnectionState::Connected } /// Create a new stream /// /// Creates and initializes a PipeWire stream for the specified node ID. /// /// # Arguments /// /// * `config` - Stream configuration /// * `node_id` - PipeWire node ID from portal /// /// # Returns /// /// The stream ID on success /// /// # Errors /// /// Returns error if not connected or stream creation fails pub async fn create_stream(&mut self, config: StreamConfig, node_id: u32) -> Result { if !self.is_connected().await { return Err(PipeWireError::ConnectionFailed("Not connected to PipeWire".to_string())); } // Generate stream ID let stream_id = { let mut id = self.next_stream_id.lock().await; let sid = *id; *id += 1; sid }; debug!( "Creating stream {} for node {} with config: {:?}", stream_id, node_id, config ); // Create stream // The stream will be connected to the Core on the PipeWire thread // via message passing (implemented in thread_comm.rs) let stream = PipeWireStream::new(stream_id, config); // Store stream reference // The actual PipeWire stream connection happens lazily on first use // or can be triggered explicitly via start_stream() self.streams .lock() .await .insert(stream_id, Arc::new(Mutex::new(stream))); // Update stats self.stats.lock().await.streams_created += 1; if let Some(ref tx) = self.event_tx { let _ = tx.send(PipeWireEvent::StreamAdded(stream_id)).await; } debug!("Stream {} created successfully", stream_id); Ok(stream_id) } /// Get a stream by ID pub async fn get_stream(&self, stream_id: u32) -> Option>> { self.streams.lock().await.get(&stream_id).cloned() } /// Remove a stream /// /// Stops and removes the specified stream from the connection. pub async fn remove_stream(&mut self, stream_id: u32) -> Result<()> { debug!("Removing stream {}", stream_id); if let Some(stream_arc) = self.streams.lock().await.remove(&stream_id) { // Stop the stream let mut stream = stream_arc.lock().await; stream.stop().await?; // Update stats self.stats.lock().await.streams_destroyed += 1; if let Some(ref tx) = self.event_tx { let _ = tx.send(PipeWireEvent::StreamRemoved(stream_id)).await; } debug!("Stream {} removed successfully", stream_id); Ok(()) } else { Err(PipeWireError::StreamNotFound(stream_id)) } } /// Get all active stream IDs pub async fn active_streams(&self) -> Vec { self.streams.lock().await.keys().copied().collect() } /// Get stream count pub async fn stream_count(&self) -> usize { self.streams.lock().await.len() } /// Set event channel /// /// Configure a channel to receive PipeWire events pub fn set_event_channel(&mut self, tx: mpsc::Sender) { self.event_tx = Some(tx); } /// Get statistics pub async fn stats(&self) -> ConnectionStats { self.stats.lock().await.clone() } /// Get file descriptor pub fn fd(&self) -> RawFd { self.fd } } impl Drop for PipeWireConnection { fn drop(&mut self) { debug!("Dropping PipeWire connection"); // Signal shutdown if let Some(tx) = self.shutdown_tx.take() { let _ = futures::executor::block_on(tx.send(())); } // Wait for thread to finish with timeout if let Some(handle) = self.thread_handle.take() { if handle.join().is_err() { error!("PipeWire thread panicked during cleanup"); } } } } // SAFETY: PipeWireConnection is Send because: // 1. All PipeWire operations occur on a dedicated thread (not the caller's thread) // 2. Communication is via thread-safe channels (std::sync::mpsc, tokio::sync) // 3. State access uses Arc> for safe concurrent access // 4. The thread handle itself is only accessed during cleanup (Drop) unsafe impl Send for PipeWireConnection {} // SAFETY: PipeWireConnection is Sync because: // 1. All state access goes through Arc> which is Sync // 2. Command channels use thread-safe std::sync::mpsc::SyncSender // 3. No direct access to PipeWire types from outside the dedicated thread // 4. Methods that access shared state use proper synchronization (await on locks) unsafe impl Sync for PipeWireConnection {} #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_connection_creation() { let conn = PipeWireConnection::new(3).unwrap(); assert_eq!(conn.state().await, ConnectionState::Disconnected); assert_eq!(conn.fd(), 3); } #[tokio::test] #[ignore] // Requires actual PipeWire daemon async fn test_connection_lifecycle() { let mut conn = PipeWireConnection::new(3).unwrap(); assert_eq!(conn.state().await, ConnectionState::Disconnected); assert!(!conn.is_connected().await); conn.connect().await.unwrap(); assert_eq!(conn.state().await, ConnectionState::Connected); assert!(conn.is_connected().await); conn.disconnect().await.unwrap(); assert_eq!(conn.state().await, ConnectionState::Disconnected); assert!(!conn.is_connected().await); } #[tokio::test] async fn test_stream_id_generation() { let conn = PipeWireConnection::new(3).unwrap(); let id1 = *conn.next_stream_id.lock().await; *conn.next_stream_id.lock().await += 1; let id2 = *conn.next_stream_id.lock().await; assert_ne!(id1, id2); } #[tokio::test] async fn test_event_channel() { let mut conn = PipeWireConnection::new(3).unwrap(); let (tx, mut rx) = mpsc::channel(10); conn.set_event_channel(tx); // Manually send event for testing if let Some(ref tx) = conn.event_tx { tx.send(PipeWireEvent::Connected).await.unwrap(); } // Should receive Connected event if let Some(event) = rx.recv().await { assert!(matches!(event, PipeWireEvent::Connected)); } } } lamco-pipewire-0.4.0/src/coordinator.rs000064400000000000000000000320031046102023000162200ustar 00000000000000//! Multi-Stream Coordination //! //! Coordinates multiple PipeWire streams for multi-monitor setups. use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, mpsc}; use tokio::task::JoinHandle; use crate::connection::PipeWireConnection; use crate::error::{PipeWireError, Result}; use crate::frame::VideoFrame; use crate::stream::{PipeWireStream, PwStreamState, StreamConfig}; /// Stream information from portal session /// /// This struct mirrors the information provided by XDG portal's ScreenCast /// session. Users of lamco-portal can easily convert their `StreamInfo` to /// this type for use with the coordinator. #[derive(Debug, Clone)] pub struct StreamInfo { /// PipeWire node ID pub node_id: u32, /// Stream position (for multi-monitor setups) pub position: (i32, i32), /// Stream size (width, height) pub size: (u32, u32), /// Source type (monitor, window, etc.) pub source_type: SourceType, } /// Source type for streams #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SourceType { /// Full monitor capture #[default] Monitor, /// Window capture Window, /// Virtual source Virtual, } /// Monitor information #[derive(Debug, Clone)] pub struct MonitorInfo { /// Monitor ID pub id: u32, /// Monitor name pub name: String, /// Position pub position: (i32, i32), /// Size pub size: (u32, u32), /// Refresh rate pub refresh_rate: u32, /// PipeWire node ID pub node_id: u32, } impl MonitorInfo { /// Create from StreamInfo pub fn from_stream_info(stream_info: &StreamInfo, name: String) -> Self { Self { id: stream_info.node_id, name, position: stream_info.position, size: stream_info.size, refresh_rate: 60, // Default node_id: stream_info.node_id, } } } /// Monitor event #[derive(Debug, Clone)] pub enum MonitorEvent { /// Monitor added Added(MonitorInfo), /// Monitor removed Removed(u32), /// Monitor changed Changed(MonitorInfo), } /// Stream handle #[derive(Clone)] pub struct StreamHandle { /// Stream ID pub id: u32, /// Stream reference pub stream: Arc>, /// Monitor information pub monitor: MonitorInfo, /// Stream state pub state: StreamState, /// Monitoring task handle pub task: Option>>, } /// Stream state #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamState { /// Stream is initializing Initializing, /// Stream is active Active, /// Stream is paused Paused, /// Stream has error Error, /// Stream is closing Closing, } /// Pending stream creation (for retry logic in future implementation) #[allow(dead_code)] struct PendingStream { monitor: MonitorInfo, retry_count: u32, last_attempt: Instant, } /// Multi-stream configuration #[derive(Debug, Clone)] pub struct MultiStreamConfig { /// Maximum concurrent streams pub max_streams: usize, /// Dispatcher configuration pub dispatcher_config: DispatcherConfig, /// Enable stream synchronization pub enable_sync: bool, /// Retry configuration pub retry_attempts: u32, } impl Default for MultiStreamConfig { fn default() -> Self { Self { max_streams: 8, dispatcher_config: DispatcherConfig::default(), enable_sync: true, retry_attempts: 5, } } } /// Dispatcher configuration #[derive(Debug, Clone)] pub struct DispatcherConfig { /// Frame buffer size per stream pub frame_buffer_size: usize, /// Enable frame ordering pub enable_ordering: bool, } impl Default for DispatcherConfig { fn default() -> Self { Self { frame_buffer_size: 32, enable_ordering: true, } } } /// Multi-stream coordinator pub struct MultiStreamCoordinator { /// Active streams streams: Arc>>, /// Pending streams pending_streams: Arc>>, /// Frame dispatcher frame_dispatcher: Arc, /// Configuration config: MultiStreamConfig, /// Statistics stats: Arc>, } impl MultiStreamCoordinator { /// Create new coordinator pub async fn new(config: MultiStreamConfig) -> Result { Ok(Self { streams: Arc::new(RwLock::new(HashMap::new())), pending_streams: Arc::new(Mutex::new(Vec::new())), frame_dispatcher: Arc::new(FrameDispatcher::new(config.dispatcher_config.clone())), config, stats: Arc::new(Mutex::new(CoordinatorStats::default())), }) } /// Add a stream for a monitor pub async fn add_stream(&self, monitor: MonitorInfo, connection: &mut PipeWireConnection) -> Result { // Check stream limit if self.streams.read().await.len() >= self.config.max_streams { return Err(PipeWireError::TooManyStreams(self.config.max_streams)); } // Create stream configuration let stream_config = StreamConfig::new(monitor.name.clone()) .with_resolution(monitor.size.0, monitor.size.1) .with_framerate(monitor.refresh_rate); // Create PipeWire stream via connection let stream_id = connection.create_stream(stream_config, monitor.node_id).await?; // Get the stream if let Some(stream_arc) = connection.get_stream(stream_id).await { // Set up frame callback let dispatcher = self.frame_dispatcher.clone(); let monitor_id = monitor.id; { let mut stream = stream_arc.lock().await; stream.set_frame_callback(Box::new(move |frame| { dispatcher.dispatch_frame(monitor_id, frame); })); } // Create stream handle let handle = StreamHandle { id: stream_id, stream: stream_arc.clone(), monitor: monitor.clone(), state: StreamState::Active, task: None, // Monitoring task disabled for Send safety }; // Note: Monitoring task disabled in this implementation // In production, use a separate monitoring service that checks stream health // without needing to clone StreamHandle across thread boundaries // Store stream self.streams.write().await.insert(monitor.id, handle); // Update stats self.stats.lock().await.streams_created += 1; Ok(stream_id) } else { Err(PipeWireError::StreamCreationFailed( "Stream not found after creation".to_string(), )) } } /// Remove a stream pub async fn remove_stream(&self, monitor_id: u32) -> Result<()> { let mut streams = self.streams.write().await; if let Some(mut handle) = streams.remove(&monitor_id) { // Stop monitoring task if let Some(task) = handle.task.take() { if let Ok(task) = Arc::try_unwrap(task) { task.abort(); } } // Stop the stream handle.stream.lock().await.stop().await?; // Update stats self.stats.lock().await.streams_destroyed += 1; Ok(()) } else { Err(PipeWireError::StreamNotFound(monitor_id)) } } /// Handle monitor change event pub async fn handle_monitor_event(&self, event: MonitorEvent) -> Result<()> { match event { MonitorEvent::Added(monitor) => { // Queue for stream creation self.pending_streams.lock().await.push(PendingStream { monitor, retry_count: 0, last_attempt: Instant::now(), }); } MonitorEvent::Removed(monitor_id) => { // Remove stream self.remove_stream(monitor_id).await?; } MonitorEvent::Changed(monitor) => { // For now, just update monitor info // Full implementation would reconfigure the stream if let Some(handle) = self.streams.write().await.get_mut(&monitor.id) { handle.monitor = monitor; } } } Ok(()) } /// Get active stream count pub async fn active_streams(&self) -> usize { self.streams.read().await.len() } /// Get stream by monitor ID pub async fn get_stream(&self, monitor_id: u32) -> Option>> { self.streams.read().await.get(&monitor_id).map(|h| h.stream.clone()) } /// Get frame receiver for a monitor pub async fn get_frame_receiver(&self, monitor_id: u32) -> Option> { self.frame_dispatcher.register_receiver(monitor_id).await } /// Get statistics pub async fn stats(&self) -> CoordinatorStats { self.stats.lock().await.clone() } } /// Frame dispatcher pub struct FrameDispatcher { /// Frame receivers indexed by monitor ID receivers: Arc>>>, /// Configuration config: DispatcherConfig, } impl FrameDispatcher { /// Create new dispatcher pub fn new(config: DispatcherConfig) -> Self { Self { receivers: Arc::new(RwLock::new(HashMap::new())), config, } } /// Dispatch frame to appropriate receiver pub fn dispatch_frame(&self, monitor_id: u32, frame: VideoFrame) { // Send to monitor-specific receiver if let Some(tx) = self.receivers.blocking_read().get(&monitor_id) { let _ = tx.try_send(frame); } } /// Register a new receiver for a monitor pub async fn register_receiver(&self, monitor_id: u32) -> Option> { let (tx, rx) = mpsc::channel(self.config.frame_buffer_size); self.receivers.write().await.insert(monitor_id, tx); Some(rx) } /// Unregister receiver pub async fn unregister_receiver(&self, monitor_id: u32) { self.receivers.write().await.remove(&monitor_id); } } /// Coordinator statistics #[derive(Debug, Clone, Default)] pub struct CoordinatorStats { /// Streams created pub streams_created: u64, /// Streams destroyed pub streams_destroyed: u64, /// Stream errors pub stream_errors: u64, /// Reconnections pub reconnections: u64, } /// Monitor stream health /// /// This function is currently unused but preserved for future health monitoring implementation. #[allow(dead_code)] async fn monitor_stream_health(handle: StreamHandle) { let mut interval = tokio::time::interval(Duration::from_secs(1)); let mut _stall_count = 0; loop { interval.tick().await; // Check stream state let state = handle.stream.lock().await.state(); match state { PwStreamState::Error(ref msg) => { tracing::warn!("Stream {} in error state: {}", handle.id, msg); break; } PwStreamState::Unconnected => { tracing::info!("Stream {} disconnected", handle.id); break; } _ => { // Reset stall counter (for future stall detection) _stall_count = 0; } } // Exit if not active if handle.state != StreamState::Active { break; } } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_coordinator_creation() { let config = MultiStreamConfig::default(); let coordinator = MultiStreamCoordinator::new(config).await.unwrap(); assert_eq!(coordinator.active_streams().await, 0); } #[test] fn test_monitor_info() { let info = MonitorInfo { id: 1, name: "Monitor-1".to_string(), position: (0, 0), size: (1920, 1080), refresh_rate: 60, node_id: 42, }; assert_eq!(info.id, 1); assert_eq!(info.size, (1920, 1080)); } #[tokio::test] async fn test_frame_dispatcher() { let config = DispatcherConfig::default(); let dispatcher = FrameDispatcher::new(config); let mut rx = dispatcher.register_receiver(1).await.unwrap(); // Create and dispatch a frame use crate::format::PixelFormat; use crate::frame::VideoFrame; let frame = VideoFrame::new(1, 100, 100, 400, PixelFormat::BGRA, 1); // Spawn a task to dispatch the frame (to avoid blocking in sync context) tokio::spawn(async move { dispatcher.dispatch_frame(1, frame); }); // Should receive the frame let received = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; assert!(received.is_ok()); } } lamco-pipewire-0.4.0/src/cursor.rs000064400000000000000000000250031046102023000152140ustar 00000000000000//! Hardware Cursor Extraction //! //! Extracts cursor position and bitmap from PipeWire metadata. //! This is useful for remote desktop scenarios where the cursor needs //! to be rendered separately from the video stream. //! //! # Why Separate Cursor? //! //! In remote desktop applications, rendering the cursor separately provides: //! - Lower perceived latency (cursor moves immediately on client) //! - Reduced bandwidth (cursor is small compared to full frame) //! - Client-side cursor customization //! //! # Usage //! //! ```rust,ignore //! use lamco_pipewire::cursor::{CursorExtractor, CursorInfo}; //! //! let mut extractor = CursorExtractor::new(); //! //! // Update from PipeWire frame metadata //! // extractor.update_from_meta(&cursor_meta); //! //! if let Some(cursor) = extractor.current_cursor() { //! println!("Cursor at {:?}, visible: {}", cursor.position, cursor.visible); //! if let Some(bitmap) = &cursor.bitmap { //! // Render cursor bitmap at position //! } //! } //! ``` use std::time::{Duration, Instant}; /// Cursor information extracted from PipeWire #[derive(Debug, Clone)] pub struct CursorInfo { /// Cursor position (x, y) in screen coordinates pub position: (i32, i32), /// Hotspot offset within the cursor bitmap pub hotspot: (i32, i32), /// Cursor bitmap size (width, height) pub size: (u32, u32), /// Cursor bitmap data (BGRA format) /// /// `None` if cursor bitmap hasn't changed since last update. pub bitmap: Option>, /// Whether cursor is currently visible pub visible: bool, /// Timestamp of last update pub timestamp: Instant, /// Serial number for change detection pub serial: u64, } impl Default for CursorInfo { fn default() -> Self { Self { position: (0, 0), hotspot: (0, 0), size: (0, 0), bitmap: None, visible: true, timestamp: Instant::now(), serial: 0, } } } impl CursorInfo { /// Check if cursor bitmap has changed #[must_use] pub fn has_bitmap_changed(&self, previous_serial: u64) -> bool { self.serial != previous_serial && self.bitmap.is_some() } /// Get age of cursor data #[must_use] pub fn age(&self) -> Duration { self.timestamp.elapsed() } } /// Hardware cursor extractor /// /// Tracks cursor state across frames and provides efficient change detection. pub struct CursorExtractor { /// Current cursor state current: CursorInfo, /// Previous cursor position for delta calculation previous_position: (i32, i32), /// Bitmap cache (serial -> bitmap) /// Keeps last N cursors for efficient switching bitmap_cache: Vec<(u64, Vec)>, /// Maximum cache entries max_cache_entries: usize, /// Statistics stats: CursorStats, } impl CursorExtractor { /// Create a new cursor extractor #[must_use] pub fn new() -> Self { Self { current: CursorInfo::default(), previous_position: (0, 0), bitmap_cache: Vec::new(), max_cache_entries: 8, stats: CursorStats::default(), } } /// Create with custom cache size #[must_use] pub fn with_cache_size(max_entries: usize) -> Self { Self { max_cache_entries: max_entries, ..Self::new() } } /// Update cursor position /// /// Called when position changes but bitmap hasn't. pub fn update_position(&mut self, x: i32, y: i32) { self.previous_position = self.current.position; self.current.position = (x, y); self.current.timestamp = Instant::now(); self.stats.position_updates += 1; } /// Update cursor visibility pub fn update_visibility(&mut self, visible: bool) { if self.current.visible != visible { self.current.visible = visible; self.stats.visibility_changes += 1; } } /// Update cursor bitmap /// /// # Arguments /// /// * `bitmap` - BGRA bitmap data /// * `width` - Bitmap width /// * `height` - Bitmap height /// * `hotspot_x` - Hotspot X offset /// * `hotspot_y` - Hotspot Y offset pub fn update_bitmap(&mut self, bitmap: Vec, width: u32, height: u32, hotspot_x: i32, hotspot_y: i32) { self.current.serial += 1; self.current.size = (width, height); self.current.hotspot = (hotspot_x, hotspot_y); self.current.timestamp = Instant::now(); // Cache the bitmap self.cache_bitmap(self.current.serial, bitmap.clone()); self.current.bitmap = Some(bitmap); self.stats.bitmap_updates += 1; } /// Update from raw PipeWire cursor metadata /// /// This is the main entry point for updating cursor state from /// PipeWire's spa_meta_cursor structure. /// /// # Arguments /// /// * `position` - Cursor position (x, y) /// * `hotspot` - Hotspot offset (x, y) /// * `size` - Bitmap size (width, height) /// * `bitmap` - Optional bitmap data (BGRA) /// * `visible` - Whether cursor is visible pub fn update_from_raw( &mut self, position: (i32, i32), hotspot: (i32, i32), size: (u32, u32), bitmap: Option>, visible: bool, ) { self.update_position(position.0, position.1); self.update_visibility(visible); if let Some(bmp) = bitmap { self.update_bitmap(bmp, size.0, size.1, hotspot.0, hotspot.1); } } /// Get current cursor information #[must_use] pub fn current_cursor(&self) -> Option<&CursorInfo> { if self.current.visible { Some(&self.current) } else { None } } /// Get cursor regardless of visibility #[must_use] pub fn cursor_state(&self) -> &CursorInfo { &self.current } /// Get position delta since last update #[must_use] pub fn position_delta(&self) -> (i32, i32) { ( self.current.position.0 - self.previous_position.0, self.current.position.1 - self.previous_position.1, ) } /// Check if cursor has moved #[must_use] pub fn has_moved(&self) -> bool { self.current.position != self.previous_position } /// Get cached bitmap by serial #[must_use] pub fn get_cached_bitmap(&self, serial: u64) -> Option<&[u8]> { self.bitmap_cache .iter() .find(|(s, _)| *s == serial) .map(|(_, b)| b.as_slice()) } /// Get statistics #[must_use] pub fn stats(&self) -> &CursorStats { &self.stats } /// Reset cursor state pub fn reset(&mut self) { self.current = CursorInfo::default(); self.previous_position = (0, 0); self.bitmap_cache.clear(); } /// Add bitmap to cache fn cache_bitmap(&mut self, serial: u64, bitmap: Vec) { // Remove oldest if at capacity if self.bitmap_cache.len() >= self.max_cache_entries { self.bitmap_cache.remove(0); } self.bitmap_cache.push((serial, bitmap)); } } impl Default for CursorExtractor { fn default() -> Self { Self::new() } } /// Cursor extraction statistics #[derive(Debug, Clone, Default)] pub struct CursorStats { /// Number of position updates pub position_updates: u64, /// Number of bitmap updates pub bitmap_updates: u64, /// Number of visibility changes pub visibility_changes: u64, } impl CursorStats { /// Calculate bitmap update rate (updates per position update) #[must_use] pub fn bitmap_rate(&self) -> f64 { if self.position_updates == 0 { 0.0 } else { self.bitmap_updates as f64 / self.position_updates as f64 } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_cursor_info_default() { let info = CursorInfo::default(); assert_eq!(info.position, (0, 0)); assert!(info.visible); assert!(info.bitmap.is_none()); } #[test] fn test_cursor_extractor() { let mut extractor = CursorExtractor::new(); // Initial state assert!(!extractor.has_moved()); // Update position extractor.update_position(100, 200); assert_eq!(extractor.current_cursor().map(|c| c.position), Some((100, 200))); assert!(extractor.has_moved()); assert_eq!(extractor.position_delta(), (100, 200)); // Update again extractor.update_position(150, 250); assert_eq!(extractor.position_delta(), (50, 50)); } #[test] fn test_bitmap_update() { let mut extractor = CursorExtractor::new(); let bitmap = vec![255u8; 32 * 32 * 4]; // 32x32 BGRA extractor.update_bitmap(bitmap.clone(), 32, 32, 0, 0); let cursor = extractor.cursor_state(); assert_eq!(cursor.size, (32, 32)); assert!(cursor.bitmap.is_some()); assert_eq!(cursor.serial, 1); } #[test] fn test_visibility() { let mut extractor = CursorExtractor::new(); assert!(extractor.current_cursor().is_some()); extractor.update_visibility(false); assert!(extractor.current_cursor().is_none()); // cursor_state always returns cursor regardless of visibility assert!(!extractor.cursor_state().visible); } #[test] fn test_bitmap_cache() { let mut extractor = CursorExtractor::with_cache_size(2); // Add three bitmaps (cache size is 2) extractor.update_bitmap(vec![1], 1, 1, 0, 0); let serial1 = extractor.cursor_state().serial; extractor.update_bitmap(vec![2], 1, 1, 0, 0); let serial2 = extractor.cursor_state().serial; extractor.update_bitmap(vec![3], 1, 1, 0, 0); let serial3 = extractor.cursor_state().serial; // First should be evicted assert!(extractor.get_cached_bitmap(serial1).is_none()); assert!(extractor.get_cached_bitmap(serial2).is_some()); assert!(extractor.get_cached_bitmap(serial3).is_some()); } #[test] fn test_stats() { let mut extractor = CursorExtractor::new(); extractor.update_position(10, 20); extractor.update_position(30, 40); extractor.update_bitmap(vec![1], 1, 1, 0, 0); extractor.update_visibility(false); extractor.update_visibility(true); let stats = extractor.stats(); assert_eq!(stats.position_updates, 2); assert_eq!(stats.bitmap_updates, 1); assert_eq!(stats.visibility_changes, 2); } } lamco-pipewire-0.4.0/src/damage/detector.rs000064400000000000000000000562011046102023000167320ustar 00000000000000//! SIMD-Accelerated Damage Detection //! //! Tile-based frame differencing to detect changed screen regions, //! enabling significant bandwidth reduction (90%+ for static content). //! //! Supports AVX2 (x86_64), NEON (aarch64), and scalar fallback. //! //! # Algorithm //! //! 1. Divide frame into configurable tile grid (default 64x64 pixels) //! 2. SIMD-compare each tile against previous frame //! 3. Mark tile dirty if difference exceeds threshold //! 4. Merge adjacent dirty tiles into larger regions //! 5. Return optimized list of damage regions #![allow(unsafe_code)] use std::time::Instant; /// A rectangular region of the screen that has changed #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DetectedRegion { pub x: u32, pub y: u32, pub width: u32, pub height: u32, } impl DetectedRegion { #[inline] pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self { Self { x, y, width, height } } #[inline] pub fn full_frame(width: u32, height: u32) -> Self { Self { x: 0, y: 0, width, height, } } #[inline] pub fn area(&self) -> u64 { self.width as u64 * self.height as u64 } pub fn overlaps(&self, other: &DetectedRegion) -> bool { let self_right = self.x + self.width; let self_bottom = self.y + self.height; let other_right = other.x + other.width; let other_bottom = other.y + other.height; self.x < other_right && self_right > other.x && self.y < other_bottom && self_bottom > other.y } #[inline] pub fn contains(&self, x: u32, y: u32) -> bool { x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height } pub fn union(&self, other: &DetectedRegion) -> DetectedRegion { let x = self.x.min(other.x); let y = self.y.min(other.y); let right = (self.x + self.width).max(other.x + other.width); let bottom = (self.y + self.height).max(other.y + other.height); DetectedRegion { x, y, width: right - x, height: bottom - y, } } pub fn is_adjacent(&self, other: &DetectedRegion, merge_distance: u32) -> bool { let self_right = self.x + self.width; let self_bottom = self.y + self.height; let other_right = other.x + other.width; let other_bottom = other.y + other.height; let gap_x = if other.x >= self_right { other.x - self_right } else { self.x.saturating_sub(other_right) }; let gap_y = if other.y >= self_bottom { other.y - self_bottom } else { self.y.saturating_sub(other_bottom) }; gap_x <= merge_distance && gap_y <= merge_distance } } /// Configuration for damage detection #[derive(Debug, Clone)] pub struct DamageConfig { /// Size of each comparison tile in pixels (default: 64) pub tile_size: usize, /// Fraction of tile pixels that must differ to mark as dirty (default: 0.05) pub diff_threshold: f32, /// Maximum per-channel pixel difference to consider "same" (default: 4) pub pixel_threshold: u8, /// Distance in pixels for merging adjacent dirty tiles (default: 32) pub merge_distance: u32, /// Minimum region area to report (default: 256) pub min_region_area: u64, } impl Default for DamageConfig { fn default() -> Self { Self { tile_size: 64, diff_threshold: 0.05, pixel_threshold: 4, merge_distance: 32, min_region_area: 256, } } } impl DamageConfig { /// Finer granularity, more sensitive detection pub fn low_bandwidth() -> Self { Self { tile_size: 32, diff_threshold: 0.02, pixel_threshold: 2, merge_distance: 16, min_region_area: 64, } } /// Coarser detection for high-motion content pub fn high_motion() -> Self { Self { tile_size: 128, diff_threshold: 0.10, pixel_threshold: 8, merge_distance: 64, min_region_area: 1024, } } } /// Detection statistics #[derive(Debug, Clone, Default)] pub struct DamageDetectorStats { pub frames_processed: u64, pub frames_skipped: u64, pub frames_full: u64, pub frames_partial: u64, pub total_damage_area: u64, pub total_frame_area: u64, pub total_detection_time_ns: u64, pub avg_damage_ratio: f32, pub avg_detection_time_ms: f32, } impl DamageDetectorStats { /// Bandwidth saved as a percentage (100% = all frames identical) pub fn bandwidth_reduction_percent(&self) -> f32 { if self.total_frame_area == 0 { return 0.0; } let ratio = self.total_damage_area as f32 / self.total_frame_area as f32; (1.0 - ratio) * 100.0 } fn update_averages(&mut self) { if self.frames_processed > 0 { self.avg_damage_ratio = self.total_damage_area as f32 / self.total_frame_area.max(1) as f32; self.avg_detection_time_ms = (self.total_detection_time_ns as f64 / self.frames_processed as f64 / 1_000_000.0) as f32; } } } // --- SIMD pixel comparison --- fn count_different_pixels_scalar(prev: &[u8], curr: &[u8], threshold: u8) -> u32 { let mut count = 0u32; for (p, c) in prev.chunks_exact(4).zip(curr.chunks_exact(4)) { let diff_b = (p[0] as i16 - c[0] as i16).unsigned_abs() as u8; let diff_g = (p[1] as i16 - c[1] as i16).unsigned_abs() as u8; let diff_r = (p[2] as i16 - c[2] as i16).unsigned_abs() as u8; if diff_b > threshold || diff_g > threshold || diff_r > threshold { count += 1; } } count } #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] fn count_different_pixels_avx2(prev: &[u8], curr: &[u8], threshold: u8) -> u32 { use std::arch::x86_64::*; if prev.len() < 32 || curr.len() < 32 { return count_different_pixels_scalar(prev, curr, threshold); } // SAFETY: AVX2 target_feature is guaranteed by cfg gate. // Pointer arithmetic stays within slice bounds (chunks * 32 <= len). unsafe { let threshold_vec = _mm256_set1_epi8(threshold as i8); let mut diff_count = 0u32; let chunks = prev.len() / 32; for i in 0..chunks { let offset = i * 32; let prev_ptr = prev.as_ptr().add(offset) as *const __m256i; let curr_ptr = curr.as_ptr().add(offset) as *const __m256i; let prev_data = _mm256_loadu_si256(prev_ptr); let curr_data = _mm256_loadu_si256(curr_ptr); let diff = _mm256_or_si256( _mm256_subs_epu8(prev_data, curr_data), _mm256_subs_epu8(curr_data, prev_data), ); let exceeds = _mm256_cmpgt_epi8(diff, threshold_vec); let mask = _mm256_movemask_epi8(exceeds) as u32; diff_count += mask.count_ones(); } let remaining_start = chunks * 32; if remaining_start < prev.len() { diff_count += count_different_pixels_scalar(&prev[remaining_start..], &curr[remaining_start..], threshold); } // Byte-level differences / 3 for approximate pixel count (R,G,B) diff_count / 3 } } #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] fn count_different_pixels_neon(prev: &[u8], curr: &[u8], threshold: u8) -> u32 { use std::arch::aarch64::*; if prev.len() < 16 || curr.len() < 16 { return count_different_pixels_scalar(prev, curr, threshold); } // SAFETY: NEON target_feature is guaranteed by cfg gate. // Pointer arithmetic stays within slice bounds. unsafe { let threshold_vec = vdupq_n_u8(threshold); let mut diff_count = 0u32; let chunks = prev.len() / 16; for i in 0..chunks { let offset = i * 16; let prev_data = vld1q_u8(prev.as_ptr().add(offset)); let curr_data = vld1q_u8(curr.as_ptr().add(offset)); let diff = vabdq_u8(prev_data, curr_data); let exceeds = vcgtq_u8(diff, threshold_vec); let sum = vaddvq_u8(exceeds); diff_count += (sum / 255) as u32; } let remaining_start = chunks * 16; if remaining_start < prev.len() { diff_count += count_different_pixels_scalar(&prev[remaining_start..], &curr[remaining_start..], threshold); } diff_count / 3 } } #[inline] fn count_different_pixels(prev: &[u8], curr: &[u8], threshold: u8) -> u32 { #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] { count_different_pixels_avx2(prev, curr, threshold) } #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] { count_different_pixels_neon(prev, curr, threshold) } #[cfg(not(any( all(target_arch = "x86_64", target_feature = "avx2"), all(target_arch = "aarch64", target_feature = "neon") )))] { count_different_pixels_scalar(prev, curr, threshold) } } // --- Region merging --- fn merge_regions(mut regions: Vec, merge_distance: u32) -> Vec { if regions.len() <= 1 { return regions; } let mut changed = true; while changed { changed = false; let mut merged = Vec::with_capacity(regions.len()); let mut used = vec![false; regions.len()]; for i in 0..regions.len() { if used[i] { continue; } let mut current = regions[i]; used[i] = true; for j in (i + 1)..regions.len() { if used[j] { continue; } if current.is_adjacent(®ions[j], merge_distance) { current = current.union(®ions[j]); used[j] = true; changed = true; } } merged.push(current); } regions = merged; } regions } fn tiles_to_regions( dirty_tiles: &[bool], tiles_x: usize, tiles_y: usize, tile_size: usize, frame_width: u32, frame_height: u32, ) -> Vec { let mut regions = Vec::new(); for ty in 0..tiles_y { for tx in 0..tiles_x { let idx = ty * tiles_x + tx; if dirty_tiles[idx] { let x = (tx * tile_size) as u32; let y = (ty * tile_size) as u32; let width = (tile_size as u32).min(frame_width.saturating_sub(x)); let height = (tile_size as u32).min(frame_height.saturating_sub(y)); if width > 0 && height > 0 { regions.push(DetectedRegion::new(x, y, width, height)); } } } } regions } // --- Main detector --- /// SIMD-accelerated damage detection engine /// /// Compares consecutive BGRA frames tile-by-tile to identify changed regions. /// Supports AVX2, NEON, and scalar paths. pub struct DamageDetector { config: DamageConfig, previous_frame: Option>, previous_dimensions: Option<(u32, u32)>, tile_dirty: Vec, tiles_x: usize, tiles_y: usize, stats: DamageDetectorStats, invalidated: bool, } impl DamageDetector { pub fn new(config: DamageConfig) -> Self { Self { config, previous_frame: None, previous_dimensions: None, tile_dirty: Vec::new(), tiles_x: 0, tiles_y: 0, stats: DamageDetectorStats::default(), invalidated: true, } } pub fn with_defaults() -> Self { Self::new(DamageConfig::default()) } /// Detect changed regions between this frame and the previous one. /// /// Returns empty if the frame is identical. Returns full-frame damage /// on first call or after invalidation. /// /// `frame` must be BGRA pixel data (4 bytes per pixel). #[expect( clippy::unwrap_used, reason = "previous_frame is guaranteed Some after first frame check" )] pub fn detect(&mut self, frame: &[u8], width: u32, height: u32) -> Vec { let start = Instant::now(); let frame_area = width as u64 * height as u64; let expected_len = (width as usize) * (height as usize) * 4; assert_eq!( frame.len(), expected_len, "Frame size mismatch: got {} bytes, expected {} for {}x{}", frame.len(), expected_len, width, height ); let dimensions_changed = self.previous_dimensions.is_none_or(|(w, h)| w != width || h != height); if self.previous_frame.is_none() || self.invalidated || dimensions_changed { self.update_tile_grid(width, height); self.previous_frame = Some(frame.to_vec()); self.previous_dimensions = Some((width, height)); self.invalidated = false; self.stats.frames_processed += 1; self.stats.frames_full += 1; self.stats.total_damage_area += frame_area; self.stats.total_frame_area += frame_area; self.stats.total_detection_time_ns += start.elapsed().as_nanos() as u64; self.stats.update_averages(); return vec![DetectedRegion::full_frame(width, height)]; } let mut prev_frame = self.previous_frame.take().unwrap(); let regions = self.detect_changes(&prev_frame, frame, width, height); let damage_area: u64 = regions.iter().map(DetectedRegion::area).sum(); self.stats.frames_processed += 1; self.stats.total_damage_area += damage_area; self.stats.total_frame_area += frame_area; if regions.is_empty() { self.stats.frames_skipped += 1; } else if damage_area >= frame_area * 9 / 10 { self.stats.frames_full += 1; } else { self.stats.frames_partial += 1; } self.stats.total_detection_time_ns += start.elapsed().as_nanos() as u64; self.stats.update_averages(); // Reuse allocation for next comparison prev_frame.clear(); prev_frame.extend_from_slice(frame); self.previous_frame = Some(prev_frame); regions } /// Force full-frame damage on next detect() call pub fn invalidate(&mut self) { self.invalidated = true; } pub fn stats(&self) -> &DamageDetectorStats { &self.stats } pub fn reset_stats(&mut self) { self.stats = DamageDetectorStats::default(); } pub fn config(&self) -> &DamageConfig { &self.config } /// Update config and invalidate (next frame treated as full damage) pub fn set_config(&mut self, config: DamageConfig) { self.config = config; self.invalidate(); } fn update_tile_grid(&mut self, width: u32, height: u32) { self.tiles_x = (width as usize).div_ceil(self.config.tile_size); self.tiles_y = (height as usize).div_ceil(self.config.tile_size); let total_tiles = self.tiles_x * self.tiles_y; if self.tile_dirty.len() != total_tiles { self.tile_dirty = vec![false; total_tiles]; } } fn detect_changes(&mut self, prev: &[u8], curr: &[u8], width: u32, height: u32) -> Vec { let tile_size = self.config.tile_size; let stride = (width as usize) * 4; let pixel_threshold = self.config.pixel_threshold; let tile_pixels = (tile_size * tile_size) as u32; let diff_threshold_count = (tile_pixels as f32 * self.config.diff_threshold) as u32; for flag in &mut self.tile_dirty { *flag = false; } for ty in 0..self.tiles_y { for tx in 0..self.tiles_x { let tile_x = tx * tile_size; let tile_y = ty * tile_size; let tile_width = tile_size.min((width as usize).saturating_sub(tile_x)); let tile_height = tile_size.min((height as usize).saturating_sub(tile_y)); if tile_width == 0 || tile_height == 0 { continue; } let diff_count = self.compare_tile( prev, curr, tile_x, tile_y, tile_width, tile_height, stride, pixel_threshold, ); let idx = ty * self.tiles_x + tx; self.tile_dirty[idx] = diff_count > diff_threshold_count; } } let mut regions = tiles_to_regions(&self.tile_dirty, self.tiles_x, self.tiles_y, tile_size, width, height); regions = merge_regions(regions, self.config.merge_distance); regions.retain(|r| r.area() >= self.config.min_region_area); regions } fn compare_tile( &self, prev: &[u8], curr: &[u8], tile_x: usize, tile_y: usize, tile_width: usize, tile_height: usize, stride: usize, pixel_threshold: u8, ) -> u32 { let mut total_diff = 0u32; let bytes_per_row = tile_width * 4; for row in 0..tile_height { let y = tile_y + row; let offset = y * stride + tile_x * 4; if offset + bytes_per_row > prev.len() || offset + bytes_per_row > curr.len() { continue; } let prev_row = &prev[offset..offset + bytes_per_row]; let curr_row = &curr[offset..offset + bytes_per_row]; total_diff += count_different_pixels(prev_row, curr_row, pixel_threshold); } total_diff } } #[cfg(test)] mod tests { use super::*; fn create_solid_frame(width: usize, height: usize, color: [u8; 4]) -> Vec { let mut data = vec![0u8; width * height * 4]; for pixel in data.chunks_exact_mut(4) { pixel.copy_from_slice(&color); } data } fn create_frame_with_region( width: usize, height: usize, bg_color: [u8; 4], region: DetectedRegion, region_color: [u8; 4], ) -> Vec { let mut data = create_solid_frame(width, height, bg_color); for y in region.y..(region.y + region.height) { for x in region.x..(region.x + region.width) { if (x as usize) < width && (y as usize) < height { let idx = ((y as usize) * width + (x as usize)) * 4; data[idx..idx + 4].copy_from_slice(®ion_color); } } } data } #[test] fn test_first_frame_full_damage() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(640, 480, [0, 0, 0, 255]); let damage = detector.detect(&frame, 640, 480); assert_eq!(damage.len(), 1); assert_eq!(damage[0], DetectedRegion::full_frame(640, 480)); } #[test] fn test_identical_frames_no_damage() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(640, 480, [100, 100, 100, 255]); let _ = detector.detect(&frame, 640, 480); let damage = detector.detect(&frame, 640, 480); assert!(damage.is_empty()); } #[test] fn test_partial_change() { let mut detector = DamageDetector::new(DamageConfig { tile_size: 64, diff_threshold: 0.01, pixel_threshold: 1, merge_distance: 0, min_region_area: 1, }); let frame1 = create_solid_frame(256, 256, [0, 0, 0, 255]); let changed_region = DetectedRegion::new(0, 0, 64, 64); let frame2 = create_frame_with_region(256, 256, [0, 0, 0, 255], changed_region, [255, 255, 255, 255]); let _ = detector.detect(&frame1, 256, 256); let damage = detector.detect(&frame2, 256, 256); assert!(!damage.is_empty()); let total_area: u64 = damage.iter().map(DetectedRegion::area).sum(); assert!(total_area >= changed_region.area() / 2); } #[test] fn test_dimension_change_invalidates() { let mut detector = DamageDetector::with_defaults(); let frame1 = create_solid_frame(640, 480, [100, 100, 100, 255]); let frame2 = create_solid_frame(800, 600, [100, 100, 100, 255]); let _ = detector.detect(&frame1, 640, 480); let damage = detector.detect(&frame2, 800, 600); assert_eq!(damage.len(), 1); assert_eq!(damage[0], DetectedRegion::full_frame(800, 600)); } #[test] fn test_invalidate() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(640, 480, [100, 100, 100, 255]); let _ = detector.detect(&frame, 640, 480); detector.invalidate(); let damage = detector.detect(&frame, 640, 480); assert_eq!(damage.len(), 1); assert_eq!(damage[0], DetectedRegion::full_frame(640, 480)); } #[test] fn test_stats() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(640, 480, [0, 0, 0, 255]); for _ in 0..5 { let _ = detector.detect(&frame, 640, 480); } let stats = detector.stats(); assert_eq!(stats.frames_processed, 5); assert_eq!(stats.frames_full, 1); assert_eq!(stats.frames_skipped, 4); assert!(stats.bandwidth_reduction_percent() > 0.0); } #[test] fn test_config_presets() { let low = DamageConfig::low_bandwidth(); assert_eq!(low.tile_size, 32); let high = DamageConfig::high_motion(); assert_eq!(high.tile_size, 128); } #[test] fn test_scalar_pixel_comparison() { let data = vec![100u8; 64]; assert_eq!(count_different_pixels_scalar(&data, &data, 4), 0); let prev = vec![0u8; 64]; let curr = vec![255u8; 64]; assert_eq!(count_different_pixels_scalar(&prev, &curr, 4), 16); } #[test] fn test_merge_adjacent_regions() { let r1 = DetectedRegion::new(0, 0, 64, 64); let r2 = DetectedRegion::new(64, 0, 64, 64); let regions = merge_regions(vec![r1, r2], 32); assert_eq!(regions.len(), 1); assert_eq!(regions[0].width, 128); } #[test] fn test_merge_separate_regions() { let r1 = DetectedRegion::new(0, 0, 64, 64); let r2 = DetectedRegion::new(200, 200, 64, 64); let regions = merge_regions(vec![r1, r2], 32); assert_eq!(regions.len(), 2); } #[test] #[should_panic(expected = "Frame size mismatch")] fn test_wrong_size_panics() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(640, 480, [0, 0, 0, 255]); let _ = detector.detect(&frame, 800, 600); } #[test] fn test_4k_frame() { let mut detector = DamageDetector::with_defaults(); let frame = create_solid_frame(3840, 2160, [0, 128, 255, 255]); let damage = detector.detect(&frame, 3840, 2160); assert_eq!(damage[0], DetectedRegion::full_frame(3840, 2160)); let damage2 = detector.detect(&frame, 3840, 2160); assert!(damage2.is_empty()); } } lamco-pipewire-0.4.0/src/damage/mod.rs000064400000000000000000000025021046102023000156730ustar 00000000000000//! Damage Detection and Region Tracking //! //! Two complementary damage systems: //! //! - **Tracker** ([`DamageTracker`]): Aggregates damage regions from PipeWire //! metadata and decides encoding strategy (full vs partial update). //! //! - **Detector** ([`DamageDetector`]): SIMD-accelerated frame differencing //! that compares consecutive BGRA frames tile-by-tile to find changes. //! Useful when PipeWire doesn't provide damage metadata. //! //! # Usage //! //! ```rust //! use lamco_pipewire::damage::{DamageTracker, DamageRegion}; //! //! // Region tracking (from PipeWire metadata) //! let mut tracker = DamageTracker::new(); //! tracker.add_region(DamageRegion::new(0, 0, 100, 100)); //! //! if tracker.should_full_update((1920, 1080)) { //! // Encode full frame //! } //! tracker.clear(); //! ``` //! //! ```rust,ignore //! use lamco_pipewire::damage::{DamageConfig, DamageDetector}; //! //! // Frame comparison (SIMD-accelerated) //! let mut detector = DamageDetector::with_defaults(); //! let regions = detector.detect(&frame_data, 1920, 1080); //! ``` mod detector; mod tracker; // Tracker (region aggregation from PipeWire metadata) // Detector (SIMD frame comparison) pub use detector::{DamageConfig, DamageDetector, DamageDetectorStats, DetectedRegion}; pub use tracker::{DamageRegion, DamageStats, DamageTracker}; lamco-pipewire-0.4.0/src/damage/tracker.rs000064400000000000000000000316771046102023000165660ustar 00000000000000//! Region Damage Tracking //! //! Tracks which regions of the screen have changed between frames. //! This is useful for efficient encoding where only changed regions //! need to be transmitted. //! //! # How It Works //! //! PipeWire can provide damage metadata indicating which rectangles //! of the frame have changed. This module aggregates that information //! and provides utilities for encoding decisions. //! //! # Usage //! //! ```rust //! use lamco_pipewire::damage::{DamageTracker, DamageRegion}; //! //! let mut tracker = DamageTracker::new(); //! //! // Add damaged regions from PipeWire metadata //! tracker.add_region(DamageRegion { x: 0, y: 0, width: 100, height: 100 }); //! tracker.add_region(DamageRegion { x: 500, y: 300, width: 200, height: 150 }); //! //! // Check encoding strategy //! let frame_size = (1920, 1080); //! if tracker.should_full_update(frame_size) { //! // Encode full frame //! } else { //! // Encode only damaged regions //! for region in tracker.damaged_regions() { //! // Encode region //! } //! } //! //! // Clear for next frame //! tracker.clear(); //! ``` use std::time::Instant; /// A damaged (changed) region of the screen #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DamageRegion { /// X coordinate of top-left corner pub x: u32, /// Y coordinate of top-left corner pub y: u32, /// Region width pub width: u32, /// Region height pub height: u32, } impl DamageRegion { /// Create a new damage region #[must_use] pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self { Self { x, y, width, height } } /// Calculate area of the region #[must_use] pub const fn area(&self) -> u64 { self.width as u64 * self.height as u64 } /// Check if region contains a point #[must_use] pub const fn contains(&self, x: u32, y: u32) -> bool { x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height } /// Check if this region overlaps with another #[must_use] pub const fn overlaps(&self, other: &Self) -> bool { self.x < other.x + other.width && self.x + self.width > other.x && self.y < other.y + other.height && self.y + self.height > other.y } /// Merge two overlapping regions into bounding box #[must_use] pub fn merge(&self, other: &Self) -> Self { let x = self.x.min(other.x); let y = self.y.min(other.y); let x2 = (self.x + self.width).max(other.x + other.width); let y2 = (self.y + self.height).max(other.y + other.height); Self { x, y, width: x2 - x, height: y2 - y, } } /// Clip region to frame bounds #[must_use] pub fn clip(&self, frame_width: u32, frame_height: u32) -> Option { if self.x >= frame_width || self.y >= frame_height { return None; } let x = self.x; let y = self.y; let width = (self.width).min(frame_width - x); let height = (self.height).min(frame_height - y); if width == 0 || height == 0 { None } else { Some(Self { x, y, width, height }) } } } /// Damage tracking statistics #[derive(Debug, Clone, Default)] pub struct DamageStats { /// Total frames processed pub frames_processed: u64, /// Frames with full damage pub full_damage_frames: u64, /// Frames with partial damage pub partial_damage_frames: u64, /// Total regions tracked pub total_regions: u64, /// Average damaged area ratio pub avg_damage_ratio: f64, } /// Tracks damaged regions between frames pub struct DamageTracker { /// Current damaged regions regions: Vec, /// Threshold for switching to full update (0.0-1.0) /// /// If damaged area exceeds this fraction of total area, /// full update is more efficient. full_damage_threshold: f32, /// Merge nearby regions if closer than this distance merge_distance: u32, /// Enable region merging enable_merging: bool, /// Statistics stats: DamageStats, /// Last update timestamp last_update: Instant, /// Maximum regions before forcing full update max_regions: usize, } impl DamageTracker { /// Create a new damage tracker with default settings #[must_use] pub fn new() -> Self { Self { regions: Vec::with_capacity(32), full_damage_threshold: 0.5, // 50% damage = full update merge_distance: 32, enable_merging: true, stats: DamageStats::default(), last_update: Instant::now(), max_regions: 64, } } /// Create with custom threshold #[must_use] pub fn with_threshold(threshold: f32) -> Self { Self { full_damage_threshold: threshold.clamp(0.0, 1.0), ..Self::new() } } /// Create with custom settings #[must_use] pub fn with_settings(threshold: f32, merge_distance: u32, max_regions: usize) -> Self { Self { full_damage_threshold: threshold.clamp(0.0, 1.0), merge_distance, max_regions, ..Self::new() } } /// Add a damaged region pub fn add_region(&mut self, region: DamageRegion) { if self.regions.len() >= self.max_regions { // Too many regions - will trigger full update return; } if self.enable_merging { self.add_with_merge(region); } else { self.regions.push(region); } self.stats.total_regions += 1; self.last_update = Instant::now(); } /// Add region with optional merging of overlapping regions fn add_with_merge(&mut self, region: DamageRegion) { // Check for overlapping regions let mut merged = region; let mut merged_any = true; while merged_any { merged_any = false; let mut i = 0; while i < self.regions.len() { if self.should_merge(&merged, &self.regions[i]) { merged = merged.merge(&self.regions[i]); self.regions.remove(i); merged_any = true; } else { i += 1; } } } self.regions.push(merged); } /// Check if two regions should be merged fn should_merge(&self, a: &DamageRegion, b: &DamageRegion) -> bool { // Merge if overlapping if a.overlaps(b) { return true; } // Merge if close enough let dist_x = if a.x + a.width < b.x { b.x.saturating_sub(a.x + a.width) } else { a.x.saturating_sub(b.x + b.width) }; let dist_y = if a.y + a.height < b.y { b.y.saturating_sub(a.y + a.height) } else { a.y.saturating_sub(b.y + b.height) }; dist_x <= self.merge_distance && dist_y <= self.merge_distance } /// Add multiple regions pub fn add_regions(&mut self, regions: impl IntoIterator) { for region in regions { self.add_region(region); } } /// Mark entire frame as damaged pub fn mark_full_damage(&mut self, width: u32, height: u32) { self.regions.clear(); self.regions.push(DamageRegion::new(0, 0, width, height)); self.stats.full_damage_frames += 1; } /// Get current damaged regions #[must_use] pub fn damaged_regions(&self) -> &[DamageRegion] { &self.regions } /// Get number of damaged regions #[must_use] pub fn region_count(&self) -> usize { self.regions.len() } /// Check if there is any damage #[must_use] pub fn has_damage(&self) -> bool { !self.regions.is_empty() } /// Calculate total damaged area #[must_use] pub fn total_damaged_area(&self) -> u64 { self.regions.iter().map(DamageRegion::area).sum() } /// Calculate damage ratio (damaged area / total area) #[must_use] pub fn damage_ratio(&self, frame_size: (u32, u32)) -> f64 { let total_area = u64::from(frame_size.0) * u64::from(frame_size.1); if total_area == 0 { return 0.0; } let damaged = self.total_damaged_area(); damaged as f64 / total_area as f64 } /// Check if full frame update is more efficient /// /// Returns true if: /// - Too many regions (overhead of encoding each) /// - Damaged area exceeds threshold /// - No damage info available #[must_use] pub fn should_full_update(&self, frame_size: (u32, u32)) -> bool { // No regions = no damage info, assume full update if self.regions.is_empty() { return true; } // Too many regions if self.regions.len() >= self.max_regions { return true; } // Check damage ratio let ratio = self.damage_ratio(frame_size); ratio >= f64::from(self.full_damage_threshold) } /// Get bounding box of all damaged regions #[must_use] pub fn bounding_box(&self) -> Option { if self.regions.is_empty() { return None; } let mut result = self.regions[0]; for region in &self.regions[1..] { result = result.merge(region); } Some(result) } /// Clear damage for next frame pub fn clear(&mut self) { self.regions.clear(); self.stats.frames_processed += 1; } /// Get statistics #[must_use] pub fn stats(&self) -> &DamageStats { &self.stats } /// Set full damage threshold pub fn set_threshold(&mut self, threshold: f32) { self.full_damage_threshold = threshold.clamp(0.0, 1.0); } /// Enable or disable region merging pub fn set_merging(&mut self, enable: bool) { self.enable_merging = enable; } } impl Default for DamageTracker { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_damage_region_basic() { let region = DamageRegion::new(10, 20, 100, 50); assert_eq!(region.area(), 5000); assert!(region.contains(50, 40)); assert!(!region.contains(0, 0)); } #[test] fn test_region_overlap() { let a = DamageRegion::new(0, 0, 100, 100); let b = DamageRegion::new(50, 50, 100, 100); let c = DamageRegion::new(200, 200, 50, 50); assert!(a.overlaps(&b)); assert!(b.overlaps(&a)); assert!(!a.overlaps(&c)); } #[test] fn test_region_merge() { let a = DamageRegion::new(0, 0, 100, 100); let b = DamageRegion::new(50, 50, 100, 100); let merged = a.merge(&b); assert_eq!(merged.x, 0); assert_eq!(merged.y, 0); assert_eq!(merged.width, 150); assert_eq!(merged.height, 150); } #[test] fn test_region_clip() { let region = DamageRegion::new(900, 500, 200, 200); let clipped = region.clip(1000, 600); assert!(clipped.is_some()); let c = clipped.expect("should clip"); assert_eq!(c.width, 100); assert_eq!(c.height, 100); } #[test] fn test_damage_tracker_basic() { let mut tracker = DamageTracker::new(); assert!(!tracker.has_damage()); tracker.add_region(DamageRegion::new(0, 0, 100, 100)); assert!(tracker.has_damage()); assert_eq!(tracker.region_count(), 1); tracker.clear(); assert!(!tracker.has_damage()); } #[test] fn test_damage_tracker_merge() { let mut tracker = DamageTracker::new(); // Add overlapping regions tracker.add_region(DamageRegion::new(0, 0, 100, 100)); tracker.add_region(DamageRegion::new(50, 50, 100, 100)); // Should be merged into one assert_eq!(tracker.region_count(), 1); } #[test] fn test_should_full_update() { let mut tracker = DamageTracker::with_threshold(0.5); let frame_size = (100, 100); // Less than 50% damage tracker.add_region(DamageRegion::new(0, 0, 40, 40)); assert!(!tracker.should_full_update(frame_size)); tracker.clear(); // More than 50% damage tracker.add_region(DamageRegion::new(0, 0, 80, 80)); assert!(tracker.should_full_update(frame_size)); } #[test] fn test_bounding_box() { let mut tracker = DamageTracker::new(); tracker.set_merging(false); // Disable to have separate regions tracker.add_region(DamageRegion::new(10, 10, 50, 50)); tracker.add_region(DamageRegion::new(200, 200, 30, 30)); let bbox = tracker.bounding_box(); assert!(bbox.is_some()); let b = bbox.expect("should have bbox"); assert_eq!(b.x, 10); assert_eq!(b.y, 10); assert_eq!(b.width, 220); assert_eq!(b.height, 220); } } lamco-pipewire-0.4.0/src/error.rs000064400000000000000000000170311046102023000150320ustar 00000000000000//! PipeWire Error Types //! //! Comprehensive error handling for the PipeWire integration module. use thiserror::Error; /// Result type for PipeWire operations pub type Result = std::result::Result; /// PipeWire integration error types #[derive(Error, Debug)] pub enum PipeWireError { /// PipeWire initialization failed #[error("PipeWire initialization failed: {0}")] InitializationFailed(String), /// Connection to PipeWire failed #[error("Connection failed: {0}")] ConnectionFailed(String), /// Stream creation failed #[error("Stream creation failed: {0}")] StreamCreationFailed(String), /// Format negotiation failed #[error("Format negotiation failed: {0}")] FormatNegotiationFailed(String), /// Buffer allocation failed #[error("Buffer allocation failed: {0}")] BufferAllocationFailed(String), /// DMA-BUF import failed #[error("DMA-BUF import failed: {0}")] DmaBufImportFailed(String), /// Frame extraction failed #[error("Frame extraction failed: {0}")] FrameExtractionFailed(String), /// Stream not found #[error("Stream not found: {0}")] StreamNotFound(u32), /// Too many streams #[error("Too many streams (max: {0})")] TooManyStreams(usize), /// Stream stalled #[error("Stream {0} stalled")] StreamStalled(u32), /// Format conversion failed #[error("Format conversion failed: {0}")] FormatConversionFailed(String), /// Timeout waiting for stream #[error("Timeout waiting for stream")] Timeout, /// Permission denied #[error("Permission denied")] PermissionDenied, /// Invalid state #[error("Invalid state: {0}")] InvalidState(String), /// Invalid parameter #[error("Invalid parameter: {0}")] InvalidParameter(String), /// Buffer not available #[error("No buffers available")] NoBuffersAvailable, /// IO error #[error("IO error: {0}")] Io(#[from] std::io::Error), /// Portal error #[error("Portal error: {0}")] Portal(String), /// FFI error #[error("FFI error: {0}")] Ffi(String), /// Thread communication failed #[error("Thread communication failed: {0}")] ThreadCommunicationFailed(String), /// Thread panicked #[error("Thread panicked: {0}")] ThreadPanic(String), /// Unknown error #[error("Unknown error: {0}")] Unknown(String), } /// Error classification for recovery strategies #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ErrorType { /// Connection-related errors Connection, /// Stream-related errors Stream, /// Buffer-related errors Buffer, /// Format-related errors Format, /// Resource-related errors Resource, /// Permission-related errors Permission, /// Timeout errors Timeout, /// Unknown error type Unknown, } /// Classify error for recovery strategy selection pub fn classify_error(error: &PipeWireError) -> ErrorType { match error { PipeWireError::ConnectionFailed(_) | PipeWireError::InitializationFailed(_) => ErrorType::Connection, PipeWireError::StreamCreationFailed(_) | PipeWireError::StreamNotFound(_) | PipeWireError::StreamStalled(_) => { ErrorType::Stream } PipeWireError::BufferAllocationFailed(_) | PipeWireError::NoBuffersAvailable => ErrorType::Buffer, PipeWireError::FormatNegotiationFailed(_) | PipeWireError::FormatConversionFailed(_) => ErrorType::Format, PipeWireError::TooManyStreams(_) | PipeWireError::DmaBufImportFailed(_) => ErrorType::Resource, PipeWireError::PermissionDenied | PipeWireError::Portal(_) => ErrorType::Permission, PipeWireError::Timeout => ErrorType::Timeout, _ => ErrorType::Unknown, } } /// Error context for recovery decisions #[derive(Debug, Clone)] pub struct ErrorContext { /// Stream ID if applicable pub stream_id: Option, /// Portal FD if available pub portal_fd: Option, /// Retry attempt number pub attempt: u32, /// Additional context information pub details: String, } impl ErrorContext { /// Create new error context pub fn new() -> Self { Self { stream_id: None, portal_fd: None, attempt: 0, details: String::new(), } } /// Set stream ID pub fn with_stream_id(mut self, id: u32) -> Self { self.stream_id = Some(id); self } /// Set portal FD pub fn with_portal_fd(mut self, fd: i32) -> Self { self.portal_fd = Some(fd); self } /// Set attempt number pub fn with_attempt(mut self, attempt: u32) -> Self { self.attempt = attempt; self } /// Set details pub fn with_details(mut self, details: impl Into) -> Self { self.details = details.into(); self } } impl Default for ErrorContext { fn default() -> Self { Self::new() } } /// Recovery action to take after error #[derive(Debug, Clone, PartialEq, Eq)] pub enum RecoveryAction { /// Retry the operation Retry(RetryConfig), /// Reconnect to PipeWire Reconnect(i32), /// Restart the stream RestartStream(u32), /// Try with fallback format RetryWithFallbackFormat, /// Reduce buffer count ReduceBufferCount, /// Request new portal session RequestNewSession, /// Fail and propagate error Fail, } /// Retry configuration #[derive(Debug, Clone, PartialEq, Eq)] pub struct RetryConfig { /// Maximum number of retries pub max_retries: u32, /// Initial delay in milliseconds pub initial_delay_ms: u64, /// Backoff multiplier pub backoff_multiplier: u32, /// Maximum delay in milliseconds pub max_delay_ms: u64, } impl Default for RetryConfig { fn default() -> Self { Self { max_retries: 3, initial_delay_ms: 100, backoff_multiplier: 2, max_delay_ms: 5000, } } } impl RetryConfig { /// Calculate delay for given attempt pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration { let delay = self.initial_delay_ms * (self.backoff_multiplier as u64).pow(attempt); let delay = delay.min(self.max_delay_ms); std::time::Duration::from_millis(delay) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_error_classification() { let error = PipeWireError::ConnectionFailed("test".to_string()); assert_eq!(classify_error(&error), ErrorType::Connection); let error = PipeWireError::StreamStalled(1); assert_eq!(classify_error(&error), ErrorType::Stream); let error = PipeWireError::NoBuffersAvailable; assert_eq!(classify_error(&error), ErrorType::Buffer); } #[test] fn test_error_context() { let ctx = ErrorContext::new() .with_stream_id(42) .with_attempt(3) .with_details("test error"); assert_eq!(ctx.stream_id, Some(42)); assert_eq!(ctx.attempt, 3); assert_eq!(ctx.details, "test error"); } #[test] fn test_retry_config() { let config = RetryConfig::default(); assert_eq!(config.delay_for_attempt(0).as_millis(), 100); assert_eq!(config.delay_for_attempt(1).as_millis(), 200); assert_eq!(config.delay_for_attempt(2).as_millis(), 400); assert_eq!(config.delay_for_attempt(3).as_millis(), 800); // Should cap at max_delay_ms assert_eq!(config.delay_for_attempt(10).as_millis(), 5000); } } lamco-pipewire-0.4.0/src/ffi.rs000064400000000000000000000175571046102023000144620ustar 00000000000000//! PipeWire FFI Bindings //! //! Low-level FFI bindings for PipeWire and SPA (Simple Plugin API). //! This module extends the pipewire-rs crate with additional functionality //! needed for DMA-BUF handling and advanced features. // Re-export ref types from pipewire crate (not owned Box/Rc types) pub use libspa::param::ParamType; pub use libspa::param::format::{MediaSubtype, MediaType}; pub use libspa::param::video::{VideoFormat, VideoInfoRaw}; pub use libspa::pod::{self as spa_pod, Pod as SpaPod}; pub use libspa::utils::{Choice, ChoiceFlags, Direction, Fraction, Id, Rectangle}; pub use libspa_sys as spa_sys; pub use pipewire::context::Context; pub use pipewire::core::Core; pub use pipewire::loop_::Loop; pub use pipewire::main_loop::MainLoop; pub use pipewire::spa::pod::Pod; pub use pipewire::spa::{self}; pub use pipewire::stream::{Stream, StreamState}; // Owned types (use these when constructing PipeWire objects) pub use pipewire::{context::ContextBox, main_loop::MainLoopBox, properties::PropertiesBox, stream::StreamBox}; /// DRM format modifiers for DMA-BUF pub mod drm_fourcc { pub const DRM_FORMAT_INVALID: u32 = 0; pub const DRM_FORMAT_MOD_INVALID: u64 = 0x00ffffffffffffff; pub const DRM_FORMAT_MOD_LINEAR: u64 = 0; // Common DRM formats pub const DRM_FORMAT_XRGB8888: u32 = 0x34325258; // XR24 pub const DRM_FORMAT_ARGB8888: u32 = 0x34325241; // AR24 pub const DRM_FORMAT_XBGR8888: u32 = 0x34324258; // XB24 pub const DRM_FORMAT_ABGR8888: u32 = 0x34324241; // AB24 } /// SPA video format to DRM fourcc conversion pub fn spa_video_format_to_drm_fourcc(format: VideoFormat) -> u32 { match format { VideoFormat::BGRx => drm_fourcc::DRM_FORMAT_XRGB8888, VideoFormat::BGRA => drm_fourcc::DRM_FORMAT_ARGB8888, VideoFormat::RGBx => drm_fourcc::DRM_FORMAT_XBGR8888, VideoFormat::RGBA => drm_fourcc::DRM_FORMAT_ABGR8888, _ => drm_fourcc::DRM_FORMAT_INVALID, } } /// DRM fourcc to SPA video format conversion pub fn drm_fourcc_to_spa_video_format(fourcc: u32) -> Option { match fourcc { drm_fourcc::DRM_FORMAT_XRGB8888 => Some(VideoFormat::BGRx), drm_fourcc::DRM_FORMAT_ARGB8888 => Some(VideoFormat::BGRA), drm_fourcc::DRM_FORMAT_XBGR8888 => Some(VideoFormat::RGBx), drm_fourcc::DRM_FORMAT_ABGR8888 => Some(VideoFormat::RGBA), _ => None, } } /// Get bytes per pixel for a video format pub fn get_bytes_per_pixel(format: VideoFormat) -> usize { match format { VideoFormat::BGRx | VideoFormat::BGRA | VideoFormat::RGBx | VideoFormat::RGBA => 4, VideoFormat::RGB | VideoFormat::BGR => 3, VideoFormat::GRAY8 => 1, // YUV formats - return for Y plane VideoFormat::NV12 | VideoFormat::I420 => 1, VideoFormat::YUY2 => 2, _ => 4, // Default to 4 } } /// Calculate stride for a given width and format pub fn calculate_stride(width: u32, format: VideoFormat) -> u32 { let bpp = get_bytes_per_pixel(format) as u32; // Align to 16 bytes for performance ((width * bpp + 15) / 16) * 16 } /// Calculate buffer size for a given format pub fn calculate_buffer_size(width: u32, height: u32, format: VideoFormat) -> usize { let stride = calculate_stride(width, format) as usize; match format { // RGB formats VideoFormat::BGRx | VideoFormat::BGRA | VideoFormat::RGBx | VideoFormat::RGBA | VideoFormat::RGB | VideoFormat::BGR | VideoFormat::GRAY8 => stride * height as usize, // YUV420 formats (1.5 bytes per pixel) VideoFormat::NV12 | VideoFormat::I420 => (stride * height as usize * 3) / 2, // YUV422 formats (2 bytes per pixel) VideoFormat::YUY2 => stride * height as usize, _ => stride * height as usize, } } /// SPA Data type for buffer negotiation #[repr(u32)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpaDataType { Invalid = 0, MemPtr = 1, MemFd = 2, DmaBuf = 3, } impl SpaDataType { pub fn from_u32(value: u32) -> Option { match value { 0 => Some(Self::Invalid), 1 => Some(Self::MemPtr), 2 => Some(Self::MemFd), 3 => Some(Self::DmaBuf), _ => None, } } } /// Buffer metadata structure #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct BufferMetadata { pub pts: u64, pub dts_offset: i64, pub seq: u32, pub flags: u32, } /// Damage region #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct DamageRegion { pub x: i32, pub y: i32, pub width: u32, pub height: u32, } impl DamageRegion { pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self { Self { x, y, width, height } } pub fn is_valid(&self) -> bool { self.width > 0 && self.height > 0 } } /// Stream events listener trait pub trait StreamEventsListener: Send + Sync { /// Called when stream state changes fn on_state_changed(&mut self, old: StreamState, new: StreamState, error: Option<&str>); /// Called when stream parameters change fn on_param_changed(&mut self, param_type: u32, param: &Pod); /// Called when a new buffer is added fn on_add_buffer(&mut self, buffer_id: u32); /// Called when a buffer is removed fn on_remove_buffer(&mut self, buffer_id: u32); /// Called when there's a frame to process fn on_process(&mut self); } /// Helper to build format parameters /// /// Note: This is a placeholder. Actual format parameter construction /// would be done using PipeWire stream builder in production code. /// The pipewire crate provides higher-level APIs for this. pub fn build_format_params(_width: u32, _height: u32, _framerate: Fraction, _formats: &[VideoFormat]) -> Vec { // In production, use pipewire::stream::StreamBuilder with appropriate parameters // This would use the PipeWire C API directly or through pipewire-rs Vec::new() } /// Helper to build buffer parameters /// /// Note: This is a placeholder. Actual buffer parameter construction /// would be done using PipeWire stream builder in production code. pub fn build_buffer_params(_buffer_count: u32, _buffer_size: u32, _stride: u32, _support_dmabuf: bool) -> Vec { // In production, use pipewire::stream::StreamBuilder with appropriate parameters Vec::new() } /// Parse video format from Pod /// /// Note: This is a placeholder. Actual format parsing would use /// the pipewire crate's built-in format parsing. pub fn parse_video_format(_pod: &Pod) -> Option { // In production, use pipewire's format parsing APIs None } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_conversions() { assert_eq!( spa_video_format_to_drm_fourcc(VideoFormat::BGRx), drm_fourcc::DRM_FORMAT_XRGB8888 ); assert_eq!( drm_fourcc_to_spa_video_format(drm_fourcc::DRM_FORMAT_XRGB8888), Some(VideoFormat::BGRx) ); } #[test] fn test_bytes_per_pixel() { assert_eq!(get_bytes_per_pixel(VideoFormat::BGRA), 4); assert_eq!(get_bytes_per_pixel(VideoFormat::RGB), 3); assert_eq!(get_bytes_per_pixel(VideoFormat::GRAY8), 1); } #[test] fn test_stride_calculation() { // 1920 * 4 = 7680, which is already aligned to 16 assert_eq!(calculate_stride(1920, VideoFormat::BGRA), 7680); // 1921 * 4 = 7684, should align to 7696 assert_eq!(calculate_stride(1921, VideoFormat::BGRA), 7696); } #[test] fn test_buffer_size_calculation() { // 1920x1080 BGRA: 1920 * 4 * 1080 = 8294400 assert_eq!(calculate_buffer_size(1920, 1080, VideoFormat::BGRA), 7680 * 1080); } #[test] fn test_damage_region() { let region = DamageRegion::new(10, 20, 100, 200); assert!(region.is_valid()); let invalid = DamageRegion::new(0, 0, 0, 0); assert!(!invalid.is_valid()); } } lamco-pipewire-0.4.0/src/format.rs000064400000000000000000000300661046102023000151740ustar 00000000000000//! Format Conversion Utilities //! //! Provides pixel format conversion between various video formats. //! Includes optimized SIMD implementations where available. use libspa::param::video::VideoFormat; use crate::error::{PipeWireError, Result}; /// Pixel format enum for our internal use #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PixelFormat { /// BGRA 32-bit BGRA, /// BGRX 32-bit (no alpha) BGRx, /// RGBA 32-bit RGBA, /// RGBX 32-bit (no alpha) RGBx, /// RGB 24-bit RGB, /// BGR 24-bit BGR, /// Grayscale 8-bit GRAY8, /// YUV 4:2:0 semi-planar (NV12) NV12, /// YUV 4:2:2 packed (YUY2) YUY2, /// YUV 4:2:0 planar (I420) I420, } impl PixelFormat { /// Convert from SPA VideoFormat pub fn from_spa(format: VideoFormat) -> Option { match format { VideoFormat::BGRA => Some(Self::BGRA), VideoFormat::BGRx => Some(Self::BGRx), VideoFormat::RGBA => Some(Self::RGBA), VideoFormat::RGBx => Some(Self::RGBx), VideoFormat::RGB => Some(Self::RGB), VideoFormat::BGR => Some(Self::BGR), VideoFormat::GRAY8 => Some(Self::GRAY8), VideoFormat::NV12 => Some(Self::NV12), VideoFormat::YUY2 => Some(Self::YUY2), VideoFormat::I420 => Some(Self::I420), _ => None, } } /// Convert to SPA VideoFormat pub fn to_spa(&self) -> VideoFormat { match self { Self::BGRA => VideoFormat::BGRA, Self::BGRx => VideoFormat::BGRx, Self::RGBA => VideoFormat::RGBA, Self::RGBx => VideoFormat::RGBx, Self::RGB => VideoFormat::RGB, Self::BGR => VideoFormat::BGR, Self::GRAY8 => VideoFormat::GRAY8, Self::NV12 => VideoFormat::NV12, Self::YUY2 => VideoFormat::YUY2, Self::I420 => VideoFormat::I420, } } /// Get bytes per pixel (for packed formats) pub fn bytes_per_pixel(&self) -> usize { match self { Self::BGRA | Self::BGRx | Self::RGBA | Self::RGBx => 4, Self::RGB | Self::BGR => 3, Self::GRAY8 => 1, Self::NV12 | Self::I420 => 1, // Y plane Self::YUY2 => 2, } } } /// Convert pixel data from one format to another pub fn convert_format( src: &[u8], dst: &mut [u8], src_format: PixelFormat, dst_format: PixelFormat, width: u32, height: u32, src_stride: u32, dst_stride: u32, ) -> Result<()> { // Fast path: no conversion needed if src_format == dst_format && src_stride == dst_stride { let row_bytes = (width * src_format.bytes_per_pixel() as u32) as usize; if row_bytes == src_stride as usize { // Can do a single memcpy dst[..src.len()].copy_from_slice(src); } else { // Copy row by row for y in 0..height { let src_offset = (y * src_stride) as usize; let dst_offset = (y * dst_stride) as usize; dst[dst_offset..dst_offset + row_bytes].copy_from_slice(&src[src_offset..src_offset + row_bytes]); } } return Ok(()); } // Conversion needed match (src_format, dst_format) { // RGB to BGRA (PixelFormat::RGB, PixelFormat::BGRA) => convert_rgb_to_bgra(src, dst, width, height, src_stride, dst_stride), // RGBA to BGRA (PixelFormat::RGBA, PixelFormat::BGRA) => convert_rgba_to_bgra(src, dst, width, height, src_stride, dst_stride), // BGR to BGRA (PixelFormat::BGR, PixelFormat::BGRA) => convert_bgr_to_bgra(src, dst, width, height, src_stride, dst_stride), // NV12 to BGRA (PixelFormat::NV12, PixelFormat::BGRA) => convert_nv12_to_bgra(src, dst, width, height), // YUY2 to BGRA (PixelFormat::YUY2, PixelFormat::BGRA) => convert_yuy2_to_bgra(src, dst, width, height, src_stride, dst_stride), // I420 to BGRA (PixelFormat::I420, PixelFormat::BGRA) => convert_i420_to_bgra(src, dst, width, height), _ => Err(PipeWireError::FormatConversionFailed(format!( "Unsupported conversion: {:?} -> {:?}", src_format, dst_format ))), } } /// Convert RGB to BGRA fn convert_rgb_to_bgra( src: &[u8], dst: &mut [u8], width: u32, height: u32, src_stride: u32, dst_stride: u32, ) -> Result<()> { for y in 0..height { let src_row = &src[(y * src_stride) as usize..]; let dst_row = &mut dst[(y * dst_stride) as usize..]; for x in 0..width as usize { let src_idx = x * 3; let dst_idx = x * 4; dst_row[dst_idx] = src_row[src_idx + 2]; // B dst_row[dst_idx + 1] = src_row[src_idx + 1]; // G dst_row[dst_idx + 2] = src_row[src_idx]; // R dst_row[dst_idx + 3] = 255; // A } } Ok(()) } /// Convert RGBA to BGRA fn convert_rgba_to_bgra( src: &[u8], dst: &mut [u8], width: u32, height: u32, src_stride: u32, dst_stride: u32, ) -> Result<()> { for y in 0..height { let src_row = &src[(y * src_stride) as usize..]; let dst_row = &mut dst[(y * dst_stride) as usize..]; for x in 0..width as usize { let src_idx = x * 4; let dst_idx = x * 4; dst_row[dst_idx] = src_row[src_idx + 2]; // B dst_row[dst_idx + 1] = src_row[src_idx + 1]; // G dst_row[dst_idx + 2] = src_row[src_idx]; // R dst_row[dst_idx + 3] = src_row[src_idx + 3]; // A } } Ok(()) } /// Convert BGR to BGRA fn convert_bgr_to_bgra( src: &[u8], dst: &mut [u8], width: u32, height: u32, src_stride: u32, dst_stride: u32, ) -> Result<()> { for y in 0..height { let src_row = &src[(y * src_stride) as usize..]; let dst_row = &mut dst[(y * dst_stride) as usize..]; for x in 0..width as usize { let src_idx = x * 3; let dst_idx = x * 4; dst_row[dst_idx] = src_row[src_idx]; // B dst_row[dst_idx + 1] = src_row[src_idx + 1]; // G dst_row[dst_idx + 2] = src_row[src_idx + 2]; // R dst_row[dst_idx + 3] = 255; // A } } Ok(()) } /// Convert NV12 to BGRA fn convert_nv12_to_bgra(src: &[u8], dst: &mut [u8], width: u32, height: u32) -> Result<()> { let y_plane = &src[0..(width * height) as usize]; let uv_plane = &src[(width * height) as usize..]; for y in 0..height { for x in 0..width { // Get Y value let y_val = y_plane[(y * width + x) as usize] as i32; // Get UV values (subsampled 2x2) let uv_x = x / 2; let uv_y = y / 2; let uv_idx = (uv_y * width + uv_x * 2) as usize; let u_val = uv_plane[uv_idx] as i32; let v_val = uv_plane[uv_idx + 1] as i32; // YUV to RGB conversion let c = y_val - 16; let d = u_val - 128; let e = v_val - 128; let r = (298 * c + 409 * e + 128) >> 8; let g = (298 * c - 100 * d - 208 * e + 128) >> 8; let b = (298 * c + 516 * d + 128) >> 8; // Clamp and write BGRA let dst_idx = ((y * width + x) * 4) as usize; dst[dst_idx] = clamp(b, 0, 255) as u8; dst[dst_idx + 1] = clamp(g, 0, 255) as u8; dst[dst_idx + 2] = clamp(r, 0, 255) as u8; dst[dst_idx + 3] = 255; } } Ok(()) } /// Convert YUY2 to BGRA fn convert_yuy2_to_bgra( src: &[u8], dst: &mut [u8], width: u32, height: u32, src_stride: u32, dst_stride: u32, ) -> Result<()> { for y in 0..height { let src_row = &src[(y * src_stride) as usize..]; let dst_row = &mut dst[(y * dst_stride) as usize..]; for x in (0..width as usize).step_by(2) { let src_idx = x * 2; let y0 = src_row[src_idx] as i32; let u = src_row[src_idx + 1] as i32; let y1 = src_row[src_idx + 2] as i32; let v = src_row[src_idx + 3] as i32; // Convert first pixel let (r0, g0, b0) = yuv_to_rgb(y0, u, v); let dst_idx0 = x * 4; dst_row[dst_idx0] = b0; dst_row[dst_idx0 + 1] = g0; dst_row[dst_idx0 + 2] = r0; dst_row[dst_idx0 + 3] = 255; // Convert second pixel if x + 1 < width as usize { let (r1, g1, b1) = yuv_to_rgb(y1, u, v); let dst_idx1 = (x + 1) * 4; dst_row[dst_idx1] = b1; dst_row[dst_idx1 + 1] = g1; dst_row[dst_idx1 + 2] = r1; dst_row[dst_idx1 + 3] = 255; } } } Ok(()) } /// Convert I420 to BGRA fn convert_i420_to_bgra(src: &[u8], dst: &mut [u8], width: u32, height: u32) -> Result<()> { let y_plane_size = (width * height) as usize; let uv_plane_size = y_plane_size / 4; let y_plane = &src[0..y_plane_size]; let u_plane = &src[y_plane_size..y_plane_size + uv_plane_size]; let v_plane = &src[y_plane_size + uv_plane_size..]; for y in 0..height { for x in 0..width { // Get Y value let y_val = y_plane[(y * width + x) as usize] as i32; // Get UV values (subsampled 2x2) let uv_x = x / 2; let uv_y = y / 2; let uv_idx = (uv_y * width / 2 + uv_x) as usize; let u_val = u_plane[uv_idx] as i32; let v_val = v_plane[uv_idx] as i32; // YUV to RGB conversion let (r, g, b) = yuv_to_rgb(y_val, u_val, v_val); // Write BGRA let dst_idx = ((y * width + x) * 4) as usize; dst[dst_idx] = b; dst[dst_idx + 1] = g; dst[dst_idx + 2] = r; dst[dst_idx + 3] = 255; } } Ok(()) } /// YUV to RGB conversion helper #[inline] fn yuv_to_rgb(y: i32, u: i32, v: i32) -> (u8, u8, u8) { let c = y - 16; let d = u - 128; let e = v - 128; let r = (298 * c + 409 * e + 128) >> 8; let g = (298 * c - 100 * d - 208 * e + 128) >> 8; let b = (298 * c + 516 * d + 128) >> 8; (clamp(r, 0, 255) as u8, clamp(g, 0, 255) as u8, clamp(b, 0, 255) as u8) } /// Clamp value to range #[inline] fn clamp(val: i32, min: i32, max: i32) -> i32 { if val < min { min } else if val > max { max } else { val } } #[cfg(test)] mod tests { use super::*; #[test] fn test_pixel_format_conversion() { assert_eq!(PixelFormat::from_spa(VideoFormat::BGRA), Some(PixelFormat::BGRA)); assert_eq!(PixelFormat::BGRA.to_spa(), VideoFormat::BGRA); } #[test] fn test_rgb_to_bgra_conversion() { let src = vec![ 255, 0, 0, // Red pixel 0, 255, 0, // Green pixel 0, 0, 255, // Blue pixel ]; let mut dst = vec![0u8; 12]; // 3 pixels * 4 bytes convert_rgb_to_bgra(&src, &mut dst, 3, 1, 9, 12).unwrap(); // Red pixel (RGB 255,0,0 -> BGRA 0,0,255,255) assert_eq!(dst[0], 0); // B assert_eq!(dst[1], 0); // G assert_eq!(dst[2], 255); // R assert_eq!(dst[3], 255); // A // Green pixel (RGB 0,255,0 -> BGRA 0,255,0,255) assert_eq!(dst[4], 0); // B assert_eq!(dst[5], 255); // G assert_eq!(dst[6], 0); // R assert_eq!(dst[7], 255); // A // Blue pixel (RGB 0,0,255 -> BGRA 255,0,0,255) assert_eq!(dst[8], 255); // B assert_eq!(dst[9], 0); // G assert_eq!(dst[10], 0); // R assert_eq!(dst[11], 255); // A } #[test] fn test_yuv_to_rgb() { // Test black (Y=16, U=128, V=128) let (r, g, b) = yuv_to_rgb(16, 128, 128); assert_eq!((r, g, b), (0, 0, 0)); // Test white (Y=235, U=128, V=128) let (r, g, b) = yuv_to_rgb(235, 128, 128); assert_eq!((r, g, b), (255, 255, 255)); } #[test] fn test_clamp() { assert_eq!(clamp(-10, 0, 255), 0); assert_eq!(clamp(300, 0, 255), 255); assert_eq!(clamp(128, 0, 255), 128); } } lamco-pipewire-0.4.0/src/frame.rs000064400000000000000000000346571046102023000150100ustar 00000000000000//! Video Frame Management //! //! Structures and utilities for handling video frames captured from PipeWire. use std::os::fd::OwnedFd; use std::sync::Arc; use std::time::{Duration, SystemTime}; use crate::ffi::DamageRegion; use crate::format::PixelFormat; use crate::meta::BufferMeta; // ============================================================================= // Frame Buffer Types (zero-copy DMA-BUF support) // ============================================================================= /// Frame pixel data: either CPU-resident memory or a GPU DMA-BUF descriptor. /// /// When a hardware encoder supports DMA-BUF import, passing the `DmaBuf` /// variant avoids copying pixels through the CPU entirely. #[derive(Debug)] pub enum FrameBuffer { /// CPU-resident pixel data (shared via Arc for cheap cloning) Memory(Arc>), /// GPU DMA-BUF descriptor — file descriptors referencing GPU memory. /// The FDs are dup'd from PipeWire in the process callback and owned /// by this struct. Dropping closes the FDs. DmaBuf(DmaBufDescriptor), } impl Clone for FrameBuffer { fn clone(&self) -> Self { match self { Self::Memory(data) => Self::Memory(Arc::clone(data)), // DMA-BUF descriptors can't be cheaply cloned (need dup). // Fall back to empty descriptor — callers should not clone DmaBuf frames. Self::DmaBuf(_) => { tracing::warn!("Cloning FrameBuffer::DmaBuf — this loses the FD!"); Self::Memory(Arc::new(Vec::new())) } } } } /// Describes a DMA-BUF GPU memory buffer with one or more planes. /// /// Each plane has its own file descriptor, stride, and offset. Multi-planar /// formats like NV12 use multiple planes; single-plane formats like BGRA /// use one plane. #[derive(Debug)] pub struct DmaBufDescriptor { /// Buffer planes (up to 4 for multi-planar formats) pub planes: Vec, /// DRM fourcc format code (e.g., DRM_FORMAT_ARGB8888) pub drm_format: u32, /// DRM format modifier (DRM_FORMAT_MOD_LINEAR = 0, or vendor-specific) pub modifier: u64, /// Buffer width in pixels pub width: u32, /// Buffer height in pixels pub height: u32, } /// A single plane of a DMA-BUF buffer. #[derive(Debug)] pub struct DmaBufPlane { /// Owned file descriptor for this plane's GPU memory. /// Dropping this closes the FD. pub fd: OwnedFd, /// Byte offset into the buffer for this plane pub offset: u32, /// Row stride in bytes for this plane pub stride: u32, } /// Raw frame data from a direct capture channel (no PipeWire). /// /// Carries pixel data with optional metadata. Fields that are `None` /// will be filled with defaults by the adapter. pub struct RawFrameData { /// Pixel data. pub data: Vec, /// Width in pixels (None = use stream default). pub width: Option, /// Height in pixels (None = use stream default). pub height: Option, /// Row stride in bytes (None = width * 4). pub stride: Option, /// Pixel format (None = BGRx). pub format: Option, } /// Video frame captured from PipeWire #[derive(Clone)] pub struct VideoFrame { /// Unique frame identifier pub frame_id: u64, /// Presentation timestamp (nanoseconds) pub pts: u64, /// Decode timestamp (nanoseconds) pub dts: u64, /// Frame duration (nanoseconds) pub duration: u64, /// Frame width in pixels pub width: u32, /// Frame height in pixels pub height: u32, /// Row stride in bytes pub stride: u32, /// Pixel format pub format: PixelFormat, /// Monitor/stream index pub monitor_index: u32, /// Pixel data: CPU memory or GPU DMA-BUF descriptor pub buffer: FrameBuffer, /// Capture timestamp pub capture_time: SystemTime, /// Damage regions (optional optimization) pub damage_regions: Vec, /// SPA buffer metadata (transform, header, crop, damage, cursor) pub meta: BufferMeta, /// Frame flags pub flags: FrameFlags, } /// Frame flags #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FrameFlags { bits: u32, } impl FrameFlags { pub const NONE: u32 = 0; pub const DMABUF: u32 = 1 << 0; pub const GPU_PROCESSED: u32 = 1 << 1; pub const KEYFRAME: u32 = 1 << 2; pub const CORRUPTED: u32 = 1 << 3; pub const INCOMPLETE: u32 = 1 << 4; pub fn new() -> Self { Self { bits: 0 } } pub fn from_bits(bits: u32) -> Self { Self { bits } } pub fn bits(&self) -> u32 { self.bits } pub fn has_dmabuf(&self) -> bool { self.bits & Self::DMABUF != 0 } pub fn set_dmabuf(&mut self) { self.bits |= Self::DMABUF; } pub fn has_gpu_processed(&self) -> bool { self.bits & Self::GPU_PROCESSED != 0 } pub fn set_gpu_processed(&mut self) { self.bits |= Self::GPU_PROCESSED; } pub fn is_keyframe(&self) -> bool { self.bits & Self::KEYFRAME != 0 } pub fn set_keyframe(&mut self) { self.bits |= Self::KEYFRAME; } pub fn is_corrupted(&self) -> bool { self.bits & Self::CORRUPTED != 0 } pub fn is_incomplete(&self) -> bool { self.bits & Self::INCOMPLETE != 0 } } impl Default for FrameFlags { fn default() -> Self { Self::new() } } impl VideoFrame { /// Create a new video frame pub fn new(frame_id: u64, width: u32, height: u32, stride: u32, format: PixelFormat, monitor_index: u32) -> Self { Self { frame_id, pts: 0, dts: 0, duration: 0, width, height, stride, format, monitor_index, buffer: FrameBuffer::Memory(Arc::new(Vec::new())), capture_time: SystemTime::now(), damage_regions: Vec::new(), meta: BufferMeta::default(), flags: FrameFlags::new(), } } /// Create frame with data pub fn with_data( frame_id: u64, width: u32, height: u32, stride: u32, format: PixelFormat, monitor_index: u32, data: Vec, ) -> Self { Self { frame_id, pts: 0, dts: 0, duration: 0, width, height, stride, format, monitor_index, buffer: FrameBuffer::Memory(Arc::new(data)), capture_time: SystemTime::now(), damage_regions: Vec::new(), meta: BufferMeta::default(), flags: FrameFlags::new(), } } /// Get the buffer transform value (0 = None, 1 = 90 CCW, 2 = 180, etc.) pub fn transform(&self) -> u32 { self.meta.transform.unwrap_or(0) } /// Check if the buffer has a negative chunk stride (bottom-up GL buffer). /// When true, the pixel data is stored bottom-to-top and needs vertical flip. pub fn is_bottom_up(&self) -> bool { self.meta.chunk_stride < 0 } /// Set timing information pub fn set_timing(&mut self, pts: u64, dts: u64, duration: u64) { self.pts = pts; self.dts = dts; self.duration = duration; } /// Add a damage region pub fn add_damage_region(&mut self, region: DamageRegion) { if region.is_valid() { self.damage_regions.push(region); } } /// Get total damage area pub fn total_damage_area(&self) -> u32 { self.damage_regions.iter().map(|r| r.width * r.height).sum() } /// Check if frame has significant damage pub fn has_significant_damage(&self, threshold: f32) -> bool { if self.damage_regions.is_empty() { return true; // No damage info = assume full frame } let total_pixels = self.width * self.height; let damage_pixels = self.total_damage_area(); (damage_pixels as f32 / total_pixels as f32) >= threshold } /// Get age of frame pub fn age(&self) -> Duration { self.capture_time.elapsed().unwrap_or(Duration::ZERO) } /// Check if frame is fresh pub fn is_fresh(&self, max_age: Duration) -> bool { self.age() <= max_age } /// Get CPU pixel data if available. /// Returns None for DMA-BUF frames (use `buffer` field directly). pub fn data(&self) -> Option<&Arc>> { match &self.buffer { FrameBuffer::Memory(data) => Some(data), FrameBuffer::DmaBuf(_) => None, } } /// Check if this frame carries a DMA-BUF descriptor pub fn is_dmabuf(&self) -> bool { matches!(self.buffer, FrameBuffer::DmaBuf(_)) } /// Get data size (0 for DMA-BUF frames since data is on GPU) pub fn data_size(&self) -> usize { match &self.buffer { FrameBuffer::Memory(data) => data.len(), FrameBuffer::DmaBuf(desc) => { // Estimated size based on dimensions (desc.width * desc.height * 4) as usize } } } /// Check if frame data is valid pub fn is_valid(&self) -> bool { let has_data = match &self.buffer { FrameBuffer::Memory(data) => !data.is_empty(), FrameBuffer::DmaBuf(desc) => !desc.planes.is_empty(), }; has_data && !self.flags.is_corrupted() && !self.flags.is_incomplete() } /// Clone frame data (makes a copy). Returns empty vec for DMA-BUF frames. pub fn clone_data(&self) -> Vec { match &self.buffer { FrameBuffer::Memory(data) => (**data).clone(), FrameBuffer::DmaBuf(_) => Vec::new(), } } } impl std::fmt::Debug for VideoFrame { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VideoFrame") .field("frame_id", &self.frame_id) .field("pts", &self.pts) .field("width", &self.width) .field("height", &self.height) .field("format", &self.format) .field("monitor_index", &self.monitor_index) .field("data_size", &self.data_size()) .field("is_dmabuf", &self.is_dmabuf()) .field("damage_regions", &self.damage_regions.len()) .field("transform", &self.meta.transform) .field("flags", &self.flags.bits()) .finish() } } /// Frame callback type pub type FrameCallback = Box; /// Frame statistics #[derive(Debug, Clone, Default)] pub struct FrameStats { /// Total frames processed pub frames_processed: u64, /// Total bytes processed pub bytes_processed: u64, /// Frames dropped pub frames_dropped: u64, /// Average frame size pub avg_frame_size: f64, /// Average frame rate pub avg_fps: f64, /// Last frame timestamp pub last_frame_time: Option, /// DMA-BUF frames pub dmabuf_frames: u64, /// Memory frames pub memory_frames: u64, } impl FrameStats { /// Create new frame statistics pub fn new() -> Self { Self::default() } /// Update with new frame pub fn update(&mut self, frame: &VideoFrame) { self.frames_processed += 1; self.bytes_processed += frame.data_size() as u64; // Update average frame size self.avg_frame_size = self.bytes_processed as f64 / self.frames_processed as f64; // Update FPS if let Some(last_time) = self.last_frame_time { if let Ok(elapsed) = frame.capture_time.duration_since(last_time) { let interval_secs = elapsed.as_secs_f64(); if interval_secs > 0.0 { let instant_fps = 1.0 / interval_secs; // Exponential moving average self.avg_fps = self.avg_fps * 0.9 + instant_fps * 0.1; } } } self.last_frame_time = Some(frame.capture_time); // Track buffer types if frame.flags.has_dmabuf() { self.dmabuf_frames += 1; } else { self.memory_frames += 1; } } /// Record dropped frame pub fn record_drop(&mut self) { self.frames_dropped += 1; } /// Get drop rate pub fn drop_rate(&self) -> f64 { if self.frames_processed == 0 { 0.0 } else { self.frames_dropped as f64 / (self.frames_processed + self.frames_dropped) as f64 } } /// Get DMA-BUF usage rate pub fn dmabuf_rate(&self) -> f64 { if self.frames_processed == 0 { 0.0 } else { self.dmabuf_frames as f64 / self.frames_processed as f64 } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_frame_creation() { let frame = VideoFrame::new(1, 1920, 1080, 7680, PixelFormat::BGRA, 0); assert_eq!(frame.frame_id, 1); assert_eq!(frame.width, 1920); assert_eq!(frame.height, 1080); assert_eq!(frame.format, PixelFormat::BGRA); } #[test] fn test_frame_flags() { let mut flags = FrameFlags::new(); assert!(!flags.has_dmabuf()); flags.set_dmabuf(); assert!(flags.has_dmabuf()); flags.set_gpu_processed(); assert!(flags.has_gpu_processed()); } #[test] fn test_damage_regions() { let mut frame = VideoFrame::new(1, 1920, 1080, 7680, PixelFormat::BGRA, 0); frame.add_damage_region(DamageRegion::new(0, 0, 100, 100)); frame.add_damage_region(DamageRegion::new(100, 100, 200, 200)); assert_eq!(frame.damage_regions.len(), 2); assert_eq!(frame.total_damage_area(), 100 * 100 + 200 * 200); } #[test] fn test_frame_stats() { let mut stats = FrameStats::new(); let frame = VideoFrame::with_data(1, 100, 100, 400, PixelFormat::BGRA, 0, vec![0u8; 40000]); stats.update(&frame); assert_eq!(stats.frames_processed, 1); assert_eq!(stats.bytes_processed, 40000); assert_eq!(stats.avg_frame_size, 40000.0); } #[test] fn test_drop_rate() { let mut stats = FrameStats::new(); // Process 10 frames for i in 0..10 { let frame = VideoFrame::new(i, 100, 100, 400, PixelFormat::BGRA, 0); stats.update(&frame); } // Drop 2 frames stats.record_drop(); stats.record_drop(); // Drop rate should be 2/12 = 0.1666... let drop_rate = stats.drop_rate(); assert!((drop_rate - 0.1666).abs() < 0.001); } } lamco-pipewire-0.4.0/src/lib.rs000064400000000000000000000406321046102023000144520ustar 00000000000000#![cfg_attr(docsrs, feature(doc_cfg))] //! # lamco-pipewire //! //! High-performance PipeWire integration for Wayland screen capture with //! DMA-BUF support, adaptive bitrate control, and comprehensive error handling. //! //! This crate is part of the [lamco-wayland](https://github.com/lamco-admin/lamco-wayland) //! workspace and is designed to work with [`lamco-portal`](https://crates.io/crates/lamco-portal) //! for XDG Desktop Portal integration. //! //! # Features //! //! - **Zero-Copy DMA-BUF**: Hardware-accelerated frame transfer when available //! - **Multi-Monitor**: Concurrent handling of multiple monitor streams //! - **Format Negotiation**: Automatic format selection with fallbacks //! - **YUV Conversion**: Built-in NV12, I420, YUY2 to BGRA conversion //! - **Cursor Extraction**: Separate cursor tracking for remote desktop //! - **Damage Tracking**: Region-based change detection for efficient encoding //! - **Adaptive Bitrate**: Network-aware bitrate control for streaming //! - **Error Recovery**: Automatic reconnection and stream recovery //! //! # Requirements //! //! This crate requires: //! - **Linux** with a Wayland compositor //! - **PipeWire** installed and running (typically via your compositor) //! - **PipeWire development libraries**: `libpipewire-0.3-dev` (Debian/Ubuntu) or `pipewire-devel` (Fedora) //! - **Rust 1.77+** (for PipeWire bindings compatibility) //! //! # Quick Start //! //! ```rust,ignore //! use lamco_pipewire::{PipeWireManager, PipeWireConfig, StreamInfo, SourceType}; //! //! # async fn example() -> Result<(), Box> { //! // Create manager with default configuration //! let mut manager = PipeWireManager::with_default()?; //! //! // Connect using portal-provided file descriptor (from lamco-portal) //! let fd = /* session.pipewire_fd() */; //! manager.connect(fd).await?; //! //! // Create stream for a monitor //! let stream_info = StreamInfo { //! node_id: 42, //! position: (0, 0), //! size: (1920, 1080), //! source_type: SourceType::Monitor, //! }; //! //! let handle = manager.create_stream(&stream_info).await?; //! //! // Receive frames //! if let Some(mut rx) = manager.frame_receiver(handle.id).await { //! while let Some(frame) = rx.recv().await { //! println!("Frame: {}x{}", frame.width, frame.height); //! } //! } //! //! manager.shutdown().await?; //! # Ok(()) //! # } //! ``` //! //! # Configuration //! //! Customize capture behavior using [`PipeWireConfig`]: //! //! ```rust //! use lamco_pipewire::{PipeWireConfig, PixelFormat}; //! //! let config = PipeWireConfig::builder() //! .buffer_count(4) // More buffers for high refresh //! .preferred_format(PixelFormat::BGRA) // Preferred pixel format //! .use_dmabuf(true) // Enable zero-copy //! .max_streams(4) // Limit concurrent streams //! .enable_cursor(true) // Extract cursor separately //! .enable_damage_tracking(true) // Track changed regions //! .build(); //! ``` //! //! # Error Handling //! //! The crate uses typed errors via [`PipeWireError`]: //! //! ```rust,ignore //! use lamco_pipewire::{PipeWireManager, PipeWireError, classify_error, ErrorType}; //! //! # async fn example() -> Result<(), Box> { //! let mut manager = PipeWireManager::with_default()?; //! //! match manager.connect(fd).await { //! Ok(()) => println!("Connected!"), //! Err(PipeWireError::ConnectionFailed(msg)) => { //! eprintln!("Connection failed: {}", msg); //! } //! Err(PipeWireError::Timeout) => { //! eprintln!("Connection timed out - is PipeWire running?"); //! } //! Err(e) => { //! // Use error classification for recovery decisions //! match classify_error(&e) { //! ErrorType::Connection => eprintln!("Retry connection"), //! ErrorType::Permission => eprintln!("Check portal permissions"), //! _ => eprintln!("Error: {}", e), //! } //! } //! } //! # Ok(()) //! # } //! ``` //! //! # Architecture //! //! PipeWire's Rust bindings use `Rc<>` and `NonNull<>` internally, making them //! **not Send**. This crate solves this with a dedicated thread architecture: //! //! ```text //! ┌─────────────────────────────────────────────────────────┐ //! │ Tokio Async Runtime │ //! │ │ //! │ Your Application → PipeWireManager │ //! │ (Send + Sync wrapper) │ //! │ │ │ //! │ │ Commands via mpsc │ //! │ ▼ │ //! └───────────────────────────┼─────────────────────────────┘ //! │ //! ┌───────────────────────────▼─────────────────────────────┐ //! │ Dedicated PipeWire Thread │ //! │ (std::thread - owns all non-Send types) │ //! │ │ //! │ MainLoop (Rc) ─> Context (Rc) ─> Core (Rc) │ //! │ │ │ //! │ ▼ │ //! │ Streams (NonNull) │ //! │ │ │ //! │ ▼ │ //! │ Frame Callbacks │ //! │ │ │ //! │ │ Frames via mpsc │ //! └──────────────────────────────────────┼──────────────────┘ //! │ //! ▼ //! Your application receives frames //! ``` //! //! # Platform Notes //! //! - **GNOME**: Works out of the box with `xdg-desktop-portal-gnome` //! - **KDE Plasma**: Use `xdg-desktop-portal-kde` //! - **wlroots** (Sway, Hyprland): Use `xdg-desktop-portal-wlr` //! - **X11**: Not supported - Wayland only (use X11 screen capture APIs directly) //! //! # Security //! //! This crate handles sensitive resources: //! //! - **File Descriptors**: The portal FD provides access to screen content. //! Never expose it to untrusted code. //! - **DMA-BUF**: Hardware buffers may contain screen content from other //! applications. Handle with appropriate security context. //! - **Memory Mapping**: Buffer contents should be treated as sensitive data. //! //! All screen capture requires user consent via the XDG Desktop Portal //! permission dialog. //! //! # Cargo Features //! //! ```toml //! [dependencies] //! lamco-pipewire = { version = "0.1", features = ["full"] } //! ``` //! //! | Feature | Default | Description | //! |---------|---------|-------------| //! | `dmabuf` | Yes | DMA-BUF zero-copy support | //! | `yuv` | No | YUV format conversion utilities | //! | `cursor` | No | Hardware cursor extraction | //! | `damage` | No | Region damage tracking | //! | `adaptive` | No | Adaptive bitrate control | //! | `full` | No | All features enabled | //! //! # Performance //! //! Typical performance on modern hardware: //! //! - **Frame latency**: < 2ms (with DMA-BUF) //! - **Memory usage**: < 100MB per stream //! - **CPU usage**: < 5% per stream (1080p @ 60Hz) //! - **Refresh rates**: Tested up to 144Hz // ============================================================================= // CORE MODULES // ============================================================================= pub mod buffer; pub mod config; pub mod connection; pub mod coordinator; pub mod error; pub mod ffi; pub mod format; pub mod frame; pub mod manager; pub mod meta; pub mod pw_thread; pub mod stream; pub mod thread_comm; // ============================================================================= // FEATURE MODULES // ============================================================================= /// YUV format conversion utilities /// /// Requires the `yuv` feature. #[cfg(feature = "yuv")] pub mod yuv; /// Hardware cursor extraction /// /// Requires the `cursor` feature. #[cfg(feature = "cursor")] pub mod cursor; /// Region damage tracking /// /// Requires the `damage` feature. #[cfg(feature = "damage")] pub mod damage; /// Audio capture via PipeWire /// /// Requires the `audio` feature. #[cfg(feature = "audio")] #[cfg_attr(docsrs, doc(cfg(feature = "audio")))] pub mod audio; /// Adaptive bitrate control /// /// Requires the `adaptive` feature. #[cfg(feature = "adaptive")] pub mod bitrate; // ============================================================================= // RE-EXPORTS - PRIMARY API // ============================================================================= // Manager (primary entry point) #[cfg(feature = "audio")] pub use audio::{AudioCapture, AudioCaptureHandle, AudioFormat, AudioSamples, CaptureConfig, spawn_audio_capture}; #[cfg(feature = "adaptive")] pub use bitrate::{BitrateController, BitrateStats}; // Buffer management pub use buffer::{BufferManager, BufferType, ManagedBuffer, SharedBufferManager}; // Configuration pub use config::{ AdaptiveBitrateConfig, AdaptiveBitrateConfigBuilder, PipeWireConfig, PipeWireConfigBuilder, QualityPreset, }; // ============================================================================= // RE-EXPORTS - ADVANCED API // ============================================================================= // Low-level connection (for advanced use cases) pub use connection::{ConnectionState, PipeWireConnection, PipeWireEvent}; // Coordinator pub use coordinator::{CoordinatorStats, DispatcherConfig, FrameDispatcher, MultiStreamCoordinator}; // Stream types pub use coordinator::{MonitorEvent, MonitorInfo, MultiStreamConfig, SourceType, StreamInfo}; #[cfg(feature = "cursor")] pub use cursor::{CursorExtractor, CursorInfo, CursorStats}; #[cfg(feature = "damage")] pub use damage::{ DamageConfig, DamageDetector, DamageDetectorStats, DamageRegion, DamageStats, DamageTracker, DetectedRegion, }; // Errors pub use error::{ErrorContext, ErrorType, PipeWireError, RecoveryAction, Result, RetryConfig, classify_error}; // FFI utilities pub use ffi::{ DamageRegion as FfiDamageRegion, SpaDataType, calculate_buffer_size, calculate_stride, drm_fourcc, get_bytes_per_pixel, spa_video_format_to_drm_fourcc, }; // Frame types pub use format::{PixelFormat, convert_format}; pub use frame::{DmaBufDescriptor, DmaBufPlane, FrameBuffer, FrameCallback, FrameFlags, FrameStats, VideoFrame}; // ============================================================================= // CRATE-LEVEL ITEMS // ============================================================================= use libspa::param::video::VideoFormat; pub use manager::{ManagerState, ManagerStats, PipeWireManager, StreamHandle}; // Buffer metadata pub use meta::{BufferMeta, CropRegion, CursorMeta, DamageRect, HeaderMeta}; // Thread management pub use pw_thread::{PipeWireThreadCommand, PipeWireThreadManager}; pub use stream::{NegotiatedFormat, PwStreamState, StreamConfig, StreamMetrics, StreamStateEvent, StreamTime}; // ============================================================================= // FEATURE RE-EXPORTS // ============================================================================= #[cfg(feature = "yuv")] pub use yuv::{YuvConverter, i420_to_bgra, nv12_to_bgra, yuy2_to_bgra}; /// Crate version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Initialize PipeWire library /// /// This should be called once at application startup. /// It's safe to call multiple times. /// /// # Examples /// /// ```rust,ignore /// fn main() { /// lamco_pipewire::init(); /// // ... use PipeWire ... /// lamco_pipewire::deinit(); /// } /// ``` pub fn init() { pipewire::init(); } /// Deinitialize PipeWire library /// /// This should be called at application shutdown after all PipeWire /// resources have been dropped. /// /// # Safety /// /// This function is safe to call if: /// - [`init()`] was called previously /// - All PipeWire resources (managers, connections, streams) have been dropped /// - No other PipeWire operations are in progress pub fn deinit() { // SAFETY: Caller ensures init() was called and all resources are dropped. // The pipewire crate tracks initialization state internally. unsafe { pipewire::deinit(); } } /// Get supported video formats in order of preference /// /// Returns formats ordered by preference for screen capture: /// 1. BGRx/BGRA - Common for desktop compositors /// 2. RGBx/RGBA - Alternative RGB formats /// 3. RGB/BGR - 24-bit formats (less common) /// 4. NV12/YUY2/I420 - YUV formats (compressed, require conversion) #[must_use] pub fn supported_formats() -> Vec { vec![ VideoFormat::BGRx, // Preferred: no alpha channel overhead VideoFormat::BGRA, // Common format with alpha VideoFormat::RGBx, // Alternative without alpha VideoFormat::RGBA, // Alternative with alpha VideoFormat::RGB, // 24-bit fallback VideoFormat::BGR, // 24-bit fallback VideoFormat::NV12, // YUV 4:2:0 (compressed) VideoFormat::YUY2, // YUV 4:2:2 (compressed) VideoFormat::I420, // YUV 4:2:0 planar ] } /// Check if DMA-BUF is likely supported /// /// This is a heuristic check based on DRM device availability. /// The actual DMA-BUF support is determined during format negotiation. /// /// # Returns /// /// `true` if DRM devices are found, suggesting DMA-BUF may be available. #[must_use] pub fn is_dmabuf_supported() -> bool { #[cfg(target_os = "linux")] { use std::path::Path; let drm_paths = ["/dev/dri/card0", "/dev/dri/card1", "/dev/dri/renderD128"]; drm_paths.iter().any(|path| Path::new(path).exists()) } #[cfg(not(target_os = "linux"))] { false } } /// Get recommended buffer count for a given refresh rate /// /// Higher refresh rates benefit from more buffers to prevent frame drops. /// /// # Arguments /// /// * `refresh_rate` - Monitor refresh rate in Hz /// /// # Returns /// /// Recommended number of buffers (2-5) #[must_use] pub fn recommended_buffer_count(refresh_rate: u32) -> u32 { match refresh_rate { 0..=30 => 2, // Low refresh: 2 buffers sufficient 31..=60 => 3, // Standard: 3 buffers 61..=120 => 4, // High refresh: 4 buffers _ => 5, // Very high refresh: 5 buffers } } /// Calculate optimal frame buffer size for a channel /// /// Returns the recommended channel buffer size to hold approximately /// 1 second of frames, capped at 144 frames. /// /// # Arguments /// /// * `refresh_rate` - Monitor refresh rate in Hz /// /// # Returns /// /// Recommended channel buffer size (30-144) #[must_use] pub fn recommended_frame_buffer_size(refresh_rate: u32) -> usize { (refresh_rate as usize).clamp(30, 144) } #[cfg(test)] mod tests { use super::*; #[test] fn test_supported_formats() { let formats = supported_formats(); assert!(!formats.is_empty()); assert_eq!(formats[0], VideoFormat::BGRx); } #[test] fn test_recommended_buffer_count() { assert_eq!(recommended_buffer_count(30), 2); assert_eq!(recommended_buffer_count(60), 3); assert_eq!(recommended_buffer_count(144), 5); } #[test] fn test_recommended_frame_buffer_size() { assert_eq!(recommended_frame_buffer_size(30), 30); assert_eq!(recommended_frame_buffer_size(60), 60); assert_eq!(recommended_frame_buffer_size(144), 144); assert_eq!(recommended_frame_buffer_size(200), 144); // Capped at 144 assert_eq!(recommended_frame_buffer_size(10), 30); // Minimum 30 } #[test] #[cfg(target_os = "linux")] fn test_dmabuf_check() { // Just verify it doesn't crash let _ = is_dmabuf_supported(); } #[test] fn test_version() { assert!(!VERSION.is_empty()); } } lamco-pipewire-0.4.0/src/manager.rs000064400000000000000000000405401046102023000153140ustar 00000000000000//! Unified PipeWire Manager //! //! Provides a single entry point for PipeWire screen capture that hides //! the internal thread architecture complexity. //! //! # Architecture //! //! The manager coordinates: //! - Thread management (PipeWire requires dedicated thread for non-Send types) //! - Stream lifecycle (creation, destruction, state changes) //! - Frame delivery via channels //! - Optional features (cursor extraction, damage tracking, adaptive bitrate) //! //! # Examples //! //! ```rust,ignore //! use lamco_pipewire::{PipeWireManager, PipeWireConfig, StreamInfo, SourceType}; //! //! # async fn example() -> Result<(), Box> { //! // Create manager with default config //! let mut manager = PipeWireManager::with_default()?; //! //! // Connect using portal-provided FD //! let fd = /* from lamco-portal */; //! manager.connect(fd).await?; //! //! // Create stream for a monitor //! let stream_info = StreamInfo { //! node_id: 42, //! position: (0, 0), //! size: (1920, 1080), //! source_type: SourceType::Monitor, //! }; //! //! let handle = manager.create_stream(&stream_info).await?; //! //! // Receive frames //! let mut rx = manager.frame_receiver(handle.id).expect("receiver"); //! while let Some(frame) = rx.recv().await { //! println!("Frame: {}x{}", frame.width, frame.height); //! } //! //! // Cleanup //! manager.shutdown().await?; //! # Ok(()) //! # } //! ``` use std::collections::HashMap; use std::os::fd::{IntoRawFd, OwnedFd, RawFd}; use std::sync::{Arc, mpsc as std_mpsc}; use tokio::sync::{Mutex, RwLock, mpsc}; use tracing::{debug, info, warn}; #[cfg(feature = "adaptive")] use crate::bitrate::BitrateController; use crate::config::PipeWireConfig; use crate::coordinator::{SourceType, StreamInfo}; #[cfg(feature = "cursor")] use crate::cursor::CursorExtractor; #[cfg(feature = "damage")] use crate::damage::DamageTracker; use crate::error::{PipeWireError, Result}; use crate::frame::VideoFrame; use crate::pw_thread::{PipeWireThreadCommand, PipeWireThreadManager}; use crate::stream::StreamConfig; /// Handle to an active stream #[derive(Debug, Clone)] pub struct StreamHandle { /// Unique stream identifier pub id: u32, /// Node ID from portal pub node_id: u32, /// Stream position (for multi-monitor) pub position: (i32, i32), /// Stream dimensions pub size: (u32, u32), /// Source type pub source_type: SourceType, } /// Manager state #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManagerState { /// Manager created but not connected Disconnected, /// Connecting to PipeWire Connecting, /// Connected and ready Connected, /// Error state Error, /// Shutting down ShuttingDown, } /// Unified PipeWire manager /// /// This is the primary entry point for PipeWire screen capture. /// It manages the PipeWire thread, streams, and optional features. pub struct PipeWireManager { /// Configuration config: PipeWireConfig, /// Manager state state: Arc>, /// Thread manager (handles PipeWire's non-Send types) thread_manager: Option, /// Active streams streams: Arc>>, /// Frame receivers per stream (take-once: each receiver can only be retrieved once) frame_receivers: Arc>>>, /// Next stream ID next_stream_id: Arc>, /// Portal file descriptor (raw copy for diagnostics; OwnedFd is consumed on connect) portal_fd: Option, // NOTE: We store RawFd here only for error context / diagnostics. // The actual OwnedFd is consumed by PipeWireThreadManager::new(). /// Cursor extractor (if enabled) #[cfg(feature = "cursor")] cursor_extractor: Option>>, /// Damage tracker (if enabled) #[cfg(feature = "damage")] damage_tracker: Option>>, /// Bitrate controller (if enabled) #[cfg(feature = "adaptive")] bitrate_controller: Option>>, } impl PipeWireManager { /// Create manager with default configuration /// /// # Examples /// /// ```rust,ignore /// let manager = PipeWireManager::with_default()?; /// ``` pub fn with_default() -> Result { Self::new(PipeWireConfig::default()) } /// Create manager with custom configuration /// /// # Examples /// /// ```rust,ignore /// use lamco_pipewire::{PipeWireManager, PipeWireConfig}; /// /// let config = PipeWireConfig::builder() /// .buffer_count(4) /// .use_dmabuf(true) /// .build(); /// /// let manager = PipeWireManager::new(config)?; /// ``` pub fn new(config: PipeWireConfig) -> Result { // Validate configuration if let Err(issues) = config.validate() { return Err(PipeWireError::InvalidParameter(issues.join(", "))); } info!("Creating PipeWireManager with config: {:?}", config); Ok(Self { config, state: Arc::new(RwLock::new(ManagerState::Disconnected)), thread_manager: None, streams: Arc::new(Mutex::new(HashMap::new())), frame_receivers: Arc::new(Mutex::new(HashMap::new())), next_stream_id: Arc::new(Mutex::new(0)), portal_fd: None, #[cfg(feature = "cursor")] cursor_extractor: None, #[cfg(feature = "damage")] damage_tracker: None, #[cfg(feature = "adaptive")] bitrate_controller: None, }) } /// Connect to PipeWire using portal-provided file descriptor /// /// The file descriptor should be obtained from XDG Desktop Portal /// (e.g., via `lamco-portal`). /// /// # Arguments /// /// * `fd` - File descriptor from portal's PipeWire connection /// /// # Errors /// /// Returns error if: /// - Already connected /// - PipeWire initialization fails /// - Connection timeout exceeded pub async fn connect(&mut self, fd: OwnedFd) -> Result<()> { let current_state = *self.state.read().await; if current_state == ManagerState::Connected { return Err(PipeWireError::InvalidState("Already connected".to_string())); } *self.state.write().await = ManagerState::Connecting; let raw_fd = fd.into_raw_fd(); info!("Connecting to PipeWire with FD {}", raw_fd); self.portal_fd = Some(raw_fd); // Initialize PipeWire thread manager let thread_manager = PipeWireThreadManager::new(raw_fd)?; self.thread_manager = Some(thread_manager); // Initialize optional features #[cfg(feature = "cursor")] if self.config.enable_cursor { self.cursor_extractor = Some(Arc::new(Mutex::new(CursorExtractor::new()))); debug!("Cursor extractor enabled"); } #[cfg(feature = "damage")] if self.config.enable_damage_tracking { self.damage_tracker = Some(Arc::new(Mutex::new(DamageTracker::new()))); debug!("Damage tracker enabled"); } #[cfg(feature = "adaptive")] if let Some(ref adaptive_config) = self.config.adaptive_bitrate { self.bitrate_controller = Some(Arc::new(Mutex::new(BitrateController::new(adaptive_config.clone())))); debug!("Bitrate controller enabled"); } *self.state.write().await = ManagerState::Connected; info!("PipeWire connected successfully"); Ok(()) } /// Create a stream for capturing from a source /// /// # Arguments /// /// * `stream_info` - Information about the source (from portal) /// /// # Returns /// /// Stream handle on success /// /// # Errors /// /// Returns error if: /// - Not connected /// - Maximum streams exceeded /// - Stream creation fails pub async fn create_stream(&mut self, stream_info: &StreamInfo) -> Result { if *self.state.read().await != ManagerState::Connected { return Err(PipeWireError::InvalidState("Not connected".to_string())); } // Check stream limit let stream_count = self.streams.lock().await.len(); if stream_count >= self.config.max_streams { return Err(PipeWireError::TooManyStreams(self.config.max_streams)); } // Generate stream ID let stream_id = { let mut id = self.next_stream_id.lock().await; let sid = *id; *id += 1; sid }; info!( "Creating stream {} for node {} ({}x{} at {:?})", stream_id, stream_info.node_id, stream_info.size.0, stream_info.size.1, stream_info.position ); // Create stream configuration let stream_name = format!("{}-{}", self.config.stream_name_prefix, stream_id); let stream_config = StreamConfig::new(stream_name) .with_resolution(stream_info.size.0, stream_info.size.1) .with_dmabuf(self.config.use_dmabuf) .with_buffer_count(self.config.buffer_count); // Create frame channel — receiver stored for consumer via frame_receiver() // TODO: wire sender to PW thread bridge for automatic frame routing let (_tx, rx) = mpsc::channel(self.config.frame_buffer_size); self.frame_receivers.lock().await.insert(stream_id, rx); // Send command to PipeWire thread if let Some(ref thread_manager) = self.thread_manager { let (response_tx, response_rx) = std_mpsc::sync_channel(1); thread_manager.send_command(PipeWireThreadCommand::CreateStream { stream_id, node_id: stream_info.node_id, config: stream_config, response_tx, })?; // Wait for response from PipeWire thread response_rx .recv() .map_err(|_| { PipeWireError::ThreadCommunicationFailed("CreateStream response channel closed".to_string()) })? .map_err(|e| PipeWireError::StreamCreationFailed(format!("Stream creation failed: {}", e)))?; } // Create handle let handle = StreamHandle { id: stream_id, node_id: stream_info.node_id, position: stream_info.position, size: stream_info.size, source_type: stream_info.source_type, }; self.streams.lock().await.insert(stream_id, handle.clone()); info!("Stream {} created successfully", stream_id); Ok(handle) } /// Take frame receiver for a stream /// /// Returns the channel receiver for frames from the specified stream. /// This can only be called once per stream — subsequent calls return None. /// /// # Arguments /// /// * `stream_id` - ID of the stream /// /// # Returns /// /// Channel receiver for frames, or None if already taken or stream not found pub async fn frame_receiver(&self, stream_id: u32) -> Option> { self.frame_receivers.lock().await.remove(&stream_id) } /// Remove a stream /// /// Stops and removes the specified stream. /// /// # Arguments /// /// * `stream_id` - ID of the stream to remove pub async fn remove_stream(&mut self, stream_id: u32) -> Result<()> { info!("Removing stream {}", stream_id); if self.streams.lock().await.remove(&stream_id).is_none() { return Err(PipeWireError::StreamNotFound(stream_id)); } self.frame_receivers.lock().await.remove(&stream_id); // Send command to PipeWire thread if let Some(ref thread_manager) = self.thread_manager { let (response_tx, response_rx) = std_mpsc::sync_channel(1); thread_manager.send_command(PipeWireThreadCommand::DestroyStream { stream_id, response_tx })?; // Wait for response (ignore errors during shutdown cleanup) if let Ok(result) = response_rx.recv() { result?; } } info!("Stream {} removed", stream_id); Ok(()) } /// Get all active stream handles pub async fn streams(&self) -> Vec { self.streams.lock().await.values().cloned().collect() } /// Get stream by ID pub async fn stream(&self, stream_id: u32) -> Option { self.streams.lock().await.get(&stream_id).cloned() } /// Get current manager state pub async fn state(&self) -> ManagerState { *self.state.read().await } /// Check if connected pub async fn is_connected(&self) -> bool { *self.state.read().await == ManagerState::Connected } /// Get configuration pub fn config(&self) -> &PipeWireConfig { &self.config } /// Access cursor extractor (if enabled) #[cfg(feature = "cursor")] pub fn cursor_extractor(&self) -> Option<&Arc>> { self.cursor_extractor.as_ref() } /// Access damage tracker (if enabled) #[cfg(feature = "damage")] pub fn damage_tracker(&self) -> Option<&Arc>> { self.damage_tracker.as_ref() } /// Access bitrate controller (if enabled) #[cfg(feature = "adaptive")] pub fn bitrate_controller(&self) -> Option<&Arc>> { self.bitrate_controller.as_ref() } /// Shutdown the manager /// /// Stops all streams and disconnects from PipeWire. pub async fn shutdown(&mut self) -> Result<()> { info!("Shutting down PipeWireManager"); *self.state.write().await = ManagerState::ShuttingDown; // Remove all streams let stream_ids: Vec = self.streams.lock().await.keys().copied().collect(); for id in stream_ids { if let Err(e) = self.remove_stream(id).await { warn!("Error removing stream {} during shutdown: {}", id, e); } } // Shutdown thread manager if let Some(ref thread_manager) = self.thread_manager { // Shutdown command doesn't need a response let _ = thread_manager.send_command(PipeWireThreadCommand::Shutdown); } self.thread_manager = None; *self.state.write().await = ManagerState::Disconnected; info!("PipeWireManager shutdown complete"); Ok(()) } } impl Drop for PipeWireManager { fn drop(&mut self) { debug!("Dropping PipeWireManager"); // Thread manager handles its own cleanup in Drop } } /// Statistics for the manager #[derive(Debug, Clone, Default)] pub struct ManagerStats { /// Number of streams created pub streams_created: u64, /// Number of streams destroyed pub streams_destroyed: u64, /// Total frames processed pub total_frames: u64, /// Total bytes processed pub total_bytes: u64, /// Connection uptime in seconds pub uptime_secs: u64, } #[cfg(test)] mod tests { use super::*; #[test] fn test_manager_creation() { let manager = PipeWireManager::with_default(); assert!(manager.is_ok()); } #[test] fn test_manager_with_config() { let config = PipeWireConfig::builder().buffer_count(5).max_streams(4).build(); let manager = PipeWireManager::new(config); assert!(manager.is_ok()); let mgr = manager.expect("manager should be created"); assert_eq!(mgr.config().buffer_count, 5); assert_eq!(mgr.config().max_streams, 4); } #[test] fn test_invalid_config() { let config = PipeWireConfig { buffer_count: 0, // Invalid ..Default::default() }; let manager = PipeWireManager::new(config); assert!(manager.is_err()); } #[tokio::test] async fn test_manager_state() { let manager = PipeWireManager::with_default().expect("manager"); assert_eq!(manager.state().await, ManagerState::Disconnected); assert!(!manager.is_connected().await); } #[test] fn test_stream_handle() { let handle = StreamHandle { id: 1, node_id: 42, position: (0, 0), size: (1920, 1080), source_type: SourceType::Monitor, }; assert_eq!(handle.id, 1); assert_eq!(handle.node_id, 42); assert_eq!(handle.size, (1920, 1080)); } } lamco-pipewire-0.4.0/src/meta.rs000064400000000000000000000231151046102023000146270ustar 00000000000000//! Buffer Metadata Extraction //! //! Reads SPA metadata from PipeWire buffers. Compositors attach metadata //! to signal buffer orientation (VideoTransform), timing (Header), //! crop regions (VideoCrop), damage rectangles (VideoDamage), and //! cursor state (Cursor). //! //! All metadata types are optional — compositors only provide what they //! support, and consumers must handle absent metadata gracefully. use tracing::{debug, trace}; /// Metadata extracted from a PipeWire buffer's SPA metadata array. /// /// All fields are `Option` because compositors may not provide all types, /// even when requested during stream negotiation. #[derive(Debug, Clone, Default)] pub struct BufferMeta { /// SPA_META_Header: presentation timestamp, flags, sequence number. /// Provides compositor-authoritative timing and buffer health signals /// (CORRUPTED, DISCONT, GAP flags). pub header: Option, /// SPA_META_VideoTransform: buffer orientation (0-7). /// Maps 1:1 to wl_output.transform values. pub transform: Option, /// SPA_META_VideoCrop: source crop region within the buffer. /// Compositor may send oversized buffers with crop hints. pub crop: Option, /// SPA_META_VideoDamage: changed regions since last buffer. /// Enables partial-frame encoding for bandwidth optimization. pub damage: Vec, /// SPA_META_Cursor: cursor position, hotspot, and bitmap info. pub cursor: Option, /// Chunk stride from spa_chunk (signed i32). /// Negative stride means the buffer is stored bottom-up (GL convention). /// Set from the buffer's data chunk, not from SPA metadata. pub chunk_stride: i32, /// Buffer data type (1=MemPtr, 2=MemFd, 3=DmaBuf). /// Different buffer types may require different transform handling /// due to compositor-specific code paths for each type. pub buffer_type: u32, } /// Timing and health metadata from SPA_META_Header. #[derive(Debug, Clone)] pub struct HeaderMeta { /// Presentation timestamp in nanoseconds pub pts: i64, /// Decode timestamp offset from PTS pub dts_offset: i64, /// Sequence number (increments per frame) pub seq: u64, /// Buffer flags (CORRUPTED, DISCONT, GAP, DELTA_UNIT) pub flags: u32, } /// Crop region from SPA_META_VideoCrop. #[derive(Debug, Clone)] pub struct CropRegion { pub x: i32, pub y: i32, pub width: u32, pub height: u32, } /// Damage rectangle from SPA_META_VideoDamage. #[derive(Debug, Clone)] pub struct DamageRect { pub x: i32, pub y: i32, pub width: u32, pub height: u32, } /// Cursor metadata from SPA_META_Cursor. #[derive(Debug, Clone)] pub struct CursorMeta { /// Cursor ID (0 = invalid/no cursor) pub id: u32, /// Position on screen pub position: (i32, i32), /// Hotspot offset within the cursor bitmap pub hotspot: (i32, i32), /// Offset to bitmap data within the meta struct. /// 0 = no bitmap, >= sizeof(spa_meta_cursor) = bitmap follows. pub bitmap_offset: u32, } /// Extract all metadata from a PipeWire buffer's SPA metadata array. /// /// # Safety /// /// `spa_buf` must point to a valid `spa_buffer` that is currently dequeued /// from a PipeWire stream (i.e., valid for the duration of a process callback). pub unsafe fn extract_buffer_meta(spa_buf: *const libspa_sys::spa_buffer) -> BufferMeta { if spa_buf.is_null() { return BufferMeta::default(); } let mut meta = BufferMeta::default(); // SAFETY: spa_buf is non-null (checked above) and valid for the process callback duration. // Each read function checks for null metadata pointers before dereferencing. unsafe { meta.header = read_header_meta(spa_buf); meta.transform = read_transform_meta(spa_buf); meta.crop = read_crop_meta(spa_buf); meta.damage = read_damage_meta(spa_buf); meta.cursor = read_cursor_meta(spa_buf); } meta } /// Read SPA_META_Header from buffer. /// /// # Safety /// `spa_buf` must be a valid, non-null pointer to a dequeued spa_buffer. unsafe fn read_header_meta(spa_buf: *const libspa_sys::spa_buffer) -> Option { // SAFETY: spa_buf validity guaranteed by caller let ptr = unsafe { libspa_sys::spa_buffer_find_meta_data( spa_buf, libspa_sys::SPA_META_Header, std::mem::size_of::(), ) }; if ptr.is_null() { return None; } // SAFETY: ptr is non-null and points to memory of at least spa_meta_header size let header = unsafe { &*(ptr as *const libspa_sys::spa_meta_header) }; trace!( "SPA_META_Header: pts={}, dts_offset={}, seq={}, flags={:#x}", header.pts, header.dts_offset, header.seq, header.flags ); Some(HeaderMeta { pts: header.pts, dts_offset: header.dts_offset, seq: header.seq, flags: header.flags, }) } /// Read SPA_META_VideoTransform from buffer. /// /// # Safety /// `spa_buf` must be a valid, non-null pointer to a dequeued spa_buffer. unsafe fn read_transform_meta(spa_buf: *const libspa_sys::spa_buffer) -> Option { // SAFETY: spa_buf validity guaranteed by caller let ptr = unsafe { libspa_sys::spa_buffer_find_meta_data( spa_buf, libspa_sys::SPA_META_VideoTransform, std::mem::size_of::(), ) }; if ptr.is_null() { return None; } // SAFETY: ptr is non-null and points to memory of at least spa_meta_videotransform size let transform = unsafe { &*(ptr as *const libspa_sys::spa_meta_videotransform) }; debug!("SPA_META_VideoTransform: value={}", transform.transform); Some(transform.transform) } /// Read SPA_META_VideoCrop from buffer. /// /// # Safety /// `spa_buf` must be a valid, non-null pointer to a dequeued spa_buffer. unsafe fn read_crop_meta(spa_buf: *const libspa_sys::spa_buffer) -> Option { // SAFETY: spa_buf validity guaranteed by caller let ptr = unsafe { libspa_sys::spa_buffer_find_meta_data( spa_buf, libspa_sys::SPA_META_VideoCrop, std::mem::size_of::(), ) }; if ptr.is_null() { return None; } // SAFETY: ptr is non-null and points to memory of at least spa_meta_region size let region = unsafe { &*(ptr as *const libspa_sys::spa_meta_region) }; let r = ®ion.region; trace!( "SPA_META_VideoCrop: {}x{} at ({},{})", r.size.width, r.size.height, r.position.x, r.position.y ); Some(CropRegion { x: r.position.x, y: r.position.y, width: r.size.width, height: r.size.height, }) } /// Read SPA_META_VideoDamage from buffer. /// /// This is an array of spa_meta_region entries. An invalid entry /// (zero-sized region) marks the end of the array. /// /// # Safety /// `spa_buf` must be a valid, non-null pointer to a dequeued spa_buffer. unsafe fn read_damage_meta(spa_buf: *const libspa_sys::spa_buffer) -> Vec { let mut rects = Vec::new(); // SAFETY: spa_buf validity guaranteed by caller let meta_ptr = unsafe { libspa_sys::spa_buffer_find_meta(spa_buf, libspa_sys::SPA_META_VideoDamage) }; if meta_ptr.is_null() { return rects; } // SAFETY: meta_ptr is non-null, returned by spa_buffer_find_meta let meta = unsafe { &*meta_ptr }; if meta.data.is_null() || meta.size == 0 { return rects; } let region_size = std::mem::size_of::(); let max_regions = meta.size as usize / region_size; let regions = meta.data as *const libspa_sys::spa_meta_region; for i in 0..max_regions { // SAFETY: i < max_regions which is bounded by the allocated meta.size let region = unsafe { &*regions.add(i) }; let r = ®ion.region; // Zero-size region marks end of array if r.size.width == 0 || r.size.height == 0 { break; } rects.push(DamageRect { x: r.position.x, y: r.position.y, width: r.size.width, height: r.size.height, }); } if !rects.is_empty() { trace!("SPA_META_VideoDamage: {} regions", rects.len()); } rects } /// Read SPA_META_Cursor from buffer. /// /// # Safety /// `spa_buf` must be a valid, non-null pointer to a dequeued spa_buffer. unsafe fn read_cursor_meta(spa_buf: *const libspa_sys::spa_buffer) -> Option { // SAFETY: spa_buf validity guaranteed by caller let ptr = unsafe { libspa_sys::spa_buffer_find_meta_data( spa_buf, libspa_sys::SPA_META_Cursor, std::mem::size_of::(), ) }; if ptr.is_null() { return None; } // SAFETY: ptr is non-null and points to memory of at least spa_meta_cursor size let cursor = unsafe { &*(ptr as *const libspa_sys::spa_meta_cursor) }; // id=0 means no valid cursor data if cursor.id == 0 { return None; } trace!( "SPA_META_Cursor: id={}, pos=({},{}), hotspot=({},{}), bitmap_offset={}", cursor.id, cursor.position.x, cursor.position.y, cursor.hotspot.x, cursor.hotspot.y, cursor.bitmap_offset ); Some(CursorMeta { id: cursor.id, position: (cursor.position.x, cursor.position.y), hotspot: (cursor.hotspot.x, cursor.hotspot.y), bitmap_offset: cursor.bitmap_offset, }) } /// Maximum number of damage rectangles to request. /// 16 is a reasonable upper bound — most compositors report fewer. pub const MAX_DAMAGE_REGIONS: usize = 16; lamco-pipewire-0.4.0/src/pw_thread.rs000064400000000000000000002071501046102023000156610ustar 00000000000000//! PipeWire Thread Manager //! //! Manages PipeWire operations on a dedicated thread to handle non-Send types. //! //! # Problem Statement //! //! PipeWire's Rust bindings use `Rc<>` for internal reference counting and `NonNull<>` //! for FFI pointers. These types are explicitly `!Send`, meaning Rust's type system //! prevents them from being transferred across thread boundaries. This creates a //! fundamental challenge when integrating with async Rust code that expects `Send + Sync`. //! //! # Solution: Dedicated Thread Architecture //! //! This module implements the industry-standard pattern for non-Send libraries: //! //! 1. **Dedicated Thread:** Spawn a `std::thread` that owns all PipeWire types //! 2. **Thread Confinement:** MainLoop, Context, Core, and Streams never leave this thread //! 3. **Message Passing:** Commands sent via `std::sync::mpsc` channel //! 4. **Frame Delivery:** Captured frames sent back via `std::sync::mpsc` channel //! 5. **Safe Wrapper:** `PipeWireThreadManager` is Send + Sync (via unsafe impl with guarantees) //! //! # Architecture //! //! ```text //! Async Runtime (Tokio) PipeWire Thread (std::thread) //! ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ //! //! PipeWireThreadManager ──Commands──> run_pipewire_main_loop() //! (Send + Sync) │ //! │ ├─ MainLoop::new() //! │ ├─ Context::new() //! │ ├─ Core::connect_fd() //! │ │ //! │ ├─ Process Commands: //! │ │ ├─ CreateStream //! │ │ ├─ DestroyStream //! │ │ └─ GetStreamState //! │ │ //! │ ├─ MainLoop.iterate() //! │ │ └─ Stream callbacks //! │ │ └─ process() extracts frames //! │ │ //! │ <──────Frames─────────────────────┘ //! │ //! recv_frame_timeout() //! ``` //! //! # Safety Guarantees //! //! The `unsafe impl Send` and `unsafe impl Sync` for `PipeWireThreadManager` are safe because: //! //! 1. All PipeWire types are confined to the PipeWire thread //! 2. No PipeWire types are ever sent across threads //! 3. Communication uses only Send types (commands and frames) //! 4. Thread join on Drop ensures cleanup before manager is destroyed //! //! # Example //! //! ```ignore //! use lamco_pipewire::{PipeWireThreadManager, PipeWireThreadCommand}; //! use lamco_pipewire::stream::StreamConfig; //! //! // Create thread manager with FD from portal //! let pipewire_fd = 42; // Obtained from lamco-portal //! let manager = PipeWireThreadManager::new(pipewire_fd)?; //! //! // Create a stream (command sent to PipeWire thread) //! let (response_tx, response_rx) = std::sync::mpsc::sync_channel(1); //! let config = StreamConfig::new("monitor-0".to_string()) //! .with_resolution(1920, 1080) //! .with_framerate(60); //! //! manager.send_command(PipeWireThreadCommand::CreateStream { //! stream_id: 1, //! node_id: 42, //! config, //! })?; //! //! // Receive frames via the channel returned by manager //! loop { //! if let Some(frame) = manager.try_recv_frame() { //! println!("Got frame: {}x{}", frame.width, frame.height); //! // Process frame... //! } //! } //! ``` //! //! # Performance //! //! - **Frame latency:** <2ms per frame //! - **Memory usage:** <100MB per stream //! - **CPU usage:** <5% per stream //! - **Thread overhead:** ~0.5ms per iteration //! - **Supports:** Up to 144Hz refresh rates use std::collections::HashMap; use std::num::NonZeroUsize; use std::os::fd::{FromRawFd, OwnedFd, RawFd}; use std::ptr::NonNull; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc as StdArc, mpsc as std_mpsc}; use std::thread::{self, JoinHandle}; use std::time::{Duration, SystemTime}; use pipewire::context::ContextBox; use pipewire::main_loop::MainLoopBox; use pipewire::properties::PropertiesBox; use pipewire::spa::param::video::VideoInfoRaw; use pipewire::spa::param::{ParamType, format_utils}; use pipewire::spa::pod::Pod; use pipewire::spa::utils::Direction; use pipewire::stream::{StreamBox, StreamFlags, StreamState}; use tracing::{debug, error, info, trace, warn}; use crate::error::{PipeWireError, Result}; use crate::format::PixelFormat; use crate::frame::{FrameFlags, VideoFrame}; use crate::stream::{PwStreamState, StreamConfig, StreamStateEvent}; /// DMA-BUF mmap cache: FD -> (mapped pointer, size) type DmaBufCache = std::rc::Rc, usize)>>>; /// Commands sent to the PipeWire thread pub enum PipeWireThreadCommand { /// Create and connect a stream to a PipeWire node CreateStream { stream_id: u32, node_id: u32, config: StreamConfig, /// Response channel response_tx: std_mpsc::SyncSender>, }, /// Destroy a stream DestroyStream { stream_id: u32, response_tx: std_mpsc::SyncSender>, }, /// Get stream state GetStreamState { stream_id: u32, response_tx: std_mpsc::SyncSender>, }, /// Shutdown the PipeWire thread Shutdown, } /// Stream data managed on PipeWire thread /// /// Some fields are prepared for future functionality (metrics, stats). #[allow(dead_code)] struct ManagedStream { /// Stream ID id: u32, /// PipeWire stream (lives on PipeWire thread only) /// SAFETY: 'static lifetime is safe because we manually enforce drop order: /// streams are cleared before core is dropped in run_pipewire_main_loop(). stream: StreamBox<'static>, /// Stream event listener (must be kept alive) _listener: pipewire::stream::StreamListener<()>, /// Configuration config: StreamConfig, /// Current state state: StreamState, /// Frame counter frame_count: u64, /// Frame channel for sending captured frames frame_tx: std_mpsc::SyncSender, } /// PipeWire thread manager /// /// Manages a dedicated thread that runs the PipeWire MainLoop and handles /// all PipeWire API operations. Communicates with async code via channels. pub struct PipeWireThreadManager { /// Thread handle thread_handle: Option>, /// Command channel sender command_tx: std_mpsc::SyncSender, /// Frame channel receiver frame_rx: std_mpsc::Receiver, /// Stream state event receiver (state changes from PipeWire callbacks) state_event_rx: std_mpsc::Receiver, /// Shutdown flag shutdown_tx: Option>, } impl PipeWireThreadManager { /// Create and start PipeWire thread manager /// /// # Arguments /// /// * `fd` - File descriptor from portal /// /// # Returns /// /// A new PipeWireThreadManager with running thread /// /// # Errors /// /// Returns error if thread creation fails pub fn new(fd: RawFd) -> Result { info!("Creating PipeWire thread manager for FD {}", fd); // Create channels for commands and frames // Using std::sync::mpsc (not tokio) because PipeWire thread is not async let (command_tx, command_rx) = std_mpsc::sync_channel::(100); // Frame channel: increased from 64 to 256 to handle burst traffic // At 60 FPS capture / 30 FPS target = 2:1 ratio needs buffer let (frame_tx, frame_rx) = std_mpsc::sync_channel::(256); // State event channel for health monitoring (bounded to prevent unbounded growth) let (state_event_tx, state_event_rx) = std_mpsc::sync_channel::(256); let (shutdown_tx, shutdown_rx) = std_mpsc::sync_channel::<()>(1); // Spawn dedicated PipeWire thread let thread_handle = thread::Builder::new() .name("pipewire-main".to_string()) .spawn(move || { run_pipewire_main_loop(fd, command_rx, frame_tx, state_event_tx, shutdown_rx); }) .map_err(|e| PipeWireError::InitializationFailed(format!("Thread spawn failed: {}", e)))?; info!("PipeWire thread started successfully"); Ok(Self { thread_handle: Some(thread_handle), command_tx, frame_rx, state_event_rx, shutdown_tx: Some(shutdown_tx), }) } /// Create a direct-channel frame source (no PipeWire thread). /// /// Used when the capture backend provides frames through a direct channel /// instead of PipeWire (e.g., portal-generic with in-process screencopy). /// The frame receiver is adapted to produce `VideoFrame` objects. pub fn new_direct(raw_rx: std_mpsc::Receiver, width: u32, height: u32) -> Self { use std::sync::Arc; use std::time::SystemTime; let (frame_tx, frame_rx) = std_mpsc::sync_channel::(256); let (state_event_tx, state_event_rx) = std_mpsc::sync_channel::(256); let (command_tx, _command_rx) = std_mpsc::sync_channel::(1); // Send initial Streaming state event let _ = state_event_tx.try_send(StreamStateEvent { stream_id: 0, state: PwStreamState::Streaming, }); // Spawn converter thread that reads RawFrameData → VideoFrame let thread_handle = thread::Builder::new() .name("direct-frame-adapter".to_string()) .spawn(move || { let mut frame_count: u64 = 0; info!("Direct frame adapter thread started"); while let Ok(raw) = raw_rx.recv() { frame_count += 1; let frame = VideoFrame { frame_id: frame_count, pts: frame_count * 33_333_333, // ~30fps dts: frame_count * 33_333_333, duration: 33_333_333, width: raw.width.unwrap_or(width), height: raw.height.unwrap_or(height), stride: raw.stride.unwrap_or(width * 4), format: raw.format.unwrap_or(PixelFormat::BGRx), monitor_index: 0, buffer: crate::frame::FrameBuffer::Memory(Arc::new(raw.data)), capture_time: SystemTime::now(), damage_regions: vec![], meta: crate::meta::BufferMeta::default(), flags: crate::frame::FrameFlags::new(), }; if frame_tx.try_send(frame).is_err() { // Channel full, drop frame } } info!("Direct frame adapter thread exited after {} frames", frame_count); }) .expect("Failed to spawn direct frame adapter thread"); Self { thread_handle: Some(thread_handle), command_tx, frame_rx, state_event_rx, shutdown_tx: None, } } /// Send a command to the PipeWire thread /// /// # Arguments /// /// * `command` - Command to execute /// /// # Errors /// /// Returns error if command cannot be sent (thread died) pub fn send_command(&self, command: PipeWireThreadCommand) -> Result<()> { self.command_tx .send(command) .map_err(|_| PipeWireError::ThreadCommunicationFailed("Command send failed".to_string())) } /// Try to receive a frame (non-blocking) /// /// # Returns /// /// Some(VideoFrame) if a frame is available, None otherwise pub fn try_recv_frame(&self) -> Option { self.frame_rx.try_recv().ok() } /// Try to receive a stream state event (non-blocking) /// /// Returns the next state change event if one is available. pub fn try_recv_state_event(&self) -> Option { self.state_event_rx.try_recv().ok() } /// Drain all pending stream state events /// /// Returns all queued state change events, useful for batch processing /// in a frame loop. Events are ordered chronologically. pub fn drain_state_events(&self) -> Vec { let mut events = Vec::new(); while let Ok(event) = self.state_event_rx.try_recv() { events.push(event); } events } /// Receive a frame (blocking with timeout) /// /// # Arguments /// /// * `timeout` - Maximum time to wait for a frame /// /// # Returns /// /// Some(VideoFrame) if received within timeout, None otherwise pub fn recv_frame_timeout(&self, timeout: Duration) -> Option { self.frame_rx.recv_timeout(timeout).ok() } /// Shutdown the PipeWire thread gracefully pub fn shutdown(&mut self) -> Result<()> { info!("Shutting down PipeWire thread"); // Send shutdown command if let Err(e) = self.send_command(PipeWireThreadCommand::Shutdown) { warn!("Failed to send shutdown command: {}", e); } // Signal shutdown via dedicated channel if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } // Wait for thread to finish (with timeout) if let Some(handle) = self.thread_handle.take() { if handle.join().is_err() { error!("PipeWire thread panicked during shutdown"); return Err(PipeWireError::ThreadPanic("Thread panicked".to_string())); } } info!("PipeWire thread shut down successfully"); Ok(()) } } impl Drop for PipeWireThreadManager { fn drop(&mut self) { debug!("Dropping PipeWireThreadManager"); let _ = self.shutdown(); } } /// Main loop function that runs on the dedicated PipeWire thread /// /// This function owns all PipeWire types (MainLoop, Context, Core, Streams) /// and processes commands from the async runtime. fn run_pipewire_main_loop( fd: RawFd, command_rx: std_mpsc::Receiver, frame_tx: std_mpsc::SyncSender, state_event_tx: std_mpsc::SyncSender, shutdown_rx: std_mpsc::Receiver<()>, ) { info!("PipeWire main loop thread started"); // Initialize PipeWire library pipewire::init(); // Create main loop let main_loop = match MainLoopBox::new(None) { Ok(ml) => ml, Err(e) => { error!("Failed to create MainLoop: {}", e); return; } }; // Create context (0.9 API: takes &Loop reference + optional properties) let context = match ContextBox::new(main_loop.loop_(), None) { Ok(ctx) => ctx, Err(e) => { error!("Failed to create Context: {}", e); return; } }; // Connect core using portal FD info!("Connecting PipeWire Core to Portal FD {}", fd); // SAFETY: The FD was provided by XDG Desktop Portal via lamco-portal. // We take exclusive ownership - the FD is not used anywhere else. let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) }; let core = match context.connect_fd(owned_fd, None) { Ok(c) => { info!("Core.connect_fd() succeeded"); c } Err(e) => { error!("Failed to connect Core with FD {}: {}", fd, e); return; } }; info!("PipeWire Core connected successfully to Portal FD {}", fd); info!("This is a PRIVATE PipeWire connection - node IDs only valid on this FD"); // Stream storage (all streams live on this thread) let mut streams: HashMap = HashMap::new(); // DMA-BUF mmap cache: Maps FD -> (ptr, size) to avoid remapping every frame // Using Rc> because we're on a single thread (PipeWire doesn't support multi-threading) // This cache is shared with all stream process() callbacks use std::cell::RefCell; use std::rc::Rc; let dmabuf_mmap_cache: DmaBufCache = Rc::new(RefCell::new(HashMap::new())); // Main event loop let mut loop_iterations = 0u64; 'main: loop { loop_iterations += 1; // Log periodic heartbeat if loop_iterations % 1000 == 0 { info!( "PipeWire main loop heartbeat: {} iterations, {} streams active", loop_iterations, streams.len() ); } // Process all pending commands while let Ok(command) = command_rx.try_recv() { match command { PipeWireThreadCommand::CreateStream { stream_id, node_id, config, response_tx, } => { info!( " CreateStream command received: stream_id={}, node_id={}", stream_id, node_id ); info!( " Config: {}x{} @ {}fps, dmabuf={}, buffers={}", config.width, config.height, config.framerate, config.use_dmabuf, config.buffer_count ); let result = create_stream_on_thread( stream_id, node_id, &core, config, frame_tx.clone(), state_event_tx.clone(), Rc::clone(&dmabuf_mmap_cache), ); match result { Ok(managed_stream) => { info!("Storing stream {} in active streams map", stream_id); streams.insert(stream_id, managed_stream); let _ = response_tx.send(Ok(())); info!( " Stream {} fully created - now in streams map (total: {} streams)", stream_id, streams.len() ); } Err(e) => { error!("Failed to create stream {}: {}", stream_id, e); let _ = response_tx.send(Err(e)); } } } PipeWireThreadCommand::DestroyStream { stream_id, response_tx } => { debug!("Destroying stream {}", stream_id); if let Some(managed_stream) = streams.remove(&stream_id) { // Clean up any DMA-BUF mmaps associated with this stream // Note: We don't know which FDs belong to which stream, so we clear all // This is safe because streams are destroyed infrequently let mut cache = dmabuf_mmap_cache.borrow_mut(); for (fd, (ptr, size)) in cache.drain() { // SAFETY: ptr and size were recorded when mmap succeeded. // drain() ensures we process each entry exactly once. unsafe { use nix::sys::mman::munmap; if let Err(e) = munmap(ptr, size) { warn!("Failed to munmap DMA-BUF FD={}: {}", fd, e); } } debug!("Unmapped DMA-BUF cache entry for FD={}", fd); } // Stream is automatically dropped here drop(managed_stream); let _ = response_tx.send(Ok(())); info!("Stream {} destroyed, DMA-BUF cache cleared", stream_id); } else { let _ = response_tx.send(Err(PipeWireError::StreamNotFound(stream_id))); } } PipeWireThreadCommand::GetStreamState { stream_id, response_tx } => { // StreamState doesn't implement Clone, so we match and reconstruct let state = streams.get(&stream_id).map(|s| match &s.state { StreamState::Error(msg) => StreamState::Error(msg.clone()), StreamState::Unconnected => StreamState::Unconnected, StreamState::Connecting => StreamState::Connecting, StreamState::Paused => StreamState::Paused, StreamState::Streaming => StreamState::Streaming, }); let _ = response_tx.send(state); } PipeWireThreadCommand::Shutdown => { info!("Shutdown command received"); break 'main; } } } // Check for shutdown signal if shutdown_rx.try_recv().is_ok() { info!("Shutdown signal received"); break 'main; } // Run one iteration of PipeWire main loop // Use non-blocking iterate (0ms timeout) to avoid frame timing jitter // Then sleep based on expected frame timing for efficiency let loop_ref = main_loop.loop_(); let events_processed = loop_ref.iterate(Duration::from_millis(0)); if loop_iterations % 1000 == 0 { trace!( "loop.iterate() returned {} (events processed this iteration)", events_processed ); } // Sleep briefly to avoid busy-looping while still maintaining low latency // At 60 FPS, frames arrive every ~16ms, so 5ms sleep is safe std::thread::sleep(Duration::from_millis(5)); } // Cleanup info!("Cleaning up PipeWire resources"); streams.clear(); drop(core); drop(context); drop(main_loop); // SAFETY: pipewire::deinit() must be called once per pipewire::init(). // All PipeWire resources (streams, core, context, main_loop) have been dropped. // This thread called init() and no other code uses this PipeWire instance. unsafe { pipewire::deinit(); } info!("PipeWire thread exited"); } /// Memory-map a file descriptor to extract buffer data /// /// Handles both DMA-BUF and MemFd buffers by mapping the FD into process memory. /// /// # Arguments /// /// * `fd` - File descriptor to map /// * `size` - Size of data to read /// * `offset` - Offset within the mapped region /// /// # Returns /// /// Vec containing the pixel data, or error if mmap fails /// /// # Safety /// /// This uses unsafe mmap operations but is safe because: /// - We immediately copy data and unmap /// - FD is owned by PipeWire buffer (valid during callback) /// - No pointer aliasing (we copy, not reference) fn mmap_fd_buffer(fd: std::os::fd::RawFd, size: usize, offset: usize) -> Result> { use std::os::fd::BorrowedFd; use nix::sys::mman::{MapFlags, ProtFlags, mmap, munmap}; // Calculate page-aligned mapping // SAFETY: sysconf(_SC_PAGESIZE) is safe and always returns a valid value let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; let map_offset = (offset / page_size) * page_size; let map_size = size + (offset - map_offset); let data_offset_in_map = offset - map_offset; info!( "mmap: fd={}, size={}, offset={}, page_size={}, map_offset={}, map_size={}", fd, size, offset, page_size, map_offset, map_size ); // Memory map the file descriptor // SAFETY: // - FD is valid (owned by PipeWire buffer during callback) // - We immediately copy and unmap (no lifetime issues) // - BorrowedFd is only used during mmap call let addr = unsafe { let borrowed_fd = BorrowedFd::borrow_raw(fd); mmap( None, NonZeroUsize::new(map_size) .ok_or_else(|| PipeWireError::FrameExtractionFailed("Invalid map size".to_string()))?, ProtFlags::PROT_READ, MapFlags::MAP_SHARED, borrowed_fd, map_offset as i64, ) .map_err(|e| PipeWireError::FrameExtractionFailed(format!("mmap failed: {}", e)))? }; // Copy data from mapped region // SAFETY: addr is valid NonNull from successful mmap above, and: // - data_offset_in_map + size <= map_size (calculated correctly above) // - Vec has sufficient capacity allocated // - copy_nonoverlapping is safe with non-overlapping src/dst // - set_len is safe because we just wrote exactly size bytes let result = unsafe { let src_ptr = (addr.as_ptr() as *const u8).add(data_offset_in_map); let mut vec = Vec::with_capacity(size); std::ptr::copy_nonoverlapping(src_ptr, vec.as_mut_ptr(), size); vec.set_len(size); vec }; // Unmap immediately after copying (no dangling pointers) // SAFETY: addr and map_size are from the successful mmap above. // We've finished reading, so unmapping is safe. unsafe { munmap(addr, map_size).map_err(|e| warn!("munmap warning: {}", e)).ok(); } info!("mmap successful: extracted {} bytes", result.len()); Ok(result) } /// Create a stream on the PipeWire thread /// /// This function performs the complete stream creation, format negotiation, /// and callback setup as specified in TASK-P1-04. fn create_stream_on_thread( stream_id: u32, node_id: u32, core: &pipewire::core::Core, config: StreamConfig, frame_tx: std_mpsc::SyncSender, state_event_tx: std_mpsc::SyncSender, dmabuf_cache: DmaBufCache, ) -> Result { let stream_name = format!("lamco-pw-{}", stream_id); let node_target = node_id.to_string(); // Build stream properties per spec info!("Building stream properties for stream {}", stream_id); let mut props = PropertiesBox::new(); props.insert("media.type", "Video"); props.insert("media.category", "Capture"); props.insert("media.role", "Screen"); props.insert("media.name", stream_name.as_str()); props.insert("node.target", node_target.as_str()); props.insert("stream.capture-sink", "true"); info!("Stream properties:"); info!(" media.type = Video"); info!(" media.category = Capture"); info!(" media.role = Screen"); info!(" media.name = {}", stream_name); info!(" node.target = {} (Portal provided node ID)", node_target); info!(" stream.capture-sink = true"); // Create the stream info!("Calling StreamBox::new() with properties"); // SAFETY: We use 'static lifetime because we manually enforce that the core // outlives all streams (streams.clear() before drop(core) in the main loop). let stream: StreamBox<'static> = unsafe { let stream_box = StreamBox::new(core, &stream_name, props) .map_err(|e| PipeWireError::StreamCreationFailed(format!("StreamBox::new failed: {}", e)))?; // Transmute lifetime from '_ (tied to core borrow) to 'static. // SAFETY: Drop ordering is manually enforced in run_pipewire_main_loop. std::mem::transmute::, StreamBox<'static>>(stream_box) }; info!("Stream::new() succeeded - stream object created"); // Set up comprehensive stream event listeners // Clone frame_tx and dmabuf_cache for use in closures let frame_tx_for_process = frame_tx.clone(); let stream_id_for_callbacks = stream_id; let dmabuf_cache_for_process = std::rc::Rc::clone(&dmabuf_cache); info!( " Registering stream {} callbacks (state_changed, param_changed, process)", stream_id ); // Shared negotiated resolution — updated by param_changed, read by process let negotiated_width = StdArc::new(AtomicU32::new(config.width)); let negotiated_height = StdArc::new(AtomicU32::new(config.height)); let param_neg_width = StdArc::clone(&negotiated_width); let param_neg_height = StdArc::clone(&negotiated_height); let proc_neg_width = StdArc::clone(&negotiated_width); let proc_neg_height = StdArc::clone(&negotiated_height); let state_tx_for_callback = state_event_tx; let _listener = stream .add_local_listener::<()>() .state_changed(move |_stream, _user_data, old_state, new_state| { info!( "Stream {} state changed: {:?} -> {:?}", stream_id_for_callbacks, old_state, new_state ); match new_state { StreamState::Error(ref err_msg) => { error!("Stream {} entered error state: {}", stream_id_for_callbacks, err_msg); } StreamState::Streaming => { info!("Stream {} is now streaming", stream_id_for_callbacks); } StreamState::Paused => { debug!("Stream {} paused", stream_id_for_callbacks); } _ => {} } // Emit state event for health monitoring // StreamState doesn't implement Clone, so reconstruct PwStreamState manually let pw_state = match new_state { StreamState::Unconnected => PwStreamState::Unconnected, StreamState::Connecting => PwStreamState::Connecting, StreamState::Paused => PwStreamState::Paused, StreamState::Streaming => PwStreamState::Streaming, StreamState::Error(msg) => PwStreamState::Error(msg.to_string()), }; let event = StreamStateEvent { stream_id: stream_id_for_callbacks, state: pw_state, }; // Non-blocking: drop event if channel full rather than stalling PipeWire let _ = state_tx_for_callback.try_send(event); }) .param_changed(move |stream, _user_data, param_id, param| { let Some(param) = param else { return; }; if param_id != ParamType::Format.as_raw() { return; } // Validate media type before parsing video specifics match format_utils::parse_format(param) { Ok((media_type, media_subtype)) => { info!( "Stream {} format negotiated: type={:?} subtype={:?}", stream_id_for_callbacks, media_type, media_subtype ); } Err(e) => { warn!("Stream {} param_changed: failed to parse media type: {e}", stream_id_for_callbacks); return; } } // Parse the actual negotiated video format from the Pod let mut video_info = VideoInfoRaw::new(); if let Err(e) = video_info.parse(param) { warn!("Stream {} param_changed: failed to parse VideoInfoRaw: {e}", stream_id_for_callbacks); return; } let size = video_info.size(); let format = video_info.format(); info!( "Stream {} negotiated: {}x{} {:?}", stream_id_for_callbacks, size.width, size.height, format ); // Update shared atomics so the process callback validates against // the actual compositor resolution, not the requested resolution param_neg_width.store(size.width, Ordering::Release); param_neg_height.store(size.height, Ordering::Release); // Request buffer metadata types from PipeWire. // Without this, compositors won't attach metadata to buffers. if let Err(e) = request_buffer_metadata(stream, stream_id_for_callbacks) { warn!("Stream {} failed to request buffer metadata: {}", stream_id_for_callbacks, e); } }) .process(move |stream, _user_data| { // This callback is called when a new frame buffer is available info!("process() callback fired for stream {}", stream_id_for_callbacks); // Capture stream timing before touching buffers (RT-safe) // SAFETY: stream pointer is valid within this callback; pw_stream_get_time_n is RT-safe let stream_time = unsafe { crate::stream::get_stream_time(stream.as_raw_ptr()) }; // Use dequeue_raw_buffer to access both SPA metadata and pixel data. // Buffer must be queued back when we're done — handled at scope exit. // SAFETY: stream is valid within this callback; dequeue returns null on empty queue let raw_buf = unsafe { stream.dequeue_raw_buffer() }; if raw_buf.is_null() { debug!( "No buffer available (dequeue returned None) for stream {}", stream_id_for_callbacks ); return; } // Scope guard: always queue the buffer back when we exit this block struct BufferGuard<'a> { stream: &'a pipewire::stream::Stream, raw_buf: *mut pipewire::sys::pw_buffer, } impl Drop for BufferGuard<'_> { fn drop(&mut self) { // SAFETY: raw_buf was obtained from dequeue and stream is still valid unsafe { self.stream.queue_raw_buffer(self.raw_buf); } } } let _guard = BufferGuard { stream, raw_buf }; // SAFETY: raw_buf is non-null (checked above) and valid for this callback let spa_buf: *mut libspa_sys::spa_buffer = unsafe { (*raw_buf).buffer }; if spa_buf.is_null() { warn!("pw_buffer has null spa_buffer for stream {}", stream_id_for_callbacks); return; } // SAFETY: spa_buf is non-null (checked above), valid for callback lifetime let mut buffer_meta = unsafe { crate::meta::extract_buffer_meta(spa_buf) }; // SAFETY: spa_buf is non-null (checked above), n_datas and datas are valid struct fields let (n_datas, datas_ptr) = unsafe { ((*spa_buf).n_datas as usize, (*spa_buf).datas) }; // Debug: dump buffer data block details if !datas_ptr.is_null() && n_datas > 0 { // SAFETY: datas_ptr is non-null, n_datas matches the allocated array length let datas_slice = unsafe { std::slice::from_raw_parts_mut( datas_ptr as *mut libspa::buffer::Data, n_datas, ) }; info!( "Got buffer from stream {}: {} data blocks", stream_id_for_callbacks, n_datas ); for (i, d) in datas_slice.iter_mut().enumerate() { let has_data = d.data().is_some(); let data_len = d.data().map_or(0, |s| s.len()); info!( " data[{}]: type={}, fd={}, has_data={}, data_len={}, chunk_size={}", i, d.type_().as_raw(), d.fd(), has_data, data_len, d.chunk().size() ); } // Extract frame data from buffer if let Some(data) = datas_slice.first_mut() { // Get buffer chunk info let chunk = data.chunk(); let size = chunk.size() as usize; let offset = chunk.offset() as usize; let chunk_stride = chunk.stride(); let data_type = data.type_(); // Record chunk-level signals in metadata for downstream consumers. // Negative stride signals bottom-up buffer (GL coordinate convention). // Buffer type affects which compositor code path produced the data. buffer_meta.chunk_stride = chunk_stride; buffer_meta.buffer_type = data_type.as_raw(); // Extract pixel data based on buffer type let fd = data.fd(); info!( "Buffer: type={}, size={}, offset={}, fd={}, chunk_stride={}", data_type.as_raw(), size, offset, fd, chunk_stride ); let pixel_data: Option> = match data_type { // MemPtr: Direct memory access via data.data() libspa::buffer::DataType::MemPtr => { if let Some(mapped_data) = data.data() { if offset + size <= mapped_data.len() { info!("MemPtr buffer: copying {} bytes (offset={})", size, offset); Some(mapped_data[offset..offset + size].to_vec()) } else { warn!( "MemPtr buffer bounds invalid: offset={}, size={}, len={}", offset, size, mapped_data.len() ); None } } else { warn!("MemPtr buffer but data.data() returned None"); None } } // MemFd: File descriptor with memory mapping // Always use manual mmap — PipeWire's MAP_BUFFERS auto-mapping // can produce stale pointers for MemFd buffers received via // portal FD connections (observed with XDPH on PipeWire 1.6.1). libspa::buffer::DataType::MemFd => { if fd >= 0 { if size == 0 { debug!("MemFd buffer: size=0 (empty/skip frame), ignoring"); None } else { info!("MemFd buffer: manual mmap (FD={}, size={}, offset={})", fd, size, offset); match mmap_fd_buffer(fd, size, offset) { Ok(data) => Some(data), Err(e) => { warn!("Failed to mmap MemFd buffer: {}", e); None } } } } else { debug!("MemFd buffer but no valid FD (fd={})", fd); None } } // DmaBuf: GPU memory buffer - use cached mmap to avoid syscalls libspa::buffer::DataType::DmaBuf => { if fd >= 0 { // Check for empty/skip frames (size=0 is normal PipeWire behavior) if size == 0 { debug!("DMA-BUF buffer: size=0 (empty/skip frame), ignoring"); None } else { // Check cache first let mut cache = dmabuf_cache_for_process.borrow_mut(); // Check cache first, or create new mapping let mapped_ptr_opt = if let Some(&(ptr, _sz)) = cache.get(&fd) { // Use cached mapping (no syscall!) debug!("DMA-BUF FD={}: using cached mmap", fd); Some(ptr) } else { // First time seeing this FD - mmap it info!("DMA-BUF buffer: mmapping {} bytes from FD={} (first time)", size, fd); use nix::sys::mman::{mmap, MapFlags, ProtFlags}; use std::os::fd::BorrowedFd; // Calculate page-aligned mapping // SAFETY: sysconf(_SC_PAGESIZE) always returns a valid value let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize; let map_offset = (offset / page_size) * page_size; let map_size = size + (offset - map_offset); match NonZeroUsize::new(map_size) { Some(nz_size) => { // SAFETY: FD is valid from PipeWire buffer (valid during callback). // We cache the mapping for reuse across frames. unsafe { let borrowed_fd = BorrowedFd::borrow_raw(fd); match mmap( None, nz_size, ProtFlags::PROT_READ, MapFlags::MAP_SHARED, borrowed_fd, map_offset as i64, ) { Ok(ptr) => { cache.insert(fd, (ptr, map_size)); info!("DMA-BUF mmap cached for FD={}", fd); Some(ptr) } Err(e) => { warn!("Failed to mmap DMA-BUF FD={}: {}", fd, e); None } } } } None => { warn!("Invalid map size for DMA-BUF FD={}", fd); None } } }; // Copy data from mapping (cached or fresh) if let Some(mapped_ptr) = mapped_ptr_opt { // SAFETY: mapped_ptr is valid from successful mmap above or cache. // offset + size <= map_size was verified during mmap. // Vec capacity is allocated before writing. let result = unsafe { let src_ptr = (mapped_ptr.as_ptr() as *const u8).add(offset); let mut vec = Vec::with_capacity(size); std::ptr::copy_nonoverlapping(src_ptr, vec.as_mut_ptr(), size); vec.set_len(size); vec }; debug!("DMA-BUF: extracted {} bytes from mapping", result.len()); Some(result) } else { warn!("Failed to get DMA-BUF mapping for FD={}", fd); None } } } else { debug!("DMA-BUF buffer but no valid FD (fd={})", fd); None } } // Unknown/Invalid type — portal source streams with // ALLOC_BUFFERS may not set the buffer type field. // Try data.data() as a fallback since the pixels may // still be mapped and valid. _ => { if let Some(mapped_data) = data.data() { if offset + size <= mapped_data.len() { info!( "Buffer type unknown (raw={}), but mapped data available: {} bytes", data_type.as_raw(), size ); Some(mapped_data[offset..offset + size].to_vec()) } else { warn!( "Buffer type unknown (raw={}), mapped data bounds invalid: offset={}, size={}, len={}", data_type.as_raw(), offset, size, mapped_data.len() ); None } } else { warn!( "Unknown buffer type: {} (raw={}), no mapped data", if data_type == libspa::buffer::DataType::Invalid { "Invalid" } else { "Unknown" }, data_type.as_raw() ); None } } }; if let Some(pixel_data) = pixel_data { // === BUFFER VALIDATION === // PipeWire sometimes provides zero-size or undersized buffers. // These MUST be rejected early to prevent visual corruption. // See: wrd-server-specs/docs/QUALITY-ISSUE-ANALYSIS-2025-12-27.md let bytes_per_pixel = 4; // BGRA/BGRx = 4 bytes // Use the actual negotiated resolution from param_changed, // not the requested config — compositor controls output size let neg_w = proc_neg_width.load(Ordering::Acquire); let neg_h = proc_neg_height.load(Ordering::Acquire); let min_expected_size = (neg_w * neg_h * bytes_per_pixel) as usize; if pixel_data.is_empty() { // Empty buffers are normal - GNOME portal sends them as "no change" signals debug!("Skipping empty buffer (size=0) - compositor indicates no change"); return; } if pixel_data.len() < min_expected_size { warn!( "Rejecting undersized buffer: {} bytes < {} expected for {}×{}", pixel_data.len(), min_expected_size, neg_w, neg_h ); return; } // Calculate proper stride with alignment // Proper stride = width * bytes_per_pixel, aligned to 16 bytes let calculated_stride = ((neg_w * bytes_per_pixel + 15) / 16) * 16; // Verify our calculated stride matches buffer let expected_size = calculated_stride * neg_h; let actual_stride = if expected_size as usize == size { calculated_stride } else { // Buffer size doesn't match our calculation - compute actual stride // This handles cases where compositor uses different alignment (size / neg_h as usize) as u32 }; // Reject frames with zero stride (indicates corrupt buffer metadata) if actual_stride == 0 { warn!("Rejecting buffer with zero stride - corrupt metadata"); return; } // Log stride calculation details for first few frames static LOGGED_FRAMES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let frame_count = LOGGED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if frame_count < 5 { info!("Buffer analysis frame {}:", frame_count); info!( " Size: {} bytes, Width: {}, Height: {} (negotiated)", size, neg_w, neg_h ); info!(" Calculated stride: {} bytes/row (16-byte aligned)", calculated_stride); info!(" Actual stride: {} bytes/row", actual_stride); info!(" Expected buffer size: {} bytes", expected_size); info!(" Buffer type: {} (1=MemPtr, 2=MemFd, 3=DmaBuf)", data_type.as_raw()); info!( " Pixel format: {:?}", config.preferred_format.unwrap_or(PixelFormat::BGRx) ); // Log first 32 bytes as hex to verify byte order if pixel_data.len() >= 32 { let hex_preview: Vec = pixel_data[0..32].iter().map(|b| format!("{:02x}", b)).collect(); info!(" First 32 bytes (hex): {}", hex_preview.join(" ")); } // Log stream timing from pw_stream_get_time_n if let Some(ref t) = stream_time { info!( " PW timing: ticks={}, delay={}ns, queued={}/{} buffers, pressure={:.0}%", t.ticks, t.delay_nsec(), t.queued_buffers, t.queued_buffers + t.avail_buffers, t.buffer_pressure() * 100.0 ); } // Log SPA metadata info!( " SPA Meta: transform={:?}, header={}, crop={}, damage={} regions, cursor={}", buffer_meta.transform, if buffer_meta.header.is_some() { "present" } else { "absent" }, if buffer_meta.crop.is_some() { "present" } else { "absent" }, buffer_meta.damage.len(), if buffer_meta.cursor.is_some() { "present" } else { "absent" }, ); if let Some(ref hdr) = buffer_meta.header { info!( " SPA Header: pts={}, seq={}, flags={:#x}", hdr.pts, hdr.seq, hdr.flags ); } } if actual_stride != calculated_stride { warn!("Stride mismatch detected:"); warn!(" Calculated: {} bytes/row", calculated_stride); warn!(" Actual: {} bytes/row (from buffer size)", actual_stride); warn!(" This may cause horizontal line artifacts!"); } // Create VideoFrame from extracted pixel data let pts = stream_time.as_ref().map_or(0, |t| t.now_nsec as u64); // Convert SPA damage rects to the crate's DamageRegion type let damage_regions: Vec = buffer_meta .damage .iter() .map(|d| crate::ffi::DamageRegion::new( d.x, d.y, d.width, d.height, )) .collect(); let frame = VideoFrame { frame_id: stream_id_for_callbacks as u64, pts, dts: 0, duration: 16_666_667, // ~60fps default width: neg_w, height: neg_h, stride: actual_stride, format: config.preferred_format.unwrap_or(PixelFormat::BGRx), monitor_index: 0, buffer: crate::frame::FrameBuffer::Memory(StdArc::new(pixel_data)), capture_time: SystemTime::now(), damage_regions, meta: buffer_meta.clone(), flags: FrameFlags::new(), }; // Send frame to async runtime if let Err(e) = frame_tx_for_process.try_send(frame) { warn!("Failed to send frame: {} (channel full, backpressure)", e); } else { debug!("Frame sent to async runtime"); } } else { debug!("Could not extract pixel data from buffer"); } } else { warn!("No data in buffer for stream {}", stream_id_for_callbacks); } } else { warn!("No data blocks in buffer for stream {}", stream_id_for_callbacks); } }) .register() .map_err(|e| PipeWireError::StreamCreationFailed(format!("Listener registration failed: {}", e)))?; info!("Stream {} callbacks registered successfully", stream_id); // Build format negotiation parameters // When DmaBuf is enabled, produces two EnumFormat pods: DmaBuf (MANDATORY) + SHM fallback // PipeWire tries params in order and skips MANDATORY params it can't satisfy let param_pod_bytes = build_stream_parameters(&config)?; let pods: Vec<&Pod> = param_pod_bytes .iter() .filter_map(|bytes| Pod::from_bytes(bytes)) .collect(); info!( "Stream {} connecting with {} format param(s), dmabuf={}", stream_id, pods.len(), config.use_dmabuf ); let mut params: Vec<&Pod> = pods; info!( stream_id, node_id, "Connecting stream with flags: AUTOCONNECT | MAP_BUFFERS | DRIVER | RT_PROCESS" ); // DRIVER flag makes this stream drive the graph clock, ensuring frames // are delivered at the negotiated framerate even on a static desktop. // Without DRIVER, ScreenCast portal streams are damage-driven: no screen // change = no frame, causing stalls in the RDP frame delivery pipeline. stream .connect( Direction::Input, None, // PW_ID_ANY - let PipeWire use node.target property StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS | StreamFlags::DRIVER | StreamFlags::RT_PROCESS, &mut params, ) .map_err(|e| PipeWireError::ConnectionFailed(format!("Stream connect failed: {}", e)))?; info!( " Stream {} .connect() succeeded - connected to node {}", stream_id, node_id ); // NOTE: PipeWire tutorial does NOT call set_active() for portal streams // AUTOCONNECT flag should handle activation automatically // Calling set_active(true) here might interfere with auto-connection info!("⏳ NOT calling set_active() - AUTOCONNECT flag should activate stream automatically"); info!("Waiting for PipeWire to transition stream to Streaming state via main loop events"); info!( " If you don't see 'Stream {} is now streaming' within 2 seconds, AUTOCONNECT failed", stream_id ); Ok(ManagedStream { id: stream_id, stream, _listener, config, state: StreamState::Connecting, // Initial state frame_count: 0, frame_tx, }) } /// Request buffer metadata types from PipeWire via stream.update_params(). /// /// Called from param_changed after format negotiation succeeds. Without this, /// PipeWire won't allocate space for metadata in buffer headers, and compositors /// won't attach metadata even if they support it. /// /// This follows the same pattern as OBS and xdg-desktop-portal-wlr. fn request_buffer_metadata(stream: &pipewire::stream::Stream, stream_id: u32) -> Result<()> { use std::io::Cursor; use pipewire::spa; use pipewire::spa::pod::Value; use pipewire::spa::pod::serialize::PodSerializer; // Each metadata type we want must be requested as a separate SPA_PARAM_Meta object. // The object specifies the meta type ID and the minimum allocation size. let meta_requests: &[(u32, usize, &str)] = &[ ( libspa_sys::SPA_META_Header, std::mem::size_of::(), "Header", ), ( libspa_sys::SPA_META_VideoTransform, std::mem::size_of::(), "VideoTransform", ), ( libspa_sys::SPA_META_VideoCrop, std::mem::size_of::(), "VideoCrop", ), ( libspa_sys::SPA_META_VideoDamage, std::mem::size_of::() * crate::meta::MAX_DAMAGE_REGIONS, "VideoDamage", ), ( libspa_sys::SPA_META_Cursor, std::mem::size_of::(), "Cursor", ), ]; let mut param_bytes_list: Vec> = Vec::new(); for &(meta_type, meta_size, name) in meta_requests { // Build the Object struct directly since the property!() macro expects // enum types with .as_raw(), but SPA_PARAM_META_* are raw u32 constants. let meta_obj = spa::pod::Object { type_: spa::utils::SpaTypes::ObjectParamMeta.as_raw(), id: spa::param::ParamType::Meta.as_raw(), properties: vec![ spa::pod::Property::new(libspa_sys::SPA_PARAM_META_type, Value::Id(spa::utils::Id(meta_type))), spa::pod::Property::new(libspa_sys::SPA_PARAM_META_size, Value::Int(meta_size as i32)), ], }; match PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(meta_obj)) { Ok(serialized) => { param_bytes_list.push(serialized.0.into_inner()); debug!( "Stream {}: requested SPA_META_{} ({} bytes)", stream_id, name, meta_size ); } Err(e) => { warn!( "Stream {}: failed to serialize SPA_META_{} request: {:?}", stream_id, name, e ); } } } if param_bytes_list.is_empty() { warn!("Stream {}: no metadata params serialized", stream_id); return Ok(()); } // Convert bytes to Pod references let pods: Vec<&Pod> = param_bytes_list .iter() .filter_map(|bytes| Pod::from_bytes(bytes)) .collect(); if pods.is_empty() { warn!("Stream {}: no valid Pod objects from metadata params", stream_id); return Ok(()); } let mut pod_refs: Vec<&Pod> = pods; stream.update_params(&mut pod_refs).map_err(|e| { PipeWireError::StreamCreationFailed(format!( "Stream {} failed to update params with metadata requests: {}", stream_id, e )) })?; info!( "Stream {}: requested {} metadata types from PipeWire", stream_id, pod_refs.len() ); Ok(()) } /// Build stream parameters for format negotiation. /// /// When `config.use_dmabuf` is true, produces two EnumFormat pods following the /// PipeWire 1.x MANDATORY flag pattern: /// 1. DmaBuf format with `SPA_FORMAT_VIDEO_modifier` carrying MANDATORY|DONT_FIXATE /// 2. SHM fallback without modifier property /// /// PipeWire tries params in order and skips MANDATORY params it can't satisfy, /// so DmaBuf is preferred but SHM works as automatic fallback. /// /// When `config.use_dmabuf` is false, produces a single SHM-only param. fn build_stream_parameters(config: &StreamConfig) -> Result>> { use std::io::Cursor; use pipewire::spa; use pipewire::spa::pod::serialize::PodSerializer; use pipewire::spa::pod::{Property, PropertyFlags, Value}; info!( "Building format parameters: {}x{} @ {}fps, dmabuf={}", config.width, config.height, config.framerate, config.use_dmabuf ); let mut param_pods = Vec::new(); // --- DmaBuf param (first = highest priority) --- if config.use_dmabuf { let mut dmabuf_obj = spa::pod::object!( spa::utils::SpaTypes::ObjectParamFormat, spa::param::ParamType::EnumFormat, spa::pod::property!( spa::param::format::FormatProperties::MediaType, Id, spa::param::format::MediaType::Video ), spa::pod::property!( spa::param::format::FormatProperties::MediaSubtype, Id, spa::param::format::MediaSubtype::Raw ), spa::pod::property!( spa::param::format::FormatProperties::VideoFormat, Choice, Enum, Id, spa::param::video::VideoFormat::BGRx, spa::param::video::VideoFormat::BGRx, spa::param::video::VideoFormat::BGRA, spa::param::video::VideoFormat::RGBx, spa::param::video::VideoFormat::RGBA ), spa::pod::property!( spa::param::format::FormatProperties::VideoSize, Choice, Range, Rectangle, spa::utils::Rectangle { width: config.width, height: config.height }, spa::utils::Rectangle { width: 1, height: 1 }, spa::utils::Rectangle { width: 8192, height: 8192 } ), spa::pod::property!( spa::param::format::FormatProperties::VideoFramerate, Choice, Range, Fraction, spa::utils::Fraction { num: config.framerate, denom: 1 }, spa::utils::Fraction { num: 0, denom: 1 }, spa::utils::Fraction { num: 1000, denom: 1 } ), ); // DRM_FORMAT_MOD_INVALID = "any modifier is acceptable" // SPA Long is i64, DRM modifiers are u64 — reinterpret bits let mod_invalid = crate::ffi::drm_fourcc::DRM_FORMAT_MOD_INVALID as i64; dmabuf_obj.properties.push(Property { key: spa::param::format::FormatProperties::VideoModifier.as_raw(), flags: PropertyFlags::MANDATORY | PropertyFlags::DONT_FIXATE, value: Value::Choice(spa::pod::ChoiceValue::Long(spa::utils::Choice( spa::utils::ChoiceFlags::empty(), spa::utils::ChoiceEnum::Enum { default: mod_invalid, alternatives: vec![mod_invalid], }, ))), }); let serialized = PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(dmabuf_obj)).map_err(|e| { PipeWireError::FormatNegotiationFailed(format!("DmaBuf format serialization failed: {e:?}")) })?; let bytes = serialized.0.into_inner(); info!("DmaBuf format param: {} bytes (MANDATORY|DONT_FIXATE)", bytes.len()); param_pods.push(bytes); } // --- SHM fallback param (no modifier property) --- let shm_obj = spa::pod::object!( spa::utils::SpaTypes::ObjectParamFormat, spa::param::ParamType::EnumFormat, spa::pod::property!( spa::param::format::FormatProperties::MediaType, Id, spa::param::format::MediaType::Video ), spa::pod::property!( spa::param::format::FormatProperties::MediaSubtype, Id, spa::param::format::MediaSubtype::Raw ), spa::pod::property!( spa::param::format::FormatProperties::VideoFormat, Choice, Enum, Id, spa::param::video::VideoFormat::BGRx, spa::param::video::VideoFormat::BGRx, spa::param::video::VideoFormat::BGRA, spa::param::video::VideoFormat::RGBx, spa::param::video::VideoFormat::RGBA ), spa::pod::property!( spa::param::format::FormatProperties::VideoSize, Choice, Range, Rectangle, spa::utils::Rectangle { width: config.width, height: config.height }, spa::utils::Rectangle { width: 1, height: 1 }, spa::utils::Rectangle { width: 8192, height: 8192 } ), spa::pod::property!( spa::param::format::FormatProperties::VideoFramerate, Choice, Range, Fraction, spa::utils::Fraction { num: config.framerate, denom: 1 }, spa::utils::Fraction { num: 0, denom: 1 }, spa::utils::Fraction { num: 1000, denom: 1 } ), ); let serialized = PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(shm_obj)) .map_err(|e| PipeWireError::FormatNegotiationFailed(format!("SHM format serialization failed: {e:?}")))?; let bytes = serialized.0.into_inner(); info!("SHM fallback format param: {} bytes", bytes.len()); param_pods.push(bytes); info!( "Format negotiation: {} param(s) built (dmabuf={})", param_pods.len(), config.use_dmabuf ); Ok(param_pods) } #[cfg(test)] mod tests { #[allow(unused_imports)] use super::*; #[test] fn test_thread_manager_creation() { // Cannot test without valid FD from portal // Full tests require integration testing with actual portal } } lamco-pipewire-0.4.0/src/stream.rs000064400000000000000000000312661046102023000152020ustar 00000000000000//! PipeWire Stream Types //! //! Configuration, state, and metrics types for PipeWire streams. //! The actual stream implementation lives in `pw_thread.rs`. use std::sync::Mutex; use libspa::param::video::VideoFormat; use pipewire::spa::utils::Fraction; use pipewire::stream::StreamState; use crate::format::PixelFormat; /// Stream configuration #[derive(Debug, Clone)] pub struct StreamConfig { /// Stream name pub name: String, /// Target width pub width: u32, /// Target height pub height: u32, /// Target framerate pub framerate: u32, /// Use DMA-BUF if available pub use_dmabuf: bool, /// Number of buffers pub buffer_count: u32, /// Preferred format pub preferred_format: Option, } impl StreamConfig { /// Create default configuration pub fn new(name: impl Into) -> Self { Self { name: name.into(), width: 1920, height: 1080, framerate: 30, use_dmabuf: true, buffer_count: 3, preferred_format: Some(PixelFormat::BGRA), } } /// Set resolution pub fn with_resolution(mut self, width: u32, height: u32) -> Self { self.width = width; self.height = height; self } /// Set framerate pub fn with_framerate(mut self, fps: u32) -> Self { self.framerate = fps; self } /// Set DMA-BUF preference pub fn with_dmabuf(mut self, use_dmabuf: bool) -> Self { self.use_dmabuf = use_dmabuf; self } /// Set buffer count pub fn with_buffer_count(mut self, count: u32) -> Self { self.buffer_count = count; self } } /// PipeWire stream state /// /// Faithfully mirrors [`pipewire::stream::StreamState`] so consumers get /// full state information for health monitoring and diagnostics. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PwStreamState { /// Stream is not connected to a PipeWire node Unconnected, /// Stream is connecting to a PipeWire node Connecting, /// Stream is connected but not actively streaming Paused, /// Stream is actively delivering frames Streaming, /// Stream encountered an error (carries the error message) Error(String), } impl From for PwStreamState { fn from(state: StreamState) -> Self { match state { StreamState::Unconnected => Self::Unconnected, StreamState::Connecting => Self::Connecting, StreamState::Paused => Self::Paused, StreamState::Streaming => Self::Streaming, StreamState::Error(msg) => Self::Error(msg), } } } /// A stream state change event /// /// Emitted by [`PipeWireThreadManager`](crate::pw_thread::PipeWireThreadManager) /// when a stream's state changes. Consumers can drain these events to /// update health monitoring, UI, or take corrective action. #[derive(Debug, Clone)] pub struct StreamStateEvent { /// Stream ID that changed state pub stream_id: u32, /// New state of the stream pub state: PwStreamState, } /// Format negotiation result #[derive(Debug, Clone)] pub struct NegotiatedFormat { /// Video format pub format: VideoFormat, /// Width pub width: u32, /// Height pub height: u32, /// Row stride pub stride: u32, /// Framerate pub framerate: Fraction, } /// Minimal stream handle used by connection and coordinator modules. /// The real PipeWire stream implementation is in `pw_thread.rs`. pub struct PipeWireStream { id: u32, state: Mutex, frame_callback: Mutex>, } impl PipeWireStream { /// Create a new stream handle pub fn new(id: u32, _config: StreamConfig) -> Self { Self { id, state: Mutex::new(PwStreamState::Unconnected), frame_callback: Mutex::new(None), } } /// Get stream ID pub fn id(&self) -> u32 { self.id } /// Get current state pub fn state(&self) -> PwStreamState { self.state.lock().unwrap_or_else(|e| e.into_inner()).clone() } /// Set frame callback pub fn set_frame_callback(&mut self, callback: crate::frame::FrameCallback) { *self.frame_callback.lock().unwrap_or_else(|e| e.into_inner()) = Some(callback); } /// Stop the stream pub async fn stop(&mut self) -> crate::error::Result<()> { *self.state.lock().unwrap_or_else(|e| e.into_inner()) = PwStreamState::Unconnected; Ok(()) } } /// Stream timing snapshot from PipeWire's graph cycle. /// /// Wraps the data from `pw_stream_get_time_n()`. All timing values are /// relative to the PipeWire graph clock and updated once per graph cycle /// (typically every quantum, e.g. every ~21ms at 48kHz/1024). /// /// For video capture streams, `ticks` increases monotonically and `delay` /// reflects the total pipeline latency from the capture source through /// any intermediate filters. #[derive(Debug, Clone, Default)] pub struct StreamTime { /// Graph clock timestamp (nanoseconds, CLOCK_MONOTONIC). /// Compare with [`get_stream_nsec()`] to compute elapsed time since /// this report was generated. pub now_nsec: i64, /// Tick rate as a fraction (numerator / denominator). /// For video streams this is typically 1/framerate (e.g. 1/60). /// For audio streams it's 1/samplerate. pub rate_num: u32, pub rate_denom: u32, /// Monotonically increasing stream position in ticks. /// Gaps in this value between consecutive reads indicate dropped frames. pub ticks: u64, /// Pipeline latency from capture source through all filters, in ticks. /// Multiply by rate (rate_num / rate_denom) to get seconds. /// Does not include queued buffer latency. pub delay: i64, /// Total bytes currently queued in the stream (sum of all queued buffer sizes). pub queued_bytes: u64, /// Extra frames buffered in the resampler (audio streams). pub buffered: u64, /// Number of buffers currently queued (handed to PipeWire, not yet returned). pub queued_buffers: u32, /// Number of buffers available for dequeue. /// Low values indicate the producer is keeping up; zero means starvation. pub avail_buffers: u32, } impl StreamTime { /// Pipeline delay in nanoseconds. /// Returns 0 if rate_denom is zero (uninitialized stream). pub fn delay_nsec(&self) -> i64 { if self.rate_denom == 0 { return 0; } // delay is in ticks; convert via rate fraction to seconds, then to ns self.delay * self.rate_num as i64 * 1_000_000_000 / self.rate_denom as i64 } /// Buffer pressure ratio (0.0 = no pressure, 1.0 = all buffers queued, none available). /// Returns 0.0 if no buffers exist. pub fn buffer_pressure(&self) -> f32 { let total = self.queued_buffers + self.avail_buffers; if total == 0 { return 0.0; } self.queued_buffers as f32 / total as f32 } } /// Query the current stream time via `pw_stream_get_time_n()`. /// /// RT-safe. Can be called from the process callback without blocking. /// Returns `None` if the call fails (stream not connected or invalid pointer). /// /// # Safety /// /// The raw stream pointer must be valid and the stream must not have been destroyed. /// This is guaranteed when called from within a stream callback or while the /// PipeWire main loop is locked. pub(crate) unsafe fn get_stream_time(raw_stream: *mut pipewire::sys::pw_stream) -> Option { let mut time = std::mem::MaybeUninit::::uninit(); // SAFETY: raw_stream is valid (caller guarantee), pw_time is POD with no // invariants, and pw_stream_get_time_n is documented RT-safe. let ret = unsafe { pipewire::sys::pw_stream_get_time_n( raw_stream, time.as_mut_ptr(), std::mem::size_of::(), ) }; if ret < 0 { return None; } // SAFETY: pw_stream_get_time_n returned success (>= 0), so time is initialized. let t = unsafe { time.assume_init() }; Some(StreamTime { now_nsec: t.now, rate_num: t.rate.num, rate_denom: t.rate.denom, ticks: t.ticks, delay: t.delay, queued_bytes: t.queued, buffered: t.buffered, queued_buffers: t.queued_buffers, avail_buffers: t.avail_buffers, }) } /// Query the current monotonic time for the stream in nanoseconds. /// /// This can be compared with [`StreamTime::now_nsec`] to compute how much /// time has elapsed since the last timing report. /// /// RT-safe. /// /// # Safety /// /// Same requirements as [`get_stream_time()`]. #[allow(dead_code)] pub(crate) unsafe fn get_stream_nsec(raw_stream: *mut pipewire::sys::pw_stream) -> u64 { // SAFETY: raw_stream is valid (caller guarantee), function is RT-safe. unsafe { pipewire::sys::pw_stream_get_nsec(raw_stream) } } /// Stream metrics #[derive(Debug, Clone, Default)] pub struct StreamMetrics { /// Frames processed pub frames_processed: u64, /// Bytes processed pub bytes_processed: u64, /// Errors encountered pub error_count: u64, /// Buffer underruns pub underruns: u64, /// Average frame latency (milliseconds) pub avg_latency_ms: f64, /// Current FPS pub current_fps: f32, /// Most recent stream timing snapshot (None until first frame) pub last_stream_time: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn test_stream_config() { let config = StreamConfig::new("test-stream") .with_resolution(1920, 1080) .with_framerate(60) .with_dmabuf(true) .with_buffer_count(4); assert_eq!(config.name, "test-stream"); assert_eq!(config.width, 1920); assert_eq!(config.height, 1080); assert_eq!(config.framerate, 60); assert!(config.use_dmabuf); assert_eq!(config.buffer_count, 4); } #[test] fn test_stream_state_from_pw() { assert_eq!(PwStreamState::from(StreamState::Streaming), PwStreamState::Streaming); assert_eq!(PwStreamState::from(StreamState::Paused), PwStreamState::Paused); assert_eq!( PwStreamState::from(StreamState::Unconnected), PwStreamState::Unconnected ); assert_eq!(PwStreamState::from(StreamState::Connecting), PwStreamState::Connecting); assert_eq!( PwStreamState::from(StreamState::Error("test error".to_string())), PwStreamState::Error("test error".to_string()) ); } #[test] fn test_stream_state_event() { let event = StreamStateEvent { stream_id: 42, state: PwStreamState::Streaming, }; assert_eq!(event.stream_id, 42); assert_eq!(event.state, PwStreamState::Streaming); } #[test] fn test_negotiated_format() { let format = NegotiatedFormat { format: VideoFormat::BGRA, width: 1920, height: 1080, stride: 7680, framerate: Fraction { num: 60, denom: 1 }, }; assert_eq!(format.width, 1920); assert_eq!(format.stride, 7680); } #[test] fn test_stream_time_delay_nsec() { let t = StreamTime { rate_num: 1, rate_denom: 60, delay: 120, // 120 ticks at 1/60 = 2 seconds = 2_000_000_000 ns ..Default::default() }; assert_eq!(t.delay_nsec(), 2_000_000_000); } #[test] fn test_stream_time_delay_zero_denom() { let t = StreamTime { rate_num: 1, rate_denom: 0, delay: 100, ..Default::default() }; // Uninitialized stream: should return 0 rather than divide-by-zero assert_eq!(t.delay_nsec(), 0); } #[test] fn test_stream_time_buffer_pressure() { // All buffers queued (maximum pressure) let t = StreamTime { queued_buffers: 4, avail_buffers: 0, ..Default::default() }; assert!((t.buffer_pressure() - 1.0).abs() < f32::EPSILON); // No buffers queued (no pressure) let t2 = StreamTime { queued_buffers: 0, avail_buffers: 4, ..Default::default() }; assert!(t2.buffer_pressure().abs() < f32::EPSILON); // Half and half let t3 = StreamTime { queued_buffers: 2, avail_buffers: 2, ..Default::default() }; assert!((t3.buffer_pressure() - 0.5).abs() < f32::EPSILON); // No buffers at all let t4 = StreamTime::default(); assert!(t4.buffer_pressure().abs() < f32::EPSILON); } } lamco-pipewire-0.4.0/src/thread_comm.rs000064400000000000000000000020411046102023000161560ustar 00000000000000//! PipeWire Thread Communication //! //! Message passing system for communicating between the async runtime //! and the dedicated PipeWire MainLoop thread. use tokio::sync::oneshot; use crate::error::Result; use crate::stream::StreamConfig; /// Commands sent to the PipeWire thread pub enum PipeWireCommand { /// Create a new stream CreateStream { stream_id: u32, config: StreamConfig, node_id: u32, response: oneshot::Sender>, }, /// Destroy a stream DestroyStream { stream_id: u32, response: oneshot::Sender>, }, /// Get stream state GetStreamState { stream_id: u32, response: oneshot::Sender>, }, /// Shutdown the PipeWire thread Shutdown, } /// Responses from the PipeWire thread pub enum PipeWireResponse { /// Stream created successfully StreamCreated(u32), /// Stream destroyed successfully StreamDestroyed(u32), /// Error occurred Error(String), } lamco-pipewire-0.4.0/src/yuv.rs000064400000000000000000000245431046102023000145320ustar 00000000000000//! YUV Format Conversion Utilities //! //! Provides conversion from YUV color formats to RGB/BGRA for display. //! These conversions are useful when PipeWire provides frames in compressed //! YUV formats (NV12, I420, YUY2) that need to be converted for rendering. //! //! # Supported Formats //! //! - **NV12**: YUV 4:2:0 with interleaved UV plane (common for hardware encoders) //! - **I420**: YUV 4:2:0 with separate U and V planes (aka YV12) //! - **YUY2**: YUV 4:2:2 packed format (YUYV) //! //! # Performance //! //! These are reference implementations prioritizing correctness over speed. //! For production use with high frame rates, consider: //! - SIMD-accelerated implementations //! - GPU-based conversion (OpenGL/Vulkan shaders) //! - Hardware decoder output directly to RGB //! //! # Examples //! //! ```rust,no_run //! use lamco_pipewire::yuv::{nv12_to_bgra, YuvConverter}; //! //! // Direct conversion //! let nv12_data: &[u8] = &[0u8; 3110400]; // 1920x1080 NV12 frame //! let bgra = nv12_to_bgra(nv12_data, 1920, 1080); //! //! // Using converter with format detection //! let converter = YuvConverter::new(); //! ``` use crate::format::PixelFormat; /// Convert NV12 to BGRA /// /// NV12 is YUV 4:2:0 with: /// - Y plane: width * height bytes /// - UV plane: width * height / 2 bytes (interleaved U, V) /// /// # Arguments /// /// * `src` - Source NV12 data /// * `width` - Frame width (must be even) /// * `height` - Frame height (must be even) /// /// # Returns /// /// BGRA data (width * height * 4 bytes) /// /// # Panics /// /// Panics if source data is too small for the given dimensions. #[must_use] pub fn nv12_to_bgra(src: &[u8], width: u32, height: u32) -> Vec { let w = width as usize; let h = height as usize; let y_plane_size = w * h; let uv_plane_size = w * h / 2; assert!( src.len() >= y_plane_size + uv_plane_size, "NV12 source data too small: need {}, got {}", y_plane_size + uv_plane_size, src.len() ); let y_plane = &src[..y_plane_size]; let uv_plane = &src[y_plane_size..y_plane_size + uv_plane_size]; let mut dst = vec![0u8; w * h * 4]; for y in 0..h { for x in 0..w { let y_idx = y * w + x; let uv_idx = (y / 2) * w + (x / 2) * 2; let y_val = i32::from(y_plane[y_idx]); let u_val = i32::from(uv_plane[uv_idx]); let v_val = i32::from(uv_plane[uv_idx + 1]); let (r, g, b) = yuv_to_rgb(y_val, u_val, v_val); let dst_idx = y_idx * 4; dst[dst_idx] = b; dst[dst_idx + 1] = g; dst[dst_idx + 2] = r; dst[dst_idx + 3] = 255; // Alpha } } dst } /// Convert I420 to BGRA /// /// I420 is YUV 4:2:0 with separate planes: /// - Y plane: width * height bytes /// - U plane: width/2 * height/2 bytes /// - V plane: width/2 * height/2 bytes /// /// # Arguments /// /// * `src` - Source I420 data /// * `width` - Frame width (must be even) /// * `height` - Frame height (must be even) /// /// # Returns /// /// BGRA data (width * height * 4 bytes) #[must_use] pub fn i420_to_bgra(src: &[u8], width: u32, height: u32) -> Vec { let w = width as usize; let h = height as usize; let y_plane_size = w * h; let uv_plane_size = (w / 2) * (h / 2); assert!( src.len() >= y_plane_size + uv_plane_size * 2, "I420 source data too small" ); let y_plane = &src[..y_plane_size]; let u_plane = &src[y_plane_size..y_plane_size + uv_plane_size]; let v_plane = &src[y_plane_size + uv_plane_size..y_plane_size + uv_plane_size * 2]; let mut dst = vec![0u8; w * h * 4]; for y in 0..h { for x in 0..w { let y_idx = y * w + x; let uv_idx = (y / 2) * (w / 2) + (x / 2); let y_val = i32::from(y_plane[y_idx]); let u_val = i32::from(u_plane[uv_idx]); let v_val = i32::from(v_plane[uv_idx]); let (r, g, b) = yuv_to_rgb(y_val, u_val, v_val); let dst_idx = y_idx * 4; dst[dst_idx] = b; dst[dst_idx + 1] = g; dst[dst_idx + 2] = r; dst[dst_idx + 3] = 255; } } dst } /// Convert YUY2 to BGRA /// /// YUY2 is YUV 4:2:2 packed format: /// - Each 4-byte macro pixel: Y0, U, Y1, V /// - Represents 2 horizontal pixels sharing U and V /// /// # Arguments /// /// * `src` - Source YUY2 data /// * `width` - Frame width (must be even) /// * `height` - Frame height /// /// # Returns /// /// BGRA data (width * height * 4 bytes) #[must_use] pub fn yuy2_to_bgra(src: &[u8], width: u32, height: u32) -> Vec { let w = width as usize; let h = height as usize; assert!(w % 2 == 0, "YUY2 width must be even"); assert!(src.len() >= w * h * 2, "YUY2 source data too small"); let mut dst = vec![0u8; w * h * 4]; for y in 0..h { for x in (0..w).step_by(2) { let src_idx = (y * w + x) * 2; let y0 = i32::from(src[src_idx]); let u = i32::from(src[src_idx + 1]); let y1 = i32::from(src[src_idx + 2]); let v = i32::from(src[src_idx + 3]); // First pixel let (r0, g0, b0) = yuv_to_rgb(y0, u, v); let dst_idx0 = (y * w + x) * 4; dst[dst_idx0] = b0; dst[dst_idx0 + 1] = g0; dst[dst_idx0 + 2] = r0; dst[dst_idx0 + 3] = 255; // Second pixel let (r1, g1, b1) = yuv_to_rgb(y1, u, v); let dst_idx1 = (y * w + x + 1) * 4; dst[dst_idx1] = b1; dst[dst_idx1 + 1] = g1; dst[dst_idx1 + 2] = r1; dst[dst_idx1 + 3] = 255; } } dst } /// Convert single YUV pixel to RGB /// /// Uses BT.601 color matrix (standard for SD video): /// R = 1.164(Y-16) + 1.596(V-128) /// G = 1.164(Y-16) - 0.813(V-128) - 0.391(U-128) /// B = 1.164(Y-16) + 2.018(U-128) #[inline] fn yuv_to_rgb(y: i32, u: i32, v: i32) -> (u8, u8, u8) { // Scale factors (multiplied by 256 for integer math) const Y_SCALE: i32 = 298; // 1.164 * 256 const V_TO_R: i32 = 409; // 1.596 * 256 const U_TO_G: i32 = 100; // 0.391 * 256 const V_TO_G: i32 = 208; // 0.813 * 256 const U_TO_B: i32 = 516; // 2.018 * 256 let y = y - 16; let u = u - 128; let v = v - 128; let r = (Y_SCALE * y + V_TO_R * v + 128) >> 8; let g = (Y_SCALE * y - U_TO_G * u - V_TO_G * v + 128) >> 8; let b = (Y_SCALE * y + U_TO_B * u + 128) >> 8; (r.clamp(0, 255) as u8, g.clamp(0, 255) as u8, b.clamp(0, 255) as u8) } /// YUV format converter with caching and format detection pub struct YuvConverter { /// Reusable output buffer to avoid allocations output_buffer: Vec, } impl YuvConverter { /// Create a new YUV converter #[must_use] pub fn new() -> Self { Self { output_buffer: Vec::new(), } } /// Convert YUV data to BGRA /// /// # Arguments /// /// * `src` - Source YUV data /// * `width` - Frame width /// * `height` - Frame height /// * `format` - Source pixel format /// /// # Returns /// /// Reference to internal BGRA buffer (valid until next conversion) pub fn convert_to_bgra(&mut self, src: &[u8], width: u32, height: u32, format: PixelFormat) -> Option<&[u8]> { let result = match format { PixelFormat::NV12 => nv12_to_bgra(src, width, height), PixelFormat::I420 => i420_to_bgra(src, width, height), PixelFormat::YUY2 => yuy2_to_bgra(src, width, height), // Already in RGB family - no conversion needed PixelFormat::BGRA | PixelFormat::RGBA | PixelFormat::BGRx | PixelFormat::RGBx => { return None; } _ => return None, }; self.output_buffer = result; Some(&self.output_buffer) } /// Check if format needs YUV conversion #[must_use] pub fn needs_conversion(format: PixelFormat) -> bool { matches!(format, PixelFormat::NV12 | PixelFormat::I420 | PixelFormat::YUY2) } /// Get required buffer size for BGRA output #[must_use] pub fn output_size(width: u32, height: u32) -> usize { (width as usize) * (height as usize) * 4 } } impl Default for YuvConverter { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_yuv_to_rgb() { // Black (Y=16, U=128, V=128) let (r, g, b) = yuv_to_rgb(16, 128, 128); assert_eq!((r, g, b), (0, 0, 0)); // White (Y=235, U=128, V=128) let (r, g, b) = yuv_to_rgb(235, 128, 128); assert!(r > 250 && g > 250 && b > 250); } #[test] fn test_nv12_to_bgra() { // 2x2 black frame in NV12 // Y plane: 4 bytes of 16 (black) // UV plane: 2 bytes of 128, 128 let nv12 = vec![16, 16, 16, 16, 128, 128]; let bgra = nv12_to_bgra(&nv12, 2, 2); assert_eq!(bgra.len(), 16); // 2x2x4 // All pixels should be near-black assert!(bgra[0] < 5 && bgra[1] < 5 && bgra[2] < 5); assert_eq!(bgra[3], 255); // Alpha } #[test] fn test_i420_to_bgra() { // 2x2 black frame in I420 let i420 = vec![ 16, 16, 16, 16, // Y plane 128, // U plane (1 byte for 2x2) 128, // V plane ]; let bgra = i420_to_bgra(&i420, 2, 2); assert_eq!(bgra.len(), 16); assert!(bgra[0] < 5 && bgra[1] < 5 && bgra[2] < 5); } #[test] fn test_yuy2_to_bgra() { // 2x2 black frame in YUY2 // Row 1: Y0, U, Y1, V (2 pixels) // Row 2: Y0, U, Y1, V (2 pixels) let yuy2 = vec![ 16, 128, 16, 128, // Row 1 16, 128, 16, 128, // Row 2 ]; let bgra = yuy2_to_bgra(&yuy2, 2, 2); assert_eq!(bgra.len(), 16); assert!(bgra[0] < 5 && bgra[1] < 5 && bgra[2] < 5); } #[test] fn test_yuv_converter() { let mut converter = YuvConverter::new(); assert!(YuvConverter::needs_conversion(PixelFormat::NV12)); assert!(YuvConverter::needs_conversion(PixelFormat::I420)); assert!(!YuvConverter::needs_conversion(PixelFormat::BGRA)); // Test conversion let nv12 = vec![16, 16, 16, 16, 128, 128]; let result = converter.convert_to_bgra(&nv12, 2, 2, PixelFormat::NV12); assert!(result.is_some()); assert_eq!(result.expect("should have result").len(), 16); } }