diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 3b1a631c81..dde11a8f2a 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -14,6 +14,36 @@ 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 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; + +/// 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) { @@ -638,11 +668,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 a5e2f5db9c..2038f07ee2 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -916,6 +916,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.