From b3a4b48a1b3d2304be2548aaada3d51107838a89 Mon Sep 17 00:00:00 2001 From: Minit Jain Date: Mon, 12 Oct 2026 01:44:38 +0530 Subject: [PATCH 1/2] fix(recording): capture the selected window itself on macOS Window targets on macOS were recorded as a display capture cropped to the window's bounds at start time, so moving the window or overlapping it with other apps changed what was recorded (#1754). Carry the window id into the macOS capture config and build a desktop-independent window SCContentFilter for it, skipping the display crop. The same filter is used when the capturer is rebuilt after a system stop. Display, Area and Camera Only targets are unchanged. Instant mode keeps the display crop when a camera is attached, because macOS Instant recordings have no camera compositing and the camera preview only appears through the display capture. Known limitations: - Studio custom cursor positions are still normalised against the window's start bounds, so the cursor overlay drifts after the window moves. - Output size is fixed at the window's size at start; resize behaviour is untested. - With system audio on, ScreenCaptureKit likely limits audio to the window's owning app. - If the window is missing from shareable content (initially or on restart), capture falls back to the previous display crop. Window-filter approach also explored in #2368. --- crates/recording/src/instant_recording.rs | 30 +++++++ .../src/sources/screen_capture/macos.rs | 86 +++++++++++++++---- .../src/sources/screen_capture/mod.rs | 5 ++ crates/recording/src/studio_recording.rs | 2 + 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 759025f1f01..100ed17174e 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -1324,6 +1324,10 @@ async fn build_instant_recording_actor( #[cfg(not(target_os = "macos"))] let max_capture_size = None; + #[cfg(target_os = "macos")] + let capture_window = + macos_capture_window(&inputs.capture_target, inputs.camera_feed.is_some()); + let screen_source = ScreenCaptureConfig::::init( display, crop_bounds, @@ -1345,6 +1349,8 @@ async fn build_instant_recording_actor( .ok_or_else(|| anyhow::anyhow!("Missing shareable content"))?, #[cfg(target_os = "macos")] inputs.excluded_windows, + #[cfg(target_os = "macos")] + capture_window, ) .await .context("screen capture init")?; @@ -1554,10 +1560,34 @@ fn clamp_size(input: (u32, u32), max: (u32, u32)) -> (u32, u32) { } } +// macOS Instant mode has no camera compositing: the camera preview only reaches the +// video because the display capture records it, so a window-only capture would drop it. +#[cfg(target_os = "macos")] +fn macos_capture_window( + target: &ScreenCaptureTarget, + camera_attached: bool, +) -> Option { + target.window().filter(|_| !camera_attached) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn macos_window_target_captures_window_unless_camera_attached() { + let id: scap_targets::WindowId = "247".parse().unwrap(); + let window = ScreenCaptureTarget::Window { id: id.clone() }; + + assert_eq!(macos_capture_window(&window, false), Some(id)); + assert_eq!(macos_capture_window(&window, true), None); + assert_eq!( + macos_capture_window(&ScreenCaptureTarget::CameraOnly, false), + None + ); + } + #[cfg(target_os = "linux")] #[test] fn linux_presentation_builder_preserves_explicit_config_and_cli_default() { diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index 66972d65eb4..be29055e607 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -28,6 +28,29 @@ use tracing::{debug, error, info, warn}; const MAX_CAPTURE_RESTARTS: u32 = 3; const RESTART_DELAY: Duration = Duration::from_secs(2); +fn window_content_filter( + window_id: &WindowId, + content: &sc::ShareableContent, + capture_phase: &'static str, +) -> Option> { + let Ok(native_id) = window_id.to_string().parse::() else { + warn!(%window_id, capture_phase, "Capture window has an invalid native id"); + return None; + }; + + let windows = content.windows(); + let Some(window) = windows.iter().find(|window| window.id() == native_id) else { + warn!( + %window_id, + capture_phase, + "Capture window missing from ScreenCaptureKit shareable content, falling back to display crop" + ); + return None; + }; + + Some(sc::ContentFilter::with_desktop_independent_window(window)) +} + fn excluded_shareable_windows( excluded_windows: &[WindowId], content: &sc::ShareableContent, @@ -219,16 +242,31 @@ impl ScreenCaptureConfig { let display = Display::from_id(&self.config.display) .ok_or_else(|| SourceError::NoDisplay(self.config.display.clone()))?; - let excluded_sc_windows = - excluded_shareable_windows(&self.excluded_windows, &self.shareable_content, "initial"); + let window_filter = self + .config + .window + .as_ref() + .and_then(|id| window_content_filter(id, &self.shareable_content, "initial")); + let captures_window = window_filter.is_some(); + + let content_filter = match window_filter { + Some(filter) => filter, + None => { + let excluded_sc_windows = excluded_shareable_windows( + &self.excluded_windows, + &self.shareable_content, + "initial", + ); - let content_filter = display - .raw_handle() - .as_content_filter_excluding_windows( - self.shareable_content.clone(), - excluded_sc_windows, - ) - .ok_or(SourceError::AsContentFilter)?; + display + .raw_handle() + .as_content_filter_excluding_windows( + self.shareable_content.clone(), + excluded_sc_windows, + ) + .ok_or(SourceError::AsContentFilter)? + } + }; debug!("SCK content filter: {:?}", content_filter); @@ -257,7 +295,7 @@ impl ScreenCaptureConfig { settings.set_pixel_format(cv::PixelFormat::_420V); settings.set_color_space_name(cg::color_space::names::srgb()); - if let Some(crop_bounds) = self.config.crop_bounds { + if !captures_window && let Some(crop_bounds) = self.config.crop_bounds { debug!("crop bounds: {:?}", crop_bounds); settings.set_src_rect(cg::Rect::new( crop_bounds.position().x(), @@ -948,13 +986,25 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result filter, + None => { + let excluded_sc_windows = + excluded_shareable_windows(¶ms.excluded_windows, &shareable_content, "restart"); + + display + .raw_handle() + .as_content_filter_excluding_windows(shareable_content, excluded_sc_windows) + .ok_or_else(|| anyhow!("Failed to create content filter during restart"))? + } + }; let size = PhysicalSize::new( params.video_info.width as f64, @@ -977,7 +1027,7 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result, } #[cfg(target_os = "macos")] @@ -473,6 +475,7 @@ impl ScreenCaptureConfig { #[cfg(windows)] d3d_device: ::windows::Win32::Graphics::Direct3D11::ID3D11Device, #[cfg(target_os = "macos")] shareable_content: SendableShareableContent, #[cfg(target_os = "macos")] excluded_windows: Vec, + #[cfg(target_os = "macos")] window: Option, ) -> Result { cap_fail::fail!("ScreenCaptureSource::init"); @@ -531,6 +534,8 @@ impl ScreenCaptureConfig { show_cursor, #[cfg(target_os = "linux")] linux_source, + #[cfg(target_os = "macos")] + window, }, video_info: VideoInfo::from_raw_ffmpeg( TCaptureFormat::pixel_format(), diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 1ff44d06f1b..35861305fa4 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -3281,6 +3281,8 @@ async fn create_segment_pipeline( .ok_or_else(|| anyhow!("Missing shareable content"))?, #[cfg(target_os = "macos")] base_inputs.excluded_windows.clone(), + #[cfg(target_os = "macos")] + capture_target.window(), ) .await .context("screen capture init")?; From c3d41d2767a488c65d7fa13633bf3cecfadb29b5 Mon Sep 17 00:00:00 2001 From: Minit Jain Date: Mon, 12 Oct 2026 16:43:42 +0530 Subject: [PATCH 2/2] fix(recording): keep excluded windows hidden in macOS window capture The window-only SCContentFilter ignored the "Hide windows from recordings" exclusions, so selecting an excluded window in Window mode recorded it. When the selected window is in the exclusion list, skip the window filter and use the display filter with exclusions, which hides it as before. The check runs both when capture starts and when the capturer is rebuilt. --- .../src/sources/screen_capture/macos.rs | 101 ++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/recording/src/sources/screen_capture/macos.rs b/crates/recording/src/sources/screen_capture/macos.rs index be29055e607..ad4cd30187b 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -28,6 +28,27 @@ use tracing::{debug, error, info, warn}; const MAX_CAPTURE_RESTARTS: u32 = 3; const RESTART_DELAY: Duration = Duration::from_secs(2); +// The display filter is what hides excluded windows, so an excluded selection must stay on +// the display-crop path instead of being captured on its own. +fn capturable_window<'a>( + config: &'a Config, + excluded_windows: &[WindowId], + capture_phase: &'static str, +) -> Option<&'a WindowId> { + let window_id = config.window.as_ref()?; + + if excluded_windows.contains(window_id) { + warn!( + %window_id, + capture_phase, + "Capture window is excluded from recordings, using display crop with exclusions" + ); + return None; + } + + Some(window_id) +} + fn window_content_filter( window_id: &WindowId, content: &sc::ShareableContent, @@ -242,10 +263,7 @@ impl ScreenCaptureConfig { let display = Display::from_id(&self.config.display) .ok_or_else(|| SourceError::NoDisplay(self.config.display.clone()))?; - let window_filter = self - .config - .window - .as_ref() + let window_filter = capturable_window(&self.config, &self.excluded_windows, "initial") .and_then(|id| window_content_filter(id, &self.shareable_content, "initial")); let captures_window = window_filter.is_some(); @@ -986,10 +1004,7 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result WindowId { + id.parse().unwrap() + } + + fn window_config(window: Option) -> Config { + Config { + display: "1".parse().unwrap(), + crop_bounds: None, + fps: 60, + show_cursor: true, + window, + } + } + + #[test] + fn initial_capture_uses_window_filter_for_non_excluded_window() { + let config = window_config(Some(window_id("247"))); + let excluded = vec![window_id("12"), window_id("99")]; + + assert_eq!( + capturable_window(&config, &excluded, "initial"), + Some(&window_id("247")) + ); + } + + #[test] + fn initial_capture_keeps_excluded_window_on_display_path() { + let config = window_config(Some(window_id("247"))); + let excluded = vec![window_id("12"), window_id("247")]; + + assert_eq!(capturable_window(&config, &excluded, "initial"), None); + } + + #[test] + fn restart_capture_keeps_excluded_window_on_display_path() { + let config = window_config(Some(window_id("247"))); + let excluded = vec![window_id("247")]; + let (restart_config, restart_excluded) = (config.clone(), excluded.clone()); + + assert_eq!( + capturable_window(&restart_config, &restart_excluded, "restart"), + None + ); + } + + #[test] + fn restart_capture_uses_window_filter_for_non_excluded_window() { + let config = window_config(Some(window_id("247"))); + let excluded = vec![window_id("12")]; + let (restart_config, restart_excluded) = (config.clone(), excluded.clone()); + + assert_eq!( + capturable_window(&restart_config, &restart_excluded, "restart"), + Some(&window_id("247")) + ); + } + + #[test] + fn non_window_targets_never_use_window_filter() { + let config = window_config(None); + + assert_eq!(capturable_window(&config, &[], "initial"), None); + assert_eq!(capturable_window(&config, &[], "restart"), None); + } +}