mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
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:
2 files changed
+60
-5
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user