Collections & Conversions
Collections are the policy layer — MIME whitelist, single-file replacement, ordering, and a per-collection disk. Conversions are the image pipeline — width/height/fit/format/quality, generated eagerly or lazily and cached.
A collection is a named bucket you attach files to, with a policy attached: which MIME types are allowed, whether it holds one file or many, which disk it lives on, and which image conversions it offers. A conversion is a derived image (a thumbnail, an OG image, a WebP variant) produced from the original. This page covers both.
Collections
You declare collections in config/media.ts. The shape is MediaCollectionConfig:
interface MediaCollectionConfig {
name: string
single?: boolean // attaching replaces any existing media in this collection
disk?: string // target disk (defaults to the storage default disk)
acceptsMimeTypes?: string[] // allowed MIME types; others are rejected on attach
conversions?: ConversionPreset[] // image presets available for this collection
transformers?: Transformer[] // content transformers (HLS, metadata probe, your own)
}conversions and transformers both produce named entries in record.conversions, so within one collection the two lists share a single namespace. A name used twice — by two presets, by two transformers, or by one of each — throws TransformerConflictError when the registry is built, which is at boot rather than on the first attach. See Transformers for what goes in that second list.
defineConfig({
collections: [
{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
{ name: 'gallery', conversions: [{ name: 'thumb', width: 200, height: 200 }] },
{ name: 'invoices', disk: 'private-s3', acceptsMimeTypes: ['application/pdf'] },
],
})Collections are opt-in constraints
Declaring a collection is how you constrain it. Attaching to a collection you never declared is allowed — it uses a permissive default ({ name }): no MIME whitelist, not single, no conversions, default disk. Declare a collection only when you want a whitelist, single-file behaviour, conversions, or a non-default disk.
MIME whitelist
When acceptsMimeTypes is set, attach checks the incoming mimeType against it before writing anything. A mismatch throws MimeNotAllowedError (code E_MEDIA_MIME_NOT_ALLOWED) — so no orphan file is ever written for a rejected upload.
// collection 'avatar' accepts only png/jpeg
await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'doc.pdf', mimeType: 'application/pdf', contents,
})
// → throws MimeNotAllowedErrorThe check is an exact string match against the mimeType you pass — there's no wildcard expansion, so list each type you accept ('image/png', 'image/jpeg', 'image/webp', …).
The whitelist is authoritative, but a generic declaration isn't a hard stop. If the declared mimeType is a bare top-level type ('text', 'application' — some multipart parsers truncate the header) or simply isn't on the list, attach / attachExisting first try to pin a concrete MIME from the file extension (a small built-in map: .csv → text/csv, .tsv, .txt, .xlsx/.xls, .json, raster images, .svg, .pdf, …). If the extension resolves to a type on the whitelist, that normalized value is what the record stores. If it still resolves to nothing acceptable, the rejection lists what the collection accepts:
// collection 'avatar' accepts only image/png
// → throws MimeNotAllowedError:
// MIME type "text" is not allowed in collection "avatar". Allowed: image/png.The signature check still applies after normalization — normalization only re-derives the declared type, never weakens what content the collection accepts.
The content is checked too, not just the declared type
The mimeType above is written by your app — very often a hardcoded constant, or a Content-Type header the client chose. Checking only that whitelists nothing about the bytes themselves: a user renames payload.png to report.pdf, your controller declares 'application/pdf', and the whitelist waves it through.
So whenever a collection declares acceptsMimeTypes, the library also reads the file's magic-byte signature and validates the real content:
| The signature says… | Result |
|---|---|
| the same type you declared | accepted |
| a different type | rejected — ContentTypeMismatchError (code E_MEDIA_CONTENT_TYPE_MISMATCH) |
| nothing recognisable, and every accepted type is signature-detectable | rejected — ContentSignatureUnrecognizedError (code E_MEDIA_CONTENT_SIGNATURE_UNRECOGNIZED) |
| nothing recognisable, and some accepted type has no signature | falls back to the declared type (already whitelisted) |
// collection 'exams' accepts only application/pdf; `contents` is really a PNG
await media.library.attach({
ownerType: 'Patient', ownerId: '7', collection: 'exams',
fileName: 'scan.pdf', mimeType: 'application/pdf', contents,
})
// → throws ContentTypeMismatchError:
// File contents are actually "image/png" (detected from the file signature), not the
// declared "application/pdf". Collection "exams" accepts [application/pdf].The embedded signature table covers PNG, JPEG, GIF, WEBP, PDF, MP4, QuickTime, WebM, Matroska, AVI and MPEG-TS — no extra dependency. You can call the detector yourself with detectMimeType(head).
For the video containers, detection is signature-precise, not extension-guessing: an MP4 is identified by its ftyp box and major brand (so a QuickTime .mov relabeled video/mp4 is a mismatch, not a pass), and a WebM is told apart from a generic Matroska file by the DocType inside the EBML header. An ftyp brand outside the recognised MP4 set (3GPP, M4A ) and an EBML DocType that is neither webm nor matroska stay unrecognised rather than being guessed — under an open whitelist they fall back to the declared type as before.
Closed whitelists reject unrecognisable content
Formats with no fixed signature (SVG, CSV, plain text, most office formats) are unknown, not invalid — rejecting them outright would break legitimate uploads. But "unknown" is only uninformative when an accepted type could plausibly BE unknown. So the rule turns on the whitelist itself:
- Closed whitelist — every type in
acceptsMimeTypesis in the signature table (['application/pdf'],['image/png', 'image/jpeg'],['video/mp4']). Content matching no signature cannot be any of them, so it is rejected withContentSignatureUnrecognizedError. A.txtrenamed to.pdfno longer becomes a record, and your app does not have to reimplement that check. - Open whitelist — at least one accepted type has no signature (
['application/pdf', 'image/svg+xml'],['text/csv']). Unrecognisable content is the normal case there, so the declared type stands, exactly as before.
// collection 'exams' accepts only application/pdf (closed); `contents` is plain text
// → throws ContentSignatureUnrecognizedError
// collection 'mixed' accepts ['application/pdf', 'image/svg+xml'] (open); an SVG
// → accepted, declared type standsUse isDetectableMimeType(type) / isClosedSignatureWhitelist(types) if you need the same reasoning outside the library.
It never reads the whole file
Only the first SIGNATURE_HEAD_BYTES (189 — two MPEG-TS sync bytes, the deepest probe in the table) are inspected. For attach the head is peeked and then replayed in front of the rest, so a Readable payload stays streaming; for attachExisting it is a short read against the disk that is torn down immediately — adopting a 2 GiB scan in place never downloads it.
Single-file collections
A single: true collection holds exactly one file per owner. On attach, the library first lists the owner's existing media in that collection and deletes it (original + every conversion), then writes the new file. This is the natural shape for an avatar, a cover image, or a logo:
const avatar = await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'me.png', mimeType: 'image/png', contents: v1,
})
const replaced = await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'me2.png', mimeType: 'image/png', contents: v2,
})
// the first file (and its conversions) are gone; only `replaced` remainsOrdering
Every record carries an order (0-based). On attach, the library asks the store for the nextOrder in that owner+collection and assigns it, so files come back in attach order. list() returns records sorted by order ascending:
await media.library.list('Post', '1', 'gallery')
// [{ order: 0, ... }, { order: 1, ... }, { order: 2, ... }]Ordering is append-only — attach always takes the next slot. There's no built-in reorder API; if you need to resequence, update the order field through your own store access. (See the Roadmap.)
Stripping metadata (GPS, EXIF)
A phone photo carries the GPS position where it was taken, the device and the time. Conversions
never keep any of it, but the original is served as uploaded. stripMetadata: true rewrites
the original on attach without EXIF, XMP or IPTC:
{ name: 'avatar', acceptsMimeTypes: ['image/jpeg', 'image/png'], stripMetadata: true }- What it keeps: the format and the colour profile. The orientation is baked into the pixels, since removing only the tag would display the photo sideways.
- Re-encoding: lossy formats are re-encoded at high quality.
- What it skips: images with nothing to strip, and non-images, stay byte-for-byte.
- Cost: it reads and rewrites the original, one round trip per attach, and that includes
attachExisting.
Per-collection disk
A collection can pin itself to a specific disk, overriding the top-level disk. A per-attach disk still wins over the collection's. Useful when, say, public images live on a CDN-backed disk and private documents live on a locked-down one:
defineConfig({
disk: 'public-s3',
collections: [
{ name: 'documents', disk: 'private-s3', acceptsMimeTypes: ['application/pdf'] },
],
})Conversions
A conversion is a derived image generated from the original by the configured image processor. You declare presets per collection; each is a ConversionPreset:
interface ConversionPreset {
name: string
width?: number
height?: number
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'
format?: 'jpeg' | 'png' | 'webp' | 'avif'
quality?: number
blur?: number // gaussian blur sigma, applied after the resize
eager?: boolean // generate on attach instead of lazily on first access
}defineConfig({
imageProcessor: processors.sharp(),
collections: [
{
name: 'gallery',
conversions: [
{ name: 'thumb', width: 200, height: 200, fit: 'cover' },
{ name: 'card', width: 600, format: 'webp', quality: 80 },
{ name: 'og', width: 1200, height: 630, eager: true },
],
},
],
})What the fields do
These map straight onto sharp. The image is always auto-oriented from its EXIF tag first, so a portrait phone photo stays upright in every conversion.
width/height— target dimensions. With both set, the result is resized to fitfit. With one set, the other is derived to preserve aspect ratio (per sharp's resize rules). With neither, no resize happens — only a format/quality change.fit— how the image fills the box:cover(default),contain,fill,inside,outside. Only applies when at least one dimension is set.format— the output encoding:jpeg,png,webp,avif. Defaults towebpwhen omitted. The actual extension and content-type follow the chosen format.quality— the encoder quality (passed to sharp'stoFormat). Omit for sharp's default.blur— gaussian blur sigma (sharp'sblur(sigma),0.3–1000), applied after the resize.{ name: 'blurred', width: 40, blur: 4 }is a blurred low-res variant served as a file; for an inline blur-up placeholder, usetransformers.placeholder()instead.position— what a crop (fit: 'cover'with both sides) keeps: a gravity ('top','left bottom', …, default'centre'), or a content strategy.'attention'keeps the most salient region (faces, saturated colour) and'entropy'the most detailed one. A record's focal point wins over it.withoutEnlargement— never upscale: an original narrower thanwidthkeeps its size.onlyDownscale— skip the conversion when it would not be narrower than the original (recorded asmeta.skipped, no file), instead of storing a same-size copy.responsivecollections use it.eager— see below.
Every generated image conversion records its output size in record.conversions[name].meta ({ width, height }).
Focal points
Crops cut around the centre by default, which beheads a portrait or loses a banner's subject. Set a
focal point, the part every crop must keep, as fractions of the displayed image ({ x: 0, y: 0 }
is the top-left corner):
await media.library.setFocalPoint(user.avatarMediaId, { x: 0.42, y: 0.31 }) // null clears it
// or at attach: attach({ ..., customProperties: { focalPoint: { x: 0.42, y: 0.31 } } })Every cropping preset is then cut so the point sits as close to the centre as the image allows.
setFocalPoint drops the crops cut around the old point: it regenerates the eager ones and lets the
lazy ones regenerate on access. media.library.image() returns the point too, and
<MediaImage> turns it into object-position, so CSS crops
(object-fit: cover) keep the same part.
Eager vs lazy
This is the central conversion behaviour:
- Eager (
eager: true) presets are generated synchronously onattach. By the timeattachresolves, the eager conversions already exist on disk and are recorded. Use eager for conversions you know you'll always need (an OG image, a primary thumbnail). - Lazy (the default) presets are generated on the first
url(id, name)that asks for them, then cached on the record so every subsequent call is a pure lookup. Use lazy for the long tail of sizes you might not always render.
// 'og' is eager → already generated when this resolves
const m = await media.library.attach({ ...input, collection: 'gallery' })
await media.library.url(m.id, 'og') // pure lookup — already on disk
await media.library.url(m.id, 'thumb') // 'thumb' is lazy → generated NOW, then cached
await media.library.url(m.id, 'thumb') // pure lookup the second timeUnder the hood both paths go through ensureConversion(id, name), which is also a public method you can call to force a lazy conversion to exist ahead of time:
await media.library.ensureConversion(m.id, 'thumb') // generate-if-absent, returns the updated recordensureConversion is idempotent — if the conversion already exists it returns the record untouched without re-running sharp.
What each path costs:
- Eager presets read the original at most once between them, and not at all when
attachalready holds the bytes (aBufferinput). All of them are stored on the record in a singlesave. WithstripMetadata, a bufferedattachstrips the bytes it holds, writes the original once, and the eager presets render from the stripped bytes. - Lazy generation is deduplicated within a process: concurrent
url()/ensureConversion()/resized()calls for the same media and conversion share one generation, so the original is decoded once. A failed generation is not cached; the next call tries again. Writes of conversion entries for one media are queued within the process, so two different conversions generated at the same moment both end up on the record.
Concurrent generation across processes
The write queue is per process. Two app instances generating different conversions of the same media at the same instant can each save over the other's entry, losing one. The file is still on disk; the next read finds the entry missing and generates it again.
Where conversion files live
A conversion is written next to its original, under a conversions/ sub-directory, named by the preset and the output format:
Post/1/gallery/<media-id>/photo.jpg ← original
Post/1/gallery/<media-id>/conversions/thumb.webp ← the 'thumb' conversion
Post/1/gallery/<media-id>/conversions/og.webp ← the 'og' conversionThe conversion shares the original's disk, and its path is recorded on the MediaRecord.conversions map (keyed by preset name). Deleting the media removes every conversion file too.
Errors
| Situation | Error | Code |
|---|---|---|
| Asking for a conversion not declared on the collection | ConversionNotDefinedError | E_MEDIA_CONVERSION_NOT_DEFINED |
Asking for any conversion with no imageProcessor configured | ImageProcessorMissingError | E_MEDIA_IMAGE_PROCESSOR_MISSING |
| Asking for a media id that doesn't exist | MediaNotFoundError | E_MEDIA_RECORD_NOT_FOUND |
Conversion presets are image-only — the sharp processor decodes the original as an image. Attaching non-images to a collection is fine (with no conversions), but declaring presets on a non-image collection and then requesting one will fail inside sharp. For everything beyond images — HLS video, metadata probing, and your own derivations — use Transformers, which persist into the same record.conversions map.
The MediaRecord
Every attached file is a MediaRecord. It's what attach, find, and list return:
interface MediaRecord {
id: string
ownerType: string
ownerId: string
collection: string
name: string // display name; defaults to the file name without extension
fileName: string
mimeType: string
size: number // bytes
disk: string // which disk it lives on
path: string
order: number
customProperties: Record<string, unknown> // your own metadata bag, passed on attach
conversions: Record<string, MediaConversion> // keyed by preset / transformer name
createdAt: Date
updatedAt: Date
}You can stash arbitrary metadata on customProperties at attach time (attach({ ..., customProperties: { alt: 'A cat' } })) and read it back off the record.
MediaConversion
One entry in record.conversions. Every field is optional, because the same type covers three quite different results:
interface MediaConversion {
path?: string // entry-point artifact key — absent for a metadata-only transform
disk?: string // disk holding the artifact(s) — absent when there are none
prefix?: string // storage prefix (ends in `/`) holding every artifact of a multi-file result
files?: string[] // every artifact key relative to `prefix`, the entry included
meta?: Record<string, unknown> // transformer-reported metadata, persisted verbatim
}- An image conversion (a
ConversionPreset) fillspath+disk: one file, one key. Itsmetaalso records the output size,{ width, height }— whatmedia.library.image()builds asrcsetfrom. - A multi-file transformer output — an HLS package — fills
path(the master playlist),prefix(the folder every artifact lives under) andfiles(all of them).filesis the authority both for serving individual artifacts and for deleting the package whole. - A metadata-only transform — a probe, a blurhash — writes no artifact at all:
path,disk,prefixandfilesare all absent and onlymetacarries the result.
Which is why nothing here can be assumed present. Reading record.conversions.probe.path on a probe result gets you undefined, not a key; narrow on the field you actually need.
Responsive images
Responsive collections
Instead of writing presets by hand, responsive: true (spatie's withResponsiveImages()) gives a
collection eager presets responsive-320 … responsive-2560, plus transformers.placeholder() if
it has none:
{ name: 'banner', responsive: true }
{ name: 'banner', responsive: { widths: [480, 960, 1440], format: 'avif', quality: 60 } }Widths at or above the original's are skipped, not stored as copies. media.library.image(id) then
returns the srcSet, the intrinsic size and the blur-up preview, with no preset written by hand.
Rendering
Presets at a few widths plus transformers.placeholder() are everything next/image-style rendering
needs: media.library.image(id) turns them into src, srcSet, the intrinsic size and the blur-up
preview, and <MediaImage> from @adonis-agora/media-react renders them. See
Responsive images.
Next steps
- Attachments — the column-attachment alternative, with the same conversion engine
- Stores & Processors — the sharp processor and the Lucid store behind records
- Testing — assert eager vs lazy behaviour with the fake processor
Configuration
Every key on defineConfig — disk and the disks map, store and stores, imageProcessor, collections, uploads, delivery, the attachment key prefix and the diagnostics toggle — and how the provider builds the MediaManager at boot.
Transformers
Pluggable content transformations — turn a stored media into derived artifacts (an HLS or DASH package, an extracted audio track) or pure metadata (a probe), persisted as named conversions. Ships with a video transformer (CMAF or TS segments, HLS and DASH manifests), a metadata probe and an image placeholder.