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.
At a glance
Section titled “At a glance”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).
1. Input formats
Section titled “1. Input formats”| 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 |
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 codestreamFF 0Aor container boxJXL; JPEG 2000jPsignature box or raw J2K codestreamFF 4F FF 51; TIFFII*\0/MM\0*and BigTIFFII+\0/MM\0+; BMPBM+ known info-header length; ICO/CUR (reserved 0, type 1/2, sane first entry); SVG viasecurity/svg.IsSVG(first 4 KiB);%PDF-;8BPSv1/v2 (PSD/PSB); RAW signatures (CR2, CR3ftypcrx, 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): aDNGVersiontag, or a camera maker’sMaketogether 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
ftypbox:avif/avis→ AVIF;heic/heix/hevc/heim/heis/hevm/hevs→ HEIC; genericmif1/msf1/miaf→ HEIF, refined to AVIF/HEIC by the loader’sheif-compression. libvips’heifloadonly accepts a fixed list of major brands (noavis) and reads only still items, so image sequences go to the shim’sheifseqload_buffer(WP-104), registered ahead of it: it claims a file whoseftypnames a sequence brand (avis,msf1,hevs,hevm), that has a top-levelmoovbox, and in which libheif finds a visual track (pictorvide). Every other HEIF/AVIF file, a still with a burst of items included, stays withheifload. - HDR inputs (WP-67): OpenEXR
76 2F 31 01; Radiance#?RADIANCEor#?RGBEas the first line; PBM/PGM/PPMP1–P6(ASCII and binary, since WP-97) and PFMPF/Pffollowed by whitespace; the shim refusesppmloadfor 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 areunsupported. Radiance and PNM headers are read by libvips (text lines, nothing decoded). - UltraHDR: a JPEG whose header (before SOS) has an APP2
MPFsegment and gain-map metadata (APP2 ISO 21496-1urn:iso:std:iso:ts:21496:-1or APP1 XMP withhdrgm:Version). libvipsuhdrloadchecks MPF and then libultrahdr’s fullis_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 withlimit_exceeded(422) before any page renders. The rawn-pagesis read, notvips_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 ormax_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 fitMAX_SRC_PIXELS, or, for an animation,MAX_ANIMATION_FRAME_PIXELS; the totals over the frames loaded (MAX_SRC_PIXELS, orMAX_ANIMATION_PIXELSwhen the frame limit replaces it) are checked by the planner, still before decoding (§3), SVG bytes ≤MAX_SVG_BYTESand the SVG is sanitised before librsvg reads even its header. Frame counts use the header’s delay array as well asn-pages, becausevips_image_get_n_pagesreports 1 for 10 000 pages or more. - The declared upstream
Content-Typenever drives detection; a disagreement only setsImageMeta.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; DDSDDSand a 124-byte header; QOIqoif; Sun raster59 A6 6A 95; SGI01 DA, storage 0/1, 1–2 bytes per channel, 1–3 dimensions; PCX0A, version 2 or 5 (the ones ImageMagick detects), encoding 0/1, 1/2/4/8 bits per plane; XPM/* XPM */; XBM#define <name>_width; PAMP7; ICNSicnsand 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 letsmagickloadrun (xi_is_magick_input), and ImageMagick’s policy is the third fence. - Designer files (WP-107): GIMP XCF
gimp xcf+fileorvNNN+ NUL (a codec format,xcf). The designer containers are recognised by the server beforecodec.Sniffsees anything (container.Detect/Open), by content, never by extension: a ZIP (PK 03 04) whosemimetypeentry isimage/openrasterorapplication/x-krita, or which holdsDocument.archiveandQuickLook/Thumbnail.png(Procreate) ordocument.json,meta.jsonandpreviews/preview.png(Sketch); a gzip stream whose first 4 KiB inflate to an SVG document;PDN3and 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 cardSIMPLE =with the logical valueT; DICOM Part 10DICMafter 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 CDPCD_IPIat 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 headerD7 CD C6 9Aor a bareMETA_HEADER; anEMR_HEADERwithEMFat offset 40) are recognised by the server beforecodec.Sniff, like the designer containers (§1.7, §1.8). - Video containers (WP-74): an ISOBMFF
ftypbox without an image brand is MP4 (isom,mp41,mp42,avc1,av01,dash,3gp*,M4V, …) or MOV (qt); a QuickTime file withoutftypstarts with amoov,mdat,wide,free,skiporpnotatom; EBML (1A 45 DF A3) is WebM when its DocType iswebm, else MKV;RIFF….AVIis 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.
1.6 Long-tail inputs (WP-97)
Section titled “1.6 Long-tail inputs (WP-97)”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 theslimbase (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 toJxrDecApp(a delegate: refused), so the shim registers its own libvips loader class,XiForeignLoadJxrBuffer(jxrload_buffer,internal/vips/jxr.c), at init: it sniffsII BC, reads the container and codestream headers inheader(no pixel decoded, so probes and pixel limits work as for any libvips format) and decodes inloadthrough jxrlib’s memory stream over the blob’s C memory into ag_malloc’d buffer the output image owns. Thumbnails (vips_thumbnail_buffer),/infoand 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 definesNDEBUG(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 ofFuzzJXR. 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 andTestNoLeakJXRchecks that decoders and buffers are freed. - ImageMagick legacy coders (
tga,pcx,qoi,sgi,sun,xbm,xpm,pam; DDS until WP-105): ImageMagick is already inslimfor PSD; its coders are compiled in, so widening the policy costs nothing in size.policy.xmlregisters 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 (qoiloadarrives 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, butcodec.Sniffnever hands DDS to libvips any more (dropping it frompolicy.xmlis left to a base revision). XPM transparency is reported when the file names theNonecolour. - DDS (pure Go,
codec/dds, WP-105): the header (DDS_HEADER, andDDS_HEADER_DXT10when the FourCC isDX10) 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_PIXELSholds at probe. Encodings: BC1–BC5 (legacyDXT1–DXT5,ATI1/ATI2,BC4U/S,BC5U/S, and their DXGI formats), BC6HUF16/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 withDDPF_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) andFuzzBlocks(every block decoder on arbitrary blocks) fuzz it. - CIE L*a*b* TIFF (WP-105): libvips 8.18’s
tiffloadreads 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.ParseLabTIFFwalks 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 CIELabPhotometricInterpretationvalues 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_transformto 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);/inforeportscolorspace: "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;
jxlloadasks libjxl for all channels in one interleaved buffer, which libjxl refuses above four channels. The shim registersXiForeignLoadJxlCmykBuffer(jxlcmykload_buffer,internal/vips/jxlcmyk.c) with a higher priority thanjxlload; itsis_a_bufferaccepts a JPEG XL stream only when its basic info declares a black channel, so every other JPEG XL file still goes tojxlload. 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 ofcmyk_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).FuzzJXLCMYKandTestNoLeakJXLCMYKcover the loader. - High-precision JPEG (WP-106): libvips 8.18’s
jpegloadreads 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 registersXiForeignLoadJpegHpBuffer(jpeghpload_buffer,internal/vips/jpeghp.c) with a higher priority thanjpegload; itsis_a_bufferwalks 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 tojpegload(shrink-on-load, UltraHDR). It decodes with libjpeg-turbo 3’sjpeg12_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 throughvips_image_generateandvips_sequentiallikejpegload, 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/inforeportsbit_depth12), 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 likejpegload, lossless YCCK; EXIF (orientation, resolution and theexif-ifd*fields, parsed by libvips when the blob is attached), XMP, IPTC, the ICC profile (libjpeg-turbo’sjpeg_read_icc_profile) and the JFIF resolution asjpegloadreads them.fail_onbehaves as forjpegload: warnings fail the decode underwarning, a premature end of data undertruncatedand 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. Fixturesjpeg/*12*.jpg,jpeg/lossless*.jpg(testdata/gen/jpeghp.sh: cjpeg/djpeg built in the builder,gen/jpeghp/encode.cfor CMYK and lossless YCbCr).FuzzJPEG12fuzzes the loader andTestNoLeakJPEGHPchecks 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 classicis32/il32/ih32/it32run-length entries (with theirs8mk… masks) are decoded in Go. Both are fuzzed (FuzzDecode). - PBM/PGM/PPM ASCII and bitmap variants (
P1–P4) now go throughppmload_bufferlike the binary ones (the shim allowed onlyP5/P6before): 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):
gplimage only, off by default. PostScript needs a PostScript interpreter, Ghostscript, which is AGPL-3.0 and has a long record of-dSAFERbypasses (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 thegplimage only, disabled unlessXI_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 headerC5 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,%%DocumentMediafor 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): onegsprocess per page, the PostScript on stdin and a PNG on stdout read into C memory; the requestedpageonly, at72 × scaleDPI so a resized page renders at the target size (capped byXI_FORMATS_EPS_MAX_DPI, 600, and byXI_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 privateTMPDIR, a process group killed on cancel, afterXI_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
/infocolour analysis of an EPS use the preview too, never Ghostscript.
- Detection (
- 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).
1.7 Designer files (WP-107)
Section titled “1.7 Designer files (WP-107)”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/serverunwraps a container right after the fetch, before the SVG sanitiser and the probe (Server.prepare:unwrap, thensanitize), for processing,/infoand 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).rawandskip_processinganswer 422 for a container (the sniffer does not know it). The ETag and/infosizestill cover the file as fetched. - Reporting.
/infoadds"container": {"format": "ora", "mime_type": "image/openraster", "entry": "mergedimage.png", "content": "composite"}(with theformatoption) whileformat,width,heightand the rest describe the embedded image;contentiscomposite(full-size flattened image),thumbnail,preview(first page) ordocument(SVGZ). WithXI_OBSERVABILITY_DEBUG_HEADERSthe response carriesX-Xi-Container-Source: ora; entry=mergedimage.png; content=composite(kept by the result cache), and the probe span thexi.containerattribute. - Bounded ZIP reader (
codec/container/zip.go, pure Go, notarchive/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 andTestDesignerContainersrequests 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 toThumbnails/thumbnail.png(ORA) orpreview.png(KRA, 256 px), reported asthumbnail. The entry must be a PNG. - Procreate:
QuickLook/Thumbnail.png, a screen-sized thumbnail (typically 1024 px on the long side), reported asthumbnail. The full canvas would need the LZO/LZ4 tiles and the NSKeyedArchiverDocument.archive(as axaril/silicate does); not done. - Sketch:
previews/preview.png, Sketch’s preview of the first page (up to 2048 px), reported aspreview. Other pages and artboards are not rendered. - SVGZ: gunzipped up to
XI_LIMITS_MAX_SVG_BYTESand 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
PDN3is XML; its<custom><thumb png="…"/>attribute is a base64 PNG thumbnail (up to 256 px), reported asthumbnail. 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.ParseXCFHeaderreads 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 fitXI_LIMITS_MAX_SRC_PIXELS.policy.xmlallows theXCFmodule and coder (read only), and the shim letsmagickloadrun 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
slimbase aslibxibasisu.sowith the app’s C wrapper (docker/base/basisu, three exported functions, everything else hidden; about 1 MB). The transcoder’scontainer_abort()callsabort()on a failed allocation;patches/basisumakes it throwstd::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/ddsdecodes 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.
- 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
- FITS (
fits,image/fits;codec/fits, pure Go): the first HDU with an image (the primary array, or the firstIMAGEextension or fpack tile-compressedBINTABLEwhen 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 withNO_DITHER/SUBTRACTIVE_DITHER_1/_2, losslessGZIP_COMPRESSED_DATA;HCOMPRESS_1andPLIO_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’sjpeghpload, WP-106;jp2kload; all from C memory).jpeghploadscales 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 fromBitsStored, so rescale and window apply as for native frames (ct-jpegll-signed.dcm,ct-jpegll12.dcmrender exactly likect-bigendian.dcmandct-rle.dcm;jpeg-extended12.dcmwithin one level ofjpeg/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/inforeports 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/dicomTestPrivacychecks that it reaches neither a parsed header nor an error, for intact, truncated and mutated files, andinternal/appTestDICOMPrivacythat it reaches no/infoanswer (every field on), image, header or log line (debug level) of the service. The gallery’sffc.dcmis 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 inpolicy.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 withPICT:/proc/self/fd/N(xi_load_magick_coder), afterxi_is_pict/xi_is_pcdrecheck 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,FuzzMagickPICTon the C entry point).TestNoLeakLongTailcycles 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./inforeports"container": {"format": "mng", "entry": "firstframe", "content": "frame"}(JNG:JDAT,JDAT+IDATorJDAT+JDAA). Fuzzed (FuzzOpenMNG). - WMF and EMF (
gplimage only;internal/metafile,docker/base/metafile): no permissive renderer exists (format-gap §3.2), so thegplbase shipsxi-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 byXI_FORMATS_METAFILE_TIMEOUT(10 s),XI_FORMATS_METAFILE_MAX_PROCS(2, 429 beyond) andXI_LIMITS_MAX_SVG_BYTESfor 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);/inforeportsformat: "svg"with"container": {"format": "wmf", "entry": "xi-metafile2svg", "content": "converted"}.XI_FORMATS_METAFILE_ENABLEDdefaults to on when the converter is onPATH(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.
1.5 Video thumbnails (WP-74)
Section titled “1.5 Video thumbnails (WP-74)”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/ffmpegis built against FFmpeg with the build tagffmpeg(the full image,-tags ffmpeg); without it the same API answersUnsupported.XI_VIDEO_ENABLED=auto(default) decodes video when the build can;truefails startup without FFmpeg;falserefuses video everywhere. A video on the slim image, or with video off, is 422unsupportedwith 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 tomov,matroskaandavi, decoders toh264,hevc,vp8,vp9,libdav1d(AV1),mpeg4,mjpeg,pngandrawvideo. Every decoder runs single-threaded (parallelism comes from the worker semaphore) withmax_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_DURATIONandXI_VIDEO_MAX_PIXELS(also from the container headers, before any decode) are checked here, andXI_LIMITS_MAX_SRC_PIXELSapplies to the frame like to any image; all are 422limit_exceeded.XI_LIMITS_MAX_SRC_BYTESstill 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. Withoutvts,XI_VIDEO_THUMBNAIL_SECONDapplies; its defaultautois best-frame selection:XI_VIDEO_THUMBNAIL_SAMPLES(5) timestamps spread over the interior of the firstXI_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 headerX-Xi-Video-Framereports 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.DisableShrinkOnLoadturns it off, as for JPEG).TestVideoPreShrinkchecks the pre-shrunk result against a full-size decode (DSSIM ≤ 0.01); the timings are below. - Cancellation and memory. A
context.AfterFuncwatcher 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-Typefollows the result. Avtaanimation, 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’sSourceMeta.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_infocosts ~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_detectionsand/infodetect_objectswork on a single-frame video: the models see the frame the request uses (the samevts/vtkor 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);/infodetects 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 andsmfalls back to attention, as for images. - Sprites (
video_thumbnail_tile,vtt, WP-75). imgproxy Pro’s argumentsstep:columns:rows:tile_width:tile_height[:extend_tile:trim:fill:focus_x:focus_y].step> 0 samples a frame everystepseconds from 0;step< 0 spreadscolumns × rowsframes evenly over the duration (duration / cells), andstep0 turns the option off. Every frame is taken upright (display matrix applied) and fitted intotile_width × tile_height(one side 0: derived from the frame’s aspect ratio; both 0: the frame size);fillcovers the tile and crops aroundfocus_x:focus_y(default 0.5:0.5);extend_tilecentres a fitted frame on a black tile of exactly the requested size. Cells past the end of a short video stay black unlesstrimdrops them (the sprite shrinks to the columns and rows used). The tiles are joined with libvipsarrayjoin(row-major), and the sprite is then processed like any image (resize, crop, format, …).video_thumbnail_keyframes(orXI_VIDEO_THUMBNAIL_KEYFRAMES) takes the keyframe at or before each timestamp. The debug headerX-Xi-Video-Framesaystile 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]:framesframes everystepseconds (< 0:duration / frames), each sized like a sprite tile, shown fordelaymilliseconds (0: the sampling pace,step, at least 20 ms), looping forever. Frames past the end of a short video are black unlesstrimdrops them. The result is an animation for negotiation (format=autokeeps 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.vttwins 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 andcrop_objects,blur_detectionsanddraw_detectionsdo 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 againstXI_LIMITS_MAX_ANIMATION_FRAMES,XI_LIMITS_MAX_SRC_PIXELS,XI_LIMITS_MAX_ANIMATION_FRAME_PIXELSandXI_LIMITS_MAX_ANIMATION_PIXELSlike 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); Cloudinaryso_<seconds>on thevideoasset type with an image format (/video/upload/so_2,f_jpg/dog.mp4;so_autokeeps the configured selection, percentages are unsupported); ImageKit<video>/ik-thumbnail.jpgwithso-<seconds>; imgix has no equivalent. URL-DIALECTS.md lists them. /info.video_meta(vm, on by default): the container tags (strings, as imgproxy) pluscontainer,codec,width/height(coded),rotation,duration,fps,bit_rate,frames;video_streams: one entry per stream with imgproxy’stype,codec,bps,fps,duration,language,frequency,layout, pluswidth,height,rotationfor video.width/height/orientationdescribe 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.
1.2 PDF (WP-50)
Section titled “1.2 PDF (WP-50)”- Loader: PDFium through
pdfload_buffer, unblocked by name (vips.DefaultLoaders). Probe measures page 0 atXI_FORMATS_PDF_DPI(default 72: one pixel per point); that is the page’s natural size and the size/inforeports (pages_numberis 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 (
scaleon top of the probe density), enlarging included, instead of rendering at the natural size and resampling. The vector guard caps the render atXI_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 frompageon 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.pagesapplies 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 nobackground. Abackgroundcolour 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 aspdfload’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.
1.3 PSD / PSB (WP-51)
Section titled “1.3 PSD / PSB (WP-51)”- Loader: ImageMagick 7 through
magickload_buffer(thevips-magick.somodule). It is libvips-UNTRUSTED and unblocked by name; three layers keep it to Photoshop files and the legacy formats of §1.6:codec.Sniffsends 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’spolicy.xmlregisters 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=truerefuses them at probe withunsupported(422) instead. - Pages:
pageselects an ImageMagick image: 0 is the composite, 1… are the layers at their own size;/infopages_numberis 1 + the layer count.pagesnever stacks PSD layers. - Limits: the canvas counts against
XI_LIMITS_MAX_SRC_PIXELSat 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 amemfd(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 throughradload_buffer(the shim names it directly for#?RGBEheaders, 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) throughppmload_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 throughvips_image_new_from_memoryas scRGB; only the header says whether a DDS file is HDR, so the probe setsImageMeta.HDRper 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;BitDepthis 16 for half OpenEXR, 32 otherwise; the colour space is scRGB./infodescribes the pixels the pipeline sees:sample_formatfloat, 3 bands (4 with OpenEXR alpha),colorspacescrgb(b-wfor a grey PFM). Width × height count againstXI_LIMITS_MAX_SRC_PIXELSat 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 curvex(2.51x + 0.03)/(x(2.43x + 0.59) + 0.14)onx = 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.Saveon a float scRGB image): JPEG XL as float (jxlsave); AVIF as BT.2100 PQ with BT.2020 primaries, 12 bits (10 withHEIF.BitDepth10), annclxcolour box (9/16/9, full range) and acllibox 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’sxi_avif_hdr_saveover libheif and libaom; libvips 8.18’sheifsavecannot 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 listspreserve_hdramong 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_hdris 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": trueand"hdr": true): UltraHDR JPEGs (libultrahdr parses Google, Adobe, ISO and Apple metadata;uhdrload), JPEGs whose MPF secondary image carries ISO 21496-1 (APP2) or AdobehdrgmXMP metadata that libultrahdr refuses (Adobe’s own files;jpegloadthen, the gain map image loaded from its byte range), and AVIF/HEIC files with atmapderived item whose base is the primary image (the shim’sxi_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. Withpreserve_hdr(andgain_map:auto, the default) orgain_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’uhdr2scRGBis 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:sdralways keeps the SDR base. Not applied (SDR base,preserve_hdrskipped): 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_thumbnailand other pages. Apple’s pre-iOS 18 HEIC gain maps (an auxiliaryurn:com:apple:photo:2020:aux:hdrgainmapimage 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.gowrites 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.
2. Camera RAW strategy
Section titled “2. Camera RAW strategy”Implemented in WP-49 (codec/raw, internal/vips/raw.c, pipeline/plan rawOnLoad, pipeline/exec/raw.go).
-
Probe, header only.
codec/raw.Probe→vips.ProbeRaw→ LibRawlibraw_open_buffer, thenlibraw_adjust_sizes_info_only(withuser_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
NEFCompression13 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_FORMATflag (GoPro VC-5 without the GPR SDK, JPEG XL raw data without the DNG SDK). Of the base images onlygplhas both SDKs (WP-103; owner decision 2026-09-28: the Adobe DNG SDK licence ships ingplonly), so inslimandfull(and with a system LibRaw) these files are preview-only: the embedded preview is the image, or 422camera 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; ingplthe files are decodable and take the demosaic path. - A camera LibRaw does not know (
libraw_open_bufferrefuses it, typically a body newer than the release, such as the Sony A7 V): its previews and maker are still listed whencodec/raw.Sniffrecognises a RAW container.
Such a file is preview-only: its embedded preview is the image (
/inforeports the preview’s size), and without a usable preview it is refused with 422camera 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 waylibraw_unpack_thumbpatches it); H.265 (CR3), lossless, arithmetic and 12-bit previews are skipped. The RAW’s orientation is LibRaw’sflipmapped to EXIF (inverse of LibRaw’s"50132467"table): it applies to the demosaic and to the preview alike, both stored as the sensor reads. - Nikon High Efficiency (HE/HE*, intoPIX TicoRAW; makernote
-
Decision, in the planner (
plan.LoadStep.Preview/HalfSize): the preview is used when, as displayed, it is at leastXI_FORMATS_RAW_PREVIEW_MIN_RATIO(default 1.0) × the size the image is scaled to on both axes (DPR included), or whenenforce_thumbnailis set, and always for a preview-only file (a request larger than its preview gets the preview’s size, as for any source withoutenlarge). The preview is then JPEG-shrunk on load (2/4/8) down to that size. -
Otherwise the demosaic: LibRaw
unpack+dcraw_process+dcraw_make_mem_imagein the shim, with dcrawload’s parameters (8 bits, camera white balance, sRGB, no rotation), athalf_sizewhen the image is scaled to half its size or less (and shrink-on-load is not disabled), full size otherwise. libvips 8.18.6’sdcrawloadhas nohalf_sizeoption, so the shim drives LibRaw itself anddcrawloadstays 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 (unsupportedorcorrupt: 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 againstXI_PROCESSING_MEMORY_BUDGET. -
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./inforeports them underexif. GPS is not exported. -
Limits (WP-102): RAW files are capped by
XI_LIMITS_MAX_SRC_BYTES. A RAW is judged byXI_LIMITS_MAX_SRC_PIXELSon 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 againstXI_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.
- 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 (
-
Observability:
xi_raw_decodes_total{path}counts decodes by path (preview,demosaic,demosaic-half); withXI_OBSERVABILITY_DEBUG_HEADERSthe response carriesX-Xi-Raw-Path(previewfor 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 thegplflavour 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 withdng/jxl-landscape.dngand Adobe’s own JPEG XL samples: linear integer, linear float and Bayer). LibRaw hands the SDK only those two compressions: the shim setsuse_dngsdkto 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), andFuzzRawLoadkeeps fuzzing the shim’s RAW entry points. - Leaf MOS (WP-108): Leaf backs (Aptus 22 and others) write no
Makein IFD0 (the maker is only in the XMP), socodec/raw.Snifftook them for a plain TIFF andtiffloadrefused them (422 “tile size out of range”). The sniffer now also accepts IFD0 carrying Leaf’s privateLeafDatatag (0x8606, aPKTSblock) 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_PIXELSraised.
3. Animation handling
Section titled “3. Animation handling”- Loaded as a vertical strip with
page-height. Limits (WP-98, imgproxy’sCheckDimensionson 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 replacesXI_LIMITS_MAX_SRC_PIXELS, so an 800×600 GIF of 176 frames (84.5 MP in all) is accepted, andXI_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 at0(imgproxy’s default) the animation counts all its frames againstXI_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 stackedpages) againstXI_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_animationis false. Otherwise framepage(default 0) is used. A video’svtaanimation (§1.5) is an animated source too. - AVIF and HEIF image sequences (WP-104) are animated sources: the shim’s
heifseqload_bufferdecodes a track’s frames with libheif’s sequence API (frames are coded against earlier ones, so frames beforepageare decoded and dropped), as 8-bit sRGB or sRGBA (a 10/12-bit sequence is reduced to 8 bits), into the vertical strip withpage-height. Frame count and delays come from the track’s sample tables (stsz/stz2andstts, 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);loopis 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.18heifsavehas no sequence output), so an animated output is WebP, GIF, JPEG XL or a video. A still output (ordisable_animation) decodes only the frames up topage. The frame limits apply as for GIF: the header’s frame count before decoding,XI_LIMITS_MAX_ANIMATION_FRAMESon the frames loaded. - Frame selection (WP-62; every dialect maps its page/frame parameters onto
page/pages, URL-DIALECTS): when the animation is kept,pageis its first frame andpagesits frame count (imgproxy ignores both then; imgixframe=relies on it to pick one frame). When it is not kept (still output,disable_animation, a frame limit of 1),pageselects one frame andpagesstacks that many frames vertically into one image (imgproxy Pro), exactly like the pages of a multi-page still.pagepast the last frame clamps to it;pages:-1runs to the end. - Frame delays and loop count preserved; per-frame geometric ops use
arrayjoinover frames. f:mp4|webm|hevc/fm=mp4|webm/f_mp4/f-mp4/ik-gif-video.mp4/.mp4extension on an animated source → GIF→video (WP-76,fullandgplimages; codec per 5.1).
4. Metadata policy
Section titled “4. Metadata policy”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.
5. Output formats
Section titled “5. Output formats”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).
5.1 Video output (WP-76)
Section titled “5.1 Video output (WP-76)”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_nameforlibx264,libx265,libaom-av1andlibvpx-vp9, andav_guess_formatfor themp4andwebmmuxers (xi_ff_encodersin the C shim ofinternal/video/ffmpeg, build tagffmpeg, ADR 0010; never by parsingffmpeg -encoders) and builds the table above from what it finds. The same binary therefore servesfullandgpl; nothing reads the image label. Slim builds (noffmpegbuild tag) report every video output asUnsupported. mp4means “the best MP4 this image can write”:libx264when present, otherwiselibaom-av1. No config switch picks AV1 over H.264 in a gpl image in M3; if one is needed later it isXI_FORMATS_MP4_CODEC=auto|h264|av1(defaultauto).hevc/h265is a separate, explicit output (dialect values that name HEVC); it is never chosen byformat=autoorbest, and it isUnsupported(the usual 4xx for an unavailable format) withoutlibx265.webmis 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 headerX-Xi-Video-Codecnames the codec of each video response. (There is no/infocapability 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
vtaanimation, and the frames kept bypage/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-rangeyuv420p(yuva420pfor 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-Widthreports 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 itsloopattribute). - 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 withXI_VIDEO_ENCODE_H264_PRESET/_HEVC_PRESET(defaultveryfast, H.264 High profile). Constant quality: the request’s quality (orformat_quality) maps onto the codec’s CRF aroundXI_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_bytesand auto-quality do not search video (reported as skipped). - Muxing. WebM or MP4 written through a custom
AVIOContextinto a growable C buffer (no temporary file); MP4 gets+faststart(themoovbox in front), which FFmpeg’s second pass does by reading the buffer back through the context’sio_open(no other file or URL can be opened), and HEVC thehvc1tag Apple players need. The finished file is copied once into a libvips buffer that the response sends.Content-Typeisvideo/webmorvideo/mp4,Content-Dispositiongets.webmor.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 422limit_exceededbefore 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=autoandbestnever do (their candidates are image formats, §6.2–6.3).XI_VIDEO_ENABLED=falseturns video output off too. - Dialects. imgproxy
f:mp4(Pro;webmandhevc/h265are xtandard-image extensions, also as the URL extension), imgixfm=mp4|webm, Cloudinaryf_mp4|f_webmand the.mp4/.webmextension on theimageasset type (fl_animatedis the default), ImageKitf-mp4|f-webmand<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
gplflavor exists for these encoders only. Its licensing and patent caveats are indocker/base/README.mdand 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 byXI_VIDEO_TRANSFORM_MAX_FPS), fade in from and out to black, 4:2:0 8-bit (yuv420p), even sizes, CRF (qmapped as for animations, orcrf) with an optional bit-rate cap, keyframe interval (ki, default 2 s). MP4 has+faststart, WebM its Cues first, so players can start with aRangerequest. - Audio: decoded, cut, gain (
vol), EBU R128 loudness normalisation (ln, single-passloudnorm, 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:1drops 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, incoloronbackground(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.
Overlays (WP-124)
Section titled “Overlays (WP-124)”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,Positiongravity and offsets (pixels when ≥ 1, else fractions of the frame),Tilerepeat 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),FadeInfromStart,FadeOutending atEnd(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 lacksoverlay/fade(ordrawtextfor timecodes) 422 before anything is fetched.
How overlays enter FFmpeg’s graph without any request text reaching filter syntax is in ADR 0012 §3.
HLS and DASH streams (WP-126)
Section titled “HLS and DASH streams (WP-126)”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);BANDWIDTHis 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 beforen·dand 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 / fpsfrom the stream’s start) inside the window, the first an IDR/keyframe, closed GOPs (one per segment, orkiapart). 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 DASHpresentationTimeOffsetremoves 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·dof 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) andloudnormkeep one transcode for the track, bounded byXI_VIDEO_TRANSFORM_TIMEOUT(a 6.6 s track takes ~0.25 s on the benchmark machine). - MPEG-TS (WP-131,
gplonly: H.264): each.tssegment 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 nextXI_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).
6. Format negotiation and best-format
Section titled “6. Format negotiation and best-format”6.1 Inputs to the decision
Section titled “6.1 Inputs to the decision”- Explicit format in URL (option, extension,
fm,f_,output) wins, exceptauto/best. - Accept header: parsed with q-values;
image/avif,image/webp,image/jxlconsidered.*/*alone does not imply modern formats. Enabled per format viaXI_FORMATS_AUTO_{AVIF,WEBP,JXL}(default true, true, false) andXI_FORMATS_ENFORCE_*(use it even when the source format could be kept). - Content analysis (probe: alpha, animation, pixel count; for
bestalso the processed image’s complexity score,negotiate.Analyze, §6.3): graphic belowXI_FORMATS_BEST_COMPLEXITY_THRESHOLD− 0.75, photo above it + 0.75, mixed in between. - 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
Acceptnames 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’sENFORCE_*does; we follow §6.1.1). - The “else” column is the first entry of
XI_FORMATS_PREFERREDcompatible with the row (alpha, animation, lossless for graphics); if none is, JPEG / PNG / GIF. The source format is never kept implicitly. format=autoclasses every still image as a photo (the probe knows no content class); onlybestanalyses the processed image (§6.3).Vary: Acceptis 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.
6.3 best (M2, WP-57)
Section titled “6.3 best (M2, WP-57)”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.
- Candidates (
negotiate.DecidewithReasonBest): the §6.2 rows computed with the request’s Accept, enforce/auto flags,XI_FORMATS_PREFERREDand 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: Acceptis set because Accept shaped the lists; the ETag covers the lists (Decision.BestKey), not only the first candidate. - Analysis (
codec/encodeEncodeBest, 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). - 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. - 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), andmax_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 fitsmax_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 withoutXI_FORMATS_HEIC_ENCODE) is skipped. - Cap: above
XI_FORMATS_BEST_MAX_PIXELSoutput pixels (all frames; default 8 MP) nothing is analysed and the negotiated first candidate (Decision.Format) is encoded once; the response listsbest_max_pixelsas skipped. - 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-QualityandContent-Typegive 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 ofTestAutoQualityDSSIMCorpusthe 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:
dssimsearches 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: thedssimresult must still fit; when it does not,max_bytes’ search runs below the chosen quality (extra saves) andMetis 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.Qualityis the chosen quality (X-Xi-Qualitywith debug headers),Result.Attemptscounts every save,Result.AutoQualityholds the method, target, measured metric andMet. -
Cost (benchstat
-count=10,BenchmarkAutoQuality, base-image builder on arm64; benchstat output ininternal/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.