From 79714856733a688ea70e5f3a226043a2b7fc4952 Mon Sep 17 00:00:00 2001 From: Stephen Psaradellis Date: Tue, 20 Oct 2026 18:37:23 -0500 Subject: [PATCH 1/6] fix(recording): capture the window itself on Windows, sized from its capture item Window targets capture the window's own GraphicsCaptureItem instead of a crop of the display. The output is sized from that item floored to even, and odd items are trimmed on the GPU (scap-direct3d Settings::even_dimensions), so a window recording's frames match the encoder size and never take the CPU scaler. The Studio cursor is placed against the window's current bounds, so it follows a moved window. Frame pool is recreated when the item resizes. --- crates/cursor-capture/src/position.rs | 2 +- crates/recording/src/cursor.rs | 76 ++- crates/recording/src/instant_recording.rs | 2 + .../src/sources/screen_capture/mod.rs | 10 +- .../src/sources/screen_capture/windows.rs | 112 +++- crates/recording/src/studio_recording.rs | 4 +- crates/scap-direct3d/src/lib.rs | 172 +++++- crates/scap-direct3d/tests/window_capture.rs | 511 ++++++++++++++++++ crates/scap-targets/src/lib.rs | 9 + crates/scap-targets/src/platform/win.rs | 13 +- 10 files changed, 873 insertions(+), 38 deletions(-) create mode 100644 crates/scap-direct3d/tests/window_capture.rs diff --git a/crates/cursor-capture/src/position.rs b/crates/cursor-capture/src/position.rs index d26d10c09d8..915eb42557c 100644 --- a/crates/cursor-capture/src/position.rs +++ b/crates/cursor-capture/src/position.rs @@ -136,7 +136,7 @@ impl std::fmt::Debug for RelativeCursorPosition { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] /// Needs to be logical coordinates on macOS and physical on Windows /// This type is opqaue on purpose as the logical/physical invariants need to hold pub struct CursorCropBounds { diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 7827d6fe9c9..8398424f726 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -53,10 +53,38 @@ pub struct IncrementalCaptureOutputs { pub struct CursorCaptureTarget { pub crop_bounds: CursorCropBounds, pub display: scap_targets::Display, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] pub window: Option, } +#[cfg(windows)] +fn windows_window_crop( + window: &scap_targets::WindowId, + display: &scap_targets::Display, +) -> Option { + let window_bounds = scap_targets::Window::from_id_direct(window)? + .raw_handle() + .physical_bounds()?; + let display_bounds = display.raw_handle().physical_bounds()?; + Some(window_crop_on_display(window_bounds, display_bounds)) +} + +#[cfg(windows)] +fn window_crop_on_display( + window_bounds: scap_targets::bounds::PhysicalBounds, + display_bounds: scap_targets::bounds::PhysicalBounds, +) -> CursorCropBounds { + use scap_targets::bounds::{PhysicalBounds, PhysicalPosition}; + + CursorCropBounds::new_windows(PhysicalBounds::new( + PhysicalPosition::new( + window_bounds.position().x() - display_bounds.position().x(), + window_bounds.position().y() - display_bounds.position().y(), + ), + window_bounds.size(), + )) +} + #[cfg(target_os = "linux")] struct X11WindowCursor { connection: x11rb::rust_connection::RustConnection, @@ -110,6 +138,32 @@ fn normalized_window_cursor(x: i16, y: i16, width: u16, height: u16) -> Option<( }) } +#[cfg(all(test, windows))] +mod windows_window_cursor_tests { + use super::window_crop_on_display; + use scap_targets::bounds::{PhysicalBounds, PhysicalPosition, PhysicalSize}; + + fn bounds(x: f64, y: f64, width: f64, height: f64) -> PhysicalBounds { + PhysicalBounds::new( + PhysicalPosition::new(x, y), + PhysicalSize::new(width, height), + ) + } + + #[test] + fn the_cursor_crop_follows_the_window_when_it_moves() { + let display = bounds(-1920.0, 0.0, 1920.0, 1080.0); + let started = window_crop_on_display(bounds(-1720.0, 200.0, 800.0, 600.0), display); + let moved = window_crop_on_display(bounds(-1320.0, 350.0, 800.0, 600.0), display); + + assert_ne!(started, moved); + assert_eq!( + moved, + cap_cursor_capture::CursorCropBounds::new_windows(bounds(600.0, 350.0, 800.0, 600.0)) + ); + } +} + #[cfg(all(test, target_os = "linux"))] mod window_cursor_tests { use super::normalized_window_cursor; @@ -504,6 +558,8 @@ pub fn spawn_cursor_recorder( }); #[cfg(target_os = "linux")] let mut last_window_position = None; + #[cfg(windows)] + let mut last_window_crop: Option = None; let device_state = DeviceState::new(); let mut last_mouse_state = device_state.get_mouse(); let mut last_keys: Vec = device_state.get_keys(); @@ -572,6 +628,24 @@ pub fn spawn_cursor_recorder( { last_window_position = window_position; } + // A window recording follows the window, so the cursor has to be + // placed against where the window is now, not where it started. + #[cfg(windows)] + let window_crop = target + .window + .as_ref() + .and_then(|window| windows_window_crop(window, &display)); + #[cfg(windows)] + let position_changed = + position_changed || (window_crop.is_some() && window_crop != last_window_crop); + #[cfg(windows)] + { + if window_crop.is_some() { + last_window_crop = window_crop; + } + } + #[cfg(windows)] + let crop_bounds = last_window_crop.unwrap_or(crop_bounds); // The cursor image only labels this tick's move and click events, // and reading it builds a full TIFF of every representation diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index bae7bf0085e..3756cae4016 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -1344,6 +1344,8 @@ async fn build_instant_recording_actor( max_capture_size, timestamps.system_time(), inputs.capture_system_audio, + #[cfg(windows)] + inputs.capture_target.window(), #[cfg(target_os = "linux")] crate::sources::screen_capture::LinuxCaptureSource::from_target( &inputs.capture_target, diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index e77a7e61156..4d028b28c05 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -378,6 +378,8 @@ pub struct Config { crop_bounds: Option, fps: u32, show_cursor: bool, + #[cfg(windows)] + window: Option, #[cfg(target_os = "linux")] linux_source: LinuxCaptureSource, } @@ -471,6 +473,7 @@ impl ScreenCaptureConfig { max_capture_size: Option<(u32, u32)>, start_time: SystemTime, system_audio: bool, + #[cfg(windows)] window: Option, #[cfg(target_os = "linux")] linux_source: LinuxCaptureSource, #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: SendableShareableContent, @@ -495,7 +498,10 @@ impl ScreenCaptureConfig { #[cfg(target_os = "windows")] { - crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0)) + window + .as_ref() + .and_then(window_capture_size) + .or_else(|| crop_bounds.map(|b| b.size().map(|v| (v / 2.0).floor() * 2.0))) } #[cfg(target_os = "linux")] @@ -531,6 +537,8 @@ impl ScreenCaptureConfig { crop_bounds, fps, show_cursor, + #[cfg(windows)] + window, #[cfg(target_os = "linux")] linux_source, }, diff --git a/crates/recording/src/sources/screen_capture/windows.rs b/crates/recording/src/sources/screen_capture/windows.rs index 36d5d448386..3543679d3ea 100644 --- a/crates/recording/src/sources/screen_capture/windows.rs +++ b/crates/recording/src/sources/screen_capture/windows.rs @@ -1,6 +1,8 @@ use crate::{ AudioFrame, SetupCtx, output_pipeline, - screen_capture::{ScreenCaptureConfig, ScreenCaptureFormat, cadence::FrameCadenceGate}, + screen_capture::{ + CropBounds, ScreenCaptureConfig, ScreenCaptureFormat, cadence::FrameCadenceGate, + }, }; use ::windows::Win32::Graphics::Direct3D11::{ D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, @@ -16,7 +18,7 @@ use futures::{ channel::{mpsc, oneshot}, }; use scap_ffmpeg::*; -use scap_targets::{Display, DisplayId}; +use scap_targets::{Display, DisplayId, Window, WindowId, bounds::PhysicalSize}; use std::{ sync::{ Arc, Mutex, @@ -351,13 +353,51 @@ impl output_pipeline::VideoFrame for VideoFrame { } } +/// The size a window recording is encoded at: the window's own capture item, +/// floored to even. WGC delivers the window's frames at the item's size, which +/// differs from the window rect (invisible resize borders, odd dimensions), so +/// sizing the output from the rect sends every frame through the CPU scaler. +/// The odd row or column is trimmed on the GPU (`Settings::even_dimensions`), +/// which keeps the frames exactly this size. +pub fn window_capture_size(window: &WindowId) -> Option { + let size = Window::from_id_direct(window)? + .raw_handle() + .try_as_capture_item() + .ok()? + .Size() + .ok()?; + even_capture_size(size.Width, size.Height) +} + +fn even_capture_size(width: i32, height: i32) -> Option { + let width = (width.max(0) as u32) & !1; + let height = (height.max(0) as u32) & !1; + (width > 0 && height > 0).then(|| PhysicalSize::new(width as f64, height as f64)) +} + +/// A window target captures the window's own GraphicsCaptureItem, so the crop +/// that carves the window out of the display is neither needed nor correct: +/// WGC already hands back just that window, and the crop would additionally +/// pin the recording to wherever the window started out. +fn crop_for_target( + window: Option<&WindowId>, + crop_bounds: Option, +) -> Option { + match window { + Some(_) => None, + None => crop_bounds, + } +} + impl ScreenCaptureConfig { pub async fn to_sources( &self, ) -> anyhow::Result<(VideoSourceConfig, Option)> { + let crop_bounds = crop_for_target(self.config.window.as_ref(), self.config.crop_bounds); + let mut settings = scap_direct3d::Settings { pixel_format: Direct3DCapture::PIXEL_FORMAT, - crop: self.config.crop_bounds.map(|b| { + crop: crop_bounds.map(|b| { let position = b.position(); let size = b.size().map(|v| (v / 2.0).floor() * 2.0); @@ -375,6 +415,8 @@ impl ScreenCaptureConfig { back: 1, } }), + // Matches `window_capture_size`, which the output is sized from. + even_dimensions: self.config.window.is_some(), ..Default::default() }; @@ -393,12 +435,13 @@ impl ScreenCaptureConfig { settings.fps = Some(self.config.fps); - // Store the display ID instead of GraphicsCaptureItem to avoid COM threading issues + // Store the target's ID instead of GraphicsCaptureItem to avoid COM threading issues // The GraphicsCaptureItem will be created on the capture thread Ok(( VideoSourceConfig { video_info: self.video_info, display_id: self.config.display.clone(), + window_id: self.config.window.clone(), settings, d3d_device: self.d3d_device.clone(), }, @@ -416,6 +459,7 @@ pub enum VideoSourceError { pub struct VideoSourceConfig { video_info: VideoInfo, display_id: DisplayId, + window_id: Option, settings: scap_direct3d::Settings, pub d3d_device: ID3D11Device, } @@ -487,6 +531,7 @@ struct CaptureClosureEvent { struct CreateCapturerParams<'a> { display_id: &'a DisplayId, + window_id: Option<&'a WindowId>, settings: &'a scap_direct3d::Settings, d3d_device: &'a ID3D11Device, video_tx: &'a mpsc::Sender, @@ -511,11 +556,21 @@ fn create_d3d_capturer( params: &CreateCapturerParams, error_tx: &mpsc::Sender, ) -> anyhow::Result { - let capture_item = Display::from_id(params.display_id) - .ok_or_else(|| anyhow!("Display not found for ID: {:?}", params.display_id))? - .raw_handle() - .try_as_capture_item() - .map_err(|e| anyhow!("Failed to create GraphicsCaptureItem: {}", e))?; + let capture_item = match params.window_id { + // Resolve the handle directly: `from_id` searches `list()`, which + // skips minimized windows, so a target minimized between selection + // and capture setup would fail to start despite still existing. + Some(window_id) => Window::from_id_direct(window_id) + .ok_or_else(|| anyhow!("Window not found for ID: {:?}", window_id))? + .raw_handle() + .try_as_capture_item() + .map_err(|e| anyhow!("Failed to create window GraphicsCaptureItem: {}", e))?, + None => Display::from_id(params.display_id) + .ok_or_else(|| anyhow!("Display not found for ID: {:?}", params.display_id))? + .raw_handle() + .try_as_capture_item() + .map_err(|e| anyhow!("Failed to create GraphicsCaptureItem: {}", e))?, + }; scap_direct3d::Capturer::new( capture_item, @@ -661,6 +716,7 @@ impl output_pipeline::VideoSource for VideoSource { VideoSourceConfig { video_info, display_id, + window_id, settings, d3d_device, }: Self::Config, @@ -723,6 +779,7 @@ impl output_pipeline::VideoSource for VideoSource { ($device:expr) => { CreateCapturerParams { display_id: &display_id, + window_id: window_id.as_ref(), settings: &settings, d3d_device: $device, video_tx: &video_tx, @@ -1598,3 +1655,40 @@ mod first_screen_frame_tests { assert!(error.to_string().contains("closed before its first frame")); } } + +#[cfg(test)] +mod capture_target_tests { + use super::*; + use scap_targets::bounds::{PhysicalPosition, PhysicalSize}; + + fn bounds() -> CropBounds { + CropBounds::new( + PhysicalPosition::new(120.0, 80.0), + PhysicalSize::new(640.0, 480.0), + ) + } + + #[test] + fn a_display_target_keeps_its_crop() { + let kept = crop_for_target(None, Some(bounds())).expect("crop kept"); + assert_eq!(kept.position().x(), 120.0); + assert_eq!(kept.size().width(), 640.0); + assert!(crop_for_target(None, None).is_none()); + } + + #[test] + fn a_window_recording_is_sized_to_its_capture_item_floored_to_even() { + let size = even_capture_size(801, 601).expect("size"); + assert_eq!((size.width(), size.height()), (800.0, 600.0)); + let size = even_capture_size(786, 593).expect("size"); + assert_eq!((size.width(), size.height()), (786.0, 592.0)); + assert!(even_capture_size(1, 600).is_none()); + assert!(even_capture_size(-4, 600).is_none()); + } + + #[test] + fn a_window_target_captures_the_window_rather_than_a_crop_of_the_display() { + let window: WindowId = "1234".parse().unwrap(); + assert!(crop_for_target(Some(&window), Some(bounds())).is_none()); + } +} diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 8c962a50f97..e3f9b9a6d64 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -3406,6 +3406,8 @@ async fn create_segment_pipeline( max_capture_size, start_time.system_time(), base_inputs.capture_system_audio, + #[cfg(windows)] + capture_target.window(), #[cfg(target_os = "linux")] sources::screen_capture::LinuxCaptureSource::from_target(&capture_target), #[cfg(windows)] @@ -3641,7 +3643,7 @@ async fn create_segment_pipeline( crate::cursor::CursorCaptureTarget { crop_bounds: cursor_crop_bounds, display: cursor_display, - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] window: base_inputs.capture_target.window(), }, cursors_dir.to_path_buf(), diff --git a/crates/scap-direct3d/src/lib.rs b/crates/scap-direct3d/src/lib.rs index dbe42566731..f5e5effab17 100644 --- a/crates/scap-direct3d/src/lib.rs +++ b/crates/scap-direct3d/src/lib.rs @@ -328,6 +328,33 @@ fn create_d3d_device_with_warp_fallback() -> windows::core::Result<(ID3D11Device } } +fn create_copy_texture( + d3d_device: &ID3D11Device, + width: u32, + height: u32, + pixel_format: PixelFormat, +) -> windows::core::Result { + let desc = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: pixel_format.as_dxgi(), + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + + let mut texture = None; + unsafe { d3d_device.CreateTexture2D(&desc, None, Some(&mut texture)) }?; + texture.ok_or_else(windows::core::Error::from_win32) +} + #[derive(Clone, Default, Debug)] pub struct Settings { pub is_border_required: Option, @@ -336,6 +363,10 @@ pub struct Settings { pub pixel_format: PixelFormat, pub crop: Option, pub fps: Option, + /// Trim an odd-sized frame to the even size below it on the GPU, so a + /// consumer that encodes at even dimensions receives frames it can take as + /// they are. Ignored when `crop` is set. + pub even_dimensions: bool, } impl Settings { @@ -506,14 +537,18 @@ impl Capturer { .map(|fps| ((fps as f32 / 30.0 * 2.0).ceil() as i32).clamp(2, 4)) .unwrap_or(2); + let item_size = item.Size().map_err(NewCapturerError::ItemSize)?; + let frame_pool = Direct3D11CaptureFramePool::CreateFreeThreaded( &direct3d_device, settings.pixel_format.as_directx(), frame_pool_size, - item.Size().map_err(NewCapturerError::ItemSize)?, + item_size, ) .map_err(NewCapturerError::FramePool)?; + let pool_size = Arc::new(Mutex::new((item_size.Width, item_size.Height))); + let session = frame_pool .CreateCaptureSession(&item) .map_err(NewCapturerError::CaptureSession)?; @@ -539,34 +574,22 @@ impl Capturer { let crop_data = settings .crop .map(|crop| { - let desc = D3D11_TEXTURE2D_DESC { - Width: (crop.right - crop.left), - Height: (crop.bottom - crop.top), - MipLevels: 1, - ArraySize: 1, - Format: settings.pixel_format.as_dxgi(), - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, - CPUAccessFlags: 0, - MiscFlags: 0, - }; - - let mut texture = None; - unsafe { d3d_device.CreateTexture2D(&desc, None, Some(&mut texture)) } - .map_err(NewCapturerError::CropTexture)?; - - let texture = texture.ok_or_else(|| { - NewCapturerError::CropTexture(windows::core::Error::from_win32()) - })?; + let texture = create_copy_texture( + &d3d_device, + crop.right - crop.left, + crop.bottom - crop.top, + settings.pixel_format, + ) + .map_err(NewCapturerError::CropTexture)?; Ok::<_, NewCapturerError>((texture, crop)) }) .transpose()?; + // Allocated on the first odd-sized frame and again whenever that size + // changes, so an even-sized capture never pays for the copy. + let even_trim = Mutex::new(None::<(ID3D11Texture2D, u32, u32)>); + let frame_arrived_token = frame_pool .FrameArrived( &TypedEventHandler::::new({ @@ -575,6 +598,9 @@ impl Capturer { let stop_flag = stop_flag.clone(); let staging_pool = staging_pool.clone(); let callback_activity = callback_activity.clone(); + let pixel_format = settings.pixel_format; + let pool_size = pool_size.clone(); + let even_dimensions = settings.even_dimensions && settings.crop.is_none(); move |frame_pool, _| { let _activity_guard = callback_activity.enter(); @@ -591,11 +617,51 @@ impl Capturer { let size = frame.ContentSize()?; + // The item can change size mid-capture (a recorded + // window is resized, or the display's resolution + // changes). The pool keeps handing out surfaces at its + // creation size, so the frame's own ContentSize stops + // describing its texture and every later frame reads + // back as black. Recreate the pool for the new size and + // skip this frame; the next one arrives correctly sized. + { + let mut pool_size = pool_size + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if (size.Width, size.Height) != *pool_size { + drop(frame); + // IDirect3DDevice is not Send, so it is rebuilt + // here from the capture device rather than held + // across the event handler. + let dxgi_device = d3d_device.cast::()?; + let direct3d_device = + unsafe { CreateDirect3D11DeviceFromDXGIDevice(&dxgi_device) }? + .cast::()?; + frame_pool.Recreate( + &direct3d_device, + pixel_format.as_directx(), + frame_pool_size, + size, + )?; + *pool_size = (size.Width, size.Height); + return Ok(()); + } + } + let surface = frame.Surface()?; let dxgi_interface = surface.cast::()?; let texture = unsafe { dxgi_interface.GetInterface::() }?; let frame = if let Some((cropped_texture, crop)) = crop_data.clone() { + // A shrunk item leaves the crop box hanging off the + // edge of the source texture, which copies nothing + // and would vend a stale surface as if it were live. + if crop.right > size.Width.max(0) as u32 + || crop.bottom > size.Height.max(0) as u32 + { + return Ok(()); + } + unsafe { d3d_context.CopySubresourceRegion( &cropped_texture, @@ -619,6 +685,64 @@ impl Capturer { d3d_device: d3d_device.clone(), staging_pool: staging_pool.clone(), } + } else if even_dimensions && (size.Width % 2 != 0 || size.Height % 2 != 0) { + let width = (size.Width.max(0) as u32) & !1; + let height = (size.Height.max(0) as u32) & !1; + if width == 0 || height == 0 { + return Ok(()); + } + + let trimmed_texture = { + let mut even_trim = even_trim + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match even_trim.as_ref() { + Some((texture, w, h)) if (*w, *h) == (width, height) => { + texture.clone() + } + _ => { + let texture = create_copy_texture( + &d3d_device, + width, + height, + pixel_format, + )?; + *even_trim = Some((texture.clone(), width, height)); + texture + } + } + }; + + unsafe { + d3d_context.CopySubresourceRegion( + &trimmed_texture, + 0, + 0, + 0, + 0, + &texture, + 0, + Some(&D3D11_BOX { + left: 0, + top: 0, + front: 0, + right: width, + bottom: height, + back: 1, + }), + ); + } + + Frame { + width, + height, + pixel_format: settings.pixel_format, + inner: frame, + texture: trimmed_texture, + d3d_context: d3d_context.clone(), + d3d_device: d3d_device.clone(), + staging_pool: staging_pool.clone(), + } } else { Frame { width: size.Width as u32, diff --git a/crates/scap-direct3d/tests/window_capture.rs b/crates/scap-direct3d/tests/window_capture.rs new file mode 100644 index 00000000000..322b869ca5f --- /dev/null +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -0,0 +1,511 @@ +#![cfg(target_os = "windows")] + +//! Window capture regressions for CapSoftware/Cap#2151. +//! +//! The fixture process owns the windows because `Window::list()` skips the +//! calling process, the same arrangement `scap-targets`' window discovery test +//! uses. + +use scap_direct3d::{Capturer, PixelFormat, Settings}; +use scap_targets::{Window, WindowId}; +use std::{ + io::{BufRead, BufReader, Write}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex, Once, mpsc}, + time::{Duration, Instant}, +}; +use windows::{ + Win32::{ + Foundation::{COLORREF, HWND, LPARAM, LRESULT, WPARAM}, + Graphics::{ + Direct3D11::D3D11_BOX, + Gdi::{CreateSolidBrush, InvalidateRect}, + }, + UI::{ + HiDpi::{PROCESS_PER_MONITOR_DPI_AWARE, SetProcessDpiAwareness}, + WindowsAndMessaging::{ + CS_HREDRAW, CS_VREDRAW, CreateWindowExW, DefWindowProcW, DispatchMessageW, + HWND_TOPMOST, MSG, PM_REMOVE, PeekMessageW, RegisterClassW, SWP_NOACTIVATE, + SWP_SHOWWINDOW, SetWindowPos, TranslateMessage, WINDOW_EX_STYLE, WNDCLASSW, + WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_VISIBLE, + }, + }, + }, + core::{PCWSTR, w}, +}; + +const TARGET_X: i32 = 200; +const TARGET_Y: i32 = 200; +const TARGET_W: i32 = 800; +const TARGET_H: i32 = 600; +const OVERLAY_W: i32 = 300; +const OVERLAY_H: i32 = 200; +const RESIZED_W: i32 = 1100; +const RESIZED_H: i32 = 700; + +struct FixtureProcess(Child); + +impl Drop for FixtureProcess { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Process-wide and only settable once, so both tests here share one call. +fn match_cli_dpi_awareness() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + unsafe { SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) } + .expect("match CLI per-monitor DPI awareness"); + }); +} + +unsafe extern "system" fn window_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + unsafe { DefWindowProcW(window, message, wparam, lparam) } +} + +fn register_class(class: PCWSTR, colour: u32) { + let class = WNDCLASSW { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(window_proc), + hbrBackground: unsafe { CreateSolidBrush(COLORREF(colour)) }, + lpszClassName: class, + ..Default::default() + }; + assert!(unsafe { RegisterClassW(&class) } != 0, "register class"); +} + +/// Runs in the fixture process: a blue window with a red always-on-top window +/// covering part of it, resized once `resize` is read on stdin. +#[test] +fn window_capture_fixture_process() { + if std::env::var_os("CAP_WINDOW_CAPTURE_FIXTURE").is_none() { + return; + } + + match_cli_dpi_awareness(); + + // COLORREF is 0x00BBGGRR. + register_class(w!("Cap2151Target"), 0x00FF_0000); + register_class(w!("Cap2151Overlay"), 0x0000_00FF); + + let target = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("Cap2151Target"), + w!("Cap2151 target"), + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + TARGET_X, + TARGET_Y, + TARGET_W, + TARGET_H, + None, + None, + None, + None, + ) + } + .expect("create target window"); + let overlay = unsafe { + CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + w!("Cap2151Overlay"), + w!("Cap2151 overlay"), + WS_POPUP | WS_VISIBLE, + TARGET_X + 200, + TARGET_Y + 150, + OVERLAY_W, + OVERLAY_H, + None, + None, + None, + None, + ) + } + .expect("create overlay window"); + unsafe { + SetWindowPos( + overlay, + Some(HWND_TOPMOST), + TARGET_X + 200, + TARGET_Y + 150, + OVERLAY_W, + OVERLAY_H, + SWP_SHOWWINDOW | SWP_NOACTIVATE, + ) + } + .expect("raise overlay"); + + println!("WINDOW_CAPTURE_FIXTURE {}", target.0 as u64); + std::io::stdout().flush().unwrap(); + + let (command_tx, command_rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(std::io::stdin()).lines() { + let Ok(line) = line else { break }; + if command_tx.send(line).is_err() { + break; + } + } + }); + + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(60) { + match command_rx.try_recv() { + Ok(line) if line.trim() == "resize" => unsafe { + SetWindowPos( + target, + None, + TARGET_X, + TARGET_Y, + RESIZED_W, + RESIZED_H, + SWP_SHOWWINDOW | SWP_NOACTIVATE, + ) + .expect("resize target"); + }, + // `size W H` resizes in place; `at X Y W H` also moves the window. + Ok(line) if line.trim().starts_with("size ") || line.trim().starts_with("at ") => { + let mut words = line.trim().split(' '); + let moves = words.next() == Some("at"); + let mut dims = words.map(|v| v.parse::().expect("fixture command number")); + let (x, y) = if moves { + (dims.next().unwrap(), dims.next().unwrap()) + } else { + (TARGET_X, TARGET_Y) + }; + let (width, height) = (dims.next().unwrap(), dims.next().unwrap()); + unsafe { + SetWindowPos( + target, + None, + x, + y, + width, + height, + SWP_SHOWWINDOW | SWP_NOACTIVATE, + ) + .expect("size target"); + } + } + Ok(line) if line.trim() == "stop" => return, + Ok(_) | Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => return, + } + + // WGC only delivers a frame when the content changes, so keep the + // window repainting for the duration of the capture. + let _ = unsafe { InvalidateRect(Some(target), None, true) }; + let mut message = MSG::default(); + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + let _ = unsafe { TranslateMessage(&message) }; + unsafe { DispatchMessageW(&message) }; + } + std::thread::sleep(Duration::from_millis(16)); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Sample { + width: u32, + height: u32, + red: usize, + blue: usize, +} + +fn capture_samples( + item: windows::Graphics::Capture::GraphicsCaptureItem, + crop: Option, + duration: Duration, + during: impl FnOnce(), +) -> Vec { + capture_samples_with( + item, + Settings { + crop, + ..Default::default() + }, + duration, + during, + ) +} + +fn capture_samples_with( + item: windows::Graphics::Capture::GraphicsCaptureItem, + settings: Settings, + duration: Duration, + during: impl FnOnce(), +) -> Vec { + let samples: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = samples.clone(); + let mut capturer = Capturer::new( + item, + Settings { + is_border_required: Some(false), + is_cursor_capture_enabled: Some(false), + pixel_format: PixelFormat::R8G8B8A8Unorm, + fps: Some(30), + ..settings + }, + move |frame| { + let buffer = frame.as_buffer()?; + let (width, height, stride) = ( + buffer.width() as usize, + buffer.height() as usize, + buffer.stride() as usize, + ); + let data = buffer.data(); + let (mut red, mut blue) = (0usize, 0usize); + for y in 0..height { + for x in 0..width { + let pixel = &data[y * stride + x * 4..y * stride + x * 4 + 4]; + let (r, g, b) = (pixel[0], pixel[1], pixel[2]); + if r > 200 && g < 60 && b < 60 { + red += 1; + } else if b > 200 && r < 60 && g < 60 { + blue += 1; + } + } + } + sink.lock().unwrap().push(Sample { + width: width as u32, + height: height as u32, + red, + blue, + }); + Ok(()) + }, + || Ok(()), + None, + ) + .expect("create capturer"); + + capturer.start().expect("start capture"); + during(); + std::thread::sleep(duration); + capturer.stop().expect("stop capture"); + let samples = samples.lock().unwrap(); + samples.clone() +} + +struct Fixture { + process: FixtureProcess, + target: WindowId, +} + +impl Fixture { + fn start() -> Self { + let mut process = FixtureProcess( + Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "window_capture_fixture_process", "--nocapture"]) + .env("CAP_WINDOW_CAPTURE_FIXTURE", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("start window capture fixture"), + ); + let stdout = process.0.stdout.take().unwrap(); + let (ready_tx, ready_rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if let Some((_, id)) = line.split_once("WINDOW_CAPTURE_FIXTURE ") { + let _ = ready_tx.send(id.to_string()); + } + } + }); + let target: WindowId = ready_rx + .recv_timeout(Duration::from_secs(15)) + .expect("fixture must become ready") + .trim() + .parse() + .expect("parse fixture window id"); + // Give the windows a moment to paint before capturing them. + std::thread::sleep(Duration::from_millis(800)); + Self { process, target } + } + + fn window(&self) -> Window { + Window::from_id(&self.target).expect("fixture window must be discoverable") + } + + fn send(&mut self, command: &str) { + let stdin = self.process.0.stdin.as_mut().expect("fixture stdin"); + stdin.write_all(command.as_bytes()).unwrap(); + stdin.write_all(b"\n").unwrap(); + stdin.flush().unwrap(); + } +} + +/// Capturing a window through the display's capture item sees whatever is +/// stacked on top of it; capturing the window's own item does not. +#[test] +fn window_item_excludes_windows_stacked_on_top() { + match_cli_dpi_awareness(); + + let fixture = Fixture::start(); + let window = fixture.window(); + let display = window.display().expect("window display"); + let window_bounds = window + .raw_handle() + .physical_bounds() + .expect("window bounds"); + let display_position = display + .raw_handle() + .physical_position() + .expect("display position"); + + let crop = D3D11_BOX { + left: (window_bounds.position().x() - display_position.x()).max(0.0) as u32, + top: (window_bounds.position().y() - display_position.y()).max(0.0) as u32, + right: (window_bounds.position().x() - display_position.x() + window_bounds.size().width()) + .max(0.0) as u32, + bottom: (window_bounds.position().y() - display_position.y() + + window_bounds.size().height()) + .max(0.0) as u32, + front: 0, + back: 1, + }; + + let cropped = capture_samples( + display + .raw_handle() + .try_as_capture_item() + .expect("display capture item"), + Some(crop), + Duration::from_secs(2), + || {}, + ); + let cropped = cropped.last().copied().expect("cropped display frames"); + assert!( + cropped.red > 0, + "a display capture cropped to the window must show the window stacked on top of it, \ + which is the bug being fixed: {cropped:?}" + ); + + let window_item = capture_samples( + window + .raw_handle() + .try_as_capture_item() + .expect("window capture item"), + None, + Duration::from_secs(2), + || {}, + ); + let window_item = window_item.last().copied().expect("window frames"); + assert_eq!( + window_item.red, 0, + "the window's own capture item must not contain the window stacked on top of it: \ + {window_item:?}" + ); + assert!( + window_item.blue > 0, + "the window's own capture item must contain the window: {window_item:?}" + ); +} + +/// The frame pool is created at the item's size; when the item resizes, frames +/// keep arriving with the new ContentSize but the pool's surfaces are still the +/// old size, so every frame reads back empty until the pool is recreated. +#[test] +fn frames_survive_the_capture_item_resizing() { + match_cli_dpi_awareness(); + + let mut fixture = Fixture::start(); + let window = fixture.window(); + let before = window.physical_size().expect("window size"); + + let samples = capture_samples( + window + .raw_handle() + .try_as_capture_item() + .expect("window capture item"), + None, + Duration::from_secs(4), + || { + std::thread::sleep(Duration::from_millis(500)); + fixture.send("resize"); + }, + ); + + let grown: Vec<_> = samples + .iter() + .filter(|sample| sample.width > before.width() as u32) + .collect(); + assert!( + !grown.is_empty(), + "the capture must follow the window's new size, saw {:?}", + samples + .iter() + .map(|s| (s.width, s.height)) + .collect::>() + ); + let last = grown.last().expect("a frame after the resize"); + assert!( + last.blue > 0, + "frames after a resize must still carry the window's contents: {last:?}" + ); +} + +/// A window's capture item is often odd-sized (the window's own dimensions, or +/// the invisible resize borders taken off them), while the recording encodes +/// at even dimensions. With `even_dimensions` the odd row or column is trimmed +/// on the GPU, so every frame arrives at exactly the even size the recording +/// sizes its output from, and none fall through to the CPU scaler. Two sizes +/// one pixel apart guarantee each axis is odd for one of them. +#[test] +fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { + match_cli_dpi_awareness(); + + let mut fixture = Fixture::start(); + let item = fixture + .window() + .raw_handle() + .try_as_capture_item() + .expect("window capture item"); + let start = item.Size().expect("item size"); + let even = |v: i32| (v.max(0) as u32) & !1; + + let samples = capture_samples_with( + item, + Settings { + even_dimensions: true, + ..Default::default() + }, + Duration::from_secs(2), + || { + std::thread::sleep(Duration::from_millis(700)); + fixture.send(&format!("size {} {}", TARGET_W + 1, TARGET_H + 1)); + std::thread::sleep(Duration::from_millis(700)); + fixture.send(&format!("size {} {}", TARGET_W + 2, TARGET_H + 2)); + }, + ); + + let sizes: Vec<_> = samples.iter().map(|s| (s.width, s.height)).collect(); + assert_eq!( + sizes.first().copied(), + Some((even(start.Width), even(start.Height))), + "the first frame must be the item's size floored to even: {sizes:?}" + ); + assert!( + sizes.iter().all(|(w, h)| w % 2 == 0 && h % 2 == 0), + "every frame must have even dimensions: {sizes:?}" + ); + let mut distinct = sizes.clone(); + distinct.dedup(); + assert!( + distinct.len() >= 2, + "the capture must follow both resizes, saw {distinct:?}" + ); + let last = samples.last().expect("frames"); + assert!( + last.blue > 0, + "trimmed frames must still carry the window's contents: {last:?}" + ); +} diff --git a/crates/scap-targets/src/lib.rs b/crates/scap-targets/src/lib.rs index 34a085f186d..0ffd4fcc661 100644 --- a/crates/scap-targets/src/lib.rs +++ b/crates/scap-targets/src/lib.rs @@ -147,6 +147,15 @@ impl Window { Self::list().into_iter().find(|d| &d.id() == id) } + /// Like [`Window::from_id`], but resolves the handle directly instead of + /// searching `list()`. Prefer it when the window was already chosen and + /// only needs to still exist: enumeration drops minimized windows, so + /// `from_id` reports a minimized target as gone. + #[cfg(windows)] + pub fn from_id_direct(id: &WindowId) -> Option { + WindowImpl::from_id_direct(&id.0).map(Self) + } + pub fn physical_size(&self) -> Option { self.0.physical_size() } diff --git a/crates/scap-targets/src/platform/win.rs b/crates/scap-targets/src/platform/win.rs index 0a824ae68c8..9fadc1b9d8c 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -47,7 +47,8 @@ use windows::{ GetCursorPos, GetIconInfo, GetLayeredWindowAttributes, GetWindow, GetWindowLongPtrW, GetWindowLongW, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, HICON, ICONINFO, IsIconic, - IsWindowVisible, PrivateExtractIconsW, SendMessageW, WM_GETICON, WS_CHILD, + IsWindow, IsWindowVisible, PrivateExtractIconsW, SendMessageW, WM_GETICON, + WS_CHILD, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WindowFromPoint, }, }, @@ -345,6 +346,16 @@ impl WindowImpl { context.list } + /// Resolve an id straight back to its HWND, without going through + /// `list()`. Enumeration deliberately skips minimized and cloaked + /// windows, so a window the user picked and then minimized is absent + /// from `list()` while still being a perfectly capturable target. + /// `IsWindow` is the only liveness question that matters here. + pub fn from_id_direct(id: &WindowIdImpl) -> Option { + let hwnd = HWND(id.0 as *mut std::ffi::c_void); + unsafe { IsWindow(Some(hwnd)) }.as_bool().then_some(Self(hwnd)) + } + pub fn inner(&self) -> HWND { self.0 } From fd8daaa84b8ee0a283f0882e7c580c214bdffb3f Mon Sep 17 00:00:00 2001 From: Stephen Psaradellis Date: Fri, 23 Oct 2026 13:20:50 -0500 Subject: [PATCH 2/6] fix(scap-direct3d): only follow the item's size for window captures Recreating the frame pool on a size change, and skipping frames a crop box no longer fits inside, now sit behind Settings::follow_item_size. windows.rs sets it alongside even_dimensions for window targets only, so display and area recordings behave exactly as on main. --- .../recording/src/sources/screen_capture/windows.rs | 3 +++ crates/scap-direct3d/src/lib.rs | 12 +++++++++--- crates/scap-direct3d/tests/window_capture.rs | 7 +++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/recording/src/sources/screen_capture/windows.rs b/crates/recording/src/sources/screen_capture/windows.rs index 3543679d3ea..bf99efb640f 100644 --- a/crates/recording/src/sources/screen_capture/windows.rs +++ b/crates/recording/src/sources/screen_capture/windows.rs @@ -417,6 +417,9 @@ impl ScreenCaptureConfig { }), // Matches `window_capture_size`, which the output is sized from. even_dimensions: self.config.window.is_some(), + // Only a window target follows its item's size; display and area + // recordings keep the pool they were created with. + follow_item_size: self.config.window.is_some(), ..Default::default() }; diff --git a/crates/scap-direct3d/src/lib.rs b/crates/scap-direct3d/src/lib.rs index f5e5effab17..77b31a6faf0 100644 --- a/crates/scap-direct3d/src/lib.rs +++ b/crates/scap-direct3d/src/lib.rs @@ -367,6 +367,10 @@ pub struct Settings { /// consumer that encodes at even dimensions receives frames it can take as /// they are. Ignored when `crop` is set. pub even_dimensions: bool, + /// Follow the capture item when it changes size: recreate the frame pool + /// at the new size, and drop frames a `crop` box no longer fits inside. + /// Off by default so display and area captures behave as they always have. + pub follow_item_size: bool, } impl Settings { @@ -601,6 +605,7 @@ impl Capturer { let pixel_format = settings.pixel_format; let pool_size = pool_size.clone(); let even_dimensions = settings.even_dimensions && settings.crop.is_none(); + let follow_item_size = settings.follow_item_size; move |frame_pool, _| { let _activity_guard = callback_activity.enter(); @@ -624,7 +629,7 @@ impl Capturer { // describing its texture and every later frame reads // back as black. Recreate the pool for the new size and // skip this frame; the next one arrives correctly sized. - { + if follow_item_size { let mut pool_size = pool_size .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -656,8 +661,9 @@ impl Capturer { // A shrunk item leaves the crop box hanging off the // edge of the source texture, which copies nothing // and would vend a stale surface as if it were live. - if crop.right > size.Width.max(0) as u32 - || crop.bottom > size.Height.max(0) as u32 + if follow_item_size + && (crop.right > size.Width.max(0) as u32 + || crop.bottom > size.Height.max(0) as u32) { return Ok(()); } diff --git a/crates/scap-direct3d/tests/window_capture.rs b/crates/scap-direct3d/tests/window_capture.rs index 322b869ca5f..895f613a134 100644 --- a/crates/scap-direct3d/tests/window_capture.rs +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -421,12 +421,15 @@ fn frames_survive_the_capture_item_resizing() { let window = fixture.window(); let before = window.physical_size().expect("window size"); - let samples = capture_samples( + let samples = capture_samples_with( window .raw_handle() .try_as_capture_item() .expect("window capture item"), - None, + Settings { + follow_item_size: true, + ..Default::default() + }, Duration::from_secs(4), || { std::thread::sleep(Duration::from_millis(500)); From e71c443f8a9efcbfa2f3e930063af5c8e86bc85c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:02:32 +0100 Subject: [PATCH 3/6] style(scap-targets): rustfmt the Windows capture target --- crates/scap-targets/src/platform/win.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/scap-targets/src/platform/win.rs b/crates/scap-targets/src/platform/win.rs index 9fadc1b9d8c..09bcb52ff03 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -46,9 +46,8 @@ use windows::{ GWL_EXSTYLE, GWL_STYLE, GetClassLongPtrW, GetClassNameW, GetClientRect, GetCursorPos, GetIconInfo, GetLayeredWindowAttributes, GetWindow, GetWindowLongPtrW, GetWindowLongW, GetWindowRect, GetWindowTextLengthW, - GetWindowTextW, GetWindowThreadProcessId, HICON, ICONINFO, IsIconic, - IsWindow, IsWindowVisible, PrivateExtractIconsW, SendMessageW, WM_GETICON, - WS_CHILD, + GetWindowTextW, GetWindowThreadProcessId, HICON, ICONINFO, IsIconic, IsWindow, + IsWindowVisible, PrivateExtractIconsW, SendMessageW, WM_GETICON, WS_CHILD, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WindowFromPoint, }, }, @@ -353,7 +352,9 @@ impl WindowImpl { /// `IsWindow` is the only liveness question that matters here. pub fn from_id_direct(id: &WindowIdImpl) -> Option { let hwnd = HWND(id.0 as *mut std::ffi::c_void); - unsafe { IsWindow(Some(hwnd)) }.as_bool().then_some(Self(hwnd)) + unsafe { IsWindow(Some(hwnd)) } + .as_bool() + .then_some(Self(hwnd)) } pub fn inner(&self) -> HWND { From 25c952bc5694285956ac517d1746799bb8400c92 Mon Sep 17 00:00:00 2001 From: Stephen Psaradellis Date: Sat, 24 Oct 2026 07:51:08 -0500 Subject: [PATCH 4/6] test(scap-direct3d): follow the item's size in the even-dimension trim test The test resizes the window twice; without follow_item_size the frame pool keeps its first surfaces while the trim copy uses the new ContentSize. --- crates/scap-direct3d/tests/window_capture.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/scap-direct3d/tests/window_capture.rs b/crates/scap-direct3d/tests/window_capture.rs index 895f613a134..b28d4858e29 100644 --- a/crates/scap-direct3d/tests/window_capture.rs +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -479,6 +479,7 @@ fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { item, Settings { even_dimensions: true, + follow_item_size: true, ..Default::default() }, Duration::from_secs(2), From 9e1a11d18eec497f860cf7df5c9eadc2f4b2f3b0 Mon Sep 17 00:00:00 2001 From: Stephen Psaradellis Date: Sat, 24 Oct 2026 12:19:30 -0500 Subject: [PATCH 5/6] fix(scap-direct3d): bound GPU copies by the frame surface, not ContentSize ContentSize is the item's size at capture time, and it runs ahead of the frame's surface whenever the item grows before the pool is recreated. That includes frames the old pool had already queued when Recreate ran: they carry the new ContentSize on a surface still at the old size, so the even-dimension trim copied a source box past the surface's edge. Hardware drivers ignore that; WARP removes the device, and Windows.Graphics.Capture then fails fast (0xc0000409) on its next use of it. - skip frames whose surface does not match their ContentSize when following the item's size (queued before the pool was recreated) - clamp the even-dimension trim and the crop check to the surface size - report a removed device instead of recreating the pool on it Tests: - can run on WARP (CAP_SCAP_TEST_WARP=1 for all, always for the two new ones) and fail if the capture device was removed - new: frames queued behind a slow consumer are not copied on recreate - new: the trim stays inside the surface without follow_item_size - size and place the fixture windows from the primary work area so the stacked-window crop fits on a 1024x768 display --- crates/scap-direct3d/src/lib.rs | 54 +++- crates/scap-direct3d/tests/window_capture.rs | 277 ++++++++++++++++--- 2 files changed, 292 insertions(+), 39 deletions(-) diff --git a/crates/scap-direct3d/src/lib.rs b/crates/scap-direct3d/src/lib.rs index 77b31a6faf0..598f42b7f6b 100644 --- a/crates/scap-direct3d/src/lib.rs +++ b/crates/scap-direct3d/src/lib.rs @@ -635,6 +635,17 @@ impl Capturer { .unwrap_or_else(|poisoned| poisoned.into_inner()); if (size.Width, size.Height) != *pool_size { drop(frame); + // Windows.Graphics.Capture fails fast, on a + // thread nothing here can catch, once its device + // is removed. Do not hand it the removed device + // again; report the removal instead. + if let Err(error) = unsafe { d3d_device.GetDeviceRemovedReason() } { + tracing::error!( + %error, + "scap-direct3d: capture device removed, not recreating the frame pool" + ); + return Err(error); + } // IDirect3DDevice is not Send, so it is rebuilt // here from the capture device rather than held // across the event handler. @@ -657,13 +668,39 @@ impl Capturer { let dxgi_interface = surface.cast::()?; let texture = unsafe { dxgi_interface.GetInterface::() }?; + // ContentSize is the item's size when the frame was + // captured, not the size of the surface it was written + // to: it runs ahead of the surface whenever the item + // grows before the pool is recreated, including frames + // the old pool had already queued when `Recreate` ran. + // Copies are bounded by the surface itself, because a + // source box past its edge is undefined behaviour that + // a hardware driver ignores but WARP answers by removing + // the device, and Windows.Graphics.Capture then fails + // fast on its next use of it. + let mut surface_desc = D3D11_TEXTURE2D_DESC::default(); + unsafe { texture.GetDesc(&mut surface_desc) }; + let content_width = size.Width.max(0) as u32; + let content_height = size.Height.max(0) as u32; + + // A frame queued before the pool was recreated: its + // surface is the old size, and the next frame is not. + if follow_item_size + && (surface_desc.Width, surface_desc.Height) + != (content_width, content_height) + { + return Ok(()); + } + let frame = if let Some((cropped_texture, crop)) = crop_data.clone() { // A shrunk item leaves the crop box hanging off the // edge of the source texture, which copies nothing // and would vend a stale surface as if it were live. - if follow_item_size - && (crop.right > size.Width.max(0) as u32 - || crop.bottom > size.Height.max(0) as u32) + if crop.right > surface_desc.Width + || crop.bottom > surface_desc.Height + || (follow_item_size + && (crop.right > content_width + || crop.bottom > content_height)) { return Ok(()); } @@ -691,9 +728,14 @@ impl Capturer { d3d_device: d3d_device.clone(), staging_pool: staging_pool.clone(), } - } else if even_dimensions && (size.Width % 2 != 0 || size.Height % 2 != 0) { - let width = (size.Width.max(0) as u32) & !1; - let height = (size.Height.max(0) as u32) & !1; + } else if even_dimensions + && ( + content_width.min(surface_desc.Width) & !1, + content_height.min(surface_desc.Height) & !1, + ) != (content_width, content_height) + { + let width = content_width.min(surface_desc.Width) & !1; + let height = content_height.min(surface_desc.Height) & !1; if width == 0 || height == 0 { return Ok(()); } diff --git a/crates/scap-direct3d/tests/window_capture.rs b/crates/scap-direct3d/tests/window_capture.rs index b28d4858e29..e98ea2f4c5d 100644 --- a/crates/scap-direct3d/tests/window_capture.rs +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -16,17 +16,22 @@ use std::{ }; use windows::{ Win32::{ - Foundation::{COLORREF, HWND, LPARAM, LRESULT, WPARAM}, + Foundation::{COLORREF, HMODULE, HWND, LPARAM, LRESULT, RECT, WPARAM}, Graphics::{ - Direct3D11::D3D11_BOX, + Direct3D::D3D_DRIVER_TYPE_WARP, + Direct3D11::{ + D3D11_BOX, D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice, + ID3D11Device, + }, Gdi::{CreateSolidBrush, InvalidateRect}, }, UI::{ HiDpi::{PROCESS_PER_MONITOR_DPI_AWARE, SetProcessDpiAwareness}, WindowsAndMessaging::{ CS_HREDRAW, CS_VREDRAW, CreateWindowExW, DefWindowProcW, DispatchMessageW, - HWND_TOPMOST, MSG, PM_REMOVE, PeekMessageW, RegisterClassW, SWP_NOACTIVATE, - SWP_SHOWWINDOW, SetWindowPos, TranslateMessage, WINDOW_EX_STYLE, WNDCLASSW, + HWND_TOPMOST, MSG, PM_REMOVE, PeekMessageW, RegisterClassW, SPI_GETWORKAREA, + SWP_NOACTIVATE, SWP_SHOWWINDOW, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SetWindowPos, + SystemParametersInfoW, TranslateMessage, WINDOW_EX_STYLE, WNDCLASSW, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_VISIBLE, }, }, @@ -34,14 +39,55 @@ use windows::{ core::{PCWSTR, w}, }; -const TARGET_X: i32 = 200; -const TARGET_Y: i32 = 200; -const TARGET_W: i32 = 800; -const TARGET_H: i32 = 600; -const OVERLAY_W: i32 = 300; -const OVERLAY_H: i32 = 200; -const RESIZED_W: i32 = 1100; -const RESIZED_H: i32 = 700; +/// Where the fixture puts the target window: a fixed fraction of the primary +/// display's work area, so the windows, the crop taken around them and every +/// resize stay on screen from a 1024x768 runner up to a 4K desktop. +#[derive(Clone, Copy, Debug)] +struct Layout { + x: i32, + y: i32, + width: i32, + height: i32, +} + +impl Layout { + /// Physical pixels once the process is per-monitor DPI aware, which the + /// fixture and every test arrange before calling this. + fn of_primary_work_area() -> Self { + let mut area = RECT::default(); + unsafe { + SystemParametersInfoW( + SPI_GETWORKAREA, + 0, + Some(&mut area as *mut RECT as *mut _), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + } + .expect("primary display work area"); + let (width, height) = (area.right - area.left, area.bottom - area.top); + Self { + x: area.left + width / 8, + y: area.top + height / 8, + width: width / 2, + height: height / 2, + } + } + + /// Inside the target's client area, clear of its title bar. + fn overlay(&self) -> (i32, i32, i32, i32) { + ( + self.x + self.width / 4, + self.y + self.height / 4, + self.width / 3, + self.height / 3, + ) + } + + /// Still inside the work area: x + 3w/2 and y + 5h/4 end at 7/8 and 6/8. + fn grown(&self) -> (i32, i32) { + (self.width * 3 / 2, self.height * 5 / 4) + } +} struct FixtureProcess(Child); @@ -90,6 +136,8 @@ fn window_capture_fixture_process() { } match_cli_dpi_awareness(); + let layout = Layout::of_primary_work_area(); + let (overlay_x, overlay_y, overlay_w, overlay_h) = layout.overlay(); // COLORREF is 0x00BBGGRR. register_class(w!("Cap2151Target"), 0x00FF_0000); @@ -101,10 +149,10 @@ fn window_capture_fixture_process() { w!("Cap2151Target"), w!("Cap2151 target"), WS_OVERLAPPEDWINDOW | WS_VISIBLE, - TARGET_X, - TARGET_Y, - TARGET_W, - TARGET_H, + layout.x, + layout.y, + layout.width, + layout.height, None, None, None, @@ -118,10 +166,10 @@ fn window_capture_fixture_process() { w!("Cap2151Overlay"), w!("Cap2151 overlay"), WS_POPUP | WS_VISIBLE, - TARGET_X + 200, - TARGET_Y + 150, - OVERLAY_W, - OVERLAY_H, + overlay_x, + overlay_y, + overlay_w, + overlay_h, None, None, None, @@ -133,10 +181,10 @@ fn window_capture_fixture_process() { SetWindowPos( overlay, Some(HWND_TOPMOST), - TARGET_X + 200, - TARGET_Y + 150, - OVERLAY_W, - OVERLAY_H, + overlay_x, + overlay_y, + overlay_w, + overlay_h, SWP_SHOWWINDOW | SWP_NOACTIVATE, ) } @@ -159,13 +207,14 @@ fn window_capture_fixture_process() { while started.elapsed() < Duration::from_secs(60) { match command_rx.try_recv() { Ok(line) if line.trim() == "resize" => unsafe { + let (width, height) = layout.grown(); SetWindowPos( target, None, - TARGET_X, - TARGET_Y, - RESIZED_W, - RESIZED_H, + layout.x, + layout.y, + width, + height, SWP_SHOWWINDOW | SWP_NOACTIVATE, ) .expect("resize target"); @@ -178,7 +227,7 @@ fn window_capture_fixture_process() { let (x, y) = if moves { (dims.next().unwrap(), dims.next().unwrap()) } else { - (TARGET_X, TARGET_Y) + (layout.x, layout.y) }; let (width, height) = (dims.next().unwrap(), dims.next().unwrap()); unsafe { @@ -236,11 +285,59 @@ fn capture_samples( ) } +/// The WARP software rasterizer, which Cap falls back to without a usable GPU. +/// It removes its device over copies a hardware driver lets pass, so it is +/// what catches an out-of-bounds copy on a machine that has a GPU. +fn warp_device() -> ID3D11Device { + let mut device = None; + unsafe { + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_WARP, + HMODULE::default(), + D3D11_CREATE_DEVICE_FLAG::default(), + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + None, + ) + } + .expect("create WARP device"); + device.expect("WARP device") +} + +/// `CAP_SCAP_TEST_WARP=1` runs every capture here on WARP; otherwise the +/// capturer picks its device the way the recorder does. +fn device_from_env() -> Option { + std::env::var_os("CAP_SCAP_TEST_WARP").map(|_| warp_device()) +} + fn capture_samples_with( item: windows::Graphics::Capture::GraphicsCaptureItem, settings: Settings, duration: Duration, during: impl FnOnce(), +) -> Vec { + capture_samples_on( + item, + settings, + device_from_env(), + Duration::ZERO, + duration, + during, + ) +} + +/// `frame_delay` holds each frame in the callback, standing in for a consumer +/// slower than the capture, so frames queue up in the pool behind it. +fn capture_samples_on( + item: windows::Graphics::Capture::GraphicsCaptureItem, + settings: Settings, + device: Option, + frame_delay: Duration, + duration: Duration, + during: impl FnOnce(), ) -> Vec { let samples: Arc>> = Arc::new(Mutex::new(Vec::new())); let sink = samples.clone(); @@ -254,6 +351,9 @@ fn capture_samples_with( ..settings }, move |frame| { + if !frame_delay.is_zero() { + std::thread::sleep(frame_delay); + } let buffer = frame.as_buffer()?; let (width, height, stride) = ( buffer.width() as usize, @@ -282,7 +382,7 @@ fn capture_samples_with( Ok(()) }, || Ok(()), - None, + device, ) .expect("create capturer"); @@ -290,6 +390,10 @@ fn capture_samples_with( during(); std::thread::sleep(duration); capturer.stop().expect("stop capture"); + // A removed device stops the frames without failing anything else, and + // on WARP it is how an invalid copy shows up. + unsafe { capturer.d3d_device().GetDeviceRemovedReason() } + .expect("the capture device must not be removed during the capture"); let samples = samples.lock().unwrap(); samples.clone() } @@ -297,6 +401,7 @@ fn capture_samples_with( struct Fixture { process: FixtureProcess, target: WindowId, + layout: Layout, } impl Fixture { @@ -328,13 +433,23 @@ impl Fixture { .expect("parse fixture window id"); // Give the windows a moment to paint before capturing them. std::thread::sleep(Duration::from_millis(800)); - Self { process, target } + Self { + process, + target, + layout: Layout::of_primary_work_area(), + } } fn window(&self) -> Window { Window::from_id(&self.target).expect("fixture window must be discoverable") } + /// Resizes the target in place to its starting size plus `pixels`. + fn grow_by(&mut self, pixels: i32) { + let (width, height) = (self.layout.width + pixels, self.layout.height + pixels); + self.send(&format!("size {width} {height}")); + } + fn send(&mut self, command: &str) { let stdin = self.process.0.stdin.as_mut().expect("fixture stdin"); stdin.write_all(command.as_bytes()).unwrap(); @@ -485,9 +600,9 @@ fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { Duration::from_secs(2), || { std::thread::sleep(Duration::from_millis(700)); - fixture.send(&format!("size {} {}", TARGET_W + 1, TARGET_H + 1)); + fixture.grow_by(1); std::thread::sleep(Duration::from_millis(700)); - fixture.send(&format!("size {} {}", TARGET_W + 2, TARGET_H + 2)); + fixture.grow_by(2); }, ); @@ -513,3 +628,99 @@ fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { "trimmed frames must still carry the window's contents: {last:?}" ); } + +/// A consumer slower than the capture leaves frames queued in the pool when it +/// is recreated for a new size. Those frames report the new ContentSize on a +/// surface still at the old size, so trimming them to their ContentSize reads +/// past the surface's edge. WARP removes the device over that copy and +/// Windows.Graphics.Capture then fails fast on its next use of it, taking the +/// whole process down with 0xc0000409. Runs on WARP whatever the machine has. +/// Which frames are queued is up to the capture's timing, so before the fix +/// this crashed on most runs rather than every one. +#[test] +fn frames_queued_before_the_pool_is_recreated_are_not_copied() { + match_cli_dpi_awareness(); + + let mut fixture = Fixture::start(); + let item = fixture + .window() + .raw_handle() + .try_as_capture_item() + .expect("window capture item"); + + let samples = capture_samples_on( + item, + Settings { + even_dimensions: true, + follow_item_size: true, + ..Default::default() + }, + Some(warp_device()), + Duration::from_millis(150), + Duration::from_secs(2), + || { + std::thread::sleep(Duration::from_millis(700)); + fixture.grow_by(1); + std::thread::sleep(Duration::from_millis(700)); + fixture.grow_by(2); + }, + ); + + let sizes: Vec<_> = samples.iter().map(|s| (s.width, s.height)).collect(); + assert!( + sizes.iter().all(|(w, h)| w % 2 == 0 && h % 2 == 0), + "every frame must have even dimensions: {sizes:?}" + ); + let mut distinct = sizes.clone(); + distinct.dedup(); + assert!( + distinct.len() >= 2, + "the capture must follow the resizes, saw {distinct:?}" + ); +} + +/// Without `follow_item_size` the pool keeps its creation size while the item +/// grows past it, so the trim has to stay inside the surface rather than +/// follow ContentSize off its edge. Runs on WARP, where that copy removes the +/// device and the capture stops delivering frames. +#[test] +fn even_dimension_trim_stays_inside_the_surface() { + match_cli_dpi_awareness(); + + let mut fixture = Fixture::start(); + let item = fixture + .window() + .raw_handle() + .try_as_capture_item() + .expect("window capture item"); + let start = item.Size().expect("item size"); + let even = |v: i32| (v.max(0) as u32) & !1; + + let samples = capture_samples_on( + item, + Settings { + even_dimensions: true, + ..Default::default() + }, + Some(warp_device()), + Duration::ZERO, + Duration::from_secs(2), + || { + // WARP can be slow to deliver its first frame while the other + // tests capture alongside it. + std::thread::sleep(Duration::from_millis(1000)); + fixture.grow_by(1); + std::thread::sleep(Duration::from_millis(700)); + fixture.grow_by(2); + }, + ); + + let sizes: Vec<_> = samples.iter().map(|s| (s.width, s.height)).collect(); + assert!(!sizes.is_empty(), "the capture must deliver frames"); + assert!( + sizes + .iter() + .all(|&(w, h)| (w, h) == (even(start.Width), even(start.Height))), + "every frame must be the pool's own size floored to even: {sizes:?}" + ); +} From 326a6e0ec1c37f4ace67569e6f8fa79adfcec376 Mon Sep 17 00:00:00 2001 From: Stephen Psaradellis Date: Sat, 24 Oct 2026 13:39:48 -0500 Subject: [PATCH 6/6] fix(scap-direct3d): copy the content region when the surface is larger than the frame The plain pass-through branch labelled the frame with ContentSize but handed over the whole pool surface. On Server 2022 a window item's surface includes the invisible resize borders, so as_buffer's CopyResource into a ContentSize staging texture was a copy between different sizes, which D3D11 drops, and the readback was all zeros. Every branch now bounds the frame by both ContentSize and the surface and copies that region when it differs from the surface. The even-dimension tests now expect the frame's ContentSize instead of item.Size(), and a new test shrinks the window under a fixed pool to reach the same path on any machine. --- crates/scap-direct3d/src/lib.rs | 149 ++++++++++--------- crates/scap-direct3d/tests/window_capture.rs | 68 +++++++-- 2 files changed, 138 insertions(+), 79 deletions(-) diff --git a/crates/scap-direct3d/src/lib.rs b/crates/scap-direct3d/src/lib.rs index 598f42b7f6b..736f9c7cafb 100644 --- a/crates/scap-direct3d/src/lib.rs +++ b/crates/scap-direct3d/src/lib.rs @@ -590,9 +590,11 @@ impl Capturer { }) .transpose()?; - // Allocated on the first odd-sized frame and again whenever that size - // changes, so an even-sized capture never pays for the copy. - let even_trim = Mutex::new(None::<(ID3D11Texture2D, u32, u32)>); + // Allocated on the first frame whose size differs from its surface + // (odd-sized, or smaller than the pool's surface) and again whenever + // that size changes, so a capture that matches its surface never pays + // for the copy. + let trim = Mutex::new(None::<(ID3D11Texture2D, u32, u32)>); let frame_arrived_token = frame_pool .FrameArrived( @@ -728,79 +730,88 @@ impl Capturer { d3d_device: d3d_device.clone(), staging_pool: staging_pool.clone(), } - } else if even_dimensions - && ( - content_width.min(surface_desc.Width) & !1, - content_height.min(surface_desc.Height) & !1, - ) != (content_width, content_height) - { - let width = content_width.min(surface_desc.Width) & !1; - let height = content_height.min(surface_desc.Height) & !1; + } else { + // The frame is the content bounded by its surface, + // floored to even when asked. A surface larger than + // the content (a window item whose pool surface + // includes the invisible resize borders) is copied + // down to it: handing the whole surface over under + // the content's size makes `as_buffer`'s + // `CopyResource` a copy between different sizes, + // which D3D11 drops, so the readback is all zeros. + let mut width = content_width.min(surface_desc.Width); + let mut height = content_height.min(surface_desc.Height); + if even_dimensions { + width &= !1; + height &= !1; + } if width == 0 || height == 0 { return Ok(()); } - let trimmed_texture = { - let mut even_trim = even_trim - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match even_trim.as_ref() { - Some((texture, w, h)) if (*w, *h) == (width, height) => { - texture.clone() - } - _ => { - let texture = create_copy_texture( - &d3d_device, - width, - height, - pixel_format, - )?; - *even_trim = Some((texture.clone(), width, height)); - texture + if (width, height) == (surface_desc.Width, surface_desc.Height) { + Frame { + width, + height, + pixel_format: settings.pixel_format, + inner: frame, + texture, + d3d_context: d3d_context.clone(), + d3d_device: d3d_device.clone(), + staging_pool: staging_pool.clone(), + } + } else { + let trimmed_texture = { + let mut trim = trim + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match trim.as_ref() { + Some((texture, w, h)) if (*w, *h) == (width, height) => { + texture.clone() + } + _ => { + let texture = create_copy_texture( + &d3d_device, + width, + height, + pixel_format, + )?; + *trim = Some((texture.clone(), width, height)); + texture + } } + }; + + unsafe { + d3d_context.CopySubresourceRegion( + &trimmed_texture, + 0, + 0, + 0, + 0, + &texture, + 0, + Some(&D3D11_BOX { + left: 0, + top: 0, + front: 0, + right: width, + bottom: height, + back: 1, + }), + ); } - }; - - unsafe { - d3d_context.CopySubresourceRegion( - &trimmed_texture, - 0, - 0, - 0, - 0, - &texture, - 0, - Some(&D3D11_BOX { - left: 0, - top: 0, - front: 0, - right: width, - bottom: height, - back: 1, - }), - ); - } - Frame { - width, - height, - pixel_format: settings.pixel_format, - inner: frame, - texture: trimmed_texture, - d3d_context: d3d_context.clone(), - d3d_device: d3d_device.clone(), - staging_pool: staging_pool.clone(), - } - } else { - Frame { - width: size.Width as u32, - height: size.Height as u32, - pixel_format: settings.pixel_format, - inner: frame, - texture, - d3d_context: d3d_context.clone(), - d3d_device: d3d_device.clone(), - staging_pool: staging_pool.clone(), + Frame { + width, + height, + pixel_format: settings.pixel_format, + inner: frame, + texture: trimmed_texture, + d3d_context: d3d_context.clone(), + d3d_device: d3d_device.clone(), + staging_pool: staging_pool.clone(), + } } }; diff --git a/crates/scap-direct3d/tests/window_capture.rs b/crates/scap-direct3d/tests/window_capture.rs index e98ea2f4c5d..9e2875a6d3d 100644 --- a/crates/scap-direct3d/tests/window_capture.rs +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -264,6 +264,10 @@ fn window_capture_fixture_process() { struct Sample { width: u32, height: u32, + /// The frame's ContentSize, which on some systems (Server 2022) is + /// smaller than `item.Size()` because the latter counts the window's + /// invisible resize borders. + content: (u32, u32), red: usize, blue: usize, } @@ -354,6 +358,8 @@ fn capture_samples_on( if !frame_delay.is_zero() { std::thread::sleep(frame_delay); } + let content = frame.inner().ContentSize()?; + let content = (content.Width.max(0) as u32, content.Height.max(0) as u32); let buffer = frame.as_buffer()?; let (width, height, stride) = ( buffer.width() as usize, @@ -376,6 +382,7 @@ fn capture_samples_on( sink.lock().unwrap().push(Sample { width: width as u32, height: height as u32, + content, red, blue, }); @@ -587,9 +594,6 @@ fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { .raw_handle() .try_as_capture_item() .expect("window capture item"); - let start = item.Size().expect("item size"); - let even = |v: i32| (v.max(0) as u32) & !1; - let samples = capture_samples_with( item, Settings { @@ -607,10 +611,11 @@ fn window_frames_are_trimmed_to_even_dimensions_on_the_gpu() { ); let sizes: Vec<_> = samples.iter().map(|s| (s.width, s.height)).collect(); + let first = samples.first().expect("frames"); assert_eq!( - sizes.first().copied(), - Some((even(start.Width), even(start.Height))), - "the first frame must be the item's size floored to even: {sizes:?}" + (first.width, first.height), + (first.content.0 & !1, first.content.1 & !1), + "the first frame must be its ContentSize floored to even: {samples:?}" ); assert!( sizes.iter().all(|(w, h)| w % 2 == 0 && h % 2 == 0), @@ -693,8 +698,10 @@ fn even_dimension_trim_stays_inside_the_surface() { .raw_handle() .try_as_capture_item() .expect("window capture item"); + // Without `follow_item_size` the pool keeps the surface size it was created + // with, which is the item's size at the start. let start = item.Size().expect("item size"); - let even = |v: i32| (v.max(0) as u32) & !1; + let pool = (start.Width.max(0) as u32, start.Height.max(0) as u32); let samples = capture_samples_on( item, @@ -718,9 +725,50 @@ fn even_dimension_trim_stays_inside_the_surface() { let sizes: Vec<_> = samples.iter().map(|s| (s.width, s.height)).collect(); assert!(!sizes.is_empty(), "the capture must deliver frames"); assert!( - sizes + samples + .iter() + .all(|s| (s.width, s.height) + == (s.content.0.min(pool.0) & !1, s.content.1.min(pool.1) & !1)), + "every frame must be its ContentSize, bounded by the pool's surface, floored to even: {samples:?}" + ); +} + +/// With no crop, trim or pool recreation, a frame whose ContentSize is smaller +/// than its surface must still read back. Handing the whole surface over under +/// the smaller size made `as_buffer`'s `CopyResource` a copy between different +/// sizes, which D3D11 drops, so the frame read back blank. Shrinking the window +/// under a fixed pool reaches this anywhere; on Server 2022 every window item +/// does, because its surface includes the invisible resize borders. +#[test] +fn frames_smaller_than_their_surface_still_read_back() { + match_cli_dpi_awareness(); + + let mut fixture = Fixture::start(); + let item = fixture + .window() + .raw_handle() + .try_as_capture_item() + .expect("window capture item"); + let start = item.Size().expect("item size"); + let pool = (start.Width.max(0) as u32, start.Height.max(0) as u32); + + let samples = capture_samples(item, None, Duration::from_secs(1), || { + std::thread::sleep(Duration::from_millis(700)); + fixture.grow_by(-41); + }); + + let shrunk: Vec<_> = samples + .iter() + .filter(|s| s.content.0 < pool.0 && s.content.1 < pool.1) + .collect(); + assert!( + !shrunk.is_empty(), + "the capture must deliver frames after the shrink: {samples:?}" + ); + assert!( + shrunk .iter() - .all(|&(w, h)| (w, h) == (even(start.Width), even(start.Height))), - "every frame must be the pool's own size floored to even: {sizes:?}" + .all(|s| (s.width, s.height) == s.content && s.blue > 0), + "a frame smaller than its surface must be its ContentSize and carry the window: {shrunk:?}" ); }