diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml
new file mode 100644
index 0000000..e905f5b
--- /dev/null
+++ b/.github/workflows/codspeed.yml
@@ -0,0 +1,50 @@
+name: CodSpeed
+
+on:
+ push:
+ branches:
+ - trunk
+ - main
+ # Required to get performance reports on pull requests.
+ pull_request:
+ # Lets CodSpeed trigger backtest runs to generate initial data.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ # OpenID Connect authentication with CodSpeed.
+ id-token: write
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_TARGET_DIR: target
+ # `.cargo/config.toml` builds with `-C target-cpu=native`, which on these
+ # runners emits AVX-512 instructions the CPU simulator cannot execute. Pin a
+ # fixed baseline instead: AVX2-era codegen that stays comparable across runs.
+ RUSTFLAGS: -C target-cpu=x86-64-v3
+
+jobs:
+ benchmarks:
+ name: Run benchmarks
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install cargo-codspeed
+ run: cargo install cargo-codspeed --locked
+
+ - name: Build the benchmarks
+ run: cargo codspeed build --profile codspeed --measurement-mode simulation
+
+ - name: Run the benchmarks
+ uses: CodSpeedHQ/action@v5
+ with:
+ mode: simulation
+ run: cargo codspeed run
diff --git a/Cargo.lock b/Cargo.lock
index be3f81d..d6054cf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -33,6 +33,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "aligned"
version = "0.4.3"
@@ -87,7 +96,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -98,7 +107,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -278,6 +287,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
[[package]]
name = "clap"
version = "4.5.53"
@@ -298,6 +313,7 @@ dependencies = [
"anstyle",
"clap_lex",
"strsim",
+ "terminal_size",
]
[[package]]
@@ -318,6 +334,66 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+[[package]]
+name = "codspeed"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3"
+dependencies = [
+ "anyhow",
+ "cc",
+ "colored",
+ "getrandom 0.2.16",
+ "glob",
+ "libc",
+ "nix",
+ "serde",
+ "serde_json",
+ "statrs",
+]
+
+[[package]]
+name = "codspeed-divan-compat"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4ea79fd0b1f2128cfac6308369013dba92df47baf4a4f66b57d8158224a361d"
+dependencies = [
+ "clap",
+ "codspeed",
+ "codspeed-divan-compat-macros",
+ "codspeed-divan-compat-walltime",
+ "regex",
+]
+
+[[package]]
+name = "codspeed-divan-compat-macros"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f70e4ddd6beedefeb48f59d5f85fc21365a66e7976408c3d39f6cbbc4f03e08c"
+dependencies = [
+ "divan-macros",
+ "itertools 0.14.0",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "codspeed-divan-compat-walltime"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490c04f6076be6eacfafb496b8b237f3efbbed93838f2689115cc6f35fcf81c9"
+dependencies = [
+ "cfg-if",
+ "clap",
+ "codspeed",
+ "condtype",
+ "divan-macros",
+ "libc",
+ "regex-lite",
+]
+
[[package]]
name = "color-eyre"
version = "0.6.5"
@@ -357,6 +433,22 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "colored"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
+dependencies = [
+ "lazy_static",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "condtype"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
+
[[package]]
name = "core2"
version = "0.4.0"
@@ -437,6 +529,17 @@ dependencies = [
"syn",
]
+[[package]]
+name = "divan-macros"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -469,6 +572,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "exr"
version = "1.74.0"
@@ -590,6 +703,12 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+[[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"
@@ -603,9 +722,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.16.1"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@@ -679,9 +798,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]]
name = "indexmap"
-version = "2.12.1"
+version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
@@ -762,9 +881,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
-version = "0.2.178"
+version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfuzzer-sys"
@@ -819,6 +938,12 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "log"
version = "0.4.29"
@@ -839,6 +964,7 @@ name = "maple"
version = "0.2.0"
dependencies = [
"clap",
+ "codspeed-divan-compat",
"color-eyre",
"eyre",
"image",
@@ -942,6 +1068,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nom"
version = "8.0.0"
@@ -1124,6 +1262,15 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.104"
@@ -1336,6 +1483,41 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "rgb"
version = "0.8.52"
@@ -1354,6 +1536,19 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1478,6 +1673,16 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "statrs"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e"
+dependencies = [
+ "approx",
+ "num-traits",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1495,6 +1700,16 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "thiserror"
version = "2.0.17"
@@ -1538,6 +1753,36 @@ dependencies = [
"zune-jpeg 0.4.21",
]
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -1718,6 +1963,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1727,6 +1981,79 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "wit-bindgen"
version = "0.46.0"
diff --git a/Cargo.toml b/Cargo.toml
index 51a513a..808ac4b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,16 +20,44 @@ maple-render-core = { path = "crates/maple-render-core", version = "0.2.0" }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"
+[dev-dependencies]
+divan = { version = "4.7.0", package = "codspeed-divan-compat" }
+
[[bin]]
name = "maple"
path = "src/main.rs"
+[[bench]]
+name = "render"
+harness = false
+
+[[bench]]
+name = "quantize"
+harness = false
+
+[[bench]]
+name = "pipeline"
+harness = false
+
+[[bench]]
+name = "webp"
+harness = false
+
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
strip = "symbols"
+# Profile for the CodSpeed benchmarks: release-grade codegen, plus debug symbols
+# so profiles and flame graphs are readable. `panic = "unwind"` is required
+# because benchmark harnesses cannot be built with `panic = "abort"`.
+[profile.codspeed]
+inherits = "release"
+debug = true
+strip = "none"
+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 82800dd..18b2c41 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
+[](https://app.codspeed.io/taskylizard/maple?utm_source=badge)
+
---
maple allows you to composite images/text with templates into animated GIFs/Videos. `templates/` has some fun ones to try!
@@ -85,6 +87,23 @@ maple --zip templates/book.zip --in front.png back.png --vid book.mp4
back-tattoo, billboard-cityscape, book, circuitboard, flag, flag2, fortune-cookie, heart-locket, rubiks, toaster, valentine
+## Benchmarks
+
+Template parsing, frame compositing, color quantization and GIF/WebP encoding
+are benchmarked with [divan](https://github.com/nvzqz/divan) in `benches/` and
+tracked on every push and pull request by
+[CodSpeed](https://app.codspeed.io/taskylizard/maple).
+
+```bash
+# Wall-clock numbers
+cargo bench
+
+# Same benchmarks, measured the way CI measures them
+cargo install cargo-codspeed
+cargo codspeed build --profile codspeed --measurement-mode simulation
+cargo codspeed run
+```
+
## License
MIT
diff --git a/benches/common/mod.rs b/benches/common/mod.rs
new file mode 100644
index 0000000..5393276
--- /dev/null
+++ b/benches/common/mod.rs
@@ -0,0 +1,97 @@
+//! Shared asset helpers for the maple benchmarks.
+//!
+//! Benchmarks live in the binary crate because that is where the shipped
+//! templates, example images and fonts are: `maple-render-core` deliberately
+//! bundles none of them.
+#![allow(dead_code)]
+
+use std::path::PathBuf;
+
+use maple::{Input, Inputs, Render, Renders, Repository, mapping::Mapping, render::RenderQuality};
+
+/// Prepare the process before handing over to divan.
+///
+/// The compositor parallelizes rows with rayon. Left to itself, the number of
+/// worker threads and the way work is split vary from machine to machine, which
+/// shows up as noise in the measurements, so benchmarks default to a single
+/// thread (override with `RAYON_NUM_THREADS`). One throwaway render then warms
+/// the thread pool up, keeping its lazy initialization out of the first
+/// measured iteration.
+pub fn init() {
+ if std::env::var_os("RAYON_NUM_THREADS").is_none() {
+ std::env::set_var("RAYON_NUM_THREADS", "1");
+ }
+
+ let mut warmup = render("toaster", 0, RenderQuality::Sampled);
+ warmup.apply(&inputs("frog.jpg")).expect("warmup render");
+}
+
+/// Path of an asset relative to the repository root.
+pub fn asset(path: &str) -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
+}
+
+/// Raw bytes of a shipped template archive.
+pub fn template_bytes(name: &str) -> Vec {
+ let path = asset(&format!("templates/{}.zip", name));
+ std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e))
+}
+
+/// Open a template archive from memory, so that benchmarks measure parsing and
+/// not the file system.
+pub fn repository(name: &str) -> Repository {
+ Repository::load_from_bytes(template_bytes(name)).expect("load template")
+}
+
+/// Decode the five PNG layers of a single template frame.
+pub fn mapping(template: &str, frame: i32) -> Mapping {
+ repository(template).take_mapping(frame).expect("take mapping")
+}
+
+/// Load one of the shipped example photos as a render input.
+pub fn input(image: &str) -> Input {
+ Input::load(asset(&format!("examples/{}", image))).expect("load input")
+}
+
+pub fn inputs(image: &str) -> Inputs {
+ let mut inputs = Inputs::new();
+ inputs.push(input(image));
+ inputs
+}
+
+/// A render with the frame's mapping already attached, ready to `apply`.
+pub fn render(template: &str, frame: i32, quality: RenderQuality) -> Render {
+ let mut render = Render::new(quality);
+ render.attach_mapping(mapping(template, frame));
+ render
+}
+
+/// A render holding a composited frame, the input every color quantization
+/// step works on. Call [`Render::get`] to reach the image.
+pub fn composited_frame(template: &str, frame: i32, image: &str) -> Render {
+ let mut render = render(template, frame, RenderQuality::Sampled);
+ render.apply(&inputs(image)).expect("apply render");
+ render
+}
+
+/// The full animation pipeline for a template and an input image.
+pub fn renders(template: &str, image: &str, quality: RenderQuality) -> Renders {
+ Renders::new(repository(template), inputs(image), quality)
+}
+
+/// The same pipeline with every frame composited up front, so that a benchmark
+/// can measure serialization on its own. [`Renders`] composites frames lazily
+/// on first access, which would otherwise be counted with the encoding.
+pub fn warm_renders(template: &str, image: &str, quality: RenderQuality) -> Renders {
+ let mut renders = renders(template, image, quality);
+
+ for frame in 0..renders.length() as i32 {
+ renders.get_render(frame).expect("composite frame");
+ }
+
+ renders
+}
+
+pub fn font() -> Vec {
+ std::fs::read(asset("fonts/DejaVuSans-Bold.ttf")).expect("read font")
+}
diff --git a/benches/pipeline.rs b/benches/pipeline.rs
new file mode 100644
index 0000000..59ee73e
--- /dev/null
+++ b/benches/pipeline.rs
@@ -0,0 +1,77 @@
+//! End-to-end benchmarks: the work a single `maple` invocation does.
+
+mod common;
+
+use divan::Bencher;
+use maple::{GifAnim, Input, TextOptions, render::RenderQuality};
+
+fn main() {
+ common::init();
+ divan::main();
+}
+
+fn gif_anim(template: &str, image: &str) -> GifAnim {
+ let repo = common::repository(template);
+ let period = repo.get_period();
+ let hold = repo.get_hold();
+ let palette = repo.get_palette();
+
+ let mut anim = GifAnim::new(common::renders(template, image, RenderQuality::Sampled));
+ anim.set_timing(period, hold);
+ anim.set_palette_frames(palette);
+ anim
+}
+
+/// Full animated GIF: every frame is decoded, composited, quantized and
+/// LZW-compressed. This is what `maple --zip ... --gif out.gif` runs.
+#[divan::bench(sample_count = 3, sample_size = 1)]
+fn gif_animation(bencher: Bencher) {
+ bencher.with_inputs(|| gif_anim("book", "frog.jpg")).bench_local_refs(|anim| {
+ anim.apply().expect("gif apply");
+ anim.encode().expect("gif encode")
+ });
+}
+
+/// The same animation with Floyd-Steinberg dithering enabled.
+#[divan::bench(sample_count = 3, sample_size = 1)]
+fn gif_animation_dithered(bencher: Bencher) {
+ bencher
+ .with_inputs(|| {
+ let mut anim = gif_anim("book", "frog.jpg");
+ anim.set_dither(true);
+ anim
+ })
+ .bench_local_refs(|anim| {
+ anim.apply().expect("gif apply");
+ anim.encode().expect("gif encode")
+ });
+}
+
+/// GIF serialization on its own: palette table plus LZW compression of the
+/// frame blocks produced by `apply`.
+#[divan::bench(sample_count = 3, sample_size = 1)]
+fn gif_serialize(bencher: Bencher) {
+ let mut anim = gif_anim("book", "frog.jpg");
+ anim.apply().expect("gif apply");
+
+ bencher.bench_local(|| anim.encode().expect("gif encode"));
+}
+
+/// Decoding a user photo into a render input.
+#[divan::bench]
+fn load_photo(bencher: Bencher) {
+ let path = common::asset("examples/monkey.jpg");
+
+ bencher.bench_local(|| Input::load(&path).expect("load input"));
+}
+
+/// Rasterizing a text layer, the `--in "text:..."` path.
+#[divan::bench]
+fn text_layer(bencher: Bencher) {
+ let font = common::font();
+ let options = TextOptions::default();
+
+ bencher.bench_local(|| {
+ Input::from_text("Hello from maple", 1, &options, &font).expect("render text")
+ });
+}
diff --git a/benches/quantize.rs b/benches/quantize.rs
new file mode 100644
index 0000000..99dd4ad
--- /dev/null
+++ b/benches/quantize.rs
@@ -0,0 +1,57 @@
+//! Benchmarks for the median-cut quantizer used by the GIF encoder.
+//!
+//! Every GIF frame goes through `Quantizer::quantize`, and the palette is
+//! rebuilt for each palette frame of a template.
+
+mod common;
+
+use divan::Bencher;
+use maple::quantize::Quantizer;
+
+fn main() {
+ common::init();
+ divan::main();
+}
+
+/// Histogram prescan plus median-cut selection of the 256 palette entries.
+#[divan::bench]
+fn build_palette(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+
+ bencher.bench_local(|| Quantizer::new(frame));
+}
+
+/// Mapping a frame onto the palette with a cold inverse colormap cache.
+#[divan::bench]
+fn map_frame_cold_cache(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+
+ bencher
+ .with_inputs(|| Quantizer::new(frame))
+ .bench_local_refs(|quantizer| quantizer.quantize_no_dither(frame));
+}
+
+/// Mapping a frame once the colormap cache is warm, which is what every frame
+/// after the first one of an animation hits.
+#[divan::bench]
+fn map_frame_warm_cache(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+ let mut quantizer = Quantizer::new(frame);
+ quantizer.quantize_no_dither(frame);
+
+ bencher.bench_local(|| quantizer.quantize_no_dither(frame));
+}
+
+/// Floyd-Steinberg dithering, enabled with `--dither`.
+#[divan::bench]
+fn map_frame_dithered(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+ let mut quantizer = Quantizer::new(frame);
+ quantizer.quantize_fs_dither(frame);
+
+ bencher.bench_local(|| quantizer.quantize_fs_dither(frame));
+}
diff --git a/benches/render.rs b/benches/render.rs
new file mode 100644
index 0000000..2f63d85
--- /dev/null
+++ b/benches/render.rs
@@ -0,0 +1,77 @@
+//! Benchmarks for template loading and frame compositing.
+//!
+//! `Render::apply` is the hot path of every maple output: it is run once per
+//! animation frame, for every input layer.
+
+mod common;
+
+use divan::Bencher;
+use maple::{Repository, render::RenderQuality};
+
+fn main() {
+ common::init();
+ divan::main();
+}
+
+/// Parsing a template archive: zip central directory plus `template.json`.
+#[divan::bench]
+fn open_template(bencher: Bencher) {
+ let bytes = common::template_bytes("toaster");
+
+ bencher
+ .with_inputs(|| bytes.clone())
+ .bench_local_values(|bytes| Repository::load_from_bytes(bytes).expect("load template"));
+}
+
+/// Decoding the five PNG layers (light, dark, map, sel, transparent) of a frame.
+#[divan::bench]
+fn decode_frame_layers(bencher: Bencher) {
+ bencher
+ .with_inputs(|| common::repository("toaster"))
+ .bench_local_refs(|repo| repo.take_mapping(11).expect("take mapping"));
+}
+
+/// Compositing a frame with the default 9-tap antialiased sampler.
+#[divan::bench]
+fn composite_sampled(bencher: Bencher) {
+ let inputs = common::inputs("frog.jpg");
+ let mut render = common::render("toaster", 11, RenderQuality::Sampled);
+
+ bencher.bench_local(|| render.apply(&inputs).expect("apply render"));
+}
+
+/// Compositing the same frame with the cheaper nearest-neighbour sampler.
+#[divan::bench]
+fn composite_simple(bencher: Bencher) {
+ let inputs = common::inputs("frog.jpg");
+ let mut render = common::render("toaster", 11, RenderQuality::Simple);
+
+ bencher.bench_local(|| render.apply(&inputs).expect("apply render"));
+}
+
+/// A template whose frames use the edge-smoothing post-process.
+#[divan::bench]
+fn composite_multi_layer(bencher: Bencher) {
+ let inputs = common::inputs("monkey.jpg");
+ let mut render = common::render("book", 6, RenderQuality::Sampled);
+
+ bencher.bench_local(|| render.apply(&inputs).expect("apply render"));
+}
+
+/// Compositing plus the downscale/letterbox pass used by `-w`/`-H`.
+#[divan::bench]
+fn composite_scaled(bencher: Bencher) {
+ let inputs = common::inputs("frog.jpg");
+ let mut render = common::render("toaster", 11, RenderQuality::Sampled);
+
+ bencher.bench_local(|| render.apply_scaled(&inputs, 800, 600).expect("apply render"));
+}
+
+/// The point cloud scan backing `--auto_zoom` and layer detection.
+#[divan::bench]
+fn frame_cloud(bencher: Bencher) {
+ let input = common::input("frog.jpg");
+ let render = common::render("toaster", 11, RenderQuality::Sampled);
+
+ bencher.bench_local(|| render.get_cloud(&input).expect("get cloud"));
+}
diff --git a/benches/webp.rs b/benches/webp.rs
new file mode 100644
index 0000000..948e73c
--- /dev/null
+++ b/benches/webp.rs
@@ -0,0 +1,84 @@
+//! Benchmarks for animated WebP output, the `--webp` and `--webp_single` paths.
+//!
+//! WebP keeps full 24-bit color with alpha, so unlike GIF it skips color
+//! quantization entirely: the cost is compositing plus libwebp. `webp_anim` is
+//! native-only and therefore not re-exported by `maple`, so these benchmarks
+//! reach it through `maple_render_core` directly.
+
+mod common;
+
+use divan::Bencher;
+use maple::render::RenderQuality;
+use maple_render_core::webp_anim::{WebpAnim, WebpOptions};
+
+fn main() {
+ common::init();
+ divan::main();
+}
+
+/// Default lossy settings: quality 95, method 4.
+fn lossy() -> WebpOptions {
+ WebpOptions::default()
+}
+
+fn lossless() -> WebpOptions {
+ WebpOptions { lossless: true, ..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);
+ anim.set_timing(repo.get_period(), repo.get_hold());
+ anim.set_options(options);
+ anim
+}
+
+/// The same animation with every frame composited up front, so that only the
+/// libwebp encoding is measured.
+fn warm_anim(template: &str, image: &str, options: WebpOptions) -> WebpAnim {
+ let mut anim = WebpAnim::new(common::warm_renders(template, image, RenderQuality::Sampled));
+ let repo = common::repository(template);
+ anim.set_timing(repo.get_period(), repo.get_hold());
+ anim.set_options(options);
+ anim
+}
+
+/// 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)]
+fn webp_animation(bencher: Bencher) {
+ bencher
+ .with_inputs(|| anim("book", "frog.jpg", lossy()))
+ .bench_local_refs(|anim| anim.encode().expect("webp encode"));
+}
+
+/// WebP serialization on its own: frame prediction and entropy coding of
+/// already composited frames, the counterpart of `gif_serialize`.
+#[divan::bench(sample_count = 3, sample_size = 1)]
+fn webp_serialize(bencher: Bencher) {
+ bencher
+ .with_inputs(|| warm_anim("book", "frog.jpg", lossy()))
+ .bench_local_refs(|anim| anim.encode().expect("webp encode"));
+}
+
+/// A single still frame through libwebp, the `--webp_single` path.
+#[divan::bench]
+fn webp_single_frame(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+ let options = lossy();
+
+ bencher.bench_local(|| WebpAnim::encode_single(frame, &options).expect("webp encode"));
+}
+
+/// The same still frame encoded losslessly, the `--webp_lossless` path. A whole
+/// lossless animation is left out on purpose: it takes minutes under the CPU
+/// simulator, and one frame already covers the lossless encoder.
+#[divan::bench]
+fn webp_single_frame_lossless(bencher: Bencher) {
+ let composited = common::composited_frame("toaster", 11, "frog.jpg");
+ let frame = composited.get();
+ let options = lossless();
+
+ bencher.bench_local(|| WebpAnim::encode_single(frame, &options).expect("webp encode"));
+}