Merge pull request #10947 from vangel76/fix/render-video-frame-cap-6103

Render Video: cap the total an animated GIF keeps staged (#6103)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-07 15:40:04 +01:00
commit e1e2a48b71
2 files changed
+60 -5

No files matched your search

+33 -5
View File
@@ -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 {
+27
View File
@@ -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.