From 7369cb7ee04897eba985a46070cc2c5bbf8db6e4 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 5 Oct 2026 03:06:36 +0500 Subject: [PATCH] feat(linux): enable Wayland window/display recording with PipeWire cursor metadata and evdev click capture --- apps/desktop-gpui/src/app_windows.rs | 6 +- apps/desktop-gpui/src/editor_clips.rs | 30 +- apps/desktop-gpui/src/hotkeys.rs | 2 +- apps/desktop-gpui/src/main.rs | 2 +- apps/desktop-gpui/src/platform.rs | 7 +- apps/desktop/src-tauri/src/main.rs | 5 - crates/recording/src/capture_pipeline.rs | 14 +- crates/recording/src/cursor.rs | 125 ++-- crates/recording/src/evdev_input.rs | 301 +++++++++ crates/recording/src/lib.rs | 2 + .../src/sources/screen_capture/linux.rs | 575 +++++++++++++++--- crates/recording/src/studio_recording.rs | 8 +- crates/utils/src/disk_space.rs | 2 +- 13 files changed, 931 insertions(+), 148 deletions(-) create mode 100644 crates/recording/src/evdev_input.rs diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 539e0dbffec..b263da73991 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2794,11 +2794,13 @@ fn clean_capture_supported( camera_requested: bool, wayland: bool, ) -> bool { + if wayland { + return false; + } matches!( target, ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::Area { .. } - ) || (!wayland - && mode == RecordingMode::Instant + ) || (mode == RecordingMode::Instant && camera_requested && matches!(target, ScreenCaptureTarget::Window { .. })) } diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 72f90015b6e..c405a190e54 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -1577,6 +1577,7 @@ impl EditorWindow { self.import_mp4(source, window, cx); return; } + let source = resolve_cap_project_path(&source).unwrap_or(source); let Some(editor) = window.window_handle().downcast::() else { return; }; @@ -2034,7 +2035,10 @@ async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Opt #[cfg(target_os = "linux")] { crate::platform::open_file_panel_async( - &[("Cap Recording", &["cap"])], + &[ + ("Cap Recording", &["cap", "json"]), + ("All Files", &["*"]), + ], Some(crate::recording::recordings_dir()), _cx, ) @@ -2044,7 +2048,7 @@ async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Opt { rfd::FileDialog::new() .set_directory(crate::recording::recordings_dir()) - .add_filter("Cap Recording", &["cap"]) + .add_filter("Cap Recording", &["cap", "json"]) .pick_file() } } @@ -2325,6 +2329,20 @@ fn is_cap_project_path(path: &Path) -> bool { path.is_dir() && path.join("recording-meta.json").is_file() } +/// On Linux, portal file pickers traverse into .cap directory bundles +/// instead of selecting them. This resolves the project root from any selected inner file. +pub(crate) fn resolve_cap_project_path(path: &Path) -> Option { + if is_cap_project_path(path) { + return Some(path.to_path_buf()); + } + for ancestor in path.ancestors() { + if is_cap_project_path(ancestor) { + return Some(ancestor.to_path_buf()); + } + } + None +} + /// `same_project_path` (`import.rs:267-271`). fn same_project_path(a: &Path, b: &Path) -> bool { let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf()); @@ -3257,12 +3275,12 @@ pub(crate) fn append_cap_project_to_editor( target_project_path: &Path, source_path: &Path, ) -> Result { - if same_project_path(target_project_path, source_path) { + let source_path = resolve_cap_project_path(source_path) + .ok_or_else(|| "Select a Cap project folder".to_string())?; + if same_project_path(target_project_path, &source_path) { return Err("Cannot import a recording into itself".to_string()); } - if !is_cap_project_path(source_path) { - return Err("Select a Cap project folder".to_string()); - } + let source_path = source_path.as_path(); let source_meta = RecordingMeta::load_for_project(source_path) .map_err(|e| format!("Failed to load source project metadata: {e}"))?; diff --git a/apps/desktop-gpui/src/hotkeys.rs b/apps/desktop-gpui/src/hotkeys.rs index cee3592439c..14ea6261242 100644 --- a/apps/desktop-gpui/src/hotkeys.rs +++ b/apps/desktop-gpui/src/hotkeys.rs @@ -385,8 +385,8 @@ async fn run_portal_stop( portal.get_property::("version"), ) .await??; - *fallback_safe = false; let session = tokio::time::timeout(Duration::from_secs(5), portal.create_session()).await??; + *fallback_safe = false; let outcome = async { let session_path = serde_json::to_value(&session)?.as_str().ok_or_else(|| anyhow::anyhow!("Invalid shortcut session identity"))?.to_owned(); let shortcut_id = format!("cap-recording-stop-{generation}"); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index aa22b4191f5..6545f4b9a33 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -145,7 +145,7 @@ fn init_logging() -> Option { // matches nothing. let filter = || { tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "cap_gpui=info".into()) + .unwrap_or_else(|_| "cap_gpui=info,cap_recording=info".into()) }; let file = create_log_appender(&diagnostics::logs_dir(), diagnostics::LOG_FILE_PREFIX).map( diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 0ee7f4e0b71..152c318a110 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -286,11 +286,16 @@ async fn linux_open_file_panel( cx: &mut gpui::AsyncApp, ) -> LinuxFileDialogResult { let filters = portal_filters(filters); + run_linux_file_dialog(cx, owner, parent, move |identifier| async move { let request = ashpd::desktop::file_chooser::OpenFileRequest::default() .identifier(identifier) + .title("Select Cap Recording") + .accept_label("Select") .multiple(false) - .filters(filters) + .filters(filters); + + let request = request .current_folder::<&std::path::PathBuf>(directory.as_ref()) .map_err(|error| LinuxFileDialogError::BeforeDispatch(error.to_string()))? .send() diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 16b44238414..eec13a04122 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -45,11 +45,6 @@ fn main() { std::env::set_var("RUST_LOG", "trace"); } - #[cfg(target_os = "linux")] - unsafe { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - // We have to hold onto the ClientInitGuard until the very end let _sentry_guard = std::option_env!("CAP_DESKTOP_SENTRY_URL").map(|url| { // Crashpad minidump initialization is intentionally disabled. Its process-wide SEH diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index c55cf72e2c2..82a537df0ad 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -389,6 +389,11 @@ impl MakeCapturePipeline for screen_capture::X11Capture { .unwrap_or_else(|| output_path.with_file_name("display")); let ultra = quality == StudioQuality::Ultra; + let effective_output_size = if screen_capture.is_window_capture() { + None + } else { + output_size + }; OutputPipeline::builder(fragments_dir) .with_video::(screen_capture) .with_timestamps(start_time) @@ -400,7 +405,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture { } else { H264Preset::Ultrafast }, - output_size, + output_size: effective_output_size, shared_pause_state, segment_tx: None, }) @@ -415,6 +420,11 @@ impl MakeCapturePipeline for screen_capture::X11Capture { start_gate: Option, segment_tx: Option>, ) -> anyhow::Result { + let effective_output_size = if screen_capture.is_window_capture() { + None + } else { + Some(output_size) + }; OutputPipeline::builder(segments_dir) .with_video::(screen_capture) .with_timestamps(start_time) @@ -422,7 +432,7 @@ impl MakeCapturePipeline for screen_capture::X11Capture { .build::(crate::ffmpeg::SegmentedVideoMuxerConfig { segment_duration: std::time::Duration::from_secs(2), preset: H264Preset::Ultrafast, - output_size: Some(output_size), + output_size: effective_output_size, shared_pause_state: None, segment_tx, }) diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 8dba976650e..2c26fc8f1c2 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -146,17 +146,6 @@ impl CursorActor { const CURSOR_FLUSH_INTERVAL_SECS: u64 = 5; -#[cfg(target_os = "linux")] -fn prefers_wayland_portal_cursor() -> bool { - if std::env::var_os("WAYLAND_DISPLAY").is_none() { - return false; - } - - std::env::var_os("DISPLAY").is_none() - || std::env::var("XDG_SESSION_TYPE") - .is_ok_and(|session| session.eq_ignore_ascii_case("wayland")) -} - fn flush_cursor_data(output_path: &Path, moves: &[CursorMoveEvent], clicks: &[CursorClickEvent]) { let events = CursorEvents { clicks: clicks.to_vec(), @@ -315,24 +304,6 @@ pub fn spawn_cursor_recorder( start_gate: Option, incremental_outputs: IncrementalCaptureOutputs, ) -> CursorActor { - #[cfg(target_os = "linux")] - if prefers_wayland_portal_cursor() { - let (tx, rx) = oneshot::channel(); - let _ = tx.send(CursorActorResponse { - cursors: prev_cursors, - next_cursor_id, - moves: vec![], - clicks: vec![], - keyboard_presses: vec![], - }); - return CursorActor { - stop: None, - stop_wakeup: None, - thread: None, - rx: rx.shared(), - }; - } - use device_query::{DeviceQuery, DeviceState}; use sha2::{Digest, Sha256}; use std::time::Duration; @@ -361,9 +332,27 @@ pub fn spawn_cursor_recorder( #[cfg(target_os = "linux")] let mut last_window_position = None; let device_state = DeviceState::new(); - let mut last_mouse_state = device_state.get_mouse(); let mut last_keys: Vec = device_state.get_keys(); - + #[cfg(target_os = "linux")] + let evdev_listener = crate::evdev_input::EvdevInputListener::new(); + #[cfg(target_os = "linux")] + let mut last_buttons = evdev_listener + .as_ref() + .map_or([false; 6], |e| e.get_buttons()); + #[cfg(not(target_os = "linux"))] + let mut last_buttons = { + let mut b = [false; 6]; + for (num, &pressed) in device_state + .get_mouse() + .button_pressed + .iter() + .enumerate() + .take(6) + { + b[num] = pressed; + } + b + }; let mut last_position = cap_cursor_capture::RawCursorPosition::get(); std::fs::create_dir_all(&cursors_dir).unwrap(); @@ -395,8 +384,17 @@ pub fn spawn_cursor_recorder( let Some(epoch) = input_epoch(start_gate.as_ref(), start_time) else { last_position = cap_cursor_capture::RawCursorPosition::get(); - last_mouse_state = device_state.get_mouse(); - last_keys = device_state.get_keys(); + #[cfg(target_os = "linux")] + if let Some(evdev) = &evdev_listener { + last_buttons = evdev.get_buttons(); + last_keys = evdev.get_keys(); + } else { + last_keys = device_state.get_keys(); + } + #[cfg(not(target_os = "linux"))] + { + last_keys = device_state.get_keys(); + } continue; }; let elapsed = epoch.elapsed().as_secs_f64() * 1000.0; @@ -411,7 +409,10 @@ pub fn spawn_cursor_recorder( last_position = position; } #[cfg(target_os = "linux")] - let window_position = window_cursor.as_ref().and_then(X11WindowCursor::position); + let window_position = window_cursor + .as_ref() + .and_then(X11WindowCursor::position) + .or_else(|| *crate::sources::screen_capture::WAYLAND_CURSOR_POSITION.read()); #[cfg(target_os = "linux")] let position_changed = position_changed || (target.window.is_some() && window_position != last_window_position); @@ -477,7 +478,8 @@ pub fn spawn_cursor_recorder( let cropped_norm_pos = if target.window.is_some() { window_position } else { - cropped_norm_pos + (*crate::sources::screen_capture::WAYLAND_CURSOR_POSITION.read()) + .or(cropped_norm_pos) }; if let Some((x, y)) = cropped_norm_pos { @@ -492,12 +494,35 @@ pub fn spawn_cursor_recorder( } } - for (num, &pressed) in mouse_state.button_pressed.iter().enumerate() { - let Some(prev) = last_mouse_state.button_pressed.get(num) else { - continue; - }; + #[cfg(target_os = "linux")] + let in_window = target.window.is_none() + || window_position + .is_some_and(|(x, y)| (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)); + let mouse_buttons: [bool; 6] = if let Some(evdev) = &evdev_listener { + if in_window { + evdev.get_buttons() + } else { + [false; 6] + } + } else { + let mut b = [false; 6]; + for (num, &pressed) in mouse_state.button_pressed.iter().enumerate().take(6) { + b[num] = pressed; + } + b + }; + #[cfg(not(target_os = "linux"))] + let mouse_buttons = { + let mut b = [false; 6]; + for (num, &pressed) in mouse_state.button_pressed.iter().enumerate().take(6) { + b[num] = pressed; + } + b + }; - if pressed == *prev { + for (num, &pressed) in mouse_buttons.iter().enumerate() { + let prev = last_buttons.get(num).copied().unwrap_or(false); + if pressed == prev { continue; } @@ -511,8 +536,26 @@ pub fn spawn_cursor_recorder( response.clicks.push(mouse_event); } - last_mouse_state = mouse_state; + last_buttons = mouse_buttons; + #[cfg(target_os = "linux")] + let current_keys = if let Some(evdev) = &evdev_listener { + if in_window { + let k = evdev.get_keys(); + if !k.is_empty() || device_state.get_keys().is_empty() { + k + } else { + device_state.get_keys() + } + } else { + Vec::new() + } + } else if in_window { + device_state.get_keys() + } else { + Vec::new() + }; + #[cfg(not(target_os = "linux"))] let current_keys = device_state.get_keys(); for key in ¤t_keys { diff --git a/crates/recording/src/evdev_input.rs b/crates/recording/src/evdev_input.rs new file mode 100644 index 00000000000..19c1bd1535b --- /dev/null +++ b/crates/recording/src/evdev_input.rs @@ -0,0 +1,301 @@ +use device_query::Keycode; +use parking_lot::RwLock; +use std::collections::HashSet; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; + +const EV_KEY: u16 = 1; +const BTN_LEFT: u16 = 0x110; +const BTN_RIGHT: u16 = 0x111; +const BTN_MIDDLE: u16 = 0x112; +const BTN_SIDE: u16 = 0x113; +const BTN_EXTRA: u16 = 0x114; +const BTN_FORWARD: u16 = 0x115; +const BTN_BACK: u16 = 0x116; +const BTN_TASK: u16 = 0x117; + +// EVIOCGBIT(EV_KEY, 96) from linux/input.h +const EVIOCGBIT_KEY_96: libc::c_ulong = 0x80604521; + +#[derive(Clone, Copy)] +#[repr(C)] +struct InputEvent { + time: libc::timeval, + type_: u16, + code: u16, + value: i32, +} + +impl InputEvent { + const fn zero() -> Self { + Self { + time: libc::timeval { + tv_sec: 0, + tv_usec: 0, + }, + type_: 0, + code: 0, + value: 0, + } + } +} + +pub struct EvdevInputListener { + buttons: Arc>, + keys: Arc>>, + stop: Arc, + thread: Option>, +} + +impl EvdevInputListener { + pub fn new() -> Option { + let mut fds = Vec::new(); + for i in 0..64 { + let path = format!("/dev/input/event{i}"); + if !Path::new(&path).exists() { + continue; + } + let c_path = match std::ffi::CString::new(path) { + Ok(p) => p, + Err(_) => continue, + }; + let fd = unsafe { + libc::open( + c_path.as_ptr(), + libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + continue; + } + + let mut key_bits = [0u8; 96]; + let res = unsafe { libc::ioctl(fd, EVIOCGBIT_KEY_96, key_bits.as_mut_ptr()) }; + if res >= 0 { + let has_btn_left = (key_bits[BTN_LEFT as usize / 8] & (1 << (BTN_LEFT % 8))) != 0; + let has_key_a = (key_bits[30 / 8] & (1 << (30 % 8))) != 0; + if has_btn_left || has_key_a { + unsafe { + fds.push(OwnedFd::from_raw_fd(fd)); + } + continue; + } + } + unsafe { + libc::close(fd); + } + } + + if fds.is_empty() { + tracing::debug!("evdev: no accessible input devices found in /dev/input/event*"); + return None; + } + + tracing::info!( + count = fds.len(), + "evdev: listening to hardware input devices" + ); + + let buttons = Arc::new(RwLock::new([false; 6])); + let keys = Arc::new(RwLock::new(HashSet::new())); + let stop = Arc::new(AtomicBool::new(false)); + + let thread_buttons = buttons.clone(); + let thread_keys = keys.clone(); + let thread_stop = stop.clone(); + + let thread = std::thread::spawn(move || { + let raw_fds: Vec = fds.iter().map(|f| f.as_raw_fd()).collect(); + let mut poll_fds: Vec = raw_fds + .iter() + .map(|&fd| libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }) + .collect(); + + let mut buf = [InputEvent::zero(); 16]; + + while !thread_stop.load(Ordering::Relaxed) { + let ret = unsafe { + libc::poll(poll_fds.as_mut_ptr(), poll_fds.len() as libc::nfds_t, 16) + }; + if ret <= 0 { + continue; + } + + for pfd in &poll_fds { + if (pfd.revents & libc::POLLIN) == 0 { + continue; + } + loop { + let bytes_read = unsafe { + libc::read( + pfd.fd, + buf.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of_val(&buf), + ) + }; + if bytes_read <= 0 { + break; + } + let num_events = bytes_read as usize / std::mem::size_of::(); + for event in &buf[..num_events] { + if event.type_ == EV_KEY { + let pressed = event.value != 0; + match event.code { + BTN_LEFT => { + thread_buttons.write()[0] = pressed; + } + BTN_MIDDLE => { + thread_buttons.write()[1] = pressed; + } + BTN_RIGHT => { + thread_buttons.write()[2] = pressed; + } + BTN_SIDE | BTN_BACK => { + thread_buttons.write()[3] = pressed; + } + BTN_EXTRA | BTN_FORWARD => { + thread_buttons.write()[4] = pressed; + } + BTN_TASK => { + thread_buttons.write()[5] = pressed; + } + code => { + if let Some(kc) = evdev_code_to_keycode(code) { + if pressed { + thread_keys.write().insert(kc); + } else { + thread_keys.write().remove(&kc); + } + } + } + } + } + } + } + } + } + }); + + Some(Self { + buttons, + keys, + stop, + thread: Some(thread), + }) + } + + pub fn get_buttons(&self) -> [bool; 6] { + *self.buttons.read() + } + + pub fn get_keys(&self) -> Vec { + self.keys.read().iter().copied().collect() + } +} + +impl Drop for EvdevInputListener { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn evdev_code_to_keycode(code: u16) -> Option { + match code { + 1 => Some(Keycode::Escape), + 2 => Some(Keycode::Key1), + 3 => Some(Keycode::Key2), + 4 => Some(Keycode::Key3), + 5 => Some(Keycode::Key4), + 6 => Some(Keycode::Key5), + 7 => Some(Keycode::Key6), + 8 => Some(Keycode::Key7), + 9 => Some(Keycode::Key8), + 10 => Some(Keycode::Key9), + 11 => Some(Keycode::Key0), + 12 => Some(Keycode::Minus), + 13 => Some(Keycode::Equal), + 14 => Some(Keycode::Backspace), + 15 => Some(Keycode::Tab), + 16 => Some(Keycode::Q), + 17 => Some(Keycode::W), + 18 => Some(Keycode::E), + 19 => Some(Keycode::R), + 20 => Some(Keycode::T), + 21 => Some(Keycode::Y), + 22 => Some(Keycode::U), + 23 => Some(Keycode::I), + 24 => Some(Keycode::O), + 25 => Some(Keycode::P), + 26 => Some(Keycode::LeftBracket), + 27 => Some(Keycode::RightBracket), + 28 => Some(Keycode::Enter), + 29 => Some(Keycode::LControl), + 30 => Some(Keycode::A), + 31 => Some(Keycode::S), + 32 => Some(Keycode::D), + 33 => Some(Keycode::F), + 34 => Some(Keycode::G), + 35 => Some(Keycode::H), + 36 => Some(Keycode::J), + 37 => Some(Keycode::K), + 38 => Some(Keycode::L), + 39 => Some(Keycode::Semicolon), + 40 => Some(Keycode::Apostrophe), + 41 => Some(Keycode::Grave), + 42 => Some(Keycode::LShift), + 43 => Some(Keycode::BackSlash), + 44 => Some(Keycode::Z), + 45 => Some(Keycode::X), + 46 => Some(Keycode::C), + 47 => Some(Keycode::V), + 48 => Some(Keycode::B), + 49 => Some(Keycode::N), + 50 => Some(Keycode::M), + 51 => Some(Keycode::Comma), + 52 => Some(Keycode::Dot), + 53 => Some(Keycode::Slash), + 54 => Some(Keycode::RShift), + 56 => Some(Keycode::LAlt), + 57 => Some(Keycode::Space), + 58 => Some(Keycode::CapsLock), + 59 => Some(Keycode::F1), + 60 => Some(Keycode::F2), + 61 => Some(Keycode::F3), + 62 => Some(Keycode::F4), + 63 => Some(Keycode::F5), + 64 => Some(Keycode::F6), + 65 => Some(Keycode::F7), + 66 => Some(Keycode::F8), + 67 => Some(Keycode::F9), + 68 => Some(Keycode::F10), + 87 => Some(Keycode::F11), + 88 => Some(Keycode::F12), + 96 => Some(Keycode::NumpadEnter), + 97 => Some(Keycode::RControl), + 98 => Some(Keycode::NumpadDivide), + 100 => Some(Keycode::RAlt), + 102 => Some(Keycode::Home), + 103 => Some(Keycode::Up), + 104 => Some(Keycode::PageUp), + 105 => Some(Keycode::Left), + 106 => Some(Keycode::Right), + 107 => Some(Keycode::End), + 108 => Some(Keycode::Down), + 109 => Some(Keycode::PageDown), + 110 => Some(Keycode::Insert), + 111 => Some(Keycode::Delete), + 125 => Some(Keycode::LMeta), + 126 => Some(Keycode::RMeta), + _ => None, + } +} diff --git a/crates/recording/src/lib.rs b/crates/recording/src/lib.rs index a7dcbbcc4c9..6a7a2cc1140 100644 --- a/crates/recording/src/lib.rs +++ b/crates/recording/src/lib.rs @@ -22,6 +22,8 @@ pub mod upload_preparation; pub mod upload_resume; pub mod upload_verification; +#[cfg(target_os = "linux")] +pub mod evdev_input; #[cfg(target_os = "linux")] pub use capture_pipeline::target_to_display_and_crop; pub use resolution_limits::{H264_MAX_DIMENSION, calculate_gpu_compatible_size}; diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index e1e792a5f9e..5f89bd74420 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -60,6 +60,13 @@ pub struct VideoSourceConfig { } impl VideoSourceConfig { + pub(crate) fn is_window_capture(&self) -> bool { + match &self.input { + LinuxInputConfig::Wayland(w) => w.is_window_capture, + LinuxInputConfig::X11(x) => x.window_id.is_some(), + } + } + pub(crate) fn video_info(&self) -> VideoInfo { self.video_info } @@ -87,6 +94,7 @@ struct WaylandInputConfig { fps: u32, crop_bounds: Option, portal_session: WaylandPortalSession, + is_window_capture: bool, } struct WaylandPortalSession { @@ -248,21 +256,48 @@ impl OutputVideoSource for VideoSource { let stop_token = ctx.stop_token(); let health_tx = ctx.health_tx().clone(); let info = config.video_info; - match config.input { + let info = match config.input { LinuxInputConfig::X11(input) => { ctx.tasks().spawn_thread("x11-capture-thread", { let stop_token = stop_token.clone(); move || capture_x11(info, input, video_tx, stop_token, health_tx) }); + info } LinuxInputConfig::Wayland(input) => { + let (init_tx, init_rx) = tokio::sync::oneshot::channel(); ctx.tasks() .spawn_thread("wayland-pipewire-capture-thread", { let stop_token = stop_token.clone(); - move || capture_wayland(info, input, video_tx, stop_token, health_tx) + move || { + capture_wayland(info, input, video_tx, stop_token, health_tx, init_tx) + } }); + + let watchdog = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(watchdog); + + tokio::select! { + _ = stop_token.cancelled() => { + bail!("Wayland capture setup was cancelled before stream initialization"); + } + _ = &mut watchdog => { + bail!("Timed out waiting for Wayland compositor to negotiate stream format"); + } + res = init_rx => { + match res { + Ok(WaylandCaptureInit::Ready(negotiated)) => negotiated, + Ok(WaylandCaptureInit::Failed(error)) => { + bail!("Wayland PipeWire stream failed during initialization: {error}"); + } + Err(_) => { + bail!("Wayland PipeWire capture thread exited before format negotiation"); + } + } + } + } } - } + }; Ok(Self { info, stop_token }) } @@ -283,6 +318,12 @@ struct WaylandPortalCapture { portal_session: WaylandPortalSession, } +#[derive(Debug)] +pub enum WaylandCaptureInit { + Ready(VideoInfo), + Failed(String), +} + struct PipewireCaptureState { format: spa::param::video::VideoInfoRaw, scaler: Option, @@ -297,15 +338,34 @@ struct PipewireCaptureState { rate_limited: Arc, capture_clock: Instant, cadence_gate: FrameCadenceGate, + init_tx: Arc>>>, } impl PipewireCaptureState { + fn is_initial_negotiation(&self) -> bool { + self.init_tx.lock().is_some() + } + fn set_fatal_error(&self, error: impl Into) { let mut fatal_error = self.fatal_error.lock(); if fatal_error.is_none() { *fatal_error = Some(error.into()); } } + + fn notify_ready(&self, info: VideoInfo) { + if let Some(tx) = self.init_tx.lock().take() { + let _ = tx.send(WaylandCaptureInit::Ready(info)); + } + } + + fn notify_failed(&self, error: impl Into) { + let msg = error.into(); + self.set_fatal_error(msg.clone()); + if let Some(tx) = self.init_tx.lock().take() { + let _ = tx.send(WaylandCaptureInit::Failed(msg)); + } + } } async fn create_wayland_source_config( @@ -329,6 +389,10 @@ async fn create_wayland_source_config( bail!("Select the same display in the screen-sharing dialog as the recording area"); } } + let is_window_capture = matches!( + config.config.linux_source, + LinuxCaptureSource::Window { .. } + ); let crop_bounds = match &config.config.linux_source { LinuxCaptureSource::Area => config.config.crop_bounds, LinuxCaptureSource::Display | LinuxCaptureSource::Window { .. } => None, @@ -347,6 +411,7 @@ async fn create_wayland_source_config( fps: config.config.fps, crop_bounds, portal_session: portal.portal_session, + is_window_capture, }, )) } @@ -365,7 +430,7 @@ async fn open_wayland_portal( let cursor_mode = if show_cursor { CursorMode::Embedded } else { - CursorMode::Hidden + CursorMode::Metadata }; proxy @@ -464,12 +529,16 @@ fn wayland_video_info( ) } +pub static WAYLAND_CURSOR_POSITION: parking_lot::RwLock> = + parking_lot::RwLock::new(None); + fn capture_wayland( video_info: VideoInfo, input: WaylandInputConfig, video_tx: mpsc::Sender, stop_token: CancellationToken, health_tx: output_pipeline::HealthSender, + init_tx: tokio::sync::oneshot::Sender, ) -> anyhow::Result<()> { let _portal_session = input.portal_session; let stop_requested = Arc::new(AtomicBool::new(false)); @@ -478,7 +547,8 @@ fn capture_wayland( let dropped = Arc::new(AtomicU64::new(0)); let rate_limited = Arc::new(AtomicU64::new(0)); let started = Instant::now(); - + *WAYLAND_CURSOR_POSITION.write() = None; + pw::init(); let thread_loop = unsafe { pw::thread_loop::ThreadLoopBox::new(Some("cap-wayland"), None) } .context("create PipeWire thread loop")?; let context = pw::context::ContextBox::new(thread_loop.loop_(), None) @@ -501,6 +571,7 @@ fn capture_wayland( rate_limited: rate_limited.clone(), capture_clock: started, cadence_gate: FrameCadenceGate::new(1_000_000_000 / i64::from(input.fps.max(1))), + init_tx: Arc::new(parking_lot::Mutex::new(Some(init_tx))), }; let stream = pw::stream::StreamBox::new( @@ -516,13 +587,21 @@ fn capture_wayland( let _listener = stream .add_local_listener_with_user_data(state) - .state_changed(|_, state, _, new| { + .state_changed(|stream, state, old, new| { + tracing::debug!(?old, ?new, "PipeWire screen capture stream state changed"); + if new == pw::stream::StreamState::Paused { + let res = stream.set_active(true); + tracing::debug!(?res, "PipeWire stream.set_active result"); + } if let pw::stream::StreamState::Error(error) = new { - state.set_fatal_error(format!("PipeWire screen capture stream failed: {error}")); + tracing::error!(%error, "PipeWire stream error"); + state.notify_failed(format!("PipeWire screen capture stream failed: {error}")); } }) - .param_changed(|_, state, id, param| { - if let Err(error) = update_pipewire_format(state, id, param) { + .param_changed(|stream, state, id, param| { + tracing::trace!(id, "PipeWire param_changed"); + if let Err(error) = update_pipewire_format(stream, state, id, param) { + tracing::error!(error = %format!("{error:#}"), "PipeWire update_pipewire_format error"); state.set_fatal_error(error.to_string()); } }) @@ -533,25 +612,61 @@ fn capture_wayland( match process_pipewire_frame(stream, state) { Ok(Some(StallSendOutcome::Sent)) => { - state.sent.fetch_add(1, Ordering::Relaxed); + let count = state.sent.fetch_add(1, Ordering::Relaxed) + 1; + if count <= 5 || count % 60 == 0 { + tracing::trace!(count, "PipeWire frame successfully sent"); + } } - Ok(Some(StallSendOutcome::StalledAndDropped { .. })) => { + Ok(Some(StallSendOutcome::StalledAndDropped { waited_ms })) => { + tracing::trace!(waited_ms, "PipeWire frame dropped (stalled)"); state.dropped.fetch_add(1, Ordering::Relaxed); } Ok(Some(StallSendOutcome::Disconnected)) => { + tracing::debug!("PipeWire frame outcome: Disconnected"); state.stop_requested.store(true, Ordering::Relaxed); } Ok(None) => {} - Err(error) => state.set_fatal_error(error.to_string()), + Err(error) => { + tracing::error!(error = %format!("{error:#}"), "PipeWire process_pipewire_frame error"); + state.set_fatal_error(error.to_string()); + } } }) .register() .context("register PipeWire stream listener")?; - - let param_bytes = pipewire_format_param(input.fps)?; - let mut params = [spa::pod::Pod::from_bytes(¶m_bytes) - .ok_or_else(|| anyhow!("create PipeWire format parameter"))?]; - + let p_bgra_mod = + pipewire_format_param(input.fps, spa::param::video::VideoFormat::BGRA, Some(0))?; + let p_bgrx_mod = + pipewire_format_param(input.fps, spa::param::video::VideoFormat::BGRx, Some(0))?; + let p_rgba_mod = + pipewire_format_param(input.fps, spa::param::video::VideoFormat::RGBA, Some(0))?; + let p_bgra_shm = pipewire_format_param(input.fps, spa::param::video::VideoFormat::BGRA, None)?; + let p_bgrx_shm = pipewire_format_param(input.fps, spa::param::video::VideoFormat::BGRx, None)?; + let p_rgba_shm = pipewire_format_param(input.fps, spa::param::video::VideoFormat::RGBA, None)?; + let p_meta_cursor = pipewire_meta_cursor_param()?; + let mut params = [ + spa::pod::Pod::from_bytes(&p_bgra_mod) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_bgrx_mod) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_rgba_mod) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_bgra_shm) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_bgrx_shm) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_rgba_shm) + .ok_or_else(|| anyhow!("create PipeWire format parameter"))?, + spa::pod::Pod::from_bytes(&p_meta_cursor) + .ok_or_else(|| anyhow!("create PipeWire meta cursor parameter"))?, + ]; + + tracing::debug!( + node_id = input.node_id, + fps = input.fps, + is_window = input.is_window_capture, + "Connecting to PipeWire node" + ); stream .connect( spa::utils::Direction::Input, @@ -562,14 +677,23 @@ fn capture_wayland( .context("connect PipeWire stream to portal node")?; thread_loop.start(); - + tracing::debug!("PipeWire thread loop started"); + let mut last_status = Instant::now(); while !stop_token.is_cancelled() && !stop_requested.load(Ordering::Relaxed) { if fatal_error.lock().is_some() { break; } + if last_status.elapsed() >= Duration::from_secs(2) { + last_status = Instant::now(); + tracing::trace!( + sent = sent.load(Ordering::Relaxed), + dropped = dropped.load(Ordering::Relaxed), + rate_limited = rate_limited.load(Ordering::Relaxed), + "PipeWire capture loop tick" + ); + } std::thread::sleep(Duration::from_millis(20)); } - stop_requested.store(true, Ordering::Relaxed); thread_loop.stop(); @@ -590,6 +714,7 @@ fn capture_wayland( } fn update_pipewire_format( + _stream: &pw::stream::Stream, state: &mut PipewireCaptureState, id: u32, param: Option<&spa::pod::Pod>, @@ -613,14 +738,49 @@ fn update_pipewire_format( format .parse(param) .context("parse PipeWire raw video format")?; - pipewire_pixel_format(format.format()).ok_or_else(|| { + let (pixel_format, _) = pipewire_pixel_format(format.format()).ok_or_else(|| { anyhow!( "Unsupported PipeWire screen capture pixel format: {:?}", format.format() ) })?; state.format = format; + let size = format.size(); + tracing::debug!( + width = size.width, + height = size.height, + format = ?format.format(), + modifier = format.modifier(), + "PipeWire negotiated format" + ); + let target_width = ensure_even(size.width); + let target_height = ensure_even(size.height); + if target_width > 0 && target_height > 0 { + let actual_info = VideoInfo::from_raw_ffmpeg( + pixel_format, + target_width, + target_height, + state.video_info.fps(), + ); + if state.is_initial_negotiation() { + state.video_info = actual_info; + state.notify_ready(actual_info); + } + } else if state.is_initial_negotiation() { + state.notify_ready(state.video_info); + } + let p_header = pipewire_meta_header_param()?; + let p_cursor = pipewire_meta_cursor_param()?; + + let p1 = spa::pod::Pod::from_bytes(&p_header).ok_or_else(|| anyhow!("create header param"))?; + let p2 = spa::pod::Pod::from_bytes(&p_cursor).ok_or_else(|| anyhow!("create cursor param"))?; + let mut update_params: [&spa::pod::Pod; 2] = [p1, p2]; + if let Err(e) = _stream.update_params(&mut update_params) { + tracing::error!(%e, "PipeWire stream.update_params error"); + } else { + tracing::debug!("PipeWire stream.update_params succeeded with cursor metadata"); + } Ok(()) } @@ -631,8 +791,23 @@ fn process_pipewire_frame( let Some(mut buffer) = stream.dequeue_buffer() else { return Ok(None); }; + if let Some(cursor_meta) = buffer.find_meta::() { + if cursor_meta.is_valid() { + let pos = cursor_meta.position(); + let width = state.video_info.width as f64; + let height = state.video_info.height as f64; + if width > 0.0 && height > 0.0 { + let norm_x = (pos.x as f64 / width).clamp(0.0, 1.0); + let norm_y = (pos.y as f64 / height).clamp(0.0, 1.0); + *WAYLAND_CURSOR_POSITION.write() = Some((norm_x, norm_y)); + } + } else { + *WAYLAND_CURSOR_POSITION.write() = None; + } + } let datas = buffer.datas_mut(); if datas.is_empty() { + tracing::trace!("PipeWire dequeued buffer but datas is empty"); return Ok(None); } @@ -650,7 +825,7 @@ fn process_pipewire_frame( let Some(raw_frame) = frame_from_pipewire_data(&mut datas[0], state.format, state.crop_bounds)? else { - return Ok(Some(StallSendOutcome::StalledAndDropped { waited_ms: 0 })); + return Ok(None); }; let frame = prepare_pipewire_frame(raw_frame, &mut state.scaler, state.video_info)?; let timestamp = Timestamp::Instant(captured_at); @@ -678,7 +853,7 @@ fn prepare_pipewire_frame( return Ok(frame); } - if scaler.is_none() { + if scaler.as_ref().is_none_or(|s| !s.matches(&frame)) { *scaler = Some(FrameScaler::new( frame.format(), frame.width(), @@ -716,8 +891,7 @@ fn frame_from_pipewire_data( let chunk_stride = data.chunk().stride(); let chunk_offset = data.chunk().offset(); let chunk_size = data.chunk().size(); - if chunk_flags.contains(spa::buffer::ChunkFlags::CORRUPTED) { - tracing::warn!("PipeWire screen capture frame was marked corrupted; skipping frame"); + if chunk_flags.contains(spa::buffer::ChunkFlags::CORRUPTED) || chunk_size == 0 { return Ok(None); } if chunk_stride < 0 { @@ -731,9 +905,67 @@ fn frame_from_pipewire_data( }; let (crop_x, crop_y, crop_width, crop_height) = pipewire_crop(source_width, source_height, crop_bounds)?; - let source = data - .data() - .ok_or_else(|| anyhow!("PipeWire screen capture buffer was not memory-mapped"))?; + let fd = data.fd(); + if fd >= 0 { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + unsafe { + libc::poll(&mut pfd, 1, 100); + } + let sync_start = DmaBufSync { + flags: DMA_BUF_SYNC_READ | DMA_BUF_SYNC_START, + }; + unsafe { + libc::ioctl(fd, DMA_BUF_IOCTL_SYNC, &sync_start); + } + } + + let (mapped_slice, _unmap_guard) = if let Some(slice) = data.data() { + let guard = if fd >= 0 { + Some(DmaBufGuard { + ptr: std::ptr::null_mut(), + size: 0, + fd, + needs_unmap: false, + }) + } else { + None + }; + (slice as &[u8], guard) + } else { + let maxsize = data.as_raw().maxsize as usize; + if fd < 0 || maxsize == 0 { + bail!("PipeWire screen capture buffer has no data pointer and invalid fd ({fd})"); + } + let ptr = unsafe { + libc::mmap( + std::ptr::null_mut(), + maxsize, + libc::PROT_READ, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if ptr == libc::MAP_FAILED { + bail!( + "Failed to mmap PipeWire buffer fd {fd} (size {maxsize}): {}", + std::io::Error::last_os_error() + ); + } + let guard = DmaBufGuard { + ptr, + size: maxsize, + fd, + needs_unmap: true, + }; + let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, maxsize) }; + (slice, Some(guard)) + }; + let source = mapped_slice; let offset = chunk_offset as usize; let source_limit = if chunk_size > 0 { offset @@ -775,6 +1007,40 @@ fn frame_from_pipewire_data( Ok(Some(frame)) } +#[repr(C)] +struct DmaBufSync { + flags: u64, +} +const DMA_BUF_SYNC_READ: u64 = 1; +const DMA_BUF_SYNC_START: u64 = 0; +const DMA_BUF_SYNC_END: u64 = 4; +// _IOW('b', 0, struct dma_buf_sync) from linux/dma-buf.h +const DMA_BUF_IOCTL_SYNC: libc::c_ulong = 0x40086200; + +struct DmaBufGuard { + ptr: *mut libc::c_void, + size: usize, + fd: libc::c_int, + needs_unmap: bool, +} + +impl Drop for DmaBufGuard { + fn drop(&mut self) { + if self.fd >= 0 { + let sync_end = DmaBufSync { + flags: DMA_BUF_SYNC_READ | DMA_BUF_SYNC_END, + }; + unsafe { + libc::ioctl(self.fd, DMA_BUF_IOCTL_SYNC, &sync_end); + } + } + if self.needs_unmap && !self.ptr.is_null() && self.size > 0 { + unsafe { + libc::munmap(self.ptr, self.size); + } + } + } +} fn pipewire_crop( source_width: usize, @@ -829,71 +1095,176 @@ fn pipewire_pixel_format( Some((pixel, 4)) } -fn pipewire_format_param(fps: u32) -> anyhow::Result> { +pub fn pipewire_format_param( + fps: u32, + format: spa::param::video::VideoFormat, + modifier: Option, +) -> anyhow::Result> { let fps = fps.max(1); + let obj = if let Some(m) = modifier { + spa::pod::object!( + spa::utils::SpaTypes::ObjectParamFormat, + spa::param::ParamType::EnumFormat, + spa::pod::property!( + spa::param::format::FormatProperties::MediaType, + Id, + spa::param::format::MediaType::Video + ), + spa::pod::property!( + spa::param::format::FormatProperties::MediaSubtype, + Id, + spa::param::format::MediaSubtype::Raw + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFormat, + Id, + format + ), + spa::pod::property!(spa::param::format::FormatProperties::VideoModifier, Long, m), + spa::pod::property!( + spa::param::format::FormatProperties::VideoSize, + Choice, + Range, + Rectangle, + spa::utils::Rectangle { + width: 1920, + height: 1080 + }, + spa::utils::Rectangle { + width: 1, + height: 1 + }, + spa::utils::Rectangle { + width: 8192, + height: 8192 + } + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFramerate, + Fraction, + spa::utils::Fraction { num: 0, denom: 1 } + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoMaxFramerate, + Choice, + Range, + Fraction, + spa::utils::Fraction { num: fps, denom: 1 }, + spa::utils::Fraction { num: 0, denom: 1 }, + spa::utils::Fraction { + num: 1000, + denom: 1 + } + ) + ) + } else { + spa::pod::object!( + spa::utils::SpaTypes::ObjectParamFormat, + spa::param::ParamType::EnumFormat, + spa::pod::property!( + spa::param::format::FormatProperties::MediaType, + Id, + spa::param::format::MediaType::Video + ), + spa::pod::property!( + spa::param::format::FormatProperties::MediaSubtype, + Id, + spa::param::format::MediaSubtype::Raw + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFormat, + Id, + format + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoSize, + Choice, + Range, + Rectangle, + spa::utils::Rectangle { + width: 1920, + height: 1080 + }, + spa::utils::Rectangle { + width: 1, + height: 1 + }, + spa::utils::Rectangle { + width: 8192, + height: 8192 + } + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFramerate, + Fraction, + spa::utils::Fraction { num: 0, denom: 1 } + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoMaxFramerate, + Choice, + Range, + Fraction, + spa::utils::Fraction { num: fps, denom: 1 }, + spa::utils::Fraction { num: 0, denom: 1 }, + spa::utils::Fraction { + num: 1000, + denom: 1 + } + ) + ) + }; + Ok(spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .map_err(|error| anyhow!("serialize PipeWire format parameter: {error:?}"))? + .0 + .into_inner()) +} + +struct RawSpaId(u32); +impl RawSpaId { + const fn as_raw(&self) -> u32 { + self.0 + } +} + +pub fn pipewire_meta_header_param() -> anyhow::Result> { let obj = spa::pod::object!( - spa::utils::SpaTypes::ObjectParamFormat, - spa::param::ParamType::EnumFormat, - spa::pod::property!( - spa::param::format::FormatProperties::MediaType, - Id, - spa::param::format::MediaType::Video - ), + spa::utils::SpaTypes::ObjectParamMeta, + spa::param::ParamType::Meta, + spa::pod::property!(RawSpaId(1), Id, RawSpaId(spa::sys::SPA_META_Header)), spa::pod::property!( - spa::param::format::FormatProperties::MediaSubtype, - Id, - spa::param::format::MediaSubtype::Raw - ), - spa::pod::property!( - spa::param::format::FormatProperties::VideoFormat, - Choice, - Enum, - Id, - spa::param::video::VideoFormat::BGRx, - spa::param::video::VideoFormat::BGRx, - spa::param::video::VideoFormat::BGRA, - spa::param::video::VideoFormat::RGBx, - spa::param::video::VideoFormat::RGBA, - spa::param::video::VideoFormat::RGB, - spa::param::video::VideoFormat::BGR - ), - spa::pod::property!( - spa::param::format::FormatProperties::VideoSize, - Choice, - Range, - Rectangle, - spa::utils::Rectangle { - width: 1920, - height: 1080 - }, - spa::utils::Rectangle { - width: 1, - height: 1 - }, - spa::utils::Rectangle { - width: 8192, - height: 8192 - } - ), - spa::pod::property!( - spa::param::format::FormatProperties::VideoFramerate, - Choice, - Range, - Fraction, - spa::utils::Fraction { num: fps, denom: 1 }, - spa::utils::Fraction { num: 0, denom: 1 }, - spa::utils::Fraction { - num: 1000, - denom: 1 - } + RawSpaId(2), + Int, + std::mem::size_of::() as i32 ) ); + Ok(spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .map_err(|error| anyhow!("serialize PipeWire meta header parameter: {error:?}"))? + .0 + .into_inner()) +} + +pub fn pipewire_meta_cursor_param() -> anyhow::Result> { + let cursor_size = (std::mem::size_of::() + + std::mem::size_of::() + + 256 * 256 * 4) as i32; + let obj = spa::pod::object!( + spa::utils::SpaTypes::ObjectParamMeta, + spa::param::ParamType::Meta, + spa::pod::property!(RawSpaId(1), Id, RawSpaId(spa::sys::SPA_META_Cursor)), + spa::pod::property!(RawSpaId(2), Int, cursor_size) + ); Ok(spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &spa::pod::Value::Object(obj), ) - .map_err(|error| anyhow!("serialize PipeWire format parameter: {error:?}"))? + .map_err(|error| anyhow!("serialize PipeWire meta cursor parameter: {error:?}"))? .0 .into_inner()) } @@ -2844,8 +3215,9 @@ mod system_audio_tests { #[cfg(test)] mod pipewire_frame_tests { use super::{ - FrameScaler, LinuxCaptureSource, VideoInfo, prefers_wayland_environment, - prepare_pipewire_frame, wayland_area_matches_display, wayland_video_info, + FrameScaler, LinuxCaptureSource, VideoInfo, pipewire_meta_cursor_param, + prefers_wayland_environment, prepare_pipewire_frame, wayland_area_matches_display, + wayland_video_info, }; #[test] @@ -2967,4 +3339,39 @@ mod pipewire_frame_tests { assert_eq!((prepared.width(), prepared.height()), (8, 6)); assert!(scaler.is_some()); } + #[test] + fn vertical_window_dimensions_are_preserved_without_scaling() { + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, 1080, 2400); + frame.set_pts(Some(100)); + let output = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRZ, 1080, 2400, 60); + let mut scaler: Option = None; + + let prepared = prepare_pipewire_frame(frame, &mut scaler, output).unwrap(); + + assert_eq!((prepared.width(), prepared.height()), (1080, 2400)); + assert!(scaler.is_none()); + } + + #[test] + fn resized_pipewire_dimensions_update_scaler() { + let frame1 = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, 1000, 2000); + let output = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRZ, 500, 1000, 60); + let mut scaler: Option = None; + + let prepared1 = prepare_pipewire_frame(frame1, &mut scaler, output).unwrap(); + assert_eq!((prepared1.width(), prepared1.height()), (500, 1000)); + assert!(scaler.is_some()); + + let frame2 = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRZ, 800, 1600); + let prepared2 = prepare_pipewire_frame(frame2, &mut scaler, output).unwrap(); + assert_eq!((prepared2.width(), prepared2.height()), (500, 1000)); + assert!(scaler.is_some()); + } + + #[test] + fn test_meta_cursor_param() { + let param_bytes = pipewire_meta_cursor_param().unwrap(); + let pod = super::spa::pod::Pod::from_bytes(¶m_bytes).unwrap(); + assert!(pod.size() > 0); + } } diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 1ff44d06f1b..791072674ef 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -3163,8 +3163,6 @@ async fn create_segment_pipeline( screen_capture::ScreenCaptureTarget::CameraOnly ); #[cfg(target_os = "linux")] - let custom_cursor_capture = custom_cursor_capture && !screen_capture::prefers_wayland_portal(); - #[cfg(target_os = "linux")] let mut start_time = start_time; let (screen, system_audio, cursor_display) = if camera_only { @@ -3285,14 +3283,16 @@ async fn create_segment_pipeline( .await .context("screen capture init")?; + let (capture_source, system_audio) = screen_config.to_sources().await?; + #[cfg(target_os = "linux")] + let screen_info = capture_source.video_info(); + #[cfg(not(target_os = "linux"))] let screen_info = screen_config.info(); let output_size = calculate_gpu_compatible_size( screen_info.width, screen_info.height, H264_MAX_DIMENSION, ); - - let (capture_source, system_audio) = screen_config.to_sources().await?; #[cfg(target_os = "linux")] { start_time = Timestamps::now(); diff --git a/crates/utils/src/disk_space.rs b/crates/utils/src/disk_space.rs index a51f3227b62..728b9887ba3 100644 --- a/crates/utils/src/disk_space.rs +++ b/crates/utils/src/disk_space.rs @@ -183,7 +183,7 @@ pub fn free_bytes_for_path(path: &Path) -> io::Result { return Err(io::Error::last_os_error()); } - let bavail = stat.f_bavail as u64; + let bavail = stat.f_bavail; let frsize = if stat.f_frsize == 0 { stat.f_bsize } else {