aspect ratio: unify video placement + add export fit modes

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 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 06:54:49 -04:00
parent 02566d571b
commit fffcf0679c
7 changed files with 103 additions and 50 deletions

View File

@ -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 /// Video quality presets
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VideoQuality { pub enum VideoQuality {
@ -385,6 +408,10 @@ pub struct VideoExportSettings {
#[serde(default)] #[serde(default)]
pub hdr: HdrExportMode, 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) /// Audio settings (None = no audio)
pub audio: Option<AudioExportSettings>, pub audio: Option<AudioExportSettings>,
@ -405,6 +432,7 @@ impl Default for VideoExportSettings {
quality: VideoQuality::High, quality: VideoQuality::High,
color_range: ColorRange::Limited, color_range: ColorRange::Limited,
hdr: HdrExportMode::Sdr, hdr: HdrExportMode::Sdr,
fit: ExportFitMode::Letterbox,
audio: Some(AudioExportSettings::high_quality_aac()), audio: Some(AudioExportSettings::high_quality_aac()),
start_time: 0.0, start_time: 0.0,
end_time: 60.0, end_time: 60.0,

View File

@ -47,6 +47,21 @@ impl Transform {
Self::default() 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 /// Create a transform with position
pub fn with_position(x: f64, y: f64) -> Self { pub fn with_position(x: f64, y: f64) -> Self {
Self { Self {

View File

@ -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.horizontal(|ui| {
ui.label("FPS:"); ui.label("FPS:");
egui::ComboBox::from_id_salt("framerate") egui::ComboBox::from_id_salt("framerate")

View File

@ -55,6 +55,8 @@ pub struct VideoExportState {
height: u32, height: u32,
/// HDR output mode — HDR uses a synchronous 10-bit path instead of the async RGBA pipeline. /// HDR output mode — HDR uses a synchronous 10-bit path instead of the async RGBA pipeline.
hdr: lightningbeam_core::export::HdrExportMode, 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 /// Channel to send rendered frames to encoder thread
frame_tx: Option<Sender<VideoFrameMessage>>, frame_tx: Option<Sender<VideoFrameMessage>>,
/// HDR GPU resources for compositing pipeline (effects, color conversion) /// HDR GPU resources for compositing pipeline (effects, color conversion)
@ -84,6 +86,8 @@ struct ZeroCopyVideo {
rgba: wgpu::Texture, rgba: wgpu::Texture,
/// True when running on the shared device → compositing can consume hardware-decoded GPU frames. /// True when running on the shared device → compositing can consume hardware-decoded GPU frames.
on_shared_device: bool, 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). /// 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, state.settings.allow_transparency,
raster_store, raster_store,
true, // image export composites on the shared device 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())); queue.submit(Some(encoder.finish()));
@ -861,6 +867,7 @@ impl ExportOrchestrator {
height: u32, height: u32,
) -> (std::thread::JoinHandle<()>, VideoExportState) { ) -> (std::thread::JoinHandle<()>, VideoExportState) {
let hdr = settings.hdr; let hdr = settings.hdr;
let fit = settings.fit;
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames); Self::run_video_encoder(settings, output_path, frame_rx, progress_tx, cancel_flag, total_frames);
}); });
@ -874,6 +881,7 @@ impl ExportOrchestrator {
width, width,
height, height,
hdr, hdr,
fit,
frame_tx: Some(frame_tx), frame_tx: Some(frame_tx),
gpu_resources: None, gpu_resources: None,
readback_pipeline: None, readback_pipeline: None,
@ -959,7 +967,7 @@ impl ExportOrchestrator {
view_formats: &[], view_formats: &[],
}); });
println!("🎬 [EXPORT] zero-copy VAAPI H.264 enabled"); 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. /// Start a video export in the background.
@ -1270,6 +1278,7 @@ impl ExportOrchestrator {
let width = state.width; let width = state.width;
let height = state.height; let height = state.height;
let fit = state.fit;
// HDR path: synchronous 10-bit render (composite → PQ/HLG → readback → 10-bit YUV), one // 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). // 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( let (y, u, v) = video_exporter::render_frame_to_yuv10_hdr(
document, timestamp, width, height, document, timestamp, width, height,
device, queue, renderer, image_cache, video_manager, 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 { if let Some(tx) = &state.frame_tx {
tx.send(VideoFrameMessage::Frame { tx.send(VideoFrameMessage::Frame {
@ -1414,6 +1423,7 @@ impl ExportOrchestrator {
false, // Video export is never transparent false, // Video export is never transparent
raster_store, raster_store,
true, // software export composites on the shared device → may use HW frames true, // software export composites on the shared device → may use HW frames
fit,
)?; )?;
let render_end = Instant::now(); let render_end = Instant::now();
@ -1504,6 +1514,7 @@ impl ExportOrchestrator {
let rgba_view = zc.rgba.create_view(&Default::default()); let rgba_view = zc.rgba.create_view(&Default::default());
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let fit = zc.fit;
let cmd = match video_exporter::render_frame_to_gpu_rgba( let cmd = match video_exporter::render_frame_to_gpu_rgba(
&mut document, &mut document,
timestamp, timestamp,
@ -1520,6 +1531,7 @@ impl ExportOrchestrator {
false, false,
Some(&raster_store), Some(&raster_store),
zc.on_shared_device, // GPU-resident decode only when on the shared device zc.on_shared_device, // GPU-resident decode only when on the shared device
fit,
) { ) {
Ok(cmd) => cmd, Ok(cmd) => cmd,
Err(e) => { Err(e) => {

View File

@ -16,6 +16,30 @@ use lightningbeam_core::gpu::{
SrgbToLinearConverter, EffectProcessor, YuvConverter, HDR_FORMAT, 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 /// Reusable frame buffers to avoid allocations
struct FrameBuffers { struct FrameBuffers {
/// RGBA buffer from GPU readback (width * height * 4 bytes) /// RGBA buffer from GPU readback (width * height * 4 bytes)
@ -1402,18 +1426,13 @@ pub fn render_frame_to_yuv10_hdr(
video_manager: &Arc<std::sync::Mutex<VideoManager>>, video_manager: &Arc<std::sync::Mutex<VideoManager>>,
gpu_resources: &mut ExportGpuResources, gpu_resources: &mut ExportGpuResources,
hdr_mode: lightningbeam_core::export::HdrExportMode, hdr_mode: lightningbeam_core::export::HdrExportMode,
fit: lightningbeam_core::export::ExportFitMode,
raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, raster_store: Option<&lightningbeam_core::raster_store::RasterStore>,
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> { ) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
use vello::kurbo::Affine;
document.current_time = timestamp; document.current_time = timestamp;
fault_in_raster_for_frame(document, raster_store); fault_in_raster_for_frame(document, raster_store);
let base_transform = if document.width > 0.0 && document.height > 0.0 { let base_transform = export_base_transform(document.width, document.height, width as f64, height as f64, fit);
Affine::scale_non_uniform(width as f64 / document.width, height as f64 / document.height)
} else {
Affine::IDENTITY
};
// HDR export composites on the shared device, so it can consume hardware-decoded GPU frames. // HDR export composites on the shared device, so it can consume hardware-decoded GPU frames.
if let Ok(mut vm) = video_manager.lock() { 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 // 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-decoded GPU frames; false for the zero-copy path on its own device.
hardware_ok: bool, hardware_ok: bool,
fit: lightningbeam_core::export::ExportFitMode,
) -> Result<wgpu::CommandEncoder, String> { ) -> Result<wgpu::CommandEncoder, String> {
use vello::kurbo::Affine;
// One-shot profiling of the render-bucket split (LB_RENDER_PROFILE=1): how much of the // 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 // 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. // 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 // 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 // transforms), so the whole stage scales up/down to fill the output. When the
// export size matches the document this is the identity. // export size matches the document this is the identity.
let base_transform = if document.width > 0.0 && document.height > 0.0 { let base_transform = export_base_transform(document.width, document.height, width as f64, height as f64, fit);
Affine::scale_non_uniform(
width as f64 / document.width,
height as f64 / document.height,
)
} else {
Affine::IDENTITY
};
// GPU frames are usable only on the shared device (software/image export); the zero-copy path // 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. // runs on its own device and must download to CPU.

View File

@ -4974,25 +4974,8 @@ impl EditorApp {
if asset_info.clip_type == panes::DragClipType::Video { if asset_info.clip_type == panes::DragClipType::Video {
if let Some((video_width, video_height)) = asset_info.dimensions { if let Some((video_width, video_height)) = asset_info.dimensions {
let doc = self.action_executor.document(); let doc = self.action_executor.document();
let doc_width = doc.width; // Fit uniformly + centered (preserve aspect). Shared with the timeline drag path.
let doc_height = doc.height; clip_instance.transform.fit_centered(video_width, video_height, doc.width, 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;
} }
} else { } else {
// Audio clips are centered in document // Audio clips are centered in document

View File

@ -6151,20 +6151,11 @@ impl PaneRenderer for TimelinePane {
let mut clip_instance = ClipInstance::new(dragging.clip_id) let mut clip_instance = ClipInstance::new(dragging.clip_id)
.with_timeline_start(drop_time); .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 dragging.clip_type == DragClipType::Video {
if let Some((video_width, video_height)) = dragging.dimensions { if let Some((video_width, video_height)) = dragging.dimensions {
// Calculate scale to fill document clip_instance.transform.fit_centered(video_width, video_height, doc.width, doc.height);
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;
} else { } else {
// No dimensions available, use document center // No dimensions available, use document center
clip_instance.transform.x = center_x; clip_instance.transform.x = center_x;