Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/cursor-capture/src/position.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 75 additions & 1 deletion crates/recording/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<scap_targets::WindowId>,
}

#[cfg(windows)]
fn windows_window_crop(
window: &scap_targets::WindowId,
display: &scap_targets::Display,
) -> Option<CursorCropBounds> {
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<CursorCropBounds> = None;
let device_state = DeviceState::new();
let mut last_mouse_state = device_state.get_mouse();
let mut last_keys: Vec<device_query::Keycode> = device_state.get_keys();
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/recording/src/instant_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion crates/recording/src/sources/screen_capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ pub struct Config {
crop_bounds: Option<CropBounds>,
fps: u32,
show_cursor: bool,
#[cfg(windows)]
window: Option<WindowId>,
#[cfg(target_os = "linux")]
linux_source: LinuxCaptureSource,
}
Expand Down Expand Up @@ -471,6 +473,7 @@ impl<TCaptureFormat: ScreenCaptureFormat> ScreenCaptureConfig<TCaptureFormat> {
max_capture_size: Option<(u32, u32)>,
start_time: SystemTime,
system_audio: bool,
#[cfg(windows)] window: Option<WindowId>,
#[cfg(target_os = "linux")] linux_source: LinuxCaptureSource,
#[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device,
#[cfg(target_os = "macos")] shareable_content: SendableShareableContent,
Expand All @@ -495,7 +498,10 @@ impl<TCaptureFormat: ScreenCaptureFormat> ScreenCaptureConfig<TCaptureFormat> {

#[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")]
Expand Down Expand Up @@ -531,6 +537,8 @@ impl<TCaptureFormat: ScreenCaptureFormat> ScreenCaptureConfig<TCaptureFormat> {
crop_bounds,
fps,
show_cursor,
#[cfg(windows)]
window,
#[cfg(target_os = "linux")]
linux_source,
},
Expand Down
115 changes: 106 additions & 9 deletions crates/recording/src/sources/screen_capture/windows.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<PhysicalSize> {
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<PhysicalSize> {
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<CropBounds>,
) -> Option<CropBounds> {
match window {
Some(_) => None,
None => crop_bounds,
}
}

impl ScreenCaptureConfig<Direct3DCapture> {
pub async fn to_sources(
&self,
) -> anyhow::Result<(VideoSourceConfig, Option<SystemAudioSourceConfig>)> {
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);

Expand All @@ -375,6 +415,11 @@ impl ScreenCaptureConfig<Direct3DCapture> {
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()
};

Expand All @@ -393,12 +438,13 @@ impl ScreenCaptureConfig<Direct3DCapture> {

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(),
},
Expand All @@ -416,6 +462,7 @@ pub enum VideoSourceError {
pub struct VideoSourceConfig {
video_info: VideoInfo,
display_id: DisplayId,
window_id: Option<WindowId>,
settings: scap_direct3d::Settings,
pub d3d_device: ID3D11Device,
}
Expand Down Expand Up @@ -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<VideoFrame>,
Expand All @@ -511,11 +559,21 @@ fn create_d3d_capturer(
params: &CreateCapturerParams,
error_tx: &mpsc::Sender<CaptureClosureEvent>,
) -> anyhow::Result<scap_direct3d::Capturer> {
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))?
Comment on lines +562 to +567

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Minimized Window Fails Setup

If the user minimizes the selected window before capture setup reaches this code, Window::from_id cannot find it because that lookup uses Window::list(), whose enumeration excludes minimized windows. Capturer creation then returns “Window not found,” so the recording fails to start even though the selected window still exists. Resolve the stored HWND directly instead of requiring the target to remain eligible for enumeration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/sources/screen_capture/windows.rs
Line: 535-537

Comment:
**Minimized Window Fails Setup**

If the user minimizes the selected window before capture setup reaches this code, `Window::from_id` cannot find it because that lookup uses `Window::list()`, whose enumeration excludes minimized windows. Capturer creation then returns “Window not found,” so the recording fails to start even though the selected window still exists. Resolve the stored HWND directly instead of requiring the target to remain eligible for enumeration.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

.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,
Expand Down Expand Up @@ -661,6 +719,7 @@ impl output_pipeline::VideoSource for VideoSource {
VideoSourceConfig {
video_info,
display_id,
window_id,
settings,
d3d_device,
}: Self::Config,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
}
4 changes: 3 additions & 1 deletion crates/recording/src/studio_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -3647,7 +3649,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(),
Expand Down
Loading
Loading