A photograph's thumbnail, its sizes for a srcset, its WebP: a file's
variants, by preset. Storehouse keeps the set of sizes closed, gives each
variant a URL that never changes, and deletes the variants with their
original. What renders them is up to the app:
- In the app, with libvips (
Storehouse.Renderer.Vix): each variant made the first time it is asked for, stored beside its original, and served by a plug. For an app without an image service. - Elsewhere, with imgproxy behind a CDN (
Storehouse.Renderer.Imgproxy), or a service of the app's own: Storehouse builds and signs the URLs, and renders nothing.
Presets
Only a preset's sizes are ever rendered. A URL with any other size gives
404, so no one can make the server render at a size of their choosing.
config :storehouse, Storehouse.Variants,
presets: [
thumb: [width: 320, height: 320, fit: :cover, focus: :attention],
photo: [widths: [640, 960, 1280, 1920, 2560], format: [:webp, :original]],
social: [width: 1200, aspect: {1200, 630}, fit: :cover, format: :jpeg, quality: 85]
]A preset has a width, or a ladder of :widths, and optionally a box
(:height, or :aspect across a ladder) that the image fits inside
(fit: :inside, the default) or fills, cropped (fit: :cover). A crop
keeps the centre, what stands out (:attention), the busiest part
(:entropy), or a point ({x, y}, in fractions). A variant is never
larger than its original: a width past the original's gives the
original's.
Formats are :original (the default: the original's, or JPEG when
browsers do not show it), :webp, :jpeg, :png, :avif and :gif. A
list is chosen from by the browser's Accept: the first it takes, else
the last. [:webp, :original] gives WebP to the browsers that ask for it,
and the original's format to the rest. A variant is stored once per
format, so the cache key is the format, not the Accept header with its
hundreds of spellings.
By default a variant is in sRGB and carries none of the original's
metadata, so where a photograph was taken never reaches a page.
profile: :keep keeps a wide-gamut photograph's colour profile, and
metadata: :all keeps its EXIF. Storehouse.Variants lists every
option.
In the app, with libvips
Add Vix. Storehouse.Renderer.Vix is then the renderer, and needs no
configuration:
# mix.exs
{:vix, "~> 0.31"}In the endpoint, before the router:
plug Storehouse.Variants.PlugThen, in a page:
<img src={Storehouse.variant_url(@photo.image, :thumb)} width="320" height="320" alt="" />
<Storehouse.Components.img file={@photo.image} preset={:photo}
sizes="(min-width: 60rem) 60rem, 100vw" alt={@photo.caption} />The second is a whole srcset, below.
Storehouse.variant_url/3 is the same every time, and costs nothing: it
reads no store. The first request for a variant makes it: the plug
downloads the original, renders it, stores the result under
variants/<original key>/ and sends it. Every request after that is
served from the store. Each variant is sent with
Cache-Control: public, max-age=31536000, immutable (and Vary: Accept
for a preset of several formats), so a CDN in front keeps it for a year.
A URL carries a fingerprint of its preset. A preset you change gets new URLs, so no CDN serves a variant of the old preset. A request for an old fingerprint, from a page cached before the change, is redirected to the current URL.
Made once. Fifty requests at once for a variant not yet made render it
once. The first caller starts a process registered under the variant's key
in :global, across every node of the cluster, and the other callers wait
for its result. A caller waits up to :timeout (60 seconds by default),
then gets 503. To make a variant outside a request, before its first
visitor:
{:ok, thumb} = Storehouse.variant(file, :thumb)For example, in a job after an upload, for the sizes a page will ask for first.
Which originals. The plug renders any image in the store whose key it
is given. Files that must stay private (served only at signed URLs) should
be kept out of it with :only, a list of key prefixes or a function:
plug Storehouse.Variants.Plug, only: ["photos/", "avatars/"]Formats. The precompiled libvips that Vix brings reads JPEG, PNG, WebP, TIFF, GIF and AVIF, and writes all of them except AVIF. Build Vix against the system's libvips for HEIC, or to write AVIF.
imgproxy behind a CDN
An image-heavy app often renders outside the BEAM already. With
Storehouse.Renderer.Imgproxy, Storehouse builds imgproxy's URLs for the
presets, signed with imgproxy's key and salt, and the app renders and
stores nothing:
config :storehouse, Storehouse.Variants,
renderer:
{Storehouse.Renderer.Imgproxy,
url: "https://images.example.com",
key: System.fetch_env!("IMGPROXY_KEY"),
salt: System.fetch_env!("IMGPROXY_SALT"),
source: "s3://my-bucket/"},
presets: [
photo: [widths: [640, 960, 1280, 1920, 2560, 3840], format: [:webp, :original]]
]Storehouse.variant_url(file, :photo, width: 1280)
#=> "https://images.example.com/<signature>/w:1280/czM6Ly9teS1idWNrZXQv…"imgproxy reads the original from the bucket (source:), never enlarges
it, and strips its metadata. A CDN in front of imgproxy keeps each URL.
For a preset of several formats, Storehouse sets no format and imgproxy
chooses one from Accept (turn on IMGPROXY_AUTO_WEBP or
IMGPROXY_AUTO_AVIF).
A preset can carry imgproxy's own processing options as they are, which are added after the ones Storehouse writes:
photo: [
widths: [640, 1280, 1920, 3840, 7680],
profile: :keep,
options: [imgproxy: "fq:webp:86:jpeg:90"]
]
#=> …/w:1280/scp:0/fq:webp:86:jpeg:90/<source>Since the URLs are signed, a call may also give a photograph's own focus point, which a URL for the plug cannot carry:
Storehouse.variant_url(file, :social, focus: {photo.focus_x, photo.focus_y})A renderer of your own
A renderer is a module of Storehouse.Renderer that implements one or
both callbacks: render/4 renders a variant from a file on the disk to
another, and url/3 gives the URL of a variant another service renders.
The presets, the closed set of sizes and the fingerprints stay
Storehouse's.
For example, for URLs of another shape, such as a CDN's Worker that signs short addresses itself:
defmodule MyApp.ShortImages do
@behaviour Storehouse.Renderer
@impl true
def url(variant, file, _opts),
do: "https://images.example.com/p/#{file.key}/#{variant.width}"
endStorehouse.Renderer.Imgproxy.path/3 and signed_path/2 build and sign
imgproxy's path, for a renderer that wraps it.
Responsive images
A preset with a ladder of widths gives every screen its size.
Storehouse.Responsive.resolve/3 makes what a page needs from it,
reading no store:
Storehouse.Responsive.resolve(file, :photo, sizes: "(min-width: 60rem) 60rem, 100vw")
#=> %Storehouse.Responsive{
# src: "/variants/…/photo-1920-…",
# srcset: "/variants/…/photo-640-… 640w, /variants/…/photo-1280-… 1280w, /variants/…/photo-1920-… 1500w",
# sizes: "(min-width: 60rem) 60rem, 100vw",
# width: 1500, height: 1000,
# placeholder: "data:image/webp;base64,…", color: "#9a7b76", thumbhash: …, blurhash: …}The srcset lists the ladder's widths below the original's, then the
original's own width. A 1500-pixel photograph is never offered at 1920,
and one wider than the ladder is offered at the ladder's widths. Each
width is requested at its step of the ladder, or the next one up, so every
photograph uses the same few sizes and a CDN keeps few copies. width and
height are those of the largest, so the page keeps the room before the
image arrives. The placeholder, colour and hashes come from the file's
analysis, when it has them (see the analysis guide).
It encodes as JSON, for an app with a JavaScript frontend:
json(conn, %{photo: Storehouse.Responsive.resolve(photo.image, :photo)})<img src={photo.src} srcSet={photo.srcset} sizes="(min-width: 60rem) 60rem, 100vw"
width={photo.width} height={photo.height}
style={{ background: `${photo.color} url(${photo.placeholder}) center / cover` }} />In HEEx, Storehouse.Components.img/1 renders it. It paints the
placeholder and the colour under the image, and only a data URI of an
image and a #rrggbb colour ever reach its style.
Choosing sizes
sizes tells the browser how wide the photograph will be on the page
before it has the CSS, so that it picks a size from the srcset.
Describe the layout's slot for it, not the image, and give each slot
of a layout its own:
- a photograph across the page:
100vw; - in a column of at most 60rem, else across:
(min-width: 60rem) 60rem, 100vw; - one of two side by side above 48rem:
(min-width: 48rem) 50vw, 100vw; - a thumbnail of a grid of four, in a page of 80rem:
(min-width: 80rem) 20rem, 25vw.
A sizes larger than the slot only costs bytes; a smaller one shows a
blurred photograph on a sharp screen. The browser multiplies it by the
screen's density itself.
Warming
A variant is made the first time it is requested, and for a large
original that takes seconds. Storehouse.Responsive.warm/3 requests each
size once, before the first visitor does:
- with a renderer in the app, it makes each size with
Storehouse.variant/3; - with imgproxy or another service, it requests each URL with the
Acceptof a current browser, so that the service renders the size and the CDN in front keeps it.
warm_later/3 does the same out of the request, as a job of the app's
runner, unique per file, without reading the original. Put it on a
queue of its own, one image at a time, so that visitors never wait behind
a batch of imports:
# config/prod.exs: off by default, so development asks no image service.
config :storehouse, Storehouse.Responsive, warm: true
config :my_app, Oban, queues: [default: 10, warm: 1]Storehouse.Responsive.warm_later(file, [:photo],
up_to: 3840,
runner: {Storehouse.Runner.Oban, queue: :warm}
)up_to: leaves the largest sizes, such as a lightbox's, to be made when
they are requested. In a pipeline of steps run out of the request, the
Storehouse.Analyzer.Warm step does the same.
Deleted with their original
Variants are stored under their original's key, so one prefix holds all
of them: Storehouse.delete/2 of an original deletes them too, and
Storehouse.delete_prefix/2 deletes those of every file under the
prefix. This happens when variants are configured with a renderer that
stores them, on a store that lists files (Storehouse.Local,
Storehouse.S3). Variants that imgproxy and a CDN keep are theirs to
expire.
Telemetry
Each Storehouse.variant/3 is a span, [:storehouse, :variant, ...],
with the original's :key, the :variant's key, the :preset and the
:width, and how it went (result: :found | :made | :error).