use crate::anmf::{BlendingMethod, DisposalMethod};
use crate::build::{self, Vp8xFlags};
use crate::container::fourcc;
use crate::vp8l_encode;
use crate::{Error, WebpError, WebpMetadata};
const ANMF_HEADER_LEN: usize = 16;
const ANIM_PAYLOAD_LEN: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimFrameMode {
#[default]
Auto,
Delta,
Lossless,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnimFrame {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub x: u32,
pub y: u32,
pub duration: u32,
pub blend: BlendingMethod,
pub dispose: DisposalMethod,
pub mode: AnimFrameMode,
}
impl AnimFrame {
pub fn new(width: u32, height: u32, pixels: Vec<u8>, duration: u32) -> Self {
Self {
pixels,
width,
height,
x: 0,
y: 0,
duration,
blend: BlendingMethod::Overwrite,
dispose: DisposalMethod::None,
mode: AnimFrameMode::Lossless,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DownsampleKernel {
#[default]
Box,
Gaussian,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeltaConfig {
pub max_components: usize,
pub auto_inner_threshold_bytes: Option<usize>,
pub msssim_downsample_kernel: DownsampleKernel,
}
impl Default for DeltaConfig {
fn default() -> Self {
Self {
max_components: 8,
auto_inner_threshold_bytes: None,
msssim_downsample_kernel: DownsampleKernel::Box,
}
}
}
impl DeltaConfig {
pub fn max_components_override(mut self, n: usize) -> Self {
self.max_components = n;
self
}
pub fn auto_inner_threshold_bytes(mut self, bytes: Option<usize>) -> Self {
self.auto_inner_threshold_bytes = bytes;
self
}
pub fn msssim_downsample_kernel(mut self, kernel: DownsampleKernel) -> Self {
self.msssim_downsample_kernel = kernel;
self
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AnimEncoderOptions<'a> {
pub loop_count: u16,
pub background_rgba: [u8; 4],
pub metadata: WebpMetadata<'a>,
pub delta: DeltaConfig,
}
pub fn build_animated_webp(frames: &[AnimFrame]) -> Result<Vec<u8>, WebpError> {
build_animated_webp_with_options(frames, &AnimEncoderOptions::default())
}
pub fn build_animated_webp_with_options(
frames: &[AnimFrame],
opts: &AnimEncoderOptions<'_>,
) -> Result<Vec<u8>, WebpError> {
if frames.is_empty() {
return Err(WebpError::InvalidData);
}
let mut canvas_width = 0u32;
let mut canvas_height = 0u32;
let mut any_alpha = false;
for f in frames {
if f.width == 0 || f.height == 0 {
return Err(WebpError::InvalidData);
}
if f.x & 1 != 0 || f.y & 1 != 0 {
return Err(WebpError::InvalidData);
}
let expected = (f.width as usize)
.checked_mul(f.height as usize)
.and_then(|n| n.checked_mul(4));
if expected != Some(f.pixels.len()) {
return Err(WebpError::InvalidData);
}
let right = f.x.checked_add(f.width).ok_or(WebpError::InvalidData)?;
let bottom = f.y.checked_add(f.height).ok_or(WebpError::InvalidData)?;
canvas_width = canvas_width.max(right);
canvas_height = canvas_height.max(bottom);
if f.pixels.chunks_exact(4).any(|px| px[3] != 0xff) {
any_alpha = true;
}
}
let meta = &opts.metadata;
let flags = Vp8xFlags {
has_iccp: meta.icc.is_some(),
has_alpha: any_alpha,
has_exif: meta.exif.is_some(),
has_xmp: meta.xmp.is_some(),
has_animation: true,
};
let vp8x_payload = build::build_vp8x_chunk(canvas_width, canvas_height, flags).map_err(to_w)?;
let mut body = Vec::new();
let mut push = |fourcc, payload: &[u8]| -> Result<(), WebpError> {
let chunk = build::build_chunk(fourcc, payload).map_err(to_w)?;
body.extend_from_slice(&chunk);
Ok(())
};
push(fourcc::VP8X, &vp8x_payload)?;
if let Some(icc) = meta.icc {
push(fourcc::ICCP, icc)?;
}
push(fourcc::ANIM, &build_anim_payload(opts))?;
let mut prev_canvas = build_initial_canvas(canvas_width, canvas_height, opts.background_rgba);
let bg_rgba = opts.background_rgba;
let mut prev_disposal: Option<(u32, u32, u32, u32, DisposalMethod)> = None;
for (idx, f) in frames.iter().enumerate() {
if let Some((px, py, pw, ph, DisposalMethod::Background)) = prev_disposal {
fill_canvas_rect_in_place(&mut prev_canvas, canvas_width, px, py, pw, ph, bg_rgba);
}
let anmf_payload =
build_anmf_payload_with_prev(f, canvas_width, canvas_height, &prev_canvas, idx == 0)?;
push(fourcc::ANMF, &anmf_payload)?;
composite_frame_onto_canvas(&mut prev_canvas, canvas_width, f);
prev_disposal = Some((f.x, f.y, f.width, f.height, f.dispose));
}
if let Some(exif) = meta.exif {
push(fourcc::EXIF, exif)?;
}
if let Some(xmp) = meta.xmp {
push(fourcc::XMP, xmp)?;
}
let file_size = (body.len() as u64) + 4;
if file_size > u64::from(u32::MAX) {
return Err(WebpError::InvalidData);
}
let mut out = Vec::with_capacity(12 + body.len());
out.extend_from_slice(&fourcc::RIFF);
out.extend_from_slice(&(file_size as u32).to_le_bytes());
out.extend_from_slice(&fourcc::WEBP);
out.extend_from_slice(&body);
Ok(out)
}
fn build_initial_canvas(width: u32, height: u32, bg: [u8; 4]) -> Vec<u8> {
let pixels = (width as usize) * (height as usize);
let mut canvas = Vec::with_capacity(pixels * 4);
for _ in 0..pixels {
canvas.extend_from_slice(&bg);
}
canvas
}
fn fill_canvas_rect_in_place(
canvas: &mut [u8],
canvas_w: u32,
x: u32,
y: u32,
w: u32,
h: u32,
rgba: [u8; 4],
) {
let cw_bytes = (canvas_w as usize) * 4;
for row in 0..(h as usize) {
let off = ((y as usize) + row) * cw_bytes + (x as usize) * 4;
for col in 0..(w as usize) {
canvas[off + col * 4] = rgba[0];
canvas[off + col * 4 + 1] = rgba[1];
canvas[off + col * 4 + 2] = rgba[2];
canvas[off + col * 4 + 3] = rgba[3];
}
}
}
fn composite_frame_onto_canvas(canvas: &mut [u8], canvas_w: u32, f: &AnimFrame) {
let cw = canvas_w as usize;
let cw_bytes = cw * 4;
let fw = f.width as usize;
let fh = f.height as usize;
let fx = f.x as usize;
let fy = f.y as usize;
match f.blend {
BlendingMethod::Overwrite => {
let src_stride = fw * 4;
for row in 0..fh {
let src_off = row * src_stride;
let dst_off = (fy + row) * cw_bytes + fx * 4;
canvas[dst_off..dst_off + src_stride]
.copy_from_slice(&f.pixels[src_off..src_off + src_stride]);
}
}
BlendingMethod::AlphaBlend => {
for row in 0..fh {
for col in 0..fw {
let src_off = (row * fw + col) * 4;
let dst_off = (fy + row) * cw_bytes + (fx + col) * 4;
let sa = f.pixels[src_off + 3] as u32;
if sa == 255 {
canvas[dst_off..dst_off + 4]
.copy_from_slice(&f.pixels[src_off..src_off + 4]);
} else if sa == 0 {
} else {
let sr = f.pixels[src_off] as u32;
let sg = f.pixels[src_off + 1] as u32;
let sb = f.pixels[src_off + 2] as u32;
let dr = canvas[dst_off] as u32;
let dg = canvas[dst_off + 1] as u32;
let db = canvas[dst_off + 2] as u32;
let da = canvas[dst_off + 3] as u32;
let dst_factor = (da * (255 - sa) + 127) / 255;
let out_a = sa + dst_factor;
let out_r = (sr * sa + dr * dst_factor + out_a / 2)
.checked_div(out_a)
.unwrap_or(0);
let out_g = (sg * sa + dg * dst_factor + out_a / 2)
.checked_div(out_a)
.unwrap_or(0);
let out_b = (sb * sa + db * dst_factor + out_a / 2)
.checked_div(out_a)
.unwrap_or(0);
canvas[dst_off] = out_r.min(255) as u8;
canvas[dst_off + 1] = out_g.min(255) as u8;
canvas[dst_off + 2] = out_b.min(255) as u8;
canvas[dst_off + 3] = out_a.min(255) as u8;
}
}
}
}
}
}
fn dirty_rect_canvas_coords(
f: &AnimFrame,
canvas_w: u32,
canvas_h: u32,
prev: &[u8],
) -> Option<DirtyRect> {
let cw = canvas_w as usize;
let _ = canvas_h;
let cw_bytes = cw * 4;
let fw = f.width as usize;
let fh = f.height as usize;
let fx = f.x as usize;
let fy = f.y as usize;
let mut min_x = usize::MAX;
let mut min_y = usize::MAX;
let mut max_x = 0usize;
let mut max_y = 0usize;
let mut any_diff = false;
for row in 0..fh {
let src_row_off = row * fw * 4;
let dst_row_off = (fy + row) * cw_bytes + fx * 4;
for col in 0..fw {
let s = &f.pixels[src_row_off + col * 4..src_row_off + col * 4 + 4];
let d = &prev[dst_row_off + col * 4..dst_row_off + col * 4 + 4];
if s != d {
any_diff = true;
let cx = fx + col;
let cy = fy + row;
if cx < min_x {
min_x = cx;
}
if cy < min_y {
min_y = cy;
}
if cx > max_x {
max_x = cx;
}
if cy > max_y {
max_y = cy;
}
}
}
}
if !any_diff {
return None;
}
let aligned_min_x = min_x & !1;
let aligned_min_y = min_y & !1;
Some(DirtyRect {
x: aligned_min_x as u32,
y: aligned_min_y as u32,
w: ((max_x + 1) - aligned_min_x) as u32,
h: ((max_y + 1) - aligned_min_y) as u32,
})
}
fn extract_subrect_from_frame(f: &AnimFrame, rect: DirtyRect) -> Vec<u8> {
let fw = f.width as usize;
let fx = f.x as usize;
let fy = f.y as usize;
let rx = rect.x as usize;
let ry = rect.y as usize;
let rw = rect.w as usize;
let rh = rect.h as usize;
let mut out = Vec::with_capacity(rw * rh * 4);
for row in 0..rh {
let src_row = (ry - fy) + row;
let src_off = (src_row * fw + (rx - fx)) * 4;
out.extend_from_slice(&f.pixels[src_off..src_off + rw * 4]);
}
out
}
#[derive(Debug, Clone, Copy)]
struct DirtyRect {
x: u32,
y: u32,
w: u32,
h: u32,
}
fn build_anim_payload(opts: &AnimEncoderOptions<'_>) -> Vec<u8> {
let [r, g, b, a] = opts.background_rgba;
let mut p = Vec::with_capacity(ANIM_PAYLOAD_LEN);
p.push(b);
p.push(g);
p.push(r);
p.push(a);
p.extend_from_slice(&opts.loop_count.to_le_bytes());
p
}
fn build_anmf_payload_with_prev(
f: &AnimFrame,
canvas_w: u32,
canvas_h: u32,
prev: &[u8],
is_first_frame: bool,
) -> Result<Vec<u8>, WebpError> {
match f.mode {
AnimFrameMode::Lossless => emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels),
AnimFrameMode::Delta => {
if is_first_frame {
emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels)
} else {
let rect =
dirty_rect_canvas_coords(f, canvas_w, canvas_h, prev).unwrap_or(DirtyRect {
x: f.x,
y: f.y,
w: 2.min(f.width),
h: 2.min(f.height),
});
let sub_rgba = extract_subrect_from_frame(f, rect);
emit_dirty_anmf(f, rect, &sub_rgba)
}
}
AnimFrameMode::Auto => {
let full = emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels)?;
if is_first_frame {
return Ok(full);
}
let Some(rect) = dirty_rect_canvas_coords(f, canvas_w, canvas_h, prev) else {
let degen_rect = DirtyRect {
x: f.x,
y: f.y,
w: 2.min(f.width),
h: 2.min(f.height),
};
let sub_rgba = extract_subrect_from_frame(f, degen_rect);
let delta = emit_dirty_anmf(f, degen_rect, &sub_rgba)?;
return Ok(if delta.len() < full.len() {
delta
} else {
full
});
};
if rect.w == f.width && rect.h == f.height && rect.x == f.x && rect.y == f.y {
return Ok(full);
}
let sub_rgba = extract_subrect_from_frame(f, rect);
let delta = emit_dirty_anmf(f, rect, &sub_rgba)?;
Ok(if delta.len() < full.len() {
delta
} else {
full
})
}
}
}
fn emit_full_anmf(
f: &AnimFrame,
x: u32,
y: u32,
w: u32,
h: u32,
pixels: &[u8],
) -> Result<Vec<u8>, WebpError> {
let argb = rgba_to_argb(pixels);
let has_alpha = pixels.chunks_exact(4).any(|px| px[3] != 0xff);
let bitstream = vp8l_encode::encode_vp8l_argb_with(&argb, w, h, has_alpha)
.map_err(Error::from)
.map_err(WebpError::from)?;
let frame_data = build::build_chunk(fourcc::VP8L, &bitstream).map_err(to_w)?;
Ok(build_anmf_header_then_data(
x,
y,
w,
h,
f.duration,
f.blend,
f.dispose,
&frame_data,
))
}
fn emit_dirty_anmf(f: &AnimFrame, rect: DirtyRect, sub_rgba: &[u8]) -> Result<Vec<u8>, WebpError> {
let argb = rgba_to_argb(sub_rgba);
let has_alpha = sub_rgba.chunks_exact(4).any(|px| px[3] != 0xff);
let bitstream = vp8l_encode::encode_vp8l_argb_with(&argb, rect.w, rect.h, has_alpha)
.map_err(Error::from)
.map_err(WebpError::from)?;
let frame_data = build::build_chunk(fourcc::VP8L, &bitstream).map_err(to_w)?;
Ok(build_anmf_header_then_data(
rect.x,
rect.y,
rect.w,
rect.h,
f.duration,
BlendingMethod::Overwrite,
DisposalMethod::None,
&frame_data,
))
}
#[allow(clippy::too_many_arguments)]
fn build_anmf_header_then_data(
x: u32,
y: u32,
w: u32,
h: u32,
duration: u32,
blend: BlendingMethod,
dispose: DisposalMethod,
frame_data: &[u8],
) -> Vec<u8> {
let mut payload = Vec::with_capacity(ANMF_HEADER_LEN + frame_data.len());
push_u24_le(&mut payload, x / 2);
push_u24_le(&mut payload, y / 2);
push_u24_le(&mut payload, w - 1);
push_u24_le(&mut payload, h - 1);
push_u24_le(&mut payload, duration & 0x00FF_FFFF);
let b_bit = match blend {
BlendingMethod::AlphaBlend => 0,
BlendingMethod::Overwrite => 1,
};
let d_bit = match dispose {
DisposalMethod::None => 0,
DisposalMethod::Background => 1,
};
payload.push((b_bit << 1) | d_bit);
payload.extend_from_slice(frame_data);
payload
}
fn push_u24_le(out: &mut Vec<u8>, v: u32) {
out.push((v & 0xFF) as u8);
out.push(((v >> 8) & 0xFF) as u8);
out.push(((v >> 16) & 0xFF) as u8);
}
fn rgba_to_argb(rgba: &[u8]) -> Vec<u32> {
rgba.chunks_exact(4)
.map(|px| {
let (r, g, b, a) = (px[0] as u32, px[1] as u32, px[2] as u32, px[3] as u32);
(a << 24) | (r << 16) | (g << 8) | b
})
.collect()
}
fn to_w(_e: build::BuildError) -> WebpError {
WebpError::InvalidData
}
#[cfg(test)]
mod tests {
use super::*;
fn solid_rgba(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let mut v = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..(w * h) {
v.extend_from_slice(&color);
}
v
}
#[test]
fn empty_frames_is_invalid_data() {
assert_eq!(build_animated_webp(&[]), Err(WebpError::InvalidData));
}
#[test]
fn auto_and_delta_modes_emit_valid_files_round_127() {
for mode in [AnimFrameMode::Auto, AnimFrameMode::Delta] {
let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [1, 2, 3, 255]), 100);
f.mode = mode;
let file = build_animated_webp(&[f]).unwrap_or_else(|e| {
panic!("mode {mode:?} must build a valid file in round 127, got {e:?}")
});
assert_eq!(&file[0..4], b"RIFF");
assert_eq!(&file[8..12], b"WEBP");
let c = crate::container::parse(&file).expect("parseable container");
assert!(c.first_chunk_with_fourcc(fourcc::ANMF).is_some());
}
}
#[test]
fn dirty_rect_shrinks_anmf_payload_for_localised_change() {
let w = 16u32;
let h = 16u32;
let mut f0 = AnimFrame::new(w, h, solid_rgba(w, h, [200, 100, 50, 255]), 80);
f0.mode = AnimFrameMode::Lossless;
let mut f1_pixels = solid_rgba(w, h, [200, 100, 50, 255]);
for row in 6..10 {
for col in 6..10 {
let off = (row * w as usize + col) * 4;
f1_pixels[off] = 0;
f1_pixels[off + 1] = 0;
f1_pixels[off + 2] = 0;
f1_pixels[off + 3] = 255;
}
}
let mut f1_lossless = AnimFrame::new(w, h, f1_pixels.clone(), 80);
f1_lossless.mode = AnimFrameMode::Lossless;
let mut f1_delta = AnimFrame::new(w, h, f1_pixels.clone(), 80);
f1_delta.mode = AnimFrameMode::Delta;
let file_lossless = build_animated_webp(&[f0.clone(), f1_lossless]).unwrap();
let file_delta = build_animated_webp(&[f0, f1_delta]).unwrap();
assert!(
file_delta.len() < file_lossless.len(),
"delta-mode file ({} bytes) must beat lossless ({} bytes) on a 4×4 change",
file_delta.len(),
file_lossless.len(),
);
}
#[test]
fn auto_mode_picks_dirty_rect_on_localised_change() {
let w = 16u32;
let h = 16u32;
let mut f0 = AnimFrame::new(w, h, solid_rgba(w, h, [200, 100, 50, 255]), 80);
f0.mode = AnimFrameMode::Lossless;
let mut f1_pixels = solid_rgba(w, h, [200, 100, 50, 255]);
for row in 6..10 {
for col in 6..10 {
let off = (row * w as usize + col) * 4;
f1_pixels[off] = 0;
}
}
let mut f1_auto = AnimFrame::new(w, h, f1_pixels.clone(), 80);
f1_auto.mode = AnimFrameMode::Auto;
let mut f1_lossless = AnimFrame::new(w, h, f1_pixels, 80);
f1_lossless.mode = AnimFrameMode::Lossless;
let file_auto = build_animated_webp(&[f0.clone(), f1_auto]).unwrap();
let file_lossless = build_animated_webp(&[f0, f1_lossless]).unwrap();
assert!(
file_auto.len() <= file_lossless.len(),
"auto-mode never regresses vs lossless ({} vs {} bytes)",
file_auto.len(),
file_lossless.len(),
);
}
#[test]
fn dirty_rect_canvas_coords_covers_only_the_changed_pixels() {
let w = 8u32;
let h = 8u32;
let mut prev = solid_rgba(w, h, [0, 0, 0, 0]);
let _ = &mut prev;
let mut pixels = solid_rgba(w, h, [0, 0, 0, 0]);
pixels[(3 * 8 + 5) * 4] = 0xff;
pixels[(4 * 8 + 5) * 4 + 1] = 0xee;
let f = AnimFrame::new(w, h, pixels, 0);
let rect = dirty_rect_canvas_coords(&f, w, h, &prev).expect("change exists");
assert_eq!(rect.x % 2, 0);
assert_eq!(rect.y % 2, 0);
assert!(rect.x <= 5 && rect.x + rect.w > 5);
assert!(rect.y <= 3 && rect.y + rect.h > 4);
}
#[test]
fn dirty_rect_is_none_on_identical_frames() {
let w = 4u32;
let h = 4u32;
let pixels = solid_rgba(w, h, [1, 2, 3, 255]);
let prev = pixels.clone();
let f = AnimFrame::new(w, h, pixels, 0);
assert!(dirty_rect_canvas_coords(&f, w, h, &prev).is_none());
}
#[test]
fn pixel_length_mismatch_is_invalid_data() {
let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [0, 0, 0, 255]), 0);
f.pixels.truncate(4);
assert_eq!(build_animated_webp(&[f]), Err(WebpError::InvalidData));
}
#[test]
fn odd_offset_is_invalid_data() {
let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [0, 0, 0, 255]), 0);
f.x = 1;
assert_eq!(build_animated_webp(&[f]), Err(WebpError::InvalidData));
}
#[test]
fn output_begins_with_riff_webp_and_is_parseable() {
let f = AnimFrame::new(4, 4, solid_rgba(4, 4, [10, 20, 30, 255]), 100);
let file = build_animated_webp(&[f]).expect("build animated webp");
assert_eq!(&file[0..4], b"RIFF");
assert_eq!(&file[8..12], b"WEBP");
let c = crate::container::parse(&file).expect("parseable container");
assert!(c.first_chunk_with_fourcc(fourcc::VP8X).is_some());
assert!(c.first_chunk_with_fourcc(fourcc::ANIM).is_some());
assert!(c.first_chunk_with_fourcc(fourcc::ANMF).is_some());
}
#[test]
fn delta_config_builders_chain() {
let cfg = DeltaConfig::default()
.max_components_override(3)
.auto_inner_threshold_bytes(Some(512))
.msssim_downsample_kernel(DownsampleKernel::Gaussian);
assert_eq!(cfg.max_components, 3);
assert_eq!(cfg.auto_inner_threshold_bytes, Some(512));
assert_eq!(cfg.msssim_downsample_kernel, DownsampleKernel::Gaussian);
}
}