Serve and accept JPEG XL as an image format (part of #222)
check / check (push) Waiting to run

A JPEG XL source is accepted, and orig of one is JPEG XL. The format
jxl works in plain and encrypted URLs and on the generator page,
served as image/jxl; auto chooses it first when Accept names
image/jxl.

govips sends libvips a JPEG XL distance, which overrides the quality,
so q becomes a distance, keeping 100 lossy. Metadata is removed from
the image before the JPEG XL save, as govips cannot have libvips strip
it, and the image is given 72 dpi so that the EXIF block libvips 8.16
adds holds nothing from the source. The sRGB conversion moved ahead of
the format switch, and a CMYK image with no ICC profile is converted
to sRGB, as libvips cannot save CMYK as JPEG XL.

Model: opus-5-5
This commit was merged in pull request #230.
This commit is contained in:
2026-10-08 11:01:12 +02:00
parent 99e4aa3bb2
commit 8597253ffd
18 changed files with 645 additions and 36 deletions
+97 -12
View File
@@ -65,6 +65,7 @@ const (
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatJXL Format = "jxl"
FormatGIF Format = "gif"
)
@@ -305,6 +306,7 @@ const (
mimeGIF = "image/gif"
mimeWebP = "image/webp"
mimeAVIF = "image/avif"
mimeJXL = "image/jxl"
)
// SupportedInputFormats returns MIME types this processor can read.
@@ -315,6 +317,7 @@ func (p *ImageProcessor) SupportedInputFormats() []string {
mimeGIF,
mimeWebP,
mimeAVIF,
mimeJXL,
}
}
@@ -326,6 +329,7 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
FormatGIF,
FormatWebP,
FormatAVIF,
FormatJXL,
}
}
@@ -342,6 +346,8 @@ func FormatToMIME(format Format) string {
return mimeGIF
case FormatAVIF:
return mimeAVIF
case FormatJXL:
return mimeJXL
case FormatOriginal:
return "application/octet-stream"
default:
@@ -411,9 +417,11 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return string(FormatAVIF)
case vips.ImageTypeJXL:
return string(FormatJXL)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K, vips.ImageTypeJXL:
vips.ImageTypeJP2K:
return "unknown"
default:
return "unknown"
@@ -485,6 +493,17 @@ func (p *ImageProcessor) encode(
quality = defaultQuality
}
// Stripping drops the ICC profile as well, and clients show an image
// with no profile as sRGB, so convert to sRGB first. "srgb" names
// libvips' built-in profile; govips' own sRGB path variable is set on
// first use but read without a lock, so concurrent requests race on it.
if img.HasICCProfile() {
err := img.TransformICCProfileWithFallback("srgb", "srgb")
if err != nil {
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
var params vips.ExportParams
switch format {
@@ -516,6 +535,9 @@ func (p *ImageProcessor) encode(
Quality: quality,
}
case FormatJXL:
return exportJXL(img, quality)
case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
@@ -523,17 +545,6 @@ func (p *ImageProcessor) encode(
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
}
// Stripping drops the ICC profile as well, and clients show an image
// with no profile as sRGB, so convert to sRGB first. "srgb" names
// libvips' built-in profile; govips' own sRGB path variable is set on
// first use but read without a lock, so concurrent requests race on it.
if img.HasICCProfile() {
err := img.TransformICCProfileWithFallback("srgb", "srgb")
if err != nil {
return nil, fmt.Errorf("failed to convert to sRGB: %w", err)
}
}
// Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for
// GIF, which carries none of them.
params.StripMetadata = true
@@ -546,6 +557,78 @@ func (p *ImageProcessor) encode(
return output, nil
}
// jxlResolution is the resolution every JPEG XL image is saved with, in
// pixels per millimetre as libvips counts it: 72 dpi, what libvips gives a
// JPEG that names none.
const jxlResolution = 72 / 25.4
// exportJXL encodes img as JPEG XL at quality, with libvips' default effort
// and without metadata. govips sends libvips a distance, the JPEG XL
// encoder's own measure of quality, along with the quality, and libvips then
// uses the distance alone, so the quality is also given as a distance.
func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
// libvips converts a CMYK image to sRGB before it saves WebP, AVIF or
// PNG, but cannot save one as JPEG XL. encode has already converted
// any image with an ICC profile to sRGB, so this is CMYK with none.
if img.Interpretation() == vips.InterpretationCMYK {
err := img.ToColorSpace(vips.InterpretationSRGB)
if err != nil {
return nil, fmt.Errorf("failed to convert CMYK to sRGB: %w", err)
}
}
// govips cannot make libvips strip metadata from JPEG XL, so it is
// removed from the image itself. RemoveMetadata removes EXIF, XMP and
// IPTC but keeps the ICC profile.
err := img.RemoveMetadata()
if err != nil {
return nil, err
}
err = img.RemoveICCProfile()
if err != nil {
return nil, err
}
// libvips 8.16 and later still write an EXIF block of their own, from
// the image's size, orientation and resolution, and fixed values. The
// image is upright, so its orientation is 1, but its resolution is still
// the source's.
toSave, err := img.CopyChangingResolution(jxlResolution, jxlResolution)
if err != nil {
return nil, err
}
defer toSave.Close()
params := vips.NewJxlExportParams()
params.Quality = quality
params.Distance = jxlDistance(quality)
output, _, err := toSave.ExportJxl(params)
if err != nil {
return nil, err
}
return output, nil
}
// jxlDistance turns a quality from 1 to 100 into the JPEG XL encoder's
// distance, with the formula libvips and libjxl use for their own quality
// setting, except that 100 stays lossy (distance 0.1) where libjxl makes it
// lossless.
//
//nolint:mnd // the constants of that formula
func jxlDistance(quality int) float64 {
q := float64(quality)
if quality >= 30 {
return 0.1 + (100-q)*0.09
}
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
}
// formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) Format {
switch format {
@@ -559,6 +642,8 @@ func (p *ImageProcessor) formatFromString(format string) Format {
return FormatWebP
case string(FormatAVIF):
return FormatAVIF
case string(FormatJXL):
return FormatJXL
default:
return FormatJPEG
}