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
|
Thumbs.db
|
||||||
|
|
||||||
# Sisyphus orchestration artifacts
|
# 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 = "0.12"
|
||||||
drm-fourcc = "2"
|
drm-fourcc = "2"
|
||||||
libc = "0.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)]
|
#[arg(short, long)]
|
||||||
pub verbose: bool,
|
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)
|
/// Port for WebTransport server (Phase 2, unused in MVP)
|
||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
pub port: u16,
|
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::ffi;
|
||||||
use ffmpeg_next::packet::Mut as _;
|
use ffmpeg_next::packet::Mut as _;
|
||||||
|
|
||||||
use crate::transform::Transform;
|
use crate::transform::{transpose_if_transform_transposed, Transform};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AvHwDevCtx
|
// 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)
|
// Filter graph (inline)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -535,31 +575,30 @@ fn build_filter_graph(
|
|||||||
src_ctx.link(0, &mut scale_ctx, 0);
|
src_ctx.link(0, &mut scale_ctx, 0);
|
||||||
|
|
||||||
match transform {
|
match transform {
|
||||||
Transform::Normal90 | Transform::Normal270 => {
|
Transform::Normal => {
|
||||||
|
scale_ctx.link(0, &mut sink_ctx, 0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
let transpose = ff::filter::find("transpose_vaapi")
|
let transpose = ff::filter::find("transpose_vaapi")
|
||||||
.ok_or_else(|| anyhow::anyhow!("filter 'transpose_vaapi' not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("filter 'transpose_vaapi' not found"))?;
|
||||||
let dir_val = match transform {
|
let dir_val = match other {
|
||||||
Transform::Normal90 => "1",
|
Transform::Normal90 => "1",
|
||||||
|
Transform::Normal180 => "4",
|
||||||
Transform::Normal270 => "2",
|
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}"))?;
|
let mut trans_ctx =
|
||||||
// SAFETY: transpose_vaapi needs hw_device_ctx for VAAPI device access.
|
graph.add(&transpose, "transpose", &format!("dir={dir_val}"))?;
|
||||||
unsafe {
|
unsafe {
|
||||||
(*trans_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
(*trans_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone();
|
||||||
}
|
}
|
||||||
scale_ctx.link(0, &mut trans_ctx, 0);
|
scale_ctx.link(0, &mut trans_ctx, 0);
|
||||||
trans_ctx.link(0, &mut sink_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
|
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 args;
|
||||||
mod avhw;
|
mod avhw;
|
||||||
|
mod backend_detect;
|
||||||
|
mod cap_portal;
|
||||||
mod cap_wlr_screencopy;
|
mod cap_wlr_screencopy;
|
||||||
mod fps_limit;
|
mod fps_limit;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod state_portal;
|
||||||
mod transform;
|
mod transform;
|
||||||
|
|
||||||
use crate::args::Args;
|
use crate::args::Args;
|
||||||
@@ -40,6 +43,19 @@ fn main() -> Result<()> {
|
|||||||
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
|
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
|
// Connect to Wayland compositor
|
||||||
let conn = Connection::connect_to_env()?;
|
let conn = Connection::connect_to_env()?;
|
||||||
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
|
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
|
||||||
@@ -158,3 +174,58 @@ fn main() -> Result<()> {
|
|||||||
tracing::info!("Done");
|
tracing::info!("Done");
|
||||||
Ok(())
|
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) };
|
let fd_dup = unsafe { libc::dup(obj.fd) };
|
||||||
if fd_dup < 0 {
|
if fd_dup < 0 {
|
||||||
tracing::error!("failed to dup dma-buf fd");
|
tracing::error!("failed to dup dma-buf fd");
|
||||||
|
// wayland-client does not auto-destroy params on Drop.
|
||||||
|
params.destroy();
|
||||||
self.errored = true;
|
self.errored = true;
|
||||||
return;
|
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");
|
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;
|
self.errored = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,25 +597,19 @@ impl<S: CaptureSource> State<S> {
|
|||||||
};
|
};
|
||||||
let (output_info, output, cap, screencopy_manager, dmabuf) = stage_data;
|
let (output_info, output, cap, screencopy_manager, dmabuf) = stage_data;
|
||||||
let drm_path = self.resolve_drm_path();
|
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 fps = self.args.fps;
|
||||||
let (enc_w, enc_h) =
|
let bitrate = self.args.bitrate.unwrap_or_else(|| {
|
||||||
transpose_if_transform_transposed(output_info.transform, width as i32, height as i32);
|
2 * (width as u64) * (height as u64) * (fps as u64) / 100
|
||||||
let enc = match EncState::new(
|
});
|
||||||
|
let enc = match crate::avhw::create_encoder(
|
||||||
&drm_path,
|
&drm_path,
|
||||||
Path::new(&self.args.output),
|
Path::new(&self.args.output),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
enc_w as u32,
|
|
||||||
enc_h as u32,
|
|
||||||
bitrate,
|
|
||||||
gop_size,
|
|
||||||
fps,
|
fps,
|
||||||
output_info.transform,
|
output_info.transform,
|
||||||
|
self.args.bitrate,
|
||||||
|
self.args.gop_size,
|
||||||
) {
|
) {
|
||||||
Ok(enc) => enc,
|
Ok(enc) => enc,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1175,21 +1190,24 @@ impl<S: CaptureSource> Dispatch<ZwpLinuxBufferParamsV1, ()> for State<S> {
|
|||||||
}
|
}
|
||||||
BufferParamsEvent::Failed => {
|
BufferParamsEvent::Failed => {
|
||||||
tracing::error!("DMA-BUF buffer creation failed");
|
tracing::error!("DMA-BUF buffer creation failed");
|
||||||
state.errored = true;
|
let taken = mem::replace(&mut state.in_flight_surface, InFlightSurface::None);
|
||||||
match mem::replace(&mut state.in_flight_surface, InFlightSurface::None) {
|
match taken {
|
||||||
InFlightSurface::CopyQueued {
|
InFlightSurface::CopyQueued {
|
||||||
surface: _,
|
|
||||||
drm_map: _,
|
|
||||||
frame: _,
|
|
||||||
buffer,
|
buffer,
|
||||||
|
frame,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
drop(buffer);
|
drop(buffer);
|
||||||
|
if let EncConstructionStage::Streaming { cap, .. } = &mut state.stage {
|
||||||
|
cap.on_done_with_frame(frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
state.in_flight_surface = other;
|
state.in_flight_surface = other;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy.destroy();
|
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_x = a * x + b * y + offset_x
|
||||||
/// new_y = c * x + d * y + offset_y
|
/// new_y = c * x + d * y + offset_y
|
||||||
/// ```
|
/// ```
|
||||||
pub fn screen_to_frame(
|
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
|
||||||
transform: Transform,
|
|
||||||
rect: Rect,
|
|
||||||
frame_w: i32,
|
|
||||||
frame_h: i32,
|
|
||||||
) -> Rect {
|
|
||||||
let (a, b, c, d) = transform_basis(transform);
|
let (a, b, c, d) = transform_basis(transform);
|
||||||
|
|
||||||
// Compute the offset so that the transformed origin maps correctly.
|
// Compute the offset so that the transformed origin maps correctly.
|
||||||
@@ -90,9 +85,10 @@ pub fn screen_to_frame(
|
|||||||
/// and `(w, h)` unchanged otherwise.
|
/// and `(w, h)` unchanged otherwise.
|
||||||
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
|
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
|
||||||
match transform {
|
match transform {
|
||||||
Transform::Normal90 | Transform::Normal270 | Transform::Flipped90 | Transform::Flipped270 => {
|
Transform::Normal90
|
||||||
(h, w)
|
| Transform::Normal270
|
||||||
}
|
| Transform::Flipped90
|
||||||
|
| Transform::Flipped270 => (h, w),
|
||||||
_ => (w, h),
|
_ => (w, h),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,15 +157,33 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn screen_to_frame_identity_unchanged() {
|
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);
|
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]
|
#[test]
|
||||||
fn screen_to_frame_90_rotates_origin() {
|
fn screen_to_frame_90_rotates_origin() {
|
||||||
// 90° CW: top-left (0,0) in screen should map to bottom-left in frame
|
// 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);
|
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)
|
// a=0,b=1,c=-1,d=0 => offset_x=0, offset_y=1920 (c+d=-1<0)
|
||||||
// new_x = 0*0 + 1*0 + 0 = 0
|
// new_x = 0*0 + 1*0 + 0 = 0
|
||||||
@@ -183,7 +197,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn screen_to_frame_180_rotates() {
|
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);
|
let result = screen_to_frame(Transform::Normal180, rect, 1920, 1080);
|
||||||
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
|
// a=-1,b=0,c=0,d=-1, offset_x=1920, offset_y=1080
|
||||||
assert_eq!(result.x, -100 + 1920);
|
assert_eq!(result.x, -100 + 1920);
|
||||||
@@ -194,7 +213,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn screen_to_frame_flipped_horizontal() {
|
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);
|
let result = screen_to_frame(Transform::Flipped, rect, 1920, 1080);
|
||||||
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
|
// a=-1,b=0,c=0,d=1, offset_x=1920, offset_y=0
|
||||||
assert_eq!(result.x, -50 + 1920);
|
assert_eq!(result.x, -50 + 1920);
|
||||||
@@ -207,85 +231,179 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transpose_normal_no_swap() {
|
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]
|
#[test]
|
||||||
fn transpose_90_swaps() {
|
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]
|
#[test]
|
||||||
fn transpose_180_no_swap() {
|
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]
|
#[test]
|
||||||
fn transpose_270_swaps() {
|
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]
|
#[test]
|
||||||
fn transpose_flipped_no_swap() {
|
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]
|
#[test]
|
||||||
fn transpose_flipped90_swaps() {
|
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]
|
#[test]
|
||||||
fn transpose_flipped180_no_swap() {
|
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]
|
#[test]
|
||||||
fn transpose_flipped270_swaps() {
|
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 ─────────────────────────────────────────
|
// ── fit_inside_bounds ─────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_inside_already_fits() {
|
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);
|
let result = fit_inside_bounds(rect, 1920, 1080);
|
||||||
assert_eq!(result, rect);
|
assert_eq!(result, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_inside_clips_right_and_bottom() {
|
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);
|
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]
|
#[test]
|
||||||
fn fit_inside_clips_negative_origin() {
|
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);
|
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]
|
#[test]
|
||||||
fn fit_inside_completely_out_of_bounds() {
|
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);
|
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]
|
#[test]
|
||||||
fn fit_inside_zero_size_rect() {
|
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);
|
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]
|
#[test]
|
||||||
fn fit_inside_zero_bounds() {
|
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);
|
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()
|
.output()
|
||||||
.expect("failed to execute wl-webrtc with invalid args");
|
.expect("failed to execute wl-webrtc with invalid args");
|
||||||
|
|
||||||
assert!(
|
assert!(!output.status.success(), "should reject unrecognized flag");
|
||||||
!output.status.success(),
|
|
||||||
"should reject unrecognized flag"
|
|
||||||
);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.to_lowercase().contains("error")
|
stderr.to_lowercase().contains("error")
|
||||||
@@ -68,10 +65,7 @@ fn test_rejects_hevc_codec() {
|
|||||||
.expect("failed to execute wl-webrtc --codec hevc");
|
.expect("failed to execute wl-webrtc --codec hevc");
|
||||||
|
|
||||||
// MVP only supports h264; hevc should be rejected.
|
// MVP only supports h264; hevc should be rejected.
|
||||||
assert!(
|
assert!(!output.status.success(), "should reject hevc codec in MVP");
|
||||||
!output.status.success(),
|
|
||||||
"should reject hevc codec in MVP"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests requiring a live Wayland compositor and VAAPI hardware.
|
/// Tests requiring a live Wayland compositor and VAAPI hardware.
|
||||||
|
|||||||
Reference in New Issue
Block a user