From fffcf0679cbf2637f032d0081a68c21668b23340 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 26 Jun 2026 06:54:49 -0400 Subject: [PATCH] aspect ratio: unify video placement + add export fit modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A) Imported video had a different aspect ratio depending on how it was placed: direct import used a uniform scale + center (preserve aspect), while the asset-library timeline drag used an independent scale_x/scale_y (stretch to fill). Add Transform::fit_centered (uniform scale, centered, aspect-preserving) and route both paths through it, so a clip looks identical however it's added. B) Exported video was stretched when the export resolution's aspect differed from the document's (base_transform was always scale_non_uniform). Add ExportFitMode {Stretch, Letterbox (default), Crop} on VideoExportSettings + a "Fit" dropdown in advanced export settings, and a shared export_base_transform() helper: Letterbox = uniform fit centered (black bars), Crop = uniform fill centered (trim), Stretch = the old distort-to-fill. Threaded through the software, HDR, and zero-copy export render paths (image export is doc-sized → identity). Co-Authored-By: Claude Opus 4.8 --- .../lightningbeam-core/src/export.rs | 28 ++++++++++++ .../lightningbeam-core/src/object.rs | 15 +++++++ .../lightningbeam-editor/src/export/dialog.rs | 13 ++++++ .../lightningbeam-editor/src/export/mod.rs | 16 ++++++- .../src/export/video_exporter.rs | 45 ++++++++++++------- .../lightningbeam-editor/src/main.rs | 21 +-------- .../src/panes/timeline.rs | 15 ++----- 7 files changed, 103 insertions(+), 50 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 33bfdc9..21b9d46 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -320,6 +320,29 @@ impl HdrExportMode { } } +/// How the document is fit into the export frame when the export resolution's aspect ratio differs +/// from the document's. Applied as the export `base_transform` (document space → export pixels). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum ExportFitMode { + /// Scale each axis independently to fill the frame — distorts when aspects differ. + Stretch, + /// Scale uniformly to fit, centered, with black bars (letterbox/pillarbox). Preserves aspect. + #[default] + Letterbox, + /// Scale uniformly to fill, centered, cropping the overflow. Preserves aspect, no bars. + Crop, +} + +impl ExportFitMode { + pub fn name(&self) -> &'static str { + match self { + ExportFitMode::Stretch => "Stretch (distort to fill)", + ExportFitMode::Letterbox => "Letterbox (fit, black bars)", + ExportFitMode::Crop => "Crop (fill, trim edges)", + } + } +} + /// Video quality presets #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VideoQuality { @@ -385,6 +408,10 @@ pub struct VideoExportSettings { #[serde(default)] pub hdr: HdrExportMode, + /// How the document is fit into the export frame when aspect ratios differ (default Letterbox). + #[serde(default)] + pub fit: ExportFitMode, + /// Audio settings (None = no audio) pub audio: Option, @@ -405,6 +432,7 @@ impl Default for VideoExportSettings { quality: VideoQuality::High, color_range: ColorRange::Limited, hdr: HdrExportMode::Sdr, + fit: ExportFitMode::Letterbox, audio: Some(AudioExportSettings::high_quality_aac()), start_time: 0.0, end_time: 60.0, diff --git a/lightningbeam-ui/lightningbeam-core/src/object.rs b/lightningbeam-ui/lightningbeam-core/src/object.rs index 601bede..2e63008 100644 --- a/lightningbeam-ui/lightningbeam-core/src/object.rs +++ b/lightningbeam-ui/lightningbeam-core/src/object.rs @@ -47,6 +47,21 @@ impl Transform { Self::default() } + /// Set scale + position so `content_w × content_h` is fit **uniformly and centered** within + /// `doc_w × doc_h`, preserving aspect ratio (letterbox/pillarbox). The single shared way to + /// place a media clip (video/image) on the document — both import paths use this so a clip + /// looks identical however it was added. No-op for non-positive content dimensions. + pub fn fit_centered(&mut self, content_w: f64, content_h: f64, doc_w: f64, doc_h: f64) { + if content_w <= 0.0 || content_h <= 0.0 { + return; + } + let scale = (doc_w / content_w).min(doc_h / content_h); + self.scale_x = scale; + self.scale_y = scale; + self.x = (doc_w - content_w * scale) / 2.0; + self.y = (doc_h - content_h * scale) / 2.0; + } + /// Create a transform with position pub fn with_position(x: f64, y: f64) -> Self { Self { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 6d06263..65e4f2b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -504,6 +504,19 @@ impl ExportDialog { } }); + // Fit mode — how the document maps into the export frame when the aspect ratios differ. + ui.horizontal(|ui| { + use lightningbeam_core::export::ExportFitMode; + ui.label("Fit:"); + egui::ComboBox::from_id_salt("video_fit_mode") + .selected_text(self.video_settings.fit.name()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Letterbox, ExportFitMode::Letterbox.name()); + ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Crop, ExportFitMode::Crop.name()); + ui.selectable_value(&mut self.video_settings.fit, ExportFitMode::Stretch, ExportFitMode::Stretch.name()); + }); + }); + ui.horizontal(|ui| { ui.label("FPS:"); egui::ComboBox::from_id_salt("framerate") diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 5673da1..9c62936 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -55,6 +55,8 @@ pub struct VideoExportState { height: u32, /// HDR output mode — HDR uses a synchronous 10-bit path instead of the async RGBA pipeline. hdr: lightningbeam_core::export::HdrExportMode, + /// How the document is fit into the export frame (stretch/letterbox/crop). + fit: lightningbeam_core::export::ExportFitMode, /// Channel to send rendered frames to encoder thread frame_tx: Option>, /// HDR GPU resources for compositing pipeline (effects, color conversion) @@ -84,6 +86,8 @@ struct ZeroCopyVideo { rgba: wgpu::Texture, /// True when running on the shared device → compositing can consume hardware-decoded GPU frames. on_shared_device: bool, + /// How the document is fit into the export frame (stretch/letterbox/crop). + fit: lightningbeam_core::export::ExportFitMode, } /// State for a single-frame image export (runs on the GPU render thread, one frame per update). @@ -628,6 +632,8 @@ impl ExportOrchestrator { state.settings.allow_transparency, raster_store, true, // image export composites on the shared device + // Image export renders at the document's own size, so the fit transform is identity. + lightningbeam_core::export::ExportFitMode::Letterbox, )?; queue.submit(Some(encoder.finish())); @@ -861,6 +867,7 @@ impl ExportOrchestrator { height: u32, ) -> (std::thread::JoinHandle<()>, VideoExportState) { let hdr = settings.hdr; + let fit = settings.fit; let handle = std::thread::spawn(move || { Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); }); @@ -874,6 +881,7 @@ impl ExportOrchestrator { width, height, hdr, + fit, frame_tx: Some(frame_tx), gpu_resources: None, readback_pipeline: None, @@ -959,7 +967,7 @@ impl ExportOrchestrator { view_formats: &[], }); println!("🎬 [EXPORT] zero-copy VAAPI H.264 enabled"); - Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba, on_shared_device }) + Some(ZeroCopyVideo { encoder, renderer, gpu_resources, rgba, on_shared_device, fit: settings.fit }) } /// Start a video export in the background. @@ -1270,6 +1278,7 @@ impl ExportOrchestrator { let width = state.width; let height = state.height; + let fit = state.fit; // HDR path: synchronous 10-bit render (composite → PQ/HLG → readback → 10-bit YUV), one // frame per call. Bypasses the SDR async RGBA pipeline (which is 8-bit only). @@ -1284,7 +1293,7 @@ impl ExportOrchestrator { let (y, u, v) = video_exporter::render_frame_to_yuv10_hdr( document, timestamp, width, height, device, queue, renderer, image_cache, video_manager, - gpu_resources, state.hdr, raster_store, + gpu_resources, state.hdr, fit, raster_store, )?; if let Some(tx) = &state.frame_tx { tx.send(VideoFrameMessage::Frame { @@ -1414,6 +1423,7 @@ impl ExportOrchestrator { false, // Video export is never transparent raster_store, true, // software export composites on the shared device → may use HW frames + fit, )?; let render_end = Instant::now(); @@ -1504,6 +1514,7 @@ impl ExportOrchestrator { let rgba_view = zc.rgba.create_view(&Default::default()); let t0 = std::time::Instant::now(); + let fit = zc.fit; let cmd = match video_exporter::render_frame_to_gpu_rgba( &mut document, timestamp, @@ -1520,6 +1531,7 @@ impl ExportOrchestrator { false, Some(&raster_store), zc.on_shared_device, // GPU-resident decode only when on the shared device + fit, ) { Ok(cmd) => cmd, Err(e) => { diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 696a222..1c65ace 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -16,6 +16,30 @@ use lightningbeam_core::gpu::{ SrgbToLinearConverter, EffectProcessor, YuvConverter, HDR_FORMAT, }; +/// The document→export-pixels transform for a given fit mode. Stretch distorts to fill; Letterbox +/// scales uniformly to fit (centered, black bars); Crop scales uniformly to fill (centered, trims). +pub fn export_base_transform( + doc_w: f64, + doc_h: f64, + out_w: f64, + out_h: f64, + fit: lightningbeam_core::export::ExportFitMode, +) -> vello::kurbo::Affine { + use lightningbeam_core::export::ExportFitMode; + use vello::kurbo::Affine; + if doc_w <= 0.0 || doc_h <= 0.0 { + return Affine::IDENTITY; + } + let (sx, sy) = (out_w / doc_w, out_h / doc_h); + match fit { + ExportFitMode::Stretch => Affine::scale_non_uniform(sx, sy), + ExportFitMode::Letterbox | ExportFitMode::Crop => { + let s = if matches!(fit, ExportFitMode::Letterbox) { sx.min(sy) } else { sx.max(sy) }; + Affine::translate(((out_w - doc_w * s) / 2.0, (out_h - doc_h * s) / 2.0)) * Affine::scale(s) + } + } +} + /// Reusable frame buffers to avoid allocations struct FrameBuffers { /// RGBA buffer from GPU readback (width * height * 4 bytes) @@ -1402,18 +1426,13 @@ pub fn render_frame_to_yuv10_hdr( video_manager: &Arc>, gpu_resources: &mut ExportGpuResources, hdr_mode: lightningbeam_core::export::HdrExportMode, + fit: lightningbeam_core::export::ExportFitMode, raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, ) -> Result<(Vec, Vec, Vec), String> { - use vello::kurbo::Affine; - document.current_time = timestamp; fault_in_raster_for_frame(document, raster_store); - let base_transform = if document.width > 0.0 && document.height > 0.0 { - Affine::scale_non_uniform(width as f64 / document.width, height as f64 / document.height) - } else { - Affine::IDENTITY - }; + let base_transform = export_base_transform(document.width, document.height, width as f64, height as f64, fit); // HDR export composites on the shared device, so it can consume hardware-decoded GPU frames. if let Ok(mut vm) = video_manager.lock() { @@ -1454,9 +1473,8 @@ pub fn render_frame_to_gpu_rgba( // True when compositing on the shared device (software/image export) → may consume // hardware-decoded GPU frames; false for the zero-copy path on its own device. hardware_ok: bool, + fit: lightningbeam_core::export::ExportFitMode, ) -> Result { - use vello::kurbo::Affine; - // One-shot profiling of the render-bucket split (LB_RENDER_PROFILE=1): how much of the // per-frame CPU "render" is document build (incl. video decode) vs. composite-command // recording (incl. the frame texture upload) vs. the sRGB pass. Prints a running average. @@ -1476,14 +1494,7 @@ pub fn render_frame_to_gpu_rgba( // base transform into every layer (vector scenes, raster and video layer // transforms), so the whole stage scales up/down to fill the output. When the // export size matches the document this is the identity. - let base_transform = if document.width > 0.0 && document.height > 0.0 { - Affine::scale_non_uniform( - width as f64 / document.width, - height as f64 / document.height, - ) - } else { - Affine::IDENTITY - }; + let base_transform = export_base_transform(document.width, document.height, width as f64, height as f64, fit); // GPU frames are usable only on the shared device (software/image export); the zero-copy path // runs on its own device and must download to CPU. diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 8c77c48..07fe8a0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -4974,25 +4974,8 @@ impl EditorApp { if asset_info.clip_type == panes::DragClipType::Video { if let Some((video_width, video_height)) = asset_info.dimensions { let doc = self.action_executor.document(); - let doc_width = doc.width; - let doc_height = doc.height; - - // Calculate scale to fit (use minimum to preserve aspect ratio) - let scale_x = doc_width / video_width; - let scale_y = doc_height / video_height; - let uniform_scale = scale_x.min(scale_y); - - clip_instance.transform.scale_x = uniform_scale; - clip_instance.transform.scale_y = uniform_scale; - - // Center the video in the document - let scaled_width = video_width * uniform_scale; - let scaled_height = video_height * uniform_scale; - let center_x = (doc_width - scaled_width) / 2.0; - let center_y = (doc_height - scaled_height) / 2.0; - - clip_instance.transform.x = center_x; - clip_instance.transform.y = center_y; + // Fit uniformly + centered (preserve aspect). Shared with the timeline drag path. + clip_instance.transform.fit_centered(video_width, video_height, doc.width, doc.height); } } else { // Audio clips are centered in document diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 7762112..5cfdca3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -6151,20 +6151,11 @@ impl PaneRenderer for TimelinePane { let mut clip_instance = ClipInstance::new(dragging.clip_id) .with_timeline_start(drop_time); - // For video clips, scale to fill document dimensions + // For video clips, fit uniformly + centered (preserve aspect). + // Shared with the direct-import path via Transform::fit_centered. if dragging.clip_type == DragClipType::Video { if let Some((video_width, video_height)) = dragging.dimensions { - // Calculate scale to fill document - let scale_x = doc.width / video_width; - let scale_y = doc.height / video_height; - - clip_instance.transform.scale_x = scale_x; - clip_instance.transform.scale_y = scale_y; - - // Position at (0, 0) to center the scaled video - // (scaled dimensions = document dimensions, so top-left at origin centers it) - clip_instance.transform.x = 0.0; - clip_instance.transform.y = 0.0; + clip_instance.transform.fit_centered(video_width, video_height, doc.width, doc.height); } else { // No dimensions available, use document center clip_instance.transform.x = center_x;