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..ed043c2c1a 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,106 @@ impl PngEncoder {
}
}
- fn encode_inner(
+ /// 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- ,
+ F::IntoIter: ExactSizeIterator,
+ {
+ self.try_encode_frames(loops, frames.into_iter().map(Ok))
+ }
+ /// 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
- >,
+ 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().transpose()? else {
+ return Err(ImageError::Parameter(ParameterError::from_kind(
+ ParameterErrorKind::Generic("Animation must have at least one frame".into()),
+ )));
+ };
+
+ let (width, height) = first.buffer().dimensions();
+
+ let mut writer = self.into_writer(
+ width,
+ height,
+ ExtendedColorType::Rgba8, // all frames are RGBA8
+ &mut |encoder| {
+ // set the PNG to animated
+ encoder
+ .set_animated(
+ num_frames as u32,
+ match loops {
+ LoopCount::Finite(n) => n.get(),
+ LoopCount::Infinite => 0,
+ },
+ )
+ .map_err(ImageError::from_png_enc)
+ },
+ )?;
+
+ 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 +953,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 +980,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 +991,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 +1005,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 +1148,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 +1276,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,