diff --git a/README.md b/README.md index f8e7d92..16380ee 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ A request whose query string cannot be decoded, or gives any parameter more than once, is refused with 400. - ``: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, - `avif`, `gif`, or `auto` (below) + `avif`, `jxl` (JPEG XL), `gif`, or `auto` (below) - ``: `orig` or `x` (e.g. `800x600`) - `sig` and `exp`: the signature and its expiry, needed unless the host is allowlisted (see Signature Specification) @@ -253,23 +253,28 @@ once, is refused with 400. both optional (values under Signature Specification). Both are part of what is cached, so each value of either is a separate cached image. +The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's +`Content-Type` must name its format, and the image's first bytes must match it. + With the format `auto`, pixa chooses the format for each request from its `Accept` header, in this order: -1. AVIF, when the header names `image/avif`; -2. WebP, when it names `image/webp`; -3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names +1. JPEG XL, when the header names `image/jxl`; +2. AVIF, when it names `image/avif`; +3. WebP, when it names `image/webp`; +4. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names allows it, or when there is no `Accept` header or it is empty. An entry with `q=0` refuses its format; other `q` values do not change the -order. AVIF and WebP must be named, as clients that cannot show them also send -`image/*` and `*/*`. pixa never sends a format the client refused: when the -header allows none of the three, the answer is 406, and a header that does not -parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The -signature, or the token of an encrypted URL, covers `auto` itself, so one URL -serves every client. Each format chosen is cached as a separate image, and every -answer that depends on `Accept` (the image, a 304, and the 400 and 406 above) -carries `Vary: Accept`, so a shared cache keeps the formats apart too. +order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them +also send `image/*` and `*/*`. pixa never sends a format the client refused: +when the header allows none of the four, the answer is 406, and a header that +does not parse, or has a `q` that is not a number from 0 to 1, is refused +with 400. The signature, or the token of an encrypted URL, covers `auto` itself, +so one URL serves every client. Each format chosen is cached as a separate +image, and every answer that depends on `Accept` (the image, a 304, and the 400 +and 406 above) carries `Vary: Accept`, so a shared cache keeps the formats apart +too. An image is served with `Cache-Control: public, max-age=, immutable`. When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age` @@ -350,6 +355,10 @@ turned off. - An image with an ICC profile is converted to sRGB first, since clients show an image with no profile as sRGB. Colours outside sRGB, such as the most saturated ones in a Display P3 photo, are clipped. +- The one exception is JPEG XL: a JPEG XL image carries an EXIF block that + libvips writes itself, with the image's orientation, resolution, size and + colour space and fixed defaults, but nothing from the source's EXIF, as govips + cannot ask libvips to leave it out. ### Source Hosts diff --git a/TODO.md b/TODO.md index 4e2b832..33346b4 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,19 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 2026-10-08 JPEG XL as an input and output format (part of #222): a source + whose bytes start with either JPEG XL signature, the bare codestream's `FF 0A` + or the container's, is detected as `image/jxl`, which the upstream fetch + accepts; `orig` of such a source is JPEG XL. The format `jxl` works in plain + and encrypted URLs and on the generator page, served as `image/jxl` and cached + like the other formats. `auto` chooses JPEG XL first when `Accept` names + `image/jxl`. govips sends libvips a JPEG XL distance with every save, so + libvips ignores the quality: pixa turns `q` into a distance with libvips' own + formula, keeping 100 lossy, and removes the metadata from the image before + saving, as govips cannot have libvips strip it from JPEG XL. libvips still + writes an EXIF block of its own into JPEG XL (orientation, resolution, size, + colour space, fixed defaults), with nothing from the source. libvips' default + effort, 7, is kept. JPEG XL is not yet the default output. - 2026-10-08 requests no longer wait behind eviction queries that read a whole table (closes #227): the new `cache_usage` table holds the total cache usage, kept up to date by triggers on `source_content` and `variant_content` in the diff --git a/internal/handlers/format_auto.go b/internal/handlers/format_auto.go index 485faed..bca3bde 100644 --- a/internal/handlers/format_auto.go +++ b/internal/handlers/format_auto.go @@ -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 } diff --git a/internal/handlers/jpegxl_internal_test.go b/internal/handlers/jpegxl_internal_test.go new file mode 100644 index 0000000..68bbe14 --- /dev/null +++ b/internal/handlers/jpegxl_internal_test.go @@ -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]) + } + } +} diff --git a/internal/httpfetcher/httpfetcher.go b/internal/httpfetcher/httpfetcher.go index 5640cf0..dd37489 100644 --- a/internal/httpfetcher/httpfetcher.go +++ b/internal/httpfetcher/httpfetcher.go @@ -45,6 +45,7 @@ const ( contentTypeGIF = "image/gif" contentTypeWebP = "image/webp" contentTypeAVIF = "image/avif" + contentTypeJXL = "image/jxl" contentTypeSVG = "image/svg+xml" contentTypeOctetStream = "application/octet-stream" ) @@ -156,6 +157,7 @@ func DefaultConfig() *Config { contentTypeGIF, contentTypeWebP, contentTypeAVIF, + contentTypeJXL, contentTypeSVG, }, AllowHTTP: false, diff --git a/internal/httpfetcher/jpegxl_internal_test.go b/internal/httpfetcher/jpegxl_internal_test.go new file mode 100644 index 0000000..8be65ec --- /dev/null +++ b/internal/httpfetcher/jpegxl_internal_test.go @@ -0,0 +1,20 @@ +package httpfetcher + +import "testing" + +// TestJPEGXLContentType verifies that the fetcher accepts an upstream answer +// of type image/jxl by default, and that the mock fetcher serves a .jxl file +// as image/jxl. +func TestJPEGXLContentType(t *testing.T) { + t.Parallel() + + const jxlType = "image/jxl" + + if !New(DefaultConfig()).isAllowedContentType(jxlType) { + t.Errorf("isAllowedContentType(%q) = false, want true", jxlType) + } + + if got := detectContentTypeFromPath("images/photo.jxl"); got != jxlType { + t.Errorf("detectContentTypeFromPath(photo.jxl) = %q, want %q", got, jxlType) + } +} diff --git a/internal/httpfetcher/mock.go b/internal/httpfetcher/mock.go index 5dca6d8..2a3b11b 100644 --- a/internal/httpfetcher/mock.go +++ b/internal/httpfetcher/mock.go @@ -109,6 +109,8 @@ func detectContentTypeFromPath(path string) string { return contentTypeWebP case strings.HasSuffix(path, ".avif"): return contentTypeAVIF + case strings.HasSuffix(path, ".jxl"): + return contentTypeJXL case strings.HasSuffix(path, ".svg"): return contentTypeSVG default: diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index b7c2718..30d78f3 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -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,54 @@ 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) { + // 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 +618,8 @@ func (p *ImageProcessor) formatFromString(format string) Format { return FormatWebP case string(FormatAVIF): return FormatAVIF + case string(FormatJXL): + return FormatJXL default: return FormatJPEG } diff --git a/internal/imageprocessor/jpegxl_internal_test.go b/internal/imageprocessor/jpegxl_internal_test.go index 373d35d..8722bcc 100644 --- a/internal/imageprocessor/jpegxl_internal_test.go +++ b/internal/imageprocessor/jpegxl_internal_test.go @@ -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) + } + } +} diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go index 7069f9e..47acca6 100644 --- a/internal/imgcache/imgcache.go +++ b/internal/imgcache/imgcache.go @@ -21,11 +21,12 @@ const ( FormatPNG ImageFormat = "png" FormatWebP ImageFormat = "webp" FormatAVIF ImageFormat = "avif" + FormatJXL ImageFormat = "jxl" FormatGIF ImageFormat = "gif" - // FormatAuto stands for AVIF, WebP or JPEG, chosen for each request - // from its Accept header once the URL's signature or token has been - // checked; it is never processed or cached as itself. + // FormatAuto stands for JPEG XL, AVIF, WebP or JPEG, chosen for each + // request from its Accept header once the URL's signature or token has + // been checked; it is never processed or cached as itself. FormatAuto ImageFormat = "auto" ) diff --git a/internal/imgcache/urlparser.go b/internal/imgcache/urlparser.go index b37d6ff..c50324b 100644 --- a/internal/imgcache/urlparser.go +++ b/internal/imgcache/urlparser.go @@ -275,6 +275,8 @@ func parseFormat(s string) (ImageFormat, error) { return FormatWebP, nil case "avif": return FormatAVIF, nil + case "jxl": + return FormatJXL, nil case "gif": return FormatGIF, nil case "auto": diff --git a/internal/magic/jpegxl_internal_test.go b/internal/magic/jpegxl_internal_test.go new file mode 100644 index 0000000..7592137 --- /dev/null +++ b/internal/magic/jpegxl_internal_test.go @@ -0,0 +1,58 @@ +package magic + +import "testing" + +// testMIMEJXL is the MIME type of JPEG XL. +const testMIMEJXL = "image/jxl" + +// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a +// bare codestream and that of the container, are detected as image/jxl. +func TestDetectFormatJPEGXL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + }{ + {"codestream", pad(0xFF, 0x0A)}, + {"container", pad( + 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A, + )}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := DetectFormat(tt.data) + if err != nil || got != MIMETypeJXL { + t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL) + } + + err = ValidateMagicBytes(tt.data, testMIMEJXL) + if err != nil { + t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err) + } + }) + } +} + +// TestJPEGXLMIMEType verifies that image/jxl is a supported input type and +// maps to and from the format jxl. +func TestJPEGXLMIMEType(t *testing.T) { + t.Parallel() + + if !IsSupportedMIMEType(testMIMEJXL) { + t.Errorf("IsSupportedMIMEType(%q) = false", testMIMEJXL) + } + + format, ok := MIMEToImageFormat(testMIMEJXL) + if format != FormatJXL || !ok { + t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true", + testMIMEJXL, format, ok, FormatJXL) + } + + if got := ImageFormatToMIME(FormatJXL); got != testMIMEJXL { + t.Errorf("ImageFormatToMIME(%q) = %q, want %q", FormatJXL, got, testMIMEJXL) + } +} diff --git a/internal/magic/magic.go b/internal/magic/magic.go index 999b474..922316a 100644 --- a/internal/magic/magic.go +++ b/internal/magic/magic.go @@ -26,6 +26,7 @@ const ( MIMETypeWebP = MIMEType("image/webp") MIMETypeGIF = MIMEType("image/gif") MIMETypeAVIF = MIMEType("image/avif") + MIMETypeJXL = MIMEType("image/jxl") MIMETypeSVG = MIMEType("image/svg+xml") ) @@ -40,6 +41,7 @@ const ( FormatPNG ImageFormat = "png" FormatWebP ImageFormat = "webp" FormatAVIF ImageFormat = "avif" + FormatJXL ImageFormat = "jxl" FormatGIF ImageFormat = "gif" ) @@ -62,6 +64,11 @@ var ( // AVIF uses the ftyp box with brand "avif" or "avis" // Format: size(4 bytes) + "ftyp" + brand(4 bytes) magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp" + // JPEG XL is a bare codestream or a codestream in a container + magicJXLCodestream = []byte{0xFF, 0x0A} + magicJXLContainer = []byte{ + 0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A, + } ) // WebP identifier appears at offset 8 after RIFF header. @@ -115,6 +122,12 @@ func DetectFormat(data []byte) (MIMEType, error) { } } + // Check JPEG XL (FF0A, or the 12-byte container signature) + if bytes.HasPrefix(data, magicJXLCodestream) || + bytes.HasPrefix(data, magicJXLContainer) { + return MIMETypeJXL, nil + } + // Check SVG - look for XML declaration or SVG tag if detectSVG(data) { return MIMETypeSVG, nil @@ -181,7 +194,8 @@ func normalizeMIMEType(mimeType string) string { func IsSupportedMIMEType(mimeType string) bool { normalized := normalizeMIMEType(mimeType) switch MIMEType(normalized) { - case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, MIMETypeSVG: + case MIMETypeJPEG, MIMETypePNG, MIMETypeWebP, MIMETypeGIF, MIMETypeAVIF, + MIMETypeJXL, MIMETypeSVG: return true default: return false @@ -226,6 +240,8 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) { return FormatGIF, true case MIMETypeAVIF: return FormatAVIF, true + case MIMETypeJXL: + return FormatJXL, true case MIMETypeSVG: // SVG has no corresponding output format. return "", false @@ -247,6 +263,8 @@ func ImageFormatToMIME(format ImageFormat) string { return string(MIMETypeGIF) case FormatAVIF: return string(MIMETypeAVIF) + case FormatJXL: + return string(MIMETypeJXL) case FormatOriginal: // Original format passes content through unchanged. return mimeOctetStream diff --git a/internal/server/jpegxl_internal_test.go b/internal/server/jpegxl_internal_test.go new file mode 100644 index 0000000..39c14a5 --- /dev/null +++ b/internal/server/jpegxl_internal_test.go @@ -0,0 +1,72 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/davidbyttow/govips/v2/vips" +) + +// TestImageProxyFlowJPEGXL requests the JPEG XL testdata/red.jxl, 64x48 and +// made with libvips, through pixa's router, upstream fetcher, image processor +// and disk cache: resized as PNG, resized as JPEG XL, and at its own size and +// format, which is JPEG XL. The second request for each URL is a cache hit. +func TestImageProxyFlowJPEGXL(t *testing.T) { + t.Parallel() + + source, err := os.ReadFile("testdata/red.jxl") + if err != nil { + t.Fatalf("reading the test image: %v", err) + } + + upstream := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/jxl") + _, _ = w.Write(source) + })) + t.Cleanup(upstream.Close) + + s, _, _ := startImageProxy(t, upstream) + + tests := []struct { + sizeFormat string // the . part of the image URL + contentType string + imageType vips.ImageType + width, height int + }{ + {"32x24.png", "image/png", vips.ImageTypePNG, 32, 24}, + {"32x24.jxl", "image/jxl", vips.ImageTypeJXL, 32, 24}, + {"orig.orig", "image/jxl", vips.ImageTypeJXL, 64, 48}, + } + + for _, tt := range tests { + target := "/v1/image/" + upstreamHost + "/red.jxl/" + tt.sizeFormat + + for _, wantCache := range []string{"MISS", "HIT"} { + rec := getImage(t, s, target) + gotType := rec.Header().Get("Content-Type") + gotCache := rec.Header().Get("X-Pixa-Cache") + + if gotType != tt.contentType || gotCache != wantCache { + t.Errorf("%s: %s, X-Pixa-Cache %s, want %s, %s", + target, gotType, gotCache, tt.contentType, wantCache) + } + + img, err := vips.NewImageFromBuffer(rec.Body.Bytes()) + if err != nil { + t.Fatalf("%s: loading the image: %v", target, err) + } + + if img.Format() != tt.imageType || + img.Width() != tt.width || img.Height() != tt.height { + t.Errorf("%s: %s %dx%d, want %s %dx%d", target, + vips.ImageTypes[img.Format()], img.Width(), img.Height(), + vips.ImageTypes[tt.imageType], tt.width, tt.height) + } + + img.Close() + } + } +} diff --git a/internal/server/testdata/red.jxl b/internal/server/testdata/red.jxl new file mode 100644 index 0000000..9429f50 Binary files /dev/null and b/internal/server/testdata/red.jxl differ diff --git a/internal/templates/generator.html b/internal/templates/generator.html index 8e6458e..a4def5a 100644 --- a/internal/templates/generator.html +++ b/internal/templates/generator.html @@ -95,11 +95,12 @@