From a082814c55b1194d875fd270010f2cb085c4ba9a Mon Sep 17 00:00:00 2001 From: martin el cheikh Date: Sun, 18 Oct 2026 15:32:08 -0300 Subject: [PATCH 1/2] fix(rendering): prevent edge apron artifact when window border or shadow is disabled Default CompositeVideoFrameUniforms border_width to 0.0 and zero it when border is disabled or delegated to frame chrome so inactive borders do not inflate edge_padding_uv. Guard custom smoothstep and shadow evaluation against zero shadow span, use textureSampleLevel(..., 0.0) in sample_texture, and avoid calling fwidth inside apply_rounded_corners after non-uniform apron returns. Fixes #2026 --- crates/rendering/src/composite_frame.rs | 14 +- crates/rendering/src/lib.rs | 120 +++++++++++++++++- .../src/shaders/composite-video-frame.wgsl | 89 ++++++++----- 3 files changed, 189 insertions(+), 34 deletions(-) diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index ccf19e628ba..0028bb3792f 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -123,7 +123,7 @@ impl Default for CompositeVideoFrameUniforms { shadow_blur: Default::default(), opacity: 1.0, border_enabled: 0.0, - border_width: 5.0, + border_width: 0.0, preserve_source_alpha: 0.0, _padding1: [0.0; 3], border_color: [0.0, 0.0, 0.0, 0.0], @@ -448,3 +448,15 @@ impl CompositeVideoFramePipeline { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_uniforms_have_zero_border_width_and_disabled_border() { + let uniforms = CompositeVideoFrameUniforms::default(); + assert_eq!(uniforms.border_enabled, 0.0); + assert_eq!(uniforms.border_width, 0.0); + } +} diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 6abde058f9f..7e7e3700d94 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -4628,7 +4628,11 @@ impl ProjectUniforms { .map_or(50.0, |s| s.blur), opacity: scene.screen_opacity as f32 * split_fade * takeover_accessory_fade, border_enabled: if decorated && border_on { 1.0 } else { 0.0 }, - border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), + border_width: if decorated && border_on { + project.background.border.as_ref().map_or(0.0, |b| b.width) + } else { + 0.0 + }, preserve_source_alpha: 1.0, _padding1: [0.0; 3], border_color, @@ -4754,7 +4758,11 @@ impl ProjectUniforms { .map_or(50.0, |s| s.blur), opacity: scene.screen_opacity as f32 * takeover_display_fade, border_enabled: if border_on && !frame_active { 1.0 } else { 0.0 }, - border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), + border_width: if border_on && !frame_active { + project.background.border.as_ref().map_or(0.0, |b| b.width) + } else { + 0.0 + }, preserve_source_alpha: if options.preserve_screen_alpha { 1.0 } else { @@ -5138,6 +5146,7 @@ impl ProjectUniforms { uniforms.shadow = 0.0; uniforms.shadow_opacity = 0.0; uniforms.border_enabled = 0.0; + uniforms.border_width = 0.0; } uniforms }; @@ -6180,6 +6189,113 @@ mod style_image_tests { } } + #[tokio::test] + async fn uniforms_zero_border_width_when_border_disabled_or_delegated_to_frame() { + use cap_project::{BorderConfiguration, FrameConfiguration, FrameStyle}; + + let recording_meta: RecordingMeta = serde_json::from_value(serde_json::json!({ + "pretty_name": "border-width-zeroing", + "display": { "path": "display.mp4", "fps": 60 }, + "camera": null, "audio": null, "cursor": null + })) + .expect("metadata"); + let meta = recording_meta.studio_meta().expect("studio").clone(); + let size = XY::new(160, 90); + let constants = RenderVideoConstants::new_with_options( + RenderOptions { + screen_size: size, + camera_size: None, + preserve_screen_alpha: false, + }, + recording_meta, + meta, + ) + .await + .expect("graphics device"); + + let mut project = ProjectConfiguration::default(); + let cursor = CursorEvents::default(); + let frames = DecodedSegmentFrames { + screen_size: size, + screen_frame: Some(DecodedFrame::new( + [0, 0, 255, 255].repeat(160 * 90), + 160, + 90, + )), + camera_frame: None, + segment_time: 0.0, + recording_time: 0.0, + segment_has_camera: false, + }; + let zoom = ZoomTransformTimeline::from_project(&project, &cursor, 1.0, size); + + project.background.border = None; + let uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + assert_eq!(uniforms.display.border_enabled, 0.0); + assert_eq!(uniforms.display.border_width, 0.0); + + project.background.border = Some(BorderConfiguration { + enabled: false, + width: 12.0, + ..Default::default() + }); + let uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + assert_eq!(uniforms.display.border_enabled, 0.0); + assert_eq!(uniforms.display.border_width, 0.0); + + project.background.border = Some(BorderConfiguration { + enabled: true, + width: 12.0, + ..Default::default() + }); + let uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + assert_eq!(uniforms.display.border_enabled, 1.0); + assert_eq!(uniforms.display.border_width, 12.0); + + project.background.frame = Some(FrameConfiguration { + style: FrameStyle::MacOS, + ..Default::default() + }); + let uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + assert_eq!(uniforms.display.border_enabled, 0.0); + assert_eq!(uniforms.display.border_width, 0.0); + let chrome = uniforms.frame_chrome.expect("frame chrome active"); + assert_eq!(chrome.composite.border_enabled, 1.0); + assert_eq!(chrome.composite.border_width, 12.0); + + project.background.border = Some(BorderConfiguration { + enabled: false, + width: 12.0, + ..Default::default() + }); + let uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + let chrome = uniforms.frame_chrome.as_ref().expect("frame chrome active"); + assert_eq!(chrome.composite.border_enabled, 0.0); + assert_eq!(chrome.composite.border_width, 0.0); + + let mut layers = RendererLayers::new(&constants.device, &constants.queue); + layers + .prepare(&constants, &uniforms, &frames, &cursor, true) + .await + .expect("prepare"); + let mut renderer = FrameRenderer::new(&constants); + let preview = renderer + .render_immediate(frames, uniforms, &cursor, true, &mut layers) + .await + .expect("preview"); + assert!(!preview.data.is_empty()); + } + #[tokio::test] async fn timed_styles_and_images_share_preview_and_export_canvas() { let instance = create_wgpu_instance_sync(); diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index ec2e57b5a59..2b5d1332423 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -104,9 +104,14 @@ fn coverage_from_distance(distance: f32, anti_alias_width: f32) -> f32 { return clamp(1.0 - smoothstep(-anti_alias_width, anti_alias_width, distance), 0.0, 1.0); } -fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32) -> f32 { +fn rounded_rect_coverage_with_aa( + p: vec2, + b: vec2, + r: f32, + rounding_type: f32, + anti_alias_width: f32, +) -> f32 { let distance = sdf_rounded_rect(p, b, r, rounding_type); - let anti_alias_width = max(fwidth(distance), 1.0); if distance <= -anti_alias_width { return 1.0; @@ -137,6 +142,12 @@ fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32) return coverage * 0.25; } +fn rounded_rect_coverage(p: vec2, b: vec2, r: f32, rounding_type: f32) -> f32 { + let distance = sdf_rounded_rect(p, b, r, rounding_type); + let anti_alias_width = max(fwidth(distance), 1.0); + return rounded_rect_coverage_with_aa(p, b, r, rounding_type, anti_alias_width); +} + // Sin-free hash so grain stays stable across GPU drivers. fn grain_hash(p: vec2) -> f32 { var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); @@ -262,15 +273,18 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { shadow_enabled ); - let shadow_dist = dist; - - // Apply blur and size to shadow - let shadow_strength_final = smoothstep(shadow_size + shadow_blur, -shadow_blur, abs(shadow_dist)); - let shadow_color = vec4(0.0, 0.0, 0.0, shadow_strength_final * shadow_opacity); + var shadow_color = vec4(0.0); + let shadow_span = shadow_size + shadow_blur; + let shadow_inner = -shadow_blur; + if shadow_enabled && shadow_opacity > 0.0001 && abs(shadow_span - shadow_inner) > 0.0001 { + let shadow_strength_final = smoothstep(shadow_span, shadow_inner, abs(dist)); + shadow_color = vec4(0.0, 0.0, 0.0, shadow_strength_final * shadow_opacity); + } let target_uv = (p - uniforms.target_bounds.xy) / uniforms.target_size; let crop_bounds_uv = vec4(uniforms.crop_bounds.xy / uniforms.frame_size, uniforms.crop_bounds.zw / uniforms.frame_size); - let edge_padding = max(2.0, uniforms.border_width + 2.0); + let border_w = select(0.0, max(0.0, uniforms.border_width), uniforms.border_enabled > 0.0); + let edge_padding = max(2.0, border_w + 2.0); let edge_padding_uv = edge_padding / uniforms.target_size; // How far outside the card the motion smear can land (card-UV). Zero when @@ -295,11 +309,11 @@ fn fs_main(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { uniforms.rounding_type ); var border_coverage = 0.0; - if (uniforms.border_enabled > 0.0) { + if uniforms.border_enabled > 0.0 && border_w > 0.0 { let border_outer_coverage = rounded_rect_coverage( p - center, - size + vec2(uniforms.border_width), - corner_radius_for(p - center) + uniforms.border_width, + size + vec2(border_w), + corner_radius_for(p - center) + border_w, uniforms.rounding_type ); border_coverage = clamp(border_outer_coverage - shape_coverage, 0.0, 1.0); @@ -441,7 +455,7 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { let upscale_ratio = max(target_size.x / source_size.x, target_size.y / source_size.y); let is_upscaling = upscale_ratio > 1.05; - let center_sample = textureSample(frame_texture, frame_sampler, cropped_uv); + let center_sample = textureSampleLevel(frame_texture, frame_sampler, cropped_uv, 0.0); let center_color = center_sample.rgb; let out_alpha = select(1.0, center_sample.a, uniforms.preserve_source_alpha > 0.5); @@ -479,25 +493,29 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { let offset_x = vec2(texel_size.x, 0.0); let offset_y = vec2(0.0, texel_size.y); - let left = textureSample( + let left = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv - offset_x, safe_min, safe_max) + clamp(cropped_uv - offset_x, safe_min, safe_max), + 0.0 ).rgb; - let right = textureSample( + let right = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv + offset_x, safe_min, safe_max) + clamp(cropped_uv + offset_x, safe_min, safe_max), + 0.0 ).rgb; - let top = textureSample( + let top = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv - offset_y, safe_min, safe_max) + clamp(cropped_uv - offset_y, safe_min, safe_max), + 0.0 ).rgb; - let bottom = textureSample( + let bottom = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv + offset_y, safe_min, safe_max) + clamp(cropped_uv + offset_y, safe_min, safe_max), + 0.0 ).rgb; let blurred = (left + right + top + bottom) * 0.25; @@ -514,25 +532,29 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { let offset_x = vec2(texel_size.x, 0.0); let offset_y = vec2(0.0, texel_size.y); - let left = textureSample( + let left = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv - offset_x, safe_min, safe_max) + clamp(cropped_uv - offset_x, safe_min, safe_max), + 0.0 ).rgb; - let right = textureSample( + let right = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv + offset_x, safe_min, safe_max) + clamp(cropped_uv + offset_x, safe_min, safe_max), + 0.0 ).rgb; - let top = textureSample( + let top = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv - offset_y, safe_min, safe_max) + clamp(cropped_uv - offset_y, safe_min, safe_max), + 0.0 ).rgb; - let bottom = textureSample( + let bottom = textureSampleLevel( frame_texture, frame_sampler, - clamp(cropped_uv + offset_y, safe_min, safe_max) + clamp(cropped_uv + offset_y, safe_min, safe_max), + 0.0 ).rgb; let blurred = (left + right + top + bottom) * 0.25; @@ -551,17 +573,22 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 { let centered_uv = (target_uv - vec2(0.5)) * uniforms.target_size; let half_size = uniforms.target_size * 0.5; - let coverage = rounded_rect_coverage( + let coverage = rounded_rect_coverage_with_aa( centered_uv, half_size, corner_radius_for(centered_uv), - uniforms.rounding_type + uniforms.rounding_type, + 1.0 ); return vec4(current_color.rgb, current_color.a * coverage); } fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 { - let t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); + let denom = edge1 - edge0; + if abs(denom) <= 0.00001 { + return 0.0; + } + let t = clamp((x - edge0) / denom, 0.0, 1.0); return t * t * (3.0 - 2.0 * t); } From da9bd8a0b833bd1e61b709ba76aa876fef804891 Mon Sep 17 00:00:00 2001 From: martin el cheikh Date: Sun, 18 Oct 2026 16:44:47 -0300 Subject: [PATCH 2/2] test(rendering): verify edge pixels around unbordered window across blur modes --- crates/rendering/src/lib.rs | 88 +++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 7e7e3700d94..f97b5afa4f7 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -6283,17 +6283,87 @@ mod style_image_tests { assert_eq!(chrome.composite.border_enabled, 0.0); assert_eq!(chrome.composite.border_width, 0.0); + project.background.frame = None; + project.background.shadow = 0.0; + project.background.rounding = 0.0; + project.background.padding = 25.0; + project.background.source = BackgroundSource::Color { + value: [0, 0, 0], + alpha: 255, + }; + let base_uniforms = ProjectUniforms::new( + &constants, &project, 0, 60, size, &cursor, &frames, 1.0, &zoom, + ); + let [x0, y0, x1, y1] = base_uniforms.display.target_bounds; + let left = x0.floor() as usize; + let top = y0.floor() as usize; + let right = x1.ceil() as usize; + let bottom = y1.ceil() as usize; + + let pixel = |frame: &RenderedFrame, x: usize, y: usize| { + let offset = y * frame.padded_bytes_per_row as usize + x * 4; + <[u8; 4]>::try_from(&frame.data[offset..offset + 4]).expect("pixel") + }; + let mut layers = RendererLayers::new(&constants.device, &constants.queue); - layers - .prepare(&constants, &uniforms, &frames, &cursor, true) - .await - .expect("prepare"); let mut renderer = FrameRenderer::new(&constants); - let preview = renderer - .render_immediate(frames, uniforms, &cursor, true, &mut layers) - .await - .expect("preview"); - assert!(!preview.data.is_empty()); + + for (case, motion_blur_params, motion_blur_vector) in [ + ("static", [0.0, 0.0, 0.0, 0.0], [0.0, 0.0]), + ("moving", [1.0, 1.0, 0.0, 0.0], [0.04, 0.0]), + ("zooming", [2.0, 1.0, 0.02, 0.0], [0.0, 0.0]), + ] { + let mut case_uniforms = base_uniforms.clone(); + case_uniforms.display.motion_blur_params = motion_blur_params; + case_uniforms.display.motion_blur_vector = motion_blur_vector; + case_uniforms.display.motion_blur_zoom_center = [0.5, 0.5]; + + let preview = renderer + .render_immediate(frames.clone(), case_uniforms, &cursor, true, &mut layers) + .await + .expect("preview"); + assert_eq!(pixel(&preview, 80, 45), [0, 0, 255, 255], "{case} center"); + + for y in 0..top.saturating_sub(3) { + for x in 0..160 { + assert_eq!( + pixel(&preview, x, y), + [0, 0, 0, 255], + "{case} top ({x},{y})" + ); + } + } + for y in (bottom + 4)..90 { + for x in 0..160 { + assert_eq!( + pixel(&preview, x, y), + [0, 0, 0, 255], + "{case} bottom ({x},{y})" + ); + } + } + let left_clear_limit = if case == "moving" { + left.saturating_sub(8) + } else { + left.saturating_sub(3) + }; + for y in 0..90 { + for x in 0..left_clear_limit { + assert_eq!( + pixel(&preview, x, y), + [0, 0, 0, 255], + "{case} left ({x},{y})" + ); + } + for x in (right + 4)..160 { + assert_eq!( + pixel(&preview, x, y), + [0, 0, 0, 255], + "{case} right ({x},{y})" + ); + } + } + } } #[tokio::test]