From c80a8de5a5913b552e741860a7e5f28fa9c5d132 Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Tue, 20 Oct 2026 22:16:44 +0300 Subject: [PATCH 1/2] feat(gpui): crop the camera image Per-side camera crop in the project config and renderer, with Left, Right, Top and Bottom crop sliders in the GPUI editor's camera tab. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/src/editor_tabs.rs | 56 ++++++++++ crates/project/src/configuration.rs | 67 +++++++++++ crates/rendering/src/lib.rs | 159 ++++++++++++++++++++++----- 3 files changed, 254 insertions(+), 28 deletions(-) diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index a793647488b..f24abfed603 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -351,6 +351,10 @@ pub enum CameraSlider { ShadowSize, ShadowOpacity, ShadowBlur, + CropLeft, + CropTop, + CropRight, + CropBottom, } impl CameraSlider { @@ -360,6 +364,7 @@ impl CameraSlider { Self::Size => (20., 80., 0.1), // `minValue={10} maxValue={60}` (`:3213-3215`). Self::ZoomSize => (10., 60., 0.1), + Self::CropLeft | Self::CropTop | Self::CropRight | Self::CropBottom => (0., 45., 0.5), _ => (0., 100., 0.1), } } @@ -376,6 +381,10 @@ impl CameraSlider { Self::ShadowSize => shadow.map_or(UI_SHADOW_FALLBACK.size, |s| s.size), Self::ShadowOpacity => shadow.map_or(UI_SHADOW_FALLBACK.opacity, |s| s.opacity), Self::ShadowBlur => shadow.map_or(UI_SHADOW_FALLBACK.blur, |s| s.blur), + Self::CropLeft => camera.crop.map_or(0., |c| (c.left * 100.) as f32), + Self::CropTop => camera.crop.map_or(0., |c| (c.top * 100.) as f32), + Self::CropRight => camera.crop.map_or(0., |c| (c.right * 100.) as f32), + Self::CropBottom => camera.crop.map_or(0., |c| (c.bottom * 100.) as f32), } } } @@ -1020,6 +1029,20 @@ impl EditorWindow { } camera.advanced_shadow = Some(shadow); } + CameraSlider::CropLeft + | CameraSlider::CropTop + | CameraSlider::CropRight + | CameraSlider::CropBottom => { + let mut crop = camera.crop.unwrap_or_default(); + let fraction = f64::from(value) / 100.; + match slider { + CameraSlider::CropLeft => crop.left = fraction, + CameraSlider::CropTop => crop.top = fraction, + CameraSlider::CropRight => crop.right = fraction, + _ => crop.bottom = fraction, + } + camera.crop = (crop != cap_project::CameraCrop::default()).then_some(crop); + } } true }); @@ -1280,6 +1303,39 @@ impl EditorWindow { ) // `
` .child(dashed_divider(Hsla::from(theme.editor.line))) + .child( + ui::Field::section(&theme, "Crop").child( + div() + .flex() + .flex_col() + .gap(px(10.)) + .child(self.slider_field( + "Left", + SliderKey::Camera(CameraSlider::CropLeft), + "%", + cx, + )) + .child(self.slider_field( + "Right", + SliderKey::Camera(CameraSlider::CropRight), + "%", + cx, + )) + .child(self.slider_field( + "Top", + SliderKey::Camera(CameraSlider::CropTop), + "%", + cx, + )) + .child(self.slider_field( + "Bottom", + SliderKey::Camera(CameraSlider::CropBottom), + "%", + cx, + )), + ), + ) + .child(dashed_divider(Hsla::from(theme.editor.line))) .child(self.slider_field("Size", SliderKey::Camera(CameraSlider::Size), "%", cx)) .child(self.slider_field( "Size During Zoom", diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 8b7f2dd2e6e..214554e5e57 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -561,6 +561,46 @@ pub struct Camera { pub scale_during_zoom: f32, #[serde(default)] pub background_blur: BackgroundBlurConfig, + #[serde(default)] + pub crop: Option, +} + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct CameraCrop { + pub left: f64, + pub top: f64, + pub right: f64, + pub bottom: f64, +} + +impl CameraCrop { + pub const MAX_TOTAL: f64 = 0.9; + + pub fn region(&self) -> [f64; 4] { + let axis = |start: f64, end: f64| { + let start = if start.is_finite() { + start.clamp(0.0, 1.0) + } else { + 0.0 + }; + let end = if end.is_finite() { + end.clamp(0.0, 1.0) + } else { + 0.0 + }; + let total = start + end; + let scale = if total > Self::MAX_TOTAL { + Self::MAX_TOTAL / total + } else { + 1.0 + }; + (start * scale, 1.0 - end * scale) + }; + let (x0, x1) = axis(self.left, self.right); + let (y0, y1) = axis(self.top, self.bottom); + [x0, y0, x1, y1] + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, Default)] @@ -605,6 +645,7 @@ impl Default for Camera { rounding_type: CornerStyle::default(), scale_during_zoom: Self::default_scale_during_zoom(), background_blur: BackgroundBlurConfig::default(), + crop: None, } } } @@ -3117,6 +3158,32 @@ mod tests { use super::*; use serde_json::Value; + #[test] + fn camera_crop_region_keeps_part_of_the_image() { + let crop = CameraCrop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.0, + }; + let region = crop.region(); + assert!((region[0] - 0.1).abs() < 1e-9); + assert!((region[1] - 0.2).abs() < 1e-9); + assert!((region[2] - 0.7).abs() < 1e-9); + assert!((region[3] - 1.0).abs() < 1e-9); + + let extreme = CameraCrop { + left: 0.8, + right: 0.8, + ..Default::default() + } + .region(); + assert!(extreme[2] - extreme[0] > 0.09); + + let legacy: Camera = serde_json::from_str(r#"{ "mirror": true }"#).unwrap(); + assert!(legacy.crop.is_none()); + } + #[test] fn studio_sound_defaults_old_projects_to_balanced_and_round_trips_tiers() { let legacy: AudioConfiguration = diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 6abde058f9f..eb73eade0c7 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -2872,6 +2872,55 @@ fn compute_camera_position( [x, y] } +// Mirroring flips the image inside the crop bounds, so the crop edges the user +// sees on the left and right come from the opposite sides of the source frame. +fn camera_source_region(camera: &Camera, frame_size: [f32; 2]) -> ([f32; 2], [f32; 2]) { + let Some(mut crop) = camera.crop else { + return ([0.0, 0.0], frame_size); + }; + if camera.mirror { + std::mem::swap(&mut crop.left, &mut crop.right); + } + let [x0, y0, x1, y1] = crop.region(); + let origin = [x0 as f32 * frame_size[0], y0 as f32 * frame_size[1]]; + let size = [ + ((x1 - x0) as f32 * frame_size[0]).max(1.0), + ((y1 - y0) as f32 * frame_size[1]).max(1.0), + ]; + (origin, size) +} + +fn camera_shape_crop_bounds(shape: CameraShape, origin: [f32; 2], size: [f32; 2]) -> [f32; 4] { + let [ox, oy] = origin; + let [rw, rh] = size; + match shape { + CameraShape::Source => [ox, oy, ox + rw, oy + rh], + CameraShape::Square => { + if rw > rh { + let offset = (rw - rh) / 2.0; + [ox + offset, oy, ox + rw - offset, oy + rh] + } else { + let offset = (rh - rw) / 2.0; + [ox, oy + offset, ox + rw, oy + rh - offset] + } + } + } +} + +fn camera_fill_crop_bounds(origin: [f32; 2], size: [f32; 2], output_aspect: f32) -> [f32; 4] { + let [ox, oy] = origin; + let [rw, rh] = size; + if rw / rh > output_aspect { + let visible_width = rh * output_aspect; + let crop_x = ox + (rw - visible_width) / 2.0; + [crop_x, oy, crop_x + visible_width, oy + rh] + } else { + let visible_height = rw / output_aspect; + let crop_y = oy + (rh - visible_height) / 2.0; + [ox, crop_y, ox + rw, crop_y + visible_height] + } +} + /// Largest centred crop of `src` (origin+size, frame px) matching `target_aspect` /// (aspect-fill, no letterboxing), then tightened by `zoom` (>=1 zooms in) and /// recentred on the normalized `focal` point, clamped to stay inside `src`. @@ -4305,7 +4354,10 @@ impl ProjectUniforms { let screen_src_origin = [crop.position.x as f32, crop.position.y as f32]; let screen_src_size = [crop.size.x as f32, crop.size.y as f32]; - let camera_src_size = [camera_size.x as f32, camera_size.y as f32]; + let (camera_src_origin, camera_src_size) = camera_source_region( + &project.camera, + [camera_size.x as f32, camera_size.y as f32], + ); let screen = SplitPaneLayout { target: screen_target, @@ -4331,7 +4383,7 @@ impl ProjectUniforms { let camera = SplitPaneLayout { target: camera_target, crop: fit_crop_to_target( - [0.0, 0.0], + camera_src_origin, camera_src_size, (camera_target[2] - camera_target[0]) / (camera_target[3] - camera_target[1]).max(f32::EPSILON), @@ -4346,7 +4398,7 @@ impl ProjectUniforms { params.camera_position.y as f32, ], zoom: params.camera_zoom as f32, - src_origin: [0.0, 0.0], + src_origin: camera_src_origin, src_size: camera_src_size, }; @@ -4801,6 +4853,8 @@ impl ProjectUniforms { .map(|camera_size| { let output_size = [output_size.0 as f32, output_size.1 as f32]; let frame_size = [camera_size.x as f32, camera_size.y as f32]; + let (region_origin, region_size) = + camera_source_region(&project.camera, frame_size); let min_axis = output_size[0].min(output_size[1]); const BASE_HEIGHT: f32 = 1080.0; @@ -4815,7 +4869,7 @@ impl ProjectUniforms { let prev_zoomed_size = Self::camera_zoom_factor(&prev_zoom, &prev_scene, base_size, scale_during_zoom); - let aspect = frame_size[0] / frame_size[1]; + let aspect = region_size[0] / region_size[1]; let camera_size_for = |scale: f32| match project.camera.shape { CameraShape::Source => { if aspect >= 1.0 { @@ -4901,18 +4955,8 @@ impl ProjectUniforms { ) }; - let crop_bounds = match project.camera.shape { - CameraShape::Source => [0.0, 0.0, frame_size[0], frame_size[1]], - CameraShape::Square => { - if frame_size[0] > frame_size[1] { - let offset = (frame_size[0] - frame_size[1]) / 2.0; - [offset, 0.0, frame_size[0] - offset, frame_size[1]] - } else { - let offset = (frame_size[1] - frame_size[0]) / 2.0; - [0.0, offset, frame_size[0], frame_size[1] - offset] - } - } - }; + let crop_bounds = + camera_shape_crop_bounds(project.camera.shape, region_origin, region_size); // Morph the camera from its PiP overlay toward its split-screen // half by the split factor. The crop is re-derived from the @@ -5011,8 +5055,9 @@ impl ProjectUniforms { .map(|camera_size| { let output_size = [output_size.0 as f32, output_size.1 as f32]; let frame_size = [camera_size.x as f32, camera_size.y as f32]; + let (region_origin, region_size) = + camera_source_region(&project.camera, frame_size); - let aspect = frame_size[0] / frame_size[1]; let padding = output_size[0].min(output_size[1]) * camera_only_padding as f32 / 100.0; let padded_size = [ @@ -5042,17 +5087,8 @@ impl ProjectUniforms { // In camera-only mode, we ignore the camera shape setting (Square/Source) // and just apply the minimum crop needed to fill the output aspect ratio. // This prevents excessive zooming when shape is set to Square. - let crop_bounds = if aspect > output_aspect { - // Camera is wider than output - crop left and right - let visible_width = frame_size[1] * output_aspect; - let crop_x = (frame_size[0] - visible_width) / 2.0; - [crop_x, 0.0, frame_size[0] - crop_x, frame_size[1]] - } else { - // Camera is taller than output - crop top and bottom - let visible_height = frame_size[0] / output_aspect; - let crop_y = (frame_size[1] - visible_height) / 2.0; - [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] - }; + let crop_bounds = + camera_fill_crop_bounds(region_origin, region_size, output_aspect); let crop_bounds = inset_crop_bounds(crop_bounds, frame_size, CAMERA_EDGE_CROP_INSET_PX); @@ -5207,6 +5243,73 @@ impl ProjectUniforms { mod tests { use super::*; + #[test] + fn camera_source_region_applies_the_crop_in_frame_pixels() { + let mut camera = Camera::default(); + assert_eq!( + camera_source_region(&camera, [1920.0, 1080.0]), + ([0.0, 0.0], [1920.0, 1080.0]) + ); + camera.crop = Some(cap_project::CameraCrop { + left: 0.25, + right: 0.25, + top: 0.1, + bottom: 0.0, + }); + let (origin, size) = camera_source_region(&camera, [1920.0, 1080.0]); + assert!((origin[0] - 480.0).abs() < 1e-3); + assert!((origin[1] - 108.0).abs() < 1e-3); + assert!((size[0] - 960.0).abs() < 1e-3); + assert!((size[1] - 972.0).abs() < 1e-3); + } + + #[test] + fn mirrored_camera_crops_the_sides_the_user_sees() { + let mut camera = Camera { + crop: Some(cap_project::CameraCrop { + left: 0.25, + ..Default::default() + }), + ..Default::default() + }; + let (plain, _) = camera_source_region(&camera, [1000.0, 500.0]); + assert!((plain[0] - 250.0).abs() < 1e-3); + camera.mirror = true; + let (mirrored, size) = camera_source_region(&camera, [1000.0, 500.0]); + assert!(mirrored[0].abs() < 1e-3); + assert!((size[0] - 750.0).abs() < 1e-3); + } + + #[test] + fn camera_layout_crops_stay_inside_the_cropped_region() { + let origin = [200.0, 100.0]; + let size = [1200.0, 800.0]; + let inside = |bounds: [f32; 4]| { + bounds[0] >= origin[0] - 1e-3 + && bounds[1] >= origin[1] - 1e-3 + && bounds[2] <= origin[0] + size[0] + 1e-3 + && bounds[3] <= origin[1] + size[1] + 1e-3 + }; + + let square = camera_shape_crop_bounds(CameraShape::Square, origin, size); + assert_eq!(square, [400.0, 100.0, 1200.0, 900.0]); + let source = camera_shape_crop_bounds(CameraShape::Source, origin, size); + assert_eq!(source, [200.0, 100.0, 1400.0, 900.0]); + + let portrait = camera_fill_crop_bounds(origin, size, 9.0 / 16.0); + assert!(inside(portrait)); + assert!((portrait[3] - portrait[1] - 800.0).abs() < 1e-3); + assert!(((portrait[2] - portrait[0]) - 450.0).abs() < 1e-3); + let wide = camera_fill_crop_bounds(origin, size, 3.0); + assert!(inside(wide)); + assert!(((wide[2] - wide[0]) - 1200.0).abs() < 1e-3); + + let pane = fit_crop_to_target(origin, size, 1.0, [0.0, 1.0], 1.5); + assert!(inside(pane)); + assert!((pane[0] - origin[0]).abs() < 1e-3); + assert!((pane[3] - (origin[1] + size[1])).abs() < 1e-3); + } + #[test] fn camera_blur_output_rejects_missing_pending_and_failed_masks() { use cap_camera_effects::{BlurFailure, BlurMaskStatus, BlurMode, BlurOutputStatus}; From ccbe88512aa1c5e510f2eeec01310202c3671376 Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Wed, 21 Oct 2026 19:20:37 +0300 Subject: [PATCH 2/2] Camera fill crop: keep the uncropped path bit-identical to before The far edge is now origin + size - offset, so with no crop set the bounds are exactly the [offset, 0, frame - offset, frame] the camera-only path used. A test compares both across frame sizes and aspects bit for bit. Co-Authored-By: Claude Opus 5.5 --- crates/rendering/src/lib.rs | 45 ++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index eb73eade0c7..d01c94733e9 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -2910,14 +2910,15 @@ fn camera_shape_crop_bounds(shape: CameraShape, origin: [f32; 2], size: [f32; 2] fn camera_fill_crop_bounds(origin: [f32; 2], size: [f32; 2], output_aspect: f32) -> [f32; 4] { let [ox, oy] = origin; let [rw, rh] = size; + // The far edge is `origin + size - offset` rather than `near + visible`, + // so with no crop (origin 0, size = frame) this is exactly the + // `[offset, 0, frame - offset, frame]` the uncropped path always used. if rw / rh > output_aspect { - let visible_width = rh * output_aspect; - let crop_x = ox + (rw - visible_width) / 2.0; - [crop_x, oy, crop_x + visible_width, oy + rh] + let offset = (rw - rh * output_aspect) / 2.0; + [ox + offset, oy, ox + rw - offset, oy + rh] } else { - let visible_height = rw / output_aspect; - let crop_y = oy + (rh - visible_height) / 2.0; - [ox, crop_y, ox + rw, crop_y + visible_height] + let offset = (rh - rw / output_aspect) / 2.0; + [ox, oy + offset, ox + rw, oy + rh - offset] } } @@ -5310,6 +5311,38 @@ mod tests { assert!((pane[3] - (origin[1] + size[1])).abs() < 1e-3); } + #[test] + fn uncropped_camera_fill_matches_the_original_arithmetic_exactly() { + // The pre-crop camera-only path, verbatim. + let original = |frame_size: [f32; 2], output_aspect: f32| { + if frame_size[0] / frame_size[1] > output_aspect { + let visible_width = frame_size[1] * output_aspect; + let crop_x = (frame_size[0] - visible_width) / 2.0; + [crop_x, 0.0, frame_size[0] - crop_x, frame_size[1]] + } else { + let visible_height = frame_size[0] / output_aspect; + let crop_y = (frame_size[1] - visible_height) / 2.0; + [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] + } + }; + for frame in [ + [1920.0, 1080.0], + [1280.0, 720.0], + [1080.0, 1920.0], + [641.0, 479.0], + ] { + for aspect in [16.0 / 9.0, 9.0 / 16.0, 1.0, 4.0 / 3.0, 21.0 / 9.0, 0.8] { + let ours = camera_fill_crop_bounds([0.0, 0.0], frame, aspect); + let theirs = original(frame, aspect); + assert_eq!( + ours.map(f32::to_bits), + theirs.map(f32::to_bits), + "{frame:?} at {aspect}" + ); + } + } + } + #[test] fn camera_blur_output_rejects_missing_pending_and_failed_masks() { use cap_camera_effects::{BlurFailure, BlurMaskStatus, BlurMode, BlurOutputStatus};