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..ad4cd30187b 100644 --- a/crates/recording/src/sources/screen_capture/macos.rs +++ b/crates/recording/src/sources/screen_capture/macos.rs @@ -28,6 +28,50 @@ 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, + 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 +263,28 @@ 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 = 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(); + + 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 +313,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 +1004,22 @@ async fn rebuild_capturer(params: &CapturerRebuildParams) -> anyhow::Result filter, + None => { + let excluded_sc_windows = + excluded_shareable_windows(¶ms.excluded_windows, &shareable_content, "restart"); - let content_filter = display - .raw_handle() - .as_content_filter_excluding_windows(shareable_content, excluded_sc_windows) - .ok_or_else(|| anyhow!("Failed to create content filter during 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 +1042,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); + } +} diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 15dfd729814..dbd5bad75c8 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 { show_cursor: bool, #[cfg(target_os = "linux")] linux_source: LinuxCaptureSource, + #[cfg(target_os = "macos")] + window: Option, } #[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 4917df97300..f0785c473d7 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -3292,6 +3292,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")?;