mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 19:22:16 +00:00
* craft-video: VP8 key-frame encoder (`vp8-encode` feature) Moves effectcraft's effectcraft-vp8enc here: the lossy picture of a WebP image or animation frame, written from RFC 6386. Rate-distortion choice between the 16x16 and B_PRED intra modes, per-frame token probability updates, loop filter level from the quantizer. `vp8` now turns on `vp8-decode` and `vp8-encode`, as `vp9` does; the decoder's tests and examples require `vp8-decode`. The tests decode the encoder's frames with this crate's decoder: with the loop filter off it reproduces the encoder's reconstruction exactly. * craft-webp: animated WebP writer, lossy through craft-video (feature vp8) The VP8X/ANIM/ANMF container around frames another encoder made (lossy with an alpha plane, or lossless VP8L), and with feature vp8 whole lossy animations from RGBA frames through craft-video's VP8 key-frame encoder. --------- Co-authored-by: Justin A. Wilson <waj334@gmail.com>
259 lines
11 KiB
Rust
259 lines
11 KiB
Rust
#![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.
|
||
//!
|
||
//! Animated files: [`anim::animation`] writes the `ANIM` / `ANMF` chunks around frames another
|
||
//! encoder made; with feature `vp8`, [`anim::lossy_animation`] encodes RGBA frames with
|
||
//! `craft-video`'s VP8 encoder.
|
||
//!
|
||
//! 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.
|
||
|
||
pub mod anim;
|
||
#[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)
|
||
}
|