From 912f828aa3e66cbc3ec3ad41996bb3dec800ea9d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:47 +0300 Subject: [PATCH 1/3] project: output_size never asks for sides past the comp limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 30 000 px comp (the repaired MAX_SIZE on load) at resolution 4.0 produced 120 000 px sides, which overflow u32 in the exporters' w * h * 4 (debug panic, release wrap then slice panic) after already requesting a ~57 GB frame. The resize path in the same file clamps to 30 000; output_size now does too. Regression test: output_size_never_exceeds_the_comp_limit — on main it returns (120000, 120000); with the clamp (30000, 30000), through both RenderSettings and the OutputModule wrapper. --- crates/project/src/render_queue.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..e49c5d1cc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -444,7 +444,11 @@ impl RenderSettings { } pub fn output_size(&self, comp: &Comp) -> (u32, u32) { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + // 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); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -1663,6 +1667,16 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[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)); + 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)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); From 30e02b06caef9ca3d6bfc672bbd4110204d86db4 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:46:24 +0300 Subject: [PATCH 2/3] 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. --- crates/engine/src/commands/render_queue.rs | 2 +- crates/export/src/pipeline.rs | 4 +-- crates/project/src/render_queue.rs | 37 ++++++++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 09a7c7d2f..39af29c42 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -601,7 +601,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { 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") { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c7..6877a09bf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -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 { diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index e49c5d1cc..44a7be5a4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -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] From 3217320c1c813f36b39b93a2674accb19e2d7aa9 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:48:55 +0300 Subject: [PATCH 3/3] export: the yuv420 fast path checks the effective scale too --- crates/export/src/pipeline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 28bd3ecc7..66c2177be 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -389,7 +389,7 @@ impl<'a> Cx<'a> { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None;