diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml
index 186c6c912e8..6c36df878d6 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 adc646a6803..11bf11716f4 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 c0b5f57b038..467abace64b 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 dee8942f499..a4b208da7d3 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 7b7393439b8..9795abe7159 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 60637b0c9c1..1742f0b7359 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 1c18cc7ab65..376cf2cb1db 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 3a49d8e5c14..f6d935f2d0a 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 7ba482c4968..121d8083c6f 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 65e5b101a40..d5440299386 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 bb0a0491097..f222c27f71f 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 da01e6295eb..b7bd26be7f2 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"] {