Serve and accept JPEG XL as an image format (part of #222)
check / check (push) Canceled after 0s

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
+11 -7
View File
@@ -14,8 +14,8 @@ import (
// Errors for an Accept header that an auto URL cannot be served for.
var (
errInvalidAccept = errors.New("invalid Accept header")
errNotAcceptable = errors.New(
"not acceptable: auto serves image/avif, image/webp or image/jpeg")
errNotAcceptable = errors.New("not acceptable: auto serves " +
"image/jxl, image/avif, image/webp or image/jpeg")
)
// chooseAutoFormat replaces the format auto in req with the format
@@ -51,11 +51,11 @@ func (s *Handlers) chooseAutoFormat(
}
// formatForAccept returns the format an auto URL is served in for the Accept
// header accept: AVIF when it names image/avif, else WebP when it names
// image/webp, else JPEG when its most specific entry of image/jpeg, image/*
// and */* allows it, or when it names nothing. A q of 0 refuses a format.
// AVIF and WebP must be named, as clients that cannot show them send image/*
// and */* too.
// header accept: JPEG XL when it names image/jxl, else AVIF when it names
// image/avif, else WebP when it names image/webp, else JPEG when its most
// specific entry of image/jpeg, image/* and */* allows it, or when it names
// nothing. A q of 0 refuses a format. JPEG XL, AVIF and WebP must be named, as
// clients that cannot show them send image/* and */* too.
func formatForAccept(accept string) (imgcache.ImageFormat, error) {
qualities, err := parseAccept(accept)
if err != nil {
@@ -66,6 +66,10 @@ func formatForAccept(accept string) (imgcache.ImageFormat, error) {
return imgcache.FormatJPEG, nil
}
if qualities["image/jxl"] > 0 {
return imgcache.FormatJXL, nil
}
if qualities["image/avif"] > 0 {
return imgcache.FormatAVIF, nil
}
+73
View File
@@ -0,0 +1,73 @@
package handlers
import (
"log/slog"
"net/http"
"testing"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imgcache"
)
// jxlType is the content type of JPEG XL.
const jxlType = "image/jxl"
// TestFormatForAccept_JPEGXL verifies that the format auto chooses JPEG XL
// when Accept names image/jxl, ahead of AVIF whatever their q, and AVIF when
// Accept refuses JPEG XL with q=0.
func TestFormatForAccept_JPEGXL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
}{
{"JPEG XL-capable browser",
"image/jxl,image/avif,image/webp,image/*,*/*;q=0.8", imgcache.FormatJXL},
{"JPEG XL only", jxlType, imgcache.FormatJXL},
{"JPEG XL with a lower q than AVIF", "image/avif,image/jxl;q=0.5",
imgcache.FormatJXL},
{"q=0 on JPEG XL", "image/jxl;q=0,image/avif,*/*", imgcache.FormatAVIF},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
if got != tt.want || err != nil {
t.Errorf("formatForAccept(%q) = %q, %v, want %q",
tt.accept, got, err, tt.want)
}
})
}
}
// TestFormatAuto_JPEGXL requests an auto URL on each image route with an
// Accept header that names image/jxl, and checks that the answer is a JPEG XL
// image.
func TestFormatAuto_JPEGXL(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedURL, encryptedURL := autoPhotoURLs(t, h)
for _, target := range []string{signedURL, encryptedURL} {
rec := requestImage(t, srv, http.MethodGet, target,
"image/jxl,image/avif,image/webp,*/*;q=0.8")
gotType := rec.Header().Get("Content-Type")
if rec.Code != http.StatusOK || gotType != jxlType {
t.Errorf("%s: %d %s, want 200 %s; body %s",
target, rec.Code, gotType, jxlType, rec.Body)
continue
}
if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL {
t.Errorf("%s: body is %s, want jxl", target, vips.ImageTypes[got])
}
}
}