From 55eb068b00d0a078997872b8f39c666f3012b430 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:02:18 +0000 Subject: [PATCH 1/2] fix(recording): apply the resolution limit to X11 display and area capture --- .../src/sources/screen_capture/linux.rs | 18 +- .../sources/screen_capture/linux/x11_tests.rs | 181 ++++++++++++++++++ 2 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 crates/recording/src/sources/screen_capture/linux/x11_tests.rs diff --git a/crates/recording/src/sources/screen_capture/linux.rs b/crates/recording/src/sources/screen_capture/linux.rs index 58faeb796d1..c82a28d19bb 100644 --- a/crates/recording/src/sources/screen_capture/linux.rs +++ b/crates/recording/src/sources/screen_capture/linux.rs @@ -35,6 +35,9 @@ use x11rb::protocol::xproto::{ }; use x11rb::rust_connection::RustConnection; +#[cfg(test)] +mod x11_tests; + #[derive(Debug)] pub struct X11Capture; @@ -141,9 +144,13 @@ impl ScreenCaptureConfig { if matches!(&self.config.linux_source, LinuxCaptureSource::Window { .. }) { self.video_info } else { + let output_size = constrain_capture_size( + PhysicalSize::new(f64::from(width), f64::from(height)), + Some((self.video_info.width, self.video_info.height)), + ); VideoInfo { - width, - height, + width: output_size.width() as u32, + height: output_size.height() as u32, ..self.video_info } }; @@ -160,8 +167,8 @@ impl ScreenCaptureConfig { }, x, y, - width: video_info.width, - height: video_info.height, + width, + height, fps: self.config.fps, show_cursor: self.config.show_cursor, }), @@ -1965,13 +1972,14 @@ impl FrameScaler { } fn capture_x11( - _video_info: VideoInfo, + video_info: VideoInfo, input_config: X11InputConfig, mut video_tx: mpsc::Sender, stop_token: CancellationToken, health_tx: output_pipeline::HealthSender, ) -> anyhow::Result<()> { let mut grabber = X11Grabber::new(&input_config)?; + grabber.output = video_info; let frame_interval = Duration::from_secs_f64(1.0 / f64::from(input_config.fps.max(1))); let started = Instant::now(); let mut next_capture = Instant::now(); diff --git a/crates/recording/src/sources/screen_capture/linux/x11_tests.rs b/crates/recording/src/sources/screen_capture/linux/x11_tests.rs new file mode 100644 index 00000000000..a3139dabda3 --- /dev/null +++ b/crates/recording/src/sources/screen_capture/linux/x11_tests.rs @@ -0,0 +1,181 @@ +use super::*; +use x11rb::protocol::xproto::{ChangeGCAux, CreateGCAux, Rectangle}; + +async fn configured_source( + crop: Option, + limit: Option<(u32, u32)>, +) -> VideoSourceConfig { + let config = ScreenCaptureConfig::::init( + Display::primary(), + crop, + false, + 30, + limit, + SystemTime::now(), + false, + if crop.is_some() { + LinuxCaptureSource::Area + } else { + LinuxCaptureSource::Display + }, + ) + .await + .unwrap(); + let (source, audio) = config.to_sources().await.unwrap(); + assert!(audio.is_none()); + source +} + +async fn capture_frame( + crop: Option, + limit: Option<(u32, u32)>, +) -> (VideoInfo, ffmpeg::frame::Video) { + let source = configured_source(crop, limit).await; + let LinuxInputConfig::X11(input) = source.input else { + panic!("requires an X11 session"); + }; + let info = source.video_info; + let (tx, mut rx) = mpsc::channel(1); + let (health_tx, _health_rx) = tokio::sync::mpsc::channel(4); + let stop = CancellationToken::new(); + let thread_stop = stop.clone(); + let thread = std::thread::spawn(move || capture_x11(info, input, tx, thread_stop, health_tx)); + let frame = tokio::time::timeout(Duration::from_secs(5), rx.next()).await; + stop.cancel(); + drop(rx); + thread.join().unwrap().unwrap(); + (info, frame.unwrap().unwrap().inner) +} + +fn paint_display() -> RustConnection { + let (conn, screen) = x11rb::connect(None).unwrap(); + let root = conn.setup().roots[screen].root; + let gc = conn.generate_id().unwrap(); + conn.create_gc(gc, root, &CreateGCAux::new().foreground(0xff0000)) + .unwrap() + .check() + .unwrap(); + conn.poly_fill_rectangle( + root, + gc, + &[Rectangle { + x: 0, + y: 0, + width: 800, + height: 600, + }], + ) + .unwrap() + .check() + .unwrap(); + conn.change_gc(gc, &ChangeGCAux::new().foreground(0x0000ff)) + .unwrap() + .check() + .unwrap(); + conn.poly_fill_rectangle( + root, + gc, + &[Rectangle { + x: 400, + y: 0, + width: 400, + height: 600, + }], + ) + .unwrap() + .check() + .unwrap(); + conn.free_gc(gc).unwrap().check().unwrap(); + conn +} + +fn assert_frame(info: VideoInfo, frame: &ffmpeg::frame::Video, expected: (u32, u32)) { + assert_eq!((info.width, info.height), expected); + assert_eq!((frame.width(), frame.height()), expected); + assert_eq!(frame.format(), ffmpeg::format::Pixel::BGRZ); + let y = frame.height() as usize / 2; + let left = y * frame.stride(0) + 4 * 4; + let right = y * frame.stride(0) + (frame.width() as usize - 5) * 4; + assert_eq!(&frame.data(0)[left..left + 3], &[0, 0, 255]); + assert_eq!(&frame.data(0)[right..right + 3], &[255, 0, 0]); +} + +#[tokio::test] +#[ignore = "requires a dedicated 800x600 Xvfb display"] +async fn x11_resolution_limit_preserves_the_whole_display_and_area() { + let _display = paint_display(); + let (info, frame) = capture_frame(None, Some((320, 240))).await; + assert_frame(info, &frame, (320, 240)); + + let crop = CropBounds::new( + PhysicalPosition::new(200.0, 100.0), + PhysicalSize::new(400.0, 300.0), + ); + let (info, frame) = capture_frame(Some(crop), Some((160, 120))).await; + assert_frame(info, &frame, (160, 120)); + + let (info, frame) = capture_frame(None, Some((320, 200))).await; + assert_frame(info, &frame, (266, 200)); +} + +#[tokio::test] +#[ignore = "requires a dedicated 800x600 Xvfb display"] +async fn x11_resolution_without_a_limit_keeps_native_dimensions() { + let _display = paint_display(); + let (info, frame) = capture_frame(None, None).await; + assert_frame(info, &frame, (800, 600)); + + let crop = CropBounds::new( + PhysicalPosition::new(200.0, 100.0), + PhysicalSize::new(400.0, 300.0), + ); + let (info, frame) = capture_frame(Some(crop), None).await; + assert_frame(info, &frame, (400, 300)); + + let (info, frame) = capture_frame(Some(crop), Some((800, 600))).await; + assert_frame(info, &frame, (400, 300)); + + let clipped = CropBounds::new( + PhysicalPosition::new(600.0, 300.0), + PhysicalSize::new(400.0, 400.0), + ); + let (info, frame) = capture_frame(Some(clipped), None).await; + assert_eq!((info.width, info.height), (200, 300)); + assert_eq!((frame.width(), frame.height()), (200, 300)); +} + +#[tokio::test] +#[ignore = "requires a dedicated 800x600 Xvfb display"] +async fn x11_resolution_limit_reaches_the_encoded_recording() { + let _display = paint_display(); + let source = configured_source(None, Some((320, 240))).await; + let output = tempfile::tempdir().unwrap(); + let pipeline = output_pipeline::OutputPipeline::builder(output.path().to_path_buf()) + .with_video::(source) + .with_timestamps(cap_timestamp::Timestamps::now()) + .build::(Default::default()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(350)).await; + let finished = pipeline.stop().await.unwrap(); + assert!(finished.video_frame_count > 0); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(output.path().join("manifest.json")).unwrap()) + .unwrap(); + assert_eq!(manifest["is_complete"], true); + assert_eq!(manifest["codec_info"]["width"], 320); + assert_eq!(manifest["codec_info"]["height"], 240); + let input = ffmpeg::format::input( + &output + .path() + .join(manifest["init_segment"].as_str().unwrap()), + ) + .unwrap(); + let stream = input.streams().best(ffmpeg::media::Type::Video).unwrap(); + let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) + .unwrap() + .decoder() + .video() + .unwrap(); + assert_eq!((decoder.width(), decoder.height()), (320, 240)); +} From 56b1aa590260748d589ef46b6a77baa66c962e76 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:34:51 +0000 Subject: [PATCH 2/2] test(recording): hold the shared display while each X11 test paints and captures --- .../src/sources/screen_capture/linux/x11_tests.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/recording/src/sources/screen_capture/linux/x11_tests.rs b/crates/recording/src/sources/screen_capture/linux/x11_tests.rs index a3139dabda3..18c951e0025 100644 --- a/crates/recording/src/sources/screen_capture/linux/x11_tests.rs +++ b/crates/recording/src/sources/screen_capture/linux/x11_tests.rs @@ -47,7 +47,12 @@ async fn capture_frame( (info, frame.unwrap().unwrap().inner) } -fn paint_display() -> RustConnection { +static DISPLAY: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// The tests share one root window, so each holds the display from painting +/// until its last capture. +async fn paint_display() -> (tokio::sync::MutexGuard<'static, ()>, RustConnection) { + let guard = DISPLAY.lock().await; let (conn, screen) = x11rb::connect(None).unwrap(); let root = conn.setup().roots[screen].root; let gc = conn.generate_id().unwrap(); @@ -86,7 +91,7 @@ fn paint_display() -> RustConnection { .check() .unwrap(); conn.free_gc(gc).unwrap().check().unwrap(); - conn + (guard, conn) } fn assert_frame(info: VideoInfo, frame: &ffmpeg::frame::Video, expected: (u32, u32)) { @@ -103,7 +108,7 @@ fn assert_frame(info: VideoInfo, frame: &ffmpeg::frame::Video, expected: (u32, u #[tokio::test] #[ignore = "requires a dedicated 800x600 Xvfb display"] async fn x11_resolution_limit_preserves_the_whole_display_and_area() { - let _display = paint_display(); + let _display = paint_display().await; let (info, frame) = capture_frame(None, Some((320, 240))).await; assert_frame(info, &frame, (320, 240)); @@ -121,7 +126,7 @@ async fn x11_resolution_limit_preserves_the_whole_display_and_area() { #[tokio::test] #[ignore = "requires a dedicated 800x600 Xvfb display"] async fn x11_resolution_without_a_limit_keeps_native_dimensions() { - let _display = paint_display(); + let _display = paint_display().await; let (info, frame) = capture_frame(None, None).await; assert_frame(info, &frame, (800, 600)); @@ -147,7 +152,7 @@ async fn x11_resolution_without_a_limit_keeps_native_dimensions() { #[tokio::test] #[ignore = "requires a dedicated 800x600 Xvfb display"] async fn x11_resolution_limit_reaches_the_encoded_recording() { - let _display = paint_display(); + let _display = paint_display().await; let source = configured_source(None, Some((320, 240))).await; let output = tempfile::tempdir().unwrap(); let pipeline = output_pipeline::OutputPipeline::builder(output.path().to_path_buf())