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 ;
pub async
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 Image;
let pipeline = from_disk.resize;
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 ;
async
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 ;
async
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 ;
async
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 ;
;
Install it during bootstrap(), before the first image is processed:
use FrameworkError;
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:
- The built-in
magickdriver. SetIMAGE_DRIVER=magick. Format breadth comes from the host's ImageMagick delegates, and there is no build dependency to manage. - 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'sVipsImageis not thread safe, which the one-image-per-process()-call driver shape already accommodates. - Any CLI tool, wrapped the way the
magickdriver is: a fixed argument array handed tostd::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 ;
/// Grow a 1x1 byte-literal fixture into whatever size a test needs.
async
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
- File Storage for the disks
from_diskandstoreread and write. - HTTP Responses for what
to_response()hands back. - Environment Variables for the full list of image settings.
