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

A JPEG XL source, a bare codestream or one in its container, is
fetched, detected and decoded, 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, and auto chooses it first when Accept names
image/jxl.

govips sends libvips a JPEG XL distance with every save, and libvips
then ignores the quality, so q is turned into a distance with libvips'
formula, 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.

Model: opus-5-5
This commit is contained in:
2026-10-08 05:26:00 +00:00
parent d2944aa891
commit c465049e67
16 changed files with 491 additions and 36 deletions
@@ -1,6 +1,10 @@
package imageprocessor
import (
"bytes"
"io"
"math"
"os"
"testing"
"github.com/davidbyttow/govips/v2/vips"
@@ -50,3 +54,118 @@ func TestLibvipsSavesAndLoadsJPEGXL(t *testing.T) {
t.Errorf("loaded size = %dx%d, want 64x48", loaded.Width(), loaded.Height())
}
}
// TestImageProcessor_EncodeJPEGXL converts a JPEG to a smaller JPEG XL and
// checks the content type, and the format and size the output loads as.
func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
t.Parallel()
req := &Request{Size: Size{Width: 100, Height: 75}, Format: FormatJXL}
result, err := New(Params{}).Process(
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), req,
)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
if result.ContentType != "image/jxl" {
t.Errorf("ContentType = %q, want image/jxl", result.ContentType)
}
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
output, err := vips.NewImageFromBuffer(data)
if err != nil {
t.Fatalf("failed to load the output: %v", err)
}
defer output.Close()
if output.Format() != vips.ImageTypeJXL {
t.Errorf("output format = %s, want jxl", vips.ImageTypes[output.Format()])
}
if output.Width() != 100 || output.Height() != 75 {
t.Errorf("output size = %dx%d, want 100x75", output.Width(), output.Height())
}
}
// TestImageProcessor_JPEGXLQuality verifies that the quality reaches the JPEG
// XL encoder: the same image comes out smaller at quality 30 than at 90.
func TestImageProcessor_JPEGXLQuality(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, 400, 300)
outputBytes := make(map[int]int64)
for _, quality := range []int{30, 90} {
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input), &Request{Format: FormatJXL, Quality: quality})
if err != nil {
t.Fatalf("Process() at quality %d error = %v", quality, err)
}
_ = result.Content.Close()
outputBytes[quality] = result.ContentLength
}
if outputBytes[30] >= outputBytes[90] {
t.Errorf("%d bytes at quality 30, %d at 90, want fewer at 30",
outputBytes[30], outputBytes[90])
}
}
// TestImageProcessor_JPEGXLDropsSourceEXIF verifies that none of the source's
// EXIF reaches a JPEG XL output. libvips 8.16 and later write an EXIF block of
// their own into JPEG XL (orientation, resolution, size, colour space and
// fixed defaults), and govips cannot ask them to leave it out, so the test
// looks for the source's fields rather than for no EXIF at all.
func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
t.Parallel()
input, err := os.ReadFile("testdata/gps-exif.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
exif := processAndDecode(t, input, &Request{Format: FormatJXL}).GetExif()
for _, field := range []string{
"exif-ifd0-Make", "exif-ifd0-Model", "exif-ifd2-BodySerialNumber",
"exif-ifd2-DateTimeOriginal", "exif-ifd3-GPSLatitude",
"exif-ifd3-GPSLongitude",
} {
if value, found := exif[field]; found {
t.Errorf("output has %s: %s", field, value)
}
}
}
// TestJXLDistance verifies the JPEG XL distance for a few qualities: 90 is
// distance 1, the encoder's own default, and 100 stays lossy.
func TestJXLDistance(t *testing.T) {
t.Parallel()
tests := []struct {
quality int
want float64
}{
{quality: 100, want: 0.1},
{quality: 90, want: 1},
{quality: 30, want: 6.4},
{quality: 20, want: 9.0667},
}
for _, tt := range tests {
got := jxlDistance(tt.quality)
if math.Abs(got-tt.want) > 0.0001 {
t.Errorf("jxlDistance(%d) = %v, want %v", tt.quality, got, tt.want)
}
}
}