tiny-webp 0.1.1

Lossy WebP encoder in Rust with a cwebp-shaped command line
Documentation
//! Encodes RGB and RGBA pixel buffers as lossy WebP files.
//!
//! Version 0.1.0 writes one VP8 key frame in a RIFF WebP container. It uses DC
//! prediction, the Y2 transform path, one token partition, and a fixed
//! quantizer chosen from [`Options::quality`]. The encoder keeps exact alpha
//! from non-opaque RGBA input in an uncompressed `ALPH` chunk.
//!
//! The library is `no_std` and allocates through `alloc`. It reads and writes
//! byte slices in memory. Callers own file and stream I/O.
//!
//! [`Options`] is `#[non_exhaustive]`, so build one from its default and
//! assign the fields that change.
//!
//! ```
//! let mut opts = tiny_webp::Options::default();
//! opts.quality = 90;
//! opts.alpha = tiny_webp::Alpha::Discard;
//!
//! let rgba = [128u8; 16];
//! let webp = tiny_webp::encode_rgba(&rgba, 2, 2, &opts)?;
//! assert_eq!(&webp[..4], b"RIFF");
//! # Ok::<(), tiny_webp::Error>(())
//! ```

#![no_std]
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![deny(clippy::float_arithmetic)]

extern crate alloc;
#[cfg(test)]
extern crate std;

mod bool_coder;
mod color;
mod frame;
#[cfg(test)]
#[path = "../fixtures/generator.rs"]
mod generator;
#[cfg(test)]
#[path = "../fixtures/png_writer.rs"]
mod png_writer;
pub(crate) mod prediction;
mod quantize;
mod residual;
mod transform;

use alloc::vec::Vec;
use core::fmt;

/// Largest width or height a WebP bitstream carries, in pixels.
///
/// The VP8 key frame header spends 14 bits on each dimension, so each side
/// runs from 1 to this value.
pub const MAX_DIMENSION: u32 = 16383;

/// What the encoder does with the alpha plane of an RGBA input.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Alpha {
    /// Keep the plane and write it so it decodes byte for byte.
    #[default]
    Lossless,
    /// Drop the plane and write an opaque image.
    Discard,
}

/// The loop filter strength the encoder signals in the frame header.
///
/// A key frame encoder signals the filter and leaves the decoder to run it.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Filter {
    /// Signal level 0 and sharpness 0 at version 0.1.0.
    #[default]
    Auto,
    /// Signal an exact level and sharpness.
    Level {
        /// Filter level, saturated at 63.
        level: u8,
        /// Filter sharpness, saturated at 7.
        sharpness: u8,
    },
    /// Signal level 0, which leaves the decoder's filter idle.
    Off,
}

/// Settings for one encode.
///
/// In version 0.1.0, [`Options::default`] uses quality 75 and keeps alpha
/// lossless. Its [`Filter::Auto`] setting signals loop filter level 0. The
/// default writes `VP8X` only when transparency needs it or
/// [`Options::force_vp8x`] requests it.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Options {
    /// Quality on cwebp's 0 to 100 scale, with larger values saturated at 100.
    pub quality: u8,
    /// Treatment of the alpha plane.
    pub alpha: Alpha,
    /// Write a `VP8X` container header even for an opaque image.
    ///
    /// An opaque image otherwise gets a bare `VP8 ` chunk. Set this for a
    /// consumer that wants one container shape for every output.
    pub force_vp8x: bool,
    /// Loop filter strength.
    pub filter: Filter,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            quality: 75,
            alpha: Alpha::Lossless,
            force_vp8x: false,
            filter: Filter::Auto,
        }
    }
}

/// A call the encoder refused.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Error {
    /// A width or a height outside 1 through [`MAX_DIMENSION`].
    DimensionsOutOfRange {
        /// The width the caller passed.
        width: u32,
        /// The height the caller passed.
        height: u32,
    },
    /// A pixel buffer whose length disagrees with the dimensions.
    BufferSizeMismatch {
        /// Bytes the dimensions ask for.
        expected: usize,
        /// Bytes the caller passed.
        actual: usize,
    },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::DimensionsOutOfRange { width, height } => {
                write!(
                    f,
                    "dimensions {width}x{height} fall outside 1..={MAX_DIMENSION}"
                )
            }
            Self::BufferSizeMismatch { expected, actual } => {
                write!(
                    f,
                    "buffer holds {actual} bytes and the dimensions ask for {expected}"
                )
            }
        }
    }
}

impl core::error::Error for Error {}

/// Encodes an RGBA buffer into the bytes of a WebP file.
///
/// `rgba` holds `width * height` pixels in row order, four bytes each, in the
/// order red, green, blue, alpha.
///
/// # Errors
///
/// [`Error::DimensionsOutOfRange`] when a side falls outside 1 through
/// [`MAX_DIMENSION`], carrying the values passed. [`Error::BufferSizeMismatch`]
/// when `rgba` runs to a length other than `width * height * 4`. The dimension
/// check runs first, so a call that gets both wrong reports the dimensions.
pub fn encode_rgba(rgba: &[u8], width: u32, height: u32, opts: &Options) -> Result<Vec<u8>, Error> {
    check(rgba.len(), width, height, 4)?;
    Ok(frame::encode(
        rgba,
        width as usize,
        height as usize,
        4,
        quantize::quantizer_index(opts.quality),
        opts,
    )
    .webp)
}

/// Encodes an RGB buffer into the bytes of a WebP file.
///
/// `rgb` holds `width * height` pixels in row order, three bytes each, in the
/// order red, green, blue. The output is opaque, and [`Options::alpha`] has
/// nothing to act on.
///
/// # Errors
///
/// The same two errors as [`encode_rgba`], in the same order, with three
/// bytes per pixel in the length the buffer check asks for.
pub fn encode_rgb(rgb: &[u8], width: u32, height: u32, opts: &Options) -> Result<Vec<u8>, Error> {
    check(rgb.len(), width, height, 3)?;
    Ok(frame::encode(
        rgb,
        width as usize,
        height as usize,
        3,
        quantize::quantizer_index(opts.quality),
        opts,
    )
    .webp)
}

/// Holds the entry-point checks in one place so both spell the same order.
fn check(actual: usize, width: u32, height: u32, bytes_per_pixel: u64) -> Result<(), Error> {
    if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
        return Err(Error::DimensionsOutOfRange { width, height });
    }
    // 16383 * 16383 * 4 fits in usize on every supported target.
    let expected = (u64::from(width) * u64::from(height) * bytes_per_pixel) as usize;
    if actual != expected {
        return Err(Error::BufferSizeMismatch { expected, actual });
    }
    Ok(())
}