6.6 KiB
Image variants
An owner or admin defines named variants of the stored images. A request for
/static/uploadedImages/<storedName>?v=<name> generates the variant on first use, caches it and
serves the cached file afterwards. The original is never changed. Only a variant that exists and is
enabled is served. The visual spec is docs/design/screens/image-variants.md; the management routes
are in api.md; the layering is in architecture.md.
Public contract
| Request | Result |
|---|---|
…/a1b2.png |
The stored file, exactly as before this feature. |
…/a1b2.png?v=thumb |
200, the cached variant. Other query parameters (t=123) are ignored. |
?v= with an unknown, disabled, deleted or badly written name |
400 {"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"<name>"}. |
width, height, format or quality |
400 unsupported_parameter with the parameter as detail. It wins over unknown_variant. |
A missing source (also .. and nested paths) |
404, after the parameter checks. |
| A blob that has no image record (a generated variant file, an orphan) | 404 for ?v=, after the parameter checks and before any storage read. Without ?v= the blob is served as before. |
A source bigger than IMAGE_VARIANT_MAX_SOURCE_BYTES |
413 The source image is too large to generate this variant. (plain text). The original is never capped. |
A source whose header declares more than IMAGE_VARIANT_MAX_SOURCE_PIXELS (width x height) |
The same 413 and body. The header is read before any pixel buffer is allocated, so a small file declaring 30000 x 30000 is refused without being decoded. |
| A source that cannot be decoded, a header that cannot be read, or a failing render | 500, nothing is cached, the next request tries again. |
The 400 body is JSON with Content-Type: application/json. Every other error is plain text.
SVG sources and animated GIFs are served as they are, with the variant parameters validated but no generation, even above the size cap. A one-frame GIF is a normal raster source.
The response carries an ETag derived from the variant settings (width, height, fit, format, quality)
and the source stored name. It is an opaque quoted value. If-None-Match answers 304 before the cache
is read. Changing generate_on_upload or enabled does not change it; changing any rendering
setting does, so a client holding an old value gets a fresh 200. Cache-Control stays
public, no-cache.
Cache
Every generated file is a blob in image.Storage (local directory or S3) plus a row in
image_variant_files (variant, source stored name, cached stored name, content type, size, settings
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
(singleflight keyed by variant, settings and source); different variants or different images are
generated in parallel.
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
are never rendered. At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS (default 4) renders run at once:
the slot is taken inside the shared generation, so identical concurrent requests use one slot, and a
request waiting for a slot stops waiting when its client goes away, without rendering. A cached
file never takes a slot. The pixel cap defaults to 50000000 pixels.
- Editing a rendering setting (
width,height,fit,format,quality) purges the variant's files; the next request regenerates. Switchinggenerate_on_uploadorenabledkeeps them, so re-enabling a variant serves from its kept cache. - A row whose fingerprint differs from the variant (an edit raced a generation) or whose blob is gone is regenerated and replaced, and the old blob is removed.
- Deleting a variant, purging it, deleting a source image or merging copies removes the blobs first and the rows second. A blob that cannot be deleted keeps its row, the call answers 500 and a retry finishes the job. A blob that is already missing does not stop a purge.
generate_on_uploadis stored and returned only. Generating at upload time is not part of this release.
Geometry
Images are never enlarged.
- With one dimension, the image keeps its aspect ratio and the output is at most that size.
insidefits the whole image in the box, without padding.coverfills the box and crops around the centre. A box larger than the source is first scaled down, keeping its aspect ratio, until it fits inside the source (300 x 200 into 400 x 300 gives 267 x 200).containwith both width and height scales the image to fit inside the box, never enlarging it, and centres it on a canvas of exactly width x height: a 300 x 200 image in a 400 x 300 box stays 300 x 200 on a 400 x 300 canvas, and a 40 x 20 image in a 40 x 40 box is padded to 40 x 40. With a single dimension there is no box, so no padding and no upscaling.
Padding colour of contain
The design does not draw it, so it is decided here: the padding is transparent for png and webp
outputs and white (#FFFFFF) for jpeg outputs, which have no alpha. A jpeg output also flattens
any transparency of the source onto white (#FFFFFF).
Formats
original keeps the source format (a GIF source becomes PNG). jpeg and webp use quality
(default 82); png ignores it and the API returns quality: null. A WebP source always decodes. The
renderer reports the formats it can encode; the API refuses a format it cannot encode with
Format must be original, jpeg or png..
Spike: a CGO-free lossy WebP encoder
The standard library and golang.org/x/image can decode WebP but not encode it, and the build must
stay CGO_ENABLED=0 (six release targets, pure-Go SQLite).
| Candidate | Lossy | CGO_ENABLED=0 |
Verdict |
|---|---|---|---|
golang.org/x/image/webp |
decode only | yes | Cannot encode. |
github.com/gen2brain/webp v0.6.4 (libwebp translated to Go with wasm2go; purego only for the optional dynamic library) |
yes, with alpha | yes | Adopted. |
The spike built gen2brain/webp for linux, darwin and windows on amd64 and arm64 with
CGO_ENABLED=0, and go list -deps shows no package with cgo files. On a noisy 4032 x 3024 image
a lossy encode took about 0.75 s at quality 20 and 1.5 s at quality 95 (a smaller, real photograph is
faster). Output is RIFF....WEBP, and a lower quality gives a smaller file. Decision: webp is offered
as an output format. Should the dependency ever be dropped, NewVariantRendererEncoding already
builds a renderer without the WebP encoder: the API then refuses webp with 422, and a WebP source
with format original is served as PNG.