Rust's WebP story requires two crates: image for loading JPEG and webp for encoding. The webp crate wraps Google's libwebp C library, which produces byte-for-byte identical output to cwebp. For batch image processing in Rust, this combination gives excellent performance — Rayon can parallelize conversions across all cores trivially.

Method 1: image + webp crates (libwebp bindings)

The webp crate wraps Google's libwebp encoder. Combined with the image crate for JPEG loading, this is the idiomatic Rust path.

# Cargo.toml
[dependencies]
image = "0.25"
webp = "0.3"
# Note: webp crate links against libwebp — needs libwebp-dev on Linux
# Ubuntu: apt install libwebp-dev
use image::GenericImageView;
use std::path::Path;
use std::fs;

fn jpg_to_webp(
    src: &str,
    dst: &str,
    quality: f32,  // 0.0 to 100.0; 80.0 is a good default
) -> Result<(), Box> {
    let img = image::open(src)?;
    let (width, height) = img.dimensions();

    // Convert to RGBA for the webp encoder
    let rgba = img.to_rgba8();

    let encoder = webp::Encoder::from_rgba(rgba.as_raw(), width, height);
    let webp_data = encoder.encode(quality);

    fs::write(dst, &*webp_data)?;
    println!("Converted: {} -> {} ({} bytes)", src, dst, webp_data.len());
    Ok(())
}

fn jpg_to_webp_lossless(
    src: &str,
    dst: &str,
) -> Result<(), Box> {
    let img = image::open(src)?;
    let (width, height) = img.dimensions();
    let rgba = img.to_rgba8();

    let encoder = webp::Encoder::from_rgba(rgba.as_raw(), width, height);
    let webp_data = encoder.encode_lossless();

    fs::write(dst, &*webp_data)?;
    Ok(())
}

fn main() -> Result<(), Box> {
    jpg_to_webp("photo.jpg", "photo.webp", 80.0)?;
    Ok(())
}
// Parallel batch conversion with Rayon
use rayon::prelude::*;

fn batch_jpg_to_webp(
    src_dir: &str,
    out_dir: &str,
    quality: f32,
) -> Result> {
    fs::create_dir_all(out_dir)?;

    let entries: Vec<_> = fs::read_dir(src_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| {
            let name = e.file_name().to_string_lossy().to_lowercase();
            name.ends_with(".jpg") || name.ends_with(".jpeg")
        })
        .collect();

    let count = entries.len();
    entries.par_iter().try_for_each(|entry| -> Result<(), Box> {
        let src = entry.path();
        let stem = src.file_stem().unwrap().to_string_lossy();
        let dst = format!("{}/{}.webp", out_dir, stem);
        let img = image::open(&src)?;
        let (w, h) = img.dimensions();
        let rgba = img.to_rgba8();
        let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h);
        let data = encoder.encode(quality);
        fs::write(&dst, &*data)?;
        Ok(())
    })?;

    Ok(count)
}

Method 2: image crate with WebP feature (pure Rust, no libwebp)

The image crate includes a pure-Rust WebP encoder behind the webp feature flag. No system library needed — it compiles fully from source. The output quality is slightly lower than libwebp but good enough for most use cases.

# Cargo.toml
[dependencies]
image = { version = "0.25", features = ["webp"] }
use std::fs;

fn jpg_to_webp_pure(
    src: &str,
    dst: &str,
) -> Result<(), Box> {
    let img = image::open(src)?;

    // image crate's WebP encoder — no quality control in current version,
    // uses a fixed lossless-ish mode. For quality control, use the webp crate.
    img.save(dst)?;  // auto-detects WebP from .webp extension
    println!("Saved: {}", dst);
    Ok(())
}

fn main() -> Result<(), Box> {
    jpg_to_webp_pure("photo.jpg", "photo.webp")?;
    Ok(())
}

The image crate's built-in WebP encoder doesn't yet support quality control — it uses a default setting. For production use where you need quality tuning, use the webp crate. The advantage of the image crate's encoder is zero C dependencies — it compiles to WASM and cross-compiles cleanly to any Rust target.

Method 3: ChangeThisFile API (reqwest, no libwebp)

The API handles WebP encoding server-side. Source auto-detected from filename — pass target=webp. Free tier: 1,000 conversions/month, no card needed.

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "blocking"] }
use reqwest::blocking::{Client, multipart};
use std::fs;
use std::path::Path;

const API_KEY: &str = "ctf_sk_your_key_here";

fn jpg_to_webp_api(
    src: &str,
    dst: &str,
) -> Result<(), Box> {
    let client = Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()?;

    let file_bytes = fs::read(src)?;
    let filename = Path::new(src)
        .file_name()
        .unwrap()
        .to_string_lossy()
        .to_string();

    let form = multipart::Form::new()
        .part(
            "file",
            multipart::Part::bytes(file_bytes)
                .file_name(filename)
                .mime_str("image/jpeg")?,
        )
        .text("target", "webp");

    let response = client
        .post("https://changethisfile.com/v1/convert")
        .header("Authorization", format!("Bearer {}", API_KEY))
        .multipart(form)
        .send()?;

    if !response.status().is_success() {
        return Err(format!("API error: {}", response.status()).into());
    }

    fs::write(dst, response.bytes()?)?;
    Ok(())
}

fn main() -> Result<(), Box> {
    jpg_to_webp_api("photo.jpg", "photo.webp")?;
    println!("Done");
    Ok(())
}

When to use each

ApproachBest forTradeoff
image + webp cratesProduction quality, quality control, batch with RayonRequires libwebp-dev system package
image crate (pure Rust)Cross-compilation, WASM, zero C depsNo quality control in current version; slightly lower compression
ChangeThisFile APINo libwebp, minimal binary footprintNetwork call per image; 25MB free tier limit

Production tips

  • Use Rayon for batch conversion. WebP encoding is CPU-bound and embarrassingly parallel. par_iter() across a directory of images gives near-linear speedup with core count.
  • Build with --release for production. Debug builds are 10-20x slower for image processing. Always use cargo build --release for benchmarks and production deployments.
  • Static link libwebp to avoid deployment issues. Add WEBP_SYS_STATIC=1 to your build environment to statically link libwebp. This creates a fully self-contained binary.
  • Quality 80.0 is a good default. The webp crate's quality parameter is 0.0-100.0. Quality 80 produces files roughly comparable to JPEG quality 90 in visual quality.
  • Handle large images with image::DynamicImage::resize_to_fill. For thumbnail generation, resize before encoding: img.resize(width, height, image::imageops::FilterType::Lanczos3).

The image + webp crates are the right choice for production Rust services that need quality control. For cross-compilation or WASM targets, the image crate's pure-Rust encoder avoids C dependencies. For services where minimizing native deps matters, the API works via reqwest. Free tier: 1,000 conversions/month.