collective

Browse / github.com/storytold/artcraft / patch

artcraft-native: bound the memory animated previews decode into

artcraft-native: bound the memory animated previews decode into `decode_frames` built `GifDecoder`/`WebPDecoder` by hand, skipping the `image` crate's default limits that `image::load_from_memory` applies on the still path, then collected every frame before subsampling to 48. Each decoded frame is a whole canvas sized by the header alone, so a few KB of GIF (a 4096x4096 logical screen with a few hundred 1x1 frames, or one 65535x65535 frame) asks for many GB and aborts the app as soon as a feed card shows that animated preview. Now a canvas over `MAX_DECODED_SIDE` falls back to the still path, the decoders get `image`'s default limits, at most 600 source f

Status
open
Score
0.00
Adopters
0 other operators
Author
Matoo125 (imported from pull request #2547, open)
Base
800217ed04d10fc12706c28e6e91ae2a6b286092
Size
+105 −24 in 1 file
Review
pending
Shared
2026-10-28
Id
68ea914b311fa9af7e62c4a9

How it went

No other operator has reported using it yet.

Diff

raw · artcraft-native/src/backend/media_cache.rs

diff --git a/artcraft-native/src/backend/media_cache.rs b/artcraft-native/src/backend/media_cache.rs--- a/artcraft-native/src/backend/media_cache.rs+++ b/artcraft-native/src/backend/media_cache.rs@@ -12,7 +12,7 @@ use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration;  use egui::{ColorImage, TextureHandle, TextureOptions};-use image::AnimationDecoder;+use image::{AnimationDecoder, ImageDecoder}; use log::warn; use tokio::runtime::Handle; use tokio::sync::Semaphore;@@ -24,6 +24,8 @@ const MAX_DECODED_SIDE: u32 = 2048; /// Animated previews: longest side and frame budget (each frame is its own texture). const ANIMATED_SIDE: u32 = 320; const ANIMATED_MAX_FRAMES: usize = 48;+/// Animated previews: source frames read at most (25 s at 24 fps); later ones are left out.+const ANIMATED_MAX_SOURCE_FRAMES: usize = 600; /// Textures kept even when unused (roughly two screens of thumbnails). const SOFT_CAPACITY: usize = 160; /// Frames an image may go undrawn before it can be evicted (animations go sooner: they're heavy).@@ -204,41 +206,120 @@ fn decode(bytes: &[u8]) -> Option<ColorImage> { }  /// The frames of an animated GIF or WebP (subsampled to the budget), or `None` when it isn't-/// animated.+/// animated. Every decoded frame is a whole canvas, so the frames are timed in a first pass and+/// only the kept ones are decoded into images in a second: one canvas is alive at a time. fn decode_frames(bytes: &[u8]) -> Option<Vec<(ColorImage, f32)>> {-  let frames = match image::guess_format(bytes).ok()? {-    image::ImageFormat::Gif => image::codecs::gif::GifDecoder::new(Cursor::new(bytes)).ok()?.into_frames().collect_frames().ok()?,+  let delays = animation_frames(bytes)?+    .take(ANIMATED_MAX_SOURCE_FRAMES)+    .map(|frame| {+      let (n, d) = frame.ok()?.delay().numer_denom_ms();+      Some(n as f32 / d.max(1) as f32 / 1000.0)+    })+    .collect::<Option<Vec<f32>>>()?;+  if delays.len() < 2 {+    return None;+  }+  // Keep every n-th frame, folding the skipped frames' time into the kept one.+  let step = delays.len().div_ceil(ANIMATED_MAX_FRAMES);+  let mut out = Vec::with_capacity(delays.len() / step + 1);+  for (frame, chunk) in animation_frames(bytes)?.step_by(step).zip(delays.chunks(step)) {+    let secs: f32 = chunk.iter().sum();+    let image = image::DynamicImage::ImageRgba8(frame.ok()?.into_buffer()).thumbnail(ANIMATED_SIDE, ANIMATED_SIDE);+    // Some encoders write 0 ms delays; browsers treat those as ~100 ms.+    out.push((to_color_image(image.to_rgba8()), if secs < 0.02 { 0.1 * chunk.len() as f32 } else { secs }));+  }+  Some(out)+}++/// An animated GIF's or WebP's frames, decoded as they're read; `None` when it isn't animated or+/// its canvas is too large.+fn animation_frames(bytes: &[u8]) -> Option<image::Frames<'_>> {+  match image::guess_format(bytes).ok()? {+    image::ImageFormat::Gif => Some(limited(image::codecs::gif::GifDecoder::new(Cursor::new(bytes)).ok()?)?.into_frames()),     image::ImageFormat::WebP => {       let decoder = image::codecs::webp::WebPDecoder::new(Cursor::new(bytes)).ok()?;       if !decoder.has_animation() {         return None;       }-      decoder.into_frames().collect_frames().ok()?+      Some(limited(decoder)?.into_frames())     },-    _ => return None,-  };-  if frames.len() < 2 {-    return None;+    _ => None,   }-  // Keep every n-th frame, folding the skipped frames' time into the kept one.-  let step = frames.len().div_ceil(ANIMATED_MAX_FRAMES);-  let mut out = Vec::with_capacity(frames.len() / step + 1);-  for chunk in frames.chunks(step) {-    let secs: f32 = chunk-      .iter()-      .map(|f| {-        let (n, d) = f.delay().numer_denom_ms();-        n as f32 / d.max(1) as f32 / 1000.0-      })-      .sum();-    let image = image::DynamicImage::ImageRgba8(chunk[0].buffer().clone()).thumbnail(ANIMATED_SIDE, ANIMATED_SIDE);-    // Some encoders write 0 ms delays; browsers treat those as ~100 ms.-    out.push((to_color_image(image.to_rgba8()), if secs < 0.02 { 0.1 * chunk.len() as f32 } else { secs }));+}++/// `decoder` with the image crate's default limits, which `image::load_from_memory` applies but a+/// decoder built by hand doesn't; `None` when its canvas is over [`MAX_DECODED_SIDE`]. The canvas+/// size comes from the header alone, so a few KB of GIF can claim 65535 x 65535 per frame.+fn limited<D: ImageDecoder>(mut decoder: D) -> Option<D> {+  let (width, height) = decoder.dimensions();+  if width.max(height) > MAX_DECODED_SIDE {+    warn!("Animated image is {width}x{height}; showing a still instead");+    return None;   }-  Some(out)+  decoder.set_limits(image::Limits::default()).ok()?;+  Some(decoder) }  fn to_color_image(rgba: image::RgbaImage) -> ColorImage {   let size = [rgba.width() as usize, rgba.height() as usize];   ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()) }++#[cfg(test)]+mod tests {+  use super::*;++  #[test]+  fn oversized_animation_canvases_show_a_still() {+    // Each decoded frame is a whole canvas, sized by the header alone (here 2049 x 64 for two 1x1+    // frames; a few KB of GIF can claim 65535 x 65535 and hundreds of frames).+    let wide = gif(MAX_DECODED_SIDE as u16 + 1, 64, 2, 10);+    assert!(decode_frames(&wide).is_none(), "a canvas over {MAX_DECODED_SIDE} px must not be decoded frame by frame");+    assert!(decode(&wide).is_some(), "it still shows as a still");+  }++  #[test]+  fn frames_past_the_source_budget_are_not_read() {+    // 610 frames of 20 ms against a budget of 600 (`ANIMATED_MAX_SOURCE_FRAMES`).+    let frames = decode_frames(&gif(1, 1, 610, 2)).expect("animated");+    let total: f32 = frames.iter().map(|(_, secs)| secs).sum();+    assert!((total - 12.0).abs() < 1e-3, "only the first 600 frames play, got {total} s");+  }++  #[test]+  fn long_animations_are_subsampled_keeping_their_timing() {+    let frames = decode_frames(&gif(8, 8, 100, 2)).expect("animated");+    assert_eq!(frames.len(), 34, "every 3rd of 100 frames");+    let total: f32 = frames.iter().map(|(_, secs)| secs).sum();+    assert!((total - 2.0).abs() < 1e-3, "skipped frames' time folds into the kept ones, got {total} s");+  }++  #[test]+  fn short_animations_keep_every_frame() {+    let frames = decode_frames(&gif(16, 16, 2, 10)).expect("animated");+    assert_eq!(frames.len(), 2);+    assert!(frames.iter().all(|(_, secs)| (secs - 0.1).abs() < 1e-6));+    assert!(decode_frames(&gif(16, 16, 1, 10)).is_none(), "one frame isn't an animation");+  }++  /// A GIF89a with a `width` x `height` canvas and `frames` black 1x1 frames at its origin, each+  /// shown for `delay_cs` hundredths of a second.+  fn gif(width: u16, height: u16, frames: usize, delay_cs: u16) -> Vec<u8> {+    let mut out = b"GIF89a".to_vec();+    out.extend(width.to_le_bytes());+    out.extend(height.to_le_bytes());+    // Global colour table of two entries (black, white); no background colour or aspect ratio.+    out.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]);+    for _ in 0..frames {+      // Graphic control extension: the delay, no transparency.+      out.extend([0x21, 0xF9, 4, 0]);+      out.extend(delay_cs.to_le_bytes());+      out.extend([0, 0]);+      // Image descriptor (1x1 at 0,0), then LZW data for one pixel of colour 0.+      out.extend([0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0]);+      out.extend([2, 2, 0x44, 0x01, 0]);+    }+    out.push(0x3B);+    out+  }+}