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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,4 +16,3 @@
|
||||
Thumbs.db
|
||||
|
||||
# Sisyphus orchestration artifacts
|
||||
.sisyphus/
|
||||
|
||||
1028
Cargo.lock
generated
1028
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
src/avhw.rs
69
src/avhw.rs
@@ -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
134
src/backend_detect.rs
Normal 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
411
src/cap_portal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/main.rs
71
src/main.rs
@@ -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(())
|
||||
}
|
||||
|
||||
54
src/state.rs
54
src/state.rs
@@ -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
306
src/state_portal.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
184
src/transform.rs
184
src/transform.rs
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user