From f19740cfd39b8725c9dac0f3a455891a9190eea8 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sun, 14 Jun 2026 15:02:35 +0200 Subject: [PATCH 1/2] Add encoding for animated PNGs --- examples/animation.rs | 106 +++++++++++++++++++++ src/codecs/png.rs | 208 +++++++++++++++++++++++++++++++++++++++--- src/metadata.rs | 2 +- 3 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 examples/animation.rs diff --git a/examples/animation.rs b/examples/animation.rs new file mode 100644 index 0000000000..a4522d3410 --- /dev/null +++ b/examples/animation.rs @@ -0,0 +1,106 @@ +//! Create a small animation of 3 spinning circles that change color over time. +//! +//! The animation will be saved as APNG and GIF. + +use image::{metadata::LoopCount, Delay, Frame, Rgba, RgbaImage}; + +fn main() { + let frames = 50; + let duration = 1000; // ms + let size = 64; // px + + let dist = 0.3; // distance of circles from center + let radius = 0.2; // radius of circles + let path_circle_exp = 1.0; + + let frame_at = |t: f32| -> RgbaImage { + use std::f32::consts::TAU; + let r = (t * TAU).sin() * 0.5 + 0.5; + let g = ((t + 1.0 / 3.0) * TAU).sin() * 0.5 + 0.5; + let b = ((t + 2.0 / 3.0) * TAU).sin() * 0.5 + 0.5; + let [r, g, b] = [r, g, b].map(|x| x.powf(1.0 / 2.2)); // gamma correct + + let apply_exp = |x: f32| x.signum() * x.abs().powf(path_circle_exp); + let circle = |t: f32| { + [ + apply_exp((t * TAU).sin()) * dist, + apply_exp((t * TAU).cos()) * dist, + ] + }; + let circles = [circle(t + 0.0), circle(t + 0.3333), circle(t + 0.6666)]; + + RgbaImage::from_fn(size, size, |x, y| { + let x = (x as f32 + 0.5) / size as f32 - 0.5; + let y = (y as f32 + 0.5) / size as f32 - 0.5; + + let min_dist = circles + .iter() + .map(|[cx, cy]| ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()) + .fold(f32::INFINITY, f32::min); + + let alpha = 255 - (((min_dist - radius) * size as f32 + 0.5) * 255.0) as u8; + + Rgba([ + (r * 255.0) as u8, + (g * 255.0) as u8, + (b * 255.0) as u8, + alpha, + ]) + }) + }; + + let frames_buffers: Vec = (0..frames) + .map(|i| { + let t = i as f32 / frames as f32; + Frame::from_parts( + frame_at(t), + 0, + 0, + Delay::from_numer_denom_ms(duration, frames), + ) + }) + .collect(); + + #[cfg(feature = "png")] + save_png("spinning.png", frames_buffers.clone()); + #[cfg(feature = "gif")] + save_gif("spinning.gif", frames_buffers); +} + +#[cfg(feature = "png")] +fn save_png(name: &str, frames: Vec) { + use image::codecs::png::{CompressionType, FilterType, PngEncoder}; + + let mut encoded = Vec::new(); + let encoder = PngEncoder::new_with_quality( + &mut encoded, + CompressionType::Balanced, + FilterType::default(), + ); + + encoder + .encode_frames(LoopCount::Infinite, frames) + .expect("Could not encode animation"); + + std::fs::write(name, &encoded).expect("Could not write output file"); + println!("Created {name}"); +} + +#[cfg(feature = "gif")] +fn save_gif(name: &str, frames: Vec) { + use image::codecs::gif::{GifEncoder, Repeat}; + + let mut encoded = Vec::new(); + let mut encoder = GifEncoder::new(&mut encoded); + encoder + .set_repeat(Repeat::Infinite) + .expect("Could not set repeat"); + + encoder + .encode_frames(frames) + .expect("Could not encode animation"); + drop(encoder); + + std::fs::write(name, &encoded).expect("Could not write output file"); + println!("Created {name}"); +} diff --git a/src/codecs/png.rs b/src/codecs/png.rs index 6c29ba9673..d5ef0377ca 100644 --- a/src/codecs/png.rs +++ b/src/codecs/png.rs @@ -10,7 +10,7 @@ use std::io::{BufRead, Seek, Write}; use png::{BlendOp, DeflateCompression, DisposeOp}; -use crate::animation::{Delay, Ratio}; +use crate::animation::{Delay, Frame, Ratio}; use crate::color::{ColorType, ExtendedColorType}; use crate::error::{ DecodingError, EncodingError, ImageError, ImageResult, LimitError, LimitErrorKind, @@ -784,13 +784,109 @@ impl PngEncoder { } } - fn encode_inner( + /// TODO + pub fn encode_frames(self, loops: LoopCount, frames: F) -> ImageResult<()> + where + F: IntoIterator, + F::IntoIter: ExactSizeIterator, + { + self.try_encode_frames(loops, frames.into_iter().map(Ok)) + } + /// TODO + pub fn try_encode_frames(self, loops: LoopCount, frames: F) -> ImageResult<()> + where + F: IntoIterator>, + F::IntoIter: ExactSizeIterator, + { + let mut frames = frames.into_iter(); + self.try_encode_frames_inner(loops, &mut frames) + } + + fn try_encode_frames_inner( + self, + loops: LoopCount, + frames: &mut dyn ExactSizeIterator>, + ) -> ImageResult<()> { + let num_frames = frames.len(); + + let Some(first) = frames.next() else { + return Err(ImageError::Parameter(ParameterError::from_kind( + ParameterErrorKind::Generic("Animation must have at least one frame".into()), + ))); + }; + let first = first?; + + let (width, height) = first.buffer().dimensions(); + + let mut writer = self.into_writer( + width, + height, + ExtendedColorType::Rgba8, // all frames are RGBA8 + &mut |encoder| { + encoder + .set_animated( + num_frames as u32, + match loops { + LoopCount::Finite(n) => n.get(), + LoopCount::Infinite => 0, + }, + ) + .map_err(ImageError::from_png_enc)?; + + // first image is part of the animation + encoder + .set_sep_def_img(false) + .map_err(ImageError::from_png_enc)?; + + Ok(()) + }, + )?; + + for frame in std::iter::once(Ok(first)).chain(frames) { + let frame = frame?; + let buffer = frame.buffer(); + let delay = to_png_delay(frame.delay()); + let (width, height) = buffer.dimensions(); + + writer + .set_frame_delay(delay.0, delay.1) + .map_err(ImageError::from_png_enc)?; + writer + .set_frame_dimension(width, height) + .map_err(ImageError::from_png_enc)?; + writer + .set_frame_position(frame.top(), frame.left()) + .map_err(ImageError::from_png_enc)?; + + writer + .write_image_data(buffer.subpixels()) + .map_err(ImageError::from_png_enc)?; + } + + writer.finish().map_err(ImageError::from_png_enc) + } + + fn encode_image( self, data: &[u8], width: u32, height: u32, color: ExtendedColorType, ) -> ImageResult<()> { + let mut writer = self.into_writer(width, height, color, &mut |_| Ok(()))?; + writer + .write_image_data(data) + .map_err(ImageError::from_png_enc)?; + writer.finish().map_err(ImageError::from_png_enc) + } + + fn into_writer( + self, + width: u32, + height: u32, + color: ExtendedColorType, + adjust_encoder: &mut dyn FnMut(&mut png::Encoder<'_, W>) -> ImageResult<()>, + ) -> ImageResult> { let (ct, bits) = match color { ExtendedColorType::L8 => (png::ColorType::Grayscale, png::BitDepth::Eight), ExtendedColorType::L16 => (png::ColorType::Grayscale, png::BitDepth::Sixteen), @@ -860,11 +956,10 @@ impl PngEncoder { encoder.set_deflate_compression(compression); } encoder.set_filter(filter); - let mut writer = encoder.write_header().map_err(ImageError::from_png_enc)?; - writer - .write_image_data(data) - .map_err(ImageError::from_png_enc)?; - writer.finish().map_err(ImageError::from_png_enc) + + adjust_encoder(&mut encoder)?; + + encoder.write_header().map_err(ImageError::from_png_enc) } } @@ -888,7 +983,7 @@ impl ImageEncoder for PngEncoder { assert_eq!( expected_buffer_len, buf.len() as u64, - "Invalid buffer length: expected {expected_buffer_len} got {} for {width}x{height} image", + "Invalid buffer length: expected {expected_buffer_len} got {} for {width}x{height} {color_type:?} image", buf.len(), ); @@ -899,7 +994,7 @@ impl ImageEncoder for PngEncoder { match color_type { L8 | La8 | Rgb8 | Rgba8 => { // No reodering necessary for u8 - self.encode_inner(buf, width, height, color_type) + self.encode_image(buf, width, height, color_type) } L16 | La16 | Rgb16 | Rgba16 => { // Because the buffer is immutable and the PNG encoder does not @@ -913,7 +1008,7 @@ impl ImageEncoder for PngEncoder { } else { buf }; - self.encode_inner(buf, width, height, color_type) + self.encode_image(buf, width, height, color_type) } _ => Err(ImageError::Unsupported( UnsupportedError::from_format_and_kind( @@ -1056,10 +1151,54 @@ fn blend_pixel_bytes(bytes: &mut [u8], layout: &ImageLayout, from: &[u8], region } } +fn to_png_delay(delay: Delay) -> (u16, u16) { + let (n, d) = delay.numer_denom_ms(); + let n = n as u64; + let d = d as u64 * 1000; // PNG delays are in seconds + + const fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + (a, b) = (b, a.rem_euclid(b)); + } + a + } + + // reduce fraction + let c = gcd(n, d); + let n = n / c; + let d = d / c; + + // mutable fraction that will approximate n/d if n/d cannot be represented exactly + let mut a = n; + let mut b = d; + + // cap denominator + if b > u16::MAX as u64 { + b = u16::MAX as u64; + // n/d = a/b => a = n*b/d + a = (n * b + (d / 2)) / d; + } + + // cap numerator + if a > u16::MAX as u64 { + a = u16::MAX as u64; + // n/d = a/b => b = d*a/n + b = (d * a + (n / 2)) / n; + + if b == 0 { + // this means that n/d > 65535 seconds + // PNG can't represent this, so just return the maximum delay + return (u16::MAX, 1); + } + } + + (a as u16, b as u16) +} + #[cfg(test)] mod tests { use super::*; - use crate::io::free_functions::decoder_to_vec; + use crate::{io::free_functions::decoder_to_vec, RgbaImage}; use std::io::{BufReader, Cursor, Read}; #[test] @@ -1140,4 +1279,51 @@ mod tests { .expect("XMP is empty"); assert_eq!(xmp, decoded_xmp); } + + #[test] + fn roundtrip_animation() { + let frames = vec![ + Frame::from_parts( + RgbaImage::from_pixel(20, 20, Rgba([255, 0, 0, 255])), + 0, + 0, + Delay::from_numer_denom_ms(1000, 1), // 1 sec + ), + Frame::from_parts( + RgbaImage::from_pixel(20, 20, Rgba([0, 255, 0, 255])), + 0, + 0, + Delay::from_numer_denom_ms(0, 1), // 0 sec + ), + Frame::from_parts( + RgbaImage::from_pixel(20, 20, Rgba([0, 0, 255, 255])), + 0, + 0, + Delay::from_numer_denom_ms(1, 60), // 60 FPS + ), + ]; + let loop_count = LoopCount::Finite(NonZeroU32::new(42).unwrap()); + + let mut encoded = Vec::new(); + PngEncoder::new(&mut encoded) + .encode_frames(loop_count, frames.clone()) + .expect("Could not encode animation"); + + // TODO: I hate this. Use the normal `ImageReader` API once #3038 is fixed + let mut reader = crate::ImageReader::from_decoder(Box::new( + PngDecoder::new(Cursor::new(encoded)).apng().unwrap(), + )); + + let animation_attrs = reader.animation_attributes().unwrap(); + assert_eq!(animation_attrs.loop_count, loop_count); + + let decoded_frames = reader.into_frames().collect_frames().unwrap(); + assert_eq!(decoded_frames.len(), frames.len()); + for (decoded, original) in decoded_frames.into_iter().zip(frames) { + assert_eq!(decoded.top(), original.top()); + assert_eq!(decoded.left(), original.left()); + assert_eq!(decoded.delay(), original.delay()); + assert_eq!(decoded.into_buffer(), original.into_buffer()); + } + } } diff --git a/src/metadata.rs b/src/metadata.rs index 3050d9641e..3c47a36ed1 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -175,7 +175,7 @@ enum ExifEndian { } /// The number of times animated image should loop over. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum LoopCount { /// Loop the image Infinitely Infinite, From b48d8e6988c4312711aa80e4ac6e02e708f8a0f8 Mon Sep 17 00:00:00 2001 From: RunDevelopment Date: Sun, 14 Jun 2026 15:27:56 +0200 Subject: [PATCH 2/2] Polish --- src/codecs/png.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/codecs/png.rs b/src/codecs/png.rs index d5ef0377ca..ed043c2c1a 100644 --- a/src/codecs/png.rs +++ b/src/codecs/png.rs @@ -784,7 +784,9 @@ impl PngEncoder { } } - /// TODO + /// Encode an animation from an iterator over frames. + /// + /// See also: [`Self::try_encode_frames`]. pub fn encode_frames(self, loops: LoopCount, frames: F) -> ImageResult<()> where F: IntoIterator, @@ -792,7 +794,9 @@ impl PngEncoder { { self.try_encode_frames(loops, frames.into_iter().map(Ok)) } - /// TODO + /// Encode an animation from an iterator over frames. + /// + /// See also: [`Self::encode_frames`]. pub fn try_encode_frames(self, loops: LoopCount, frames: F) -> ImageResult<()> where F: IntoIterator>, @@ -809,12 +813,11 @@ impl PngEncoder { ) -> ImageResult<()> { let num_frames = frames.len(); - let Some(first) = frames.next() else { + let Some(first) = frames.next().transpose()? else { return Err(ImageError::Parameter(ParameterError::from_kind( ParameterErrorKind::Generic("Animation must have at least one frame".into()), ))); }; - let first = first?; let (width, height) = first.buffer().dimensions(); @@ -823,6 +826,7 @@ impl PngEncoder { height, ExtendedColorType::Rgba8, // all frames are RGBA8 &mut |encoder| { + // set the PNG to animated encoder .set_animated( num_frames as u32, @@ -831,14 +835,7 @@ impl PngEncoder { LoopCount::Infinite => 0, }, ) - .map_err(ImageError::from_png_enc)?; - - // first image is part of the animation - encoder - .set_sep_def_img(false) - .map_err(ImageError::from_png_enc)?; - - Ok(()) + .map_err(ImageError::from_png_enc) }, )?;