1 Commits
Author SHA1 Message Date
clawbot dc3667ba62 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
2026-10-08 07:03:03 +00:00
6 changed files with 17 additions and 62 deletions
+7 -9
View File
@@ -17,8 +17,8 @@ COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase. script/test builds it alone. # Test phase. script/test builds it alone.
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too # golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS test
WORKDIR /src WORKDIR /src
@@ -40,8 +40,8 @@ RUN go test -count=1 -timeout 90s -race -cover ./... || \
# Build stage. Nothing is wanted from the two phases above: these copies # Build stage. Nothing is wanted from the two phases above: these copies
# make BuildKit build them first, so this stage runs only when lint and # make BuildKit build them first, so this stage runs only when lint and
# test passed. # test passed.
# golang:1.25.4-alpine3.22, 2026-02-25; the runtime stage uses Alpine 3.22 too # golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine3.22@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
@@ -77,11 +77,9 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
-o /pixad ./cmd/pixad -o /pixad ./cmd/pixad
# Runtime stage, and the last one: a plain `docker build .` builds this # Runtime stage, and the last one: a plain `docker build .` builds this
# stage and what it depends on, and nothing else. It must use the Alpine # stage and what it depends on, and nothing else.
# release the golang image above is based on, so that pixad runs against # alpine:3.21, 2026-02-25
# the libvips and musl it was built with. FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
# alpine:3.22, 2026-10-08
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
# Install runtime dependencies only. vips-jxl is libvips' JPEG XL # Install runtime dependencies only. vips-jxl is libvips' JPEG XL
# support, without which pixad does not start. # support, without which pixad does not start.
+4 -4
View File
@@ -88,7 +88,7 @@ or with 1 when images were still being processed after those 5 seconds or
another part of pixa failed to stop. A request not finished by then is cut off. another part of pixa failed to stop. A request not finished by then is cut off.
`docker stop` waits 10 seconds before it kills the container. `docker stop` waits 10 seconds before it kills the container.
Outside Docker, pixa needs libvips (the image has 8.16) and libheif to run, as Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL it uses libvips through CGO. pixad does not start unless libvips has its JPEG XL
support, which on Alpine is the `vips-jxl` package and which the nix and brew support, which on Alpine is the `vips-jxl` package and which the nix and brew
packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04 packages of libvips include, as do the apt ones from Debian 12 and Ubuntu 24.04
@@ -357,9 +357,9 @@ turned off.
image with no profile as sRGB. Colours outside sRGB, such as the most image with no profile as sRGB. Colours outside sRGB, such as the most
saturated ones in a Display P3 photo, are clipped. saturated ones in a Display P3 photo, are clipped.
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes - The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
an EXIF block of its own into the image, as govips cannot ask libvips to leave an EXIF block of its own into the image, with its orientation, resolution,
it out. It holds the image's size and otherwise fixed values, such as an size and colour space and fixed defaults, but nothing from the source's EXIF,
orientation of 1 and a resolution of 72 dpi, and nothing from the source. as govips cannot ask libvips to leave it out.
### Source Hosts ### Source Hosts
+2 -7
View File
@@ -40,17 +40,12 @@ P2: security: per-IP rate limiting on the image routes
libvips ignores the quality: pixa turns `q` into a distance with libvips' own 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 formula, keeping 100 lossy, and removes the metadata from the image before
saving, as govips cannot have libvips strip it from JPEG XL. libvips 8.16 and saving, as govips cannot have libvips strip it from JPEG XL. libvips 8.16 and
later still write an EXIF block of their own into JPEG XL, from the image's later still write an EXIF block of their own into JPEG XL (orientation,
size, orientation and resolution, and fixed values; the image is upright and resolution, size, colour space, fixed defaults), with nothing from the source.
is given 72 dpi before the save, so the block holds nothing from the source.
An image with an ICC profile is converted to sRGB before the JPEG XL save too, An image with an ICC profile is converted to sRGB before the JPEG XL save too,
and a CMYK image with none is converted to sRGB, as libvips cannot save CMYK and a CMYK image with none is converted to sRGB, as libvips cannot save CMYK
as JPEG XL. libvips' default effort, 7, is kept. JPEG XL is not yet the as JPEG XL. libvips' default effort, 7, is kept. JPEG XL is not yet the
default output. default output.
- 2026-10-08 pixad runs on the Alpine release it is built on (closes #229): the
runtime stage of the `Dockerfile` uses `alpine:3.22`, the release of the
`golang:1.25.4-alpine3.22` image that the test phase and the build stage use,
so all three have libvips 8.16, where the runtime image had 8.15.
- 2026-10-08 requests no longer wait behind eviction queries that read a whole - 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, 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 kept up to date by triggers on `source_content` and `variant_content` in the
+4 -18
View File
@@ -557,11 +557,6 @@ func (p *ImageProcessor) encode(
return output, nil 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 // exportJXL encodes img as JPEG XL at quality, with libvips' default effort
// and without metadata. govips sends libvips a distance, the JPEG XL // and without metadata. govips sends libvips a distance, the JPEG XL
// encoder's own measure of quality, along with the quality, and libvips then // encoder's own measure of quality, along with the quality, and libvips then
@@ -579,7 +574,9 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
// govips cannot make libvips strip metadata from JPEG XL, so it is // govips cannot make libvips strip metadata from JPEG XL, so it is
// removed from the image itself. RemoveMetadata removes EXIF, XMP and // removed from the image itself. RemoveMetadata removes EXIF, XMP and
// IPTC but keeps the ICC profile. // 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() err := img.RemoveMetadata()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -590,22 +587,11 @@ func exportJXL(img *vips.ImageRef, quality int) ([]byte, error) {
return nil, err 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 := vips.NewJxlExportParams()
params.Quality = quality params.Quality = quality
params.Distance = jxlDistance(quality) params.Distance = jxlDistance(quality)
output, _, err := toSave.ExportJxl(params) output, _, err := img.ExportJxl(params)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -102,30 +102,6 @@ func TestImageProcessor_JPEGXLDropsSourceEXIF(t *testing.T) {
} }
} }
// TestImageProcessor_JPEGXLDropsSourceResolution verifies that the source's
// resolution does not reach the EXIF block libvips 8.16 and later write into
// JPEG XL. A JPEG XL image holds its resolution in that block alone, so the
// resolution the output loads with is the block's.
func TestImageProcessor_JPEGXLDropsSourceResolution(t *testing.T) {
t.Parallel()
// dpi-300.jpg is a flat 8x8 grey image with a resolution of 300 dpi.
input, err := os.ReadFile("testdata/dpi-300.jpg")
if err != nil {
t.Fatalf("failed to read test JPEG: %v", err)
}
output := processAndDecode(t, input, &Request{Format: FormatJXL})
// libvips gives the resolution in pixels per millimetre.
xDPI := math.Round(output.ResX() * 25.4)
yDPI := math.Round(output.ResY() * 25.4)
if xDPI == 300 || yDPI == 300 {
t.Errorf("output resolution = %vx%v dpi, the source's", xDPI, yDPI)
}
}
// TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL // TestImageProcessor_JPEGXLAppliesEXIFOrientation verifies that a JPEG XL
// output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does // output is turned upright, as TestImageProcessor_AppliesEXIFOrientation does
// for PNG. // for PNG.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B