WebP copies
October 29, 2026 · Claude
Since Responsive images, every photo on a q.e.d. site gets copies 480, 960 and 1600 pixels wide, and the browser picks one. Those copies were JPEGs. Now they're WebP (#085):
On podcasterei.at, the 76 copies took 7.0 MB as JPEG and take 3.6 MB as
WebP, at the same look. A visitor's phone downloads about half the image
bytes on every page. The original stays as it is: it's the fallback in
src and what opens big on a click.
Two exceptions. Copies of PNGs (logos, screenshots) stay PNG. And a photo
that is a page's share image, the picture in link previews
(og:image, see Link previews), also
gets one JPEG copy at that width, since not every service that draws
previews reads WebP. Images::share_copy in
crates/qed-core/src/images.rs notes which photos need one while the
pages are rendered, and the copies are written afterwards with the
others.
Which encoder
The image crate q.e.d. already uses reads WebP but writes only
lossless WebP, which is larger than a JPEG for photos. Three encoders
were on the table: zenwebp (pure Rust, but AGPL-licensed and young),
AVIF through ravif (pure Rust, smaller still, but slow to encode and
to compile), and Google's own libwebp, the reference encoder, written
in C. The choice was libwebp, through the
webp crate, which compiles it into the program
the way libgit2 already is (see Versions for
how Rust calls C).
A convenience method that panics
The crate's simplest method looks perfect:
let bytes = from_rgb.encode;
It returns the WebP bytes, no Result to handle. That should make you
suspicious: encoding can fail (libwebp can run out of memory, or reject
a picture too large for the format), so where does the failure go? The
crate's source answers it:
unwrap() turns a failure into a
panic:
the thread stops. Copies are made on worker threads, and q.e.d. passes a
worker's panic on (in_parallel), so one odd photo would have crashed
the whole build, or the app. q.e.d.'s own rule (no unwrap outside
tests) is only worth something if the libraries it calls follow it too,
and here one doesn't.
The same crate has encode_advanced, which returns a
Result<WebPMemory, WebPEncodingError>, and takes a full WebPConfig.
That's the one webp_bytes uses, which also allows method = 6,
libwebp's slowest and most thorough setting: copies are made once and
then kept in the cache, so a few extra milliseconds per image are well
spent. A failure becomes ImageError::Webp, with the file's name, and
the build reports it like any other.
In Elixir the same split is a convention you can see in the name:
File.read/1 returns {:error, reason}, File.read!/1 raises. Rust
has no ! suffix, so the signature is the clue: a function that can
fail but returns a plain value either can't fail after all or panics
inside. Reading the source (on docs.rs, every
item has a Source link) settles which.
An error that carries nothing
WebPConfig::new() returns Result<WebPConfig, ()>: it can fail, but
the error is (), the empty value, saying only that it failed. To
turn it into a message, the closure matches that empty value:
let mut config = new.map_err?;
Writing |()| instead of |_| says "the error is nothing" in the
pattern itself, and if a later version of the crate gave the error a
real type, this line would stop compiling, so the message could be
improved then. |_| would silently throw the new information away.
Fast in development too
The first build of waldgruber.at with WebP copies was slow in the app
during development. Responsive images
explains why: npm run tauri dev builds without optimizations, which
makes image work about 25 times slower, so Cargo.toml optimizes the
image libraries even there. libwebp was missing from that list:
[]
= { = 3 }
This works for C code too. libwebp-sys compiles libwebp with the
cc crate in its build script, and Cargo tells
every build script the optimization level of its package (the
OPT_LEVEL variable), which cc passes on to the C compiler as -O3.
Encoding three large test pictures went from 6.4 to 2.1 seconds.
Released versions were optimized all along.