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

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Alexandre Bury
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,32 @@
The auto-generated bindings are under the 3-clause BSD license:
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,30 @@
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+12
View File
@@ -0,0 +1,12 @@
# libfluxcore licenses
`libfluxcore_bg.wasm` is built from this crate and bundled into the app. It
contains third-party code that keeps its upstream license.
| Component | Source | License |
| --- | --- | --- |
| Zstandard 1.5.7 | Vendored by the `zstd-sys` 2.1.0 crate | BSD-3-Clause, see `LICENSE-ZSTD.txt` (Zstandard is dual licensed, Fluxer uses it under BSD-3-Clause) |
| `zstd-sys` 2.1.0 | Rust bindings, build script and WebAssembly libc shim | BSD-3-Clause, see `LICENSE-ZSTD-SYS.txt` |
| `zstd` 0.14.0 | Rust wrapper | BSD-3-Clause, see `LICENSE-ZSTD-RS.txt` |
No Fluxer license notice grants rights to third-party trademarks or brand names.
+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) {