project: output_size clamps the scale, not the sides, and every renderer uses it

Clamping each side on its own broke the aspect ratio (a 30000x15000 comp
at 4x came out 30000x30000). The effective scale min(s, MAX_SIZE/w,
MAX_SIZE/h) keeps both sides within the comp limit, and the export
pipeline and the locked-aspect width derivation render at that same
scale, so nothing requests a frame larger than output_size reports.
This commit is contained in:
midasmatterhorn
2026-10-16 14:46:24 +03:00
parent 912f828aa3
commit 30e02b06ca
3 changed files with 30 additions and 13 deletions
+1 -1
View File
@@ -601,7 +601,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = item.settings.output_size(comp);
let mut crop_only = om.clone();
crop_only.resize.enabled = false;
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp));
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
}
let post = match str_p(p, "postRenderAction") {
+2 -2
View File
@@ -147,7 +147,7 @@ impl<'a> Cx<'a> {
// compositors differ slightly, so a per-frame choice made renders differ run to run.
let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu };
let opts = RenderOpts {
scale: s.resolution.clamp(0.01, 4.0),
scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
guides: s.guide_layers == CurrentOrOff::Current,
draft: s.quality == RenderQuality::Draft,
@@ -231,7 +231,7 @@ impl<'a> Cx<'a> {
/// Output Module ▸ Crop, then Resize.
fn crop_resize(&self, img: Image) -> Image {
let om = self.job.output;
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
if !om.resize.enabled {
+27 -10
View File
@@ -442,12 +442,17 @@ impl RenderSettings {
pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick {
self.rate(comp).tick_of(self.first_frame(comp) + i as i64)
}
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
pub fn effective_scale(&self, comp: &Comp) -> f64 {
let s = self.resolution.clamp(0.01, 4.0);
// A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing
// `w * h * 4` in the exporters. The resize path clamps to the same limit.
let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000);
let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000);
let max = f64::from(crate::limits::MAX_SIZE);
s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1)))
}
pub fn output_size(&self, comp: &Comp) -> (u32, u32) {
let s = self.effective_scale(comp);
// The scale is clamped so neither side exceeds the comp limit, keeping the aspect
// ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner.
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
(w, h)
}
pub fn resolution_label(&self) -> &'static str {
@@ -1120,7 +1125,7 @@ impl OutputModule {
/// rounding).
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
let (w, h) = settings.output_size(comp);
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
let (w, h) = self.frame_size(w, h, settings.effective_scale(comp));
self.format.coded_size(w, h)
}
/// Every setting as `(label, value)` lines (render logs).
@@ -1669,12 +1674,24 @@ mod tests {
#[test]
fn output_size_never_exceeds_the_comp_limit() {
// 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`).
let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
// The effective scale is clamped so neither side exceeds the limit (exporter
// `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the
// same scale, so it never requests a frame larger than this reports.
let rs = RenderSettings { resolution: 4.0, ..Default::default() };
assert_eq!(rs.output_size(&c), (30_000, 30_000));
let m = OutputModule::for_format(OutputFormat::PngSequence);
assert_eq!(m.output_size(&c, &rs), (30_000, 30_000));
let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(rs.effective_scale(&square), 1.0);
assert_eq!(rs.output_size(&square), (30_000, 30_000));
assert_eq!(m.output_size(&square, &rs), (30_000, 30_000));
// Wide comps keep their aspect ratio instead of clamping each side on its own.
let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(rs.effective_scale(&wide), 1.0);
assert_eq!(rs.output_size(&wide), (30_000, 15_000));
assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000));
// A mid-size comp renders at the largest scale that still fits.
let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0);
assert_eq!(rs.output_size(&mid), (30_000, 15_000));
}
#[test]