diff --git a/Cargo.lock b/Cargo.lock index 9b5243fb43e..aabdd55b32b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1714,6 +1714,7 @@ dependencies = [ "libloading 0.8.8", "log", "metal 0.31.0", + "naga", "objc2 0.6.2", "pollster", "pretty_assertions", diff --git a/apps/desktop-gpui/src/overlay_backdrop.rs b/apps/desktop-gpui/src/overlay_backdrop.rs index b896b3981bf..81b644c49cb 100644 --- a/apps/desktop-gpui/src/overlay_backdrop.rs +++ b/apps/desktop-gpui/src/overlay_backdrop.rs @@ -21,8 +21,11 @@ use std::{ use gpui::{AsyncApp, RenderImage, Window}; use scap_targets::DisplayId; -/// A capture older than this is retaken the next time the overlay draws. -pub const STALE_AFTER: Duration = Duration::from_secs(3); +/// A capture older than this is retaken the next time the overlay draws, or +/// older than `STALE_AFTER_MIN` once the area or display size it was taken for +/// has changed. +pub const STALE_AFTER: Duration = Duration::from_secs(8); +pub const STALE_AFTER_MIN: Duration = Duration::from_secs(2); /// How long the panels must hold still before a stale capture is retaken. pub const SETTLE: Duration = Duration::from_millis(400); diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index fcc7e77285f..5dbcaeb5d23 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -106,7 +106,7 @@ pub fn run(main: WindowHandle, cx: &mut App) { let mut mode = cx.update(|cx| probe_mode(label, cx)); mode["overlayInactiveBeforeActivation"] = inactive_before.into(); let screen = screenshot(&output, label, cx).await; - mode["backdrop"] = probe_backdrop(&output, label, screen.as_ref(), cx).await; + mode["backdrop"] = probe_backdrop(&output, label, kind, screen.as_ref(), cx).await; modes.push(mode); if kind == TargetType::Window { window_arrangement = Some(probe_window_arrangement(&output, cx).await); @@ -372,6 +372,7 @@ fn mean_difference( async fn probe_backdrop( output: &std::ffi::OsStr, label: &str, + kind: TargetType, screen: Option<&image::DynamicImage>, cx: &mut AsyncApp, ) -> serde_json::Value { @@ -393,6 +394,13 @@ async fn probe_backdrop( .ok() .flatten() }); + if !crate::target_overlay::backdrop_suits(Some(kind)) { + return serde_json::json!({ + "skipped": "the picker takes no backdrop in this mode here", + "present": image.is_some(), + "passed": image.is_none(), + }); + } let Some(image) = image else { return serde_json::json!({ "present": false, "passed": false }); }; diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 28cc9b5b91f..231289aef65 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -830,7 +830,7 @@ fn more_area_ratios() -> Vec<(u32, u32)> { /// so a capture under their panes showed through it as hard-edged blocks; /// there the panes sit on the fallback fill, as they do where no capture /// is possible. -fn backdrop_suits(mode: Option) -> bool { +pub(crate) fn backdrop_suits(mode: Option) -> bool { match mode { None => false, Some(TargetType::Display | TargetType::Window) => !cfg!(windows), @@ -1224,6 +1224,8 @@ pub struct OverlayWindow { /// The area as the last render drew it, and when it last changed: a /// capture waits for the panels to stop moving. backdrop_crop: Option, + backdrop_capture_crop: Option, + backdrop_display_size: Option<(f32, f32)>, crop_moved_at: Option, backdrop_wake_pending: bool, inline_camera: Option>, @@ -1398,6 +1400,8 @@ impl OverlayWindow { backdrop_at: None, backdrop_capturing: false, backdrop_crop: None, + backdrop_capture_crop: None, + backdrop_display_size: None, crop_moved_at: None, backdrop_wake_pending: false, inline_camera: None, @@ -1570,6 +1574,8 @@ impl OverlayWindow { if self.backdrop.has_image() { self.backdrop.set_image(None, gpui::Bounds::default()); self.backdrop_at = None; + self.backdrop_capture_crop = None; + self.backdrop_display_size = None; } return; } @@ -1577,17 +1583,25 @@ impl OverlayWindow { self.backdrop_crop = self.crop; self.crop_moved_at = Some(Instant::now()); } + let bounds_unchanged = self.backdrop.has_image() + && self.backdrop_capture_crop == self.crop + && self.backdrop_display_size == Some(self.display_size); // A drawn area moves the camera into it, so a capture from before the // area last changed is stale however recent it is. let moved_since = self .crop_moved_at .zip(self.backdrop_at) .is_some_and(|(moved, captured)| moved > captured); + let stale_after = if bounds_unchanged { + crate::overlay_backdrop::STALE_AFTER + } else { + crate::overlay_backdrop::STALE_AFTER_MIN + }; if self.backdrop_capturing || (!moved_since && self .backdrop_at - .is_some_and(|at| at.elapsed() < crate::overlay_backdrop::STALE_AFTER)) + .is_some_and(|at| at.elapsed() < stale_after)) { return; } @@ -1644,6 +1658,8 @@ impl OverlayWindow { size, ) }); + this.backdrop_capture_crop = this.crop; + this.backdrop_display_size = image.as_ref().map(|_| size); this.backdrop.set_image( image, gpui::Bounds::new( diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index faab33d6e76..c99081c0231 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -346,6 +346,7 @@ impl Muxer for WindowsMuxer { let mut first_timestamp: Option = None; let mut frame_count: u64 = 0; let mut frames_reused: u64 = 0; + let mut pooled_frame: Option = None; let result = encoder.run( Arc::new(AtomicBool::default()), @@ -353,6 +354,7 @@ impl Muxer for WindowsMuxer { let (texture, timestamp) = match pacer.next(&video_rx, &pause_flag) { Paced::Frame(frame, timestamp) => { let texture = frame.texture().clone(); + pooled_frame = frame.into_pooled(); last_texture = Some(texture.clone()); (texture, timestamp) } diff --git a/crates/recording/src/output_pipeline/win_segmented.rs b/crates/recording/src/output_pipeline/win_segmented.rs index 1edaf97daf1..412a26a31af 100644 --- a/crates/recording/src/output_pipeline/win_segmented.rs +++ b/crates/recording/src/output_pipeline/win_segmented.rs @@ -531,6 +531,7 @@ impl WindowsSegmentedMuxer { either::Left((mut encoder, mut muxer)) => { trace!("Running native encoder for segment"); let mut first_timestamp: Option = None; + let mut pooled_frame: Option = None; let result = encoder.run( Arc::new(AtomicBool::default()), || { @@ -547,7 +548,9 @@ impl WindowsSegmentedMuxer { }; let frame_time = duration_to_timespan(relative); - Ok(Some((frame.texture().clone(), frame_time))) + let texture = frame.texture().clone(); + pooled_frame = frame.into_pooled(); + Ok(Some((texture, frame_time))) }, |output_sample| { let mut output = match output_clone.lock() { diff --git a/crates/recording/src/sources/screen_capture/windows.rs b/crates/recording/src/sources/screen_capture/windows.rs index ef8a9dbd343..972e9542499 100644 --- a/crates/recording/src/sources/screen_capture/windows.rs +++ b/crates/recording/src/sources/screen_capture/windows.rs @@ -118,6 +118,17 @@ impl ScreenFrame { } } + /// The frame, when its texture goes back to the capturer's pool once the + /// frame is dropped. A caller that passes such a texture on must keep the + /// frame until the texture has been consumed, or the next capture can + /// overwrite it. + pub fn into_pooled(self) -> Option { + match self { + ScreenFrame::Captured(frame) if frame.is_pooled() => Some(frame), + ScreenFrame::Captured(_) | ScreenFrame::Scaled(_) => None, + } + } + pub fn width(&self) -> u32 { match self { ScreenFrame::Captured(frame) => frame.width(), diff --git a/crates/rendering/Cargo.toml b/crates/rendering/Cargo.toml index de9690088a2..b0f6b8a066c 100644 --- a/crates/rendering/Cargo.toml +++ b/crates/rendering/Cargo.toml @@ -74,6 +74,7 @@ cap-video-decode = { path = "../video-decode", features = ["scripted"] } cap-verify-expect = { path = "../verify-expect" } tempfile = "3" pollster = "0.4.0" +naga = { version = "25", features = ["wgsl-in"] } pretty_assertions = "1.4.1" tracing-subscriber = "0.3.19" diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index ec2e57b5a59..d9a5ddc7efa 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -359,7 +359,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { for (var i = 0; i <= 20; i = i + 1) { let sample_uv = target_uv + velocity_uv * (f32(i) / k); - var tap = sample_texture(sample_uv, crop_bounds_uv); + var tap = sample_texture_blur(sample_uv, crop_bounds_uv); tap = apply_rounded_corners(tap, sample_uv); accum += tap.rgb * tap.a; alpha_sum += tap.a; @@ -399,7 +399,7 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { let weight = 4.0 * (percent - percent * percent); let sample_uv = target_uv + scaled_dir * percent; - var tap = sample_texture(sample_uv, crop_bounds_uv); + var tap = sample_texture_blur(sample_uv, crop_bounds_uv); tap = apply_rounded_corners(tap, sample_uv); accum += tap.rgb * tap.a * weight; alpha_sum += tap.a * weight; @@ -419,21 +419,40 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { ); } -fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { +fn cropped_sample_uv(uv: vec2, crop_bounds_uv: vec4) -> vec2 { + var sample_uv = uv; + if uniforms.mirror_x != 0.0 { + sample_uv.x = 1.0 - sample_uv.x; + } + + let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy; + let cropped_uv = sample_uv * crop_size + crop_bounds_uv.xy; + + let texel_offset = 1.0 / uniforms.frame_size; + let safe_min = crop_bounds_uv.xy + texel_offset; + let safe_max = crop_bounds_uv.zw - texel_offset; + return clamp(cropped_uv, safe_min, safe_max); +} + +fn sample_texture_blur(uv: vec2, crop_bounds_uv: vec4) -> vec4 { if uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 { - var sample_uv = uv; - if uniforms.mirror_x != 0.0 { - sample_uv.x = 1.0 - sample_uv.x; - } + let cropped_uv = cropped_sample_uv(uv, crop_bounds_uv); + let center_sample = textureSample(frame_texture, frame_sampler, cropped_uv); + let out_alpha = select(1.0, center_sample.a, uniforms.preserve_source_alpha > 0.5); + return vec4(center_sample.rgb, out_alpha); + } - let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy; - var cropped_uv = sample_uv * crop_size + crop_bounds_uv.xy; + return vec4(0.0); +} +fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { + if uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 { + let cropped_uv = cropped_sample_uv(uv, crop_bounds_uv); let texel_offset = 1.0 / uniforms.frame_size; let safe_min = crop_bounds_uv.xy + texel_offset; let safe_max = crop_bounds_uv.zw - texel_offset; - cropped_uv = clamp(cropped_uv, safe_min, safe_max); + let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy; let source_size = uniforms.frame_size * crop_size; let target_size = uniforms.target_size; let scale_ratio = source_size / target_size; diff --git a/crates/rendering/tests/wgsl_shaders.rs b/crates/rendering/tests/wgsl_shaders.rs new file mode 100644 index 00000000000..08740aa5640 --- /dev/null +++ b/crates/rendering/tests/wgsl_shaders.rs @@ -0,0 +1,51 @@ +use naga::{ + front::wgsl, + valid::{Capabilities, ValidationFlags, Validator}, +}; +use std::path::PathBuf; + +fn shader_paths() -> Vec { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/shaders"); + let mut paths: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|error| panic!("read {}: {error}", dir.display())) + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "wgsl") + }) + .collect(); + paths.sort(); + paths +} + +fn validate(source: &str) -> Result<(), String> { + let module = wgsl::parse_str(source).map_err(|error| error.emit_to_string(source))?; + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&module) + .map(|_| ()) + .map_err(|error| error.emit_to_string(source)) +} + +#[test] +fn every_wgsl_shader_parses_and_validates() { + let paths = shader_paths(); + assert!(!paths.is_empty(), "no shaders found"); + + let failures: Vec = paths + .iter() + .filter_map(|path| { + let source = std::fs::read_to_string(path).unwrap(); + validate(&source) + .err() + .map(|error| format!("{}:\n{error}", path.file_name().unwrap().to_string_lossy())) + }) + .collect(); + + assert!(failures.is_empty(), "{}", failures.join("\n\n")); +} + +#[test] +fn validation_rejects_an_undefined_identifier() { + let source = "@fragment fn main() -> @location(0) vec4 { return vec4(missing); }"; + assert!(validate(source).is_err()); +} diff --git a/crates/scap-direct3d/src/lib.rs b/crates/scap-direct3d/src/lib.rs index 92e37f9d342..b6967529747 100644 --- a/crates/scap-direct3d/src/lib.rs +++ b/crates/scap-direct3d/src/lib.rs @@ -622,15 +622,15 @@ impl Capturer { let crop_data = settings .crop .map(|crop| { - 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)) + let width = crop.right - crop.left; + let height = crop.bottom - crop.top; + let texture = + create_copy_texture(&d3d_device, width, height, settings.pixel_format) + .map_err(NewCapturerError::CropTexture)?; + let crop_pool = Arc::new(TrimPool::default()); + crop_pool.give(texture, width, height); + + Ok::<_, NewCapturerError>((crop_pool, crop, width, height)) }) .transpose()?; @@ -737,7 +737,9 @@ impl Capturer { return Ok(()); } - let frame = if let Some((cropped_texture, crop)) = crop_data.clone() { + let frame = if let Some((crop_pool, crop, crop_width, crop_height)) = + 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. @@ -750,6 +752,13 @@ impl Capturer { return Ok(()); } + let cropped_texture = crop_pool.take( + &d3d_device, + crop_width, + crop_height, + pixel_format, + )?; + unsafe { d3d_context.CopySubresourceRegion( &cropped_texture, @@ -764,15 +773,15 @@ impl Capturer { } Frame { - width: crop.right - crop.left, - height: crop.bottom - crop.top, + width: crop_width, + height: crop_height, pixel_format: settings.pixel_format, inner: frame, texture: cropped_texture, d3d_context: d3d_context.clone(), d3d_device: d3d_device.clone(), staging_pool: staging_pool.clone(), - recycle: None, + recycle: Some(crop_pool), } } else { // The frame is the content bounded by its surface, @@ -981,6 +990,10 @@ impl Frame { &self.texture } + pub fn is_pooled(&self) -> bool { + self.recycle.is_some() + } + pub fn d3d_device(&self) -> &ID3D11Device { &self.d3d_device } diff --git a/crates/verify/tests/codec.rs b/crates/verify/tests/codec.rs index d2efa9de025..bbff4a6afe8 100644 --- a/crates/verify/tests/codec.rs +++ b/crates/verify/tests/codec.rs @@ -215,7 +215,11 @@ fn video_code_survives_compositing_scaling_rounding_bubble_and_blur() { let dir = tempfile::tempdir().unwrap(); // 640x360 is a typical verification output; 320x180 puts the screen at // a quarter of its source size and the camera code cells at ~6 px. - for (out_w, out_h, bitrate) in [(640, 360, 150_000), (320, 180, 80_000)] { + // 320x180 is encoded at the Web export preset (0.08 bpp): at 80 kbps the + // paravirtualised VideoToolbox HEVC encoder on the CI macOS VMs + // occasionally left a blurred camera cell at margin 0.17, below the + // reader's 0.18, while this host's hardware encoder read it at 0.31. + for (out_w, out_h, bitrate) in [(640, 360, 150_000), (320, 180, 138_240)] { for blur in [false, true] { let pairs: Vec<(u32, u32)> = (0..40).map(|i| (i * 3 + 100, i / 2 + 7)).collect(); let frames: Vec = pairs diff --git a/scripts/linux-desktop-harness/arch-up.sh b/scripts/linux-desktop-harness/arch-up.sh index 6a1a8028862..f3a883510d6 100755 --- a/scripts/linux-desktop-harness/arch-up.sh +++ b/scripts/linux-desktop-harness/arch-up.sh @@ -23,13 +23,16 @@ common=(LIBSEAT_BACKEND=seatd XDG_SESSION_TYPE=wayland) case "$desktop" in hyprland) - cat >"$HOME/.config/hypr/hyprland.conf" <"$HOME/.config/hypr/hyprland.lua" <"$HOME/.config/hypr/xdph.conf" < /^(Cap|so\.cap\.Cap)$/.test(w.get_wm_class() || '') && w.showing_on_its_workspace()).forEach(w => w.activate(global.display.get_current_time_roundtrip()))" \ >/dev/null 2>&1 elif [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then - hyprctl dispatch focuswindow class:Cap >/dev/null 2>&1 + hyprctl dispatch "hl.dsp.focus({ window = 'class:Cap' })" >/dev/null 2>&1 fi } for _ in $(seq 60); do