feat: add KWin/KDE Plasma screen capture via xdg-desktop-portal ScreenCast + PipeWire

Add a second capture backend for compositors without wlr-screencopy
(KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface
and PipeWire DMA-BUF streaming.

New files:
- src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend
- src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread
- src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI)

Changes:
- Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9,
  crossbeam-channel 0.5
- src/args.rs: add --backend CLI flag
- src/avhw.rs: extract create_encoder() from inline State code
- src/main.rs: route to portal or wlr-screencopy based on backend
- src/state.rs: fix params.destroy() on dup failure, cleanup
  in_flight_surface on copy fail, use create_encoder()
- tests/integration_test.rs: add --backend flag tests
This commit is contained in:
dailz
2026-05-11 08:49:08 +08:00
parent 2972216a02
commit d7fbb5256c
12 changed files with 2198 additions and 79 deletions

1
.gitignore vendored
View File

@@ -16,4 +16,3 @@
Thumbs.db
# Sisyphus orchestration artifacts
.sisyphus/

1028
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,3 +19,8 @@ anyhow = "1"
drm = "0.12"
drm-fourcc = "2"
libc = "0.2"
ashpd = { version = "0.13", features = ["tokio", "screencast"] }
tokio = { version = "1", features = ["rt"] }
pipewire = "0.9"
libspa = "0.9"
crossbeam-channel = "0.5"

View File

@@ -39,6 +39,10 @@ pub struct Args {
#[arg(short, long)]
pub verbose: bool,
/// Capture backend to use: 'screencopy' (wlroots) or 'portal' (KWin/KDE). Auto-detected if omitted
#[arg(long)]
pub backend: Option<String>,
/// Port for WebTransport server (Phase 2, unused in MVP)
#[arg(long, default_value_t = 0)]
pub port: u16,

View File

@@ -7,7 +7,7 @@ use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut as _;
use crate::transform::Transform;
use crate::transform::{transpose_if_transform_transposed, Transform};
// ---------------------------------------------------------------------------
// AvHwDevCtx
@@ -463,6 +463,46 @@ impl EncState {
}
}
// ---------------------------------------------------------------------------
// Shared encoder creation (used by both wlr-screencopy and portal paths)
// ---------------------------------------------------------------------------
/// Create a fully configured encoder with VAAPI hardware acceleration.
///
/// Convenience wrapper around [`EncState::new`] that computes default values
/// for `bitrate` and `gop_size` when not provided, and handles encoder dimension
/// transposition for rotated/transformed outputs.
#[allow(clippy::too_many_arguments)]
pub fn create_encoder(
drm_device: &Path,
output_path: &Path,
width: u32,
height: u32,
fps: u32,
transform: Transform,
bitrate: Option<u64>,
gop_size: Option<u32>,
) -> Result<EncState> {
let (enc_w, enc_h) =
transpose_if_transform_transposed(transform, width as i32, height as i32);
let actual_bitrate = bitrate.unwrap_or_else(|| {
2 * (width as u64) * (height as u64) * (fps as u64) / 100
});
let actual_gop_size = gop_size.unwrap_or(fps);
EncState::new(
drm_device,
output_path,
width,
height,
enc_w as u32,
enc_h as u32,
actual_bitrate,
actual_gop_size,
fps,
transform,
)
}
// ---------------------------------------------------------------------------
// Filter graph (inline)
// ---------------------------------------------------------------------------
@@ -535,31 +575,30 @@ fn build_filter_graph(
src_ctx.link(0, &mut scale_ctx, 0);
match transform {
Transform::Normal90 | Transform::Normal270 => {
Transform::Normal => {
scale_ctx.link(0, &mut sink_ctx, 0);
}
other => {
let transpose = ff::filter::find("transpose_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'transpose_vaapi' not found"))?;
let dir_val = match transform {
let dir_val = match other {
Transform::Normal90 => "1",
Transform::Normal180 => "4",
Transform::Normal270 => "2",
_ => unreachable!(),
Transform::Flipped => "5",
Transform::Flipped90 => "3",
Transform::Flipped180 => "6",
Transform::Flipped270 => "0",
Transform::Normal => unreachable!(),
};
let mut trans_ctx = graph.add(&transpose, "transpose", &format!("dir={dir_val}"))?;
// SAFETY: transpose_vaapi needs hw_device_ctx for VAAPI device access.
let mut trans_ctx =
graph.add(&transpose, "transpose", &format!("dir={dir_val}"))?;
unsafe {
(*trans_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
}
scale_ctx.link(0, &mut trans_ctx, 0);
trans_ctx.link(0, &mut sink_ctx, 0);
}
Transform::Normal180 => {
tracing::warn!(
"Normal180 transform detected; rotation correction deferred to follow-up"
);
scale_ctx.link(0, &mut sink_ctx, 0);
}
_ => {
scale_ctx.link(0, &mut sink_ctx, 0);
}
}
graph

134
src/backend_detect.rs Normal file
View File

@@ -0,0 +1,134 @@
use anyhow::Result;
use wayland_client::globals::registry_queue_init;
use wayland_client::globals::GlobalListContents;
use wayland_client::protocol::wl_registry::{Event, WlRegistry};
use wayland_client::{Connection, Dispatch, QueueHandle};
use crate::args::Args;
/// Capture backend to use for screen capture.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureBackend {
/// wlroots wlr-screencopy protocol (Sway, Hyprland, etc.)
WlrScreencopy,
/// xdg-desktop-portal with PipeWire (KWin/KDE, GNOME, etc.)
PortalPipeWire,
}
/// Minimal dispatch type for listing Wayland globals during backend detection.
struct RegistryLs;
impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
fn event(
_state: &mut Self,
_registry: &WlRegistry,
_event: Event,
_data: &GlobalListContents,
_conn: &Connection,
_qhandle: &QueueHandle<Self>,
) {
}
}
/// Detect which capture backend to use.
///
/// Priority:
/// 1. Explicit `--backend` override from CLI args
/// 2. Auto-detect by checking for `zwlr_screencopy_manager_v1` in Wayland globals
///
/// The detection Wayland connection is dropped before returning so the actual
/// capture backend can create its own connection without holding two simultaneously.
pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
// 1. Check explicit override
if let Some(ref backend) = args.backend {
return match backend.as_str() {
"portal" => {
tracing::info!("Backend override: Portal/PipeWire");
Ok(CaptureBackend::PortalPipeWire)
}
"screencopy" => {
tracing::info!("Backend override: wlr-screencopy");
Ok(CaptureBackend::WlrScreencopy)
}
other => {
anyhow::bail!(
"Unknown backend '{}'. Use 'screencopy' or 'portal'.",
other
);
}
};
}
// 2. Auto-detect: check if zwlr_screencopy_manager_v1 is available
tracing::info!("Auto-detecting capture backend...");
let conn = Connection::connect_to_env()?;
let (globals, _queue) = registry_queue_init::<RegistryLs>(&conn)?;
let has_screencopy = globals
.contents()
.clone_list()
.iter()
.any(|g| g.interface == "zwlr_screencopy_manager_v1");
// Drop the Wayland connection explicitly before returning.
// The screencopy path creates its own connection. Holding two connections
// simultaneously is wasteful and may cause issues on some compositors.
drop(conn);
if has_screencopy {
tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend");
Ok(CaptureBackend::WlrScreencopy)
} else {
tracing::info!("No wlr-screencopy support → using Portal/PipeWire backend");
Ok(CaptureBackend::PortalPipeWire)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_args(backend: Option<&str>) -> Args {
Args {
output: "test.mp4".to_string(),
output_name: None,
fps: 30,
codec: "h264".to_string(),
hw_accel: "vaapi".to_string(),
drm_device: None,
bitrate: None,
gop_size: None,
verbose: false,
backend: backend.map(String::from),
port: 0,
}
}
#[test]
fn explicit_portal_backend() {
let args = make_args(Some("portal"));
let result = detect_backend(&args);
assert!(result.is_ok());
assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire);
}
#[test]
fn explicit_screencopy_backend() {
let args = make_args(Some("screencopy"));
let result = detect_backend(&args);
assert!(result.is_ok());
assert_eq!(result.unwrap(), CaptureBackend::WlrScreencopy);
}
#[test]
fn invalid_backend_name_returns_error() {
let args = make_args(Some("magic"));
let result = detect_backend(&args);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unknown backend 'magic'"),
"Expected error about unknown backend, got: {err}"
);
}
}

411
src/cap_portal.rs Normal file
View File

@@ -0,0 +1,411 @@
use std::os::fd::{FromRawFd, OwnedFd};
use std::thread::{self, JoinHandle};
use anyhow::Result;
use crossbeam_channel::{Receiver, Sender, bounded};
use tokio::runtime::Runtime;
use crate::args::Args;
pub struct PwDmaBufFrame {
pub fd: OwnedFd,
pub offset: u64,
pub stride: u32,
pub modifier: u64,
pub width: u32,
pub height: u32,
pub format: u32,
pub pts: i64,
}
pub enum PwEvent {
Frame(PwDmaBufFrame),
StreamEnded,
Error(String),
}
pub enum PwCmd {
Shutdown,
}
pub struct CapPortal {
cmd_tx: Sender<PwCmd>,
frame_rx: Receiver<PwEvent>,
pw_thread: Option<JoinHandle<()>>,
rt: Runtime,
}
struct PwThreadCtx {
frame_tx: Sender<PwEvent>,
cmd_rx: Receiver<PwCmd>,
pw_fd: OwnedFd,
node_id: u32,
fps: u32,
}
impl CapPortal {
pub fn new(args: &Args) -> Result<Self> {
let rt = Runtime::new()?;
let (pw_fd, node_id) = rt.block_on(async {
Self::setup_portal().await
})?;
let (frame_tx, frame_rx) = bounded(3);
let (cmd_tx, cmd_rx) = bounded(1);
let ctx = PwThreadCtx {
frame_tx,
cmd_rx,
pw_fd,
node_id,
fps: args.fps,
};
let pw_thread = thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || {
pipewire_thread(ctx);
})?;
Ok(Self {
cmd_tx,
frame_rx,
pw_thread: Some(pw_thread),
rt,
})
}
pub fn frame_receiver(&self) -> &Receiver<PwEvent> {
&self.frame_rx
}
async fn setup_portal() -> Result<(OwnedFd, u32)> {
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
let proxy = Screencast::new().await.map_err(|e| {
anyhow::anyhow!("Failed to create Screencast proxy: {e}")
})?;
let session = proxy
.create_session(Default::default())
.await
.map_err(|e| anyhow::anyhow!("Failed to create ScreenCast session: {e}"))?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.map_err(|e| {
anyhow::anyhow!("屏幕共享权限被拒绝 / Screen sharing permission denied: {e}")
})?;
let response = proxy
.start(&session, None, Default::default())
.await
.map_err(|e| anyhow::anyhow!("ScreenCast start failed: {e}"))?
.response()
.map_err(|e| anyhow::anyhow!("ScreenCast response error: {e}"))?;
let stream = response
.streams()
.first()
.ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?;
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.map_err(|e| anyhow::anyhow!("Failed to open PipeWire remote: {e}"))?;
tracing::info!("Portal session established: node_id={node_id}");
Ok((fd, node_id))
}
}
impl Drop for CapPortal {
fn drop(&mut self) {
let _ = self.cmd_tx.send(PwCmd::Shutdown);
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}
}
}
fn pipewire_thread(ctx: PwThreadCtx) {
use pipewire as pw;
use pw::properties::properties;
use pw::stream::{StreamBox, StreamFlags};
use std::cell::Cell;
use std::rc::Rc;
use pw::spa::param::video::VideoInfoRaw;
pw::init();
let PwThreadCtx {
frame_tx,
cmd_rx,
pw_fd,
node_id,
fps: _fps,
} = ctx;
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("MainLoop::new failed: {e}")));
return;
}
};
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
Ok(c) => c,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("Context::new failed: {e}")));
return;
}
};
let core = match context.connect_fd(pw_fd, None) {
Ok(c) => c,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("connect_fd failed: {e}")));
return;
}
};
let stream = match StreamBox::new(
&core,
"wl-webrtc",
properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
},
) {
Ok(s) => s,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("Stream::new failed: {e}")));
return;
}
};
// Shared format state: (width, height, drm_fourcc, modifier)
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> =
Rc::new(Cell::new(None));
let frame_tx_clone = frame_tx.clone();
let _listener = stream
.add_local_listener::<()>()
.state_changed(move |_, _, old, new| {
tracing::debug!("PipeWire stream state: {old:?} -> {new:?}");
match new {
pw::stream::StreamState::Error(_)
| pw::stream::StreamState::Unconnected => {
let _ = frame_tx_clone.send(PwEvent::StreamEnded);
}
_ => {}
}
})
.param_changed({
let format_info = format_info.clone();
move |_, _, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = VideoInfoRaw::new();
if let Err(e) = info.parse(param) {
tracing::warn!("Failed to parse video format: {e}");
return;
}
let width = info.size().width;
let height = info.size().height;
let drm_format = spa_to_drm_fourcc(info.format());
let modifier = info.modifier();
format_info.set(Some((width, height, drm_format, modifier)));
tracing::info!(
"PipeWire format negotiated: {width}x{height}, \
drm_format={drm_format:#010x}, modifier={modifier:#x}"
);
}
})
.process({
let format_info = format_info.clone();
let frame_tx = frame_tx.clone();
move |stream, _| {
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
if raw_buf.is_null() {
return;
}
let spa_buf = unsafe { (*raw_buf).buffer };
if spa_buf.is_null() {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let n_datas = unsafe { (*spa_buf).n_datas };
let datas_ptr = unsafe { (*spa_buf).datas };
if n_datas == 0 || datas_ptr.is_null() {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// Access first data item through libspa Data wrapper
let data_ref: &pw::spa::buffer::Data = unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
let fd = data_ref.fd();
if fd < 0 {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let chunk = data_ref.chunk();
let offset = chunk.offset() as u64;
let stride = chunk.stride() as u32;
// Get PTS from SPA_META_Header metadata
let pts: i64 = unsafe {
let mut pts_val: i64 = 0;
let n_metas = (*spa_buf).n_metas;
let metas = (*spa_buf).metas;
if !metas.is_null() {
for i in 0..n_metas {
let meta = &*metas.add(i as usize);
if meta.type_ == libspa::sys::SPA_META_Header
&& meta.size as usize >= std::mem::size_of::<libspa::sys::spa_meta_header>()
&& !meta.data.is_null()
{
let header = &*(meta.data as *const libspa::sys::spa_meta_header);
pts_val = header.pts;
break;
}
}
}
pts_val
};
let dup_fd = unsafe { libc::dup(fd) };
if dup_fd < 0 {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let (width, height, format, modifier) =
format_info.get().unwrap_or((0, 0, 0, 0));
let frame = PwDmaBufFrame {
fd: unsafe { OwnedFd::from_raw_fd(dup_fd) },
offset,
stride,
modifier,
width,
height,
format,
pts,
};
let _ = frame_tx.send(PwEvent::Frame(frame));
unsafe { stream.queue_raw_buffer(raw_buf) };
}
})
.register();
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
pw::spa::utils::Direction::Input,
Some(node_id),
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
&mut params,
) {
let _ = frame_tx.send(PwEvent::Error(format!("stream.connect failed: {e}")));
return;
}
let loop_ = mainloop.loop_();
loop_.add_signal_local(
pw::loop_::Signal::SIGINT,
Box::new(|| {}),
);
loop_.add_signal_local(
pw::loop_::Signal::SIGTERM,
Box::new(|| {}),
);
// Store raw pointer as usize so it is Send-safe across threads.
// PipeWire's pw_main_loop_quit is thread-safe by design.
let mainloop_ptr = mainloop.as_raw_ptr() as usize;
let cmd_rx_moved = cmd_rx;
std::thread::spawn(move || {
let _ = cmd_rx_moved.recv();
// SAFETY: mainloop is still alive on the pipewire thread while we wait
// for cmd_rx, and quit() is thread-safe in PipeWire C API.
unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr as *mut _) };
});
mainloop.run();
// SAFETY: pipewire has been initialized with pw::init() above and all
// PipeWire resources (mainloop, stream) have been dropped.
unsafe { pw::deinit() };
}
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 {
use libspa::param::video::VideoFormat;
match format {
VideoFormat::BGRA => fourcc(b'B', b'G', b'R', b'A'),
VideoFormat::BGRx => fourcc(b'B', b'G', b'R', b'X'),
VideoFormat::RGBA => fourcc(b'R', b'G', b'B', b'A'),
VideoFormat::RGBx => fourcc(b'R', b'G', b'B', b'X'),
VideoFormat::ARGB => fourcc(b'A', b'R', b'2', b'4'),
VideoFormat::xRGB => fourcc(b'X', b'R', b'2', b'4'),
VideoFormat::ABGR => fourcc(b'A', b'B', b'2', b'4'),
VideoFormat::xBGR => fourcc(b'X', b'B', b'2', b'4'),
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spa_to_drm_fourcc_bgra() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::BGRA), fourcc(b'B', b'G', b'R', b'A'));
}
#[test]
fn spa_to_drm_fourcc_rgba() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::RGBA), fourcc(b'R', b'G', b'B', b'A'));
}
#[test]
fn spa_to_drm_fourcc_unknown_returns_zero() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::Unknown), 0);
}
#[test]
fn fourcc_encoding() {
assert_eq!(fourcc(b'B', b'G', b'R', b'A'), 0x41524742);
}
}

View File

@@ -9,9 +9,12 @@ use wayland_client::Connection;
mod args;
mod avhw;
mod backend_detect;
mod cap_portal;
mod cap_wlr_screencopy;
mod fps_limit;
mod state;
mod state_portal;
mod transform;
use crate::args::Args;
@@ -40,6 +43,19 @@ fn main() -> Result<()> {
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
}
let backend = crate::backend_detect::detect_backend(&args)?;
match backend {
crate::backend_detect::CaptureBackend::WlrScreencopy => {
run_wlr_screencopy(args)
}
crate::backend_detect::CaptureBackend::PortalPipeWire => {
run_portal_pipewire(args)
}
}
}
fn run_wlr_screencopy(args: Args) -> Result<()> {
// Connect to Wayland compositor
let conn = Connection::connect_to_env()?;
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
@@ -158,3 +174,58 @@ fn main() -> Result<()> {
tracing::info!("Done");
Ok(())
}
fn run_portal_pipewire(args: Args) -> Result<()> {
use crate::state_portal::StatePortal;
tracing::info!("Using Portal/PipeWire backend (KWin/KDE/GNOME)");
let mut state = StatePortal::new(args)?;
// Set up signal handling only (no Wayland fd needed)
let mut signals = signal_hook_mio::v1_0::Signals::new(&[
signal_hook::consts::SIGINT,
signal_hook::consts::SIGTERM,
])?;
let mut poll = mio::Poll::new()?;
let mut events = mio::Events::with_capacity(8);
poll.registry().register(
&mut signals,
mio::Token(1),
mio::Interest::READABLE,
)?;
let mut running = true;
while running {
poll.poll(&mut events, Some(std::time::Duration::from_millis(10)))
.unwrap_or_else(|e| {
if e.kind() == std::io::ErrorKind::Interrupted {
return;
}
tracing::error!("poll failed: {e}");
running = false;
});
for event in &events {
if event.token() == mio::Token(1) {
tracing::info!("Received quit signal");
running = false;
}
}
// Process all available PipeWire frames
while state.poll_and_encode()? {}
if state.is_errored() {
tracing::error!("Fatal error in portal state machine, exiting");
running = false;
}
}
tracing::info!("Shutting down...");
state.shutdown();
tracing::info!("Done");
Ok(())
}

View File

@@ -465,6 +465,8 @@ impl<S: CaptureSource> State<S> {
let fd_dup = unsafe { libc::dup(obj.fd) };
if fd_dup < 0 {
tracing::error!("failed to dup dma-buf fd");
// wayland-client does not auto-destroy params on Drop.
params.destroy();
self.errored = true;
return;
}
@@ -553,8 +555,27 @@ impl<S: CaptureSource> State<S> {
}
}
pub fn on_copy_fail(&mut self) {
pub fn on_copy_fail(&mut self)
where
S::Frame: Default,
{
tracing::error!("compositor copy failed");
let taken = mem::replace(&mut self.in_flight_surface, InFlightSurface::None);
match taken {
InFlightSurface::CopyQueued {
buffer,
frame,
..
} => {
drop(buffer);
if let EncConstructionStage::Streaming { cap, .. } = &mut self.stage {
cap.on_done_with_frame(frame);
}
}
other => {
self.in_flight_surface = other;
}
}
self.errored = true;
}
@@ -576,25 +597,19 @@ impl<S: CaptureSource> State<S> {
};
let (output_info, output, cap, screencopy_manager, dmabuf) = stage_data;
let drm_path = self.resolve_drm_path();
let bitrate = self.args.bitrate.unwrap_or_else(|| {
let fps = self.args.fps as u64;
2 * (width as u64) * (height as u64) * fps / 100
});
let gop_size = self.args.gop_size.unwrap_or(self.args.fps);
let fps = self.args.fps;
let (enc_w, enc_h) =
transpose_if_transform_transposed(output_info.transform, width as i32, height as i32);
let enc = match EncState::new(
let bitrate = self.args.bitrate.unwrap_or_else(|| {
2 * (width as u64) * (height as u64) * (fps as u64) / 100
});
let enc = match crate::avhw::create_encoder(
&drm_path,
Path::new(&self.args.output),
width,
height,
enc_w as u32,
enc_h as u32,
bitrate,
gop_size,
fps,
output_info.transform,
self.args.bitrate,
self.args.gop_size,
) {
Ok(enc) => enc,
Err(e) => {
@@ -1175,21 +1190,24 @@ impl<S: CaptureSource> Dispatch<ZwpLinuxBufferParamsV1, ()> for State<S> {
}
BufferParamsEvent::Failed => {
tracing::error!("DMA-BUF buffer creation failed");
state.errored = true;
match mem::replace(&mut state.in_flight_surface, InFlightSurface::None) {
let taken = mem::replace(&mut state.in_flight_surface, InFlightSurface::None);
match taken {
InFlightSurface::CopyQueued {
surface: _,
drm_map: _,
frame: _,
buffer,
frame,
..
} => {
drop(buffer);
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
cap.on_done_with_frame(frame);
}
}
other => {
state.in_flight_surface = other;
}
}
proxy.destroy();
state.errored = true;
}
_ => {}
}

306
src/state_portal.rs Normal file
View File

@@ -0,0 +1,306 @@
use std::mem;
use std::path::PathBuf;
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use crate::args::Args;
use crate::avhw::{self, EncState};
use crate::cap_portal::{CapPortal, PwDmaBufFrame, PwEvent};
use crate::fps_limit::FpsLimit;
use crate::transform::Transform;
enum PortalStage {
WaitingForFormat,
Streaming,
}
pub struct StatePortal {
stage: PortalStage,
enc: Option<EncState>,
fps_limit: FpsLimit<()>,
cap: CapPortal,
args: Args,
errored: bool,
first_frame: bool,
drm_device: PathBuf,
}
impl StatePortal {
pub fn new(args: Args) -> Result<Self> {
let drm_device = resolve_drm_device(&args)?;
tracing::info!("Using DRM device: {}", drm_device.display());
let cap = CapPortal::new(&args)?;
Ok(Self {
stage: PortalStage::WaitingForFormat,
enc: None,
fps_limit: FpsLimit::new(args.fps),
cap,
args,
errored: false,
first_frame: true,
drm_device,
})
}
pub fn poll_and_encode(&mut self) -> Result<bool> {
let event = match self.cap.frame_receiver().try_recv() {
Ok(event) => event,
Err(_) => return Ok(false),
};
match event {
PwEvent::Frame(frame) => {
match self.stage {
PortalStage::WaitingForFormat => {
tracing::info!(
"First DMA-BUF frame: {}x{} format=0x{:08X} stride={} modifier=0x{:X}",
frame.width,
frame.height,
frame.format,
frame.stride,
frame.modifier
);
let enc = avhw::create_encoder(
&self.drm_device,
self.args.output.as_ref(),
frame.width,
frame.height,
self.args.fps,
Transform::Normal,
self.args.bitrate,
self.args.gop_size,
)?;
self.enc = Some(enc);
self.stage = PortalStage::Streaming;
drop(frame);
}
PortalStage::Streaming => {
self.handle_pw_frame(frame)?;
}
}
}
PwEvent::StreamEnded => {
tracing::warn!("PipeWire stream ended");
self.errored = true;
}
PwEvent::Error(e) => {
tracing::error!("PipeWire error: {e}");
self.errored = true;
}
}
Ok(true)
}
fn handle_pw_frame(&mut self, frame: PwDmaBufFrame) -> Result<()> {
// 1. FPS limiting (first frame bypasses)
if self.first_frame {
self.first_frame = false;
} else {
let now = std::time::Instant::now();
if self.fps_limit.on_new_frame((), now).is_none() {
return Ok(());
}
}
// 2. Build DRM descriptor for DMA-BUF import
let desc = build_drm_descriptor(&frame);
let desc_box = Box::new(desc);
// 3. Allocate raw DRM_PRIME source frame using Video wrapper
let mut raw_frame = ff::frame::Video::empty();
unsafe {
let raw_ptr = raw_frame.as_mut_ptr();
(*raw_ptr).data[0] = Box::into_raw(desc_box) as *mut u8;
(*raw_ptr).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
(*raw_ptr).width = frame.width as i32;
(*raw_ptr).height = frame.height as i32;
}
// 4. Get encoder reference
let enc = match self.enc.as_mut() {
Some(e) => e,
None => {
// Recover the Box to prevent memory leak of the descriptor
unsafe {
let desc_ptr = (*raw_frame.as_ptr()).data[0] as *mut ffi::AVDRMFrameDescriptor;
if !desc_ptr.is_null() {
let _ = Box::from_raw(desc_ptr);
}
}
bail!("encoder not initialized");
}
};
// 5. Allocate VAAPI hardware target frame
let mut hw_frame = ff::frame::Video::empty();
let ret = unsafe {
ffi::av_hwframe_get_buffer(enc.frames_rgb().as_ptr(), hw_frame.as_mut_ptr(), 0)
};
if ret < 0 {
// Recover the Box to prevent memory leak of the descriptor
unsafe {
let desc_ptr = (*raw_frame.as_ptr()).data[0] as *mut ffi::AVDRMFrameDescriptor;
if !desc_ptr.is_null() {
let _ = Box::from_raw(desc_ptr);
}
}
bail!("av_hwframe_get_buffer failed: error {ret}");
}
// 6. Import DMA-BUF into VAAPI via transfer_data
let ret = unsafe {
ffi::av_hwframe_transfer_data(hw_frame.as_mut_ptr(), raw_frame.as_ptr(), 0)
};
if ret < 0 {
unsafe {
let desc_ptr = (*raw_frame.as_ptr()).data[0] as *mut ffi::AVDRMFrameDescriptor;
if !desc_ptr.is_null() {
let _ = Box::from_raw(desc_ptr);
}
}
if ret == -(ffi::EINVAL as i32) {
bail!(
"VAAPI does not support DMA-BUF modifier 0x{:X}",
frame.modifier
);
}
bail!("av_hwframe_transfer_data failed: error {ret}");
}
// 7. Set PTS
unsafe {
(*hw_frame.as_mut_ptr()).pts = frame.pts;
}
// 8. Encode
enc.encode_frame(&hw_frame)?;
// 9. Clean up: recover the Boxed descriptor from raw_frame to prevent leak.
// Video::drop calls av_frame_free which does NOT free data[0].
unsafe {
let desc_ptr = (*raw_frame.as_ptr()).data[0] as *mut ffi::AVDRMFrameDescriptor;
if !desc_ptr.is_null() {
let _ = Box::from_raw(desc_ptr);
}
}
// raw_frame and hw_frame drop here via Video::drop → av_frame_free
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
if let Some(enc) = &mut self.enc {
enc.flush()?;
}
Ok(())
}
pub fn shutdown(&mut self) {
if let Err(e) = self.flush() {
tracing::error!("Flush error during shutdown: {e}");
}
tracing::info!("StatePortal shutdown complete");
}
pub fn is_errored(&self) -> bool {
self.errored
}
}
fn build_drm_descriptor(frame: &PwDmaBufFrame) -> ffi::AVDRMFrameDescriptor {
let mut desc: ffi::AVDRMFrameDescriptor = unsafe { mem::zeroed() };
desc.nb_objects = 1;
desc.objects[0].fd = frame.fd.as_raw_fd();
desc.objects[0].size = 0;
desc.objects[0].format_modifier = frame.modifier;
desc.nb_layers = 1;
desc.layers[0].format = frame.format;
desc.layers[0].nb_planes = 1;
desc.layers[0].planes[0].object_index = 0;
desc.layers[0].planes[0].offset = frame.offset as isize;
desc.layers[0].planes[0].pitch = frame.stride as isize;
desc
}
use std::os::fd::AsRawFd;
fn resolve_drm_device(args: &Args) -> Result<PathBuf> {
if let Some(ref drm) = args.drm_device {
return Ok(PathBuf::from(drm));
}
for render in &["/dev/dri/renderD128", "/dev/dri/renderD129"] {
let path = PathBuf::from(render);
if path.exists() {
return Ok(path);
}
}
bail!("No DRM render device found. Specify --drm-device.")
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::{FromRawFd, OwnedFd};
fn make_test_frame() -> PwDmaBufFrame {
// Create a dummy fd from stderr (always valid fd 2)
let fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
PwDmaBufFrame {
fd,
offset: 0,
stride: 1920 * 4,
modifier: 0, // DRM_FORMAT_MOD_LINEAR
width: 1920,
height: 1080,
format: 0x34325258, // XR24 little-endian
pts: 12345,
}
}
#[test]
fn build_drm_descriptor_single_plane() {
let frame = make_test_frame();
let desc = build_drm_descriptor(&frame);
assert_eq!(desc.nb_objects, 1);
assert_eq!(desc.objects[0].format_modifier, 0);
assert_eq!(desc.nb_layers, 1);
assert_eq!(desc.layers[0].format, 0x34325258);
assert_eq!(desc.layers[0].nb_planes, 1);
assert_eq!(desc.layers[0].planes[0].object_index, 0);
assert_eq!(desc.layers[0].planes[0].offset, 0);
assert_eq!(desc.layers[0].planes[0].pitch, 1920 * 4);
}
#[test]
fn resolve_drm_device_explicit() {
let args = Args {
output: "test.mp4".to_string(),
output_name: None,
fps: 30,
codec: "h264".to_string(),
hw_accel: "vaapi".to_string(),
drm_device: Some("/dev/dri/renderD128".to_string()),
bitrate: None,
gop_size: None,
verbose: false,
backend: None,
port: 0,
};
let result = resolve_drm_device(&args);
assert!(result.is_ok());
assert_eq!(result.unwrap(), std::path::PathBuf::from("/dev/dri/renderD128"));
}
}

View File

@@ -56,12 +56,7 @@ pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
/// new_x = a * x + b * y + offset_x
/// new_y = c * x + d * y + offset_y
/// ```
pub fn screen_to_frame(
transform: Transform,
rect: Rect,
frame_w: i32,
frame_h: i32,
) -> Rect {
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
let (a, b, c, d) = transform_basis(transform);
// Compute the offset so that the transformed origin maps correctly.
@@ -90,9 +85,10 @@ pub fn screen_to_frame(
/// and `(w, h)` unchanged otherwise.
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
match transform {
Transform::Normal90 | Transform::Normal270 | Transform::Flipped90 | Transform::Flipped270 => {
(h, w)
}
Transform::Normal90
| Transform::Normal270
| Transform::Flipped90
| Transform::Flipped270 => (h, w),
_ => (w, h),
}
}
@@ -161,15 +157,33 @@ mod tests {
#[test]
fn screen_to_frame_identity_unchanged() {
let rect = Rect { x: 10, y: 20, w: 100, h: 50 };
let rect = Rect {
x: 10,
y: 20,
w: 100,
h: 50,
};
let result = screen_to_frame(Transform::Normal, rect, 1920, 1080);
assert_eq!(result, Rect { x: 10, y: 20, w: 100, h: 50 });
assert_eq!(
result,
Rect {
x: 10,
y: 20,
w: 100,
h: 50
}
);
}
#[test]
fn screen_to_frame_90_rotates_origin() {
// 90° CW: top-left (0,0) in screen should map to bottom-left in frame
let rect = Rect { x: 0, y: 0, w: 100, h: 50 };
let rect = Rect {
x: 0,
y: 0,
w: 100,
h: 50,
};
let result = screen_to_frame(Transform::Normal90, rect, 1080, 1920);
// a=0,b=1,c=-1,d=0 => offset_x=0, offset_y=1920 (c+d=-1<0)
// new_x = 0*0 + 1*0 + 0 = 0
@@ -183,7 +197,12 @@ mod tests {
#[test]
fn screen_to_frame_180_rotates() {
let rect = Rect { x: 100, y: 200, w: 300, h: 400 };
let rect = Rect {
x: 100,
y: 200,
w: 300,
h: 400,
};
let result = screen_to_frame(Transform::Normal180, rect, 1920, 1080);
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
assert_eq!(result.x, -100 + 1920);
@@ -194,7 +213,12 @@ mod tests {
#[test]
fn screen_to_frame_flipped_horizontal() {
let rect = Rect { x: 50, y: 30, w: 200, h: 100 };
let rect = Rect {
x: 50,
y: 30,
w: 200,
h: 100,
};
let result = screen_to_frame(Transform::Flipped, rect, 1920, 1080);
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
assert_eq!(result.x, -50 + 1920);
@@ -207,85 +231,179 @@ mod tests {
#[test]
fn transpose_normal_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal, 1920, 1080), (1920, 1080));
assert_eq!(
transpose_if_transform_transposed(Transform::Normal, 1920, 1080),
(1920, 1080)
);
}
#[test]
fn transpose_90_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal90, 1920, 1080), (1080, 1920));
assert_eq!(
transpose_if_transform_transposed(Transform::Normal90, 1920, 1080),
(1080, 1920)
);
}
#[test]
fn transpose_180_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal180, 1920, 1080), (1920, 1080));
assert_eq!(
transpose_if_transform_transposed(Transform::Normal180, 1920, 1080),
(1920, 1080)
);
}
#[test]
fn transpose_270_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Normal270, 1920, 1080), (1080, 1920));
assert_eq!(
transpose_if_transform_transposed(Transform::Normal270, 1920, 1080),
(1080, 1920)
);
}
#[test]
fn transpose_flipped_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped, 1920, 1080), (1920, 1080));
assert_eq!(
transpose_if_transform_transposed(Transform::Flipped, 1920, 1080),
(1920, 1080)
);
}
#[test]
fn transpose_flipped90_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped90, 1920, 1080), (1080, 1920));
assert_eq!(
transpose_if_transform_transposed(Transform::Flipped90, 1920, 1080),
(1080, 1920)
);
}
#[test]
fn transpose_flipped180_no_swap() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped180, 1920, 1080), (1920, 1080));
assert_eq!(
transpose_if_transform_transposed(Transform::Flipped180, 1920, 1080),
(1920, 1080)
);
}
#[test]
fn transpose_flipped270_swaps() {
assert_eq!(transpose_if_transform_transposed(Transform::Flipped270, 1920, 1080), (1080, 1920));
assert_eq!(
transpose_if_transform_transposed(Transform::Flipped270, 1920, 1080),
(1080, 1920)
);
}
// ── fit_inside_bounds ─────────────────────────────────────────
#[test]
fn fit_inside_already_fits() {
let rect = Rect { x: 10, y: 20, w: 100, h: 50 };
let rect = Rect {
x: 10,
y: 20,
w: 100,
h: 50,
};
let result = fit_inside_bounds(rect, 1920, 1080);
assert_eq!(result, rect);
}
#[test]
fn fit_inside_clips_right_and_bottom() {
let rect = Rect { x: 1800, y: 1000, w: 200, h: 200 };
let rect = Rect {
x: 1800,
y: 1000,
w: 200,
h: 200,
};
let result = fit_inside_bounds(rect, 1920, 1080);
assert_eq!(result, Rect { x: 1800, y: 1000, w: 120, h: 80 });
assert_eq!(
result,
Rect {
x: 1800,
y: 1000,
w: 120,
h: 80
}
);
}
#[test]
fn fit_inside_clips_negative_origin() {
let rect = Rect { x: -50, y: -30, w: 200, h: 200 };
let rect = Rect {
x: -50,
y: -30,
w: 200,
h: 200,
};
let result = fit_inside_bounds(rect, 1920, 1080);
assert_eq!(result, Rect { x: 0, y: 0, w: 150, h: 170 });
assert_eq!(
result,
Rect {
x: 0,
y: 0,
w: 150,
h: 170
}
);
}
#[test]
fn fit_inside_completely_out_of_bounds() {
let rect = Rect { x: 2000, y: 2000, w: 100, h: 100 };
let rect = Rect {
x: 2000,
y: 2000,
w: 100,
h: 100,
};
let result = fit_inside_bounds(rect, 1920, 1080);
assert_eq!(result, Rect { x: 1920, y: 1080, w: 0, h: 0 });
assert_eq!(
result,
Rect {
x: 1920,
y: 1080,
w: 0,
h: 0
}
);
}
#[test]
fn fit_inside_zero_size_rect() {
let rect = Rect { x: 100, y: 100, w: 0, h: 0 };
let rect = Rect {
x: 100,
y: 100,
w: 0,
h: 0,
};
let result = fit_inside_bounds(rect, 1920, 1080);
assert_eq!(result, Rect { x: 100, y: 100, w: 0, h: 0 });
assert_eq!(
result,
Rect {
x: 100,
y: 100,
w: 0,
h: 0
}
);
}
#[test]
fn fit_inside_zero_bounds() {
let rect = Rect { x: 0, y: 0, w: 100, h: 100 };
let rect = Rect {
x: 0,
y: 0,
w: 100,
h: 100,
};
let result = fit_inside_bounds(rect, 0, 0);
assert_eq!(result, Rect { x: 0, y: 0, w: 0, h: 0 });
assert_eq!(
result,
Rect {
x: 0,
y: 0,
w: 0,
h: 0
}
);
}
}

View File

@@ -44,10 +44,7 @@ fn test_rejects_invalid_args() {
.output()
.expect("failed to execute wl-webrtc with invalid args");
assert!(
!output.status.success(),
"should reject unrecognized flag"
);
assert!(!output.status.success(), "should reject unrecognized flag");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.to_lowercase().contains("error")
@@ -68,10 +65,7 @@ fn test_rejects_hevc_codec() {
.expect("failed to execute wl-webrtc --codec hevc");
// MVP only supports h264; hevc should be rejected.
assert!(
!output.status.success(),
"should reject hevc codec in MVP"
);
assert!(!output.status.success(), "should reject hevc codec in MVP");
}
/// Tests requiring a live Wayland compositor and VAAPI hardware.