diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 570652933e..ee639bcb17 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -101,7 +101,10 @@ the same. `DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file states it (frames and TIFF directories are counted without decoding them; reduced-resolution TIFF directories and transparency masks are not pages). Any TIFF page can still be opened - with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). + with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes + from `decode_gif_frames`: each frame composited onto the logical screen with the disposal + methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and + `MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`). * **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet. `caps.exif = false` for TIFF, and a warning is raised. * **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml index bbbc77aa95..9cbb3d9815 100644 --- a/crates/codecs/fuzz/Cargo.toml +++ b/crates/codecs/fuzz/Cargo.toml @@ -62,3 +62,10 @@ path = "fuzz_targets/decode_heif.rs" test = false doc = false bench = false + +[[bin]] +name = "decode_gif" +path = "fuzz_targets/decode_gif.rs" +test = false +doc = false +bench = false diff --git a/crates/codecs/fuzz/fuzz_targets/decode_gif.rs b/crates/codecs/fuzz/fuzz_targets/decode_gif.rs new file mode 100644 index 0000000000..babcd6cd1e --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_gif.rs @@ -0,0 +1,13 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, decode_gif_frames, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + ..Default::default() + }; + let _ = decode_as_with(Format::Gif, data, &opts); + let _ = decode_gif_frames(data, &opts); +}); diff --git a/crates/codecs/src/codecs/gif_frames.rs b/crates/codecs/src/codecs/gif_frames.rs new file mode 100644 index 0000000000..a5bed96254 --- /dev/null +++ b/crates/codecs/src/codecs/gif_frames.rs @@ -0,0 +1,79 @@ +//! Every frame of an animated GIF. [`crate::decode`] keeps the first frame only; this walks the +//! whole animation for callers that can hold it (video layers). +//! +//! The `image` crate composites each frame onto the logical screen and applies the disposal +//! methods, so every returned frame is a full, self-contained picture. + +use std::io::Cursor; + +use image::codecs::gif::GifDecoder; +use image::{AnimationDecoder, ImageDecoder}; + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, DecodeWarning, Image}; +use crate::options::DecodeOptions; + +/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise +/// ask for millions of them while staying inside the byte budget. +pub const MAX_GIF_FRAMES: usize = 100_000; + +/// One composited frame of an animation and how long it stays on screen. +#[derive(Debug, Clone, PartialEq)] +pub struct AnimationFrame { + pub image: Image, + /// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a + /// multiple of 10 (0 when the file gives none). + pub delay_ms: u32, +} + +/// Decodes every frame of a GIF, each as a full RGBA8 image of the logical screen size. +/// +/// The first frame must decode. If a later frame is damaged the frames before it are returned +/// and the first one carries [`DecodeWarning::Truncated`]. Dimensions, the frame count +/// ([`MAX_GIF_FRAMES`]) and the total decoded bytes are checked against `opts.limits` before +/// each frame is kept. +pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result, CodecError> { + let limits = &opts.limits; + let map = |e: image::ImageError| match e { + image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()), + image::ImageError::Unsupported(u) => CodecError::unsupported(Format::Gif, u.to_string()), + e => CodecError::malformed(Format::Gif, e), + }; + let mut decoder = GifDecoder::new(Cursor::new(bytes)).map_err(map)?; + let mut il = image::Limits::default(); + il.max_image_width = Some(limits.max_width); + il.max_image_height = Some(limits.max_height); + il.max_alloc = Some(limits.max_alloc); + decoder.set_limits(il).map_err(map)?; + let (w, h) = decoder.dimensions(); + limits.check_bytes(w, h, 4)?; + let frame_bytes = u64::from(w) * u64::from(h) * 4; + + let mut out: Vec = Vec::new(); + let mut damaged = false; + for frame in decoder.into_frames() { + let frame = match frame { + Ok(f) => f, + Err(e) if out.is_empty() => return Err(map(e)), + Err(_) => { + damaged = true; + break; + } + }; + if out.len() >= MAX_GIF_FRAMES { + return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); + } + if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); + } + let (numer, denom) = frame.delay().numer_denom_ms(); + let delay_ms = numer.checked_div(denom).unwrap_or(0); + let rgba = frame.into_buffer(); + out.push(AnimationFrame { image: Image::from_u8(w, h, ChannelLayout::Rgba, rgba.into_raw())?, delay_ms }); + } + if damaged && let Some(first) = out.first_mut() { + first.image.warnings.push(DecodeWarning::Truncated { format: Format::Gif }); + } + Ok(out) +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 4ec1587c81..ea70ccc2b8 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod deep_exr; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; +pub(crate) mod gif_frames; pub(crate) mod heif; pub(crate) mod jpeg; pub(crate) mod png; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index b7722e9cf5..f107732555 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -25,6 +25,7 @@ pub mod resolution; pub mod web; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; +pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; pub use crate::codecs::png::encode_indexed as encode_png_indexed; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; @@ -108,6 +109,13 @@ pub fn decode_tiff_page(bytes: &[u8], page: usize, opts: &DecodeOptions) -> Resu finish_decode(img, tiff::orientation(bytes, Some(page)), opts) } +/// Decodes every frame of a GIF (a still GIF yields one), each a full RGBA8 picture of the +/// logical screen with its delay. [`decode`] keeps the first frame only. See +/// [`MAX_GIF_FRAMES`] and `opts.limits` for what is refused. +pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result, CodecError> { + crate::codecs::gif_frames::decode(bytes, opts) +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { diff --git a/crates/codecs/tests/gif_frames.rs b/crates/codecs/tests/gif_frames.rs new file mode 100644 index 0000000000..257e8c928c --- /dev/null +++ b/crates/codecs/tests/gif_frames.rs @@ -0,0 +1,168 @@ +//! `decode_gif_frames`: every frame of an animated GIF, composited, with its delay. + +use photocraft_codecs::{AnimationFrame, CodecError, DecodeOptions, DecodeWarning, Format, Limits, decode, decode_gif_frames}; + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const CLEAR: [u8; 4] = [0, 0, 0, 0]; + +fn frames(bytes: &[u8]) -> Vec { + decode_gif_frames(bytes, &DecodeOptions::default()).unwrap() +} + +fn pixels(f: &AnimationFrame) -> Vec { + f.image.to_rgba8() +} + +/// Solid full-screen frames written by the `image` crate's encoder, `delay_ms` apart. +fn solid_gif(colours: &[[u8; 4]], w: u32, h: u32, delay_ms: u32) -> Vec { + let mut out = Vec::new(); + let mut enc = image::codecs::gif::GifEncoder::new(&mut out); + let list = colours.iter().map(|c| { + let buf = image::RgbaImage::from_pixel(w, h, image::Rgba(*c)); + image::Frame::from_parts(buf, 0, 0, image::Delay::from_numer_denom_ms(delay_ms, 1)) + }); + enc.encode_frames(list).unwrap(); + drop(enc); + out +} + +/// A palette-index image LZW-packed with a 2-bit minimum code size. Few enough pixels that the +/// code size never grows past 3 bits. +fn lzw2(indices: &[u8]) -> Vec { + let mut codes = vec![4u8]; + codes.extend_from_slice(indices); + codes.push(5); + let (mut acc, mut nbits, mut out) = (0u32, 0, Vec::new()); + for c in codes { + acc |= u32::from(c) << nbits; + nbits += 3; + while nbits >= 8 { + out.push((acc & 0xFF) as u8); + acc >>= 8; + nbits -= 8; + } + } + if nbits > 0 { + out.push(acc as u8); + } + out +} + +/// One image of [`raw_gif`]: `(x, y, width, height, disposal method, palette indices)`. +type Part<'a> = (u16, u16, u16, u16, u8, &'a [u8]); + +/// A hand-built GIF89a: a `w`x`h` screen with the 4-colour global palette red, green, blue, +/// black, and one image per [`Part`]. Frames are 10 cs apart. +fn raw_gif(w: u16, h: u16, parts: &[Part]) -> Vec { + let mut b = b"GIF89a".to_vec(); + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[0b1000_0001, 0, 0]); + b.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]); + for &(x, y, fw, fh, disposal, indices) in parts { + // Graphic control extension: disposal method, 10 cs delay, no transparency. + b.extend_from_slice(&[0x21, 0xF9, 4, disposal << 2, 10, 0, 0, 0]); + b.push(0x2C); + for v in [x, y, fw, fh] { + b.extend_from_slice(&v.to_le_bytes()); + } + b.extend_from_slice(&[0, 2]); + let data = lzw2(indices); + b.push(data.len() as u8); + b.extend_from_slice(&data); + b.push(0); + } + b.push(0x3B); + b +} + +#[test] +fn a_still_gif_gives_one_frame_equal_to_decode() { + let still = solid_gif(&[RED], 3, 2, 100); + let all = frames(&still); + assert_eq!(all.len(), 1); + assert_eq!((all[0].image.width(), all[0].image.height()), (3, 2)); + assert_eq!(pixels(&all[0]), decode(&still).unwrap().to_rgba8()); + assert!(all[0].image.warnings.is_empty()); +} + +#[test] +fn every_frame_comes_back_in_order_with_its_delay() { + let all = frames(&solid_gif(&[RED, GREEN, BLUE], 2, 2, 120)); + assert_eq!(all.len(), 3); + for (f, want) in all.iter().zip([RED, GREEN, BLUE]) { + assert_eq!(pixels(f), want.repeat(4)); + assert_eq!(f.delay_ms, 120); + } +} + +#[test] +fn a_smaller_frame_is_drawn_over_the_previous_one() { + // Frame 2 is one pixel at x = 1, kept ("do not dispose", 1): the screen is red, blue. + let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 1, &[0, 0]), (1, 0, 1, 1, 1, &[2])])); + assert_eq!(all.len(), 2); + assert_eq!(pixels(&all[0]), [RED, RED].concat()); + assert_eq!(pixels(&all[1]), [RED, BLUE].concat()); + assert_eq!(all[1].delay_ms, 100); +} + +#[test] +fn disposal_to_background_clears_the_previous_frame() { + // Frame 1 (disposal 2, restore to background) covers the screen; frame 2 draws one pixel. + let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 2, &[1, 1]), (1, 0, 1, 1, 1, &[2])])); + assert_eq!(pixels(&all[0]), [GREEN, GREEN].concat()); + assert_eq!(pixels(&all[1]), [CLEAR, BLUE].concat()); +} + +#[test] +fn disposal_to_previous_restores_what_was_there() { + // Frame 1 is kept; frame 2 (disposal 3, restore previous) paints one pixel over it, so frame + // 3, which draws elsewhere, sees frame 1 again. + let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 1, &[0, 0]), (0, 0, 1, 1, 3, &[1]), (1, 0, 1, 1, 1, &[2])])); + assert_eq!(pixels(&all[1]), [GREEN, RED].concat()); + assert_eq!(pixels(&all[2]), [RED, BLUE].concat()); +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let good = solid_gif(&[RED, GREEN, BLUE], 8, 8, 100); + // Cut inside the last frame's data, so frames 1 and 2 are intact. + let cut = &good[..good.len() - 12]; + let all = frames(cut); + assert!((1..3).contains(&all.len()), "{} frames", all.len()); + assert_eq!(all[0].image.warnings, [DecodeWarning::Truncated { format: Format::Gif }]); + assert!(all[1..].iter().all(|f| f.image.warnings.is_empty())); +} + +#[test] +fn the_first_frame_must_decode() { + assert!(decode_gif_frames(b"", &DecodeOptions::default()).is_err()); + assert!(decode_gif_frames(b"GIF89a", &DecodeOptions::default()).is_err()); + assert!(decode_gif_frames(&[0xFF; 64], &DecodeOptions::default()).is_err()); +} + +#[test] +fn every_prefix_of_a_gif_returns() { + let g = solid_gif(&[RED, GREEN, BLUE], 5, 4, 100); + for n in 0..g.len() { + let _ = decode_gif_frames(&g[..n], &DecodeOptions::default()); + } +} + +#[test] +fn dimension_limits_apply() { + let g = solid_gif(&[RED], 4, 4, 100); + let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() }; + assert!(matches!(decode_gif_frames(&g, &opts), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn the_byte_budget_covers_all_frames_together() { + // One 4x4 RGBA frame is 64 bytes: two frames fit in 128, three do not. + let limits = Limits { max_alloc: 128, ..Limits::default() }; + let opts = DecodeOptions { limits, ..Default::default() }; + assert_eq!(decode_gif_frames(&solid_gif(&[RED, GREEN], 4, 4, 100), &opts).unwrap().len(), 2); + assert!(matches!(decode_gif_frames(&solid_gif(&[RED, GREEN, BLUE], 4, 4, 100), &opts), Err(CodecError::LimitExceeded(_)))); +} diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 3ea52ab2b5..dbb51d82d2 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -200,9 +200,38 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result { // ------------------------------------------------------------------ file-based footage -/// Load frames from `path`: a single image (1 frame), or a directory of images (sorted = an image -/// sequence). Each becomes a full raster frame in the document's format. -fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result> { +/// Frames loaded from a file or an image sequence. +struct Footage { + frames: Vec, + /// The frame rate the footage itself implies (a single animated GIF's delays); `None` for + /// stills and sequences, which carry none. + fps: Option, +} + +/// The frame rate for a GIF's frame delays (milliseconds): that of the most common delay, the +/// first one seen on a tie. GIF has a delay per frame but the timeline one rate. Delays under +/// 20 ms, which includes the 0 and 10 ms many encoders write, are shown at 100 ms, as browsers +/// do, since they would otherwise play at an unintended 50-100 fps. +fn fps_from_delays(delays: &[u32]) -> Option { + let clean = |d: u32| if d < 20 { 100 } else { d }; + let mut counts: Vec<(u32, usize)> = Vec::new(); + for d in delays.iter().copied().map(clean) { + match counts.iter_mut().find(|(k, _)| *k == d) { + Some((_, n)) => *n += 1, + None => counts.push((d, 1)), + } + } + let best = counts.iter().fold(None::<(u32, usize)>, |best, &(d, n)| match best { + Some((_, bn)) if bn >= n => best, + _ => Some((d, n)), + }); + best.map(|(d, _)| 1000.0 / d as f32) +} + +/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF), or a +/// directory of images (sorted = an image sequence). Each becomes a full raster frame in the +/// document's format. +fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result { let p = std::path::Path::new(path); let is_img = |f: &std::path::Path| { matches!( @@ -225,9 +254,19 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result Result { @@ -244,9 +285,10 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_str) .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })? .to_string(); - let fps = p.get("fps").and_then(Value::as_f64).unwrap_or(30.0) as f32; let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?; + let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; + // An explicit `fps` wins, then the rate the footage implies, then 30. + let fps = p.get("fps").and_then(Value::as_f64).map(|f| f as f32).or(footage_fps).unwrap_or(30.0); let n = frames.len(); let id = s.edit("New Video Layer from File", |doc, active| { store(doc); @@ -277,7 +319,7 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?; + let frames = load_frames(&path, fmt)?.frames; edit_video(s, "Replace Footage", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -311,7 +353,7 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { let photocraft_doc::VideoSource::File { path } = src else { return Err(EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "this layer has no file source to reload".into() }); }; - let frames = load_frames(&path, fmt)?; + let frames = load_frames(&path, fmt)?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -327,7 +369,7 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?; + let frames = load_frames(&path, fmt)?.frames; let n = frames.len(); s.edit("Video Frames to Layers", |doc, active| { let mut above = *active; diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index bce352dd29..3380d3b9d0 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -258,7 +258,8 @@ fn tga_sequence_frames_keep_their_transparency() { let dir = tmpdir("tga"); write_tga(&format!("{dir}/f01.tga")); write_tga(&format!("{dir}/f02.tga")); - let frames = load_frames(&dir, photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true)).unwrap(); + let frames = + load_frames(&dir, photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true)).unwrap().frames; assert_eq!(frames.len(), 2); for f in &frames { assert_eq!(f.pixel(2, 6), vec![1.0, 0.0, 0.0, 1.0]); @@ -267,6 +268,117 @@ fn tga_sequence_frames_keep_their_transparency() { let _ = std::fs::remove_dir_all(&dir); } +// ------------------------------------------------------------------ animated GIF footage + +/// An animated GIF of solid `w`x`h` frames: one `(colour, delay in centiseconds)` each. +fn write_gif(path: &str, w: u32, h: u32, frames: &[([u8; 3], u16)]) { + use photocraft_codecs::web::{GifFrame, encode_gif_animated}; + let list: Vec = + frames.iter().map(|&(c, delay_cs)| GifFrame { indices: vec![0; (w * h) as usize], palette: vec![c], transparent: None, delay_cs }).collect(); + std::fs::write(path, encode_gif_animated(w, h, &list, true).unwrap()).unwrap(); +} + +fn frame_rgb(s: &Session, id: LayerId, frame: usize) -> Vec { + let v = s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap(); + v.frames[frame].pixel(1, 1)[..3].to_vec() +} + +#[test] +fn an_animated_gif_becomes_a_video_layer_with_every_frame() { + let dir = tmpdir("gifin"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 16, 12, &[([255, 0, 0], 4), ([0, 255, 0], 4), ([0, 0, 255], 4)]); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 3); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(frames(&s, id), 3); + assert_eq!(frame_rgb(&s, id, 0), [1.0, 0.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 1), [0.0, 1.0, 0.0]); + assert_eq!(frame_rgb(&s, id, 2), [0.0, 0.0, 1.0]); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.timeline.as_ref().unwrap().duration, 3); + // 4 cs = 40 ms per frame: 25 fps, on the timeline and on the layer. + assert_eq!(doc.timeline.as_ref().unwrap().fps, 25.0); + assert_eq!(doc.layer(id).unwrap().video.as_ref().unwrap().fps, 25.0); + // One history step: undo takes the layer back. + assert!(s.undo()); + assert!(s.active().unwrap().doc.layer(id).is_none()); +} + +#[test] +fn an_explicit_fps_beats_the_gif_delays() { + let dir = tmpdir("gifin-fps"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 10), ([0, 255, 0], 10)]); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path, "fps": 12.0})).unwrap(); + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 12.0); +} + +#[test] +fn a_still_gif_is_one_frame_at_the_default_rate() { + let dir = tmpdir("gifin-still"); + let path = format!("{dir}/still.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 0)]); + let mut s = session(); + let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap(); + assert_eq!(r["frames"], 1); + // A 0 delay is shown at 100 ms, so 10 fps. + let id = LayerId(r["layer"].as_u64().unwrap()); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 10.0); +} + +#[test] +fn video_frames_to_layers_takes_every_gif_frame() { + let dir = tmpdir("gifin-ftl"); + let path = format!("{dir}/anim.gif"); + write_gif(&path, 8, 8, &[([255, 0, 0], 5), ([0, 255, 0], 5), ([0, 0, 255], 5), ([9, 9, 9], 5)]); + let mut s = session(); + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 4); +} + +#[test] +fn a_gif_rendered_by_render_video_loads_back_frame_for_frame() { + let src = tmpdir("gifrt-src"); + write_png(&format!("{src}/a.png"), 10, 8, "#ff0000"); + write_png(&format!("{src}/b.png"), 10, 8, "#0000ff"); + let mut s = session(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": src, "fps": 20.0})).unwrap(); + let out = tmpdir("gifrt-out"); + let r = s.execute("file.export.renderVideo", json!({"dir": out, "format": "gif"})).unwrap(); + let mut t = session(); + let back = t.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": r["file"]})).unwrap(); + assert_eq!(back["frames"], 2); + let id = LayerId(back["layer"].as_u64().unwrap()); + assert_eq!(t.active().unwrap().doc.timeline.as_ref().unwrap().fps, 20.0); + // GIF palettes are 8-bit: the colours come back to within a quantization step. + let (a, b) = (frame_rgb(&t, id, 0), frame_rgb(&t, id, 1)); + assert!(a[0] > 0.9 && a[2] < 0.1, "{a:?}"); + assert!(b[2] > 0.9 && b[0] < 0.1, "{b:?}"); +} + +#[test] +fn a_broken_gif_is_an_error_not_a_panic() { + let dir = tmpdir("gifin-bad"); + let path = format!("{dir}/bad.gif"); + std::fs::write(&path, b"GIF89a\x01\x00").unwrap(); + let mut s = session(); + assert!(s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).is_err()); +} + +#[test] +fn gif_delays_map_to_one_frame_rate() { + // The most common delay wins; the first seen on a tie. + assert_eq!(fps_from_delays(&[40, 40, 100]), Some(25.0)); + assert_eq!(fps_from_delays(&[100, 40]), Some(10.0)); + // Delays under 20 ms (0 and 10 are common) play at 100 ms, as in browsers. + assert_eq!(fps_from_delays(&[0, 10, 0]), Some(10.0)); + assert_eq!(fps_from_delays(&[]), None); +} + #[test] fn imported_video_restore_frame_and_all_keep_source_pixels() { let dir = tmpdir("original-footage"); diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index c2632d22b4..3659666f8e 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -38,6 +38,18 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { Ok(r) } +/// Every frame of a GIF as a single-layer document, handed to `each` in order with its delay in +/// milliseconds (a still GIF has one). One frame at a time becomes a document, so the caller +/// can flatten and drop it. Returns the number of frames; stops at the first error `each` gives. +pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result { + let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?; + let n = frames.len(); + for f in frames { + each(image_to_document(name, &f.image)?.document, f.delay_ms)?; + } + Ok(n) +} + /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the /// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; /// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 2caf027977..4a395e1630 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -64,7 +64,7 @@ use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; -pub use flat::{document_to_image, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits}; +pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits}; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; diff --git a/crates/io/tests/gif_frames.rs b/crates/io/tests/gif_frames.rs new file mode 100644 index 0000000000..565b766062 --- /dev/null +++ b/crates/io/tests/gif_frames.rs @@ -0,0 +1,39 @@ +//! `import_gif_frames`: each GIF frame as a single-layer document, in order, with its delay. + +use photocraft_codecs::web::{GifFrame, encode_gif_animated}; +use photocraft_io::{IoError, import_gif_frames}; + +fn gif(colours: &[([u8; 3], u16)]) -> Vec { + let frames: Vec = colours.iter().map(|&(c, delay_cs)| GifFrame { indices: vec![0; 6], palette: vec![c], transparent: None, delay_cs }).collect(); + encode_gif_animated(3, 2, &frames, true).unwrap() +} + +#[test] +fn frames_arrive_in_order_as_documents_with_their_delays() { + let bytes = gif(&[([255, 0, 0], 3), ([0, 0, 255], 7)]); + let mut seen = Vec::new(); + let n = import_gif_frames("a.gif", &bytes, &mut |doc, delay_ms| { + seen.push((doc.layers.len(), doc.bounds().width(), doc.bounds().height(), delay_ms)); + Ok(()) + }) + .unwrap(); + assert_eq!(n, 2); + assert_eq!(seen, [(1, 3, 2, 30), (1, 3, 2, 70)]); +} + +#[test] +fn an_error_from_the_callback_stops_the_import() { + let bytes = gif(&[([255, 0, 0], 3), ([0, 0, 255], 7), ([0, 255, 0], 7)]); + let mut calls = 0; + let r = import_gif_frames("a.gif", &bytes, &mut |_, _| { + calls += 1; + Err(IoError::Unsupported("stop".into())) + }); + assert!(matches!(r, Err(IoError::Unsupported(_)))); + assert_eq!(calls, 1); +} + +#[test] +fn garbage_is_an_error() { + assert!(import_gif_frames("a.gif", b"not a gif", &mut |_, _| Ok(())).is_err()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 014cf183c8..c1da1665a9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -27,7 +27,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) | -| GIF | R/W | yes | yes | First frame only | +| GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP | | AVIF | R/W | no (#463) | optional feature, off by default | |