From 2b4f6b4f87268dcca5246ed28200da90ca177f03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 22:05:00 +0000 Subject: [PATCH] Add shared animated GIF writer --- crates/gif/Cargo.toml | 5 +- crates/gif/src/lib.rs | 10 ++- crates/gif/src/write.rs | 159 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 crates/gif/src/write.rs diff --git a/crates/gif/Cargo.toml b/crates/gif/Cargo.toml index 62abd418e..909d9b605 100644 --- a/crates/gif/Cargo.toml +++ b/crates/gif/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-gif" -description = "GIF block reader: the logical screen, each frame's position, delay and transparency, the loop count and colour tables, without decompressing pixels." +description = "GIF block reader, bounded frame decoder and animation writer." version.workspace = true edition.workspace = true license.workspace = true @@ -15,9 +15,8 @@ workspace = true frames = ["dep:image"] [dependencies] +gif = "0.14" image = { version = "0.25", default-features = false, features = ["gif"], optional = true } [dev-dependencies] -# Writes the animated test files the scan is checked against; not a product dependency. -gif = "0.14" image = { version = "0.25", default-features = false, features = ["gif"] } diff --git a/crates/gif/src/lib.rs b/crates/gif/src/lib.rs index 33cdad53c..fdb8b2d07 100644 --- a/crates/gif/src/lib.rs +++ b/crates/gif/src/lib.rs @@ -1,7 +1,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! The blocks of a GIF, read without decompressing any pixels (GIF89a specification: Logical -//! Screen Descriptor, Global and Local Color Tables, Graphic Control Extension, Application -//! Extension, Image Descriptor). +//! GIF reading and writing. The block reader reads without decompressing pixels (GIF89a +//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control +//! Extension, Application Extension, Image Descriptor). //! //! [`scan`] walks the file once and reports what an importer needs before it decodes anything: //! the logical screen size, every frame's position, size, delay and transparent colour, whether @@ -12,6 +12,8 @@ //! //! With feature `frames`, [`frames::read_frames`] decodes every frame through the `image` crate's //! GIF decoder, after the checks [`scan`] makes possible, with bounded memory. +//! [`write::animation`] writes full-canvas RGBA8 animation frames with GIF palette quantization, +//! timing, loop and disposal metadata. //! //! Input is treated as hostile: every read is bounds-checked, and the number of frames recorded //! is capped by the caller. @@ -20,6 +22,8 @@ pub mod frames; #[cfg(test)] mod tests; +/// GIF image and animation encoding. +pub mod write; /// One frame (image descriptor) of a GIF. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/gif/src/write.rs b/crates/gif/src/write.rs new file mode 100644 index 000000000..0d698cb66 --- /dev/null +++ b/crates/gif/src/write.rs @@ -0,0 +1,159 @@ +//! Bounded-shape GIF animation writing from full-canvas RGBA8 frames. +//! +//! Timing and frame production remain the caller's choice. This module owns the GIF-specific +//! encoding rules: palette quantization, loop extension, transparency and disposal metadata. + +/// Disposal behavior to write for every frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Disposal { + /// Leave the encoded disposal method unspecified (`0`), as the encoder default does. + Unspecified, + /// Restore the logical screen to the background after each frame. + Background, +} + +/// GIF encoding options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Options { + /// Loop indefinitely. `false` uses `gif`'s `Finite(0)` setting (no repeat extension). + pub loop_forever: bool, + /// Disposal metadata to attach to each frame. + pub disposal: Disposal, + /// `gif` encoder palette quality (`1` is best, `30` is fastest). Default 10. + pub speed: i32, +} + +impl Default for Options { + fn default() -> Self { + Self { loop_forever: true, disposal: Disposal::Unspecified, speed: 10 } + } +} + +/// A full-canvas, straight-alpha RGBA8 frame. Delays are centiseconds, as stored in GIF. +#[derive(Debug, Clone, Copy)] +pub struct RgbaFrame<'a> { + pub pixels: &'a [u8], + pub delay_cs: u16, +} + +/// Why the requested animation could not be encoded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + Dimensions { width: u32, height: u32 }, + InvalidSpeed(i32), + FrameLength { index: usize, actual: usize, expected: usize }, + NoFrames, + Encode(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Dimensions { width, height } => write!(f, "GIF dimensions {width}x{height} exceed the 65535 px limit"), + Self::InvalidSpeed(speed) => write!(f, "GIF quantization speed {speed} is outside 1–30"), + Self::FrameLength { index, actual, expected } => write!(f, "GIF frame {index} has {actual} bytes; expected {expected} RGBA bytes"), + Self::NoFrames => f.write_str("GIF animation has no frames"), + Self::Encode(message) => write!(f, "GIF encode failed: {message}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(error: gif::EncodingError) -> Self { + Self::Encode(error.to_string()) + } +} + +/// Encode an animation into a complete GIF file. +/// +/// Every input frame must cover the whole canvas and contain exactly `width * height * 4` bytes. +/// At most one mutable frame copy is made at a time for the quantizer. This is intentionally a +/// streaming writer so callers can render and encode frames incrementally. +pub fn animation(width: u32, height: u32, frames: &[RgbaFrame<'_>], options: Options) -> Result, Error> { + if width == 0 || height == 0 || width > u32::from(u16::MAX) || height > u32::from(u16::MAX) { + return Err(Error::Dimensions { width, height }); + } + if frames.is_empty() { + return Err(Error::NoFrames); + } + if !(1..=30).contains(&options.speed) { + return Err(Error::InvalidSpeed(options.speed)); + } + let expected = usize::try_from(width) + .ok() + .and_then(|w| usize::try_from(height).ok().and_then(|h| w.checked_mul(h))) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(Error::Dimensions { width, height })?; + for (index, frame) in frames.iter().enumerate() { + if frame.pixels.len() != expected { + return Err(Error::FrameLength { index, actual: frame.pixels.len(), expected }); + } + } + + let mut bytes = Vec::new(); + { + let mut encoder = gif::Encoder::new(&mut bytes, width as u16, height as u16, &[]).map_err(Error::from)?; + encoder.set_repeat(if options.loop_forever { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(Error::from)?; + for frame in frames { + let mut pixels = frame.pixels.to_vec(); + let mut encoded = gif::Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, options.speed); + encoded.delay = frame.delay_cs; + encoded.dispose = match options.disposal { + Disposal::Unspecified => gif::DisposalMethod::Any, + Disposal::Background => gif::DisposalMethod::Background, + }; + encoder.write_frame(&encoded).map_err(Error::from)?; + } + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{Disposal, Options, RgbaFrame, animation}; + + #[test] + fn writes_repeat_delays_disposal_and_dimensions() { + let red = [255, 0, 0, 255]; + let blue = [0, 0, 255, 255]; + let bytes = animation( + 1, + 1, + &[RgbaFrame { pixels: &red, delay_cs: 4 }, RgbaFrame { pixels: &blue, delay_cs: 7 }], + Options { loop_forever: false, disposal: Disposal::Background, speed: 10 }, + ) + .unwrap(); + let info = crate::scan(&bytes, 4).unwrap(); + assert_eq!((info.width, info.height), (1, 1)); + assert_eq!(info.loop_count, None); + assert_eq!(info.frames.iter().map(|f| f.delay_cs).collect::>(), [4, 7]); + assert!(info.frames.iter().all(|f| f.disposal == 2)); + } + + #[test] + fn rejects_dimensions_empty_animation_and_wrong_pixel_lengths() { + assert!(matches!(animation(0, 1, &[RgbaFrame { pixels: &[0; 4], delay_cs: 1 }], Options::default()), Err(super::Error::Dimensions { .. }))); + assert!(matches!(animation(1, 1, &[], Options::default()), Err(super::Error::NoFrames))); + assert!(matches!(animation(1, 1, &[RgbaFrame { pixels: &[0; 3], delay_cs: 1 }], Options::default()), Err(super::Error::FrameLength { .. }))); + assert!(matches!( + animation(1, 1, &[RgbaFrame { pixels: &[0; 4], delay_cs: 1 }], Options { speed: 0, ..Options::default() }), + Err(super::Error::InvalidSpeed(0)) + )); + } + + #[cfg(feature = "frames")] + #[test] + fn encoded_frames_decode_to_the_requested_colors() { + let red = [255, 0, 0, 255]; + let blue = [0, 0, 255, 255]; + let bytes = + animation(1, 1, &[RgbaFrame { pixels: &red, delay_cs: 1 }, RgbaFrame { pixels: &blue, delay_cs: 2 }], Options::default()).unwrap(); + let decoded = crate::frames::read_frames(&bytes, &crate::frames::FrameLimits::default()).unwrap(); + assert_eq!(decoded.frames[0].rgba, red); + assert_eq!(decoded.frames[1].rgba, blue); + } +}