use crate::error::{Result, WebpError as Error};
use crate::riff::WebpMetadata;
use crate::vp8l::encode_vp8l_argb;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AnimFrameMode {
Lossless,
Lossy,
#[default]
Auto,
}
#[derive(Clone, Debug)]
pub struct AnimEncoderOptions<'a> {
pub mode: AnimFrameMode,
pub lossy_quality: f32,
pub metadata: WebpMetadata<'a>,
}
impl<'a> Default for AnimEncoderOptions<'a> {
fn default() -> Self {
Self {
mode: AnimFrameMode::default(),
lossy_quality: 75.0,
metadata: WebpMetadata::default(),
}
}
}
#[derive(Clone)]
pub struct AnimFrame<'a> {
pub width: u32,
pub height: u32,
pub x_offset: u32,
pub y_offset: u32,
pub duration_ms: u32,
pub blend: bool,
pub dispose_to_background: bool,
pub rgba: &'a [u8],
}
pub fn build_animated_webp(
canvas_w: u32,
canvas_h: u32,
background_bgra: [u8; 4],
loop_count: u16,
frames: &[AnimFrame<'_>],
) -> Result<Vec<u8>> {
build_animated_webp_with_options(
canvas_w,
canvas_h,
background_bgra,
loop_count,
frames,
AnimEncoderOptions {
mode: AnimFrameMode::Lossless,
..AnimEncoderOptions::default()
},
)
}
pub fn build_animated_webp_with_options(
canvas_w: u32,
canvas_h: u32,
background_bgra: [u8; 4],
loop_count: u16,
frames: &[AnimFrame<'_>],
options: AnimEncoderOptions<'_>,
) -> Result<Vec<u8>> {
if canvas_w == 0 || canvas_h == 0 {
return Err(Error::invalid("animated WebP: zero canvas size"));
}
if canvas_w > 16384 || canvas_h > 16384 {
return Err(Error::invalid("animated WebP: canvas exceeds 16384 px"));
}
if frames.is_empty() {
return Err(Error::invalid("animated WebP: needs at least one frame"));
}
let mut any_frame_has_alpha = false;
let mut anmf_payloads: Vec<Vec<u8>> = Vec::with_capacity(frames.len());
for f in frames {
if f.width == 0 || f.height == 0 {
return Err(Error::invalid("animated WebP: zero frame size"));
}
if f.x_offset
.checked_add(f.width)
.map(|r| r > canvas_w)
.unwrap_or(true)
|| f.y_offset
.checked_add(f.height)
.map(|r| r > canvas_h)
.unwrap_or(true)
{
return Err(Error::invalid(
"animated WebP: frame bbox extends past canvas",
));
}
if f.rgba.len() != (f.width as usize) * (f.height as usize) * 4 {
return Err(Error::invalid(
"animated WebP: frame rgba length mismatch frame_w*frame_h*4",
));
}
if f.duration_ms > 0x00FF_FFFF {
return Err(Error::invalid(
"animated WebP: duration_ms exceeds 24-bit field",
));
}
if !any_frame_has_alpha && f.rgba.chunks_exact(4).any(|px| px[3] != 0xff) {
any_frame_has_alpha = true;
}
let chosen = encode_one_anmf_image(f, &options)?;
let nested_capacity = chosen.iter().map(|c| 8 + c.payload.len()).sum::<usize>();
let mut payload = Vec::with_capacity(16 + nested_capacity);
write_u24_le(&mut payload, (f.x_offset / 2) & 0x00FF_FFFF);
write_u24_le(&mut payload, (f.y_offset / 2) & 0x00FF_FFFF);
write_u24_le(&mut payload, (f.width - 1) & 0x00FF_FFFF);
write_u24_le(&mut payload, (f.height - 1) & 0x00FF_FFFF);
write_u24_le(&mut payload, f.duration_ms & 0x00FF_FFFF);
let mut flags: u8 = 0;
if !f.blend {
flags |= 0x01;
}
if f.dispose_to_background {
flags |= 0x02;
}
payload.push(flags);
for sub in &chosen {
write_chunk(&mut payload, &sub.fourcc, &sub.payload);
}
anmf_payloads.push(payload);
}
let mut body: Vec<u8> = Vec::new();
let mut flags: u8 = 0x02; if any_frame_has_alpha {
flags |= 0x10;
}
if options.metadata.icc.is_some() {
flags |= 0x20;
}
if options.metadata.exif.is_some() {
flags |= 0x08;
}
if options.metadata.xmp.is_some() {
flags |= 0x04;
}
let vp8x = vp8x_payload(flags, canvas_w, canvas_h);
write_chunk(&mut body, b"VP8X", &vp8x);
if let Some(icc) = options.metadata.icc {
write_chunk(&mut body, b"ICCP", icc);
}
let mut anim = [0u8; 6];
anim[0] = background_bgra[0];
anim[1] = background_bgra[1];
anim[2] = background_bgra[2];
anim[3] = background_bgra[3];
anim[4] = (loop_count & 0xff) as u8;
anim[5] = ((loop_count >> 8) & 0xff) as u8;
write_chunk(&mut body, b"ANIM", &anim);
for payload in &anmf_payloads {
write_chunk(&mut body, b"ANMF", payload);
}
if let Some(exif) = options.metadata.exif {
write_chunk(&mut body, b"EXIF", exif);
}
if let Some(xmp) = options.metadata.xmp {
write_chunk(&mut body, b"XMP ", xmp);
}
let riff_size = 4 + body.len();
let mut out = Vec::with_capacity(8 + riff_size);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(riff_size as u32).to_le_bytes());
out.extend_from_slice(b"WEBP");
out.extend_from_slice(&body);
Ok(out)
}
struct AnmfSubChunk {
fourcc: [u8; 4],
payload: Vec<u8>,
}
fn encode_one_anmf_image(
f: &AnimFrame<'_>,
options: &AnimEncoderOptions<'_>,
) -> Result<Vec<AnmfSubChunk>> {
let lossless: Option<Vec<AnmfSubChunk>> = match options.mode {
AnimFrameMode::Lossy => None,
AnimFrameMode::Lossless | AnimFrameMode::Auto => Some(encode_lossless_anmf(f)?),
};
let lossy: Option<Vec<AnmfSubChunk>> = match options.mode {
AnimFrameMode::Lossless => None,
AnimFrameMode::Lossy | AnimFrameMode::Auto => encode_lossy_anmf(f, options.lossy_quality)?,
};
match (lossless, lossy) {
(None, None) => unreachable!("at least one mode must produce a candidate"),
(Some(l), None) => Ok(l),
(None, Some(l)) => Ok(l),
(Some(ll), Some(ly)) => {
let cost = |subs: &[AnmfSubChunk]| -> usize {
subs.iter()
.map(|s| 8 + s.payload.len() + (s.payload.len() & 1))
.sum()
};
if cost(&ly) < cost(&ll) {
Ok(ly)
} else {
Ok(ll)
}
}
}
}
fn encode_lossless_anmf(f: &AnimFrame<'_>) -> Result<Vec<AnmfSubChunk>> {
let mut pixels = Vec::with_capacity((f.width as usize) * (f.height as usize));
let mut has_alpha = false;
for px in f.rgba.chunks_exact(4) {
let r = px[0] as u32;
let g = px[1] as u32;
let b = px[2] as u32;
let a = px[3] as u32;
if a != 0xff {
has_alpha = true;
}
pixels.push((a << 24) | (r << 16) | (g << 8) | b);
}
let vp8l_bytes = encode_vp8l_argb(f.width, f.height, &pixels, has_alpha)?;
Ok(vec![AnmfSubChunk {
fourcc: *b"VP8L",
payload: vp8l_bytes,
}])
}
fn encode_lossy_anmf(f: &AnimFrame<'_>, quality: f32) -> Result<Option<Vec<AnmfSubChunk>>> {
if f.width == 0 || f.height == 0 {
return Ok(None);
}
let qindex = crate::encoder_vp8::quality_to_qindex(quality);
let w = f.width as usize;
let h = f.height as usize;
let mut alpha_plane: Vec<u8> = Vec::with_capacity(w * h);
let (y_plane, u_plane, v_plane) =
crate::encoder_vp8::rgba_rows_to_yuv420(w, h, w * 4, f.rgba, &mut alpha_plane);
let has_alpha = alpha_plane.iter().any(|&a| a != 0xff);
let vp8_frame = oxideav_vp8::Vp8Frame {
width: f.width,
height: f.height,
pts: None,
y: y_plane,
u: u_plane,
v: v_plane,
y_stride: f.width,
uv_stride: (f.width + 1) / 2,
};
let vp8_bytes =
match oxideav_vp8::encoder::encode_vp8_keyframe(f.width, f.height, qindex, &vp8_frame) {
Ok(b) => b,
Err(_) => return Ok(None),
};
let mut subs: Vec<AnmfSubChunk> = Vec::with_capacity(2);
if has_alpha {
let alph = crate::encoder_vp8::encode_alph_chunk(f.width, f.height, &alpha_plane)
.map_err(|e| Error::invalid(format!("animated WebP: ALPH encode: {e}")))?;
let mut alph_payload = Vec::with_capacity(1 + alph.payload.len());
alph_payload.push(alph.header_byte);
alph_payload.extend_from_slice(&alph.payload);
subs.push(AnmfSubChunk {
fourcc: *b"ALPH",
payload: alph_payload,
});
}
subs.push(AnmfSubChunk {
fourcc: *b"VP8 ",
payload: vp8_bytes,
});
Ok(Some(subs))
}
fn vp8x_payload(flags: u8, canvas_w: u32, canvas_h: u32) -> [u8; 10] {
let mut out = [0u8; 10];
out[0] = flags;
let w_minus_1 = canvas_w.saturating_sub(1) & 0x00FF_FFFF;
let h_minus_1 = canvas_h.saturating_sub(1) & 0x00FF_FFFF;
out[4] = (w_minus_1 & 0xff) as u8;
out[5] = ((w_minus_1 >> 8) & 0xff) as u8;
out[6] = ((w_minus_1 >> 16) & 0xff) as u8;
out[7] = (h_minus_1 & 0xff) as u8;
out[8] = ((h_minus_1 >> 8) & 0xff) as u8;
out[9] = ((h_minus_1 >> 16) & 0xff) as u8;
out
}
fn write_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 write_chunk(out: &mut Vec<u8>, fourcc: &[u8; 4], payload: &[u8]) {
out.extend_from_slice(fourcc);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
if payload.len() & 1 == 1 {
out.push(0);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn solid_frame(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut v = Vec::with_capacity((w as usize) * (h as usize) * 4);
for _ in 0..(w * h) {
v.extend_from_slice(&rgba);
}
v
}
#[test]
fn build_animated_emits_vp8x_anim_anmf_in_order() {
let f0 = solid_frame(8, 8, [0xff, 0, 0, 0xff]);
let f1 = solid_frame(8, 8, [0, 0xff, 0, 0xff]);
let frames = [
AnimFrame {
width: 8,
height: 8,
x_offset: 0,
y_offset: 0,
duration_ms: 100,
blend: false,
dispose_to_background: false,
rgba: &f0,
},
AnimFrame {
width: 8,
height: 8,
x_offset: 0,
y_offset: 0,
duration_ms: 200,
blend: false,
dispose_to_background: false,
rgba: &f1,
},
];
let out = build_animated_webp(8, 8, [0; 4], 0, &frames).expect("build");
assert_eq!(&out[0..4], b"RIFF");
assert_eq!(&out[8..12], b"WEBP");
assert_eq!(&out[12..16], b"VP8X");
assert_ne!(out[20] & 0x02, 0, "ANIM flag must be set in VP8X");
let vp8x_chunk_len = u32::from_le_bytes([out[16], out[17], out[18], out[19]]) as usize;
let anim_off = 12 + 8 + vp8x_chunk_len + (vp8x_chunk_len & 1);
assert_eq!(&out[anim_off..anim_off + 4], b"ANIM");
let anim_chunk_len = u32::from_le_bytes([
out[anim_off + 4],
out[anim_off + 5],
out[anim_off + 6],
out[anim_off + 7],
]) as usize;
let anmf0_off = anim_off + 8 + anim_chunk_len + (anim_chunk_len & 1);
assert_eq!(&out[anmf0_off..anmf0_off + 4], b"ANMF");
}
#[test]
fn rejects_oversized_frame_bbox() {
let f = solid_frame(8, 8, [0; 4]);
let frames = [AnimFrame {
width: 8,
height: 8,
x_offset: 4,
y_offset: 4,
duration_ms: 0,
blend: false,
dispose_to_background: false,
rgba: &f,
}];
let r = build_animated_webp(8, 8, [0; 4], 0, &frames);
assert!(r.is_err(), "expected oversized-bbox to be rejected");
}
#[test]
fn auto_mode_picks_smaller_of_the_two_candidates() {
let w = 96u32;
let h = 96u32;
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
let mut s = y.wrapping_mul(0x9E37_79B9) ^ x.wrapping_mul(0x85EB_CA77);
s ^= s.wrapping_shr(13);
s = s.wrapping_mul(0xC2B2_AE35);
s ^= s.wrapping_shr(16);
rgba[i] = ((s >> 0) & 0xff) as u8;
rgba[i + 1] = ((s >> 8) & 0xff) as u8;
rgba[i + 2] = ((s >> 16) & 0xff) as u8;
rgba[i + 3] = 0xff;
}
}
let frames = [AnimFrame {
width: w,
height: h,
x_offset: 0,
y_offset: 0,
duration_ms: 50,
blend: false,
dispose_to_background: false,
rgba: &rgba,
}];
let lossless = build_animated_webp_with_options(
w,
h,
[0; 4],
0,
&frames,
AnimEncoderOptions {
mode: AnimFrameMode::Lossless,
..Default::default()
},
)
.expect("encode lossless");
let lossy = build_animated_webp_with_options(
w,
h,
[0; 4],
0,
&frames,
AnimEncoderOptions {
mode: AnimFrameMode::Lossy,
..Default::default()
},
)
.expect("encode lossy");
let auto = build_animated_webp_with_options(
w,
h,
[0; 4],
0,
&frames,
AnimEncoderOptions::default(),
)
.expect("encode auto");
eprintln!(
"anim sizes (noise 96x96): lossless={} lossy={} auto={}",
lossless.len(),
lossy.len(),
auto.len()
);
let smaller = lossless.len().min(lossy.len());
assert!(
auto.len() <= smaller + 2,
"auto ({}) > min(lossless={}, lossy={}) + 2 — mode-selection broken",
auto.len(),
lossless.len(),
lossy.len(),
);
}
#[test]
fn auto_mode_picks_lossless_for_palette_frame() {
let w = 32u32;
let h = 32u32;
let rgba = solid_frame(w, h, [0x80, 0x40, 0x20, 0xff]);
let frames = [AnimFrame {
width: w,
height: h,
x_offset: 0,
y_offset: 0,
duration_ms: 50,
blend: false,
dispose_to_background: false,
rgba: &rgba,
}];
let auto = build_animated_webp_with_options(
w,
h,
[0; 4],
0,
&frames,
AnimEncoderOptions::default(),
)
.expect("encode auto");
let lossless = build_animated_webp_with_options(
w,
h,
[0; 4],
0,
&frames,
AnimEncoderOptions {
mode: AnimFrameMode::Lossless,
..Default::default()
},
)
.expect("encode lossless");
assert_eq!(
auto.len(),
lossless.len(),
"auto mode failed to pick lossless on a flat-colour fixture"
);
}
#[test]
fn loop_count_and_background_round_trip_on_disk() {
let f = solid_frame(4, 4, [0; 4]);
let frames = [AnimFrame {
width: 4,
height: 4,
x_offset: 0,
y_offset: 0,
duration_ms: 1,
blend: false,
dispose_to_background: false,
rgba: &f,
}];
let out = build_animated_webp(4, 4, [0x12, 0x34, 0x56, 0x78], 7, &frames).expect("build");
let vp8x_chunk_len = u32::from_le_bytes([out[16], out[17], out[18], out[19]]) as usize;
let anim_off = 12 + 8 + vp8x_chunk_len + (vp8x_chunk_len & 1);
let anim_payload = &out[anim_off + 8..anim_off + 8 + 6];
assert_eq!(&anim_payload[0..4], &[0x12, 0x34, 0x56, 0x78]);
let lc = u16::from_le_bytes([anim_payload[4], anim_payload[5]]);
assert_eq!(lc, 7);
}
}