Serve and accept JPEG XL as an image format (part of #222)
check / check (push) Waiting to run
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user