Skip to content

Formats

libvips 8.18.x is the engine; library choices and licenses are in docs/research/stack.md and ADR 0006. Format detection is always by magic bytes (codec.Sniff), never by extension or upstream Content-Type. Every loader runs behind the probe stage, which reads only headers to enforce pixel, frame and page limits before any pixel decode.

Inputs: JPEG, PNG, GIF (animated), WebP (animated), AVIF, HEIC, JPEG XL, TIFF, BMP, ICO, SVG, JPEG 2000, UltraHDR, camera RAW (CR3, NEF, ARW, DNG and more, embedded preview first), PDF, PSD/PSB, OpenEXR, Radiance HDR, PBM/PGM/PPM/PAM/PFM, JPEG XR, TGA, PCX, DDS (BC1–BC7 including BC6H HDR, uncompressed and float), QOI, SGI, Sun raster, XBM, XPM, WBMP and ICNS, designer files (GIMP XCF flattened; OpenRaster and Krita composites, Procreate and Paint.NET thumbnails, Sketch previews and SVGZ, see FORMATS §1.7), and EPS/PostScript/Illustrator in the opt-in gpl image with XI_FORMATS_EPS_ENABLED=true (AGPL Ghostscript in a sandboxed subprocess, off by default; elsewhere only a DOS EPS preview, see FORMATS §1.6 and ADR 0011). Outputs: JPEG, PNG, GIF, WebP, AVIF, JPEG XL, TIFF, BMP, ICO (HEIC opt-in). Video input, GIF to video and video transformations need the full or gpl image (§5.1, §5.2).

The tables below give the loader, limits, metadata handling and flavour of each format. The “Milestone” column records when support landed (CHANGELOG).

Format Loader Anim / pages Shrink-on-load Metadata Notes Milestone
JPEG libjpeg-turbo (jpegload) — shrink 2/4/8 EXIF, ICC, XMP, IPTC CMYK → sRGB via embedded or built-in profile (relative colorimetric with black point compensation, WP-114: the profile’s black maps to sRGB black instead of a lifted grey); fail_on=warning rejects truncated files; UltraHDR JPEGs load through libultrahdr (uhdrload) as their SDR base image, and their gain map (or the MPF gain map of an Adobe/ISO gain-map JPEG libultrahdr refuses) is applied for HDR outputs with preserve_hdr or gain_map:hdr (§1.4, WP-104); 12-bit DCT and lossless (2–16-bit) frames load through the shim’s jpeghpload_buffer over libjpeg-turbo 3’s 12/16-bit APIs as 8-bit or 16-bit images (§1.6, WP-106); hierarchical, arithmetic-coded lossless, other-precision DCT and JPEG-LS frames are refused at probe with 422 M1
PNG / APNG libspng (pngload) APNG: first frame only (libvips limitation) — eXIf, iCCP, tEXt 16-bit kept only with preserve_hdr; zlib bomb guarded by pixel limit + libspng chunk limits M1
GIF libnsgif (gifload) yes — — frame count checked from header; all frames loaded with n=-1 M1
WebP libwebp (webpload) yes scale EXIF, ICC, XMP M1
AVIF libheif + dav1d (heifload); image sequences through the shim’s heifseqload_buffer (libheif’s sequence API, WP-104) image sequences (avis, .avifs): animations, frames 8-bit sRGB(A) with delays and loop count (§3) embedded thumbnail when enforce_thumbnail EXIF, ICC, XMP (stills) 10/12-bit → 8-bit (a gain map, tmap, is applied for HDR outputs with preserve_hdr, §1.4) M1
HEIC / HEIF libheif + libde265 multi-image: page; HEVC image sequences (msf1/hevs, iPhone “sequence” HEICs): animations like AVIF embedded thumbnail EXIF, ICC, XMP iOS 18 tmap gain maps as for AVIF; Apple’s older auxiliary hdrgainmap image is not applied M1
JPEG XL libjxl (jxlload) yes — EXIF, ICC, XMP CMYK files (K as a black extra channel, which jxlload cannot read) go through the shim’s own jxlcmykload_buffer (libjxl, internal/vips/jxlcmyk.c, §1.6, WP-105) and are converted to sRGB through their CMYK profile like a CMYK JPEG M1
TIFF libtiff (tiffload) pages pyramid subifd level EXIF, ICC, XMP, IPTC reject tiles/strips exceeding pixel limit; compressions: none, packbits, LZW, deflate, JPEG, WebP and ZSTD (libzstd, BSD-3-Clause, since base r5/WP-102); not LZMA, JBIG (GPL), LERC, old-style JPEG or the GDAL-only JPEG XL (50002); CIE Lab* (8/16-bit, with or without alpha and ICC) is read through a copy relabelled RGB and converted to sRGB colorimetrically (§1.6, WP-105) M1
BMP own pure-Go loader (codec/bmp, reimplemented from imgproxy’s C bmpload.c, Apache-2.0: upstream header kept, NOTICE entry) — — — 1/4/8/16/24/32-bit, RLE4/8, bitfields; decoded in Go into a vips memory image M1
ICO / CUR own loader (codec/ico) picks best entry picks the smallest entry ≥ target — PNG-in-ICO entries go through pngload M1
SVG librsvg (svgload) after security/svg sanitiser — vector: rendered at target scale (scale/dpi, 96 DPI base) — passthrough when output is SVG and no raster op requested; style option injects CSS M1
PDF PDFium (pdfload) pages (page, pages stacked) scale at XI_FORMATS_PDF_DPI so the page renders at the target size — see §1.2; page count checked at probe; password-protected → 422 M2
PSD / PSB ImageMagick 7 (magickload, PSD coder only) page = layer (0 is the composite) — — see §1.3; header probed in Go; hard 10 s / 256 MiB magick limits M2
Camera RAW (CR2, CR3, CRW, NEF, NRW, ARW, SRF, SR2, DNG incl. JPEG XL, GPR, ORF, RW2, RAF, PEF, SRW, X3F, 3FR, IIQ, …) LibRaw 0.22 snapshot with X3F tools; gpl adds the Adobe DNG SDK 1.7.1 and GoPro GPR SDK (JPEG XL DNG, GPR; WP-103) — embedded preview EXIF from LibRaw see §2 M2
JPEG 2000 OpenJPEG (jp2kload; added to the base image by WP-05, not in imgproxy-docker-base) — page pyramid level — M1
OpenEXR, Radiance HDR, PPM/PFM OpenEXR 3.4 (openexrload, the buffer spooled to a memfd) / libvips radload_buffer, ppmload_buffer (PBM/PGM/PPM ASCII and binary, P1–P6, since WP-97; PF/Pf) — — — see §1.4: tone-mapped to 8-bit sRGB (XI_FORMATS_HDR_TONEMAP) unless preserve_hdr and a JPEG XL or AVIF output; /info reports "hdr": true and sample_format: "float" M2
Video (MP4, MOV, WebM, MKV, AVI, animated MP4 GIF-likes) FFmpeg 8.1 LGPL through our own shim internal/video/ffmpeg (build tag ffmpeg, full and gpl images, ADR 0010; decoders h264, hevc, vp8, vp9, AV1 via dav1d, mpeg4, mjpeg, png, rawvideo) one frame becomes the image; vtt assembles a sprite of frames and vta an animation (WP-75, §1.5) libswscale scales the frame to 2× the target container tags and streams (/info video_meta, video_streams); display matrix → orientation frame at vts (negative from the end, vtk keyframe), else best frame among keyframes (luma entropy and spread, fades penalised); see §1.5 M3 (WP-74)
JPEG XR / HD Photo (.jxr, .wdp, .hdp) jxrlib (Microsoft, BSD-2-Clause) through the shim’s own libvips loader jxrload_buffer (internal/vips/jxr.c, §1.6) — — ICC 8/16-bit grey, RGB(A) and CMYK(A) kept; half, float, fixed-point, RGBE and packed formats converted to 8-bit by jxrlib M4 (WP-97)
TGA, PCX, QOI, SGI, Sun raster, XBM, XPM, PAM ImageMagick 7 (magickload, one coder per format in policy.xml, §1.6) first image only — — header probed in Go (codec.ParseLegacyHeader) M4 (WP-97)
DDS (DirectDraw Surface): BC1–BC7 from legacy FourCCs or a DX10 header, BC6H (unsigned and signed), uncompressed and float formats pure Go (codec/dds, fuzzed, §1.6) first image (mipmap level 0, first array element, cube face or volume slice) — — BC6H and the float formats are scene-linear HDR, tone-mapped like OpenEXR (§1.4) M4 (WP-97, WP-105)
WBMP, ICNS own pure-Go loaders (codec/wbmp, codec/icns, §1.6) ICNS: largest entry — — ICNS PNG/JPEG 2000 entries go through libvips M4 (WP-97)
EPS, PostScript, Illustrator (PostScript-based, AI ≤ 8) Ghostscript 10.08 gs in a sandboxed subprocess (internal/ghostscript, ADR 0011), gpl image only, XI_FORMATS_EPS_ENABLED=true (off by default); otherwise a DOS EPS file’s embedded TIFF preview (every image) page (%%Pages) rendered at the target size (72 × scale DPI, ≤ XI_FORMATS_EPS_MAX_DPI) — header (DSC bounding box) probed in Go, never by gs; without Ghostscript or a preview → 422 “EPS needs the gpl image with XI_FORMATS_EPS_ENABLED=true”; PDF-based AI files are PDFs (PDFium); §1.6 M4 (WP-99)
GIMP XCF ImageMagick 7 XCF coder (magickload, policy.xml), one call per layer, flattened in Go (codec.FlattenXCF, §1.7) flattened (visible top-level layers) — — 8-bit RGB and greyscale, RLE tiles (GIMP 2.10 and older; versions 0–3 and 11+); indexed, high bit depth, zlib or uncompressed tiles and versions 4–10 → 422; header and layer table probed in Go (codec.ParseXCFHeader) M4 (WP-107)
Designer containers: OpenRaster (.ora), Krita (.kra), Procreate (.procreate), Sketch (.sketch), SVGZ, Paint.NET (.pdn) own bounded ZIP / gzip / XML readers (codec/container, pure Go, §1.7): the server replaces the file by the image it carries before the probe — as the embedded image (PNG; SVG for SVGZ) as the embedded PNG ORA/KRA: mergedimage.png (full size), else the thumbnail; Procreate: QuickLook/Thumbnail.png (a thumbnail, not the canvas); Sketch: previews/preview.png; SVGZ: the whole document, then the SVG sanitiser; Paint.NET: the header thumbnail (≤ 256 px). /info adds container, debug header X-Xi-Container-Source M4 (WP-107)
KTX2 (.ktx2): Basis Universal (ETC1S, UASTC LDR and HDR, ASTC/XUASTC), BC1–BC7, uncompressed; zlib and Zstandard supercompression container in pure Go (codec/ktx2); Basis Universal 2.50 transcoder (Apache-2.0) in the shim’s libxibasisu; BCn and uncompressed levels through codec/dds first image (level 0, first layer, face or slice) — — HDR payloads (BC6H, UASTC HDR, float) tone-mapped like OpenEXR; ETC2/EAC and PVRTC → 422; §1.8 M4 (WP-108)
FITS (.fits, .fit, .fts, fpack .fz) pure Go (codec/fits, fuzzed) first image HDU; a 3-plane cube as RGB — — 8/16/32/64-bit integer and 32/64-bit float, BZERO/BSCALE, tile compression (Rice, GZIP 1/2, dithered float); auto-stretched for display (§1.8) M4 (WP-108)
DICOM (.dcm) pure Go (codec/dicom, fuzzed) for native, deflated and RLE frames; JPEG baseline/extended (8- and 12-bit), lossless JPEG and JPEG 2000/HTJ2K frames decoded by libvips and the shim’s jpeghpload first frame — none: the dataset’s values are never read beyond the pixel description (§1.8) rescale and VOI window from the dataset, MONOCHROME1, YBR, PALETTE COLOR, always 8-bit RGB; JPEG-LS and the JPEG frames a standalone JPEG refuses → 422 M4 (WP-108)
Macintosh PICT, Kodak Photo CD (Base image) ImageMagick 7 PICT and PCD coders named explicitly (magickload, policy.xml) — — — headers probed in Go (codec.ParsePICTHeader, ParsePCDHeader); §1.8 M4 (WP-108)
MNG, JNG unwrapped in Go by the server (codec/container, like the designer containers): first embedded PNG or JNG; a JNG’s JPEG, or JPEG + alpha re-encoded as PNG first image only as the embedded PNG/JPEG as the embedded image /info container with content: "frame"; MNG composition not interpreted; §1.8 M4 (WP-108)
WMF, EMF (EMF+) xi-metafile2svg (libwmf, libemf2svg) in a sandboxed subprocess, gpl image only (XI_FORMATS_METAFILE_ENABLED), then the SVG path — vector (rendered at target scale like SVG) — converted to SVG before the sanitiser and librsvg; slim/full answer 422 naming the gpl image; §1.8 M4 (WP-108)
Blocked libvips’ own FITS (fitsload, not built; FITS is decoded in Go), Matlab, NIfTI, Analyze, CSV/matrix, raw pixels, magick for anything except PSD/PSB, XCF, PICT, PCD and the WP-97 legacy coders vips_block_untrusted_set(TRUE) and operation block list —

Per image flavour. Every input format above is decoded by every base image flavour (slim, full, gpl), video excepted (full and gpl only, §1.5): jxrlib and the widened ImageMagick policy are in slim (jxrlib adds about 0.4 MB) and the other flavours start from it. GIMP XCF and the designer containers (§1.7) are read by every flavour. The gpl flavour adds two inputs: EPS/PostScript/Illustrator rendered by Ghostscript, and only with XI_FORMATS_EPS_ENABLED=true (§1.6, ADR 0011), and WMF/EMF converted by xi-metafile2svg (libwmf and the GPL-2.0-only libemf2svg, §1.8); every flavour decodes a DOS EPS file’s TIFF preview. The WP-108 long tail (KTX2, FITS, DICOM, PICT, Photo CD, MNG/JNG) is in every flavour: the Basis Universal transcoder adds about 1 MB to slim, the rest is Go or ImageMagick coders already compiled in.

Base image availability (WP-66, base 8.18.6-r2-slim). The slim base image now ships the M2 loaders: dcrawload (LibRaw 0.22.x, libraw_r, headers in the builder for codec/raw), pdfload (PDFium, never poppler), openexrload (OpenEXR 3.4 + Imath) and magickload (ImageMagick 7, load only, as the vips-magick.so module). ImageMagick’s /opt/xtandard/etc/ImageMagick-7/policy.xml registers and allows only the PSD module (PSD/PSB read; widened by WP-97 to the legacy coders of §1.6) and caps each magick image at 10 s, 256 MiB memory/map, no disk cache, 64 MP area, 16K width/height (docker/base/README.md). Because libvips’ generic loader dispatch now considers magickload, any file that reaches libvips without a native loader (for example BMP or ICO bytes passed straight to vips_image_new_from_buffer) fails in magickload with a policy error instead of “not a known format”; the app still routes BMP/ICO to its own loaders and picks every loader from the codec.Sniff result. dcrawload, openexrload and magickload are libvips UNTRUSTED operations; with vips_block_untrusted_set(TRUE) the app unblocks only the ones it uses, by name: magickload_buffer (WP-51), pdfload_buffer (WP-50), openexrload, radload_buffer and ppmload_buffer (WP-67), and the shim’s own XiForeignLoadJxrBuffer (WP-97, registered as untrusted as well). Camera RAW goes through the app’s own LibRaw shim, so dcrawload stays blocked (§2).

1.1 Detection and probe (codec.Sniff, codec.Probe)

Section titled “1.1 Detection and probe (codec.Sniff, codec.Probe)”
  • Magic bytes: JPEG FF D8 FF; PNG signature; GIF87a/GIF89a; RIFF….WEBP; JPEG XL codestream FF 0A or container box JXL ; JPEG 2000 jP signature box or raw J2K codestream FF 4F FF 51; TIFF II*\0/MM\0* and BigTIFF II+\0/MM\0+; BMP BM + known info-header length; ICO/CUR (reserved 0, type 1/2, sane first entry); SVG via security/svg.IsSVG (first 4 KiB); %PDF-; 8BPS v1/v2 (PSD/PSB); RAW signatures (CR2, CR3 ftypcrx, CRW, RAF, ORF, RW2, X3F, MRW). TIFF-based RAWs without their own signature (NEF, ARW, DNG, PEF, SRW, 3FR, ERF, MEF, MOS, DCR) are told apart from TIFF by IFD0 (codec/raw.Sniff): a DNGVersion tag, or a camera maker’s Make together with a RAW-shaped IFD0 (reduced-resolution preview, SubIFDs, a camera-specific compression or CFA/linear-raw pixels). A plain RGB TIFF written by a camera stays TIFF.
  • ISOBMFF: major brand first, then compatible brands inside the ftyp box: avif/avis → AVIF; heic/heix/hevc/heim/heis/hevm/hevs → HEIC; generic mif1/msf1/miaf → HEIF, refined to AVIF/HEIC by the loader’s heif-compression. libvips’ heifload only accepts a fixed list of major brands (no avis) and reads only still items, so image sequences go to the shim’s heifseqload_buffer (WP-104), registered ahead of it: it claims a file whose ftyp names a sequence brand (avis, msf1, hevs, hevm), that has a top-level moov box, and in which libheif finds a visual track (pict or vide). Every other HEIF/AVIF file, a still with a burst of items included, stays with heifload.
  • HDR inputs (WP-67): OpenEXR 76 2F 31 01; Radiance #?RADIANCE or #?RGBE as the first line; PBM/PGM/PPM P1–P6 (ASCII and binary, since WP-97) and PFM PF/Pf followed by whitespace; the shim refuses ppmload for anything else. OpenEXR headers are parsed in Go (codec.ParseEXRHeader: data window, channels, tiled/deep/multi-part flags), so the pixel limit holds before OpenEXR sizes its line-offset tables; deep and multi-part files and files without R, G, B or Y channels are unsupported. Radiance and PNM headers are read by libvips (text lines, nothing decoded).
  • UltraHDR: a JPEG whose header (before SOS) has an APP2 MPF segment and gain-map metadata (APP2 ISO 21496-1 urn:iso:std:iso:ts:21496:-1 or APP1 XMP with hdrgm:Version). libvips uhdrload checks MPF and then libultrahdr’s full is_uhdr_image; the probe trusts the loader libvips picked (uhdrload ⇒ UHDR).
  • The format libvips’ loader reports must agree with the sniffed family, otherwise the file is refused (polyglots).
  • Camera RAW headers are read by LibRaw without unpacking (§2). PDF is probed by PDFium’s header read (page tree and first page size, nothing rendered) and PSD/PSB by a Go parser of the Photoshop header (codec.ParsePSDHeader: canvas, channels, depth, colour mode, layer count, composite flag), never by ImageMagick, which decodes the whole file even for a header.
  • PDF pages: more than XI_LIMITS_MAX_PDF_PAGES (500) fail at probe with limit_exceeded (422) before any page renders. The raw n-pages is read, not vips_image_get_n_pages, which reports 1 for 10 000 pages or more.
  • Limits before decode: frames ≤ MAX_ANIMATION_FRAMES (a limit of 1, from config or max_animation_frames:1, disables animation instead: the first frame is processed, as in imgproxy), and at probe one frame (a still: the image; multi-page stills count one page) must fit MAX_SRC_PIXELS, or, for an animation, MAX_ANIMATION_FRAME_PIXELS; the totals over the frames loaded (MAX_SRC_PIXELS, or MAX_ANIMATION_PIXELS when the frame limit replaces it) are checked by the planner, still before decoding (§3), SVG bytes ≤ MAX_SVG_BYTES and the SVG is sanitised before librsvg reads even its header. Frame counts use the header’s delay array as well as n-pages, because vips_image_get_n_pages reports 1 for 10 000 pages or more.
  • The declared upstream Content-Type never drives detection; a disagreement only sets ImageMeta.ContentTypeMismatch.
  • Decode failures after the header (WP-101): loading is lazy, so a decoder can fail inside the saver’s pipeline long after the probe. libvips signals it to the shim (“invalidate”) only for some failures, so every load (xi_load, xi_thumbnail) is wrapped in a pass-through guard image whose region generator marks the source failed when the loader’s region cannot be computed; the saver’s failure is then the input’s (corrupt, 422), never an internal error (500). A saver refusing a valid image (WebP’s 16383 px limit) stays 500.
  • Long-tail formats (WP-97): JPEG XR II BC 00|01; DDS DDS and a 124-byte header; QOI qoif; Sun raster 59 A6 6A 95; SGI 01 DA, storage 0/1, 1–2 bytes per channel, 1–3 dimensions; PCX 0A, version 2 or 5 (the ones ImageMagick detects), encoding 0/1, 1/2/4/8 bits per plane; XPM /* XPM */; XBM #define <name>_width; PAM P7; ICNS icns and a length. TGA and WBMP have no signature and are checked last: TGA by a self-consistent 18-byte header (colour map only with a colour-mapped type, a known type with a matching depth, non-zero size, no interleave bits, not ISO BMFF), WBMP (type 0, no extension headers) only when the file length is exactly what its header declares, so it needs the whole file (every caller passes it). The shim applies the same TGA and signature checks before it lets magickload run (xi_is_magick_input), and ImageMagick’s policy is the third fence.
  • Designer files (WP-107): GIMP XCF gimp xcf + file or vNNN + NUL (a codec format, xcf). The designer containers are recognised by the server before codec.Sniff sees anything (container.Detect/Open), by content, never by extension: a ZIP (PK 03 04) whose mimetype entry is image/openraster or application/x-krita, or which holds Document.archive and QuickLook/Thumbnail.png (Procreate) or document.json, meta.json and previews/preview.png (Sketch); a gzip stream whose first 4 KiB inflate to an SVG document; PDN3 and a 24-bit header length (Paint.NET). Any other ZIP or gzip file is left alone and fails the sniff (422).
  • Scientific and legacy long tail (WP-108): KTX2 «KTX 20»\r\n\x1A\n; FITS a first card SIMPLE = with the logical value T; DICOM Part 10 DICM after the 128-byte preamble; PICT a 512-byte application header (any content), then a non-empty picture frame and a version 1 (11 01) or version 2 (00 11 02 FF) opcode; Photo CD PCD_IPI at offset 2048 and a file long enough for the Base image. PICT is checked before the formats without a signature (its header is usually zeros, which ImageMagick’s own sniffer takes for TGA). MNG (8A MNG), JNG (8B JNG) and Windows metafiles (a placeable WMF header D7 CD C6 9A or a bare META_HEADER; an EMR_HEADER with EMF at offset 40) are recognised by the server before codec.Sniff, like the designer containers (§1.7, §1.8).
  • Video containers (WP-74): an ISOBMFF ftyp box without an image brand is MP4 (isom, mp41, mp42, avc1, av01, dash, 3gp*, M4V , …) or MOV (qt ); a QuickTime file without ftyp starts with a moov, mdat, wide, free, skip or pnot atom; EBML (1A 45 DF A3) is WebM when its DocType is webm, else MKV; RIFF….AVI is AVI. They are input-only formats (video/mp4, video/quicktime, video/webm, video/x-matroska, video/x-msvideo) and the FFmpeg demuxer that reads them must match the sniff (mov, matroska, avi), like the libvips loader for images.

Input only; none of these formats can be written (§5), and format=auto negotiates the output like for any source.

  • JPEG XR (jxr, image/jxr): jxrlib at the 4creators commit Debian and Homebrew ship (1.2~git20170615.f752187), BSD-2-Clause, built in the slim base (docker/base/build-jxrlib.sh, two shared libraries, 0.4 MB) with Debian’s fixes and ours (docker/base/patches/jxrlib). libvips has no JPEG XR loader and ImageMagick’s shells out to JxrDecApp (a delegate: refused), so the shim registers its own libvips loader class, XiForeignLoadJxrBuffer (jxrload_buffer, internal/vips/jxr.c), at init: it sniffs II BC, reads the container and codestream headers in header (no pixel decoded, so probes and pixel limits work as for any libvips format) and decodes in load through jxrlib’s memory stream over the blob’s C memory into a g_malloc’d buffer the output image owns. Thumbnails (vips_thumbnail_buffer), /info and every pipeline path find it like any loader. Pixel formats: 8- and 16-bit grey, RGB (BGR orders swapped) and RGBA, 8/16-bit CMYK(A) as libvips CMYK are kept; half, float, fixed-point, RGBE, 5/6/10-bit packed and 1-bit formats go through jxrlib’s converters to 8-bit grey, RGB or RGBA (HDR JPEG XR is clipped, not tone-mapped); N-channel and YCC formats are refused. The container’s orientation is applied by jxrlib; an embedded ICC profile is kept. Hardening: jxrlib is old C code, so the build defines NDEBUG (its asserts would abort the process; the two that validate headers became checks, patch 0005), the library no longer prints to stdout/stderr (0004), and about four hours of libFuzzer (ASan, UBSan, LeakSanitizer) plus 40 minutes of Go fuzzing (FuzzJXR, 12 M executions) over the loader (jxr.c + jxrlib, seeded with the fixtures and the imagecodecs samples) found a NULL write, over-reads of a global table, the stack and a heap buffer, and leaks (failed and interleaved-alpha decodes, repeated metadata tags) and unbounded allocations sized by a corrupt alpha plane header or metadata count (the latter also a 32-bit wrap to a 1-byte buffer): all fixed (patches 0005 and 0008-0020) or refused up front (the loader rejects a container whose pixel format disagrees with its codestream, which is where jxrlib wrote past its buffers). The crashing inputs are regression seeds of FuzzJXR. jxrlib remains old C code decoding untrusted input inside the process; the residual risk is recorded here and in ADR 0006. FuzzJXR (Go) keeps fuzzing the loader entry point and TestNoLeakJXR checks that decoders and buffers are freed.
  • ImageMagick legacy coders (tga, pcx, qoi, sgi, sun, xbm, xpm, pam; DDS until WP-105): ImageMagick is already in slim for PSD; its coders are compiled in, so widening the policy costs nothing in size. policy.xml registers the modules PSD, TGA, PCX, DDS, QOI, SGI, SUN, XBM, XPM and PNM and lets exactly the coders PSD, PSB, TGA, PCX, DDS, QOI, SGI, SUN, XBM, XPM and PAM read (not their aliases ICB, VDA, VST, DCX, DXT1, DXT5, RAS, PICON, PM, PBM, PGM, PPM, PNM, PFM, PHM); every delegate, filter, indirect path and other coder stays denied and the resource limits are unchanged (10 s, 256 MiB, no disk, 64 MP, 16K per side). libvips 8.18 has no QOI loader of its own (qoiload arrives in 8.19). ImageMagick decodes a whole file even for its header, so headers are parsed in Go (codec.ParseLegacyHeader: size, alpha, grey, depth) and the pixel limits hold before ImageMagick runs. DDS moved to Go in WP-105 (below): ImageMagick’s coder refused DX10 headers and BC6H (“image type not supported”); the base policy still lets the DDS coder read, but codec.Sniff never hands DDS to libvips any more (dropping it from policy.xml is left to a base revision). XPM transparency is reported when the file names the None colour.
  • DDS (pure Go, codec/dds, WP-105): the header (DDS_HEADER, and DDS_HEADER_DXT10 when the FourCC is DX10) is parsed before anything is allocated, and the whole first image must be present, so a file cannot make the decoder allocate more than 48 bytes per input byte; XI_LIMITS_MAX_SRC_PIXELS holds at probe. Encodings: BC1–BC5 (legacy DXT1–DXT5, ATI1/ATI2, BC4U/S, BC5U/S, and their DXGI formats), BC6H UF16/SF16, BC7, the legacy bit-mask RGB(A), luminance(+alpha), alpha-only and 8-bit palette formats, and the DXGI 8-bit RGBA/BGRA/BGRX, R8, R8G8, A8, R10G10B10A2, 16-bit RGBA, 5:6:5, 5:5:5:1, 4:4:4:4 and half/float formats; YUV and bump-map formats, ASTC/ETC and the other DXGI formats are refused (unsupported, 422). One-channel textures (BC4, R8, luminance) come out grey, two-channel ones (BC5, R8G8) as red and green (blue 0, 128 for SNORM), SNORM values mapped from [−1, 1] to [0, 255]. Alpha follows the header: BC2/BC3/BC7 and the RGBA formats have it unless a DX10 header says the alpha mode is opaque; legacy DXT1 only with DDPF_ALPHAPIXELS. The block decoders follow the D3D11 functional specification with bcdec’s integer arithmetic (MIT, NOTICE) and match bcdec exactly on 288 pseudo-random blocks per format covering every BC6H and BC7 mode (testdata/bcdec), and Pillow within ±1 on real files. FuzzDecode (files) and FuzzBlocks (every block decoder on arbitrary blocks) fuzz it.
  • CIE L*a*b* TIFF (WP-105): libvips 8.18’s tiffload reads 8-bit CIELab as LabQ-coded pixels its own sanity check then rejects (“bad labq format”), and would read 16-bit CIELab as D65-relative LabS, while TIFF CIELab is relative to D50 unless told otherwise (libtiff’s default, after Adobe’s TIFF technical note). codec.ParseLabTIFF walks the IFD chain in Go; when page 0 is CIELab (8 or 16 bits, at least 3 samples, not JPEG-compressed) every consumer (probe, pipeline, /info, sampling, watermarks) loads a C-memory copy whose CIELab PhotometricInterpretation values are rewritten to RGB, so libvips returns the raw samples. They are unpacked with a lookup table (L* × 100/255 or /65535, a* and b* two’s complement, ÷ 256 at 16 bits) and converted to 8-bit sRGB by lcms (vips_icc_transform to the built-in sRGB profile, relative colorimetric) from an identity D50 L*a*b* profile built in Go, or from the file’s own profile when it is an L*a*b* one. Alpha (extra samples) is kept, 16-bit alpha scaled to 8 bits. The result matches the CIE conversion (Bradford to D65, IEC 61966-2-1) within one level (TestColourMatchesReference); /info reports colorspace: "lab".
  • CMYK JPEG XL (WP-105): a CMYK JPEG XL stores C, M and Y as colour channels and K as an extra channel of type black, with a CMYK ICC profile; jxlload asks libjxl for all channels in one interleaved buffer, which libjxl refuses above four channels. The shim registers XiForeignLoadJxlCmykBuffer (jxlcmykload_buffer, internal/vips/jxlcmyk.c) with a higher priority than jxlload; its is_a_buffer accepts a JPEG XL stream only when its basic info declares a black channel, so every other JPEG XL file still goes to jxlload. It decodes the first frame (layers coalesced, orientation applied) with the colour channels and K (and alpha) in separate libjxl buffers and interleaves them as libvips CMYK(A), inverted to 0 = no ink, 8 or 16 bits, with the embedded profile; the pipeline then converts it like a CMYK JPEG (relative colorimetric import with black point compensation, WP-114). Verified against lcms on a lossless fixture (TestColourMatchesReference) and against the conformance suite’s reference rendering of cmyk_layers (mean error 0.95 with relative colorimetric plus black-point compensation or perceptual intent; relative without black-point compensation, the behaviour before WP-114, gives mean 1.9). FuzzJXLCMYK and TestNoLeakJXLCMYK cover the loader.
  • High-precision JPEG (WP-106): libvips 8.18’s jpegload reads JPEG with libjpeg’s 8-bit API only, so a 12-bit DCT frame (T.81 extended or progressive) fails once decoding starts and a lossless frame (T.81 process 14, SOF3) either fails (above 8 bits, or coded as YCbCr, which libjpeg-turbo cannot convert in lossless mode) or loads with its samples in the wrong range (2–7 bits). The shim registers XiForeignLoadJpegHpBuffer (jpeghpload_buffer, internal/vips/jpeghp.c) with a higher priority than jpegload; its is_a_buffer walks the markers to the first frame header and accepts only a 12-bit Huffman or arithmetic DCT frame (SOF0/1/2/9/10) or a Huffman lossless frame of 2–16 bits (SOF3), so every 8-bit DCT JPEG still goes to jpegload (shrink-on-load, UltraHDR). It decodes with libjpeg-turbo 3’s jpeg12_read_scanlines / jpeg16_read_scanlines (the library the base already ships; the 12/16-bit APIs are always built since 3.0) over its memory source on the blob’s C memory, sequentially through vips_image_generate and vips_sequential like jpegload, with the decoder state owned by the generated image. Output: 9–16 bits as ushort scaled to 0–65535 (v × 65535 / (2^P − 1), so a 12-bit image is an ordinary RGB16/GREY16 image and /info reports bit_depth 12), 8 bits as uchar, 2–7 bits scaled to 0–255; grey, RGB (DCT YCbCr converted by libjpeg-turbo, lossless YCbCr converted by the shim with the JFIF equations), CMYK inverted to 0 = no ink like jpegload, lossless YCCK; EXIF (orientation, resolution and the exif-ifd* fields, parsed by libvips when the blob is attached), XMP, IPTC, the ICC profile (libjpeg-turbo’s jpeg_read_icc_profile) and the JFIF resolution as jpegload reads them. fail_on behaves as for jpegload: warnings fail the decode under warning, a premature end of data under truncated and above. Thumbnails go through the same loader without shrink-on-load (lossless frames cannot be scaled while decoding). The probe (codec.JPEGFrame) refuses what no loader decodes with a 422 naming it: DCT precisions other than 8 and 12, hierarchical frames (SOF5–7, 13–15), arithmetic-coded lossless (SOF11, not in libjpeg-turbo) and JPEG-LS (SOF55; CharLS is not in the base). Verified against djpeg (libjpeg-turbo’s reference decoder, built from the pinned release): every sample of the 12-bit colour and progressive grey, 16-, 10-, 8- and 6-bit lossless fixtures equals djpeg’s at the file’s precision (TestLoadJPEGHPReference); CMYK and lossless YCbCr against the master photo (DSSIM ≤ 0.01), lossless CMYK exactly. Fixtures jpeg/*12*.jpg, jpeg/lossless*.jpg (testdata/gen/jpeghp.sh: cjpeg/djpeg built in the builder, gen/jpeghp/encode.c for CMYK and lossless YCbCr). FuzzJPEG12 fuzzes the loader and TestNoLeakJPEGHP checks that decompressors are freed, failed and abandoned decodes included.
  • WBMP and ICNS (pure Go, codec/wbmp, codec/icns): ImageMagick has a WBMP coder but no content detection for it, so libvips could never pick it; a 60-line Go decoder is simpler and safer. ICNS picks the largest entry: PNG and JPEG 2000 entries (ic04–ic14, icp4–icp6) are handed to libvips like PNG-in-ICO, “ARGB” and classic is32/il32/ih32/it32 run-length entries (with their s8mk… masks) are decoded in Go. Both are fuzzed (FuzzDecode).
  • PBM/PGM/PPM ASCII and bitmap variants (P1–P4) now go through ppmload_buffer like the binary ones (the shim allowed only P5/P6 before): ASCII parsing is bounded by the same pixel limit as the binary files. PBM decodes to 8-bit grey.
  • EPS / PostScript / Illustrator (WP-99, ADR 0011): gpl image only, off by default. PostScript needs a PostScript interpreter, Ghostscript, which is AGPL-3.0 and has a long record of -dSAFER bypasses (CVE-2016-7977, CVE-2018-16509, CVE-2019-14811, CVE-2021-3781, CVE-2023-36664, CVE-2024-29510: file access and command execution from a crafted document). WP-97 therefore declined it; the owner then decided (2026-09-27) to support it in the gpl image only, disabled unless XI_FORMATS_EPS_ENABLED=true, with Ghostscript run out of process:
    • Detection (eps, application/postscript): %!PS (after an optional Ctrl-D), a bare %! line, or the DOS EPS binary header C5 D0 D3 C6. PDF-based Illustrator files (AI 9 and later) start with %PDF- and are PDFs (PDFium, §1.2); only PostScript-based ones (AI 8 and older) are EPS here.
    • Probe in Go, never gs (codec.ParseEPSHeader): the DOS EPS header’s PostScript and TIFF sections, then the DSC header comments up to %%EndComments: %%HiResBoundingBox (preferred, as Ghostscript does) or %%BoundingBox, (atend) values read from the last 64 KiB, %%Pages, %%DocumentMedia for a document without a box, Letter without either. The page is its box at 72 DPI (one pixel per point, like PDF), with alpha; boxes that are not finite, positive and at most 40 000 pt per side are corrupt, and the pixel limits apply before anything renders. Fuzzed (FuzzParseEPSHeader).
    • Render (internal/ghostscript): one gs process per page, the PostScript on stdin and a PNG on stdout read into C memory; the requested page only, at 72 × scale DPI so a resized page renders at the target size (capped by XI_FORMATS_EPS_MAX_DPI, 600, and by XI_LIMITS_MAX_SRC_PIXELS); the device fixed to the page box (-g, -dFIXEDMEDIA: a document asking for a larger page is clipped) with the box’s corner moved to the origin of every page; transparent background (pngalpha; an output without alpha flattens it on the request’s background, white by default). Stacking pages (pages) is not supported (the first selected page renders).
    • Sandbox: -dSAFER, an empty environment, a private TMPDIR, a process group killed on cancel, after XI_FORMATS_EPS_TIMEOUT (10 s, 504) or when the output outgrows the page (422); on Linux a re-exec helper sets no-new-privileges, the parent-death signal, an unprivileged user, and address-space, CPU, file-size, open-file and process limits before it execs gs, and tries a network namespace (Docker’s default seccomp profile refuses it: startup logs which layers hold). XI_FORMATS_EPS_MAX_PROCS (2) bounds concurrent renders (429 beyond). ADR 0011 lists what this does not protect.
    • Without Ghostscript (slim, full, gpl with the switch off): a DOS EPS file with an embedded TIFF preview is decoded from that preview (every flavour; the preview is usually a 72 DPI screen image); any other EPS answers 422 “EPS needs the gpl image with XI_FORMATS_EPS_ENABLED=true”. Detection and /info colour analysis of an EPS use the preview too, never Ghostscript.
  • PICT: declined by WP-97 (libvips’ buffer loader lets ImageMagick guess the coder, and PICT’s 512-byte zero header looks like TGA to it); supported since WP-108 by naming the coder (§1.8).

Input only (§5). The formats designers save their work in either carry a finished raster, which is used as is, or (GIMP) layers that are composited here. Evidence and priorities: docs/research/format-gap.md §3 and §5.

  • Where it happens. internal/server unwraps a container right after the fetch, before the SVG sanitiser and the probe (Server.prepare: unwrap, then sanitize), for processing, /info and watermark images alike: the embedded image goes into a new allocator buffer (C memory), and everything after it (probe, limits, negotiation, cache keys of the output, format=auto) sees that image. A kept source format is therefore the embedded one (PNG; SVG for SVGZ). raw and skip_processing answer 422 for a container (the sniffer does not know it). The ETag and /info size still cover the file as fetched.
  • Reporting. /info adds "container": {"format": "ora", "mime_type": "image/openraster", "entry": "mergedimage.png", "content": "composite"} (with the format option) while format, width, height and the rest describe the embedded image; content is composite (full-size flattened image), thumbnail, preview (first page) or document (SVGZ). With XI_OBSERVABILITY_DEBUG_HEADERS the response carries X-Xi-Container-Source: ora; entry=mergedimage.png; content=composite (kept by the result cache), and the probe span the xi.container attribute.
  • Bounded ZIP reader (codec/container/zip.go, pure Go, not archive/zip): it reads the classic end-of-central-directory record (the last one whose comment ends exactly at the end of the file) and the central directory, and inflates only the entry it needs, straight into the destination buffer. Limits: at most 20 000 entries; the declared uncompressed sizes of all entries at most 4 GiB together; the entry read at most the source byte limit (XI_LIMITS_MAX_SRC_BYTES; 64 MiB when it is 0, unlimited) and at most 200 times its compressed size (entries under 1 KiB exempt); the stream must produce exactly the declared size with the declared CRC-32. Stored and deflate only; encrypted entries, other methods, ZIP64 and multi-disk archives are 422. Every name is checked before anything is read: empty, over 1 KiB, absolute, drive-lettered, containing NUL, backslash or a .. segment fails the whole archive (nothing is ever written to disk, but an archive carrying such names is hostile or broken), and two entries with the name the reader wants are refused as ambiguous. Fuzzed (FuzzOpen, FuzzOpenZip); zip-bomb, lying-size, CRC and traversal cases are unit tests and TestDesignerContainers requests them through the service.
  • OpenRaster and Krita: mergedimage.png, the full-size composite both applications write (OpenRaster 0.0.5 requires it; Krita writes it since 2.x); an archive without it (or with an empty one) falls back to Thumbnails/thumbnail.png (ORA) or preview.png (KRA, 256 px), reported as thumbnail. The entry must be a PNG.
  • Procreate: QuickLook/Thumbnail.png, a screen-sized thumbnail (typically 1024 px on the long side), reported as thumbnail. The full canvas would need the LZO/LZ4 tiles and the NSKeyedArchiver Document.archive (as axaril/silicate does); not done.
  • Sketch: previews/preview.png, Sketch’s preview of the first page (up to 2048 px), reported as preview. Other pages and artboards are not rendered.
  • SVGZ: gunzipped up to XI_LIMITS_MAX_SVG_BYTES and at most 200 times the compressed size (a gzip bomb is 422), then the SVG path: the sanitiser, the SVG limits, librsvg.
  • Paint.NET: the header after PDN3 is XML; its <custom><thumb png="…"/> attribute is a base64 PNG thumbnail (up to 256 px), reported as thumbnail. The layers are a .NET serialisation and are not read; a file without a thumbnail is 422 “Paint.NET file without an embedded thumbnail is not supported”. The header is bounded to 4 MiB.
  • GIMP XCF (xcf, image/x-xcf): XCF has no stored composite. codec.ParseXCFHeader reads the header, the image properties and every layer’s header and properties in Go (size, offsets, type, opacity, visibility, mode, group membership); the probe never runs ImageMagick. To flatten (codec.FlattenXCF), each visible top-level layer, bottom first, is decoded by ImageMagick’s XCF coder from a copy of the file whose layer table lists only that layer (the coder otherwise decodes every layer below the one asked for, quadratic in the layers), then composited onto a transparent canvas with libvips at its offset, with its opacity and blend mode; the canvas is materialised after every layer, so at most two canvases and one layer are in memory. A group contributes its stored projection (its children are skipped). Blend modes map to libvips’ (normal, multiply, screen, overlay, soft light, hard light, difference, addition, darken, lighten, dodge, burn, exclusion); the others (dissolve, HSV/LCh, divide, grain, …) composite as normal, and layer masks are not applied. libvips blends in sRGB while GIMP 2.10 blends its default modes in linear light, so semi-transparent and non-normal layers differ slightly from GIMP’s own render (DSSIM ≤ 0.015 on the fixtures). What ImageMagick’s coder cannot decode correctly is refused up front with 422: indexed images, precisions other than 8-bit gamma, zlib-compressed tiles (GIMP’s “better compression”), uncompressed tiles (the coder misreads them) and versions 4–10 (their 32-bit offsets are read as 64-bit). At most 256 layers; the canvas and every composited layer must fit XI_LIMITS_MAX_SRC_PIXELS. policy.xml allows the XCF module and coder (read only), and the shim lets magickload run only on the XCF signature (xi_is_magick_input). XCF is old C code in ImageMagick decoding untrusted input: the Go header parser is fuzzed (FuzzParseXCFHeader) and gates what reaches it, and the usual magick limits apply (10 s, 256 MiB, 64 MP, 16K per side).
  • Not supported: Affinity (.afphoto, .afdesign), Figma (.fig) and Photoshop-like files of other tools (format-gap §3); Procreate and Sketch full renders.

1.8 Scientific and legacy long tail (WP-108)

Section titled “1.8 Scientific and legacy long tail (WP-108)”

Input only (§5); the items of the WP-108 proposal in docs/research/format-gap.md §5, all delivered. Every flavour decodes them except WMF/EMF (gpl). Where the roadmap named a C library (cfitsio, DCMTK/GDCM, ImageMagick’s MNG coder), the format is read in Go instead: less C on untrusted input, no new base dependency, and the parts that matter for display are small.

  • KTX2 (ktx2, image/ktx2; codec/ktx2 + internal/vips/ktx2.c): the container (header, data format descriptor, level index, supercompression global data) is parsed in Go and bounded before anything is allocated; level 0 must lie inside the file and decompress to exactly the size its format needs. The first image is level 0 of the first layer, the +X face of a cube map or the first slice of a volume. Payloads:
    • Basis Universal (ETC1S/BasisLZ, UASTC LDR, UASTC HDR 4×4, ASTC and the XUASTC/XUBC7 formats of Basis Universal 2.x): transcoded to RGBA8 (LDR) or RGBA half-float (HDR) by the Basis Universal 2.50 transcoder (Binomial, Apache-2.0), built in the slim base as libxibasisu.so with the app’s C wrapper (docker/base/basisu, three exported functions, everything else hidden; about 1 MB). The transcoder’s container_abort() calls abort() on a failed allocation; patches/basisu makes it throw std::bad_alloc, which the wrapper catches like every other exception. The transcoder inflates Zstandard-supercompressed UASTC itself, with the base’s libzstd.
    • BC1–BC7 (BC6H included) and the uncompressed Vulkan formats with a DXGI equivalent: a DDS DX10 header is written in front of the level and codec/dds decodes it (§1.6), so both texture containers share one set of block decoders. zlib supercompression is inflated in Go, Zstandard through the shim’s libzstd, both bounded by the declared size.
    • ETC2/EAC, PVRTC and the other Vulkan formats are refused (unsupported, 422). One-channel formats come out grey; alpha follows the format and the DFD. HDR payloads (BC6H, UASTC HDR, the float formats) are scene-linear and tone-mapped like OpenEXR (§1.4). Probe from the header, never a transcode.
  • FITS (fits, image/fits; codec/fits, pure Go): the first HDU with an image (the primary array, or the first IMAGE extension or fpack tile-compressed BINTABLE when the primary HDU is empty); axes past the third must be 1; a third axis of 3 is drawn as RGB (Siril and PixInsight colour composites), any other plane count draws plane 0 (the first WFPC2 chip). BITPIX 8/16/32/64 and −32/−64 with BZERO/BSCALE; BLANK and NaN are “no data”, drawn black. Tile compression: RICE_1, GZIP_1, GZIP_2, NOCOMPRESS, quantised float with NO_DITHER/SUBTRACTIVE_DITHER_1/_2, lossless GZIP_COMPRESSED_DATA; HCOMPRESS_1 and PLIO_1 → 422. Rows are flipped (FITS stores the bottom row first, as DS9 and astropy display). Stretch: astronomy data is linear and mostly dark sky, so a linear map shows a black frame with a few stars. An 8-bit image without scaling is shown as stored; everything else gets the automatic screen transfer function of PixInsight and Siril: normalise to [0, 1] over the finite samples (linked across RGB), take the median and the normalised MAD (1.4826 × median absolute deviation) of up to 2²⁰ of them, clip shadows at median − 2.8 × MAD and pick the midtones balance that puts the background at 25 % (MTF(0.25, median − shadows)); constant images are mid-grey. Output is 8-bit sRGB. Headers (1000 blocks), HDUs (1000) and sizes are bounded; the pixel limit holds before allocation. Fuzzed (FuzzDecode, FuzzDecodeConfig).
  • DICOM (dicom, application/dicom; codec/dicom, pure Go): a Part 10 file’s meta information and top-level dataset; the first frame. Transfer syntaxes: implicit/explicit little endian, explicit big endian and deflated explicit little endian (native pixels), RLE Lossless (in Go), and encapsulated JPEG baseline/extended, lossless JPEG, JPEG 2000 and HTJ2K frames, whose codestream is handed to libvips (jpegload; 12-bit DCT and lossless frames go to the shim’s jpeghpload, WP-106; jp2kload; all from C memory). jpeghpload scales 9–16-bit samples to 0–65535 and 2–7-bit ones to 0–255; the DICOM path scales them back to stored values at the frame’s precision and sign-extends a signed frame from BitsStored, so rescale and window apply as for native frames (ct-jpegll-signed.dcm, ct-jpegll12.dcm render exactly like ct-bigendian.dcm and ct-rle.dcm; jpeg-extended12.dcm within one level of jpeg/rgb12.jpg). JPEG-LS and the JPEG frames the standalone probe refuses (hierarchical, arithmetic lossless, other DCT precisions) answer 422. Rendering: modality rescale (slope, intercept), then the dataset’s first VOI window (LINEAR, LINEAR_EXACT, SIGMOID), else the frame’s own min/max; MONOCHROME1 inverted; YBR_FULL/YBR_FULL_422 converted; PALETTE COLOR through its LUTs; always 8-bit RGB. Patient data: a DICOM file carries names, IDs, dates, institutions and free text. The parser reads the values of only the elements it needs (transfer syntax, image pixel module, rescale and VOI) at the top level; every other element, sequences included, is skipped by length without being read or kept, errors name tags and never values, and /info reports the rendered image only (no EXIF/XMP/IPTC, no DICOM attributes). The fixtures carry a marker in every patient, study and free-text element: codec/dicom TestPrivacy checks that it reaches neither a parsed header nor an error, for intact, truncated and mutated files, and internal/app TestDICOMPrivacy that it reaches no /info answer (every field on), image, header or log line (debug level) of the service. The gallery’s ffc.dcm is a synthetic test file whose first frame is uniformly white. Fixtures are generated (testdata/gen dicom). Fuzzed (FuzzParseHeader, FuzzDecode, FuzzFirstFrame).
  • Macintosh PICT and Kodak Photo CD (pict, image/x-pict; pcd, image/x-photo-cd): ImageMagick’s PICT and PCD coders, allowed in policy.xml (read only; not their aliases PCT and PCDS) and named explicitly: the shim spools the source to a memfd and calls the magick file loader with PICT:/proc/self/fd/N (xi_load_magick_coder), after xi_is_pict/xi_is_pcd recheck the signature. Headers are parsed in Go (codec.ParsePICTHeader: frame, version, the state-setting opcodes before the first drawing opcode; ParsePCDHeader: Base image 768×512, rotation), so probes never run ImageMagick and the pixel limits hold first. Output is 8-bit sRGB of exactly the probed size (a PICT whose clip changes after the header is cropped or extended). Photo CD: the Base image only (not the 4Base/16Base residuals), rotated as the header says. The usual magick limits apply (10 s, 256 MiB, 64 MP). Fuzzed (FuzzParsePICTHeader, FuzzParsePCDHeader, FuzzMagickPICT on the C entry point). TestNoLeakLongTail cycles PICT, Photo CD and Basis Universal KTX2 decodes under the shim’s leak checker.
  • MNG and JNG (codec/container/mng.go): chunk streams handled like the designer containers (§1.7): the server replaces the file by the image it carries before the probe. MNG: the first embedded PNG (IHDR…IEND, with the MNG’s global PLTE/tRNS/gAMA/cHRM/sRGB/iCCP where the stream relies on them) or JNG; composition (DEFI offsets, BACK, FRAM clipping), later frames, Delta-PNG and BASI are not interpreted. JNG: its JPEG stream as is; with an alpha channel (PNG-compressed IDAT samples of 1–16 bits, or a JDAA JPEG) the colour and alpha are decoded with the standard library (bounded by the pixel limit) and re-encoded as one PNG. CRCs are checked, chunk walks bounded. /info reports "container": {"format": "mng", "entry": "firstframe", "content": "frame"} (JNG: JDAT, JDAT+IDAT or JDAT+JDAA). Fuzzed (FuzzOpenMNG).
  • WMF and EMF (gpl image only; internal/metafile, docker/base/metafile): no permissive renderer exists (format-gap §3.2), so the gpl base ships xi-metafile2svg, an MIT driver linking libwmf 0.2.16 (LGPL-2.0-or-later, with its URW fonts for text metrics; its SVG device patched for the SVG namespace, exact colours and escaped text, patches/libwmf) and libemf2svg 1.8.1 (GPL-2.0-only, EMF and EMF+). The server detects the metafile by content (container.DetectMetafile), runs the driver in the Ghostscript sandbox (internal/sandbox: empty environment, private TMPDIR, process group, rlimits, no-new-privileges, unprivileged user; ADR 0011) with the file on stdin and an SVG on stdout, bounded by XI_FORMATS_METAFILE_TIMEOUT (10 s), XI_FORMATS_METAFILE_MAX_PROCS (2, 429 beyond) and XI_LIMITS_MAX_SVG_BYTES for the SVG it writes (422 beyond); the drawing’s larger side is capped at 16 384 px. The SVG then takes the normal SVG path (sanitiser, limits, librsvg, vector scaling); /info reports format: "svg" with "container": {"format": "wmf", "entry": "xi-metafile2svg", "content": "converted"}. XI_FORMATS_METAFILE_ENABLED defaults to on when the converter is on PATH (the gpl image); elsewhere a metafile answers 422 “WMF and EMF input needs the gpl image (XI_FORMATS_METAFILE_ENABLED)”. EMF text is rendered with the SVG’s font names (librsvg’s fontconfig fonts); WMF text uses libwmf’s metrics.

A frame of a video becomes the image, which the pipeline then processes like any still (imgproxy Pro’s video thumbnails). Only the full image decodes video; the design is ADR 0010. Video → video and video → audio transformations are M5 (§5.2, ADR 0012).

  • Availability. internal/video/ffmpeg is built against FFmpeg with the build tag ffmpeg (the full image, -tags ffmpeg); without it the same API answers Unsupported. XI_VIDEO_ENABLED=auto (default) decodes video when the build can; true fails startup without FFmpeg; false refuses video everywhere. A video on the slim image, or with video off, is 422 unsupported with the public message “video sources are not supported by this image” (or “… are disabled”), never a crash or “unrecognised format”.
  • Reading the source. The bytes stay in the C memory of the fetched buffer and FFmpeg reads them through a custom AVIOContext (no temporary file, no protocol: QuickTime references and playlists cannot open anything). Demuxers are limited to mov, matroska and avi, decoders to h264, hevc, vp8, vp9, libdav1d (AV1), mpeg4, mjpeg, png and rawvideo. Every decoder runs single-threaded (parallelism comes from the worker semaphore) with max_pixels = XI_VIDEO_MAX_PIXELS.
  • Probe. Container and stream headers (avformat_find_stream_info): the best video stream (av_find_best_stream), its codec, coded size, sample aspect ratio (the frame is shown, and decoded, at the display width: anamorphic video comes out undistorted), average frame rate, frame count (from the container, or duration × fps), container duration and bit rate, the display matrix and every stream. The probe reports the display size and turns the display matrix into the EXIF orientation that shows the frame the same way (90° clockwise → 6, 180° → 3, 270° → 8, mirrored → 2, 7, 4, 5), which the planner applies after the resize like a photo’s. XI_VIDEO_MAX_BYTES, XI_VIDEO_MAX_DURATION and XI_VIDEO_MAX_PIXELS (also from the container headers, before any decode) are checked here, and XI_LIMITS_MAX_SRC_PIXELS applies to the frame like to any image; all are 422 limit_exceeded. XI_LIMITS_MAX_SRC_BYTES still caps the download itself.
  • Frame selection. video_thumbnail_second (vts, seconds, float) picks the first frame whose timestamp reaches it (FFmpeg’s accurate seek: the keyframe before it is decoded and the frames in between are dropped); a negative value counts from the end (vts:-1: one second before the end) and the time is clamped to the video, the last frame answering a time past the end. video_thumbnail_keyframes (vtk:1) takes the keyframe at or before the time instead: faster (non-key frames are skipped by the decoder), less precise. Without vts, XI_VIDEO_THUMBNAIL_SECOND applies; its default auto is best-frame selection: XI_VIDEO_THUMBNAIL_SAMPLES (5) timestamps spread over the interior of the first XI_VIDEO_THUMBNAIL_SPAN (10 s) of the video (points (i+1)/(n+1), never the first frame, often black, or the last), the keyframe at or before each, and the best-scoring one wins: the Shannon entropy of a 64 px wide grey copy’s luma histogram plus its standard deviation / 64, times (1 − share of pixels darker than 24 or brighter than 232)², quartered when the mean luma is below 32 or above 224 (fades, black or white title cards, flashes). Deterministic; a tie keeps the earlier frame. The debug header X-Xi-Video-Frame reports the frame (0.480s key best 4 7.65: time, keyframe, best-frame selection over 4 distinct keyframes with that score).
  • Decode and conversion. The chosen frame is converted by libswscale into 8-bit RGB (RGBA for pixel formats with alpha, e.g. PNG or rawvideo RGBA; VP8/VP9 alpha side data is not decoded) straight into a libvips-owned buffer that becomes the image without a copy. The YUV → RGB matrix and range are the stream’s (BT.709, BT.601, BT.2020, full or limited range; unspecified is BT.601 like FFmpeg). High bit depth and HDR (PQ/HLG) video is converted to 8 bits without tone mapping. Shrink on load: when the result is at least 2× smaller than the frame, libswscale scales the frame to twice the target (area averaging) and libvips does the last 2× with its kernel, like JPEG shrink-on-load; smaller reductions decode at the display size (plan.LoadStep.Scale; plan.Limits.DisableShrinkOnLoad turns it off, as for JPEG). TestVideoPreShrink checks the pre-shrunk result against a full-size decode (DSSIM ≤ 0.01); the timings are below.
  • Cancellation and memory. A context.AfterFunc watcher sets an atomic abort flag that FFmpeg’s I/O interrupt callback and the decode loop poll: a request that times out or whose client leaves stops at the next packet with its context’s kind. Nothing in C points to Go memory; every FFmpeg object is freed before the call returns (TestVideoNoLeak: 200 thumbnails, no libvips object, tracked byte or FFmpeg handle left behind). The planner adds 8 bytes per stream pixel for the decoder (frame, references, scaler) to the memory-budget estimate.
  • Output. Any image format, negotiated like any source (a video has no output format of its own: the §6.2 “not encodable” row); Content-Type follows the result. A vta animation, or any animated result, can also be encoded as MP4/WebM (§5.1, WP-76).
  • One open per request. The probe keeps its FFmpeg handle open (codec.ProbeSession, ffmpeg.Session) and hands it to the processor through the server’s SourceMeta.Session (closed by the server when the request ends, errors included): the frame, sprite or animation is decoded on the probed handle instead of opening and probing the file a second time (the WP-74 double open; avformat_find_stream_info costs ~10 ms on H.264). The handle is only reused when it reads the same C memory as the executor’s source (Session.Reads); otherwise the file is opened again.
  • Detection (WP-91). Face and object gravities, crop_objects, blur_detections, draw_detections and /info detect_objects work on a single-frame video: the models see the frame the request uses (the same vts/vtk or best-frame choice), upright (display matrix applied), so the boxes line up with the frame as shown. For processing that frame is decoded once, at the display size, on the probe’s handle, and the same decode is detected on (thumbnailed to 1024 px) and then processed (it is not scaled on load: plan.SourceMeta.FrameDecoded); /info detects on the frame its default selection picks, like its pixel fields. The detection cache key has the frame’s timestamp. Without the detector explicit detection options are 422 and sm falls back to attention, as for images.
  • Sprites (video_thumbnail_tile, vtt, WP-75). imgproxy Pro’s arguments step:columns:rows:tile_width:tile_height[:extend_tile:trim:fill:focus_x:focus_y]. step > 0 samples a frame every step seconds from 0; step < 0 spreads columns × rows frames evenly over the duration (duration / cells), and step 0 turns the option off. Every frame is taken upright (display matrix applied) and fitted into tile_width × tile_height (one side 0: derived from the frame’s aspect ratio; both 0: the frame size); fill covers the tile and crops around focus_x:focus_y (default 0.5:0.5); extend_tile centres a fitted frame on a black tile of exactly the requested size. Cells past the end of a short video stay black unless trim drops them (the sprite shrinks to the columns and rows used). The tiles are joined with libvips arrayjoin (row-major), and the sprite is then processed like any image (resize, crop, format, …). video_thumbnail_keyframes (or XI_VIDEO_THUMBNAIL_KEYFRAMES) takes the keyframe at or before each timestamp. The debug header X-Xi-Video-Frame says tile 3x2 6 frames 0.000-1.680s.
  • Animations (video_thumbnail_animation, vta, WP-75). step:delay:frames:frame_width:frame_height[:extend_frame:trim:fill:focus_x:focus_y]: frames frames every step seconds (< 0: duration / frames), each sized like a sprite tile, shown for delay milliseconds (0: the sampling pace, step, at least 20 ms), looping forever. Frames past the end of a short video are black unless trim drops them. The result is an animation for negotiation (format=auto keeps it animated: WebP or GIF, §6.2), the frame limits and the planner, which resizes each frame like a GIF’s; a still output format keeps the first frame. vtt wins when both are set. Neither a sprite nor an animation is detected (WP-72/73): face and object gravities fall back to the smart crop and crop_objects, blur_detections and draw_detections do nothing.
  • One handle, forward decoding. A sprite or animation decodes all its timestamps on one handle (Session.Frames): a timestamp after the previous one with no keyframe in between (by the demuxer’s index) is reached by decoding forward instead of seeking back to the keyframe, so ten frames of the one-keyframe H.264 clip cost one pass instead of ten seeks (BenchmarkFrames, below). The forward and the seeking decode give the same frames (TestSessionFrames, DSSIM ≤ 0.001 on H.264, VP9, AV1, VP8 and rawvideo).
  • Limits. XI_VIDEO_MAX_ANIMATION_FRAMES (100) caps the frames decoded (a larger grid keeps its columns and loses rows; a longer animation is cut), each sprite or animation side is at most 16384 px (larger cells are shrunk), and the server and the planner check the assembled result against XI_LIMITS_MAX_ANIMATION_FRAMES, XI_LIMITS_MAX_SRC_PIXELS, XI_LIMITS_MAX_ANIMATION_FRAME_PIXELS and XI_LIMITS_MAX_ANIMATION_PIXELS like a decoded animation (§3; 422). The memory estimate of the plan counts the composite’s pixels plus the decoder’s (8 bytes per stream pixel) before anything is decoded.
  • Dialects. imgproxy vts/video_thumbnail_second, vtk/video_thumbnail_keyframes (also in /info URLs); Cloudinary so_<seconds> on the video asset type with an image format (/video/upload/so_2,f_jpg/dog.mp4; so_auto keeps the configured selection, percentages are unsupported); ImageKit <video>/ik-thumbnail.jpg with so-<seconds>; imgix has no equivalent. URL-DIALECTS.md lists them.
  • /info. video_meta (vm, on by default): the container tags (strings, as imgproxy) plus container, codec, width/height (coded), rotation, duration, fps, bit_rate, frames; video_streams: one entry per stream with imgproxy’s type, codec, bps, fps, duration, language, frequency, layout, plus width, height, rotation for video. width/height/orientation describe the frame as shown; the pixel options (average, blurhash, …) sample the default frame.

Measured on the full builder (arm64 Docker on Apple silicon, one thread, -count=10, benchstat medians; raw output in internal/video/ffmpeg/testdata/bench-thumbnail.txt), 1280×720 fixtures:

Codec (720p fixture) exact frame at 0.9 s, 720p exact, scaled to 320×180 on load keyframe (vtk), 320×180 best frame (5 samples), 320×180 probe (headers) ffmpeg CLI, exact 320×180 (process per request) full pipeline to a 320×180 JPEG: shrink on load / full-size decode
H.264 High (Big Buck Bunny, B-frames, one keyframe) 74.9 ms 66.2 ms 22.4 ms 19.8 ms 10.1 ms 140 ms 84.0 ms / 87.3 ms
VP9 (keyframe every 0.48 s) 12.3 ms 7.4 ms 2.7 ms 7.3 ms 0.18 ms 42 ms 11.7 ms / 15.8 ms
AV1 (dav1d, keyframe every 0.48 s) 20.3 ms 17.8 ms 8.3 ms 24.2 ms 0.08 ms 74 ms 19.7 ms / 24.5 ms

Every benchmarked thumbnail call opens the file, so the probe time is included (in the server the probe’s handle is reused since WP-75, which saves that time per request); H.264’s is high because avformat_find_stream_info decodes a frame to learn the pixel format. An exact H.264 frame decodes every frame since the previous keyframe (27 here), which is what vtk:1 avoids. Shrink on load saves 20–25% end to end for VP9 and AV1 and is within noise for H.264, whose decode dominates; the pre-shrunk result matches the full-size decode (TestVideoPreShrink), so it stays on. The in-process decoder is 2–6× faster than spawning the CLI, before counting the temporary file the CLI would need for MP4s with the moov box at the end. Go allocations are 2–4.5 KiB and 30–60 allocations per thumbnail (the frame lives in C memory). Raw outputs: internal/video/ffmpeg/testdata/bench-thumbnail.txt, internal/pipeline/exec/testdata/bench-video.txt.

  • Loader: PDFium through pdfload_buffer, unblocked by name (vips.DefaultLoaders). Probe measures page 0 at XI_FORMATS_PDF_DPI (default 72: one pixel per point); that is the page’s natural size and the size /info reports (pages_number is the page count).
  • Rendering at the target size (like imgproxy Pro): the planner treats PDF as a vector source, so the page is rendered by PDFium directly at the scale the request needs (scale on top of the probe density), enlarging included, instead of rendering at the natural size and resampling. The vector guard caps the render at XI_LIMITS_MAX_SRC_PIXELS. A test compares a 800 px render with a 576 DPI render downscaled (DSSIM ≈ 0.001, against ≈ 0.19 for the upscaled 72 DPI render).
  • Pages: page (0-based) selects a page; pages (-1 = to the end) stacks that many pages from page on into one image, vertically and left-aligned, the gap beside narrower pages filled like the background (imgproxy Pro semantics). A page other than the first, or a stack, has a size the header does not tell: the executor measures the selection with a header-only PDFium load (no rendering) and plans on that size. pages applies to multi-page stills generally (TIFF, HEIF: their pages are loaded one at a time and joined, since pages may differ in size, the gap beside narrower pages transparent and flattened like any alpha), to the frames of an animation that is not kept as one (§3), and never to PSD layers.
  • Background: pages render onto opaque white and the result has no alpha, so negotiation picks JPEG for an automatic format. Pages render transparent (alpha kept) only when the request names an output format with alpha itself (f:png, webp, avif, …; a negotiated format does not count) and no background. A background colour renders transparent and flattens onto that colour (PDFium’s own background option is only used for white or transparent: libvips passes other colours to it with red and blue swapped).
  • Password-protected PDFs (a user password is needed to open them) fail with unsupported (422) and the public message “password-protected documents are not supported”; there is no password option. PDFium reports the case only as pdfload’s error text (“password required or incorrect password”), which the shim matches: the one place an error kind comes from libvips text. Owner-password-only PDFs open normally.
  • Loader: ImageMagick 7 through magickload_buffer (the vips-magick.so module). It is libvips-UNTRUSTED and unblocked by name; three layers keep it to Photoshop files and the legacy formats of §1.6: codec.Sniff sends only those signatures to it, the shim refuses it for any buffer that does not start with one of them (even when libvips’ own dispatch picks it for an unknown format, as it does for BMP bytes), and the base image’s policy.xml registers no other coder (regression test: ImageMagick called directly on BMP, PNG, GIF, SVG and PDF bytes fails).
  • Composite strategy: ImageMagick reads the merged composite (image data section) when the file has one (“Maximize compatibility” on, or a PSB/PSD without the version-info resource saying otherwise). Without it (resource 0x0421 hasRealMergedData = 0, the image data is blank) ImageMagick flattens the layers itself: pixel layers with normal blending come out right, adjustment layers, effects and smart objects do not. Such files are processed by default; XI_FORMATS_PSD_REQUIRE_COMPOSITE=true refuses them at probe with unsupported (422) instead.
  • Pages: page selects an ImageMagick image: 0 is the composite, 1… are the layers at their own size; /info pages_number is 1 + the layer count. pages never stacks PSD layers.
  • Limits: the canvas counts against XI_LIMITS_MAX_SRC_PIXELS at probe; ImageMagick’s policy caps every image at 10 s, 256 MiB and 64 MP whatever the configuration says.

1.4 HDR inputs: OpenEXR, Radiance, PFM (WP-67), DDS BC6H (WP-105)

Section titled “1.4 HDR inputs: OpenEXR, Radiance, PFM (WP-67), DDS BC6H (WP-105)”
  • Loaders: OpenEXR through openexrload, which only reads files: the shim spools the source buffer to a memfd (Linux; opened as /proc/self/fd/N, so a read-only root filesystem is fine and nothing lands in /tmp; other platforms use a temporary file) that lives exactly as long as the loaded image. Decode failures inside OpenEXR are mapped back to the source (corrupt, 422) through the spool registry. libvips reads every OpenEXR image as 4-band float scRGB (RGBA half → float, A = 1 when the file has none; luminance/chroma files come out grey; multi-part and deep files are refused at probe). Radiance through radload_buffer (the shim names it directly for #?RGBE headers, which libvips’ sniffer ignores); RGBE decodes to float scRGB (XYZE files are converted to scRGB). PFM (and 8/16-bit PGM/PPM, which are not HDR) through ppmload_buffer.
  • DDS: BC6H textures (and the half and float DDS formats) are decoded in Go (codec/dds) to float RGB(A) and enter the same tone mapper through vips_image_new_from_memory as scRGB; only the header says whether a DDS file is HDR, so the probe sets ImageMeta.HDR per file. A BC6H copy of the fixture scene renders like its OpenEXR original (DSSIM 0.002 with each operator, TestDDSBC6HMatchesEXR).
  • Probe and limits: ImageMeta.HDR (and /info "hdr": true) marks OpenEXR, Radiance, PFM and HDR DDS textures; BitDepth is 16 for half OpenEXR, 32 otherwise; the colour space is scRGB. /info describes the pixels the pipeline sees: sample_format float, 3 bands (4 with OpenEXR alpha), colorspace scrgb (b-w for a grey PFM). Width × height count against XI_LIMITS_MAX_SRC_PIXELS at probe. The planner loads HDR sources with random access (the tone mapper reads them twice) and counts 4 bytes per sample in the memory estimate.
  • Tone mapping (pipeline/exec/hdr.go, the colour-to-working-space step): negative values clip to 0; the white point W is the nearest-rank 99th percentile of the Rec. 709 luminance, measured on a box-filtered copy at most 256 px on its long side, and at least 1. Operators (XI_FORMATS_HDR_TONEMAP), per channel on linear light:
    • reinhard (default): extended Reinhard, x·(1 + x/W²)/(1 + x). W maps to 1, and a source within [0, 1] (W = 1) is unchanged.
    • aces: Narkowicz’s fitted ACES filmic curve x(2.51x + 0.03)/(x(2.43x + 0.59) + 0.14) on x = c·e, with the usual exposure e = 0.6, lowered to 7.2417/W when W would otherwise pass the curve’s white (f(7.2417) = 1).
    • clip: min(x, 1): no curve, no exposure change. The result is clipped to [0, 1], sRGB-encoded to 8 bits (libvips’ scRGB → sRGB), and the pipeline continues in 8-bit sRGB (8-bit grey for a grey PFM). Alpha (OpenEXR) is clamped to [0, 1] and kept. The formulas are pinned by a test that maps a linear ramp through each operator.
  • preserve_hdr: the image stays linear float scRGB (1.0 = SDR reference white, values above 1 included) and is written as HDR by the output (WP-104; vips.Image.Save on a float scRGB image): JPEG XL as float (jxlsave); AVIF as BT.2100 PQ with BT.2020 primaries, 12 bits (10 with HEIF.BitDepth 10), an nclx colour box (9/16/9, full range) and a clli box with the measured maximum and average light levels, 1.0 mapped to 203 cd/m² (BT.2408) and values above 10000 cd/m² clipped (the shim’s xi_avif_hdr_save over libheif and libaom; libvips 8.18’s heifsave cannot write CICP); JPEG as UltraHDR (uhdrsave: libultrahdr derives the SDR base and a new gain map), only without alpha, since JPEG would flatten it. HDR AVIF carries no EXIF or XMP. Only crop, resize, rotate, flip (and the no-op flatten a JPEG output plans for an image without alpha) keep that precision, so any other step (filters, colour adjustments, trim, backgrounds, watermarks, color_profile, chained pipelines), or any other output format, makes the source clip to 8 bits instead (clip, not the configured operator) and lists preserve_hdr among the skipped options. Until WP-104, AVIF was written as 16-bit sRGB clipped at 1. For sources that are neither HDR nor carry a gain map (16-bit PNG, 10/12-bit AVIF) preserve_hdr is not implemented and is reported as skipped.
  • Gain maps (WP-104): a gain map maps an SDR base rendition to an HDR alternate (ISO 21496-1: log2 gain = lerp(min, max, g^(1/γ)) · w, HDR = (SDR + k_base) · 2^gain − k_alt, per channel, in linear light). The probe finds them in the headers (codec/gainmap, ImageMeta.GainMap, /info "gain_map": true and "hdr": true): UltraHDR JPEGs (libultrahdr parses Google, Adobe, ISO and Apple metadata; uhdrload), JPEGs whose MPF secondary image carries ISO 21496-1 (APP2) or Adobe hdrgm XMP metadata that libultrahdr refuses (Adobe’s own files; jpegload then, the gain map image loaded from its byte range), and AVIF/HEIC files with a tmap derived item whose base is the primary image (the shim’s xi_heif_gainmap: the gain map item decoded by libheif without colour conversion, the tmap payload parsed in Go). Without a request for HDR, or with any other output, the SDR base is processed exactly as before. With preserve_hdr (and gain_map:auto, the default) or gain_map:hdr, and an HDR output (JPEG XL, AVIF, JPEG) whose steps are all float-exact (as above), the colour step applies the map at full weight (the alternate rendition; libvips’ uhdr2scRGB is not used because it decodes three-channel maps with the sRGB curve): the base goes to linear sRGB (an embedded non-sRGB profile, Display P3 from Apple, is converted to 16-bit sRGB first), the gain map is upsampled bilinearly to the base’s size, and the result, clamped at 0, continues as scene-linear float like OpenEXR, alpha included. gain_map:sdr always keeps the SDR base. Not applied (SDR base, preserve_hdr skipped): unsupported or malformed metadata (a tmap version or minimum version other than 0, zero denominators, gamma ≤ 0, max < min), files whose base is the HDR rendition (base headroom above the alternate’s; libvips cannot read their PQ base as HDR), stacked pages, animations, enforce_thumbnail and other pages. Apple’s pre-iOS 18 HEIC gain maps (an auxiliary urn:com:apple:photo:2020:aux:hdrgainmap image with its headroom in the maker note) are not read. The goldens render HDR outputs as linear light / 4 (exec/gainmap-*), next to the SDR base’s rendering.
  • Fixtures: testdata/gen/hdr.go writes the OpenEXR, Radiance, PFM and PPM fixtures in Go (libvips has no OpenEXR saver) from a procedural scene with a sun at 40 and about 5% of the pixels above 1.0.

Implemented in WP-49 (codec/raw, internal/vips/raw.c, pipeline/plan rawOnLoad, pipeline/exec/raw.go).

  1. Probe, header only. codec/raw.Probe → vips.ProbeRaw → LibRaw libraw_open_buffer, then libraw_adjust_sizes_info_only (with user_flip = 0) for the demosaiced size in the stored orientation. Nothing is unpacked; about 0.1 ms per file. It returns size, flip, camera metadata, LibRaw’s thumbnail list (thumbs_list: format, offset, length) and whether LibRaw can demosaic the file at all (RawHeader.Undecodable, WP-101):

    • Nikon High Efficiency (HE/HE*, intoPIX TicoRAW; makernote NEFCompression 13 or 14 on a Nikon NEF, not on a DNG converted from one): no open decoder exists. LibRaw 0.22 flags it only for the Z 9, Z 8, Z f and Z6III and decodes other bodies (Z50II) as lossless NEF, which gives wrong pixels, so the makernote decides for every body.
    • A placeholder decoder: LibRaw’s LIBRAW_DECODER_UNSUPPORTED_FORMAT flag (GoPro VC-5 without the GPR SDK, JPEG XL raw data without the DNG SDK). Of the base images only gpl has both SDKs (WP-103; owner decision 2026-09-28: the Adobe DNG SDK licence ships in gpl only), so in slim and full (and with a system LibRaw) these files are preview-only: the embedded preview is the image, or 422 camera RAW not supported (<make> <model>: JPEG XL or GoPro VC-5 raw data needs the gpl image) and the file has no usable embedded preview, never demosaiced garbage. vips.RawLibraryInfo() reports the linked build’s capabilities; in gpl the files are decodable and take the demosaic path.
    • A camera LibRaw does not know (libraw_open_buffer refuses it, typically a body newer than the release, such as the Sony A7 V): its previews and maker are still listed when codec/raw.Sniff recognises a RAW container.

    Such a file is preview-only: its embedded preview is the image (/info reports the preview’s size), and without a usable preview it is refused with 422 camera RAW not supported (<make> <model>: <reason>) and the file has no usable embedded preview. The shim also refuses to demosaic it (defence in depth). The usable preview is the largest thumbnail LibRaw classifies as JPEG whose bytes parse as an 8-bit baseline/extended/progressive JPEG frame (codec/raw.JPEGSize, with the SOI marker taken as present the way libraw_unpack_thumb patches it); H.265 (CR3), lossless, arithmetic and 12-bit previews are skipped. The RAW’s orientation is LibRaw’s flip mapped to EXIF (inverse of LibRaw’s "50132467" table): it applies to the demosaic and to the preview alike, both stored as the sensor reads.

  2. Decision, in the planner (plan.LoadStep.Preview/HalfSize): the preview is used when, as displayed, it is at least XI_FORMATS_RAW_PREVIEW_MIN_RATIO (default 1.0) × the size the image is scaled to on both axes (DPR included), or when enforce_thumbnail is set, and always for a preview-only file (a request larger than its preview gets the preview’s size, as for any source without enlarge). The preview is then JPEG-shrunk on load (2/4/8) down to that size.

  3. Otherwise the demosaic: LibRaw unpack + dcraw_process + dcraw_make_mem_image in the shim, with dcrawload’s parameters (8 bits, camera white balance, sRGB, no rotation), at half_size when the image is scaled to half its size or less (and shrink-on-load is not disabled), full size otherwise. libvips 8.18.6’s dcrawload has no half_size option, so the shim drives LibRaw itself and dcrawload stays blocked with every other untrusted loader. A cancelled request stops LibRaw at its next processing stage (progress callback). When LibRaw fails to unpack or demosaic a file whose header it read (unsupported or corrupt: a damaged mosaic, a decoder bug) and the file has a usable preview, the executor plans the request again with the preview as the source and serves it (X-Xi-Raw-Path: preview): an embedded preview is better than a 422. The demosaic’s working memory (2 bytes per sensor pixel + 8 per output pixel) is added to the plan’s cost estimate checked against XI_PROCESSING_MEMORY_BUDGET.

  4. Metadata: make, model, software, exposure time, f-number, ISO, focal length, capture time and lens model from LibRaw become exif-ifdN-* fields (replacing any EXIF the preview carried), so the encoder keeps or strips them by the metadata policy like any EXIF; stripped by default. /info reports them under exif. GPS is not exported.

  5. Limits (WP-102): RAW files are capped by XI_LIMITS_MAX_SRC_BYTES. A RAW is judged by XI_LIMITS_MAX_SRC_PIXELS on what is decoded, not on its sensor (transform.SourceLimits.CheckRaw/CheckRawDecode, shared by the codec probe, the server and the planner):

    • At probe (before the request’s path is known) a RAW passes when one of its decode paths fits: its embedded preview, or its half-size demosaic (⌈w/2⌉ × ⌈h/2⌉) unless it is preview-only.
    • The planner then checks the path the request takes: the preview at its full size (as a JPEG is judged by its header), the half-size demosaic, or the full demosaic (the sensor’s output size). A demosaic is also bounded by XI_LIMITS_MAX_RAW_DEMOSAIC_PIXELS (sensor width × height, default 120 MP; checked by the planner and again in the shim before unpacking), and its working memory (2 bytes per sensor pixel + 8 per output pixel) counts against XI_PROCESSING_MEMORY_BUDGET.
    • So with the defaults a 102 MP Fujifilm GFX100RF renders thumbnails and 2000 px images from its 12 MP preview and 5000 px ones from its 25.5 MP half-size demosaic; a 51 MP Hasselblad X1D II (no JPEG preview) renders up to half its size; their full-size demosaic stays a 422 (source is 11662×8746 (102 MP); limit XI_LIMITS_MAX_SRC_PIXELS is 50 MP) until the limit is raised. The messages name the path: camera RAW half-size demosaic is …, camera RAW embedded preview is …, camera RAW sensor is …; limit XI_LIMITS_MAX_RAW_DEMOSAIC_PIXELS is 120 MP.
  6. Observability: xi_raw_decodes_total{path} counts decodes by path (preview, demosaic, demosaic-half); with XI_OBSERVABILITY_DEBUG_HEADERS the response carries X-Xi-Raw-Path (preview for every preview-only file and every fallback).

Measured on the builder image (arm64 Docker on Apple silicon, 1024 px JPEG output, plan + decode + encode; BenchmarkRaw -count 10, benchstat medians): Sony ILCE-7S ARW (12 MP) preview 37 ms, half-size demosaic 76 ms, full demosaic 311 ms; Pentax K-50 DNG (16 MP) preview 46 ms, half-size 382 ms, full 1.06 s. ProbeRaw is ~0.1 ms per file.

Coverage measured by WP-100 (research/format-gap.md §3.3; 72 raw.pixls.us samples, release image with the full base):

  • Decoded on both the preview and the demosaic path:
    • Canon CR3, both lossless RAW and lossy C-RAW, cropped aspect ratios included.
    • CR2 (sRAW/mRAW included) and CRW.
    • Nikon NEF: lossless, uncompressed, lossy type 1/2 and small RAW; NRW.
    • Sony ARW v1/v2, lossless compressed and lossy; SR2.
    • Fujifilm RAF: X-Trans compressed, lossy and lossless; GFX; SuperCCD.
    • OM System ORF/ORI.
    • Panasonic RW2 and Leica RWL.
    • Pentax PEF, pixel shift included.
    • Samsung SRW.
    • Hasselblad 3FR/FFF and Phase One IIQ.
    • Minolta MRW, Kodak DCR and Epson ERF.
    • DNG: in-camera, Leica Monochrom, Apple ProRAW and Samsung Expert RAW (linear), drones, lossy DNG (Bayer and linear), HDRMerge float, CinemaDNG.
  • Preview only (WP-101): Nikon High Efficiency (HE/HE*, TicoRAW: Z50II, Z 8, Z f, Z 9, Z6III) is served from its full-size embedded JPEG at every size, with the camera’s own pixels (the Z50II used to demosaic into garbage with 200). So is any body newer than the pinned LibRaw snapshot (it refuses to open them) while it carries a preview.
  • Added by WP-103 (the base’s LibRaw build, docker/base/README.md “LibRaw snapshot and options”): LibRaw is a snapshot of its master branch (0.22.2 + 100 commits, 2026-09-25): it adds the Sony A7 V (ARW 6; the gallery sample now demosaics), Sony A1 II, Canon EOS R50 V and Hasselblad X2D II to the camera list. Sigma X3F (Foveon) decodes through LibRaw’s X3F tools in every flavour. In the gpl flavour only: GoPro GPR (VC-5 compressed DNG, no usable preview: every size is a demosaic; slim/full answer 422 naming the gpl image unless a GPR carries a usable JPEG preview) through the GoPro GPR SDK and the Adobe DNG SDK, and DNG 1.7 with JPEG XL raw data (slim/full: its embedded preview, else 422) (Lightroom’s “JPEG XL” DNG export, iPhone ProRAW JPEG-XL) through the Adobe DNG SDK 1.7.1 with the base’s libjxl (checked with dng/jxl-landscape.dng and Adobe’s own JPEG XL samples: linear integer, linear float and Bayer). LibRaw hands the SDK only those two compressions: the shim sets use_dngsdk to none, so every other DNG keeps LibRaw’s decoders. Residual risk: the DNG SDK (Adobe’s reference C++), the X3F tools and above all GoPro’s VC-5 reference decoder run in-process on untrusted input; a libFuzzer + ASan campaign found and fixed out-of-bounds reads/writes, NULL dereferences and leaks in the VC-5 decoder (docker/base/patches/gpr), and FuzzRawLoad keeps fuzzing the shim’s RAW entry points.
  • Leaf MOS (WP-108): Leaf backs (Aptus 22 and others) write no Make in IFD0 (the maker is only in the XMP), so codec/raw.Sniff took them for a plain TIFF and tiffload refused them (422 “tile size out of range”). The sniffer now also accepts IFD0 carrying Leaf’s private LeafData tag (0x8606, a PKTS block) with a RAW-shaped IFD0; LibRaw decodes and demosaics the gallery sample (4008×5344).
  • Pixel limit (WP-102): sensors over 50 MP (GFX100, X1D/X2D, 50 MP phone DNGs) render under the default limits through their preview or half-size demosaic (step 5); only a full-size demosaic needs XI_LIMITS_MAX_SRC_PIXELS raised.
  • Loaded as a vertical strip with page-height. Limits (WP-98, imgproxy’s CheckDimensions on the frames loaded): XI_LIMITS_MAX_ANIMATION_FRAMES (default 256) caps the frames; for an animation processed as one, XI_LIMITS_MAX_ANIMATION_FRAME_PIXELS (default 16 MP) caps width × height of a frame and replaces XI_LIMITS_MAX_SRC_PIXELS, so an 800×600 GIF of 176 frames (84.5 MP in all) is accepted, and XI_LIMITS_MAX_ANIMATION_PIXELS (default 200 MP, not in imgproxy) caps width × height × frames so the decoded strip stays bounded (256 frames of 16 MP would be 4.1 gigapixels). With the frame limit at 0 (imgproxy’s default) the animation counts all its frames against XI_LIMITS_MAX_SRC_PIXELS. An animation processed as a still (still output, disable_animation, a frame limit of 1) counts the frames it loads (one, or the stacked pages) against XI_LIMITS_MAX_SRC_PIXELS. The probe cannot know which applies yet, so it only rejects a frame that no limit admits; the planner checks the totals before anything is decoded. Every 422 names the limit and the numbers (source has 176 frames of 800×600 (84.5 MP); limit XI_LIMITS_MAX_SRC_PIXELS is 50 MP).
  • Output keeps animation when source is animated, output format supports animation (GIF, WebP, AVIF sequences when libvips supports saving them, JXL when supported, MP4/WebM/HEVC video, §5.1) and disable_animation is false. Otherwise frame page (default 0) is used. A video’s vta animation (§1.5) is an animated source too.
  • AVIF and HEIF image sequences (WP-104) are animated sources: the shim’s heifseqload_buffer decodes a track’s frames with libheif’s sequence API (frames are coded against earlier ones, so frames before page are decoded and dropped), as 8-bit sRGB or sRGBA (a 10/12-bit sequence is reduced to 8 bits), into the vertical strip with page-height. Frame count and delays come from the track’s sample tables (stsz/stz2 and stts, read by a bounded box walk; libheif has no call for them, and walking its samples would follow a looping edit list); the media timeline is decoded once (ignore_sequence_editlist); loop is the edit list’s repetition count (0 forever, 1 when there is none). They are read-only: AVIF and HEIC outputs are still images (libvips 8.18 heifsave has no sequence output), so an animated output is WebP, GIF, JPEG XL or a video. A still output (or disable_animation) decodes only the frames up to page. The frame limits apply as for GIF: the header’s frame count before decoding, XI_LIMITS_MAX_ANIMATION_FRAMES on the frames loaded.
  • Frame selection (WP-62; every dialect maps its page/frame parameters onto page/pages, URL-DIALECTS): when the animation is kept, page is its first frame and pages its frame count (imgproxy ignores both then; imgix frame= relies on it to pick one frame). When it is not kept (still output, disable_animation, a frame limit of 1), page selects one frame and pages stacks that many frames vertically into one image (imgproxy Pro), exactly like the pages of a multi-page still. page past the last frame clamps to it; pages:-1 runs to the end.
  • Frame delays and loop count preserved; per-frame geometric ops use arrayjoin over frames.
  • f:mp4|webm|hevc / fm=mp4|webm / f_mp4 / f-mp4 / ik-gif-video.mp4 / .mp4 extension on an animated source → GIF→video (WP-76, full and gpl images; codec per 5.1).

Default: strip EXIF/XMP/IPTC, keep copyright fields (XI_PROCESSING_KEEP_COPYRIGHT=true), convert to sRGB and drop the ICC profile (strip_color_profile=true; with false the converted output is tagged with the built-in sRGB profile, sGrey for grey, and an sRGB IEC61966-2.1 source profile is kept as is), always reset orientation after auto-rotate (the output EXIF, when kept, says orientation 1, so a second pass does not rotate again). color_profile embeds sRGB/CMYK/custom profiles from XI_PROCESSING_COLOR_PROFILES_DIR. GPS is always removed when stripping, even with keep-copyright.

Defaults are config values (XI_FORMATS_<FMT>_*) and can be overridden per request by imgproxy *_options, format_quality, and dialect equivalents.

Format Encoder Quality default Lossless Effort / speed Chroma subsampling Progressive / interlace Animation Alpha
JPEG libjpeg-turbo, UltraHDR (libultrahdr uhdrsave) for HDR with preserve_hdr (§1.4); jpeg_options trellis_quant, overshoot_deringing, optimize_scans and quant_table (0–8) are passed to libvips as imgproxy does and only take effect with a mozjpeg build (libjpeg-turbo ignores them with a GLib warning) 80 — — 4:2:0 default; 4:4:4 when q ≥ 90 or no_subsample progressive default on (XI_FORMATS_JPEG_PROGRESSIVE) — flattened on background (white default)
PNG libspng/libpng; quantizr palette when quantize or imgix png8 n/a (compression level 6) yes compression 0–9 — Adam7 interlaced off — (APNG not written) yes
WebP libwebp 80 lossless, near-lossless effort 0–6 (default 4) smart subsample option — yes yes
AVIF libheif + aom (SVT-AV1 evaluated later); HDR (preserve_hdr, §1.4) through the shim’s libheif encoder as 12-bit BT.2100 PQ 60 (AVIF 60 ≈ JPEG 80) yes effort 0–9 (default 4; ≤ 2 for images > 4 MP to bound latency) 4:2:0 default; avif_options:subsample auto/on/off, 4:4:4 when q ≥ 90 — not in M1 (sequences are read, WP-104, not written) yes
HEIC libheif + kvazaar (HEVC encoder, BSD) 60 — kvazaar preset 4:2:0 — — yes
JPEG XL libjxl 75 (distance mapped) yes effort 1–10 (libvips default 7; ours 5 for latency) n/a progressive option yes (libvips ≥ 8.16 frames) yes
GIF cgif + quantizr n/a (effort, dither 0–1, bitdepth 1–8) — effort 1–10 (default 7) — interlace option yes 1-bit
TIFF libtiff 80 (JPEG compression) deflate/LZW/zstd — — — pages yes
BMP own encoder (24/32-bit) — yes — — — — 32-bit
ICO own encoder (PNG-in-ICO, sizes ≤ 256) — yes — — — — yes
SVG passthrough of sanitised source only — — — — — — —
MP4 / WebM / HEVC FFmpeg (full and gpl images): VP9 (libvpx, BSD) for WebM; MP4 is AV1 (libaom) in full and H.264 (libx264) in gpl; HEVC-in-MP4 (libx265) only in gpl. See Video output CRF mapped from quality (XI_VIDEO_ENCODE_*_CRF) — realtime cpu-used / x26x preset 4:2:0 (even sides, padded) MP4 +faststart yes (WP-76) WebM VP9 alpha (yuva420p); MP4 flattened
JSON info endpoint / fm=json / f=json / output=json

*_options (WP-64 audit): jpeg_options (progressive, no_subsample, the mozjpeg arguments above), png_options (interlaced, quantize, quantization_colors → palette bit depth, as imgproxy’s vips_pngsave_go), webp_options (compression lossy/near_lossless/lossless, smart_subsample, preset) and avif_options (subsample auto/on/off) are applied; tests inspect the output bytes (SOF0/SOF2 and the luma sampling factors, the PNG IHDR colour type and interlace flag and the PLTE size, the WebP VP8/VP8L chunk, the AVIF av1C subsampling bits). imgproxy defines no jxl_options or tiff_options, and gif_options was removed upstream (still accepted and ignored for old URLs).

dpi (WP-64) replaces the output resolution: PNG pHYs, TIFF resolution tags, JPEG Exif when metadata is kept and otherwise a JFIF APP0 density (libvips built with libexif writes JPEG resolution into Exif only, so the encoder inserts the 18-byte JFIF segment itself: one copy of the output, only for dpi requests). WebP, AVIF, HEIC, JPEG XL and GIF have no resolution field. It survives strip_metadata.

enforce_thumbnail (WP-64): a HEIF/AVIF still is always decoded from its embedded thumbnail when it has one, whatever the target size (imgproxy Pro; without the option the thumbnail is used only when it is large enough, like imgproxy’s shrink-on-load). As an extension, a JPEG is decoded from its Exif (IFD1) JPEG thumbnail when the option is on, the thumbnail has the image’s aspect ratio (±1%, which refuses letterboxed 160×120 camera thumbnails), and planning on it gives the same result size without enlarging anything; otherwise from the image. The Exif thumbnail carries no ICC profile. The executor reports it in Result.Thumbnail.

Not produced: PDF, PSD, RAW, JPEG 2000, EXR, XCF and the designer containers (§1.7) (open question if anyone needs JP2 output for Safari legacy; no).

The contract WP-76 (GIF → video) and the video packages implement. Which encoders exist depends on the image flavor (docker/base/README.md, ADR 0005, ADR 0006 amendment 2026-09-27):

Requested output slim full gpl
webm Unsupported VP9 (libvpx-vp9) VP9 (libvpx-vp9)
mp4 Unsupported AV1 in MP4 (libaom-av1) H.264 in MP4 (libx264, High, yuv420p, +faststart)
hevc / h265 (MP4, hvc1 tag) Unsupported Unsupported HEVC in MP4 (libx265, Main)
  • Detection, not configuration. At startup the Go side probes the linked FFmpeg with avcodec_find_encoder_by_name for libx264, libx265, libaom-av1 and libvpx-vp9, and av_guess_format for the mp4 and webm muxers (xi_ff_encoders in the C shim of internal/video/ffmpeg, build tag ffmpeg, ADR 0010; never by parsing ffmpeg -encoders) and builds the table above from what it finds. The same binary therefore serves full and gpl; nothing reads the image label. Slim builds (no ffmpeg build tag) report every video output as Unsupported.
  • mp4 means “the best MP4 this image can write”: libx264 when present, otherwise libaom-av1. No config switch picks AV1 over H.264 in a gpl image in M3; if one is needed later it is XI_FORMATS_MP4_CODEC=auto|h264|av1 (default auto).
  • hevc/h265 is a separate, explicit output (dialect values that name HEVC); it is never chosen by format=auto or best, and it is Unsupported (the usual 4xx for an unavailable format) without libx265.
  • webm is VP9 in every video-capable image.
  • The startup log line ("msg":"video", "encoders":"vp9,av1" or "vp9,av1,h264,hevc") lists the detected video encoders, so an operator can see which flavor is running; the debug header X-Xi-Video-Codec names the codec of each video response. (There is no /info capability output to list them in.)

Implemented (WP-76). The state per flavour, verified with ffprobe in each builder (TestVideoOutput):

slim full gpl
f:webm 422 unsupported VP9 in WebM VP9 in WebM
f:mp4 422 unsupported AV1 in MP4 (X-Xi-Video-Codec: av1) H.264 High in MP4 (h264)
f:hevc (h265) 422 unsupported 422 unsupported HEVC Main in MP4, hvc1
  • Sources. Any animated result: GIF and animated WebP (every frame), a video’s vta animation, and the frames kept by page/pages. A still result becomes a one-frame video. APNG and AVIF sequences load as their first frame (libvips), so they give one-frame videos.
  • Frames. libvips renders one frame at a time into C memory (WritePixels), libswscale converts RGB(A) to BT.709 limited-range yuv420p (yuva420p for WebM with alpha; MP4 has no alpha and is flattened on white, or on the request’s background by the planner), and the encoder gets it; nothing but the current frame is held. An odd width or height is padded by one repeated column or row (4:2:0 needs even sides; X-Xi-Result-Width reports the padded size).
  • Timing. Timestamps in milliseconds from the source’s per-frame delays; a delay of 10 ms or less (0 included) plays as 100 ms, as browsers show such GIF frames. Each packet carries its frame’s duration, so the last frame lasts as long as in the GIF and the duration matches the source (TestVideoOutput: 0.600 s for the 6-frame fixture, 4.000 s for a 100-frame GIF). The loop count is ignored (a <video> loops with its loop attribute).
  • Encoders. One thread each (parallelism comes from the worker semaphore); VP9 and AV1 in their realtime modes (deadline=realtime / usage=realtime, cpu-used = XI_VIDEO_ENCODE_VP9_SPEED / _AV1_SPEED, default 8), x264/x265 with XI_VIDEO_ENCODE_H264_PRESET / _HEVC_PRESET (default veryfast, H.264 High profile). Constant quality: the request’s quality (or format_quality) maps onto the codec’s CRF around XI_VIDEO_ENCODE_<CODEC>_CRF (80 is the configured CRF, 100 a quarter of it, 1 the codec’s worst: 63 for VP9/AV1, 51 for H.264/HEVC); without one the configured CRF applies (VP9 36, AV1 36, H.264 25, HEVC 28). A keyframe every 10 s at most. max_bytes and auto-quality do not search video (reported as skipped).
  • Muxing. WebM or MP4 written through a custom AVIOContext into a growable C buffer (no temporary file); MP4 gets +faststart (the moov box in front), which FFmpeg’s second pass does by reading the buffer back through the context’s io_open (no other file or URL can be opened), and HEVC the hvc1 tag Apple players need. The finished file is copied once into a libvips buffer that the response sends. Content-Type is video/webm or video/mp4, Content-Disposition gets .webm or .mp4 (HEVC too), and ETag and the result cache treat it like any image.
  • Limits and cancellation. XI_VIDEO_ENCODE_MAX_FRAMES (1000), _MAX_DURATION (1 m) and _MAX_PIXELS (8.3 MP per frame) are 422 limit_exceeded before encoding. The request context sets the encoder’s abort flag (checked per frame, per packet and by the muxer’s I/O callbacks). TestVideoOutputNoLeak / TestEncoderNoLeak: 200 encodes leave no encoder, libvips object or tracked byte behind, and glibc’s heap does not grow.
  • Negotiation. Only an explicit format picks video; format=auto and best never do (their candidates are image formats, §6.2–6.3). XI_VIDEO_ENABLED=false turns video output off too.
  • Dialects. imgproxy f:mp4 (Pro; webm and hevc/h265 are xtandard-image extensions, also as the URL extension), imgix fm=mp4|webm, Cloudinary f_mp4|f_webm and the .mp4/.webm extension on the image asset type (fl_animated is the default), ImageKit f-mp4|f-webm and <gif>/ik-gif-video.mp4; wsrv, Cloudflare Images, thumbor, imaginary and the other dialects have no video output value (URL-DIALECTS.md).

Measured on the full and gpl builders (arm64 Docker on Apple silicon, one thread, -count=10, benchstat medians; raw output in internal/pipeline/exec/testdata/bench-video-out-*.txt and internal/video/ffmpeg/testdata/bench-frames.txt):

Benchmark (end to end through the executor) full gpl output
100-frame 320×180 GIF (2.2 MiB, 40 ms frames) → WebM (VP9) 191 ms (1.9 ms/frame) 193 ms 72.7 KiB (3.2% of the GIF)
same GIF → MP4 AV1: 305 ms (3.0 ms/frame) H.264: 238 ms (2.4 ms/frame) AV1 38.9 KiB (1.7%), H.264 46.6 KiB (2.0%)
3×3 sprite of a 720p clip, 160×90 tiles → PNG H.264 59 ms, VP9 39 ms, AV1 85 ms
10-frame 320×180 animation of a 720p clip → WebP H.264 139 ms, VP9 89 ms, AV1 133 ms
BenchmarkFrames (ten frames 0–0.9 s of a 720p clip at 320×180) one handle, forward decoding (Session.Frames) ten Thumbnail calls (open, probe, seek each)
H.264 (one keyframe, B-frames) 52.7 ms 324.5 ms (6.2×)
VP9 (keyframe every 0.48 s) 17.4 ms 44.5 ms (2.6×)
AV1 (keyframe every 0.48 s) 41.3 ms 130.9 ms (3.2×)

The GIF timings include decoding the GIF with libvips and the per-frame pipeline; the encoders run single-threaded in their realtime modes. With the ffprobe tests: the 6-frame 96×72 fixture GIF (8038 bytes, 0.6 s) becomes a 2905-byte WebM, a 2776-byte AV1 MP4 (full) or a 3095-byte H.264 MP4 and a 4530-byte HEVC MP4 (gpl), all 6 frames and 0.600 s long.

  • The gpl flavor exists for these encoders only. Its licensing and patent caveats are in docker/base/README.md and the image’s /opt/xtandard/share/licenses/LICENSE-GPL.md.

5.2 Video transformations (M5: WP-121 core, WP-122 options, WP-123 audio, WP-126 streams)

Section titled “5.2 Video transformations (M5: WP-121 core, WP-122 options, WP-123 audio, WP-126 streams)”

A video (or audio) source transformed into a video file, an audio file or a waveform picture, in a subsystem of its own (internal/media: own worker pool, limits, coalescing; ADR 0012), off by default (XI_VIDEO_TRANSFORM_ENABLED), full and gpl images only. URLs: /video/<signature>/<options>/<source>@<container> in the imgproxy dialect and /q/video?url=… in the query dialect (URL-DIALECTS §3.8); signed only by default. Image, text and timecode overlays (WP-124) use the watermark options with ow/of/tc there; the Cloudinary, ImageKit and Cloudflare video syntaxes (WP-125) map onto the same model; HLS and DASH streams (WP-126) are below.

Output Container / codecs (full) gpl Content type
mp4 (default) MP4: AV1 + AAC (vc:av1) H.264 + AAC; vc:hevc HEVC (hvc1) video/mp4
webm WebM: VP9 (or vc:av1) + Opus same video/webm
m4a MP4 audio: AAC same audio/mp4
mp3 MP3: LAME same audio/mpeg
opus, ogg Ogg: Opus same audio/ogg
flac FLAC same audio/flac
wav WAV: 16-bit PCM same audio/wav
png, webp waveform picture (FFmpeg showwavespic → RGBA → libvips, lossless) same image/png, image/webp
  • Inputs: the video containers of §1.5 (MP4/MOV, Matroska/WebM, AVI) and, for audio outputs and waveforms, audio files (MP3, AAC/ADTS, AC-3, E-AC-3, Ogg Vorbis/Opus, FLAC, WAV); decoders as in ADR 0012 §7. The shim’s demuxer and decoder whitelists name exactly these; no protocol is enabled.
  • Video: exact cut (seek to the keyframe before the start, frames dropped up to it), display matrix, rot/fl, gravity-placed crop, fit/fill/force/pad with gravity, frame rate (capped by XI_VIDEO_TRANSFORM_MAX_FPS), fade in from and out to black, 4:2:0 8-bit (yuv420p), even sizes, CRF (q mapped as for animations, or crf) with an optional bit-rate cap, keyframe interval (ki, default 2 s). MP4 has +faststart, WebM its Cues first, so players can start with a Range request.
  • Audio: decoded, cut, gain (vol), EBU R128 loudness normalisation (ln, single-pass loudnorm, target -23 LUFS / -1 dBTP / 7 LU by default, measured within ±1 LU on the test fixtures), fades, resampled (sr; AAC 8–96 kHz, MP3 8–48 kHz, Opus 8/12/16/24/48 kHz, FLAC/WAV any listed rate; else the source’s rate when the codec takes it, 48 kHz otherwise, 44.1 kHz for MP3) and mixed to mono or stereo (ach; more channels are downmixed to stereo). Bit rates default to 128 kbit/s stereo / 96 kbit/s mono (AAC, MP3), 96/64 kbit/s (Opus); FLAC and WAV are lossless. mu:1 drops the stream; a source without audio has none in a video output and is a 422 for audio-only outputs and waveforms.
  • Waveforms: width × height (default 1024 × 256) of the cut, in color on background (transparent by default; any opaque colour is flattened by libvips), mono or one band per channel (split_channels), linear amplitude scale. Golden: testdata/golden/video/waveform-tones.png (perceptual).
  • Responses: Accept-Ranges: bytes, Range/If-Range/206/416, ETag and the result cache keyed by the canonical request plus the resolved output (<container>:<video codec>:<audio codec>), X-Xi-Video-Codec (av1+aac, vp9, opus; empty for a waveform) with debug headers.
  • Limits: XI_VIDEO_TRANSFORM_MAX_* (CONFIG.md): input bytes, duration and frame pixels; output bytes, duration, frame pixels (also the waveform’s) and frame rate (422), plus the pool’s 429/503 and the transcode timeout’s 504.

The outputs of §5.1 (GIF and animations → MP4/WebM through the image pipeline) are unchanged.

transform.VideoOp.Overlays draws up to 8 overlays, in order, over the output video (after the trim, geometry and frame rate; never on audio-only outputs, 400). Each is a transform.Watermark (the image watermark model, so the same dialect options map onto both) plus a time window and fades on the output timeline:

Kind From Rendered by
Image Watermark.Source, fetched like wmu (source allowlist, SSRF-safe fetcher, XI_LIMITS_MAX_SRC_BYTES, SVG sanitised) libvips, once per transcode
Text Watermark.Text, Pango markup sanitised by security/pango (as wmt), Font, Color, TextWidth libvips/Pango, once per transcode: the same fonts, shaping and size limits as image text watermarks
Configured neither: XI_PROCESSING_WATERMARK_SOURCE (skipped when none is configured) libvips
Timecode Timecode = hms (HH:MM:SS.mmm) or seconds (12.345678), TimecodeSize px, Color, Opacity, Shadow, compass Position FFmpeg drawtext with a fixed expansion (%{pts:hms}, %{pts:flt}) and the default fontconfig Sans
  • Geometry is the image watermark’s at DPR 1 on a frame of the output’s size: Scale (fraction of the frame), Width/Height (fit box), Rotate, Shadow, Opacity × XI_PROCESSING_WATERMARK_OPACITY, Position gravity and offsets (pixels when ≥ 1, else fractions of the frame), Tile repeat or chessboard (the offsets are the spacing). The rendered pixels are clipped to the frame; overlays whose opacity is 0, or whose text is empty, are left out.
  • Time: Start/End (0 = from the first / to the last frame of the output), FadeIn from Start, FadeOut ending at End (or the output’s end when known). A window that starts after the output ends is dropped; one that ends after it runs to the end.
  • Compositing: straight-alpha RGBA over 4:2:0 (FFmpeg overlay, format=yuv420), so edges of thin text are chroma-subsampled like any other 4:2:0 content. Image and text overlays cost one RGBA buffer of at most the output frame each for the transcode; a fading one also converts its pixels on every frame of the output.
  • Errors: invalid markup 400, markup over the size limits or text that cannot fit 422, an overlay source outside the allowlist 403 or missing 404 (like wmu), an image whose FFmpeg lacks overlay/fade (or drawtext for timecodes) 422 before anything is fetched.

How overlays enter FFmpeg’s graph without any request text reaching filter syntax is in ADR 0012 §3.

A stream request (URL-DIALECTS §3.10) answers a manifest whose parts are transcoded on request (design: ADR 0012 §6).

full gpl
Manifests HLS version 7 (EXT-X-MAP, EXT-X-PLAYLIST-TYPE:VOD, EXT-X-INDEPENDENT-SEGMENTS, audio as an EXT-X-MEDIA group) and DASH static MPD (isoff-live, SegmentTemplate with $Number$, presentationTimeOffset) same; MPEG-TS streams: HLS version 3, no EXT-X-MAP, no audio group
Segments CMAF fragmented MP4: an init segment per rendition (ftyp + empty moov), media segments moof + mdat same, or (WP-131, sgf:ts, Cloudinary fl_hlsv3, HLS only) MPEG-TS .ts segments with H.264 and AAC muxed
Video AV1 Main (av01.0.LLM.08), or VP9 profile 0 (vp09.00.LL.08) with vc:vp9 H.264 High (avc1.6400LL, the level the manifest names is the encoder’s), or AV1/VP9 on request
Audio AAC-LC 48 kHz (mp4a.40.2), mono or stereo same
  • Renditions: the ladder’s rungs (XI_VIDEO_TRANSFORM_ABR_LADDER, default 1080/720/480/360/240 at 5000/2800/1400/800/400 kbit/s, or the URL’s) no larger than the source’s short side, each the source’s aspect ratio rounded to even sizes (854×480), at the stream’s constant frame rate. Each rung’s bit rate caps the encoder (x264 VBV with a 2× buffer; AV1 and VP9 constrained quality); BANDWIDTH is that cap plus a quarter, plus the audio’s.
  • Video segments are independent transcodes of [n·d, (n+1)·d) of the stream (d = XI_VIDEO_TRANSFORM_SEGMENT_DURATION): the demuxer seeks to the keyframe before n·d and reads one second past the end, so a segment decodes its window plus at most one source GOP. Its frames are those of the stream’s constant-rate grid (k / fps from the stream’s start) inside the window, the first an IDR/keyframe, closed GOPs (one per segment, or ki apart). The fragment’s decode time (tfdt) is its place on the stream’s timeline, so segments encoded on their own join without a gap or an overlap, and every rendition and the audio share that timeline. Composition offsets are never negative (every MP4 reader agrees on them), so every timestamp is moved by a fixed stream offset of at least the audio priming and the H.264 reordering delay (about 0.1 s; the DASH presentationTimeOffset removes it).
  • Audio is AAC on one packet grid for the whole stream, cut into fragments on the segment grid (a fragment starts with the first packet at or after k·d of the source): the encoder’s 1024-sample priming exists once, at the start, and no segment boundary has a gap, an overlap or a click. Since WP-131 it is encoded in chunks of whole segments (XI_VIDEO_TRANSFORM_AUDIO_CHUNK, default 30 s), each its own transcode with 16 packets of pre-roll, joined exactly where a single encode’s fragments would be (resampled sources keep the resampler’s phase); sources whose audio timestamps are not sample-exact (Matroska/WebM) and loudnorm keep one transcode for the track, bounded by XI_VIDEO_TRANSFORM_TIMEOUT (a 6.6 s track takes ~0.25 s on the benchmark machine).
  • MPEG-TS (WP-131, gpl only: H.264): each .ts segment is muxed on its own with the audio of its span copied from the audio track (PAT/PMT and an IDR frame first, the stream’s 90 kHz timestamps, the discontinuity indicator on each PID’s first packet since continuity counters restart per segment).
  • Overlays (WP-131): image and text watermarks and timecodes are drawn on every segment of every rendition, on the stream’s timeline (windows and the timecode count from the stream’s start), laid out on the largest rendition and scaled to the others, so they keep their place and size when a player switches.
  • Cost: a 2 s 360p segment takes ~0.12 s (VP9), ~0.25 s (H.264), ~0.44 s (AV1) on one thread of the benchmark machine (internal/video/ffmpeg/testdata/bench-stream.txt, -count=10). A player’s first segment waits for one such transcode (plus the probe and the fetch on the master’s first request, cached afterwards); the next XI_VIDEO_TRANSFORM_PREFETCH_SEGMENTS (default 2) are transcoded while it plays when the video pool has a free slot and their cost in free capacity, and nobody waits.
  • Caching: the probe, the init segments, the media segments and the audio track are kept in the stream cache (memory, XI_VIDEO_TRANSFORM_STREAM_CACHE_BYTES) and in the result cache; a persistent tier (disk, S3) keeps them across restarts and instances. The source is fetched for each part that is transcoded (the origin cache, XI_CACHE_ORIGIN_*, should hold it).
  • Tested: the init segment’s codec string equals the manifest’s for every codec up to 1080p60; the segments of every rendition join into the source’s frame count on the frame grid with a keyframe per segment; the audio joins without gaps into the stream’s length, in sync with the video; manifests against goldens; tampered and expired tickets; leaks over 40 stream transcodes. WP-131: joined MPEG-TS segments (frame grid, keyframes, gapless audio in sync, continuity counters, clean FFmpeg decode); an overlay window across segment boundaries on both renditions; audio chunks against the single encode (same fragment boundaries and sample count, 37 dB apart, no larger difference or click at the joins).
  1. Explicit format in URL (option, extension, fm, f_, output) wins, except auto/best.
  2. Accept header: parsed with q-values; image/avif, image/webp, image/jxl considered. */* alone does not imply modern formats. Enabled per format via XI_FORMATS_AUTO_{AVIF,WEBP,JXL} (default true, true, false) and XI_FORMATS_ENFORCE_* (use it even when the source format could be kept).
  3. Content analysis (probe: alpha, animation, pixel count; for best also the processed image’s complexity score, negotiate.Analyze, §6.3): graphic below XI_FORMATS_BEST_COMPLEXITY_THRESHOLD − 0.75, photo above it + 0.75, mixed in between.
  4. Cost guard: AVIF skipped above XI_FORMATS_AVIF_MAX_PIXELS (default 16 MP) and JXL above 32 MP.

When the chosen format depends on Accept, the response carries Vary: Accept.

6.2 Decision matrix (format=auto, the default when no format is given and auto flags are on)

Section titled “6.2 Decision matrix (format=auto, the default when no format is given and auto flags are on)”
Content Accept has AVIF else WebP else JXL (if enabled) else
Still photo, opaque AVIF lossy WebP lossy JXL lossy JPEG
Still photo, alpha AVIF lossy + alpha WebP lossy + alpha JXL PNG
Graphic / flat colours, opaque or alpha WebP lossless (AVIF lossless is larger) WebP lossless JXL lossless PNG (quantised when ≤ 256 colours)
Animated WebP animated (AVIF sequences when supported) WebP animated GIF GIF
SVG source, no raster ops, SVG acceptable (XI_FORMATS_SVG_PASSTHROUGH) SVG SVG SVG SVG
Source format not encodable (PDF, PSD, RAW, HEIC to non-Safari, BMP, TIFF) apply rows above JPEG or PNG by alpha

Resolution rules (internal/negotiate, WP-31):

  • Order: info → JSON; raw request or source format in XI_FORMATS_SKIP_PROCESSING / skip_processing → passthrough (before the explicit format, like imgproxy); explicit URL format; SVG row; content row.
  • A modern format is a candidate only when Accept names it exactly with q > 0 (image/* and */* never imply it), its auto or enforce flag is on, the row allows it and the cost guard passes. Candidates are ordered by enforce flag, then Accept q-value, then the column order above (AVIF, WebP, JXL). Enforce never overrides an explicit URL format (imgproxy’s ENFORCE_* does; we follow §6.1.1).
  • The “else” column is the first entry of XI_FORMATS_PREFERRED compatible with the row (alpha, animation, lossless for graphics); if none is, JPEG / PNG / GIF. The source format is never kept implicitly.
  • format=auto classes every still image as a photo (the probe knows no content class); only best analyses the processed image (§6.3).
  • Vary: Accept is set whenever Accept was consulted (any enabled, applicable modern format, SVG passthrough check, or strict Accept), including when the fallback won; never when the format is explicit or the source is passed through.

Save-Data: on or ECT: slow-2g|2g lowers the quality by XI_FORMATS_SAVE_DATA_QUALITY_DELTA (default −15) when the URL did not fix quality.

format=best (imgproxy f:best / format:best, query dialect f=best / format=best; the other dialects’ auto values mean §6.2 negotiation only) encodes up to three candidates and returns the smallest. imgproxy Pro’s semantics: measure the complexity to decide between lossless and lossy encodings, then try several formats and keep the smallest.

  1. Candidates (negotiate.Decide with ReasonBest): the §6.2 rows computed with the request’s Accept, enforce/auto flags, XI_FORMATS_PREFERRED and cost guards, before processing. A still image gets two lists, photo (the alpha or opaque row, lossy) and graphic (lossless: WebP lossless, JPEG XL lossless when enabled and accepted, AVIF lossless only when enforced, then PNG); GIF is never a still candidate. An animation gets the animated row only (WebP, AVIF sequences when supported, GIF). Each list is capped at 3. Vary: Accept is set because Accept shaped the lists; the ETag covers the lists (Decision.BestKey), not only the first candidate.
  2. Analysis (codec/encode EncodeBest, on the processed image, first frame of an animation): the image is rendered to memory once, then a copy of at most 256 px on the longer side is taken by nearest neighbour (no invented colours), converted to 8-bit sRGB, and scored in pure Go (negotiate.Analyze): the Shannon entropy (bits) of the Sobel gradient magnitudes of the luma (premultiplied by alpha, so alpha edges count) plus ¼·log2 of the unique RGBA colour count. It also reports the edge density (share of pixels with gradient ≥ 32/255) and whether any pixel is translucent. XI_FORMATS_BEST_COMPLEXITY_THRESHOLD = 0 skips it (every image is a photo, like imgproxy).
  3. Class → candidates (Decision.BestCandidates): graphic → the graphic list (PNG quantised when the sample has ≤ 256 colours); photo → the photo list; mixed → the two interleaved (lossy 1st, lossless 1st, lossy 2nd). Animation: the animated row, WebP/AVIF saved lossless for graphics, both variants for mixed.
  4. Encode and pick: every candidate is encoded with the request’s quality settings: its quality/format_quality/config quality, or the auto-quality search when one applies (§6.4), and max_bytes. A try meets the bar when its auto-quality target was met (or no search ran: configured quality and lossless saves count as met) and it fits max_bytes. The smallest try that meets the bar wins, else the smallest; ties go to the earlier (preferred) candidate. A candidate this build cannot encode (e.g. HEIC without XI_FORMATS_HEIC_ENCODE) is skipped.
  5. Cap: above XI_FORMATS_BEST_MAX_PIXELS output pixels (all frames; default 8 MP) nothing is analysed and the negotiated first candidate (Decision.Format) is encoded once; the response lists best_max_pixels as skipped.
  6. Reporting: X-Xi-Best (debug headers) summarises the class, score, colour count and every try, e.g. graphic c=1.83 colors=5; webp~=246*, png~=386 (~ lossless, q<n> quality, * chosen, ! missed the bar); X-Xi-Quality and Content-Type give the chosen quality and format. Result-cache hits replay both. HEAD responses report the first candidate’s type (nothing is encoded).

Calibration (TestEncodeBestCorpus, fixtures at 320–1024 px and synthetic graphics): photos score 7.2–7.9 (JPEG, PNG 8/16-bit, WebP, alpha); text 0.96, flat shapes 1.16, a bar chart 1.56, the palette/GIF/lossless-WebP graphic 1.8–2.0 (its animated GIF 2.02), the SVG logo rendered at 384 px 4.55 (graphic) and at 96 px 5.21 (mixed: its gradient sky and anti-aliased edges weigh as much as its flat areas, and lossy WebP won). A greyscale photo has at most 256 colours and tends to land in the mixed band, where both kinds are tried.

Cost against plain negotiation (one encode of the negotiated format), benchstat -count=10, BenchmarkEncodeBest, base-image builder on arm64, 640 px, configured quality, Chrome-like Accept (output in internal/codec/encode/testdata/bench-best.txt):

Image negotiate best tries output
photo 291 ms (AVIF) 386 ms (+33%; +14% in an earlier run, the spread is ±16%) AVIF, WebP, JPEG same AVIF, 13.6 KiB
bar chart 197 ms (lossy AVIF) 28 ms (−86%) WebP lossless, PNG WebP lossless, 2.41 KiB vs 2.30 KiB lossy AVIF

The analysis itself (256 px sample, entropy and colour count) costs under 1 ms and about 0.9 MiB of Go allocations (luma and colour planes, colour hash set); for photos the extra cost is the WebP and JPEG saves on top of AVIF’s. For graphics best is faster than negotiation because lossless WebP and PNG are much cheaper than AVIF. With auto-quality every candidate runs its own search (§6.4 costs per format, added). Results are cached by the result cache (keyed by the Accept fingerprint), so the cost is paid once per variant.

6.4 Auto-quality (M2, WP-56, codec/encode)

Section titled “6.4 Auto-quality (M2, WP-56, codec/encode)”

imgproxy Pro’s autoquality (aq:method:target:min_quality:max_quality:allowed_error) and XI_FORMATS_AUTOQUALITY_* (CONFIG.md) pick the quality of a lossy save by search. It applies only to quality-driven saves (JPEG, lossy WebP, AVIF, HEIC, JPEG XL, TIFF-JPEG); lossless and quality-less formats save once.

Resolution, per field: the request’s value, else the per-format config (_FORMAT_TARGET, _FORMAT_MIN, _FORMAT_MAX; defaults JPEG and WebP 40–90, AVIF 30–80), else the global config (target 0.02, 60–85, allowed error 0.001). A method that comes only from the config yields to an explicit request quality/format_quality (including the Save-Data delta); a request method always runs. When one request bound crosses a configured one, the configured one moves onto it.

Method Search Result
none — the quality of §5
size the max_bytes search (fitBytes) over min..max: max first, then binary search, ≤ 6 saves. The target is bytes, capped by max_bytes; a target < 1 falls back to max_bytes, and without either the option is skipped (autoquality_size_target). An animation is searched with all its frames. the largest quality tried whose output fits, else the smallest output with OverBudget
dssim ≤ 6 saves starting at the §5 quality clamped into min..max. Each candidate is decoded with libvips and compared with the pre-encode image (after flatten/metadata) by the in-house SSIM (internal/testutil/ssim, luma, alpha composited on white and black). From one side of the target the next quality extrapolates ln(DSSIM) (slope of the last two saves, 0.05/quality before that); once the target is bracketed it interpolates between the closest failing and passing qualities, kept away from the bracket ends. A save within allowed_error of the target stops the search. the lowest passing quality found; unmet (Met=false) at max when even max fails
ml not implemented parsed and run as none; reported in the skipped list (autoquality_ml)
  • Comparison size: both images are reduced to at most 512 px on the longer side (CompareMaxDimension, linear kernel, premultiplied) before SSIM, so one comparison costs the same for a 1 MP and a 16 MP image. Trade-off: downscaling averages away part of the artefacts, so above 512 px the measured DSSIM is lower than at full size and the search settles on a lower quality. On the 1024 px corpus of TestAutoQualityDSSIMCorpus the full-size DSSIM of the chosen output was 0.9–2.3× the compared one (median ≈ 1.4×; the largest gaps are on flat graphics and alpha edges, where the absolute values are small); at or below 512 px the two are the same measurement.

  • Animation: dssim searches on the first frame only (cheap, and frames of one animation compress alike), then saves every frame once at the chosen quality: up to 7 saves in all.

  • With max_bytes: the dssim result must still fit; when it does not, max_bytes’ search runs below the chosen quality (extra saves) and Met is false.

  • Metric scale: our DSSIM is (1 − SSIM)/2 on luma (Wang et al. 2004); imgproxy’s comes from a different, multi-scale implementation, so the same numeric target is not the same visual quality. On the corpus the default 0.02 is lenient: most photos land at the per-format minimum. Tighten _TARGET (≈ 0.005–0.01) for visibly cleaner output.

  • Reporting: Result.Quality is the chosen quality (X-Xi-Quality with debug headers), Result.Attempts counts every save, Result.AutoQuality holds the method, target, measured metric and Met.

  • Cost (benchstat -count=10, BenchmarkAutoQuality, base-image builder on arm64; benchstat output in internal/codec/encode/testdata/bench-autoquality.txt), photo, default target):

    Size / format plain dssim saves overhead
    320 px JPEG 1.6 ms 18.2 ms 3 11×
    320 px WebP 7.5 ms 27.1 ms 2 3.6×
    320 px AVIF 109 ms 524 ms 4 4.8×
    1024 px JPEG 8.4 ms 44.7 ms 2 5.3×
    1024 px WebP 69 ms 309 ms 3 4.5×
    1024 px AVIF 845 ms 1.88 s 2 2.2×

    Each comparison (decode, 512 px copy, SSIM) costs about 5 ms at 320 px and 15 ms at 1024 px, so JPEG pays mostly for the metric and WebP/AVIF mostly for the extra saves; allocations grow from 1 KiB to 3–9 MiB per request (luma planes and pixel copies). Enable it where the result cache absorbs the cost.