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, 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:
@@ -245,7 +245,7 @@ A request whose query string cannot be decoded, or gives any parameter more than
|
||||
once, is refused with 400.
|
||||
|
||||
- `<format>`: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`,
|
||||
`avif`, `gif`, or `auto` (below)
|
||||
`avif`, `jxl` (JPEG XL), `gif`, or `auto` (below)
|
||||
- `<size>`: `orig` or `<width>x<height>` (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=<seconds>, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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
|
||||
|
||||
@@ -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 <size>.<format> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -95,11 +95,12 @@
|
||||
</label>
|
||||
<select id="format" name="format">
|
||||
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
|
||||
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (AVIF, WebP or JPEG)</option>
|
||||
<option value="auto" {{if eq .FormFormat "auto"}}selected{{end}}>Auto (JPEG XL, AVIF, WebP or JPEG)</option>
|
||||
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
|
||||
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
|
||||
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
|
||||
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
|
||||
<option value="jxl" {{if eq .FormFormat "jxl"}}selected{{end}}>JPEG XL</option>
|
||||
<option value="gif" {{if eq .FormFormat "gif"}}selected{{end}}>GIF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user