Media library
How uploads, alt text and captions per locale and per site, licence and rights records, duplicates, orphans and image derivatives work, with every media setting in engine.yaml.
The media library holds every image, document, audio and video file a site uses. An asset is uploaded once, install-wide, and chosen in documents through an asset field. The library keeps the words about each asset (alt text and caption) per locale with a per-site override, records the licence it is used under, and generates the image sizes your templates ask for.
A minimal setup
A new project already has a working library: originals and derivatives are stored on the local disk under storage.filesystem.root.
# config/engine.yaml
storage:
driver: filesystem
filesystem:
root: ./storage
media:
max_upload_bytes: 30MB # default 50MB
require_alt_text: true # the default: an image needs alt text unless it is decorative A content type offers the library through an asset field, and a template renders the image through a named transform from images.yaml:
# config/schema/article.yaml
fields:
- name: hero
type: asset
alt: required # never empty unless the image is marked decorative
caption: true # offer a per-use caption {% if entry.hero %}{{ entry.hero.figure("hero", priority=true) }}{% endif %} figure() writes the picture, the caption and the licence credit. How to render images, the transforms in images.yaml and the responsive sizes are covered in Images.
Uploading
Staff upload in the admin’s Media screen, or from an asset field that declares upload: true. Members upload through the portal (DMO bundle), agents through the content MCP, and a migration through engine-dmo media import. Every path goes through the same pipeline:
media.max_upload_bytes, and the file’s type is sniffed from its bytes and checked against media.accept. The file name and the declared type are never trusted.media.max_pixels.media.strip_exif_gps is true. A photo whose metadata cannot be removed is refused rather than stored with its location. WebP originals are kept as uploaded; every derivative is re-encoded.media.require_alt_text on, an image uploaded in the admin needs alt text in the source locale unless it is marked decorative. Other locales are required when a document using the image is published in them.A refused upload says why: “this file type is not accepted”, “the file is larger than the upload limit”, or “alt text is required for every enabled locale unless the image is decorative”.
The admin’s upload form needs media.upload. Editing an asset afterwards needs media.edit.
Alt text and captions
Alt text describes the image for someone who cannot see it. A caption is editorial text shown to everyone. The library keeps both:
- Per locale. Each enabled locale has its own alt text and caption.
- Per site. The text written on the asset is the default for every site. A site may override the alt text, the caption, or both, in any locale, so the same photograph can be described differently on two sites. The asset page shows, for each locale, what every site will display.
- Per use. An
assetfield may carry its own alt text and caption for one document (alt: inherit, the default, lets the editor override;caption: trueoffers a caption). A per-use value wins over the library’s. - Decorative. An image marked decorative renders
alt=""everywhere and is never overridden per site. The library counts decorative images separately, so marking cannot hide a backlog.
The admin refuses alt text over 200 characters, shows a count against an advised 125, and refuses alt text that opens with “image of”, “photo of” and similar.
How a page finds its alt text
For a page on site S in locale L, alt text and caption are each taken from the first of:
- the per-use value on the field;
- S’s override in L;
- the default in L;
- for each locale in L’s fallback chain, then the install’s default locale: S’s override, then the default.
Language comes before site: a reader gets text in their own language before a site-specific text in another language. When the text comes from a fallback locale, the image value’s altLang (and captionLang) names that locale, and img(), picture() and figure() write a lang attribute so a screen reader pronounces it correctly.
Missing translations at publish
Each site decides what happens when an image’s alt text exists only in a fallback locale, in site.yaml:
# config/sites/<site>/site.yaml
accessibility:
alt_fallback: warn # allow | warn (the default) | block
alt_fallback_locales:
cy-GB: block # Welsh pages must carry Welsh alt text | Policy | At publish |
|---|---|
allow | Publishes with the fallback and says nothing. |
warn | Publishes, and lists the image in the publish warnings (alt_fallback). |
block | Refuses the publish (alt_fallback_blocked) until the locale has its own alt text. |
An image with no alt text in any locale of the chain is always an error at publish (alt_needed for an alt: required field, otherwise alt while media.require_alt_text is on). Saving a draft is never refused for alt text. The policy used is the owning site’s, also for a document shared to other sites.
Working through a backlog
The admin’s Alt text queue lists images missing alt text (or captions) in one locale, for the default or for one site, most used first, with a field per row saved from the keyboard. With an AI provider configured, “Draft these with AI” fills the queue with drafts a person reviews (AI assistance).
engine-dmo media stats reports the library’s size and, per site and locale, how many images have alt text of their own, how many show a fallback, and how many have none:
engine-dmo media stats
engine-dmo media stats --site main --json Editing the default text needs media.edit with no site restriction; a site’s override needs media.edit on that site; a grant scoped to locales covers those locales only.
Rights and licences
Every asset can carry a rights record: the licence term, the holder, the uses it covers, the territory, the start and end dates, the credit line, the source, and an evidence file (a model release or licence PDF, itself an asset). The record is edited on the asset’s Rights panel and needs the media.rights permission, which no built-in role except Owner holds; grant it in config/access/roles.yaml. Every change is audited.
Licence terms
media.rights.terms is the vocabulary editors choose from. Applying a term fills the uses and territory when they are empty, and the end date from the start date (today when none is given) plus the term’s duration. A term with credit_required refuses a record without a credit line.
# config/engine.yaml
media:
rights:
on_expiry: placeholder
expiry_warnings: [90d, 30d, 0d]
terms:
commissioned:
label: { en: "Commissioned, perpetual" }
uses: [website, social, newsletter, print]
territory: [WORLD]
stock-web-1y:
label: { en: "Stock, web only, one year" }
uses: [website]
territory: [WORLD]
duration: 1y
credit_required: true
member-supplied:
label: { en: "Supplied by member" }
uses: [website, social, newsletter]
territory: [WORLD]
credit_required: true Setting terms replaces the engine’s four defaults: commissioned-perpetual, stock-2yr-web, member-supplied and press-only. Keep a member-supplied term if members upload photos through the portal (DMO bundle): without it, member uploads are unavailable.
| Term key | Type | Meaning |
|---|---|---|
label | map of locale to text | What the Rights panel shows. |
uses | list | Any of website, social, newsletter, print, editorial, press, partner, handover. |
territory | list | WORLD or country codes. |
duration | period | 90d, 18m, 2y; empty is perpetual. |
credit_required | boolean | The record must have a credit line, and templates must show it. |
Expiry
- At publish, an image whose licence ended before today refuses the publish (
rights_expired) whileblock_publish_when_expiredis true, the default, and is a warning when it is false. An image whose licence ends while the page stays live is a warning (rights_expiring) whilewarn_publish_when_expiringis true. Saving is never refused for rights. - Every day, a scheduled sweep finds licences ending within the largest of
expiry_warningsand notifies every user holdingmedia.rights, once per threshold crossed. - Once a licence has ended,
on_expiry: warn(the default) changes nothing on the live site.on_expiry: placeholderrenders the image as absent on public pages, exactly as a deleted asset, so a template’s{% if entry.hero %}falls through to its own fallback. Nothing is ever deleted automatically.
Credits
An image’s credit is the credit line its licence requires, and creditRequired is true when there is one. figure() always writes it in the <figcaption>. img() and picture() write no caption, so a template that uses them must print the credit itself:
{{ entry.hero.picture("hero") }}
{% if entry.hero.credit %}<small>{{ entry.hero.credit }}</small>{% endif %} engine-dmo check warns (image-credit) at every X.img(...) or X.picture(...) in a template that neither prints X.credit nor calls X.figure(...) anywhere in the same file.
Member-supplied photos
A photo whose licence is member-supplied, or that a member uploaded and accepted the upload terms for, is given to a third party only when its rights record names that use: partner for a partner site’s widget or embed and for engine-dmo export embeds, handover for engine-dmo site export. Without it the photo is left out. The site’s own pages, feeds and APIs always show it.
Duplicates, orphans and bulk editing
- Duplicates. Each image gets a 64-bit perceptual hash at upload. Images whose hashes differ by at most
media.duplicates.thresholdbits (10 by default), and files with the same checksum, are listed on the asset page under “Possible duplicates” and in the library’s “Possible duplicates” filter. It is a suggestion: nothing is merged or refused. “Not a duplicate” dismisses a pair for good. - Orphans. A scheduled scan, at
media.orphans.scan_schedule(03:00 UTC by default), marks every asset as in use, used only in old revisions, or unreferenced. The library’s Usage filter reads it, and “Scan now” runs it on demand. An asset used as rights evidence or in a collection counts as in use. - Bulk editing. The library’s list view selects assets (up to 2,000 matching a filter) and applies one operation: add or remove a tag, apply a licence term or credit line (
media.rights), add to a collection, set or clear decorative, regenerate derivatives, soft delete or restore (media.delete). A preview says what will change before it runs; the run is one audited job. Deleted assets are listed under “Deleted”, where they can be restored. - Collections are named, install-wide, manual groups of assets, used as a filter.
Not in this version: merging two duplicate assets into one, a hard purge of a deleted asset’s file (a deleted asset stays restorable; media prune removes only derivatives), collections that fill themselves from a rule, and press packs.
Derivatives
Image sizes are generated on demand from the transforms in config/images.yaml (and a site’s own config/sites/<site>/images.yaml). A derivative’s address carries a keyed hash, so nobody can request a size the site never rendered. Derivatives are stored and reused; the pure Go build serves JPEG, and the -vips image also serves WebP and AVIF.
engine-dmo media transforms # every transform and its width ladder
engine-dmo media transforms --site main --source-width 1600
engine-dmo media warm --dry-run # count derivatives the published pages need
engine-dmo media warm --site main # generate them, JPEG before WebP before AVIF
engine-dmo media prune --dry-run # what pruning would remove, and the bytes freed | Command | What it does |
|---|---|
media transforms | Lists every transform a template can name, resolved over images.yaml defaults and, with --site, the site’s own file; --source-width and --source-height show the ladder for a given source. |
media warm | Renders published pages in process and generates the derivatives their markup references. --site, --type, --document narrow it; --shard i/n splits the work across runs; --dry-run only counts. |
media prune | Removes the derivatives of deleted assets, those not served or warmed for --older-than (default media.derivatives.keep_unused), and, once unused for a day, those of old asset versions or retired transforms. --dry-run removes nothing. |
media stats | Asset count and bytes, alt text and caption coverage by site and locale, derivatives by transform and format. |
media import | Uploads the files a meridian.media.v1 manifest lists through the pipeline, under the manifest’s ids, with alt text, captions and rights. Running it again skips ids already in the library. See Migrating content. |
All media commands need the database.
Configuration reference
Every key is under media: in config/engine.yaml.
| Key | Default | Meaning |
|---|---|---|
max_upload_bytes | 50MB | The largest upload. A size such as 30MB. |
max_pixels | 50000000 | The largest image, in pixels, that is decoded. At least 1,000,000. |
accept | image/jpeg, image/png, image/webp, image/avif, application/pdf, application/gpx+xml | The media types an upload may be, sniffed from the bytes. |
strip_exif_gps | true | Re-encode JPEG and PNG uploads to remove metadata, GPS included. |
require_alt_text | true | Alt text at upload (source locale) and at publish (the page’s locale), unless decorative. |
signing_key | derived | The key derivative addresses are signed with. A secret (${VAR}); when empty it is derived from server.security.signing_key. Changing it changes every derivative address. |
derivatives.keep_unused | 90d | How long a derivative nothing serves or warms is kept before the nightly prune. At least a day. |
rights.terms | four built-in terms | The licence vocabulary; setting it replaces the defaults. |
rights.expiry_warnings | [180d, 90d, 30d, 0d] | Periods before a licence ends at which media.rights holders are notified. |
rights.block_publish_when_expired | true | An ended licence refuses a publish; false makes it a warning. |
rights.warn_publish_when_expiring | true | Warn at publish when a licence ends while the page stays live. |
rights.on_expiry | warn | warn or placeholder (render the image as absent once its licence has ended). |
orphans.scan_schedule | 0 3 * * * | The reference scan’s schedule, a five-field cron expression in UTC. |
duplicates.threshold | 10 | How many of the perceptual hash’s 64 bits may differ for two images to be offered as near duplicates. |
The files themselves live where storage: says:
| Key | Default | Meaning |
|---|---|---|
storage.driver | filesystem | filesystem or s3. |
storage.filesystem.root | The directory, relative to the project. | |
storage.filesystem.shared | false | Declare that every replica mounts the same filesystem. Required for filesystem storage with server.replicas above 1. |
storage.s3.endpoint, region (eu-west-2), bucket, access_key_id, secret_access_key, path_style, public_base_url | An S3-compatible bucket. secret_access_key is a secret. |
Permissions
| Permission | Allows | Built-in roles |
|---|---|---|
media.view | Reading the library, the asset pages and the queue | Administrator, Editor, Author, Translator |
media.upload | Uploading | Administrator, Editor, Author |
media.edit | Alt text, captions, focal point, tags, collections, decorative, duplicates, the orphan scan | Administrator, Editor |
media.rights | The rights record, licence terms and credit lines, alone and in bulk | Owner only |
media.delete | Soft delete and restore | Owner only |
Owner holds every permission. A built-in role is changed by declaring a role with the same key in config/access/roles.yaml.
Features
What the engine does for a website beyond content and templates, in both bundles, and which page explains each feature.
Search
How to give a site a search page, connect Meilisearch, Elasticsearch or OpenSearch, what is indexed, the search.html template and search() function, browse mode without a backend, synonyms and the zero-result report.