From 9fd8cdcc969916f15c14b2b7ca375b607ceac4e7 Mon Sep 17 00:00:00 2001 From: jatmn Date: Tue, 13 Oct 2026 09:37:07 -0700 Subject: [PATCH 1/3] fix(desktop-gpui): honor export preset height --- apps/desktop-gpui/src/editor_export.rs | 112 +++++++++++++++++++++---- 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index abbf6d98e15..0644b285293 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -15,6 +15,7 @@ use cap_export::preview::{ use cap_export::settings::ExportSettings; use cap_export::{ExporterBase, make_cursor_only_project}; use cap_project::{BackgroundSource, RecordingMeta, XY}; +use cap_rendering::{ProjectUniforms, RenderOptions}; use cap_utils::export_resources::{ DiskBudget, ExportResources, estimated_working_bytes, is_resource_stop, }; @@ -132,12 +133,14 @@ impl ExportResolution { } } - fn size(self) -> (u32, u32) { - match self { - Self::P720 => (1280, 720), - Self::P1080 => (1920, 1080), - Self::P4k => (3840, 2160), - } + fn size(self, canvas: (u32, u32)) -> (u32, u32) { + let height = match self { + Self::P720 => 720, + Self::P1080 => 1080, + Self::P4k => 2160, + }; + let width = (f64::from(canvas.0) * f64::from(height) / f64::from(canvas.1)).ceil() as u32; + ((width + 3) & !3, height) } fn from_slug(slug: &str) -> Self { @@ -322,9 +325,7 @@ impl ExportUi { .unwrap_or_else(|| self.compression.bits_per_pixel()) } - fn estimate_settings(&self) -> ExportSettings { - let (width, height) = self.resolution.size(); - let resolution_base = XY::new(width, height); + fn estimate_settings(&self, resolution_base: XY) -> ExportSettings { if self.cursor_only { ExportSettings::Mov(MovExportSettings { fps: self.fps, @@ -439,6 +440,20 @@ fn decode_jpeg_bytes(bytes: &[u8]) -> Option> { } impl EditorWindow { + fn export_canvas_size(&self) -> Option<(u32, u32)> { + let options = if let Some(instance) = &self.instance { + instance.render_constants.options + } else { + let display = &self.summary()?.recordings.segments.first()?.display; + RenderOptions { + screen_size: XY::new(display.width, display.height), + camera_size: None, + preserve_screen_alpha: false, + } + }; + Some(ProjectUniforms::get_base_size(&options, &self.project)) + } + pub(crate) fn render_reupload_button(&self, cx: &mut Context) -> impl IntoElement { div() .rounded(px(8.)) @@ -600,6 +615,9 @@ impl EditorWindow { } fn refresh_export_preview(&mut self, window: &mut Window, cx: &mut Context) { + let Some(canvas) = self.export_canvas_size() else { + return; + }; let path = self.project_path.clone(); let time = self.preview_or_playhead(); let instance = self.instance.clone(); @@ -613,10 +631,10 @@ impl EditorWindow { ui.estimate_loading = true; ui.preview_stats = None; let estimate_cancel = ui.estimate_cancel.clone(); - let estimate_settings = ui.estimate_settings(); + let (width, height) = ui.resolution.size(canvas); + let estimate_settings = ui.estimate_settings(XY::new(width, height)); let estimate_instance = instance.clone(); let estimate_project = project.clone(); - let (width, height) = ui.resolution.size(); let settings = ExportPreviewSettings { fps: ui.fps, resolution_base: XY::new(width, height), @@ -728,6 +746,9 @@ impl EditorWindow { } pub(crate) fn start_export(&mut self, window: &mut Window, cx: &mut Context) { + let Some(canvas) = self.export_canvas_size() else { + return; + }; let pretty_name = self .summary() .map(|summary| summary.pretty_name.clone()) @@ -770,7 +791,7 @@ impl EditorWindow { let format = ui.format; let cursor_only = ui.cursor_only; let fps = ui.fps; - let (width, height) = ui.resolution.size(); + let (width, height) = ui.resolution.size(canvas); let compression = ui.compression; let custom_bpp = ui.custom_bpp; let optimize = ui.optimize_filesize; @@ -1062,6 +1083,9 @@ impl EditorWindow { } fn start_link_export(&mut self, window: &mut Window, cx: &mut Context) { + let Some(canvas) = self.export_canvas_size() else { + return; + }; let duration = self.total_duration(); let Some(ui) = self.export.as_mut() else { return; @@ -1090,7 +1114,7 @@ impl EditorWindow { let format = ui.format; let cursor_only = ui.cursor_only; let fps = ui.fps; - let (width, height) = ui.resolution.size(); + let (width, height) = ui.resolution.size(canvas); let compression = ui.compression; let custom_bpp = ui.custom_bpp; let optimize = ui.optimize_filesize; @@ -2984,14 +3008,74 @@ async fn run_export( #[cfg(test)] mod tests { - use super::{ExportDestination, ExportPhase, ExportUi, cancel_matching_export}; + use super::{ + ExportDestination, ExportPhase, ExportResolution, ExportUi, cancel_matching_export, + }; use crate::store::ExportPrefs; + use cap_project::{AspectRatio, Crop, ProjectConfiguration, XY}; + use cap_rendering::{ProjectUniforms, RenderOptions}; use std::path::PathBuf; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; + #[test] + fn export_presets_keep_selected_height_for_canvas_shapes() { + let options = RenderOptions { + screen_size: XY::new(3840, 1080), + camera_size: None, + preserve_screen_alpha: false, + }; + let mut project = ProjectConfiguration::default(); + project.background.padding = 0.0; + for (aspect, expected_widths) in [ + (None, [2560, 3840, 7680]), + (Some(AspectRatio::Wide), [1280, 1920, 3840]), + (Some(AspectRatio::Square), [720, 1080, 2160]), + (Some(AspectRatio::Vertical), [404, 608, 1216]), + ] { + project.aspect_ratio = aspect; + for ((preset, height), width) in [ + (ExportResolution::P720, 720), + (ExportResolution::P1080, 1080), + (ExportResolution::P4k, 2160), + ] + .into_iter() + .zip(expected_widths) + { + let (x, y) = preset.size(ProjectUniforms::get_base_size(&options, &project)); + assert_eq!( + ProjectUniforms::get_output_size(&options, &project, XY::new(x, y)), + (width, height), + "{preset:?} {:?}", + project.aspect_ratio, + ); + } + } + } + + #[test] + fn export_preset_uses_cropped_padded_canvas() { + let options = RenderOptions { + screen_size: XY::new(3840, 2160), + camera_size: None, + preserve_screen_alpha: false, + }; + let mut project = ProjectConfiguration::default(); + project.background.padding = 25.0; + project.background.crop = Some(Crop { + position: XY::new(100, 50), + size: XY::new(2400, 600), + }); + let (x, y) = + ExportResolution::P1080.size(ProjectUniforms::get_base_size(&options, &project)); + assert_eq!( + ProjectUniforms::get_output_size(&options, &project, XY::new(x, y)), + (4320, 1080), + ); + } + fn clipboard_export() -> ExportUi { ExportUi::from_preferences(ExportPrefs { format: "Mp4".into(), From 601ef17fda7e1443b221a64135ffed1e24f602b3 Mon Sep 17 00:00:00 2001 From: jatmn Date: Tue, 13 Oct 2026 10:10:01 -0700 Subject: [PATCH 2/3] fix(desktop-gpui): reject oversized export dimensions --- apps/desktop-gpui/src/editor_export.rs | 98 +++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 0644b285293..239d99039b7 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -133,14 +133,21 @@ impl ExportResolution { } } - fn size(self, canvas: (u32, u32)) -> (u32, u32) { + fn size(self, canvas: (u32, u32)) -> Result<(u32, u32), String> { let height = match self { Self::P720 => 720, Self::P1080 => 1080, Self::P4k => 2160, }; let width = (f64::from(canvas.0) * f64::from(height) / f64::from(canvas.1)).ceil() as u32; - ((width + 3) & !3, height) + let width = (width + 3) & !3; + let limit = wgpu::Limits::default().max_texture_dimension_2d; + if width > limit || height > limit { + return Err(format!( + "Export size {width} × {height} exceeds the maximum dimension of {limit} pixels. Choose a lower resolution or a less wide crop." + )); + } + Ok((width, height)) } fn from_slug(slug: &str) -> Self { @@ -630,8 +637,22 @@ impl EditorWindow { ui.estimate = None; ui.estimate_loading = true; ui.preview_stats = None; + let request = Arc::new(()); + ui.preview_request = request.clone(); + ui.preview_task = None; + let (width, height) = match ui.resolution.size(canvas) { + Ok(size) => size, + Err(error) => { + ui.estimate_loading = false; + ui.preview_error = Some(error); + if let Some(image) = ui.preview.take() { + let _ = window.drop_image(image); + } + cx.notify(); + return; + } + }; let estimate_cancel = ui.estimate_cancel.clone(); - let (width, height) = ui.resolution.size(canvas); let estimate_settings = ui.estimate_settings(XY::new(width, height)); let estimate_instance = instance.clone(); let estimate_project = project.clone(); @@ -644,8 +665,6 @@ impl EditorWindow { // Match Windows editor playback: a fresh Media Foundation preview seek can return black. let force = cfg!(target_os = "windows") || ui.force_ffmpeg; ui.preview_error = None; - let request = Arc::new(()); - ui.preview_request = request.clone(); ui.preview_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(120)) @@ -761,6 +780,13 @@ impl EditorWindow { if ui.phase.is_busy() || ui.close_requested { return; } + let (width, height) = match ui.resolution.size(canvas) { + Ok(size) => size, + Err(_) => { + self.refresh_export_preview(window, cx); + return; + } + }; if ui.destination == ExportDestination::Link { if ui.sign_in_pending { ui.sign_in_cancel.store(true, Ordering::Relaxed); @@ -791,7 +817,6 @@ impl EditorWindow { let format = ui.format; let cursor_only = ui.cursor_only; let fps = ui.fps; - let (width, height) = ui.resolution.size(canvas); let compression = ui.compression; let custom_bpp = ui.custom_bpp; let optimize = ui.optimize_filesize; @@ -1093,6 +1118,13 @@ impl EditorWindow { if ui.phase.is_busy() || ui.close_requested { return; } + let (width, height) = match ui.resolution.size(canvas) { + Ok(size) => size, + Err(_) => { + self.refresh_export_preview(window, cx); + return; + } + }; let upgraded = store::auth_snapshot().is_upgraded(); if !upgraded && duration >= 300.0 { @@ -1114,7 +1146,6 @@ impl EditorWindow { let format = ui.format; let cursor_only = ui.cursor_only; let fps = ui.fps; - let (width, height) = ui.resolution.size(canvas); let compression = ui.compression; let custom_bpp = ui.custom_bpp; let optimize = ui.optimize_filesize; @@ -3044,7 +3075,9 @@ mod tests { .into_iter() .zip(expected_widths) { - let (x, y) = preset.size(ProjectUniforms::get_base_size(&options, &project)); + let (x, y) = preset + .size(ProjectUniforms::get_base_size(&options, &project)) + .unwrap(); assert_eq!( ProjectUniforms::get_output_size(&options, &project, XY::new(x, y)), (width, height), @@ -3068,14 +3101,59 @@ mod tests { position: XY::new(100, 50), size: XY::new(2400, 600), }); - let (x, y) = - ExportResolution::P1080.size(ProjectUniforms::get_base_size(&options, &project)); + let (x, y) = ExportResolution::P1080 + .size(ProjectUniforms::get_base_size(&options, &project)) + .unwrap(); assert_eq!( ProjectUniforms::get_output_size(&options, &project, XY::new(x, y)), (4320, 1080), ); } + #[test] + fn export_presets_reject_crops_exceeding_the_renderer_limit() { + let options = RenderOptions { + screen_size: XY::new(3840, 2160), + camera_size: None, + preserve_screen_alpha: false, + }; + let mut project = ProjectConfiguration::default(); + project.background.padding = 0.0; + for (crop, saved_preset, expected_width) in [ + (XY::new(1920, 200), "1080p", 10368), + (XY::new(3840, 600), "4K", 13824), + ] { + project.background.crop = Some(Crop { + position: XY::new(0, 0), + size: crop, + }); + let canvas = ProjectUniforms::get_base_size(&options, &project); + let preset = ExportResolution::from_slug(saved_preset); + let error = preset + .size(canvas) + .expect_err("oversized exports must be rejected"); + assert!(error.contains(&expected_width.to_string())); + assert!(error.contains("8192")); + assert!(error.contains("Choose a lower resolution")); + let (width, height) = ExportResolution::P720.size(canvas).unwrap(); + assert_eq!(height, 720); + assert_eq!(width, if saved_preset == "1080p" { 6912 } else { 4608 }); + assert_eq!( + ProjectUniforms::get_output_size(&options, &project, XY::new(width, height)), + (width, height), + ); + } + } + + #[test] + fn export_size_limit_includes_codec_alignment() { + assert_eq!( + ExportResolution::P1080.size((8191, 1080)).unwrap(), + (8192, 1080) + ); + assert!(ExportResolution::P1080.size((8193, 1080)).is_err()); + } + fn clipboard_export() -> ExportUi { ExportUi::from_preferences(ExportPrefs { format: "Mp4".into(), From 2bc38acb54af329f3bcc73e2852251e116cfc0be Mon Sep 17 00:00:00 2001 From: jatmn Date: Tue, 13 Oct 2026 10:43:28 -0700 Subject: [PATCH 3/3] fix(desktop-gpui): preserve export cancellation and reject invalid sizes --- apps/desktop-gpui/src/editor_export.rs | 55 ++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 239d99039b7..c5410b541e8 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -134,13 +134,19 @@ impl ExportResolution { } fn size(self, canvas: (u32, u32)) -> Result<(u32, u32), String> { + if canvas.1 == 0 { + return Err("Export canvas has no height. Adjust the crop and try again.".into()); + } let height = match self { Self::P720 => 720, Self::P1080 => 1080, Self::P4k => 2160, }; let width = (f64::from(canvas.0) * f64::from(height) / f64::from(canvas.1)).ceil() as u32; - let width = (width + 3) & !3; + let width = width + .checked_add(3) + .ok_or("Export width is too large. Choose a lower resolution or adjust the crop.")? + & !3; let limit = wgpu::Limits::default().max_texture_dimension_2d; if width > limit || height > limit { return Err(format!( @@ -780,6 +786,12 @@ impl EditorWindow { if ui.phase.is_busy() || ui.close_requested { return; } + if ui.destination == ExportDestination::Link && ui.sign_in_pending { + ui.sign_in_cancel.store(true, Ordering::Relaxed); + ui.sign_in_pending = false; + cx.notify(); + return; + } let (width, height) = match ui.resolution.size(canvas) { Ok(size) => size, Err(_) => { @@ -788,12 +800,6 @@ impl EditorWindow { } }; if ui.destination == ExportDestination::Link { - if ui.sign_in_pending { - ui.sign_in_cancel.store(true, Ordering::Relaxed); - ui.sign_in_pending = false; - cx.notify(); - return; - } if !store::auth_snapshot().signed_in() { self.start_share_sign_in(window, cx); return; @@ -3154,6 +3160,41 @@ mod tests { assert!(ExportResolution::P1080.size((8193, 1080)).is_err()); } + #[test] + fn export_presets_reject_zero_height_crops_without_panicking() { + let options = RenderOptions { + screen_size: XY::new(1920, 1080), + camera_size: None, + preserve_screen_alpha: false, + }; + let mut project = ProjectConfiguration::default(); + project.background.padding = 0.0; + project.background.crop = Some(Crop { + position: XY::new(0, 0), + size: XY::new(1920, 0), + }); + let canvas = ProjectUniforms::get_base_size(&options, &project); + assert_eq!(canvas, (1920, 0)); + for preset in [ + ExportResolution::P720, + ExportResolution::P1080, + ExportResolution::P4k, + ] { + assert!(preset.size(canvas).is_err()); + } + } + + #[test] + fn export_presets_reject_widths_that_cannot_be_aligned() { + for preset in [ + ExportResolution::P720, + ExportResolution::P1080, + ExportResolution::P4k, + ] { + assert!(preset.size((u32::MAX, 1)).is_err()); + } + } + fn clipboard_export() -> ExportUi { ExportUi::from_preferences(ExportPrefs { format: "Mp4".into(),