craft-webp: WebP container reader and writer (#958)

The WebP container code the apps wrote themselves: EffectCraft's chunk walk
(canvas, alpha, animation frames and their durations) and PhotoCraft's RIFF
writer around an encoded VP8 frame with alpha, ICC, EXIF and XMP chunks. The
codecs are not part of the crate.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:42:34 -05:00
1 parent 119415f46f
commit 01173ddad7
6 files changed
+488

No files matched your search

Generated
+8
View File
@@ -1794,6 +1794,14 @@ dependencies = [
"web-sys",
]
[[package]]
name = "craft-webp"
version = "0.1.0"
dependencies = [
"image",
"thiserror 2.0.21",
]
[[package]]
name = "craft-window"
version = "0.1.0"
+1
View File
@@ -116,6 +116,7 @@ craft-video = { path = "crates/video" }
craft-web-api = { path = "crates/web-api" }
craft-window = { path = "crates/window" }
craft-web-host = { path = "crates/web-host" }
craft-webp = { path = "crates/webp" }
egui = { version = "0.37", default-features = false }
md5 = { package = "md-5", version = "0.10", default-features = false }
rayon = "1"
+4
View File
@@ -289,6 +289,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
`window.<app>` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's
JSON control channel.
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
- [`craft-webp`](crates/webp/src/lib.rs): the WebP container, from EffectCraft (reader) and
PhotoCraft (writer): canvas size, alpha and every animation frame's timing from the chunk
headers without decoding, and the RIFF container around an encoded VP8 frame with alpha, ICC,
EXIF and XMP chunks. The VP8 and VP8L codecs are not part of it.
- [`craft-xls`](crates/xls/README.md): clean-room Excel 97-2003 workbook (`.xls`, BIFF8) reader: worksheets, cell values, formulas as A1 text (shared and array formulas included), cell formats with resolved palette colours, merged cells, column and row records, defined names, print areas and calculation settings, with limits on records, cells and text.
- [`craft-xlsx`](crates/xlsx/README.md): Excel workbooks (`.xlsx`, `.xlsm`, SpreadsheetML, transitional and strict) and binary workbooks (`.xlsb`) on `craft-opc`: the workbook part, shared strings, the styles tables and worksheet rows streamed one at a time, with limits on the package, the XML and the binary records.
- [`craft-ora`](crates/ora/README.md): OpenRaster documents (`.ora`): the ZIP archive and the `stack.xml` layer tree as written, read with bounded sizes and written. From PhotoCraft.
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "craft-webp"
description = "WebP container (RIFF) reader and writer: canvas, alpha and animation frames from the chunk headers, and the container around an encoded VP8 frame with alpha and metadata chunks."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
# Decodes the files the writer makes and writes still test files; not a product dependency.
image = { version = "0.25", default-features = false, features = ["webp"] }
+253
View File
@@ -0,0 +1,253 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! The WebP container (the RIFF layout of Google's WebP Container Specification), read and
//! written. The codecs inside it (VP8, VP8L) are not part of this crate.
//!
//! Reading: [`scan`] walks the chunk headers and reports the canvas size, whether the file has
//! alpha, and, for an animation, every frame's position, size, duration, blending and disposal
//! and the loop count, without decoding any pixels. That tells an animation from a still and
//! gives its timing and memory needs before a decoder runs.
//!
//! Writing: [`still`] wraps one encoded VP8 key frame, as the simple form when there is nothing
//! else, or the extended form (`VP8X`) with an uncompressed alpha plane and ICC, EXIF and XMP
//! chunks in the order the specification gives.
//!
//! Input is treated as hostile: every read is bounds-checked, chunk sizes are checked against the
//! file, and the number of frames recorded is capped by the caller.
#[cfg(test)]
mod tests;
/// Why a file could not be written.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The picture or one of its chunks does not fit the container.
#[error("WebP: cannot write: {0}")]
Encode(&'static str),
}
/// The largest canvas side of the extended format (24 bits, stored minus one).
pub const MAX_CANVAS: u32 = 1 << 24;
/// The largest side of a lossy (VP8) frame (14 bits).
pub const MAX_VP8_DIMENSION: u32 = 16383;
/// Whether `bytes` starts like a WebP file.
pub fn is_webp(bytes: &[u8]) -> bool {
bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP")
}
/// One frame (`ANMF` chunk) of an animation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Frame {
/// Position of the frame on the canvas (always even).
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
/// How long the frame is shown, in milliseconds, as stored. Players show very small values
/// longer; that is the caller's rule.
pub duration_ms: u32,
/// Whether the frame is alpha-blended onto the canvas (false: it replaces its rectangle).
pub blend: bool,
/// Whether the frame's rectangle is cleared to the background before the next frame.
pub dispose_to_background: bool,
}
/// What the chunk headers of a WebP file say.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Info {
/// Canvas size.
pub width: u32,
pub height: u32,
/// Whether the file says it has alpha (the `VP8X` alpha flag, or a lossless picture's alpha bit).
pub alpha: bool,
/// Whether the `VP8X` animation flag is set.
pub animated: bool,
/// The frames of an animation, in file order, up to the caller's limit. Empty for a still.
pub frames: Vec<Frame>,
/// The `ANIM` loop count: `Some(0)` loops forever. `None` without an `ANIM` chunk.
pub loop_count: Option<u16>,
/// Whether the walk reached the end of the RIFF data. When it did not (the file is cut short,
/// or the frame limit was reached), more frames may exist than [`Info::frames`] holds.
pub complete: bool,
}
impl Info {
/// Whether the file is an animation of more than one frame; anything else is shown as a still.
pub fn is_animation(&self) -> bool {
self.animated && self.frames.len() > 1
}
}
fn u24(b: &[u8], at: usize) -> Option<u32> {
match b.get(at..at.checked_add(3)?)? {
[a, b, c] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16),
_ => None,
}
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
/// The size of a lossy picture from the start of its `VP8 ` chunk: a key frame tag, the start
/// code, then 14-bit width and height.
fn vp8_size(body: &[u8]) -> Option<(u32, u32)> {
if body.first()? & 1 != 0 || body.get(3..6)? != [0x9D, 0x01, 0x2A] {
return None;
}
let dim = |at: usize| Some(u32::from(u16::from_le_bytes(body.get(at..at + 2)?.try_into().ok()?) & 0x3FFF));
Some((dim(6)?, dim(8)?))
}
/// The size and alpha bit of a lossless picture from the start of its `VP8L` chunk: the
/// signature byte, then 14-bit width and height (each minus one) and the alpha bit.
fn vp8l_size(body: &[u8]) -> Option<(u32, u32, bool)> {
if *body.first()? != 0x2F {
return None;
}
let bits = u32_at(body, 1)?;
Some(((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1, bits & (1 << 28) != 0))
}
/// Walks the chunks of a WebP file without decoding any pixels, recording at most `max_frames`
/// animation frames. `None` when `bytes` is not a WebP file or no chunk gives its size. A file
/// that breaks off later gives what was read, with [`Info::complete`] false.
pub fn scan(bytes: &[u8], max_frames: usize) -> Option<Info> {
if !is_webp(bytes) {
return None;
}
let declared_end = usize::try_from(u32_at(bytes, 4)?).ok()?.saturating_add(8);
let end = declared_end.min(bytes.len());
let mut at = 12usize;
let (mut canvas, mut alpha, mut animated, mut frames, mut loop_count, mut complete) = (None, false, false, Vec::new(), None, false);
loop {
if at >= end {
// The end of the data the RIFF header declares, unless the file stops before it.
complete = declared_end <= bytes.len();
break;
}
let Some(head) = at.checked_add(8).filter(|&e| e <= end).and_then(|e| bytes.get(at..e)) else { break };
let Some(size) = u32_at(head, 4).and_then(|s| usize::try_from(s).ok()) else { break };
let body_start = at + 8;
// A chunk cut short by the end of the file still has its header fields read.
let body_end = body_start.saturating_add(size);
let body = bytes.get(body_start..body_end.min(end)).unwrap_or_default();
match head.get(..4) {
Some(b"VP8X") => {
let Some(&flags) = body.first() else { break };
let (Some(w), Some(h)) = (u24(body, 4), u24(body, 7)) else { break };
canvas = Some((w + 1, h + 1));
alpha = flags & 0x10 != 0;
animated = flags & 0x02 != 0;
}
Some(b"ANIM") => loop_count = body.get(4..6).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes),
Some(b"ANMF") => {
if frames.len() >= max_frames {
break;
}
let (Some(x), Some(y), Some(w), Some(h), Some(duration_ms), Some(&flags)) =
(u24(body, 0), u24(body, 3), u24(body, 6), u24(body, 9), u24(body, 12), body.get(15))
else {
break;
};
frames.push(Frame {
x: x * 2,
y: y * 2,
width: w + 1,
height: h + 1,
duration_ms,
blend: flags & 0x02 == 0,
dispose_to_background: flags & 0x01 != 0,
});
}
// The simple forms have no canvas header: the picture's own size is the canvas.
Some(b"VP8 ") if canvas.is_none() => canvas = vp8_size(body),
Some(b"VP8L") if canvas.is_none() => {
if let Some((w, h, a)) = vp8l_size(body) {
canvas = Some((w, h));
alpha = a;
}
}
_ => {}
}
if body_end > end {
break;
}
// Chunks are padded to an even length.
at = body_end.saturating_add(size & 1);
}
let (width, height) = canvas?;
Some(Info { width, height, alpha, animated, frames, loop_count, complete })
}
/// Appends a RIFF chunk: FourCC, little-endian size, payload, padded to an even length.
fn chunk(out: &mut Vec<u8>, fourcc: &[u8; 4], payload: &[u8]) -> Result<(), Error> {
let size = u32::try_from(payload.len()).map_err(|_| Error::Encode("a chunk is larger than 4 GiB"))?;
out.extend_from_slice(fourcc);
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(payload);
if payload.len() % 2 == 1 {
out.push(0);
}
Ok(())
}
/// What goes around the picture of a still WebP. Everything `None` gives the simple form.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Extras<'a> {
/// The alpha plane, one byte a pixel, top row first (`width × height` bytes). Stored uncompressed.
pub alpha: Option<&'a [u8]>,
/// An ICC profile.
pub icc: Option<&'a [u8]>,
/// EXIF, as a TIFF structure.
pub exif: Option<&'a [u8]>,
/// XMP, as its XML text.
pub xmp: Option<&'a [u8]>,
}
/// The WebP container around one encoded VP8 key frame of `width` × `height`: the simple form
/// when `extras` is empty, else the extended form (`VP8X`) with the alpha plane and the metadata
/// chunks in the specified order (ICCP, ALPH, VP8, EXIF, XMP).
pub fn still(width: u32, height: u32, vp8_frame: &[u8], extras: &Extras) -> Result<Vec<u8>, Error> {
if width == 0 || height == 0 || width > MAX_VP8_DIMENSION || height > MAX_VP8_DIMENSION {
return Err(Error::Encode("a lossy picture is 1 to 16383 pixels a side"));
}
if extras.alpha.is_some_and(|a| a.len() as u64 != u64::from(width) * u64::from(height)) {
return Err(Error::Encode("the alpha plane does not match the picture size"));
}
let mut body = b"WEBP".to_vec();
let Extras { alpha, icc, exif, xmp } = *extras;
if alpha.is_some() || icc.is_some() || exif.is_some() || xmp.is_some() {
let flags = [(icc.is_some(), 0x20u8), (alpha.is_some(), 0x10), (exif.is_some(), 0x08), (xmp.is_some(), 0x04)]
.iter()
.filter(|(on, _)| *on)
.fold(0, |f, (_, bit)| f | bit);
let mut vp8x = vec![flags, 0, 0, 0];
vp8x.extend_from_slice(&(width - 1).to_le_bytes()[..3]);
vp8x.extend_from_slice(&(height - 1).to_le_bytes()[..3]);
chunk(&mut body, b"VP8X", &vp8x)?;
if let Some(icc) = icc {
chunk(&mut body, b"ICCP", icc)?;
}
if let Some(a) = alpha {
// Header byte: reserved, no pre-processing, no filtering, no compression.
let mut alph = Vec::with_capacity(a.len() + 1);
alph.push(0);
alph.extend_from_slice(a);
chunk(&mut body, b"ALPH", &alph)?;
}
}
chunk(&mut body, b"VP8 ", vp8_frame)?;
if let Some(exif) = exif {
chunk(&mut body, b"EXIF", exif)?;
}
if let Some(xmp) = xmp {
chunk(&mut body, b"XMP ", xmp)?;
}
let size = u32::try_from(body.len()).map_err(|_| Error::Encode("the file is larger than 4 GiB"))?;
let mut out = Vec::with_capacity(body.len() + 8);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&body);
Ok(out)
}
+204
View File
@@ -0,0 +1,204 @@
//! The animation header tests follow EffectCraft's; the writer is checked by decoding its output
//! with the `image` crate.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use image::{ExtendedColorType, ImageDecoder, ImageEncoder, codecs::webp::WebPDecoder, codecs::webp::WebPEncoder};
use super::*;
fn chunk_of(fourcc: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
chunk(&mut out, fourcc, body).unwrap();
out
}
fn u24_bytes(v: u32) -> [u8; 3] {
[v as u8, (v >> 8) as u8, (v >> 16) as u8]
}
/// A lossless still of one colour, written by `image`.
fn lossless(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
WebPEncoder::new_lossless(&mut out).write_image(&rgba.repeat((w * h) as usize), w, h, ExtendedColorType::Rgba8).unwrap();
out
}
/// The `VP8L` chunk of a lossless still.
fn vp8l_chunk(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let file = lossless(w, h, rgba);
let at = file.windows(4).position(|c| c == b"VP8L").unwrap();
file[at..].to_vec()
}
/// An animated WebP: one solid frame for each (colour, duration in ms).
fn animated(w: u32, h: u32, frames: &[([u8; 4], u32)], loops: u16) -> Vec<u8> {
let mut body = b"WEBP".to_vec();
let mut vp8x = vec![0x12, 0, 0, 0];
vp8x.extend(u24_bytes(w - 1));
vp8x.extend(u24_bytes(h - 1));
body.extend(chunk_of(b"VP8X", &vp8x));
body.extend(chunk_of(b"ANIM", &[0, 0, 0, 0, loops as u8, (loops >> 8) as u8]));
for (rgba, ms) in frames {
let mut anmf = Vec::new();
anmf.extend([0; 6]);
anmf.extend(u24_bytes(w - 1));
anmf.extend(u24_bytes(h - 1));
anmf.extend(u24_bytes(*ms));
anmf.push(0x02);
anmf.extend(vp8l_chunk(w, h, *rgba));
body.extend(chunk_of(b"ANMF", &anmf));
}
let mut out = b"RIFF".to_vec();
out.extend((body.len() as u32).to_le_bytes());
out.extend(body);
out
}
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const HALF: [u8; 4] = [0, 0, 255, 128];
#[test]
fn an_animation_reports_canvas_frames_and_loop_count() {
let bytes = animated(4, 2, &[(RED, 50), (GREEN, 100), (HALF, 50)], 3);
assert!(is_webp(&bytes));
let info = scan(&bytes, 100).unwrap();
assert_eq!((info.width, info.height, info.alpha, info.animated, info.loop_count, info.complete), (4, 2, true, true, Some(3), true));
assert!(info.is_animation());
assert_eq!(info.frames.iter().map(|f| f.duration_ms).collect::<Vec<_>>(), [50, 100, 50]);
assert!(info.frames.iter().all(|f| (f.x, f.y, f.width, f.height, f.blend, f.dispose_to_background) == (0, 0, 4, 2, false, false)));
// The `image` crate's decoder agrees it is an animation of this canvas.
let d = WebPDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
assert!(d.has_animation());
assert_eq!(d.dimensions(), (4, 2));
}
#[test]
fn frame_fields_are_decoded() {
let mut bytes = animated(8, 8, &[(RED, 0), (GREEN, 0xABCDE)], 0);
let at = bytes.windows(4).position(|c| c == b"ANMF").unwrap() + 8;
bytes[at..at + 3].copy_from_slice(&u24_bytes(1));
bytes[at + 3..at + 6].copy_from_slice(&u24_bytes(2));
bytes[at + 15] = 0x01;
let info = scan(&bytes, 100).unwrap();
let f = info.frames[0];
assert_eq!((f.x, f.y, f.blend, f.dispose_to_background, f.duration_ms), (2, 4, true, true, 0));
assert_eq!((info.frames[1].duration_ms, info.loop_count), (0xABCDE, Some(0)));
}
#[test]
fn stills_report_their_size_and_are_not_animations() {
// Lossless simple form: the size and alpha bit come from the VP8L header.
let info = scan(&lossless(5, 3, HALF), 100).unwrap();
assert_eq!((info.width, info.height, info.alpha, info.animated, info.is_animation(), info.complete), (5, 3, true, false, false, true));
assert_eq!(scan(&lossless(300, 200, RED), 100).map(|i| (i.width, i.height)), Some((300, 200)));
// Lossy simple form: the size comes from the VP8 key frame header.
let frame = [0x10, 0x02, 0x00, 0x9D, 0x01, 0x2A, 0x40, 0x01, 0xF0, 0x00];
let file = still(320, 240, &frame, &Extras::default()).unwrap();
let info = scan(&file, 100).unwrap();
assert_eq!((info.width, info.height, info.alpha, info.animated), (320, 240, false, false));
// The animation flag with a single frame is still shown as a still.
assert!(!scan(&animated(2, 2, &[(RED, 10)], 0), 100).unwrap().is_animation());
assert_eq!(scan(b"RIFF\x04\x00\x00\x00WEBP", 100), None);
assert_eq!(scan(b"\x89PNG\r\n\x1a\n....", 100), None);
assert!(!is_webp(b"RIFF....WAVE") && !is_webp(b"RIFF"));
}
#[test]
fn the_frame_limit_stops_the_walk() {
let bytes = animated(2, 2, &[(RED, 10), (GREEN, 20), (RED, 30)], 0);
let info = scan(&bytes, 2).unwrap();
assert_eq!((info.frames.len(), info.complete), (2, false));
let info = scan(&bytes, 3).unwrap();
assert_eq!((info.frames.len(), info.complete), (3, true));
assert_eq!(scan(&bytes, 0).unwrap().frames.len(), 0);
}
#[test]
fn a_cut_or_damaged_file_gives_what_was_read() {
let bytes = animated(4, 2, &[(RED, 50), (GREEN, 100), (HALF, 50)], 0);
let whole = scan(&bytes, 100).unwrap();
for cut in 0..bytes.len() {
if let Some(info) = scan(&bytes[..cut], 100) {
assert!(!info.complete && info.frames.len() <= whole.frames.len(), "cut {cut}");
assert_eq!((info.width, info.height), (4, 2));
}
}
for i in 0..bytes.len() {
for v in [0u8, 1, 0x7F, 0xFF] {
let mut b = bytes.clone();
b[i] = v;
if let Some(info) = scan(&b, 100) {
assert!(info.frames.len() <= 100);
}
}
}
// A cut inside the last frame's picture keeps that frame's header.
assert_eq!(scan(&bytes[..bytes.len() - 3], 100).map(|i| (i.frames.len(), i.complete)), Some((3, false)));
// A chunk size running past the file ends the walk.
let mut far = bytes.clone();
let at = far.windows(4).position(|c| c == b"ANIM").unwrap();
far[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(scan(&far, 100).map(|i| (i.frames.len(), i.complete)), Some((0, false)));
}
/// A stand-in for an encoded VP8 frame: a real key frame header for 16 × 16 (frame tag, start
/// code, sizes) followed by filler. The container tests read headers only and never decode it.
const VP8_FRAME: &[u8] =
&[0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, 0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x1F, 0x25, 0xA4, 0x00, 0x03, 0x70, 0x00, 0xFE, 0xFB, 0x94, 0x00, 0x00];
#[test]
fn the_simple_form_is_one_chunk() {
let file = still(16, 16, VP8_FRAME, &Extras::default()).unwrap();
assert_eq!(&file[..4], b"RIFF");
assert_eq!(u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize, file.len() - 8);
assert_eq!(&file[8..16], b"WEBPVP8 ");
assert_eq!(&file[20..], VP8_FRAME);
// An odd payload is padded to an even length, and the size field keeps the real length.
let odd = still(16, 16, &VP8_FRAME[..23], &Extras::default()).unwrap();
assert_eq!((odd.len(), u32::from_le_bytes(odd[16..20].try_into().unwrap())), (20 + 24, 23));
}
#[test]
fn the_extended_form_orders_its_chunks() {
let alpha = vec![128u8; 256];
let extras = Extras { alpha: Some(&alpha), icc: Some(b"icc"), exif: Some(b"exif!"), xmp: Some(b"<x/>") };
let file = still(16, 16, VP8_FRAME, &extras).unwrap();
let mut at = 12;
let mut seen = Vec::new();
while at < file.len() {
let size = u32::from_le_bytes(file[at + 4..at + 8].try_into().unwrap()) as usize;
seen.push((String::from_utf8_lossy(&file[at..at + 4]).into_owned(), size));
at += 8 + size + (size & 1);
}
assert_eq!(at, file.len());
assert_eq!(
seen,
[("VP8X".into(), 10), ("ICCP".into(), 3), ("ALPH".into(), 257), ("VP8 ".into(), VP8_FRAME.len()), ("EXIF".into(), 5), ("XMP ".into(), 4)]
);
// Flags: ICC, alpha, EXIF, XMP; canvas 16 × 16 stored minus one.
assert_eq!(&file[20..30], &[0x3C, 0, 0, 0, 15, 0, 0, 15, 0, 0]);
let info = scan(&file, 10).unwrap();
assert_eq!((info.width, info.height, info.alpha, info.animated, info.complete), (16, 16, true, false, true));
// Only the flags of what is present are set.
let file = still(16, 16, VP8_FRAME, &Extras { xmp: Some(b"<x/>"), ..Extras::default() }).unwrap();
assert_eq!(file[20], 0x04);
// The `image` crate reads the container: size, alpha and the profile.
let file = still(16, 16, VP8_FRAME, &Extras { alpha: Some(&alpha), icc: Some(b"icc"), ..Extras::default() }).unwrap();
let mut d = WebPDecoder::new(std::io::Cursor::new(&file)).unwrap();
assert_eq!((d.dimensions(), d.color_type().has_alpha()), ((16, 16), true));
assert_eq!(d.icc_profile().unwrap().as_deref(), Some(&b"icc"[..]));
}
#[test]
fn the_writer_refuses_what_does_not_fit() {
let e = Extras::default();
assert!(still(0, 16, VP8_FRAME, &e).is_err());
assert!(still(16, 0, VP8_FRAME, &e).is_err());
assert!(still(MAX_VP8_DIMENSION + 1, 16, VP8_FRAME, &e).is_err());
assert!(still(MAX_VP8_DIMENSION, MAX_VP8_DIMENSION, VP8_FRAME, &e).is_ok());
assert_eq!(
still(16, 16, VP8_FRAME, &Extras { alpha: Some(&[0; 255]), ..e }),
Err(Error::Encode("the alpha plane does not match the picture size"))
);
}