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
@@ -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+ }+}