From f4adb8fb9b9a0608dae983338467b3a0c66658e2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 2 Nov 2026 23:36:03 +0100 Subject: [PATCH 1/2] Render Video: cap the total an animated GIF keeps staged (#6103) The GIF branch checked one frame against MAX_GIF_FRAME_PIXELS but kept every encoded frame until the file was written, so frames x pixels was unbounded: a 4000 x 4000 canvas on a long timeline staged terabytes. Check the total (one index byte per pixel plus the palette, 2 GiB like imported footage) and the 16-bit GIF side before the first frame is rendered, with checked u64 arithmetic and an error that names the limit. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/video_cmds.rs | 32 +++++++++++++++++++---- crates/engine/src/cmd/video_cmds/tests.rs | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index edc6c7e59d..6f216ac7cd 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -14,6 +14,30 @@ use crate::{EngineError, Result, Session}; /// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the /// quantised frame and palette while rendering, so the document canvas limit is not enough. const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; +/// Largest total an animated GIF render keeps staged before encoding: one index byte per pixel plus +/// a 768-byte palette for every frame, the same 2 GiB bound as imported footage ([`MAX_FOOTAGE_BYTES`]). +/// The per-frame cap alone lets `frames × pixels` reach terabytes (a 4000 × 4000 canvas over a +/// 1,000,000-frame timeline). 1920 × 1080 holds about 1,000 frames and 4096 × 2160 about 240. +const MAX_GIF_STAGED_BYTES: u64 = MAX_FOOTAGE_BYTES; +/// The GIF format stores each side as 16 bits. +const MAX_GIF_SIDE: u64 = 65_535; + +/// Reject a GIF render whose canvas or frames-times-canvas staging would be unreasonable, before any +/// frame is rendered. All sums are checked in `u64`. +fn check_gif_budget(w: u64, h: u64, frames: u64) -> std::result::Result<(), String> { + if w > MAX_GIF_SIDE || h > MAX_GIF_SIDE { + return Err(format!("{w} × {h} is too large to render as an animated GIF (the GIF format allows {MAX_GIF_SIDE} pixels per side)")); + } + let pixels = w.checked_mul(h).filter(|p| *p <= MAX_GIF_FRAME_PIXELS); + let Some(pixels) = pixels else { + return Err(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)")); + }; + let per_frame = pixels + 768; + if frames.checked_mul(per_frame).is_none_or(|total| total > MAX_GIF_STAGED_BYTES) { + return Err(format!("{frames} frames of {w} × {h} are too large to render as an animated GIF (limit {} MiB staged in total); shorten the timeline or reduce the canvas", MAX_GIF_STAGED_BYTES >> 20)); + } + Ok(()) +} /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { @@ -540,11 +564,9 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { - // The whole canvas is staged as f32 RGBA for each frame; reject over-budget - // dimensions before an infallible allocation can abort the process. - if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { - return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"))); - } + // Each frame is staged as f32 RGBA and every encoded frame is kept until the file is written; + // reject over-budget sizes before an infallible allocation can abort the process. + check_gif_budget(w as u64, h as u64, frames as u64).map_err(EngineError::Other)?; let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index d08135f60e..51510eaabf 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -745,6 +745,33 @@ fn render_video_rejects_gif_frames_too_large_to_stage() { assert!(err.to_string().contains("too large"), "{err}"); } +#[test] +fn render_video_rejects_a_gif_whose_frames_together_are_too_large() { + // Each 4000 x 4000 frame is under the per-frame cap, but 100,000 of them keep ~1.6 TB staged. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4000, "height": 4000})).unwrap(); + s.execute("timeline.create", json!({"duration": 100_000, "fps": 30})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("many-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("staged in total"), "{err}"); +} + +#[test] +fn gif_budget_boundaries() { + // Sane work is untouched: a minute of 1080p at 30 fps, and the existing small cases. + assert!(check_gif_budget(1920, 1080, 1000).is_ok()); + assert!(check_gif_budget(16, 12, 1).is_ok()); + // Exactly at the limit passes, one frame more fails. + let per_frame = 1000 * 1000 + 768; + let n = MAX_GIF_STAGED_BYTES / per_frame; + assert!(check_gif_budget(1000, 1000, n).is_ok()); + assert!(check_gif_budget(1000, 1000, n + 1).is_err()); + // The per-frame cap and the 16-bit GIF side still apply, and nothing overflows. + assert!(check_gif_budget(9000, 8000, 1).is_err()); + assert!(check_gif_budget(65_536, 1, 1).is_err()); + assert!(check_gif_budget(65_535, 65_535, u64::MAX).is_err()); + assert!(check_gif_budget(1, 1, u64::MAX).is_err()); +} + #[test] fn fps_from_delays_picks_most_common_and_breaks_ties_by_first_seen() { // Most common delay wins: 100 ms appears twice, 40 ms once -> 10 fps. From 439e029b190876dc473b3c69c766451a9a6e6511 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 2 Nov 2026 23:44:40 +0100 Subject: [PATCH 2/2] Render Video: say what the GIF staging cap does and does not bound (#6103) Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/video_cmds.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 6f216ac7cd..c73faed55c 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -17,7 +17,10 @@ const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; /// Largest total an animated GIF render keeps staged before encoding: one index byte per pixel plus /// a 768-byte palette for every frame, the same 2 GiB bound as imported footage ([`MAX_FOOTAGE_BYTES`]). /// The per-frame cap alone lets `frames × pixels` reach terabytes (a 4000 × 4000 canvas over a -/// 1,000,000-frame timeline). 1920 × 1080 holds about 1,000 frames and 4096 × 2160 about 240. +/// 1,000,000-frame timeline). 1920 × 1080 holds about 2,145 frames and 4096 × 2160 about 242. +/// This bounds the staged frames only (indices and palettes). It does not bound the encoder's output +/// buffer (LZW data, roughly 1.3-1.5 times the raw size, growing by doubling) or the per-frame +/// transients (RGBA f32, 16 bytes per pixel), so it is not a bound on peak memory. const MAX_GIF_STAGED_BYTES: u64 = MAX_FOOTAGE_BYTES; /// The GIF format stores each side as 16 bits. const MAX_GIF_SIDE: u64 = 65_535; @@ -34,7 +37,10 @@ fn check_gif_budget(w: u64, h: u64, frames: u64) -> std::result::Result<(), Stri }; let per_frame = pixels + 768; if frames.checked_mul(per_frame).is_none_or(|total| total > MAX_GIF_STAGED_BYTES) { - return Err(format!("{frames} frames of {w} × {h} are too large to render as an animated GIF (limit {} MiB staged in total); shorten the timeline or reduce the canvas", MAX_GIF_STAGED_BYTES >> 20)); + return Err(format!( + "{frames} frames of {w} × {h} are too large to render as an animated GIF (limit {} MiB staged in total); shorten the timeline or reduce the canvas", + MAX_GIF_STAGED_BYTES >> 20 + )); } Ok(()) }