Manual contentsDigging DeeperBrowse 116 chapters
Manual 17 min read

Images

Suprnova ships a Laravel-shaped image pipeline: build it in a handler, chain the operations you want, and finish with a terminal that hands you bytes, a response, or a stored file.

use suprnova::{Image, OutputFormat, Response, handler};

#[handler]
pub async fn thumbnail() -> Response {
    Ok(Image::from_path("storage/photos/hero.jpg")
        .cover(320, 320)
        .to_format(OutputFormat::WebP)
        .quality(80)
        .to_response()
        .await?)
}

That handler decodes the JPEG, fills a 320x320 box, crops the overflow from the centre, encodes WebP, and returns a 200 with Content-Type: image/webp.

The subsystem lives in suprnova::media, behind the default-on media feature. Everything you normally reach for - Image, OutputFormat, ImageDriver, ImageConfig - is re-exported flat at the crate root, so use suprnova::Image; is the import you want. The module name is plural-in-spirit on purpose: it is where the OxideAV-backed audio and video surfaces will live too.

If you are upgrading, note that the upload validator that used to be called Image is now ImageFile, which frees the plain name for this pipeline type. That mirrors Laravel, where the validation rule is ImageFile and the manipulation type is Image. See Requests for the validator.

The pipeline is lazy

Constructing an Image reads nothing and decodes nothing. Operations record themselves; the source is opened only when a terminal runs. So this is free:

use suprnova::Image;

let pipeline = Image::from_disk("uploads", "avatars/42.png").resize(64, 64);

Nothing has touched the disk yet. Image is Clone, and a clone re-runs the pipeline from its source rather than sharing a result.

Two constructors have to be eager, and say so in their docs: from_upload (an upload's temp file does not outlive the request) and from_stream (a stream can only be consumed once).

Construction

Constructor Source Eager?
Image::from_bytes(bytes) anything Into<Bytes> no
Image::from_path(path) the filesystem no
Image::from_disk(disk, path) a Storage disk no
Image::from_upload(&file).await? an UploadedFile yes
Image::from_stream(stream).await? a Stream<Item = io::Result<Bytes>> yes

from_stream enforces IMAGE_MAX_ALLOC_BYTES while collecting, so an endless stream is cut off rather than discovered after it has already filled memory.

Operations

Method Effect
resize(w, h) Exact dimensions, aspect ratio ignored
resize_width(w) / resize_height(h) One dimension, the other derived from the aspect ratio
scale(w, h) Fit inside the box, preserving aspect ratio. Never enlarges
scale_width(w) / scale_height(h) Scale down to at most one dimension. Never enlarges
crop(w, h, x, y) Cut a rectangle out. Errors if it falls outside the image
cover(w, h) Fill the box exactly, cropping the overflow from the centre
contain(w, h) Fit inside the box, preserving aspect ratio. No padding
rotate(degrees) Rotate clockwise by any angle, growing the canvas to fit
flip_vertically() / flip_horizontally() Laravel's flip and flop
blur(amount) Gaussian blur, 0..=100. 0 is a no-op
sharpen(amount) Unsharp mask, 0..=100. 0 is a no-op. 50 is the classic strength
grayscale() Desaturate. Spelled the Laravel way
to_format(format) Choose the output container: Jpeg, Png, WebP, WebPLossless, Gif or Bmp
quality(q) Encode quality, clamped to 1..=100, default 70

Values that would be nonsense are clamped rather than rejected: blur(500) records 100, quality(0) records 1. A crop that falls outside the image is a real error, not a clamp, because silently moving someone's crop box is worse than telling them.

rotate takes arbitrary angles. A 90-degree multiple takes an exact axis-aligned path with no resampling; anything else is bilinear, and the canvas grows so no pixel is clipped. The exposed corners are transparent where the output format has an alpha channel.

Terminals

Every terminal is async, consumes the Image, and runs the decode, transform, and encode work on a blocking thread so it never stalls the runtime. Source I/O happens before that hop, so a slow disk never occupies a blocking worker.

Terminal Returns
to_bytes() Vec<u8> of the encoded file
to_response() An HttpResponse with the right Content-Type
save(path) Writes to the filesystem
store(disk, path) Writes to a Storage disk
dimensions() (width, height) of the processed image
mime_type() The processed image's media type
dominant_color() The average colour, as #rrggbb

dimensions(), mime_type(), and dominant_color() all describe the finished image, not the source - the same contract Laravel has. Asking for the mime type still runs the pipeline, because reporting a type for an image that cannot actually be produced is a lie the caller would only discover later.

use suprnova::{FrameworkError, Image, OutputFormat};

async fn describe() -> Result<(), FrameworkError> {
    let banner = Image::from_path("hero.png").resize(1200, 400);

    // Reads (1200, 400), not the source's dimensions.
    let (width, height) = banner.clone().dimensions().await?;
    println!("{width}x{height}");

    let accent = banner.to_format(OutputFormat::Jpeg).dominant_color().await?;
    println!("{accent}");

    Ok(())
}

Formats

Five formats are read and written: PNG, JPEG, WebP, GIF, and BMP.

Format Reads Writes Quality knob
PNG yes yes ignored (lossless)
JPEG yes yes honoured
WebP (OutputFormat::WebP) yes yes honoured (lossy; see below for the lossless cases)
WebP (OutputFormat::WebPLossless) yes yes ignored (lossless)
GIF yes yes ignored (palette)
BMP yes yes ignored (lossless)

AVIF is neither read nor written: there is no AVIF encoder crate with a license compatible with Suprnova's. WebP is the modern-format path.

GIF output is palette-quantised to at most 256 colours with Floyd-Steinberg dithering before encoding, so a photographic source converts cleanly rather than erroring.

WebP

OutputFormat::WebP is lossy and uses the quality of the pipeline, the same dial JPEG has. The quality is 70 when you set none. OutputFormat::WebPLossless is always lossless and ignores the quality. Use it when the pixels of the file must be exact. Both variants have the content type image/webp and the extension webp.

use suprnova::{FrameworkError, Image, OutputFormat};

async fn encode() -> Result<(), FrameworkError> {
    // Lossy at quality 80.
    let small = Image::from_path("storage/photos/hero.jpg")
        .to_format(OutputFormat::WebP)
        .quality(80)
        .to_bytes()
        .await?;

    // Lossless: the quality has no effect.
    let exact = Image::from_path("storage/photos/hero.jpg")
        .to_format(OutputFormat::WebPLossless)
        .to_bytes()
        .await?;

    Ok(())
}

The built-in driver writes OutputFormat::WebP lossless, and ignores the quality, in two cases. In both the lossy form of WebP cannot hold the image:

  • A pixel is not fully opaque. The lossy encoder has no alpha channel, so a lossy file would lose the transparency.
  • A side is longer than 16383 px, the largest side a lossy frame can have.

The ImageMagick driver writes WebP lossy at every quality, and keeps the alpha channel. It writes WebPLossless lossless.

A WebP source that you resize and do not convert is written as WebP. An opaque one is therefore written lossy.

Storage

from_disk and store work against any registered Storage disk, so a resize-and-restore round trip never touches local paths:

use suprnova::{FrameworkError, Image};

async fn make_web_copy() -> Result<(), FrameworkError> {
    Image::from_disk("uploads", "originals/42.png")
        .scale(1024, 1024)
        .store("uploads", "web/42.png")
        .await
}

See File Storage for registering disks.

Decode limits

Decoding is where hostile input does damage: a few kilobytes can declare a 40000x40000 canvas and ask a server to allocate six gigabytes for it. Suprnova refuses that before allocating anything.

Var Default Purpose
IMAGE_MAX_DIMENSION 16384 Cap on width and height in pixels
IMAGE_MAX_ALLOC_BYTES 1073741824 (1 GiB) Cap on the memory one decode may allocate, and on the size of the source file itself
IMAGE_MAGICK_TIMEOUT_SECS 30 Wall-clock ceiling on one ImageMagick invocation (magick driver only)

The framework parses the input's own header - a few dozen bytes, no allocation - reads the declared dimensions, and rejects oversized input before a decoder is constructed. The same caps apply to resize targets, because resize(50_000, 50_000) allocates just as much whether the numbers came from an attacker or a typo.

A header can also declare a small image over data that asks for far more, and the default driver bounds that too:

  • PNG pixel data that inflates past the size its header declares is refused when the inflate reaches that size. A few kilobytes of compressed data can expand to gigabytes.
  • Only the first frame of an animated GIF is decoded, because the pipeline only uses the first frame, and decoding stops the moment that frame is complete. A first frame larger than the GIF's logical screen is refused before it is decoded.
  • A lossless WebP, or a WebP's lossless alpha plane, is read as far as its last prefix code before it decodes, and the tables those codes build count toward the limit. How many tables there are is written in the compressed data, not in a header: 160 KiB of codes can ask for 250 MB of tables for a 4x4 image. The read keeps no pixels and holds one group's tables at a time, at most about 17 KiB.
  • A file or stored source is read no further than IMAGE_MAX_ALLOC_BYTES, even when the size its storage reports is wrong or missing, as it is for a pipe.
  • A JPEG's Extended XMP segments are counted before it decodes. The JPEG decoder keeps every segment of an unfinished series and re-reads all of them after each marker, so the work grows with the square of the segment count: 100,000 one-byte segments, about 8 MB, ask for billions of comparisons. The default driver counts the bytes those passes would read and refuses the JPEG when that is over IMAGE_MAX_ALLOC_BYTES. A complete series, as cameras and editors write one, is far below it.

What a decode costs

IMAGE_MAX_ALLOC_BYTES is the most memory one decode may allocate, not only the size of the decoded image. Decoders hold more than the pixels they return: an inflated PNG next to its unfiltered rows, a progressive JPEG's coefficients, the canvases a GIF frame is composed on. So the default driver works out, from the image's headers, how many bytes its decode will allocate, and refuses the image when that is over the limit. The refusal names the estimate:

image exceeds configured decode limits: decoding this 8000x6000 image/png
needs about 1923381182 bytes, over the IMAGE_MAX_ALLOC_BYTES limit of 1073741824

As a guide, a decode needs about this many times width x height x 4 bytes:

Format Times
PNG, 8-bit 1.3 (grey or palette) to 5 (incompressible RGBA)
PNG, 16-bit 2.5 (grey) to 10 (incompressible RGBA). At the 1 GiB default, 16-bit RGBA tops out at about 27 megapixels and 16-bit RGB at about 36
GIF 1.0 to 1.1
JPEG, sequential (baseline, extended, arithmetic) 1.0 to 1.1
JPEG, progressive, or one component a scan 1.5 (grey) to 2.5 (4:4:4)
JPEG, lossless 1.3 (grey) to 4 (RGB)
WebP 1.4 to 2.4
BMP 1.0 to 2.1

So the default 1 GiB decodes a 48-megapixel photo (8000x6000) in every 8-bit format, a progressive 4:4:4 JPEG and a PNG of incompressible RGBA included. 16-bit PNG holds more and tops out lower, as the table says. Raise IMAGE_MAX_ALLOC_BYTES if your users upload larger images, or lower it on a small host.

A limit hit is a 4xx-shaped FrameworkError::param, because oversized input is a client problem, not a server fault.

Out-of-range configuration clamps with a warning rather than failing boot: IMAGE_MAX_DIMENSION=0 would reject every image in the application, which is not what anyone meant to configure.

One bound is not configurable

A WebP declares its real decoded size in its innermost bitstream chunk, not in the canvas header, so the framework walks the container to find it. That walk stops after 4096 chunks per level and follows nesting two levels deep, and a file that exceeds either is refused outright rather than measured.

It is refused rather than measured on purpose. Reporting a number from a walk that did not reach the end of the file would be a gate that a large enough pile of filler chunks could step around, so an unfinishable walk has no answer to give.

Neither number is tunable, and no IMAGE_MAX_* variable affects them - the error says so, rather than saying "configured", precisely so nobody spends an afternoon raising IMAGE_MAX_ALLOC_BYTES and watching nothing change. In practice only a deliberately hostile file gets near it: a 300-frame animation passes comfortably, and a 4100-frame one does not.

Backends

Like Laravel, the image surface is two drivers, chosen with IMAGE_DRIVER.

Driver Value Needs Reads
OxideAV oxideav (default) nothing PNG, JPEG, WebP, GIF, BMP
ImageMagick magick ImageMagick 7 on the host whatever the host's delegates provide

IMAGE_DRIVER=oxideav

The default. Pure Rust, built on the OxideAV codec family, with zune-jpeg decoding JPEG: no native library, nothing to install, nothing to configure. It is the right choice for almost every application, and it is what a scaffolded app gets.

It reads 8-bit JPEGs in every coding: baseline, progressive and arithmetic, greyscale, YCbCr at 4:4:4, 4:2:2, 4:2:0, 4:4:0 or 4:1:1, RGB, and lossless. CMYK and 12-bit JPEGs need the magick driver, and so does a lossless JPEG of more than 67,108,864 samples (width x height x components), which its decoder, oxideav-mjpeg, refuses.

IMAGE_DRIVER=magick

Opt-in. Runs a host-installed ImageMagick 7 binary, piping the image in over stdin and reading the result back over stdout - no temp files. The binary name comes from IMAGE_MAGICK_BINARY and defaults to magick; a missing binary is a clear error at first use, not a silent fallback.

Choose it when you need input formats the pure-Rust driver does not carry - HEIC being the common one. The cost is a host dependency: the operator installs ImageMagick and its delegates, and owns their licensing. The framework links nothing and compiles nothing native either way.

Like the default driver, it works on the first frame of an animation and drops the rest. A GIF's first frame is composed onto the GIF's logical screen first, so both drivers produce the same image and report the same size for it.

Arguments are always a fixed array handed straight to the process, never a shell string, and every numeric argument is formatted from an already-validated field. There is no argument position user input can reach.

When the framework recognises the input, the decoder is named on the command line - png:- rather than a bare -. That matters: given a bare -, ImageMagick picks a coder from the bytes it is handed, so a file whose magic says MVG or MSL is read as a script regardless of what your application believed it was accepting. Pinning the coder makes a mislabelled file fail instead of becoming something else.

Input the framework cannot name still relies on your policy.xml. Reading those formats is the whole reason this driver exists, so that path cannot pin a coder. Harden the host's ImageMagick policy - at minimum disabling the MVG, MSL, URL, HTTPS, EPHEMERAL, and TEXT coders - if you accept arbitrary uploads under IMAGE_DRIVER=magick.

Decode limits are enforced twice under this driver. For the five formats the framework can parse, the header check above runs before the process is spawned. For everything else a pre-parse is impossible, so every invocation carries ImageMagick's own -limit flags derived from the same configuration, including a wall-clock -limit time.

That flag is not the whole story, because ImageMagick enforces it with its own resource monitor, and a process wedged inside a delegate before that monitor engages never trips it. So Suprnova also holds its own deadline: past IMAGE_MAGICK_TIMEOUT_SECS (plus a couple of seconds of grace for IM's own limit to fire first) it kills the process group - delegates included, not just the process it started - and stops waiting on the pipes. A stalled delegate therefore cannot pin a worker thread. Delegates that stay in the process group die with it; one that leaves the group, or a host with no kill binary, can outlive the request - that residual is what host process supervision is for.

A kill surfaces as a 5xx FrameworkError::internal, not a 4xx, even though a request triggered it. Something wedged the image path badly enough to need killing, which belongs in server-error monitoring where an operator will see it - classifying it as a client error would file away the one condition here worth paging on.

Custom drivers

ImageDriver is the extension point: &[u8] in, Vec<u8> out, no codec type crossing the boundary.

use suprnova::{FrameworkError, ImageDriver, ImagePipeline};

struct MyDriver;

impl ImageDriver for MyDriver {
    fn process(
        &self,
        contents: &[u8],
        pipeline: &ImagePipeline,
    ) -> Result<Vec<u8>, FrameworkError> {
        // Decode `contents`, replay `pipeline.transformations`, then encode
        // to `pipeline.format` at `pipeline.quality`. Give every
        // `OutputFormat` variant an arm, `WebPLossless` included: the
        // enum is not `#[non_exhaustive]`.
        todo!()
    }

    fn dimensions(&self, contents: &[u8]) -> Result<(u32, u32), FrameworkError> {
        todo!()
    }

    fn dominant_color(&self, contents: &[u8]) -> Result<String, FrameworkError> {
        todo!()
    }

    fn name(&self) -> &'static str {
        "mine"
    }
}

Install it during bootstrap(), before the first image is processed:

use suprnova::FrameworkError;

pub fn register() -> Result<(), FrameworkError> {
    suprnova::media::set_default_driver(Box::new(MyDriver))
}

A conforming driver enforces the configured ImageConfig limits before allocating for a decode. The framework cannot do it on a driver's behalf, because it never sees the decoded buffer.

Reaching more formats

If the built-in five are not enough, there are three routes, in rough order of how much you take on:

  1. The built-in magick driver. Set IMAGE_DRIVER=magick. Format breadth comes from the host's ImageMagick delegates, and there is no build dependency to manage.
  2. A custom driver around libvips, for example via the libvips-rust-bindings crate (MIT). libvips is the engine behind Node's sharp, with a very wide format range - JPEG, JPEG XL, TIFF, PNG, WebP, HEIC, AVIF, PDF, SVG, GIF, and more, plus ImageMagick delegation - and strong streaming performance. It binds the libvips C library, so your app installs libvips at build and run time and owns that dependency, which is exactly why it belongs behind the trait rather than in the framework. One practical note: the binding's VipsImage is not thread safe, which the one-image-per-process()-call driver shape already accommodates.
  3. Any CLI tool, wrapped the way the magick driver is: a fixed argument array handed to std::process::Command, image bytes over stdin and out over stdout, never a shell string.

Suprnova endorses the trait boundary, not any particular dependency behind it. What sits back there is your call, and so is its licensing.

Testing

The subsystem needs no fixtures on disk - it is its own fixture factory once decode and encode round-trip:

use suprnova::{FrameworkError, Image, OutputFormat};

/// Grow a 1x1 byte-literal fixture into whatever size a test needs.
async fn fixture(source: &[u8]) -> Result<Vec<u8>, FrameworkError> {
    Image::from_bytes(source.to_vec())
        .resize(4, 2)
        .to_format(OutputFormat::Png)
        .to_bytes()
        .await
}

Tests that tighten the decode limits must be serialised: the limits are process-global, so a parallel sibling would decode under the tightened cap.

Why Suprnova diverges

No HEIC in the default driver, and the reason is patents. HEVC, the codec inside HEIC, is patent-encumbered - the Access Advance pool among others. Suprnova installs no native libraries, so a built-in decoder would have to be pure Rust and would carry that exposure directly, and the one credible pure-Rust decoder is dual AGPL-3.0/commercial, which is a per-application legal obligation rather than something an MIT framework gets to default anybody into.

Both frameworks make HEIC a host-provisioning concern; Suprnova's version just has one fewer moving part. Laravel's default driver, GD, cannot read HEIC at all, and its Imagick path needs the libheif delegate compiled into both the system ImageMagick binary and the PHP imagick extension. In Suprnova the default driver does not read HEIC, and IMAGE_DRIVER=magick reads it whenever the host's ImageMagick carries the libheif delegate - no extension layer in between. So HEIC ingestion works: install ImageMagick with libheif through your package manager and flip the env var. The licensing sits where it belongs, with the host.

When the oxideav driver meets a HEIC file it says so by name, points at this chapter, and names both ways forward, rather than returning a generic "unsupported format".

No base64 or URL constructors. Laravel's ImageManager has ->read($base64) and ->read($url). from_bytes composes with whatever produced the bytes, including the HTTP client, and keeping a URL fetch out of the image subsystem keeps its timeouts, retries, and SSRF policy in one place instead of two.

from_stream is eager, with a cap. Laravel's contents are a lazy closure. A stream cannot be replayed, so this one is drained at construction, counting bytes against IMAGE_MAX_ALLOC_BYTES as it goes.

contain does not pad. It fits the image inside the box and stops there; it does not letterbox onto a background. Compose it with a background yourself if you need one.

Resize uses bilinear resampling. The backend's filter set ships nearest-neighbour and bilinear; bilinear is its documented default for natural images.

Images are never serialisable. Laravel throws on __serialize and Suprnova simply does not implement it. Store the path or the disk key and rebuild the pipeline.

Next