mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): crop animated images on web with libwebp (#2947)
This commit is contained in:
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.
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user