use oxideav_webp::anmf::{BlendingMethod, DisposalMethod};
use oxideav_webp::{
build_animated_webp, build_animated_webp_with_options, decode_webp, AnimEncoderOptions,
AnimFrame, AnimFrameMode, DeltaConfig, DownsampleKernel, WebpError, WebpMetadata,
};
fn make_rgba(width: u32, height: u32, seed: u32, opaque: bool) -> Vec<u8> {
let mut buf = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
buf.push((x.wrapping_mul(37).wrapping_add(y).wrapping_add(seed) & 0xff) as u8);
buf.push((y.wrapping_mul(53).wrapping_add(x).wrapping_mul(seed.max(1)) & 0xff) as u8);
buf.push(((x ^ y).wrapping_mul(101).wrapping_add(seed) & 0xff) as u8);
let a = if opaque {
0xff
} else {
(255 - ((x.wrapping_add(y).wrapping_add(seed)) & 0xff)) as u8
};
buf.push(a);
}
}
buf
}
#[test]
fn build_animated_webp_round_trips_three_frames() {
let (w, h) = (6u32, 5u32);
let f0 = make_rgba(w, h, 0, true);
let f1 = make_rgba(w, h, 7, true);
let f2 = make_rgba(w, h, 19, true);
let frames = vec![
AnimFrame::new(w, h, f0.clone(), 100),
AnimFrame::new(w, h, f1.clone(), 150),
AnimFrame::new(w, h, f2.clone(), 200),
];
let file = build_animated_webp(&frames).expect("build animated webp");
assert_eq!(&file[0..4], b"RIFF");
assert_eq!(&file[8..12], b"WEBP");
let img = decode_webp(&file).expect("decode animation");
assert_eq!(img.frames.len(), 3, "one WebpFrame per ANMF");
for (i, (decoded, (src, dur))) in img
.frames
.iter()
.zip([(f0, 100u32), (f1, 150), (f2, 200)])
.enumerate()
{
assert_eq!(decoded.width, w, "frame {i} width");
assert_eq!(decoded.height, h, "frame {i} height");
assert_eq!(decoded.duration_ms, dur, "frame {i} duration");
assert_eq!(
decoded.rgba.len(),
(w * h * 4) as usize,
"frame {i} flat len"
);
assert_eq!(decoded.rgba, src, "frame {i} pixels round-trip exactly");
}
assert_eq!(img.anim_loop_count, Some(0));
assert_eq!(img.anim_background_rgba, Some([0, 0, 0, 0]));
}
#[test]
fn build_animated_webp_with_options_carries_loop_bg_and_metadata() {
let (w, h) = (4u32, 4u32);
let frames = vec![
AnimFrame::new(w, h, make_rgba(w, h, 1, false), 80),
AnimFrame::new(w, h, make_rgba(w, h, 2, false), 80),
];
let icc = b"icc-bytes".to_vec();
let exif = b"Exif\x00\x00MM".to_vec();
let xmp = b"<x:xmpmeta/>".to_vec();
let opts = AnimEncoderOptions {
loop_count: 3,
background_rgba: [10, 20, 30, 255],
metadata: WebpMetadata {
icc: Some(&icc),
exif: Some(&exif),
xmp: Some(&xmp),
},
delta: DeltaConfig::default(),
};
let file = build_animated_webp_with_options(&frames, &opts).expect("build with options");
let img = decode_webp(&file).expect("decode");
assert_eq!(img.frames.len(), 2);
assert_eq!(img.anim_loop_count, Some(3));
assert_eq!(img.anim_background_rgba, Some([10, 20, 30, 255]));
assert_eq!(img.metadata.icc.as_deref(), Some(&icc[..]));
assert_eq!(img.metadata.exif.as_deref(), Some(&exif[..]));
assert_eq!(img.metadata.xmp.as_deref(), Some(&xmp[..]));
assert_eq!(img.frames[0].rgba, make_rgba(w, h, 1, false));
assert_eq!(img.frames[1].rgba, make_rgba(w, h, 2, false));
}
#[test]
fn frame_blend_dispose_and_offset_fields_are_carried() {
let (w, h) = (2u32, 2u32);
let frame = AnimFrame {
pixels: make_rgba(w, h, 5, true),
width: w,
height: h,
x: 2,
y: 4,
duration: 42,
blend: BlendingMethod::Overwrite,
dispose: DisposalMethod::Background,
mode: AnimFrameMode::Lossless,
};
let file = build_animated_webp(&[frame]).expect("build offset frame");
let img = decode_webp(&file).expect("decode");
assert_eq!(img.frames.len(), 1);
let f = &img.frames[0];
assert_eq!(f.duration_ms, 42);
assert_eq!(f.width, 4);
assert_eq!(f.height, 6);
assert_eq!(f.rgba.len(), (4 * 6 * 4) as usize);
let src = make_rgba(w, h, 5, true);
for row in 0..h {
for col in 0..w {
let src_off = ((row * w + col) * 4) as usize;
let dst_off = (((4 + row) * 4 + (2 + col)) * 4) as usize;
assert_eq!(
f.rgba[dst_off..dst_off + 4],
src[src_off..src_off + 4],
"sub-rect pixel ({col},{row}) round-trips byte-exact"
);
}
}
for row in 0..6u32 {
for col in 0..4u32 {
let in_sub = (2..4).contains(&col) && (4..6).contains(&row);
if !in_sub {
let off = ((row * 4 + col) * 4) as usize;
assert_eq!(
&f.rgba[off..off + 4],
&[0, 0, 0, 0],
"outside-sub-rect pixel ({col},{row}) = ANIM bg"
);
}
}
}
}
#[test]
fn auto_and_delta_modes_round_trip_byte_exact() {
let (w, h) = (8u32, 8u32);
let base = make_rgba(w, h, 0, true);
let mut moved = base.clone();
moved[(4 * 8 + 3) * 4] ^= 0xff;
for mode in [AnimFrameMode::Auto, AnimFrameMode::Delta] {
let f0 = AnimFrame::new(w, h, base.clone(), 100);
let mut f1 = AnimFrame::new(w, h, moved.clone(), 150);
f1.mode = mode;
let file = build_animated_webp(&[f0, f1]).expect("build animated webp");
let img = decode_webp(&file).expect("decode animation");
assert_eq!(img.frames.len(), 2, "{mode:?}: one WebpFrame per ANMF");
assert_eq!(
img.frames[0].rgba, base,
"{mode:?}: frame 0 round-trips byte-exact"
);
assert_eq!(
img.frames[1].rgba, moved,
"{mode:?}: frame 1 round-trips byte-exact"
);
assert_eq!(img.frames[1].duration_ms, 150);
}
}
#[test]
fn delta_mode_three_frames_round_trip_byte_exact() {
let (w, h) = (24u32, 24u32);
let f0_px = make_rgba(w, h, 1, true);
let mut f1_px = f0_px.clone();
for row in 10..14 {
for col in 10..14 {
let off = (row * w as usize + col) * 4;
f1_px[off] ^= 0xff;
f1_px[off + 1] ^= 0xff;
}
}
let mut f2_px = f1_px.clone();
for row in 4..8 {
for col in 4..8 {
let off = (row * w as usize + col) * 4;
f2_px[off + 2] ^= 0xff;
}
}
let f0 = AnimFrame::new(w, h, f0_px.clone(), 80);
let mut f1 = AnimFrame::new(w, h, f1_px.clone(), 80);
f1.mode = AnimFrameMode::Delta;
let mut f2 = AnimFrame::new(w, h, f2_px.clone(), 80);
f2.mode = AnimFrameMode::Delta;
let file = build_animated_webp(&[f0.clone(), f1.clone(), f2.clone()]).expect("build delta");
let img = decode_webp(&file).expect("decode");
assert_eq!(img.frames.len(), 3);
assert_eq!(img.frames[0].rgba, f0_px, "frame 0 round-trips");
assert_eq!(img.frames[1].rgba, f1_px, "frame 1 round-trips");
assert_eq!(img.frames[2].rgba, f2_px, "frame 2 round-trips");
let f1_l = {
let mut x = AnimFrame::new(w, h, f1_px, 80);
x.mode = AnimFrameMode::Lossless;
x
};
let f2_l = {
let mut x = AnimFrame::new(w, h, f2_px, 80);
x.mode = AnimFrameMode::Lossless;
x
};
let file_lossless = build_animated_webp(&[f0, f1_l, f2_l]).expect("build lossless");
assert!(
file.len() < file_lossless.len(),
"3-frame delta ({} B) beats 3-frame lossless ({} B)",
file.len(),
file_lossless.len(),
);
}
#[test]
fn auto_mode_picks_dirty_rect_on_small_localised_change() {
let (w, h) = (32u32, 32u32);
let base = make_rgba(w, h, 0, true);
let mut moved = base.clone();
let off = (16 * 32 + 16) * 4;
moved[off] ^= 0xff;
moved[off + 1] ^= 0xff;
let f0_lossless = AnimFrame::new(w, h, base.clone(), 80);
let mut f1_lossless = AnimFrame::new(w, h, moved.clone(), 80);
f1_lossless.mode = AnimFrameMode::Lossless;
let mut f1_auto = AnimFrame::new(w, h, moved, 80);
f1_auto.mode = AnimFrameMode::Auto;
let file_lossless =
build_animated_webp(&[f0_lossless.clone(), f1_lossless]).expect("lossless build");
let file_auto = build_animated_webp(&[f0_lossless, f1_auto]).expect("auto build");
assert!(
file_auto.len() < file_lossless.len(),
"auto-mode ({} B) must beat lossless ({} B) on a 2-pixel change",
file_auto.len(),
file_lossless.len(),
);
let _ = WebpError::InvalidData;
}
#[test]
fn empty_frame_list_is_invalid_data() {
assert_eq!(build_animated_webp(&[]), Err(WebpError::InvalidData));
}
#[test]
fn delta_config_builder_methods_are_exposed() {
let cfg = DeltaConfig::default()
.max_components_override(4)
.auto_inner_threshold_bytes(Some(256))
.msssim_downsample_kernel(DownsampleKernel::Gaussian);
assert_eq!(cfg.max_components, 4);
assert_eq!(cfg.auto_inner_threshold_bytes, Some(256));
assert_eq!(cfg.msssim_downsample_kernel, DownsampleKernel::Gaussian);
}
fn blend_px(dst: [u8; 4], src: [u8; 4]) -> [u8; 4] {
let sa = u32::from(src[3]);
if sa == 255 {
return src;
}
if sa == 0 {
return dst;
}
let da = u32::from(dst[3]);
let dst_factor = (da * (255 - sa) + 127) / 255;
let out_a = sa + dst_factor;
let mut out = [0u8; 4];
for c in 0..3 {
let v = (u32::from(src[c]) * sa + u32::from(dst[c]) * dst_factor + out_a / 2)
.checked_div(out_a)
.unwrap_or(0);
out[c] = v.min(255) as u8;
}
out[3] = out_a.min(255) as u8;
out
}
#[test]
fn delta_frame_with_background_dispose_round_trips() {
let (w, h) = (6u32, 6u32);
let red = [255u8, 0, 0, 255];
let f0_px: Vec<u8> = red
.iter()
.copied()
.cycle()
.take((w * h * 4) as usize)
.collect();
let mut f1_px = f0_px.clone();
f1_px[0..4].copy_from_slice(&[0, 0, 255, 255]);
let mut f2_px = vec![0u8; (w * h * 4) as usize];
f2_px[0..4].copy_from_slice(&[0, 255, 0, 255]);
let mut f0 = AnimFrame::new(w, h, f0_px.clone(), 10);
f0.mode = AnimFrameMode::Delta;
let mut f1 = AnimFrame::new(w, h, f1_px.clone(), 10);
f1.mode = AnimFrameMode::Delta;
f1.dispose = DisposalMethod::Background;
let mut f2 = AnimFrame::new(w, h, f2_px.clone(), 10);
f2.mode = AnimFrameMode::Delta;
let file = build_animated_webp(&[f0, f1, f2]).expect("build");
let img = decode_webp(&file).expect("decode");
assert_eq!(img.frames.len(), 3);
assert_eq!(img.frames[0].rgba, f0_px, "frame 0 round-trips");
assert_eq!(img.frames[1].rgba, f1_px, "frame 1 round-trips");
assert_eq!(
img.frames[2].rgba, f2_px,
"frame 2 must render on the background-cleared canvas"
);
}
#[test]
fn delta_frame_with_alpha_blend_round_trips() {
let (w, h) = (6u32, 6u32);
let red = [255u8, 0, 0, 255];
let f0_px: Vec<u8> = red
.iter()
.copied()
.cycle()
.take((w * h * 4) as usize)
.collect();
let mut f1_px = f0_px.clone();
let semi_blue = [0u8, 0, 255, 128];
f1_px[0..4].copy_from_slice(&semi_blue);
for mode in [AnimFrameMode::Delta, AnimFrameMode::Auto] {
let mut f0 = AnimFrame::new(w, h, f0_px.clone(), 10);
f0.mode = mode;
let mut f1 = AnimFrame::new(w, h, f1_px.clone(), 10);
f1.mode = mode;
f1.blend = BlendingMethod::AlphaBlend;
let file = build_animated_webp(&[f0, f1]).expect("build");
let img = decode_webp(&file).expect("decode");
assert_eq!(img.frames.len(), 2);
assert_eq!(img.frames[0].rgba, f0_px, "{mode:?}: frame 0 round-trips");
let mut expected = f0_px.clone();
expected[0..4].copy_from_slice(&blend_px(red, semi_blue));
assert_eq!(
img.frames[1].rgba, expected,
"{mode:?}: AlphaBlend delta frame must land blended"
);
}
}
#[test]
fn oversized_anim_canvas_is_rejected_without_eager_allocation() {
let mut vp8x = Vec::new();
vp8x.push(0b0000_0010); vp8x.extend_from_slice(&[0, 0, 0]); vp8x.extend_from_slice(&[0x00, 0x40, 0x00]); vp8x.extend_from_slice(&[0x00, 0x00, 0x00]);
let anim = [0u8, 0, 0, 0, 0, 0];
fn chunk(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(fourcc);
v.extend_from_slice(&(payload.len() as u32).to_le_bytes());
v.extend_from_slice(payload);
if payload.len() % 2 == 1 {
v.push(0); }
v
}
let mut body = Vec::new();
body.extend_from_slice(chunk(b"VP8X", &vp8x).as_slice());
body.extend_from_slice(chunk(b"ANIM", &anim).as_slice());
let mut file = Vec::new();
file.extend_from_slice(b"RIFF");
file.extend_from_slice(&((4 + body.len()) as u32).to_le_bytes());
file.extend_from_slice(b"WEBP");
file.extend_from_slice(&body);
assert_eq!(
decode_webp(&file),
Err(WebpError::InvalidData),
"an over-ceiling §2.7.1 animation canvas must be rejected, not eagerly allocated",
);
let mut vp8x_ok = vp8x.clone();
vp8x_ok[4..7].copy_from_slice(&[0xff, 0x3f, 0x00]); let mut body_ok = Vec::new();
body_ok.extend_from_slice(chunk(b"VP8X", &vp8x_ok).as_slice());
body_ok.extend_from_slice(chunk(b"ANIM", &anim).as_slice());
let mut file_ok = Vec::new();
file_ok.extend_from_slice(b"RIFF");
file_ok.extend_from_slice(&((4 + body_ok.len()) as u32).to_le_bytes());
file_ok.extend_from_slice(b"WEBP");
file_ok.extend_from_slice(&body_ok);
assert_eq!(
decode_webp(&file_ok),
Err(WebpError::InvalidData),
"a canvas at the §3.4 ceiling passes the alloc guard (then errors on the empty frame list)",
);
}