mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Animated WebP: every frame loads into video layers (#1198)
An animated WebP used to bring only its first frame into a video layer. Now New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, as they do for an animated GIF (#572): - photocraft-codecs: `decode_webp_frames` returns each frame composited onto the canvas by image-webp (offsets, blending, disposal), with its duration in milliseconds and the file's ICC profile. A still WebP is one frame, read as `decode` reads it. A damaged later frame keeps the frames before it and marks the first `Truncated`; the frame count and total bytes are checked against `Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz target. - photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop. - photocraft-engine: `load_frames` takes an animated WebP's frames; the timeline rate comes from the most common duration. A still WebP keeps the existing path and the 30 fps default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -128,7 +128,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
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`).
|
||||
`MAX_GIF_FRAMES`. Every frame of an animated WebP comes from `decode_webp_frames` the same
|
||||
way, composited onto its canvas, with its duration in milliseconds and the file's ICC profile.
|
||||
The video-layer commands use both (`photocraft_io::import_gif_frames`,
|
||||
`photocraft_io::import_webp_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
|
||||
|
||||
@@ -69,3 +69,10 @@ path = "fuzz_targets/decode_gif.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "decode_webp"
|
||||
path = "fuzz_targets/decode_webp.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_codecs::{decode_as_with, decode_webp_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::WebP, data, &opts);
|
||||
let _ = decode_webp_frames(data, &opts);
|
||||
});
|
||||
@@ -14,16 +14,16 @@ 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.
|
||||
/// Most frames one GIF (or animated WebP) 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).
|
||||
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so there this is a
|
||||
/// multiple of 10 (0 when the file gives none); WebP counts in milliseconds.
|
||||
pub delay_ms: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,56 @@ pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<V
|
||||
Ok((w, h, dec.exif_metadata().ok().flatten()))
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an animated WebP of more than one frame.
|
||||
pub(crate) fn is_animated(bytes: &[u8]) -> bool {
|
||||
image_webp::WebPDecoder::new(Cursor::new(bytes)).is_ok_and(|d| d.is_animated() && d.num_frames() > 1)
|
||||
}
|
||||
|
||||
/// Every frame of an animated WebP, or `None` when it is a still one (or an animation of one
|
||||
/// frame), which [`decode`] reads whole. The frames come composited onto the canvas by
|
||||
/// `image-webp` (offsets, blending and disposal applied), each a full picture with the file's
|
||||
/// ICC profile and its duration in milliseconds.
|
||||
///
|
||||
/// The first frame must decode. If a later frame is damaged the frames before it are returned
|
||||
/// and the first one carries [`DecodeWarning::Truncated`]. The frame count
|
||||
/// ([`super::gif_frames::MAX_GIF_FRAMES`]) and the total decoded bytes are checked against
|
||||
/// `limits` before each frame is kept.
|
||||
pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result<Option<Vec<super::gif_frames::AnimationFrame>>, CodecError> {
|
||||
use super::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
if !is_animated(bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (mut dec, w, h, layout) = open(bytes, limits)?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
let mut out: Vec<AnimationFrame> = Vec::new();
|
||||
let mut damaged = false;
|
||||
for _ in 0..dec.num_frames() {
|
||||
if out.len() >= MAX_GIF_FRAMES {
|
||||
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
|
||||
}
|
||||
if (size as u64).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 delay_ms = match dec.read_frame(&mut buf) {
|
||||
Ok(d) => d,
|
||||
Err(e) if out.is_empty() => return Err(err(e)),
|
||||
Err(_) => {
|
||||
damaged = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let mut image = Image::from_u8(w, h, layout, buf.clone())?;
|
||||
image.icc.clone_from(&icc);
|
||||
out.push(AnimationFrame { image, delay_ms });
|
||||
}
|
||||
if damaged && let Some(first) = out.first_mut() {
|
||||
first.image.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
if !opts.webp_lossless {
|
||||
|
||||
@@ -149,6 +149,23 @@ pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<Anima
|
||||
crate::codecs::gif_frames::decode(bytes, opts)
|
||||
}
|
||||
|
||||
/// Decodes every frame of a WebP (a still one yields one frame with a 0 delay, as [`decode`]
|
||||
/// reads it), each a full picture of the canvas with its duration and the file's ICC profile.
|
||||
/// [`decode`] keeps the first frame only. See [`MAX_GIF_FRAMES`] and `opts.limits` for what is
|
||||
/// refused.
|
||||
pub fn decode_webp_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
|
||||
match crate::codecs::webp::decode_frames(bytes, &opts.limits)? {
|
||||
Some(frames) => Ok(frames),
|
||||
None => Ok(vec![AnimationFrame { image: decode_as_with(Format::WebP, bytes, opts)?, delay_ms: 0 }]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` is an animated WebP of more than one frame (which [`decode`] opens as its
|
||||
/// first frame, with a warning, and [`decode_webp_frames`] reads whole).
|
||||
pub fn webp_is_animated(bytes: &[u8]) -> bool {
|
||||
crate::codecs::webp::is_animated(bytes)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198).
|
||||
|
||||
use photocraft_codecs::{
|
||||
AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode,
|
||||
webp_is_animated,
|
||||
};
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
fn frames(bytes: &[u8]) -> Vec<AnimationFrame> {
|
||||
decode_webp_frames(bytes, &DecodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn solid(c: [u8; 4], w: u32, h: u32) -> Image {
|
||||
Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap()
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn u24(v: u32) -> [u8; 3] {
|
||||
let b = v.to_le_bytes();
|
||||
[b[0], b[1], b[2]]
|
||||
}
|
||||
|
||||
/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP
|
||||
/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the
|
||||
/// canvas rather than replacing it.
|
||||
struct Part {
|
||||
image: Image,
|
||||
at: [u32; 2],
|
||||
duration_ms: u32,
|
||||
blend: bool,
|
||||
}
|
||||
|
||||
fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part {
|
||||
Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false }
|
||||
}
|
||||
|
||||
/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part,
|
||||
/// each holding a lossless still from our own encoder.
|
||||
fn animated(w: u32, h: u32, parts: &[Part]) -> Vec<u8> {
|
||||
let mut body = b"WEBP".to_vec();
|
||||
let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags
|
||||
vp8x.extend_from_slice(&u24(w - 1));
|
||||
vp8x.extend_from_slice(&u24(h - 1));
|
||||
body.extend(chunk(b"VP8X", &vp8x));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever
|
||||
for p in parts {
|
||||
let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
|
||||
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let mut anmf = Vec::new();
|
||||
anmf.extend_from_slice(&u24(p.at[0] / 2));
|
||||
anmf.extend_from_slice(&u24(p.at[1] / 2));
|
||||
anmf.extend_from_slice(&u24(p.image.width() - 1));
|
||||
anmf.extend_from_slice(&u24(p.image.height() - 1));
|
||||
anmf.extend_from_slice(&u24(p.duration_ms));
|
||||
anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend
|
||||
anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len]));
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] {
|
||||
let rgba = f.image.to_rgba8();
|
||||
let i = ((y * f.image.width() + x) * 4) as usize;
|
||||
rgba[i..i + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_webp_gives_one_frame_equal_to_decode() {
|
||||
let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
assert!(!webp_is_animated(&still));
|
||||
let f = frames(&still);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].delay_ms, 0);
|
||||
assert_eq!(f[0].image, decode(&still).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_frame_animation_is_not_animated() {
|
||||
let a = animated(6, 4, &[part(RED, 6, 4, 70)]);
|
||||
assert!(!webp_is_animated(&a));
|
||||
assert_eq!(frames(&a).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_frame_comes_back_in_order_with_its_duration() {
|
||||
let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]);
|
||||
assert!(webp_is_animated(&a));
|
||||
let f = frames(&a);
|
||||
assert_eq!(f.iter().map(|f| f.delay_ms).collect::<Vec<_>>(), [40, 120, 7], "WebP durations are milliseconds");
|
||||
for (f, c) in f.iter().zip([RED, GREEN, BLUE]) {
|
||||
assert_eq!((f.image.width(), f.image.height()), (6, 4));
|
||||
assert_eq!(pixel(f, 0, 0), c);
|
||||
assert_eq!(pixel(f, 5, 3), c);
|
||||
assert_eq!(f.image.warnings, []);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smaller_frame_is_drawn_over_the_previous_one() {
|
||||
let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true };
|
||||
let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small]));
|
||||
assert_eq!(f.len(), 2);
|
||||
// Alpha blending rounds: within one level.
|
||||
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through");
|
||||
assert!(near(pixel(&f[1], 2, 2), BLUE));
|
||||
assert!(near(pixel(&f[1], 3, 3), BLUE));
|
||||
assert!(near(pixel(&f[1], 4, 2), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_later_frame_keeps_the_frames_before_it() {
|
||||
let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]);
|
||||
// Break the last frame's VP8L signature byte; the container around it stays whole.
|
||||
let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap();
|
||||
a[last + 8] = 0;
|
||||
let f = frames(&a);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert_eq!(pixel(&f[0], 0, 0), RED);
|
||||
assert_eq!(pixel(&f[1], 0, 0), GREEN);
|
||||
assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_prefix_of_an_animated_webp_returns() {
|
||||
let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]);
|
||||
for n in 0..a.len() {
|
||||
let _ = decode_webp_frames(&a[..n], &DecodeOptions::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_limits_apply() {
|
||||
let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
|
||||
let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() };
|
||||
assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_budget_covers_all_frames_together() {
|
||||
// One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so
|
||||
// two frames need more than 128; ten frames of the same size must not fit where two do.
|
||||
let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
|
||||
let ten: Vec<Part> = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect();
|
||||
let ten = animated(4, 4, &ten);
|
||||
let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() };
|
||||
assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2);
|
||||
assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
}
|
||||
@@ -212,7 +212,7 @@ fn rasterize(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
/// Frames loaded from a file or an image sequence.
|
||||
struct Footage {
|
||||
frames: Vec<Surface>,
|
||||
/// The frame rate the footage itself implies (a single animated GIF's delays); `None` for
|
||||
/// The frame rate the footage itself implies (a single animated GIF's or WebP's delays); `None` for
|
||||
/// stills and sequences, which carry none.
|
||||
fps: Option<f32>,
|
||||
}
|
||||
@@ -237,7 +237,14 @@ fn fps_from_delays(delays: &[u32]) -> Option<f32> {
|
||||
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
|
||||
/// `photocraft_io::import_gif_frames` / `import_webp_frames`.
|
||||
type ImportFrames = fn(
|
||||
&str,
|
||||
&[u8],
|
||||
&mut dyn FnMut(photocraft_doc::Document, u32) -> std::result::Result<(), photocraft_io::IoError>,
|
||||
) -> std::result::Result<usize, photocraft_io::IoError>;
|
||||
|
||||
/// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), 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<Footage> {
|
||||
@@ -267,8 +274,14 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Footage
|
||||
for f in &files {
|
||||
let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?;
|
||||
let name = f.to_string_lossy();
|
||||
if photocraft_codecs::detect(&bytes) == Some(photocraft_codecs::Format::Gif) {
|
||||
photocraft_io::import_gif_frames(&name, &bytes, &mut |doc, delay_ms| {
|
||||
// A GIF (a still one is its only frame) or an animated WebP brings every frame.
|
||||
let animation = match photocraft_codecs::detect(&bytes) {
|
||||
Some(photocraft_codecs::Format::Gif) => Some(photocraft_io::import_gif_frames as ImportFrames),
|
||||
Some(photocraft_codecs::Format::WebP) if photocraft_codecs::webp_is_animated(&bytes) => Some(photocraft_io::import_webp_frames as ImportFrames),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(import) = animation {
|
||||
import(&name, &bytes, &mut |doc, delay_ms| {
|
||||
frames.push(crate::file_cmds::flattened(&doc, fmt));
|
||||
delays.push(delay_ms);
|
||||
Ok(())
|
||||
@@ -283,7 +296,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Footage
|
||||
}
|
||||
frames.push(crate::file_cmds::flattened(&doc, fmt));
|
||||
}
|
||||
// Only a lone GIF says how fast it plays; a sequence mixing files has no single rate.
|
||||
// Only a lone GIF or WebP says how fast it plays; a sequence mixing files has no single rate.
|
||||
let fps = if files.len() == 1 { fps_from_delays(&delays) } else { None };
|
||||
Ok(Footage { frames, fps })
|
||||
}
|
||||
|
||||
@@ -340,6 +340,80 @@ fn video_frames_to_layers_takes_every_gif_frame() {
|
||||
assert_eq!(r["layers"], 4);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ animated WebP footage (#1198)
|
||||
|
||||
/// An animated WebP of solid `w`x`h` frames: one `(colour, duration in ms)` each, every frame a
|
||||
/// lossless still in an ANMF chunk.
|
||||
fn write_webp(path: &str, w: u32, h: u32, frames: &[([u8; 3], u32)]) {
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, encode};
|
||||
let chunk = |id: &[u8; 4], data: &[u8]| {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
};
|
||||
let u24 = |v: u32| v.to_le_bytes()[..3].to_vec();
|
||||
let mut body = b"WEBP".to_vec();
|
||||
body.extend(chunk(b"VP8X", &[[0x02, 0, 0, 0].as_slice(), &u24(w - 1), &u24(h - 1)].concat()));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]));
|
||||
for &(c, ms) in frames {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgb, c.repeat((w * h) as usize)).unwrap();
|
||||
let still = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
|
||||
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
|
||||
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
let anmf = [[0; 6].as_slice(), &u24(w - 1), &u24(h - 1), &u24(ms), &[0x02], &chunk(b"VP8L", &still[at + 8..at + 8 + len])].concat();
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend(body);
|
||||
std::fs::write(path, out).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animated_webp_becomes_a_video_layer_with_every_frame() {
|
||||
let dir = tmpdir("webpin");
|
||||
let path = format!("{dir}/anim.webp");
|
||||
write_webp(&path, 16, 12, &[([255, 0, 0], 50), ([0, 255, 0], 50), ([0, 0, 255], 80)]);
|
||||
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!(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]);
|
||||
// The most common duration, 50 ms: 20 fps.
|
||||
assert_eq!(s.active().unwrap().doc.timeline.as_ref().unwrap().fps, 20.0);
|
||||
assert!(s.undo());
|
||||
assert!(s.active().unwrap().doc.layer(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_frames_to_layers_takes_every_webp_frame() {
|
||||
let dir = tmpdir("webpin-ftl");
|
||||
let path = format!("{dir}/anim.webp");
|
||||
write_webp(&path, 8, 8, &[([255, 0, 0], 40), ([0, 255, 0], 40), ([0, 0, 255], 40)]);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["layers"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_webp_is_one_frame_at_the_default_30_fps() {
|
||||
let dir = tmpdir("webpin-still");
|
||||
let path = format!("{dir}/still.webp");
|
||||
let img = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap();
|
||||
std::fs::write(&path, photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &Default::default()).unwrap()).unwrap();
|
||||
let mut s = session();
|
||||
let r = s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["frames"], 1);
|
||||
let id = LayerId(r["layer"].as_u64().unwrap());
|
||||
assert_eq!(s.active().unwrap().doc.layer(id).unwrap().video.as_ref().unwrap().fps, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_rendered_by_render_video_loads_back_frame_for_frame() {
|
||||
let src = tmpdir("gifrt-src");
|
||||
|
||||
+11
-1
@@ -58,7 +58,17 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
/// 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<usize, IoError> {
|
||||
let frames = codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?;
|
||||
import_frames(name, codecs::decode_gif_frames(bytes, &codecs::DecodeOptions::default())?, each)
|
||||
}
|
||||
|
||||
/// Every frame of a WebP as a single-layer document, as [`import_gif_frames`] does for a GIF: an
|
||||
/// animation's frames composited onto its canvas, with their durations in milliseconds; a still
|
||||
/// WebP is one frame.
|
||||
pub fn import_webp_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result<usize, IoError> {
|
||||
import_frames(name, codecs::decode_webp_frames(bytes, &codecs::DecodeOptions::default())?, each)
|
||||
}
|
||||
|
||||
fn import_frames(name: &str, frames: Vec<codecs::AnimationFrame>, each: &mut dyn FnMut(Document, u32) -> Result<(), IoError>) -> Result<usize, IoError> {
|
||||
let n = frames.len();
|
||||
for f in frames {
|
||||
each(image_to_document(name, &f.image)?.document, f.delay_ms)?;
|
||||
|
||||
@@ -72,8 +72,8 @@ use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
pub use adjust_map::ADJUSTMENT_KEYS;
|
||||
pub use flat::{
|
||||
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency,
|
||||
tga_default_bits,
|
||||
DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames,
|
||||
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};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (animated WebP frames load into video layers, #1198; earlier: Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
||||
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
||||
@@ -29,7 +29,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
|
||||
| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) |
|
||||
| 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; palette BMPs open in Indexed Color mode |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) |
|
||||
| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) |
|
||||
| AVIF | R/W | no (#463) | optional feature, off by default | |
|
||||
| JPEG XL | R/W | inside DNG only | no | |
|
||||
| HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit |
|
||||
@@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Animated WebP: New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, composited, with the timeline rate from the durations (#1198) |
|
||||
| 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 |
|
||||
| 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) |
|
||||
| 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) |
|
||||
|
||||
Reference in New Issue
Block a user