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 is turned into a distance, keeping 100 lossy. govips cannot have
libvips strip metadata from JPEG XL, so it is removed from the image
before saving; libvips 8.16 and later still add an EXIF block of their
own, with nothing from the source. The conversion to sRGB moved ahead
of the format switch, and a CMYK image with no ICC profile is
converted to sRGB too, as libvips cannot save CMYK as JPEG XL.

Model: opus-5-5
This commit is contained in:
2026-10-08 07:03:03 +00:00
parent d2944aa891
commit dc3667ba62
17 changed files with 606 additions and 36 deletions
+83 -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,64 @@ func (p *ImageProcessor) encode(
return output, nil
}
// 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. libvips 8.16 and later still write an
// EXIF block of their own: orientation, resolution, size, colour space
// and fixed defaults.
err := img.RemoveMetadata()
if err != nil {
return nil, err
}
err = img.RemoveICCProfile()
if err != nil {
return nil, err
}
params := vips.NewJxlExportParams()
params.Quality = quality
params.Distance = jxlDistance(quality)
output, _, err := img.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 +628,8 @@ func (p *ImageProcessor) formatFromString(format string) Format {
return FormatWebP
case string(FormatAVIF):
return FormatAVIF
case string(FormatJXL):
return FormatJXL
default:
return FormatJPEG
}