On-demand image optimization, in Rust

The URL is the API,
with claws.

crabcrop fetches your original, applies the operations in the URL in order, and serves the result. Write the resize, crop, format, and filters into a signed URL, and skip the pre-generated thumbnails and the build step entirely.

Signed with HMAC-SHA256. Cached by canonical key. Served with Cache-Control: immutable for a year.

Hi, I am Snap. I snap images to size.
300x200 Snap, the crochet crab mascot of crabcrop, waving one claw and holding a photo format(avif)
Snap, resident crabest. low tide
Our aim: the fastest image service on the planet.
URL anatomy

One URL. Every instruction inside it.

Every request is GET /<signature>/<operations>/<source-url>. Click a segment. Learn what it does, what it hides, and where it bites.

Snap the crochet crab at a small workbench, clicking chunky colored blocks into a row and tightening the last block with a wrench
Snap, building the URL.
Size family
https://img.example.com////:/

The signature is shortened for display. A real one is 64 hex characters, like the worked example in the panel.

signatureSignature

Grammar

    Example

    Watch out:

    I check this first. No match, no image.
    Signature. Proof the URL came from you. First segment, always.
    Operations. The recipe, in path order. Size, crop, filters, metadata segments like exp: and preset:.
    Source. Your original image, full URL. Everything from the first non-operation segment onward.
    Size
    300x200crop:1200x630:smartfit-in:800x600pad:1200x630:blur
    Filters
    filters:grayscalefilters:format(avif)filters:quality(auto)filters:radius(24)filters:watermark(...)filters:removebgfilters:upscale(2)filters:blurclass(person)
    Control
    preset:og-cardexp:1893456000maxage:3600filters:dpr(2)
    Playground

    The playground. Snap a URL together.

    Flip the controls. Watch a real crabcrop URL assemble, segment by segment, in the exact grammar the server parses. Runs entirely in your browser. Nothing you type leaves the page.

    Snap the crochet crab pushing big sliders and a lever on a friendly control panel while the photo print in its claw grows larger and more vivid
    Snap, working the controls.
    Signature
    Expiry
    Mode
    Size operation

    Pick one sizing move, or none. The crab does one snap at a time, in path order.

    Device pixel ratio

    Scales size and crop targets before any no_upscale cap. An explicit dpr in the URL beats the DPR client hint header.

    Each toggle joins the same filters: chain, in the order you switch it on. Also in the grammar, but needing coordinates, images, or server configuration a generic builder cannot set honestly: rgb, curve, convolution, trim, rotate, flip, monochrome, mask, blur_region, pixelate_region, watermark, overlay, text, blurfaces, pixelatefaces, blurclass, pixelateclass, facepad, keep_profile.

    Assembled URL

    The first segment above is a placeholder that marks where the signature goes. In production it is the hex HMAC-SHA256 of everything after it, computed with your secret by a client library or the CLI. Produce a real one in the Sign it section.

    What you just built

    What the server will do

    Flip something. I will assemble it.
    Transformation toy

    Before, after, and a little sideways.

    Drag the crop window by its claws. Stack filters in any order and watch the order change the picture. Crop coordinates are source pixels, and filter order is meaning.

    CSS preview. Your browser approximates the look with CSS filters. A running crabcrop server applies the real operations to the actual pixels, in this order. Blur strength and brighten do not map one to one.

    Snap the crochet crab sliding a glowing divider across a big photo print, dull and faded on one side, bright and vivid on the other
    Snap, sliding before into after.
    Original ยท 1200x800 Snap grilling skewers at a beach BBQ

    left 300, top 150, right 900, bottom 550, size 600x400

    Drag inside the window to move it, drag a claw to resize. Keyboard: arrows move by 1 px, Shift by 10 px, Alt plus arrows resize from the bottom right, Home resets.

    Preview
    Snap grilling skewers at a beach BBQ, cropped preview with the filter stack applied
    Snap grilling skewers at a beach BBQ, cropped preview without filters
    Filter palette
    Your chain, in order
    Empty for now. Toggle a filter and it lands here, numbered.

    Your chain so far:

    filters: (none yet, toggle a filter)
    That preview is CSS standing in. My real claws work on the actual pixels.
    Sign it

    Sign it in your stack.

    One signing scheme, specified once, with a canonical set of test vectors that every implementation runs as its test suite. The server verifies with the same Rust crate your app can build with, so client and server cannot drift.

    Compute HMAC-SHA256 over the remainder of the path, the operations and the source URL exactly as they appear with no leading slash, keyed with your secret. Hex encode the result. Use it as the first path segment.

    Snap the crochet crab wearing a notary sash, stamping a coral-red wax seal onto a paper scroll
    Snap, stamping it official.
    # Sign the remainder. The claw signs what it serves.
    # The hex output is segment one of the URL.
    printf '%s' '300x200/https://example.com/photo.jpg' \
      | openssl dgst -sha256 -hmac 'your-secret'
    # SHA2-256(stdin)= 3c1f6c180116661877bd4c85fd201a178087d74c1cc188cc584e819d901308c6
    // Sign once, reuse the URL everywhere.
    // Same vectors, same signature, no drift.
    import { buildURL, buildSrcSet, purgeSignature } from "@crabcrop/url";
    
    const url = buildURL({
      base: "https://img.example.com",
    secret: "your-secret",
      operations: "crop:1200x630:smart/filters:format(webp):quality(80)",
      source: "https://example.com/photo.jpg",
    });
    
    const srcset = buildSrcSet({ /* same options */ }, [400, 800, 1200]);
    # Standard library only. Nothing extra to haul in.
    # Builds the signed URL and the srcset in one go.
    from crabcrop_url import build_url, build_srcset, purge_signature
    
    url = build_url(
        base="https://images.example.com",
    secret="your-secret",
        operations="300x200/filters:grayscale",
        source="https://example.com/photo.jpg",
    )
    
    srcset = build_srcset(..., widths=[400, 800, 1200])
    // The same crate the server verifies with.
    // If it signs here, it verifies there.
    use crabcrop_url::{build_url, sign};
    
    let signature = sign(secret, "300x200/https://example.com/photo.jpg");
    let url = build_url(base, secret,
        "crop:1200x630:smart/filters:format(avif):quality(auto)",
        "https://example.com/photo.jpg")?;

    Check the scheme yourself, right now.

    Secret Jefe, message what do ye want for nothing?, HMAC-SHA256 ba5396e10039ec002bf1fde117eee48df8c2f06bc47b5f2d3272ea77f6862da9.

    Secret your-secret, remainder 300x200/https://example.com/photo.jpg, HMAC-SHA256 3c1f6c180116661877bd4c85fd201a178087d74c1cc188cc584e819d901308c6.

    Same vectors in every language. Sign here, verify there.
    Beyond the snap

    Small URL. Long reach.

    The transform is only the first tide. Caching, metadata, negotiation, and purging are built into the same URL.

    41 filters and encoding directives, chained inside one filters: segment in the order you write them.

    Snap the crochet crab at a small barbecue grill, flipping photo prints like patties with a spatula
    Snap, flipping prints on the grill.
    Snap, the crochet crab mascot, shown cycling through filter looks
    Six filters. Zero effort.

    Canonical keys, three-tier cache

    Signed URLs are content addressed, and entries key on the canonical form of the parsed operations, so equivalent spellings share one entry. A byte-bounded in-memory LRU sits in front, an optional disk tier underneath survives restarts and promotes hits back into memory, and an optional shared S3 tier behind both lets every replica serve the same stored results. Only 200 responses are cached. Errors never are.

    Cache-Control: immutable

    Presets that stay put

    A preset names an operation string on the server, so templates stay short and the transform menu stays under operator control. Treat a preset as a stable template and never edit one in place: roll a change out under a new name, because signed responses travel with a one-year immutable cache header that no purge can reach downstream.

    preset:og-card

    Purge one, or purge the group

    /purge invalidates every stored variant of one result across every cache tier, and /purge-group clears a whole preset output set named in the Cache-Group header. Purge signatures are HMACs under a subkey derived from your secret, a separate domain from image signatures, so an image signature can never be replayed as a purge.

    POST /purge-group/<signature>/small

    Meta JSON and placeholders

    Send Accept: application/json on any image URL and get dimensions, format, byte size, and source size instead of image bytes. Ask for placeholders and the same response adds the dominant color, a five-color palette, a tiny blurred JPEG preview, and a ThumbHash string your client decodes into an aspect-correct blur while the real image loads.

    Accept: application/json; placeholders=1

    quality(auto), verified

    quality(auto) picks a quality per image, from 50 for flat images to 95 for detailed ones. For JPEG and JPEG XL, crabcrop then encodes, decodes, and scores the result against the processed source with SSIMULACRA2, adjusting quality until it lands on target, and reports the achieved score back in meta.

    filters:quality(auto)

    Format negotiation that listens

    Name no format and the Accept header decides: among AVIF, JPEG XL, and WebP, the highest q-value wins, ties break by server preference in that order, and a format refused with q=0 is never picked. An explicit format(...) in the URL always wins, and wildcards do not count as an offer.

    Accept: image/avif;q=0.5, image/webp

    Steer the crop: focal and corners

    A focal: segment names the source rectangle the next crop must keep, and center and smart crops position their window on it, so the region the caller cares about stays in frame; a smart crop with a focal rectangle pending skips detection entirely. Or pin a corner: top-left, top-right, bottom-left, and bottom-right keep that corner's edges on whichever axis the crop cuts.

    focal:100x50:700x450/crop:800x600:smart

    Pro colour tools: curve and convolution

    filters:curve(...) remaps tones per channel, rgb, r, g, or b, through 2 to 16 control points you set: the fine tone control the additive filters only approximate. filters:convolution(...) applies your own kernel, 9 values (3x3) or 25 (5x5), with optional normalization, for the sharpen, blur, and edge styles the built-ins do not cover.

    filters:curve(r,0,64,255,255)

    A cache lifetime per request

    Responses normally ride a one-year immutable cache header. A maxage: segment overrides the max-age for that response only: it changes the header and never the cache key, so URLs differing only in maxage share one stored image. Presets stay immutable, unless a preset URL carries maxage itself, which is the caller's explicit choice for that response.

    maxage:3600

    Client hints, DPR, and Save-Data

    A URL that sizes nothing can take its width from client hints, quantized so nearby viewports share one cache entry, with dpr scaling the same template for retina displays. A request carrying Save-Data: on gets the default quality stepped down one notch, from 80 to 70, and stepped responses are cached separately.

    filters:dpr(2)

    Sources, where they already live

    Fetch originals over HTTP and HTTPS under a source policy, from S3 or S3-compatible stores, from Google Cloud Storage, or from a local file root that refuses path escapes. Every fetch is bounded at 10 seconds and 10 MiB, and EXIF orientation is applied at decode, so phone photos come out upright.

    s3://bucket/key ยท gs://bucket/key ยท file:///path

    Graceful by design

    An optional fallback image serves through the same operations when a source cannot be fetched or decoded, marked with X-Crabcrop-Fallback: 1. Signature and parse failures never fall back, and fallback responses are never cached, so a recovered origin is picked up on the next request.

    X-Crabcrop-Fallback: 1
    Formats

    Every major format. In and out.

    Originals arrive in whatever format they arrive in, and every visitor's browser does best with a different one. crabcrop takes any of them in and serves the right one back out, so it is the only image service you run: no separate thumbnail pipeline, no format-specific tooling, no build step.

    FormatDecodeEncodeNotes
    JPEG Yes Yes Universal, and quality(auto) is verified perceptually for it.
    PNG Yes Yes Lossless, and carries alpha.
    WebP Yesstill and animated Yesstill and animated; animated out is lossless The modern default.
    GIF Yesanimated Yesanimated Animation survives: frames, delays, and loop count are preserved.
    AVIF Yesin the Docker image, the release binaries, and the Homebrew install Yes The smallest files, and the first choice in negotiation. Source builds opt in to decoding with the avif-decode feature.
    JPEG XL Yes Yes quality(auto) is verified perceptually for it, like JPEG.
    HEIC / HEIF Yesdecoded in pure Rust No What phone cameras shoot. It comes in, and goes back out as any of the six output formats.

    Negotiation picks per visitor

    Name no format and the Accept header decides by q-value: AVIF first, then JPEG XL, then WebP. An explicit format(...) in the URL always wins over the header.

    filters:format(webp) always wins

    Animation survives the trip

    Animated GIF and WebP sources keep their frames, delays, and loop count when the output carries animation. Ask for a still and you get the first frame, and an animated AVIF source decodes its first frame too.

    GIF in, GIF out ยท WebP in, WebP out

    Alpha, end to end

    The radius, mask, and removebg chains end in transparency, so the output has to carry alpha: png, webp, jxl, and avif all do.

    filters:mask(circle):format(png)

    Every format, one pipeline

    EXIF orientation is applied at decode, so phone photos come out upright, and ICC profiles are converted to sRGB before any operation runs. SVG originals join the same pipeline too: sanitized first, with scripts and external references stripped, then rasterized.

    EXIF upright ยท ICC to sRGB ยท SVG sanitized

    One honest note: HEIC decodes in pure Rust, with no external tools. The first load of a phone photo is slower than other formats, then it caches like everything else.

    Snap the crab dressed as a magician, waving a star-tipped wand at the photo in his claw as its mountain scene transforms into a castle
    Snap, converting with a flourish.
    Seven formats in. Six back out.

    Fetch, resize, crop, filter, convert, cache, purge, meta, and AI, in a single Rust binary. That is all you need for image serving.

    AI features

    Models you supply. Inference in process.

    AI filters run ONNX models you provide as files, loaded at boot before the server reports ready. Inference runs behind a server-wide gate of 2 concurrent inferences, so a burst of AI requests cannot starve the normal transform pipeline.

    Snap the crochet crab scanning a photo print with a magnifying glass while neat boxes draw themselves around the mountain, the sun, and the bird in the scene
    AI runs on models you bring.
    The photo is the demo. Models are yours to supply.

    Proof, not promises: Snap went to the skate park, and every after image below is the direct output of a running crabcrop server fed these exact photos, with the operations printed under each demo. Nothing was retouched in an editor. Drag the sliders.

    Snap at the skate park with the background removed, over transparency
    Snap the crochet crab at a skate park, original photo
    AFTERBEFORE

    Background removal

    removebg keeps the subject and swaps the background for real alpha, ready to composite anywhere. Ask for a format that carries alpha: png, webp, jxl, or avif. The skateboard went with the background, because the mask keeps the subject, and the subject is the crab.

    fit-in:1000x667/filters:removebg:format(webp)

    The skate park photo with the skateboard pixelated by crabcrop
    Snap at the skate park next to a skateboard, original photo
    AFTERBEFORE

    Redact a class: pixelate

    The detection model found the board (skateboard, 0.53) and pixelateclass covered its box. Redaction hides pixels. It does not erase the object or paint in what was behind it, and crabcrop does not claim otherwise.

    fit-in:1100x733/filters:pixelateclass(skateboard)

    Snap at the edge of a skate bowl with the skater in the background blurred by crabcrop
    Snap at the edge of a skate bowl with a skater riding in the background, original photo
    AFTERBEFORE

    Redact people: blur

    Same idea, softer edge. blurclass(person) blurs every detected person and nothing else: handy for street shots, event photos, and one skater who never signed a release. Repeat class filters to cover more classes, matched case-insensitively.

    fit-in:1100x733/filters:blurclass(person)

    The full skate park photo, Snap left of center next to a skateboard
    source, 1920x1280
    Plain center crop of the skate park photo, clipping Snap's raised claw at the frame edge
    crop:760x760
    Smart crop of the same photo, keeping the whole crab in frame
    crop:760x760:smart

    Smart crop finds the subject

    The plain center crop clips Snap's raised claw at the edge. :smart scores the frame for the interesting region and keeps the whole crab in shot. Add a face model and :smart(faces) lets detected faces pick the focus instead, with :face cropping on a face outright.

    crop:760x760:smart

    Snap the crochet crab at a skate park, the photo described by the meta response
    {
      "tags": [
        { "label": "a crochet crab plush toy", "score": 1 }
      ],
      "alt_text": "A crochet crab plush toy,
        a skateboard, a skate park",
      "detections": [
        { "label": "teddy bear", "score": 0.693,
          "x": 58, "y": 82, "w": 504, "h": 454 },
        { "label": "skateboard", "score": 0.528,
          "x": 511, "y": 312, "w": 389, "h": 203 }
      ],
      "dominant_color": "#a47f74"
    }

    Meta that describes the picture

    Ask for JSON instead of pixels and a meta response adds CLIP tags and composed alt_text from a label vocabulary you supply, plus detection boxes, palette, dominant color, and a placeholder. It read this scene right. The detector's 80 classes hold no crab, so it called Snap a teddy bear at 0.69. Fair enough.

    signed fit-in:900x600 URL, sent with Accept: application/json; placeholders=1; detections=1

    ai/06

    Upscale

    filters:upscale(2) or upscale(4) runs super-resolution by that factor, capped at 4096 px per dimension. Source transparency is carried across, even though the model only sees RGB.

    ai/07

    Face redaction

    blurfaces and pixelatefaces cover every detected face. No landmarks, no identity, no embeddings: detection in, redaction out, and the boxes go no further than the response.

    The rules, with no fine print.

    • Models are operator-supplied files, deployment artifacts. crabcrop never downloads a model, at startup or at request time.
    • A configured model that cannot load is a startup error with a one-line diagnostic. It fails fast at boot, not on the first request.
    • No model configured means a clean 501, with a body like removebg is not configured on this server.
    • Nothing silently degrades. upscale never falls back to a plain resize, and a detection failure fails a redaction request rather than serving an unredacted image.
    Libraries and tools

    Build URLs from anywhere. Even from your agent.

    One grammar and one signing scheme, specified once. Every builder below runs the same canonical test vectors as its test suite.

    Rust crate

    crabcrop-url: sign, verify, build URLs and srcsets, and compute purge signatures, with the same crate the server verifies with.

    TypeScript

    @crabcrop/url: the same sign, verify, URL, srcset, and purge helpers for TypeScript, standard library only.

    Python

    crabcrop_url: the same builders and purge helpers for Python, standard library only.

    MCP server

    A first-party MCP server for coding agents, exposing sign_url, explain_url, fetch_meta, and purge_result over the same SDK and HTTP surface.

    Operator CLI

    The server binary doubles as its own operator CLI, with sign, explain, meta, purge, and purge-group, and URLs are validated against the server grammar before they are signed.

    Playground

    A static browser page for building, signing, previewing, and explaining URLs segment by segment, with the secret never leaving the page.

    Framework loaders

    A Next.js custom loader, a Nuxt provider, and a Hydrogen loader, all recommending presets so client-side URL building never needs the secret.

    Snap, the crabcrop crab, ready to work

    Put a crab in front of your images.

    Run one binary in front of your origin image store, sign your URLs, and let the URL do the work from there.

    One binary. I handle the rest.
    Start with the playground Read how signing works Source on GitHub github.com/crabcrop
    docker run -p 3000:3000 -e CRABCROP_SECRET=your-secret crabcrop serve