fix(app): crop animated images on web with libwebp (#2947)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-24 22:45:53 +02:00
1 parent e0843ac4f5
commit bbfe809bef
104 files changed
+10884 -7316

No files matched your search

+68 -3
View File
@@ -16,6 +16,22 @@ pub fn is_animated_image_bytes(input: &[u8]) -> bool {
false
}
pub fn sniff_image_format_bytes(input: &[u8]) -> u8 {
if is_png(input) {
1
} else if is_gif(input) {
2
} else if is_webp(input) {
3
} else if is_avif_file(input) {
4
} else if input.starts_with(&[0xff, 0xd8, 0xff]) {
5
} else {
0
}
}
fn is_gif(input: &[u8]) -> bool {
input.starts_with(b"GIF89a") || input.starts_with(b"GIF87a")
}
@@ -35,7 +51,16 @@ fn is_avif_file(input: &[u8]) -> bool {
}
fn has_avif_anim(input: &[u8]) -> bool {
is_avif_file(input) && &input[8..12] == b"avis"
if !is_avif_file(input) {
return false;
}
if &input[8..12] == b"avis" {
return true;
}
let box_end = read_u32_be(input, 0).map_or(0, |size| (size as usize).min(input.len()));
input
.get(16..box_end)
.is_some_and(|brands| brands.as_chunks::<4>().0.contains(b"avis"))
}
fn has_apng_actl(input: &[u8]) -> bool {
@@ -243,13 +268,53 @@ mod tests {
#[test]
fn detects_avif_sequence_brand() {
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avis";
assert!(!is_animated_image_bytes(avif));
let still = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1miaf";
assert!(!is_animated_image_bytes(still));
let avis = b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00avif";
assert!(is_animated_image_bytes(avis));
}
#[test]
fn detects_avis_among_compatible_brands() {
let compat = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00mif1avismsf1";
assert!(is_animated_image_bytes(compat));
let outside_ftyp = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00mif1avis";
assert!(!is_animated_image_bytes(outside_ftyp));
let truncated = b"\x00\x00\x00\x40ftypavif\x00\x00\x00\x00mif1av";
assert!(!is_animated_image_bytes(truncated));
}
#[test]
fn sniffs_image_formats_from_magic_bytes() {
assert_eq!(sniff_image_format_bytes(b""), 0);
assert_eq!(sniff_image_format_bytes(b"<svg xmlns"), 0);
assert_eq!(
sniff_image_format_bytes(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"),
1
);
assert_eq!(sniff_image_format_bytes(b"GIF89a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"GIF87a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WEBPVP8 "), 3);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WAVEfmt "), 0);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"),
0
);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF"), 5);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8"), 0);
}
#[test]
fn rejects_truncated_chunks_without_panicking() {
assert!(!is_animated_image_bytes(
+40 -2
View File
@@ -5,8 +5,8 @@ mod rgba;
mod zstd_frame;
mod zstd_stream;
use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use formats::{is_animated_image_bytes, sniff_image_format_bytes};
use rgba::{TransformRequest, crop_rotate_rgba_alloc, crop_rotate_rgba_into};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
@@ -40,6 +40,39 @@ pub fn crop_rotate_rgba_raw(
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_into_buffer(
input: &[u8],
output: &mut [u8],
src_width: u32,
src_height: u32,
x: u32,
y: u32,
width: u32,
height: u32,
rotation_deg: u32,
output_width: u32,
output_height: u32,
) -> Result<(), JsValue> {
crop_rotate_rgba_into(
input,
output,
TransformRequest {
src_width,
src_height,
x,
y,
width,
height,
rotation_deg,
resize_width: output_width,
resize_height: output_height,
},
)
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Vec<u8>, JsValue> {
zstd_frame::decompress(input).map_err(zstd_error_to_js)
@@ -106,6 +139,11 @@ pub fn is_animated_image(input: &[u8]) -> bool {
is_animated_image_bytes(input)
}
#[wasm_bindgen]
pub fn sniff_image_format(input: &[u8]) -> u8 {
sniff_image_format_bytes(input)
}
fn optional_dimension_to_abi(value: Option<u32>) -> u32 {
value.filter(|dimension| *dimension > 0).unwrap_or(u32::MAX)
}
+65 -9
View File
@@ -22,6 +22,7 @@ pub struct TransformRequest {
pub enum TransformError {
InvalidDimensions,
InvalidRgbaLength,
InvalidOutputLength,
EmptyCrop,
EmptyTarget,
ImageTooLarge,
@@ -33,6 +34,7 @@ impl TransformError {
match self {
Self::InvalidDimensions => "invalid RGBA dimensions",
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
Self::InvalidOutputLength => "RGBA output length does not match target dimensions",
Self::EmptyCrop => "Crop area is empty",
Self::EmptyTarget => "Target dimensions are empty",
Self::ImageTooLarge => "Image is too large to crop",
@@ -75,12 +77,7 @@ pub fn crop_rotate_rgba_alloc(
input: &[u8],
request: TransformRequest,
) -> Result<Vec<u8>, TransformError> {
let geometry = output_geometry(request)?;
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
if input.len() != expected_len {
return Err(TransformError::InvalidRgbaLength);
}
let geometry = checked_geometry(input, request)?;
let output_len = rgba_byte_len(
geometry.target_width,
geometry.target_height,
@@ -89,8 +86,45 @@ pub fn crop_rotate_rgba_alloc(
let mut output = try_zeroed_vec(output_len)?;
write_u32_le(&mut output, 0, geometry.target_width);
write_u32_le(&mut output, 4, geometry.target_height);
write_transformed(
input,
&mut output[RGBA_RESULT_HEADER_BYTES..],
request,
geometry,
);
Ok(output)
}
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
pub fn crop_rotate_rgba_into(
input: &[u8],
output: &mut [u8],
request: TransformRequest,
) -> Result<(), TransformError> {
let geometry = checked_geometry(input, request)?;
if output.len() != rgba_byte_len(geometry.target_width, geometry.target_height, 0)? {
return Err(TransformError::InvalidOutputLength);
}
write_transformed(input, output, request, geometry);
Ok(())
}
fn checked_geometry(
input: &[u8],
request: TransformRequest,
) -> Result<OutputGeometry, TransformError> {
let geometry = output_geometry(request)?;
if input.len() != rgba_byte_len(request.src_width, request.src_height, 0)? {
return Err(TransformError::InvalidRgbaLength);
}
Ok(geometry)
}
fn write_transformed(
input: &[u8],
dst: &mut [u8],
request: TransformRequest,
geometry: OutputGeometry,
) {
if geometry.target_width == geometry.base_width
&& geometry.target_height == geometry.base_height
{
@@ -98,8 +132,6 @@ pub fn crop_rotate_rgba_alloc(
} else {
copy_rotated_with_nearest_resize(input, dst, request, geometry);
}
Ok(output)
}
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
@@ -463,6 +495,30 @@ mod tests {
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
}
#[test]
fn into_writes_the_same_pixels_as_alloc() {
let input = rgba(&[1, 2, 3, 4, 5, 6]);
for rotation_deg in [0, 90, 180, 270] {
let mut transform = request(2, 3);
transform.rotation_deg = rotation_deg;
transform.resize_width = 5;
transform.resize_height = 4;
let expected = crop_rotate_rgba_alloc(&input, transform).unwrap();
let mut output = vec![0xaa; 5 * 4 * RGBA_BYTES_PER_PIXEL];
crop_rotate_rgba_into(&input, &mut output, transform).unwrap();
assert_eq!(output, payload(&expected));
}
}
#[test]
fn into_rejects_an_output_of_the_wrong_length() {
let mut output = vec![0; 3 * RGBA_BYTES_PER_PIXEL];
assert_eq!(
crop_rotate_rgba_into(&rgba(&[1, 2, 3, 4]), &mut output, request(2, 2)).unwrap_err(),
TransformError::InvalidOutputLength
);
}
proptest! {
#[test]
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {