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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions apps/desktop-gpui/src/overlay_backdrop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop-gpui/src/picker_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub fn run(main: WindowHandle<MainWindow>, 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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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 });
};
Expand Down
20 changes: 18 additions & 2 deletions apps/desktop-gpui/src/target_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TargetType>) -> bool {
pub(crate) fn backdrop_suits(mode: Option<TargetType>) -> bool {
match mode {
None => false,
Some(TargetType::Display | TargetType::Window) => !cfg!(windows),
Expand Down Expand Up @@ -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<AreaRect>,
backdrop_capture_crop: Option<AreaRect>,
backdrop_display_size: Option<(f32, f32)>,
crop_moved_at: Option<Instant>,
backdrop_wake_pending: bool,
inline_camera: Option<Entity<crate::camera_window::CameraWindow>>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1570,24 +1574,34 @@ 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;
}
if self.backdrop_crop != self.crop {
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;
}
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions crates/recording/src/output_pipeline/win.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,13 +346,15 @@ impl Muxer for WindowsMuxer {
let mut first_timestamp: Option<Duration> = None;
let mut frame_count: u64 = 0;
let mut frames_reused: u64 = 0;
let mut pooled_frame: Option<scap_direct3d::Frame> = None;

let result = encoder.run(
Arc::new(AtomicBool::default()),
|| loop {
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)
}
Expand Down
5 changes: 4 additions & 1 deletion crates/recording/src/output_pipeline/win_segmented.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ impl WindowsSegmentedMuxer {
either::Left((mut encoder, mut muxer)) => {
trace!("Running native encoder for segment");
let mut first_timestamp: Option<Duration> = None;
let mut pooled_frame: Option<scap_direct3d::Frame> = None;
let result = encoder.run(
Arc::new(AtomicBool::default()),
|| {
Expand All @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions crates/recording/src/sources/screen_capture/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<scap_direct3d::Frame> {
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(),
Expand Down
1 change: 1 addition & 0 deletions crates/rendering/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
39 changes: 29 additions & 10 deletions crates/rendering/src/shaders/composite-video-frame.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ fn fs_main(@builtin(position) frag_coord: vec4<f32>) -> @location(0) vec4<f32> {

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;
Expand Down Expand Up @@ -399,7 +399,7 @@ fn fs_main(@builtin(position) frag_coord: vec4<f32>) -> @location(0) vec4<f32> {
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;
Expand All @@ -419,21 +419,40 @@ fn fs_main(@builtin(position) frag_coord: vec4<f32>) -> @location(0) vec4<f32> {
);
}

fn sample_texture(uv: vec2<f32>, crop_bounds_uv: vec4<f32>) -> vec4<f32> {
fn cropped_sample_uv(uv: vec2<f32>, crop_bounds_uv: vec4<f32>) -> vec2<f32> {
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<f32>, crop_bounds_uv: vec4<f32>) -> vec4<f32> {
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<f32>, crop_bounds_uv: vec4<f32>) -> vec4<f32> {
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;
Comment on lines +450 to +455

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 Video shader cannot load

Moving safe_min and safe_max into cropped_sample_uv leaves them undefined in sample_texture, which still uses both names for neighboring samples. WGSL rejects the whole shader when CompositeVideoFramePipeline::new creates it, so video rendering fails even with motion blur disabled. Keep the bounds available inside sample_texture, or return them from the helper.

Suggested change
let cropped_uv = cropped_sample_uv(uv, crop_bounds_uv);
let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy;
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;
let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/shaders/composite-video-frame.wgsl
Line: 450-452

Comment:
**Video shader cannot load**

Moving `safe_min` and `safe_max` into `cropped_sample_uv` leaves them undefined in `sample_texture`, which still uses both names for neighboring samples. WGSL rejects the whole shader when `CompositeVideoFramePipeline::new` creates it, so video rendering fails even with motion blur disabled. Keep the bounds available inside `sample_texture`, or return them from the helper.

```suggestion
        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;

        let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy;
```

---

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

let source_size = uniforms.frame_size * crop_size;
let target_size = uniforms.target_size;
let scale_ratio = source_size / target_size;
Expand Down
51 changes: 51 additions & 0 deletions crates/rendering/tests/wgsl_shaders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use naga::{
front::wgsl,
valid::{Capabilities, ValidationFlags, Validator},
};
use std::path::PathBuf;

fn shader_paths() -> Vec<PathBuf> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
let mut paths: Vec<PathBuf> = 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<String> = 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<f32> { return vec4(missing); }";
assert!(validate(source).is_err());
}
39 changes: 26 additions & 13 deletions crates/scap-direct3d/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;

Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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),

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 Crop textures return too early

Returning cropped textures to TrimPool when Frame drops does not fully fix the in-flight overwrite. The Windows encoder callbacks return only frame.texture().clone(), dropping Frame before the encoder calls process_texture. The next capture can then reuse and overwrite the same surface before the encoder copies its pixels, producing overwritten frames in cropped recordings. Keep the frame's pool ownership alive until the encoder has copied the texture; a cloned texture handle does not keep Frame alive.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/scap-direct3d/src/lib.rs
Line: 778

Comment:
**Crop textures return too early**

Returning cropped textures to `TrimPool` when `Frame` drops does not fully fix the in-flight overwrite. The Windows encoder callbacks return only `frame.texture().clone()`, dropping `Frame` before the encoder calls `process_texture`. The next capture can then reuse and overwrite the same surface before the encoder copies its pixels, producing overwritten frames in cropped recordings. Keep the frame's pool ownership alive until the encoder has copied the texture; a cloned texture handle does not keep `Frame` alive.

---

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

}
} else {
// The frame is the content bounded by its surface,
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion crates/verify/tests/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rgba> = pairs
Expand Down
Loading
Loading