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:
parent
02566d571b
commit
fffcf0679c
|
|
@ -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<AudioExportSettings>,
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<Sender<VideoFrameMessage>>,
|
||||
/// 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) => {
|
||||
|
|
|
|||
|
|
@ -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<std::sync::Mutex<VideoManager>>,
|
||||
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<u8>, Vec<u8>, Vec<u8>), 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<wgpu::CommandEncoder, String> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue