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 c973aa5e724..3c3ad6ecd96 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -51,10 +51,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, @@ -108,6 +136,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; @@ -601,6 +655,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(); @@ -671,6 +727,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 84a51044053..f4518e1405e 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -1345,6 +1345,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 9e8a19c51d1..5ad49d179dc 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..bf99efb640f 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,11 @@ impl ScreenCaptureConfig { back: 1, } }), + // 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() }; @@ -393,12 +438,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 +462,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 +534,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 +559,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 +719,7 @@ impl output_pipeline::VideoSource for VideoSource { VideoSourceConfig { video_info, display_id, + window_id, settings, d3d_device, }: Self::Config, @@ -723,6 +782,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 +1658,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 d5ef7422c14..f29c112d56b 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -3413,6 +3413,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)] @@ -3654,7 +3656,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..736f9c7cafb 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,14 @@ 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, + /// 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 { @@ -506,14 +541,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 +578,24 @@ 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 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( &TypedEventHandler::::new({ @@ -575,6 +604,10 @@ 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(); + let follow_item_size = settings.follow_item_size; move |frame_pool, _| { let _activity_guard = callback_activity.enter(); @@ -591,11 +624,89 @@ 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. + if follow_item_size { + let mut pool_size = pool_size + .lock() + .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. + 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::() }?; + // 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 crop.right > surface_desc.Width + || crop.bottom > surface_desc.Height + || (follow_item_size + && (crop.right > content_width + || crop.bottom > content_height)) + { + return Ok(()); + } + unsafe { d3d_context.CopySubresourceRegion( &cropped_texture, @@ -620,15 +731,87 @@ impl Capturer { 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(), + // 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(()); + } + + 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, + }), + ); + } + + 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 new file mode 100644 index 00000000000..9e2875a6d3d --- /dev/null +++ b/crates/scap-direct3d/tests/window_capture.rs @@ -0,0 +1,774 @@ +#![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, HMODULE, HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::{ + 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, 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, + }, + }, + }, + core::{PCWSTR, w}, +}; + +/// 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); + +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(); + 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); + register_class(w!("Cap2151Overlay"), 0x0000_00FF); + + let target = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("Cap2151Target"), + w!("Cap2151 target"), + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + layout.x, + layout.y, + layout.width, + layout.height, + 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, + overlay_x, + overlay_y, + overlay_w, + overlay_h, + None, + None, + None, + None, + ) + } + .expect("create overlay window"); + unsafe { + SetWindowPos( + overlay, + Some(HWND_TOPMOST), + overlay_x, + overlay_y, + 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 { + let (width, height) = layout.grown(); + SetWindowPos( + target, + None, + layout.x, + layout.y, + width, + height, + 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 { + (layout.x, layout.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, + /// 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, +} + +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, + ) +} + +/// 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(); + 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| { + 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, + 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, + content, + red, + blue, + }); + Ok(()) + }, + || Ok(()), + device, + ) + .expect("create capturer"); + + capturer.start().expect("start capture"); + 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() +} + +struct Fixture { + process: FixtureProcess, + target: WindowId, + layout: Layout, +} + +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, + 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(); + 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_with( + window + .raw_handle() + .try_as_capture_item() + .expect("window capture item"), + Settings { + follow_item_size: true, + ..Default::default() + }, + 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 samples = capture_samples_with( + item, + Settings { + even_dimensions: true, + follow_item_size: true, + ..Default::default() + }, + 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(); + let first = samples.first().expect("frames"); + assert_eq!( + (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), + "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:?}" + ); +} + +/// 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"); + // 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 pool = (start.Width.max(0) as u32, start.Height.max(0) as u32); + + 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!( + 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(|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:?}" + ); +} 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..09bcb52ff03 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -46,7 +46,7 @@ use windows::{ GWL_EXSTYLE, GWL_STYLE, GetClassLongPtrW, GetClassNameW, GetClientRect, GetCursorPos, GetIconInfo, GetLayeredWindowAttributes, GetWindow, GetWindowLongPtrW, GetWindowLongW, GetWindowRect, GetWindowTextLengthW, - GetWindowTextW, GetWindowThreadProcessId, HICON, ICONINFO, IsIconic, + 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, }, @@ -345,6 +345,18 @@ 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 }