#![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,
/// The `ANIM` loop count: `Some(0)` loops forever. `None` without an `ANIM` chunk.
pub loop_count: Option,
/// 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 {
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 {
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 {
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, 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, 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)
}