Files
RenewCMS/docs/image-variants.md
T
2026-10-08 02:33:32 +02:00

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. Switching generate_on_upload or enabled keeps 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_upload is 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.
  • inside fits the whole image in the box, without padding.
  • cover fills 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).
  • contain with 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.