diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index 186c6c912e..6c36df878d 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -2462,6 +2462,11 @@ stereo = "Stereo" advanced_shadow = "Erweiterte Schatteneinstellungen" background = "Hintergrund" corner_style = "Eckenstil" +crop = "Zuschneiden" +crop_bottom = "Unten" +crop_left = "Links" +crop_right = "Rechts" +crop_top = "Oben" custom_position = "Eigene Position (auf der Leinwand verschoben)" hide = "Kamera ausblenden" keep_size_during_zoom = "Originalgröße beim Zoomen beibehalten" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index adc646a680..11bf11716f 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -2464,6 +2464,11 @@ stereo = "Stereo" advanced_shadow = "Advanced shadow settings" background = "Background" corner_style = "Corner Style" +crop = "Crop" +crop_bottom = "Bottom" +crop_left = "Left" +crop_right = "Right" +crop_top = "Top" custom_position = "Custom position (dragged on canvas)" hide = "Hide Camera" keep_size_during_zoom = "Keep original size during zoom" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index c0b5f57b03..467abace64 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -2462,6 +2462,11 @@ stereo = "Estéreo" advanced_shadow = "Ajustes avanzados de sombra" background = "Fondo" corner_style = "Estilo de esquinas" +crop = "Recortar" +crop_bottom = "Abajo" +crop_left = "Izquierda" +crop_right = "Derecha" +crop_top = "Arriba" custom_position = "Posición personalizada (arrastrada en el lienzo)" hide = "Ocultar cámara" keep_size_during_zoom = "Mantener el tamaño original durante el zoom" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index dee8942f49..a4b208da7d 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -2462,6 +2462,11 @@ stereo = "Stéréo" advanced_shadow = "Réglages avancés de l'ombre" background = "Arrière-plan" corner_style = "Style des coins" +crop = "Recadrer" +crop_bottom = "Bas" +crop_left = "Gauche" +crop_right = "Droite" +crop_top = "Haut" custom_position = "Position personnalisée (déplacée sur le canevas)" hide = "Masquer la caméra" keep_size_during_zoom = "Conserver la taille d'origine pendant le zoom" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 7b7393439b..9795abe715 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -2462,6 +2462,11 @@ stereo = "स्टीरियो" advanced_shadow = "छाया की एडवांस्ड सेटिंग्स" background = "बैकग्राउंड" corner_style = "कोने की स्टाइल" +crop = "क्रॉप" +crop_bottom = "नीचे" +crop_left = "बायाँ" +crop_right = "दायाँ" +crop_top = "ऊपर" custom_position = "कस्टम पोज़िशन (कैनवास पर ड्रैग की गई)" hide = "कैमरा छिपाएँ" keep_size_during_zoom = "ज़ूम के दौरान मूल आकार बनाए रखें" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index 60637b0c9c..1742f0b735 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -2462,6 +2462,11 @@ stereo = "ステレオ" advanced_shadow = "影の詳細設定" background = "背景" corner_style = "角のスタイル" +crop = "切り抜き" +crop_bottom = "下" +crop_left = "左" +crop_right = "右" +crop_top = "上" custom_position = "カスタム位置(キャンバス上でドラッグ)" hide = "カメラを非表示" keep_size_during_zoom = "ズーム中も元のサイズを保つ" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index 1c18cc7ab6..376cf2cb1d 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -2462,6 +2462,11 @@ stereo = "立体声" advanced_shadow = "高级阴影设置" background = "背景" corner_style = "圆角样式" +crop = "裁剪" +crop_bottom = "下" +crop_left = "左" +crop_right = "右" +crop_top = "上" custom_position = "自定义位置(在画布上拖动)" hide = "隐藏摄像头" keep_size_during_zoom = "缩放时保持原始大小" diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index 3a49d8e5c1..f6d935f2d0 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -393,6 +393,10 @@ pub enum CameraSlider { ShadowSize, ShadowOpacity, ShadowBlur, + CropLeft, + CropTop, + CropRight, + CropBottom, } impl CameraSlider { @@ -408,7 +412,11 @@ impl CameraSlider { | Self::Shadow | Self::ShadowSize | Self::ShadowOpacity - | Self::ShadowBlur => false, + | Self::ShadowBlur + | Self::CropLeft + | Self::CropTop + | Self::CropRight + | Self::CropBottom => false, } } @@ -417,6 +425,7 @@ impl CameraSlider { // `minValue={20} maxValue={80} step={0.1}` (`:3204-3206`). Self::Size => (20., 80., 0.1), Self::ZoomSize => (10., 100., 0.1), + Self::CropLeft | Self::CropTop | Self::CropRight | Self::CropBottom => (0., 45., 0.5), _ => (0., 100., 0.1), } } @@ -432,6 +441,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), } } } @@ -1226,6 +1239,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 }, @@ -1518,6 +1545,39 @@ impl EditorWindow { ) // `
` .child(dashed_divider(Hsla::from(theme.editor.line))) + .child( + ui::Field::section(&theme, tr::editor::tabs::camera::crop()).child( + div() + .flex() + .flex_col() + .gap(px(10.)) + .child(self.slider_field( + tr::editor::tabs::camera::crop_left(), + SliderKey::Camera(CameraSlider::CropLeft), + "%", + cx, + )) + .child(self.slider_field( + tr::editor::tabs::camera::crop_right(), + SliderKey::Camera(CameraSlider::CropRight), + "%", + cx, + )) + .child(self.slider_field( + tr::editor::tabs::camera::crop_top(), + SliderKey::Camera(CameraSlider::CropTop), + "%", + cx, + )) + .child(self.slider_field( + tr::editor::tabs::camera::crop_bottom(), + SliderKey::Camera(CameraSlider::CropBottom), + "%", + cx, + )), + ), + ) + .child(dashed_divider(Hsla::from(theme.editor.line))) .child(self.slider_field( tr::editor::tabs::camera::size_label(), SliderKey::Camera(CameraSlider::Size), diff --git a/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs b/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs index 7ba482c496..121d8083c6 100644 --- a/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/editor_shell_surface_tests.rs @@ -414,6 +414,11 @@ fn editor_shell_surfaces_match_the_literals_they_replaced() { "Keep original size during zoom", ), (tr::editor::tabs::camera::mirror(), "Mirror Camera"), + (tr::editor::tabs::camera::crop(), "Crop"), + (tr::editor::tabs::camera::crop_bottom(), "Bottom"), + (tr::editor::tabs::camera::crop_left(), "Left"), + (tr::editor::tabs::camera::crop_right(), "Right"), + (tr::editor::tabs::camera::crop_top(), "Top"), (tr::editor::tabs::camera::position(), "Position"), (tr::editor::tabs::camera::reset(), "Reset"), ( diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 65e5b101a4..d544029938 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -769,7 +769,7 @@ export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; * Normalized (0-1) center of the camera rect in output-frame space. * Overrides `position` when set. */ -manualPosition: XY | null; size: number; zoomSize: number | null; rounding: number; shadow: number; advancedShadow: ShadowConfiguration | null; shape: CameraShape; roundingType: CornerStyle; scaleDuringZoom?: number; backgroundBlur?: BackgroundBlurConfig } +manualPosition: XY | null; size: number; zoomSize: number | null; rounding: number; shadow: number; advancedShadow: ShadowConfiguration | null; shape: CameraShape; roundingType: CornerStyle; scaleDuringZoom?: number; backgroundBlur?: BackgroundBlurConfig; crop?: CameraCrop | null } /** * Screen-space focus blur applied over the composed frame while a 3d segment * is active (a UV-mask variable blur, not a depth-of-field). `strength` is a @@ -878,6 +878,7 @@ transitionOut?: number } * after its last keyframe. */ export type Camera3DTracks = { tiltX: Camera3DKeyframe[]; tiltY: Camera3DKeyframe[]; roll: Camera3DKeyframe[]; rotateX: Camera3DKeyframe[]; rotateY: Camera3DKeyframe[]; fov: Camera3DKeyframe[]; zoom: Camera3DKeyframe[]; panX: Camera3DKeyframe[]; panY: Camera3DKeyframe[]; blurStrength: Camera3DKeyframe[]; blurFalloff: Camera3DKeyframe[]; blurFocusSize: Camera3DKeyframe[]; blurFocusX: Camera3DKeyframe[]; blurFocusY: Camera3DKeyframe[]; blurAngle: Camera3DKeyframe[]; blurDirPosition: Camera3DKeyframe[] } +export type CameraCrop = { left: number; top: number; right: number; bottom: number } export type CameraDeviceSettings = { width: number | null; height: number | null; frameRate: number | null } export type CameraFormatInfo = { width: number; height: number; frameRate: number } export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a049109..f222c27f71 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -565,6 +565,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)] @@ -609,6 +649,7 @@ impl Default for Camera { rounding_type: CornerStyle::default(), scale_during_zoom: Self::default_scale_during_zoom(), background_blur: BackgroundBlurConfig::default(), + crop: None, } } } @@ -3263,6 +3304,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 da01e6295e..b7bd26be7f 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -3056,6 +3056,70 @@ 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] + } + } + } +} + +/// Shrinks a camera card that would not fit the output (minus padding on +/// both sides) by one factor on both axes, so a heavily cropped, very wide +/// or tall region keeps its aspect instead of being clamped on one axis and +/// squeezed by the shader. Cards that fit are returned unchanged. +fn fit_camera_card(size: [f32; 2], output_size: [f32; 2], padding: f32) -> [f32; 2] { + let fit = ((output_size[0] - 2.0 * padding) / size[0].max(1.0)) + .min((output_size[1] - 2.0 * padding) / size[1].max(1.0)); + if fit >= 1.0 || !fit.is_finite() || fit <= 0.0 { + size + } else { + [size[0] * fit, size[1] * fit] + } +} + +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 offset = (rw - rh * output_aspect) / 2.0; + [ox + offset, oy, ox + rw - offset, oy + rh] + } else { + let offset = (rh - rw / output_aspect) / 2.0; + [ox, oy + offset, ox + rw, oy + rh - offset] + } +} + /// 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`. @@ -4522,7 +4586,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, @@ -4548,7 +4615,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), @@ -4563,7 +4630,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, }; @@ -5011,6 +5078,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; @@ -5025,25 +5094,28 @@ 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 camera_size_for = |scale: f32| match project.camera.shape { - CameraShape::Source => { - if aspect >= 1.0 { - [ - (min_axis * scale + camera_padding) * aspect, - min_axis * scale + camera_padding, - ] - } else { - [ - min_axis * scale + camera_padding, - (min_axis * scale + camera_padding) / aspect, - ] + let aspect = region_size[0] / region_size[1]; + let camera_size_for = |scale: f32| { + let size = match project.camera.shape { + CameraShape::Source => { + if aspect >= 1.0 { + [ + (min_axis * scale + camera_padding) * aspect, + min_axis * scale + camera_padding, + ] + } else { + [ + min_axis * scale + camera_padding, + (min_axis * scale + camera_padding) / aspect, + ] + } } - } - CameraShape::Square => [ - min_axis * scale + camera_padding, - min_axis * scale + camera_padding, - ], + CameraShape::Square => [ + min_axis * scale + camera_padding, + min_axis * scale + camera_padding, + ], + }; + fit_camera_card(size, output_size, camera_padding) }; let size = camera_size_for(zoomed_size); @@ -5111,18 +5183,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 @@ -5220,8 +5282,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 = [ @@ -5251,17 +5314,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); @@ -5416,6 +5470,132 @@ 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 heavily_cropped_cameras_shrink_to_fit_instead_of_squeezing() { + let output = [1920.0, 1080.0]; + let pad = 40.0; + // 45% top + 45% bottom of a 1920x1080 feed: a ~17.8:1 region. + let aspect = 1920.0 / (1080.0 * 0.1); + let edge = 1080.0 * 0.3 + pad; + let card = fit_camera_card([edge * aspect, edge], output, pad); + assert!(card[0] <= output[0] - 2.0 * pad + 1e-3, "{card:?}"); + assert!(card[1] <= output[1] - 2.0 * pad + 1e-3, "{card:?}"); + assert!( + (card[0] / card[1] - aspect).abs() < 1e-3, + "aspect kept: {card:?}" + ); + + // A card that fits is returned bit-for-bit. + let small = [633.6, 356.4]; + assert_eq!( + fit_camera_card(small, output, pad).map(f32::to_bits), + small.map(f32::to_bits) + ); + // Tall regions shrink on the height. + let tall = fit_camera_card([400.0, 4000.0], output, pad); + assert!(tall[1] <= output[1] - 2.0 * pad + 1e-3); + assert!((tall[0] / tall[1] - 0.1).abs() < 1e-4); + } + + #[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 color_components_parse_rgb_hex_with_either_case_and_optional_hash() { for hex in ["#4785FF", "4785ff", "#4785ff"] {