From fbccc8bc1e00610a62e8f424354c6e0c41753198 Mon Sep 17 00:00:00 2001 From: xirreal Date: Wed, 12 Aug 2026 17:55:26 +0200 Subject: [PATCH] Optimize and expose WebP encoding --- .cargo/config.toml | 11 - .github/workflows/build-release.yml | 17 +- .github/workflows/ci.yml | 55 +- .gitignore | 2 + Cargo.lock | 46 +- Cargo.toml | 7 +- README.md | 9 +- benches/webp.rs | 52 +- crates/maple-render-core/Cargo.toml | 14 +- crates/maple-render-core/README.md | 10 + crates/maple-render-core/src/error.rs | 2 + crates/maple-render-core/src/lib.rs | 8 +- crates/maple-render-core/src/webp_anim.rs | 1004 +++++++++-- crates/maple-render-core/tests/webp_output.rs | 248 ++- docs/publishing.md | 21 +- fuzz/Cargo.lock | 1538 +++++++++++++++++ fuzz/Cargo.toml | 20 + fuzz/fuzz_targets/webp_encoder.rs | 63 + src/lib.rs | 5 + 19 files changed, 2914 insertions(+), 218 deletions(-) delete mode 100644 .cargo/config.toml create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/webp_encoder.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 61720c7..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -[target.x86_64-unknown-linux-gnu] -rustflags = ["-C", "target-cpu=native"] - -[target.aarch64-unknown-linux-gnu] -rustflags = ["-C", "target-cpu=native"] - -[target.x86_64-apple-darwin] -rustflags = ["-C", "target-cpu=native"] - -[target.aarch64-apple-darwin] -rustflags = ["-C", "target-cpu=native"] diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 831486d..d7745dc 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -3,7 +3,7 @@ name: Build and Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: permissions: @@ -22,8 +22,19 @@ jobs: - name: Set up Rust uses: dtolnay/rust-toolchain@stable + - name: Validate tag matches crate version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + VERSION=$(grep '^version = ' Cargo.toml | head -n1 | cut -d '"' -f2) + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$VERSION" != "$TAG_VERSION" ]; then + echo "Tag version ($TAG_VERSION) does not match crate version ($VERSION)" + exit 1 + fi + - name: Build release binary - run: cargo build --release + run: cargo build --release --locked - name: Package binary (Unix) if: runner.os != 'Windows' @@ -31,6 +42,7 @@ jobs: mkdir -p dist cp target/release/maple dist/maple tar -czf dist/maple-${{ runner.os }}.tar.gz -C dist maple + rm dist/maple - name: Package binary (Windows) if: runner.os == 'Windows' @@ -38,6 +50,7 @@ jobs: New-Item -ItemType Directory -Force -Path dist | Out-Null Copy-Item target\\release\\maple.exe dist\\maple.exe Compress-Archive -Path dist\\maple.exe -DestinationPath dist\\maple-Windows.zip + Remove-Item dist\\maple.exe shell: pwsh - name: Create GitHub Release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9341baa..085bc08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: - main tags: - "core-v*" + pull_request: + branches: + - trunk + - main workflow_dispatch: inputs: publish: @@ -59,13 +63,62 @@ jobs: - name: Test maple-render-core standalone crate run: cargo test --manifest-path crates/maple-render-core/Cargo.toml + - name: Check maple-render-core without default features + run: cargo check --manifest-path crates/maple-render-core/Cargo.toml --no-default-features --all-targets + + - name: Test maple-render-core without default features + run: cargo test --manifest-path crates/maple-render-core/Cargo.toml --no-default-features + - name: Verify maple-render-core publish dry-run run: cargo publish --dry-run --allow-dirty --manifest-path crates/maple-render-core/Cargo.toml + native: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Check native path + run: cargo check --locked + + - name: Test native WebP path + run: cargo test --manifest-path crates/maple-render-core/Cargo.toml + + msrv: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust 1.88 + uses: dtolnay/rust-toolchain@1.88.0 + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Check root crate at minimum supported Rust version + run: cargo check --locked --all-targets + + - name: Check standalone core at minimum supported Rust version + run: cargo check --manifest-path crates/maple-render-core/Cargo.toml --all-targets + + - name: Check standalone core without default features at minimum supported Rust version + run: cargo check --manifest-path crates/maple-render-core/Cargo.toml --no-default-features --all-targets + publish-maple-render-core: name: Publish maple-render-core runs-on: ubuntu-latest - needs: rust + needs: [rust, native, msrv] if: startsWith(github.ref, 'refs/tags/core-v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true') steps: diff --git a/.gitignore b/.gitignore index b0ca6c3..da43a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ **/target +fuzz/artifacts +fuzz/corpus *.gif *.png diff --git a/Cargo.lock b/Cargo.lock index d6054cf..a2faa82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,30 +912,14 @@ dependencies = [ ] [[package]] -name = "libwebp-sys2" -version = "0.1.11" +name = "libwebp-sys" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4790186411a6843ecc0a141c8948c8e623a0bb5e886834b1b6c90f3dfa85bb99" +checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c" dependencies = [ "cc", - "cfg-if", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libwebp-sys2" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dc3d00aeaa1d6bf4f35f3003bc1835135e0fb41f2fc19082a3f98d619792505" -dependencies = [ - "cc", - "cfg-if", - "libc", - "libwebp-sys2 0.1.11", + "glob", "pkg-config", - "vcpkg", ] [[package]] @@ -961,7 +945,7 @@ dependencies = [ [[package]] name = "maple" -version = "0.2.0" +version = "0.3.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -978,17 +962,17 @@ dependencies = [ [[package]] name = "maple-render-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "ab_glyph", "delaunator", "gif 0.13.3", "image", "imageproc", + "libwebp-sys", "rayon", "serde", "serde_json", - "webp-animation", "zip", ] @@ -1865,12 +1849,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1931,16 +1909,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "webp-animation" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf0c124cbce8c045bc972736d9f9f116bbd8243fd75ea1b68db429d809de44d" -dependencies = [ - "libwebp-sys2 0.2.0", - "log", -] - [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 808ac4b..5bc45a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "maple" -version = "0.2.0" +version = "0.3.0" edition = "2021" -rust-version = "1.80" +rust-version = "1.88" [lib] crate-type = ["cdylib", "rlib"] @@ -14,7 +14,7 @@ eyre = "0.6" color-eyre = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" -maple-render-core = { path = "crates/maple-render-core", version = "0.2.0" } +maple-render-core = { path = "crates/maple-render-core", version = "0.3.0" } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" @@ -61,4 +61,3 @@ panic = "unwind" [target.'cfg(not(target_arch = "wasm32"))'.dependencies.mimalloc] version = "0.1" default-features = false - diff --git a/README.md b/README.md index 18b2c41..361189c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Core rendering logic now lives in the publishable [`maple-render-core`](crates/m cargo install --path . ``` -Requires Rust 1.80+. Video output requires ffmpeg. +Requires Rust 1.88+. Video output requires ffmpeg. ## Core crate (publishable) @@ -35,10 +35,14 @@ maple --zip --in --gif out.gif | Flag | Description | | ------------------------- | ----------------------- | | `--gif ` | Animated GIF | +| `--webp ` | Animated WebP | +| `--webp-single ` | Single-frame WebP | | `--vid ` | Video (ffmpeg required) | | `--save "frame_%06d.jpg"` | Individual frames | | `--single` | Single frame only | +WebP output defaults to lossy quality 95 and compression method 4. Use `--webp-quality <0..100>`, `--webp-method <0..6>`, or `--webp-lossless` to override it. + ### Transform | Flag | Description | @@ -76,6 +80,9 @@ maple --json config.json # Photo to toaster GIF maple --zip templates/toaster.zip --in photo.jpg --gif out.gif +# Full-color animated WebP +maple --zip templates/toaster.zip --in photo.jpg --webp out.webp + # Text billboard maple --zip templates/billboard-cityscape.zip --in "text:SALE" --gif ad.gif diff --git a/benches/webp.rs b/benches/webp.rs index 948e73c..66270de 100644 --- a/benches/webp.rs +++ b/benches/webp.rs @@ -9,7 +9,7 @@ mod common; use divan::Bencher; use maple::render::RenderQuality; -use maple_render_core::webp_anim::{WebpAnim, WebpOptions}; +use maple_render_core::webp_anim::{WebpAnim, WebpFrame, WebpOptions, encode_webp_animation}; fn main() { common::init(); @@ -25,6 +25,10 @@ fn lossless() -> WebpOptions { WebpOptions { lossless: true, ..WebpOptions::default() } } +fn fast_lossy() -> WebpOptions { + WebpOptions { quality: 85.0, method: 0, ..WebpOptions::default() } +} + fn anim(template: &str, image: &str, options: WebpOptions) -> WebpAnim { let mut anim = WebpAnim::new(common::renders(template, image, RenderQuality::Sampled)); let repo = common::repository(template); @@ -43,6 +47,20 @@ fn warm_anim(template: &str, image: &str, options: WebpOptions) -> WebpAnim { anim } +struct BatchAnimation { + dimensions: (u32, u32), + frames: Vec>, +} + +fn batch_anim(template: &str, image: &str) -> BatchAnimation { + let mut renders = common::warm_renders(template, image, RenderQuality::Sampled); + let dimensions = renders.get_render(0).expect("first frame").get().dimensions(); + let frames = (0..renders.length() as i32) + .map(|index| renders.get_render(index).expect("composited frame").get().as_raw().clone()) + .collect(); + BatchAnimation { dimensions, frames } +} + /// Full animated WebP: every frame is composited and handed to libwebp. This is /// what `maple --zip ... --webp out.webp` runs. #[divan::bench(sample_count = 3, sample_size = 1)] @@ -61,6 +79,38 @@ fn webp_serialize(bencher: Bencher) { .bench_local_refs(|anim| anim.encode().expect("webp encode")); } +/// Bounded-memory method-0 serialization, matching the fast settings used by +/// latency-sensitive consumers. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn webp_serialize_fast(bencher: Bencher) { + bencher + .with_inputs(|| warm_anim("book", "frog.jpg", fast_lossy())) + .bench_local_refs(|anim| anim.encode().expect("webp encode")); +} + +/// Batch method-0 serialization through the public high-throughput API. The +/// benchmark runner fixes Rayon to one thread for deterministic CodSpeed +/// results; local multicore runs can override `RAYON_NUM_THREADS`. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn webp_batch_serialize_fast(bencher: Bencher) { + bencher.with_inputs(|| batch_anim("book", "frog.jpg")).bench_local_refs(|animation| { + let frames: Vec<_> = animation + .frames + .iter() + .enumerate() + .map(|(index, rgba)| WebpFrame::new(rgba, index as i32 * 40)) + .collect(); + encode_webp_animation( + animation.dimensions, + &frames, + frames.len() as i32 * 40, + fast_lossy(), + 0, + ) + .expect("batch WebP encode") + }); +} + /// A single still frame through libwebp, the `--webp_single` path. #[divan::bench] fn webp_single_frame(bencher: Bencher) { diff --git a/crates/maple-render-core/Cargo.toml b/crates/maple-render-core/Cargo.toml index 24f78e2..2c02842 100644 --- a/crates/maple-render-core/Cargo.toml +++ b/crates/maple-render-core/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "maple-render-core" -version = "0.2.0" +version = "0.3.0" edition = "2021" -rust-version = "1.80" +rust-version = "1.88" description = "Core rendering and animation logic for maple templates" license = "MIT" repository = "https://github.com/taskylizard/maple" @@ -23,11 +23,13 @@ imageproc = "0.25" [target.'cfg(not(target_arch = "wasm32"))'.dependencies.rayon] version = "1.10" -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.webp-animation] -version = "0.10" -features = ["static"] +# Pinned because Maple relies on this release's native initialization behavior. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.libwebp-sys] +version = "=0.14.4" +default-features = false +features = ["std", "parallel"] optional = true [features] default = ["webp"] -webp = ["dep:webp-animation"] +webp = ["dep:libwebp-sys"] diff --git a/crates/maple-render-core/README.md b/crates/maple-render-core/README.md index a940c1a..fc7c568 100644 --- a/crates/maple-render-core/README.md +++ b/crates/maple-render-core/README.md @@ -13,6 +13,16 @@ This crate contains: It intentionally does **not** bundle templates or font assets. For text rendering, callers must pass font bytes into `Input::from_text`. +## Animated WebP + +`WebpAnim` and `WebpEncoder` use libwebp's animation encoder sequentially. This is the default path because it keeps memory bounded and performs animation-wide compression. + +`encode_webp_animation` is an opt-in batch API for callers that already hold complete RGBA frames. It detects dirty rectangles and encodes them concurrently with Rayon. This can reduce encoding time by roughly an order of magnitude on multicore machines, but it retains all source frames and can produce materially larger files because each rectangle is compressed independently. Benchmark both output size and latency for your workload before selecting it. + +The native `libwebp-sys` dependency is pinned exactly at 0.14.4. Its bundled libwebp 1.6.0 synchronizes lazy DSP initialization on Unix; Maple performs a one-time serialized warm-up on Windows, where that release uses an unsynchronized fallback. Upgrading the binding requires re-reviewing that initialization contract. + +WebP APIs are native-only and are excluded from `wasm32` builds. + ## Minimal usage ```rust diff --git a/crates/maple-render-core/src/error.rs b/crates/maple-render-core/src/error.rs index 0b46431..f6284ed 100644 --- a/crates/maple-render-core/src/error.rs +++ b/crates/maple-render-core/src/error.rs @@ -17,6 +17,7 @@ pub enum Error { NoRepository, NoInputs, GifEncode(String), + WebpEncode(String), VideoEncode(String), TextRender(String), Other(String), @@ -38,6 +39,7 @@ impl std::fmt::Display for Error { Error::NoRepository => write!(f, "No repository attached"), Error::NoInputs => write!(f, "No inputs attached"), Error::GifEncode(msg) => write!(f, "GIF encoding error: {}", msg), + Error::WebpEncode(msg) => write!(f, "WebP encoding error: {}", msg), Error::VideoEncode(msg) => write!(f, "Video encoding error: {}", msg), Error::TextRender(msg) => write!(f, "Text rendering error: {}", msg), Error::Other(msg) => write!(f, "{}", msg), diff --git a/crates/maple-render-core/src/lib.rs b/crates/maple-render-core/src/lib.rs index a8424d3..f4f02d7 100644 --- a/crates/maple-render-core/src/lib.rs +++ b/crates/maple-render-core/src/lib.rs @@ -12,7 +12,7 @@ pub mod vid_anim; #[cfg(not(target_arch = "wasm32"))] pub mod anim; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "webp", not(target_arch = "wasm32")))] pub mod webp_anim; #[cfg(not(target_arch = "wasm32"))] @@ -26,5 +26,7 @@ pub use renders::Renders; pub use repository::Repository; pub use template::Template; pub use vid_anim::VidAnim; -#[cfg(not(target_arch = "wasm32"))] -pub use webp_anim::{WebpAnim, WebpOptions}; +#[cfg(all(feature = "webp", not(target_arch = "wasm32")))] +pub use webp_anim::{ + WebpAnim, WebpAnimationOptions, WebpEncoder, WebpFrame, WebpOptions, encode_webp_animation, +}; diff --git a/crates/maple-render-core/src/webp_anim.rs b/crates/maple-render-core/src/webp_anim.rs index 50286b7..68ee3df 100644 --- a/crates/maple-render-core/src/webp_anim.rs +++ b/crates/maple-render-core/src/webp_anim.rs @@ -1,47 +1,657 @@ -//! Animated WebP output. -//! -//! Unlike GIF, animated WebP supports full 24-bit color with alpha per frame, -//! so it does **not** need color quantization and exhibits no gradient banding. -//! -//! Encoding is delegated to [`libwebp`](https://developers.google.com/speed/webp) -//! via the [`webp-animation`] crate (statically linked when the `static` feature -//! is enabled, which is the default on native targets). - -#[cfg(not(target_arch = "wasm32"))] -use std::{fs::File, io::Write, path::Path}; - -#[cfg(not(target_arch = "wasm32"))] -use webp_animation::Encoder as WebPEncoder; - -#[cfg(not(target_arch = "wasm32"))] +//! Native animated WebP output backed by libwebp. + +#[cfg(windows)] +use std::sync::OnceLock; +use std::{ffi::CStr, fs::File, io::Write, mem, path::Path, ptr, slice}; + +use libwebp_sys as ffi; +use rayon::prelude::*; + use crate::{ error::{Error, Result}, renders::Renders, }; -/// Quality (0..=100) used for lossy WebP encoding. +/// Quality used for lossy WebP encoding. pub const DEFAULT_WEBP_QUALITY: f32 = 95.0; -/// Lossy method (0=fastest … 6=slowest-best). libwebp default is 4. +/// Balanced libwebp method used by the published encoder. pub const DEFAULT_WEBP_METHOD: usize = 4; -#[cfg(not(target_arch = "wasm32"))] +const MAX_WEBP_DIMENSION: u32 = 16_383; +const MAX_WEBP_FRAME_DURATION_MS: i64 = (1 << 24) - 1; +const PARALLEL_ENCODER_MEMORY_BUDGET: usize = 256 * 1024 * 1024; +const ESTIMATED_NATIVE_BYTES_PER_PIXEL: usize = 16; + +// libwebp 1.6.0 synchronizes lazy DSP initialization on Unix, but its +// WEBP_USE_THREAD guard excludes Windows. Warm every native path Maple uses +// once there before Rayon starts concurrent frame encodes. +#[cfg(windows)] +static LIBWEBP_INITIALIZED: OnceLock> = OnceLock::new(); + +/// Per-frame WebP compression options. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct WebpOptions { - /// 0..=100. 100 = best quality (largest). + /// Lossy quality or lossless compression effort in the range 0 through 100. pub quality: f32, - /// Lossless encoding when true (quality acts as compression effort 0..=100). + /// Use lossless compression when true. pub lossless: bool, - /// Method / speed: 0 = fastest (larger/slightly lower quality), 6 = slowest best. + /// Compression method in the range 0 through 6. Zero is fastest. pub method: usize, } impl Default for WebpOptions { fn default() -> Self { - WebpOptions { quality: DEFAULT_WEBP_QUALITY, lossless: false, method: DEFAULT_WEBP_METHOD } + Self { quality: DEFAULT_WEBP_QUALITY, lossless: false, method: DEFAULT_WEBP_METHOD } + } +} + +/// Animation-level WebP options. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WebpAnimationOptions { + /// Number of animation loops. Zero means infinite. + pub loop_count: u16, + /// Spend additional time minimizing the complete animation size. + pub minimize_size: bool, + /// Minimum distance between keyframes. + pub kmin: i32, + /// Maximum distance between keyframes. Zero disables keyframe insertion. + pub kmax: i32, + /// Permit libwebp to choose lossy or lossless compression per frame. + pub allow_mixed: bool, +} + +/// One borrowed RGBA frame in an animation timeline. +#[derive(Debug, Clone, Copy)] +pub struct WebpFrame<'a> { + /// Tightly packed RGBA pixels for the complete canvas. + pub rgba: &'a [u8], + /// Start time in milliseconds. Frame timestamps must strictly increase. + pub timestamp_ms: i32, +} + +impl<'a> WebpFrame<'a> { + pub fn new(rgba: &'a [u8], timestamp_ms: i32) -> Self { + Self { rgba, timestamp_ms } + } +} + +/// Encode complete RGBA frames concurrently and assemble an animated WebP. +/// +/// This is an opt-in high-throughput path for callers that already hold every +/// frame in memory. Unchanged pixels are omitted from later frames, while +/// libwebp encodes independent frame rectangles in parallel through Rayon. +/// Because frames are encoded independently, output can be materially larger +/// than [`WebpEncoder`]'s animation-wide optimization. Use [`WebpEncoder`] for +/// bounded memory and smaller output. +pub fn encode_webp_animation( + dimensions: (u32, u32), + frames: &[WebpFrame<'_>], + final_timestamp_ms: i32, + options: WebpOptions, + loop_count: u16, +) -> Result> { + let expected_frame_bytes = frame_bytes(dimensions)?; + let options = normalize_options(options)?; + let durations = validate_frames(frames, final_timestamp_ms, expected_frame_bytes)?; + initialize_libwebp()?; + let (width, height) = (dimensions.0 as usize, dimensions.1 as usize); + + let rectangles: Vec<_> = frames + .par_iter() + .enumerate() + .map(|(index, frame)| { + if index == 0 { + Some(FrameRect::full(width, height)) + } else { + dirty_rect(frames[index - 1].rgba, frame.rgba, width) + } + }) + .collect(); + + let mut plans: Vec> = Vec::with_capacity(frames.len()); + for ((frame, rectangle), duration_ms) in + frames.iter().zip(rectangles).zip(durations.into_iter()) + { + if let Some(rectangle) = rectangle { + plans.push(FramePlan { rgba: frame.rgba, rectangle, duration_ms }); + } else { + let previous = plans.last_mut().expect("the first frame is always present"); + if previous.duration_ms <= MAX_WEBP_FRAME_DURATION_MS - duration_ms { + previous.duration_ms += duration_ms; + } else { + plans.push(FramePlan { + rgba: frame.rgba, + rectangle: FrameRect::full(width, height), + duration_ms, + }); + } + } + } + + let mut encoded = Vec::with_capacity(plans.len()); + let mut remaining = plans.as_slice(); + while !remaining.is_empty() { + let chunk_len = parallel_chunk_len(remaining); + let (chunk, rest) = remaining.split_at(chunk_len); + let encoded_chunk: Result> = + chunk.par_iter().map(|plan| encode_frame_rect(plan, width, options)).collect(); + encoded.extend(encoded_chunk?); + remaining = rest; + } + mux_frames( + dimensions, + &plans, + &encoded, + WebpAnimationOptions { loop_count, ..Default::default() }, + ) +} + +#[derive(Debug, Clone, Copy)] +struct FrameRect { + x: usize, + y: usize, + width: usize, + height: usize, +} + +impl FrameRect { + fn full(width: usize, height: usize) -> Self { + Self { x: 0, y: 0, width, height } + } +} + +#[derive(Debug, Clone, Copy)] +struct FramePlan<'a> { + rgba: &'a [u8], + rectangle: FrameRect, + duration_ms: i64, +} + +fn parallel_chunk_len(plans: &[FramePlan<'_>]) -> usize { + parallel_chunk_len_for(plans, rayon::current_num_threads()) +} + +fn parallel_chunk_len_for(plans: &[FramePlan<'_>], available_threads: usize) -> usize { + let mut estimated_bytes = 0usize; + let mut chunk_len = 0; + for plan in plans.iter().take(available_threads.max(1)) { + let plan_bytes = plan + .rectangle + .width + .saturating_mul(plan.rectangle.height) + .saturating_mul(ESTIMATED_NATIVE_BYTES_PER_PIXEL) + .max(1); + if chunk_len > 0 + && estimated_bytes.saturating_add(plan_bytes) > PARALLEL_ENCODER_MEMORY_BUDGET + { + break; + } + estimated_bytes = estimated_bytes.saturating_add(plan_bytes); + chunk_len += 1; + } + chunk_len.max(1) +} + +fn validate_frames( + frames: &[WebpFrame<'_>], + final_timestamp_ms: i32, + expected_frame_bytes: usize, +) -> Result> { + if frames.is_empty() { + return Err(webp_error("no frames were provided")); + } + for frame in frames { + if frame.rgba.len() != expected_frame_bytes { + return Err(Error::WebpEncode(format!( + "RGBA frame has {} bytes; expected {}", + frame.rgba.len(), + expected_frame_bytes + ))); + } + } + for timestamps in frames.windows(2) { + if timestamps[1].timestamp_ms <= timestamps[0].timestamp_ms { + return Err(webp_error("frame timestamps must be strictly increasing")); + } + } + if final_timestamp_ms < frames.last().unwrap().timestamp_ms { + return Err(webp_error("final timestamp must not precede the last frame")); + } + + frames + .iter() + .enumerate() + .map(|(index, frame)| { + let next_timestamp = + frames.get(index + 1).map_or(final_timestamp_ms, |next| next.timestamp_ms); + let duration = i64::from(next_timestamp) - i64::from(frame.timestamp_ms); + if duration > MAX_WEBP_FRAME_DURATION_MS { + Err(webp_error("frame duration exceeds WebP's limit")) + } else { + Ok(duration) + } + }) + .collect() +} + +fn dirty_rect(previous: &[u8], current: &[u8], width: usize) -> Option { + let row_bytes = width * 4; + let mut rows = previous.chunks_exact(row_bytes).zip(current.chunks_exact(row_bytes)); + let top = rows.clone().position(|(before, after)| before != after)?; + let bottom = rows.rposition(|(before, after)| before != after).unwrap(); + let mut left = width; + let mut right = 0; + + for y in top..=bottom { + let start = y * row_bytes; + let before = &previous[start..start + row_bytes]; + let after = ¤t[start..start + row_bytes]; + if before == after { + continue; + } + let first_byte = before.iter().zip(after).position(|(a, b)| a != b).unwrap(); + let last_byte = before.iter().zip(after).rposition(|(a, b)| a != b).unwrap(); + left = left.min(first_byte / 4); + right = right.max(last_byte / 4 + 1); + } + + let x = left & !1; + let y = top & !1; + Some(FrameRect { x, y, width: right - x, height: bottom + 1 - y }) +} + +unsafe extern "C" fn write_webp_memory( + data: *const u8, + size: usize, + picture: *const ffi::WebPPicture, +) -> i32 { + // SAFETY: libwebp invokes this callback with its live picture and output + // bytes. custom_ptr was initialized to a live WebPMemoryWriter below. + unsafe { ffi::WebPMemoryWrite(data, size, picture) } +} + +fn encode_frame_rect( + plan: &FramePlan<'_>, + canvas_width: usize, + options: WebpOptions, +) -> Result> { + let rect = plan.rectangle; + // SAFETY: the picture, config, and memory writer are initialized through + // libwebp before use. Every native allocation is released before return. + unsafe { + let mut config = mem::zeroed(); + if ffi::WebPConfigInitInternal( + &mut config, + ffi::WebPPreset::WEBP_PRESET_DEFAULT, + 75.0, + ffi::WEBP_ENCODER_ABI_VERSION as i32, + ) == 0 + { + return Err(webp_error("could not initialize frame options")); + } + config.lossless = i32::from(options.lossless); + config.quality = options.quality; + config.method = options.method as i32; + config.exact = i32::from(options.lossless); + if ffi::WebPValidateConfig(&config) == 0 { + return Err(webp_error("libwebp rejected the frame options")); + } + + let mut picture = mem::zeroed(); + if ffi::WebPPictureInitInternal(&mut picture, ffi::WEBP_ENCODER_ABI_VERSION as i32) == 0 { + return Err(webp_error("could not initialize a frame")); + } + picture.width = rect.width as i32; + picture.height = rect.height as i32; + picture.use_argb = 1; + let offset = (rect.y * canvas_width + rect.x) * 4; + if ffi::WebPPictureImportRGBA( + &mut picture, + plan.rgba.as_ptr().add(offset), + (canvas_width * 4) as i32, + ) == 0 + { + ffi::WebPPictureFree(&mut picture); + return Err(webp_error("could not import an RGBA frame")); + } + + let mut writer = mem::zeroed(); + ffi::WebPMemoryWriterInit(&mut writer); + picture.writer = Some(write_webp_memory); + picture.custom_ptr = (&mut writer as *mut ffi::WebPMemoryWriter).cast(); + let encoded = ffi::WebPEncode(&config, &mut picture) != 0; + let output = if encoded && !writer.mem.is_null() { + slice::from_raw_parts(writer.mem, writer.size).to_vec() + } else { + Vec::new() + }; + ffi::WebPMemoryWriterClear(&mut writer); + ffi::WebPPictureFree(&mut picture); + + if !encoded || output.is_empty() { + Err(webp_error("could not encode a frame rectangle")) + } else { + Ok(output) + } + } +} + +#[cfg(not(windows))] +fn initialize_libwebp() -> Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn initialize_libwebp() -> Result<()> { + LIBWEBP_INITIALIZED + .get_or_init(|| initialize_libwebp_inner().map_err(|error| error.to_string())) + .as_ref() + .map_err(|message| Error::WebpEncode(message.clone())) + .copied() +} + +#[cfg(windows)] +fn initialize_libwebp_inner() -> Result<()> { + let rgba = [255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 0, 255, 255, 255, 255]; + let plan = FramePlan { rgba: &rgba, rectangle: FrameRect::full(2, 2), duration_ms: 1 }; + + for lossless in [false, true] { + let encoded = + encode_frame_rect(&plan, 2, WebpOptions { lossless, ..WebpOptions::default() })?; + let mut width = 0; + let mut height = 0; + // SAFETY: encoded is a live WebP bitstream. libwebp owns the returned + // decode buffer until WebPFree releases it below. + let decoded = unsafe { + ffi::WebPDecodeRGBA(encoded.as_ptr(), encoded.len(), &mut width, &mut height) + }; + if decoded.is_null() { + return Err(webp_error("could not initialize libwebp's decoder")); + } + unsafe { ffi::WebPFree(decoded.cast()) }; + } + Ok(()) +} + +fn mux_frames( + dimensions: (u32, u32), + plans: &[FramePlan<'_>], + encoded: &[Vec], + animation: WebpAnimationOptions, +) -> Result> { + // SAFETY: encoded frame buffers remain alive until assembly because mux + // receives non-owning references. The mux and assembled data are released + // on every return path. + unsafe { + let mux = ffi::WebPMuxNew(); + if mux.is_null() { + return Err(webp_error("could not create WebP muxer")); + } + let result = (|| { + let canvas_status = + ffi::WebPMuxSetCanvasSize(mux, dimensions.0 as i32, dimensions.1 as i32); + if canvas_status != ffi::WebPMuxError::WEBP_MUX_OK { + return Err(mux_error("could not set animation canvas", canvas_status)); + } + let params = ffi::WebPMuxAnimParams { + bgcolor: 0xffff_ffff, + loop_count: i32::from(animation.loop_count), + }; + let params_status = ffi::WebPMuxSetAnimationParams(mux, ¶ms); + if params_status != ffi::WebPMuxError::WEBP_MUX_OK { + return Err(mux_error("could not set animation options", params_status)); + } + + for (plan, bitstream) in plans.iter().zip(encoded) { + let frame = ffi::WebPMuxFrameInfo { + bitstream: ffi::WebPData { bytes: bitstream.as_ptr(), size: bitstream.len() }, + x_offset: plan.rectangle.x as i32, + y_offset: plan.rectangle.y as i32, + duration: plan.duration_ms as i32, + id: ffi::WebPChunkId::WEBP_CHUNK_ANMF, + dispose_method: ffi::WebPMuxAnimDispose::WEBP_MUX_DISPOSE_NONE, + blend_method: ffi::WebPMuxAnimBlend::WEBP_MUX_NO_BLEND, + pad: [0], + }; + let frame_status = ffi::WebPMuxPushFrame(mux, &frame, 0); + if frame_status != ffi::WebPMuxError::WEBP_MUX_OK { + return Err(mux_error("could not add animation frame", frame_status)); + } + } + + let mut data = mem::zeroed(); + ffi::WebPDataInit(&mut data); + let assemble_status = ffi::WebPMuxAssemble(mux, &mut data); + let output = + if assemble_status == ffi::WebPMuxError::WEBP_MUX_OK && !data.bytes.is_null() { + slice::from_raw_parts(data.bytes, data.size).to_vec() + } else { + Vec::new() + }; + ffi::WebPDataClear(&mut data); + if assemble_status != ffi::WebPMuxError::WEBP_MUX_OK { + Err(mux_error("could not assemble animation", assemble_status)) + } else if output.is_empty() { + Err(webp_error("muxer returned an empty file")) + } else { + Ok(output) + } + })(); + ffi::WebPMuxDelete(mux); + result + } +} + +fn mux_error(message: &str, status: ffi::WebPMuxError) -> Error { + Error::WebpEncode(format!("{message} (libwebp mux status {status:?})")) +} + +/// Incremental RGBA animation encoder shared by Maple consumers. +/// +/// The input slice is borrowed only for the duration of [`Self::add_frame`]. +/// libwebp performs architecture-specific runtime dispatch internally and +/// falls back to its scalar implementation on unsupported CPUs. +pub struct WebpEncoder { + raw: *mut ffi::WebPAnimEncoder, + picture: ffi::WebPPicture, + config: ffi::WebPConfig, + expected_frame_bytes: usize, + stride: i32, + previous_timestamp: Option, + failed: bool, +} + +impl WebpEncoder { + /// Create an encoder with default animation settings. + pub fn new(dimensions: (u32, u32), options: WebpOptions) -> Result { + Self::new_with_animation_options(dimensions, options, WebpAnimationOptions::default()) + } + + /// Create an encoder with explicit frame and animation settings. + pub fn new_with_animation_options( + dimensions: (u32, u32), + options: WebpOptions, + animation: WebpAnimationOptions, + ) -> Result { + let expected_frame_bytes = frame_bytes(dimensions)?; + let options = normalize_options(options)?; + validate_animation_options(animation)?; + initialize_libwebp()?; + let (width, height) = (dimensions.0 as i32, dimensions.1 as i32); + + // SAFETY: each libwebp structure is initialized before use, checked for + // failure, owned by this value, and released in Drop. + unsafe { + let mut animation_config = mem::zeroed(); + if ffi::WebPAnimEncoderOptionsInitInternal( + &mut animation_config, + ffi::WEBP_MUX_ABI_VERSION as i32, + ) == 0 + { + return Err(webp_error("could not initialize animation options")); + } + animation_config.anim_params.loop_count = i32::from(animation.loop_count); + animation_config.minimize_size = i32::from(animation.minimize_size); + animation_config.kmin = animation.kmin; + animation_config.kmax = animation.kmax; + animation_config.allow_mixed = i32::from(animation.allow_mixed); + + let raw = ffi::WebPAnimEncoderNewInternal( + width, + height, + &animation_config, + ffi::WEBP_MUX_ABI_VERSION as i32, + ); + if raw.is_null() { + return Err(webp_error("could not create animation encoder")); + } + + let mut config = mem::zeroed(); + if ffi::WebPConfigInitInternal( + &mut config, + ffi::WebPPreset::WEBP_PRESET_DEFAULT, + 75.0, + ffi::WEBP_ENCODER_ABI_VERSION as i32, + ) == 0 + { + ffi::WebPAnimEncoderDelete(raw); + return Err(webp_error("could not initialize frame options")); + } + config.lossless = i32::from(options.lossless); + config.quality = options.quality; + config.method = options.method as i32; + config.exact = i32::from(options.lossless); + if ffi::WebPValidateConfig(&config) == 0 { + ffi::WebPAnimEncoderDelete(raw); + return Err(webp_error("libwebp rejected the frame options")); + } + + let mut picture = mem::zeroed(); + if ffi::WebPPictureInitInternal(&mut picture, ffi::WEBP_ENCODER_ABI_VERSION as i32) == 0 + { + ffi::WebPAnimEncoderDelete(raw); + return Err(webp_error("could not initialize a frame")); + } + picture.width = width; + picture.height = height; + picture.use_argb = 1; + + Ok(Self { + raw, + picture, + config, + expected_frame_bytes, + stride: width * 4, + previous_timestamp: None, + failed: false, + }) + } + } + + /// Encode one tightly packed RGBA frame at an increasing timestamp. + pub fn add_frame(&mut self, rgba: &[u8], timestamp_ms: i32) -> Result<()> { + if self.failed { + return Err(webp_error("encoder cannot be reused after a native failure")); + } + if rgba.len() != self.expected_frame_bytes { + return Err(Error::WebpEncode(format!( + "RGBA frame has {} bytes; expected {}", + rgba.len(), + self.expected_frame_bytes + ))); + } + if let Some(previous) = self.previous_timestamp { + if validate_frame_duration(previous, timestamp_ms)? == 0 { + return Err(webp_error("frame timestamps must be strictly increasing")); + } + } + + // SAFETY: rgba has the validated canvas length and remains alive for + // both calls. libwebp imports it into picture-owned memory before the + // animation encoder consumes the picture synchronously. + if unsafe { ffi::WebPPictureImportRGBA(&mut self.picture, rgba.as_ptr(), self.stride) } == 0 + { + self.failed = true; + return Err(webp_error("could not import an RGBA frame")); + } + if unsafe { + ffi::WebPAnimEncoderAdd(self.raw, &mut self.picture, timestamp_ms, &self.config) + } == 0 + { + let error = self.encoder_error("could not encode frame"); + self.failed = true; + return Err(Error::WebpEncode(error)); + } + self.previous_timestamp = Some(timestamp_ms); + Ok(()) + } + + /// Finalize the timeline and return the complete WebP file. + pub fn finish(self, final_timestamp_ms: i32) -> Result> { + if self.failed { + return Err(webp_error("encoder cannot be finalized after a native failure")); + } + let Some(previous_timestamp) = self.previous_timestamp else { + return Err(webp_error("no frames were added")); + }; + validate_frame_duration(previous_timestamp, final_timestamp_ms)?; + + // SAFETY: self.raw is live and the null frame is libwebp's documented + // end-of-timeline sentinel. + if unsafe { + ffi::WebPAnimEncoderAdd(self.raw, ptr::null_mut(), final_timestamp_ms, ptr::null()) + } == 0 + { + return Err(Error::WebpEncode(self.encoder_error("could not finalize timeline"))); + } + + // SAFETY: WebPData is initialized before assembly. Its storage is + // copied into Rust ownership and cleared exactly once on every path. + let mut data = unsafe { + let mut data = mem::zeroed(); + ffi::WebPDataInit(&mut data); + data + }; + if unsafe { ffi::WebPAnimEncoderAssemble(self.raw, &mut data) } == 0 { + let error = self.encoder_error("could not assemble animation"); + unsafe { ffi::WebPDataClear(&mut data) }; + return Err(Error::WebpEncode(error)); + } + let output = if data.bytes.is_null() { + Vec::new() + } else { + unsafe { slice::from_raw_parts(data.bytes, data.size) }.to_vec() + }; + unsafe { ffi::WebPDataClear(&mut data) }; + if output.is_empty() { + return Err(webp_error("encoder returned an empty file")); + } + + Ok(output) + } + + fn encoder_error(&self, fallback: &str) -> String { + let message = unsafe { ffi::WebPAnimEncoderGetError(self.raw) }; + if message.is_null() { + fallback.to_string() + } else { + unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned() + } + } +} + +impl Drop for WebpEncoder { + fn drop(&mut self) { + // SAFETY: both values were initialized in the constructor and are + // owned exclusively by this encoder. + unsafe { + ffi::WebPPictureFree(&mut self.picture); + ffi::WebPAnimEncoderDelete(self.raw); + } } } -#[cfg(not(target_arch = "wasm32"))] pub struct WebpAnim { renders: Renders, period: f64, @@ -50,16 +660,9 @@ pub struct WebpAnim { options: WebpOptions, } -#[cfg(not(target_arch = "wasm32"))] impl WebpAnim { pub fn new(renders: Renders) -> Self { - WebpAnim { - renders, - period: 0.1, - hold: 5.0, - first_frame: -1, - options: WebpOptions::default(), - } + Self { renders, period: 0.1, hold: 5.0, first_frame: -1, options: WebpOptions::default() } } pub fn set_first_frame(&mut self, index: i32) { @@ -72,130 +675,261 @@ impl WebpAnim { } pub fn set_options(&mut self, options: WebpOptions) { - self.options = WebpOptions { - quality: options.quality.clamp(0.0, 100.0), - lossless: options.lossless, - method: options.method.min(6), - }; + self.options = options; } - /// Encode a single RGBA frame to a WebP byte buffer (still image, not animation). - /// - /// This is a convenience for `--webp_single`. It is only available on native - /// targets (libwebp is not built for wasm). + /// Encode one RGBA image as a still WebP file. pub fn encode_single(img: &image::RgbaImage, options: &WebpOptions) -> Result> { - let (width, height) = (img.width(), img.height()); - let rgba = img.as_raw().to_vec(); - - let enc_options = if options.lossless { - webp_animation::EncoderOptions { - encoding_config: Some(webp_animation::EncodingConfig { - encoding_type: webp_animation::EncodingType::Lossless, - quality: options.quality, - method: options.method, - ..Default::default() - }), - color_mode: webp_animation::ColorMode::Rgba, - ..Default::default() - } - } else { - let mut cfg = webp_animation::EncodingConfig::new_lossy(options.quality); - cfg.method = options.method; - webp_animation::EncoderOptions { - encoding_config: Some(cfg), - color_mode: webp_animation::ColorMode::Rgba, - ..Default::default() - } - }; - - let mut enc = WebPEncoder::new_with_options((width, height), enc_options) - .map_err(|e| Error::VideoEncode(format!("WebP encoder init: {}", e)))?; - enc.add_frame(&rgba, 0) - .map_err(|e| Error::VideoEncode(format!("WebP add_frame: {}", e)))?; - let data = - enc.finalize(1).map_err(|e| Error::VideoEncode(format!("WebP finalize: {}", e)))?; - Ok(data.as_ref().to_vec()) + let mut encoder = WebpEncoder::new(img.dimensions(), *options)?; + encoder.add_frame(img.as_raw(), 0)?; + encoder.finish(1) } - /// Encode all frames to a WebP byte buffer. + /// Render and encode every template frame with bounded memory. pub fn encode(&mut self) -> Result> { - let frames = self.renders.length() as i32; - if frames == 0 { - return Err(Error::VideoEncode("No frames to encode".to_string())); + let frame_count = i32::try_from(self.renders.length()) + .map_err(|_| webp_error("frame count exceeds WebP's timestamp range"))?; + if frame_count == 0 { + return Err(webp_error("no frames to encode")); } + let (timeline, final_timestamp) = + animation_timeline(self.period, self.hold, frame_count, self.first_frame)?; - // Dimensions come from the first render. - let first = self.renders.get_render(0)?; - let (width, height) = (first.get().width(), first.get().height()); + let dimensions = self.renders.get_render(0)?.get().dimensions(); + let mut encoder = WebpEncoder::new_with_animation_options( + dimensions, + self.options, + WebpAnimationOptions { kmin: 3, kmax: 5, ..Default::default() }, + )?; + for (index, timestamp) in timeline { + encoder.add_frame(self.renders.get_render(index)?.get().as_raw(), timestamp)?; + self.renders.remove_render(index); + } - let encoding_config = if self.options.lossless { - webp_animation::EncodingConfig { - encoding_type: webp_animation::EncodingType::Lossless, - quality: self.options.quality, - method: self.options.method, - ..Default::default() - } - } else { - let mut cfg = webp_animation::EncodingConfig::new_lossy(self.options.quality); - cfg.method = self.options.method; - cfg - }; + encoder.finish(final_timestamp) + } - let enc_options = webp_animation::EncoderOptions { - encoding_config: Some(encoding_config), - color_mode: webp_animation::ColorMode::Rgba, - kmin: 3, - kmax: 5, - ..Default::default() - }; + /// Encode all frames and write the result to `path`. + pub fn save>(&mut self, path: P) -> Result<()> { + let data = self.encode()?; + let mut writer = std::io::BufWriter::new(File::create(path)?); + writer.write_all(&data)?; + Ok(()) + } +} - let mut enc = WebPEncoder::new_with_options((width, height), enc_options) - .map_err(|e| Error::VideoEncode(format!("WebP encoder init: {}", e)))?; +fn validate_timing(period: f64, hold: f64, frame_count: i32) -> Result<()> { + if !period.is_finite() || !hold.is_finite() { + return Err(webp_error("animation timing must be finite")); + } + if period < 0.0 || hold < 0.0 { + return Err(webp_error("animation timing must not be negative")); + } - let mut curr_ms: f64 = 0.0; - let mut prev_ts: i32 = -1; + let total_ms = (period * f64::from(frame_count) + hold) * 1000.0; + let timestamp_upper_bound = total_ms.round() + f64::from(frame_count); + if !timestamp_upper_bound.is_finite() || timestamp_upper_bound > f64::from(i32::MAX) { + return Err(webp_error("animation timing exceeds WebP's timestamp range")); + } + Ok(()) +} - for base in 0..frames { - let i = if self.first_frame >= 0 { (base + self.first_frame) % frames } else { base }; +fn animation_timeline( + period: f64, + hold: f64, + frame_count: i32, + first_frame: i32, +) -> Result<(Vec<(i32, i32)>, i32)> { + validate_timing(period, hold, frame_count)?; + let offset = if first_frame >= 0 { first_frame % frame_count } else { 0 }; + let mut timeline = Vec::with_capacity(frame_count as usize); + let mut current_ms = 0.0f64; + let mut previous_timestamp = -1; - let step = if i == frames - 1 { self.period + self.hold } else { self.period }; + for base in 0..frame_count { + let index = ((i64::from(base) + i64::from(offset)) % i64::from(frame_count)) as i32; + let timestamp = rounded_timestamp_ms(current_ms, previous_timestamp)?; + if previous_timestamp >= 0 { + validate_frame_duration(previous_timestamp, timestamp)?; + } + timeline.push((index, timestamp)); + let step = if index == frame_count - 1 { period + hold } else { period }; + previous_timestamp = timestamp; + current_ms += step * 1000.0; + } - let render = self.renders.get_render(i)?; - let img = render.get(); - // libwebp expects raw RGBA bytes (one plane, no stride padding). - let rgba: Vec = img.as_raw().to_vec(); + let final_timestamp = rounded_timestamp_ms(current_ms, previous_timestamp)?; + validate_frame_duration(previous_timestamp, final_timestamp)?; + Ok((timeline, final_timestamp)) +} - let mut ts = (curr_ms).round() as i32; - if ts <= prev_ts { - ts = prev_ts + 1; - } - enc.add_frame(&rgba, ts) - .map_err(|e| Error::VideoEncode(format!("WebP add_frame: {}", e)))?; - prev_ts = ts; +fn rounded_timestamp_ms(current_ms: f64, previous_timestamp: i32) -> Result { + let rounded = current_ms.round(); + if !rounded.is_finite() || rounded < 0.0 || rounded > f64::from(i32::MAX) { + return Err(webp_error("animation timing exceeds WebP's timestamp range")); + } - curr_ms += step * 1000.0; - self.renders.remove_render(i); - } + let timestamp = rounded as i32; + if timestamp <= previous_timestamp { + previous_timestamp + .checked_add(1) + .ok_or_else(|| webp_error("animation timing exceeds WebP's timestamp range")) + } else { + Ok(timestamp) + } +} - // Finalize: timestamp marks when the final frame's display ends. - let mut final_ts = curr_ms.round() as i32; - if final_ts <= prev_ts { - final_ts = prev_ts + 1; - } +fn validate_frame_duration(timestamp_ms: i32, next_timestamp_ms: i32) -> Result { + let duration = i64::from(next_timestamp_ms) - i64::from(timestamp_ms); + if duration < 0 { + Err(webp_error("frame timestamps must be non-decreasing")) + } else if duration > MAX_WEBP_FRAME_DURATION_MS { + Err(webp_error("frame duration exceeds WebP's limit")) + } else { + Ok(duration) + } +} - let webp_data = enc - .finalize(final_ts) - .map_err(|e| Error::VideoEncode(format!("WebP finalize: {}", e)))?; +fn frame_bytes((width, height): (u32, u32)) -> Result { + if width == 0 || height == 0 { + return Err(webp_error("dimensions must be positive")); + } + if width > MAX_WEBP_DIMENSION || height > MAX_WEBP_DIMENSION { + return Err(Error::WebpEncode(format!( + "dimensions exceed WebP's {MAX_WEBP_DIMENSION}px limit" + ))); + } + (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| webp_error("RGBA frame size overflowed")) +} - Ok(webp_data.as_ref().to_vec()) +fn normalize_options(mut options: WebpOptions) -> Result { + if !options.quality.is_finite() { + return Err(webp_error("quality must be a finite number")); } + options.quality = options.quality.clamp(0.0, 100.0); + options.method = options.method.min(6); + Ok(options) +} - /// Encode all frames and write the result to `path`. - pub fn save>(&mut self, path: P) -> Result<()> { - let data = self.encode()?; - let file = File::create(path.as_ref()).map_err(|e| Error::Io(e))?; - let mut writer = std::io::BufWriter::new(file); - writer.write_all(&data).map_err(|e| Error::Io(e))?; - Ok(()) +fn validate_animation_options(options: WebpAnimationOptions) -> Result<()> { + let valid_keyframes = options.kmax <= 0 + || options.kmax == 1 + || (options.kmin > options.kmax / 2 && options.kmin < options.kmax); + if !valid_keyframes { + return Err(webp_error("invalid keyframe interval")); + } + Ok(()) +} + +fn webp_error(message: &str) -> Error { + Error::WebpEncode(message.to_string()) +} + +#[cfg(test)] +mod tests { + #[cfg(not(miri))] + use super::WebpEncoder; + use super::{ + FramePlan, FrameRect, WebpAnimationOptions, WebpFrame, WebpOptions, animation_timeline, + encode_webp_animation, frame_bytes, normalize_options, parallel_chunk_len_for, + rounded_timestamp_ms, validate_animation_options, validate_timing, + }; + + #[test] + fn validates_dimensions() { + assert!(frame_bytes((0, 1)).is_err()); + assert!(frame_bytes((16_384, 1)).is_err()); + } + + #[test] + fn validates_batch_inputs_before_ffi() { + let options = WebpOptions::default(); + assert!(encode_webp_animation((1, 1), &[], 0, options, 0).is_err()); + + let short = [0; 3]; + let malformed = [WebpFrame::new(&short, 0)]; + assert!(encode_webp_animation((1, 1), &malformed, 1, options, 0).is_err()); + + let pixel = [0; 4]; + let duplicate_timestamps = [WebpFrame::new(&pixel, 0), WebpFrame::new(&pixel, 0)]; + assert!(encode_webp_animation((1, 1), &duplicate_timestamps, 1, options, 0).is_err()); + } + + #[test] + fn limits_large_frame_parallelism() { + let rgba = [0; 4]; + let small = FramePlan { rgba: &rgba, rectangle: FrameRect::full(640, 360), duration_ms: 1 }; + let large = + FramePlan { rgba: &rgba, rectangle: FrameRect::full(8_192, 8_192), duration_ms: 1 }; + + assert_eq!(parallel_chunk_len_for(&[small; 32], 24), 24); + assert_eq!(parallel_chunk_len_for(&[large; 2], 24), 1); + let mut full_then_dirty = vec![large]; + full_then_dirty.extend([small; 24]); + assert_eq!(parallel_chunk_len_for(&full_then_dirty, 24), 1); + assert_eq!(parallel_chunk_len_for(&full_then_dirty[1..], 24), 24); + } + + #[cfg(not(miri))] + #[test] + fn validates_frame_lengths_before_import() { + let mut encoder = WebpEncoder::new((2, 2), WebpOptions::default()).unwrap(); + assert!(encoder.add_frame(&[0; 15], 0).is_err()); + } + + #[cfg(not(miri))] + #[test] + fn validates_timestamps_without_entering_ffi() { + let frame = [0; 16]; + let mut encoder = WebpEncoder::new((2, 2), WebpOptions::default()).unwrap(); + encoder.add_frame(&frame, 0).unwrap(); + assert!(encoder.add_frame(&frame, 0).is_err()); + } + + #[test] + fn normalizes_public_options() { + let options = + normalize_options(WebpOptions { quality: 120.0, lossless: false, method: 20 }).unwrap(); + assert_eq!(options.quality, 100.0); + assert_eq!(options.method, 6); + assert!( + normalize_options(WebpOptions { quality: f32::NAN, ..Default::default() }).is_err() + ); + } + + #[test] + fn validates_animation_timing() { + assert!(validate_timing(0.1, 5.0, 10).is_ok()); + assert!(validate_timing(f64::NAN, 0.0, 1).is_err()); + assert!(validate_timing(0.1, -1.0, 1).is_err()); + assert!(validate_timing(f64::MAX, 0.0, 1).is_err()); + assert!(validate_timing(f64::from(i32::MAX) / 1000.0, 0.0, 1).is_err()); + assert_eq!(rounded_timestamp_ms(0.1, 0).unwrap(), 1); + assert!(rounded_timestamp_ms(f64::from(i32::MAX), i32::MAX).is_err()); + + let (timeline, final_timestamp) = + animation_timeline(0.1, 0.0, 3, i32::MAX).expect("large rotation is normalized"); + assert_eq!(timeline, vec![(1, 0), (2, 100), (0, 200)]); + assert_eq!(final_timestamp, 300); + assert!( + animation_timeline(0.1, (super::MAX_WEBP_FRAME_DURATION_MS + 1) as f64 / 1000.0, 2, 0) + .is_err() + ); + } + + #[test] + fn validates_keyframe_intervals() { + assert!(validate_animation_options(WebpAnimationOptions::default()).is_ok()); + assert!( + validate_animation_options(WebpAnimationOptions { + kmin: 1, + kmax: 5, + ..Default::default() + }) + .is_err() + ); } } diff --git a/crates/maple-render-core/tests/webp_output.rs b/crates/maple-render-core/tests/webp_output.rs index c8fd3f4..9e045f3 100644 --- a/crates/maple-render-core/tests/webp_output.rs +++ b/crates/maple-render-core/tests/webp_output.rs @@ -3,10 +3,11 @@ // The full pipeline test opens real template/example files and drives libwebp // via FFI, both of which Miri isolates. Gate the test so `cargo miri test` // still passes; the in-crate unit tests exercise the pure-Rust code under Miri. -#![cfg(not(miri))] +#![cfg(all(feature = "webp", not(miri)))] -use std::path::PathBuf; +use std::{io::Cursor, path::PathBuf}; +use image::{AnimationDecoder, codecs::webp::WebPDecoder}; use maple_render_core::{ GifAnim, error::Result, @@ -14,7 +15,9 @@ use maple_render_core::{ render::RenderQuality, renders::Renders, repository::Repository, - webp_anim::{WebpAnim, WebpOptions}, + webp_anim::{ + WebpAnim, WebpAnimationOptions, WebpEncoder, WebpFrame, WebpOptions, encode_webp_animation, + }, }; fn here() -> PathBuf { @@ -38,6 +41,11 @@ fn build_renders(template_name: &str, input: &str) -> Result { #[test] fn webp_renders_and_is_decodable() { + if !template("toaster").is_file() || !example("frog.jpg").is_file() { + // Published crates intentionally omit Maple's template and example assets. + return; + } + let renders = build_renders("toaster", "frog.jpg").expect("renders"); // Animated WebP. @@ -55,6 +63,55 @@ fn webp_renders_and_is_decodable() { // Valid RIFF/WEBP: "RIFF" + u32 size + "WEBP". assert_eq!(&data[0..4], b"RIFF", "valid RIFF tag"); assert_eq!(&data[8..12], b"WEBP", "valid WEBP FourCC"); + let decoded = WebPDecoder::new(Cursor::new(&data)) + .expect("decode WebP") + .into_frames() + .collect_frames() + .expect("decode WebP frames"); + assert!(!decoded.is_empty()); + assert!(decoded.len() as u32 <= repo.length()); + assert_eq!(decoded[0].buffer().dimensions(), (400, 300)); + + let mut streaming_renders = build_renders("toaster", "frog.jpg").expect("renders"); + let frame_count = streaming_renders.length() as i32; + let dimensions = streaming_renders.get_render(0).expect("first frame").get().dimensions(); + let mut encoder = WebpEncoder::new_with_animation_options( + dimensions, + WebpOptions::default(), + WebpAnimationOptions { kmin: 3, kmax: 5, ..Default::default() }, + ) + .expect("streaming encoder"); + let mut current_ms = 0.0f64; + let mut previous_timestamp = -1; + for index in 0..frame_count { + let step = if index == frame_count - 1 { + repo.get_period() + repo.get_hold() + } else { + repo.get_period() + }; + let mut timestamp = current_ms.round() as i32; + if timestamp <= previous_timestamp { + timestamp = previous_timestamp + 1; + } + encoder + .add_frame( + streaming_renders.get_render(index).expect("render frame").get().as_raw(), + timestamp, + ) + .expect("streaming frame"); + streaming_renders.remove_render(index); + previous_timestamp = timestamp; + current_ms += step * 1000.0; + } + let mut final_timestamp = current_ms.round() as i32; + if final_timestamp <= previous_timestamp { + final_timestamp = previous_timestamp + 1; + } + assert_eq!( + data, + encoder.finish(final_timestamp).expect("streaming output"), + "WebpAnim must retain the bounded-memory streaming path" + ); // The same frames encoded as GIF use a 256-color palette. let renders = build_renders("toaster", "frog.jpg").expect("renders"); @@ -68,3 +125,188 @@ fn webp_renders_and_is_decodable() { assert!(data.len() > 12, "webp has payload"); assert!(gif_bytes.len() > 12, "gif has payload"); } + +fn decode_webp(data: &[u8]) -> Vec { + WebPDecoder::new(Cursor::new(data)) + .expect("decode WebP") + .into_frames() + .collect_frames() + .expect("decode frames") +} + +#[test] +fn batch_encoder_preserves_lossless_replacement_and_timing() { + let dimensions = (10, 8); + let red = [255, 0, 0, 255].repeat(dimensions.0 as usize * dimensions.1 as usize); + let mut odd_change = red.clone(); + let odd_pixel = (3 + 3 * dimensions.0 as usize) * 4; + odd_change[odd_pixel..odd_pixel + 4].copy_from_slice(&[0, 255, 0, 128]); + let mut transparent = odd_change.clone(); + transparent[odd_pixel..odd_pixel + 4].copy_from_slice(&[17, 29, 41, 0]); + let frames = [ + WebpFrame::new(&red, 0), + WebpFrame::new(&odd_change, 30), + WebpFrame::new(&transparent, 70), + ]; + + let data = encode_webp_animation( + dimensions, + &frames, + 120, + WebpOptions { quality: 0.0, lossless: true, method: 0 }, + 0, + ) + .expect("batch encode"); + let decoded = decode_webp(&data); + + assert_eq!(decoded.len(), 3); + assert_eq!(decoded[0].buffer().as_raw(), &red); + assert_eq!(decoded[1].buffer().as_raw(), &odd_change); + assert_eq!(decoded[2].buffer().as_raw(), &transparent); + assert_eq!(decoded[0].delay().numer_denom_ms(), (30, 1)); + assert_eq!(decoded[1].delay().numer_denom_ms(), (40, 1)); + assert_eq!(decoded[2].delay().numer_denom_ms(), (50, 1)); +} + +#[test] +fn batch_encoder_coalesces_identical_frames_without_losing_duration() { + let red = [255, 0, 0, 255].repeat(8 * 8); + let blue = [0, 0, 255, 255].repeat(8 * 8); + let frames = [WebpFrame::new(&red, 0), WebpFrame::new(&red, 20), WebpFrame::new(&blue, 50)]; + let data = encode_webp_animation( + (8, 8), + &frames, + 100, + WebpOptions { quality: 0.0, lossless: true, method: 0 }, + 0, + ) + .expect("batch encode"); + let decoded = decode_webp(&data); + + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].buffer().as_raw(), &red); + assert_eq!(decoded[1].buffer().as_raw(), &blue); + assert_eq!(decoded[0].delay().numer_denom_ms(), (50, 1)); + assert_eq!(decoded[1].delay().numer_denom_ms(), (50, 1)); +} + +fn rgb_mean_absolute_error(actual: &[u8], expected: &[u8]) -> f64 { + let error: u64 = actual + .chunks_exact(4) + .zip(expected.chunks_exact(4)) + .map(|(actual, expected)| { + u64::from(actual[0].abs_diff(expected[0])) + + u64::from(actual[1].abs_diff(expected[1])) + + u64::from(actual[2].abs_diff(expected[2])) + }) + .sum(); + error as f64 / (actual.len() / 4 * 3) as f64 +} + +#[test] +fn batch_encoder_retains_duplicates_when_merged_duration_would_overflow() { + let pixel = [255, 0, 0, 255]; + let frames = [WebpFrame::new(&pixel, 0), WebpFrame::new(&pixel, 10_000_000)]; + let data = encode_webp_animation( + (1, 1), + &frames, + 20_000_000, + WebpOptions { quality: 0.0, lossless: true, method: 0 }, + 0, + ) + .expect("batch encode"); + let decoded = decode_webp(&data); + + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].delay().numer_denom_ms(), (10_000_000, 1)); + assert_eq!(decoded[1].delay().numer_denom_ms(), (10_000_000, 1)); +} + +#[test] +fn batch_lossy_quality_tracks_streaming_encoder() { + let dimensions = (64, 48); + let mut frames = Vec::new(); + for frame_index in 0..4usize { + let mut pixels = Vec::with_capacity(dimensions.0 as usize * dimensions.1 as usize * 4); + for y in 0..dimensions.1 as usize { + for x in 0..dimensions.0 as usize { + pixels.extend_from_slice(&[ + (x * 3 + y + frame_index * 5) as u8, + (y * 5 + x / 2) as u8, + (x + y * 2) as u8, + 255, + ]); + } + } + let left = 3 + frame_index * 7; + for y in 11..25 { + for x in left..left + 9 { + let offset = (y * dimensions.0 as usize + x) * 4; + pixels[offset..offset + 4].copy_from_slice(&[240, 20, 180, 255]); + } + } + frames.push(pixels); + } + let options = WebpOptions { quality: 85.0, lossless: false, method: 0 }; + let batch_frames: Vec<_> = frames + .iter() + .enumerate() + .map(|(index, frame)| WebpFrame::new(frame, index as i32 * 40)) + .collect(); + let batch = + encode_webp_animation(dimensions, &batch_frames, 160, options, 0).expect("batch encode"); + let mut streaming = WebpEncoder::new(dimensions, options).expect("streaming encoder"); + for (index, frame) in frames.iter().enumerate() { + streaming.add_frame(frame, index as i32 * 40).expect("streaming frame"); + } + let streaming = streaming.finish(160).expect("streaming finish"); + let batch_decoded = decode_webp(&batch); + let streaming_decoded = decode_webp(&streaming); + + assert_eq!(batch_decoded.len(), frames.len()); + assert_eq!(streaming_decoded.len(), frames.len()); + for ((batch_frame, streaming_frame), source) in + batch_decoded.iter().zip(&streaming_decoded).zip(&frames) + { + let batch_error = rgb_mean_absolute_error(batch_frame.buffer().as_raw(), source); + let streaming_error = rgb_mean_absolute_error(streaming_frame.buffer().as_raw(), source); + assert!(batch_error < 12.0, "batch RGB error {batch_error:.2} is too high"); + assert!( + batch_error <= streaming_error + 3.0, + "batch RGB error {batch_error:.2} exceeds streaming error {streaming_error:.2}" + ); + assert!(batch_frame.buffer().pixels().all(|pixel| pixel[3] == 255)); + assert_eq!(batch_frame.delay().numer_denom_ms(), (40, 1)); + } +} + +#[test] +fn streaming_encoder_preserves_timing_and_lossless_pixels() { + let red = [255, 0, 0, 255].repeat(8 * 8); + let blue = [0, 0, 255, 255].repeat(8 * 8); + let mut encoder = + WebpEncoder::new((8, 8), WebpOptions { quality: 0.0, lossless: true, method: 0 }) + .expect("encoder"); + encoder.add_frame(&red, 0).expect("red frame"); + encoder.add_frame(&blue, 40).expect("blue frame"); + let data = encoder.finish(120).expect("finish"); + let decoded = WebPDecoder::new(Cursor::new(data)) + .expect("decode WebP") + .into_frames() + .collect_frames() + .expect("decode frames"); + + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].buffer().as_raw(), &red); + assert!( + decoded[1] + .buffer() + .as_raw() + .iter() + .zip(&blue) + .all(|(actual, expected)| actual.abs_diff(*expected) <= 1), + "image-webp's alpha compositor may round opaque channels down by one" + ); + assert_eq!(decoded[0].delay().numer_denom_ms(), (40, 1)); + assert_eq!(decoded[1].delay().numer_denom_ms(), (80, 1)); +} diff --git a/docs/publishing.md b/docs/publishing.md index ee68a15..9bcc485 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -33,15 +33,12 @@ You can also publish from **Actions → CI → Run workflow**: ## What CI does before publish -The `CI` workflow runs: - -- `cargo fmt --all -- --check` -- `cargo clippy --locked --all-targets -- -D warnings` (root crate) -- `cargo check --locked` (root crate) -- `cargo test --locked --lib` (root crate) -- `cargo check --locked --lib --target wasm32-unknown-unknown` (root wasm exports) -- `cargo check --manifest-path crates/maple-render-core/Cargo.toml` -- `cargo test --manifest-path crates/maple-render-core/Cargo.toml` -- `cargo publish --dry-run --allow-dirty --manifest-path crates/maple-render-core/Cargo.toml` - -Then it executes real publish when triggered by tag/manual publish mode. +The `CI` workflow requires all of these jobs before publishing: + +- Linux formatting, Clippy, root checks/tests, and the WASM export check +- Standalone core checks/tests with and without default features +- A `maple-render-core` crates.io publish dry-run +- Native root checks and core tests on macOS and Windows +- Root and standalone core checks on Rust 1.88, including the core's no-default-feature targets + +Then it executes the real publish when triggered by tag or manual publish mode. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..679c0c1 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,1538 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "delaunator" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1ee323c1275374f7e612d3724d12707079fb6a2117349fe144def656f5a880" +dependencies = [ + "robust", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif 0.14.2", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.7", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libwebp-sys" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c" +dependencies = [ + "cc", + "glob", + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maple-render-core" +version = "0.3.0" +dependencies = [ + "ab_glyph", + "delaunator", + "gif 0.13.3", + "image", + "imageproc", + "libwebp-sys", + "rayon", + "serde", + "serde_json", + "zip", +] + +[[package]] +name = "maple-render-core-fuzz" +version = "0.0.0" +dependencies = [ + "image", + "libfuzzer-sys", + "maple-render-core", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..75e688e --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "maple-render-core-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +maple-render-core = { path = "../crates/maple-render-core" } +image = { version = "0.25", default-features = false, features = ["webp"] } + +[[bin]] +name = "webp_encoder" +path = "fuzz_targets/webp_encoder.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/webp_encoder.rs b/fuzz/fuzz_targets/webp_encoder.rs new file mode 100644 index 0000000..04dcf45 --- /dev/null +++ b/fuzz/fuzz_targets/webp_encoder.rs @@ -0,0 +1,63 @@ +#![no_main] + +use std::io::Cursor; + +use image::{AnimationDecoder, ImageDecoder, codecs::webp::WebPDecoder}; +use libfuzzer_sys::fuzz_target; +use maple_render_core::{WebpEncoder, WebpFrame, WebpOptions, encode_webp_animation}; + +fuzz_target!(|data: &[u8]| { + if data.len() < 5 { + return; + } + let width = u32::from(data[0] % 16 + 1); + let height = u32::from(data[1] % 16 + 1); + let frame_count = usize::from(data[2] % 4 + 1); + let frame_bytes = width as usize * height as usize * 4; + let options = WebpOptions { + quality: f32::from(data[3] % 101), + lossless: data[4] & 1 != 0, + method: usize::from(data[4] % 7), + }; + + let mut frames = Vec::with_capacity(frame_count); + let mut cursor = 5; + for frame_index in 0..frame_count { + let mut rgba = vec![0; frame_bytes]; + for byte in &mut rgba { + *byte = data.get(cursor).copied().unwrap_or(frame_index as u8); + cursor = cursor.saturating_add(1); + } + frames.push(rgba); + } + let borrowed: Vec<_> = frames + .iter() + .enumerate() + .map(|(index, rgba)| WebpFrame::new(rgba, index as i32 * 3 + 1)) + .collect(); + if let Ok(encoded) = + encode_webp_animation((width, height), &borrowed, frame_count as i32 * 3 + 1, options, 0) + { + let decoder = WebPDecoder::new(Cursor::new(encoded)).expect("encoder output must decode"); + if decoder.has_animation() { + let decoded = decoder.into_frames().collect_frames().expect("frames must decode"); + assert!(!decoded.is_empty()); + } else { + let mut pixels = vec![0; decoder.total_bytes() as usize]; + decoder.read_image(&mut pixels).expect("still must decode"); + } + } + + let mut streaming = WebpEncoder::new((width, height), options).expect("valid dimensions"); + for (index, rgba) in frames.iter().enumerate() { + streaming.add_frame(rgba, index as i32 * 3 + 1).expect("valid frame"); + } + let encoded = streaming.finish(frame_count as i32 * 3 + 1).expect("valid timeline"); + WebPDecoder::new(Cursor::new(encoded)).expect("streaming output must decode"); + + let mut invalid = WebpEncoder::new((width, height), options).expect("valid dimensions"); + let short = &frames[0][..frame_bytes.saturating_sub(1)]; + assert!(invalid.add_frame(short, 0).is_err()); + assert!(invalid.add_frame(&frames[0], 0).is_ok()); + assert!(invalid.add_frame(&frames[0], 0).is_err()); +}); diff --git a/src/lib.rs b/src/lib.rs index a10b1e3..9623447 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,11 @@ pub use maple_render_core::{ TextOptions, error, gif_anim, input, mapping, pixer, quantize, render, renders, repository, template, vid_anim, }; +#[cfg(not(target_arch = "wasm32"))] +pub use maple_render_core::{ + WebpAnim, WebpAnimationOptions, WebpEncoder, WebpFrame, WebpOptions, encode_webp_animation, + webp_anim, +}; #[cfg(target_arch = "wasm32")] pub mod wasm;