diff --git a/CHANGELOG.md b/CHANGELOG.md index 112f423d..2d201cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] ### Add +- Add JPEL XL (JXL) support. +- Add [IMGPROXY_ENABLE_JXL_DETECTION](https://docs.imgproxy.net/latest/configuration/options#IMGPROXY_ENABLE_JXL_DETECTION), [IMGPROXY_ENFORCE_JXL](https://docs.imgproxy.net/latest/configuration/options#IMGPROXY_ENFORCE_JXL), and [IMGPROXY_JXL_EFFORT](https://docs.imgproxy.net/latest/configuration/options#IMGPROXY_JXL_EFFORT) configs. - (pro) Add [objects_position](https://docs.imgproxy.net/latest/usage/processing#objects-position) processing and info options. - (pro) Add [IMGPROXY_OBJECT_DETECTION_SWAP_RB](https://docs.imgproxy.net/latest/configuration/options#IMGPROXY_OBJECT_DETECTION_SWAP_RB) config. diff --git a/config/config.go b/config/config.go index 1103de1b..418f0dea 100644 --- a/config/config.go +++ b/config/config.go @@ -54,6 +54,7 @@ var ( PngQuantize bool PngQuantizationColors int AvifSpeed int + JxlEffort int Quality int FormatQuality map[imagetype.Type]int StripMetadata bool @@ -68,6 +69,8 @@ var ( EnforceWebp bool EnableAvifDetection bool EnforceAvif bool + EnableJxlDetection bool + EnforceJxl bool EnableClientHints bool PreferredFormats []imagetype.Type @@ -253,8 +256,9 @@ func Reset() { PngQuantize = false PngQuantizationColors = 256 AvifSpeed = 9 + JxlEffort = 4 Quality = 80 - FormatQuality = map[imagetype.Type]int{imagetype.AVIF: 65} + FormatQuality = map[imagetype.Type]int{imagetype.AVIF: 65, imagetype.JXL: 77} StripMetadata = true KeepCopyright = true StripColorProfile = true @@ -267,6 +271,8 @@ func Reset() { EnforceWebp = false EnableAvifDetection = false EnforceAvif = false + EnableJxlDetection = false + EnforceJxl = false EnableClientHints = false PreferredFormats = []imagetype.Type{ @@ -475,6 +481,7 @@ func Configure() error { configurators.Bool(&PngQuantize, "IMGPROXY_PNG_QUANTIZE") configurators.Int(&PngQuantizationColors, "IMGPROXY_PNG_QUANTIZATION_COLORS") configurators.Int(&AvifSpeed, "IMGPROXY_AVIF_SPEED") + configurators.Int(&JxlEffort, "IMGPROXY_JXL_EFFORT") configurators.Int(&Quality, "IMGPROXY_QUALITY") if err := configurators.ImageTypesQuality(FormatQuality, "IMGPROXY_FORMAT_QUALITY"); err != nil { return err @@ -491,6 +498,8 @@ func Configure() error { configurators.Bool(&EnforceWebp, "IMGPROXY_ENFORCE_WEBP") configurators.Bool(&EnableAvifDetection, "IMGPROXY_ENABLE_AVIF_DETECTION") configurators.Bool(&EnforceAvif, "IMGPROXY_ENFORCE_AVIF") + configurators.Bool(&EnableJxlDetection, "IMGPROXY_ENABLE_JXL_DETECTION") + configurators.Bool(&EnforceJxl, "IMGPROXY_ENFORCE_JXL") configurators.Bool(&EnableClientHints, "IMGPROXY_ENABLE_CLIENT_HINTS") configurators.URLPath(&HealthCheckPath, "IMGPROXY_HEALTH_CHECK_PATH") @@ -706,11 +715,17 @@ func Configure() error { } if AvifSpeed < 0 { - return fmt.Errorf("Avif speed should be greater than 0, now - %d\n", AvifSpeed) + return fmt.Errorf("Avif speed should be greater than or equal to 0, now - %d\n", AvifSpeed) } else if AvifSpeed > 9 { return fmt.Errorf("Avif speed can't be greater than 9, now - %d\n", AvifSpeed) } + if JxlEffort < 1 { + return fmt.Errorf("JXL effort should be greater than 0, now - %d\n", JxlEffort) + } else if JxlEffort > 9 { + return fmt.Errorf("JXL effort can't be greater than 9, now - %d\n", JxlEffort) + } + if Quality <= 0 { return fmt.Errorf("Quality should be greater than 0, now - %d\n", Quality) } else if Quality > 100 { diff --git a/imagemeta/jxl.go b/imagemeta/jxl.go new file mode 100644 index 00000000..7ba69820 --- /dev/null +++ b/imagemeta/jxl.go @@ -0,0 +1,257 @@ +package imagemeta + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/imgproxy/imgproxy/v3/imagetype" +) + +const ( + jxlCodestreamHeaderMinSize = 4 + jxlCodestreamHeaderMaxSize = 11 +) + +var jxlCodestreamMarker = []byte{0xff, 0x0a} +var jxlISOBMFFMarker = []byte{0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A} + +var jxlSizeSizes = []uint64{9, 13, 18, 30} + +var jxlRatios = [][]uint64{ + {1, 1}, + {12, 10}, + {4, 3}, + {3, 2}, + {16, 9}, + {5, 4}, + {2, 1}, +} + +type jxlBitReader struct { + buf uint64 + bufLen uint64 +} + +func NewJxlBitReader(data []byte) *jxlBitReader { + return &jxlBitReader{ + buf: binary.LittleEndian.Uint64(data), + bufLen: uint64(len(data) * 8), + } +} + +func (br *jxlBitReader) Read(n uint64) (uint64, error) { + if n > br.bufLen { + return 0, io.EOF + } + + mask := uint64(1<>= n + br.bufLen -= n + + return res, nil +} + +type JxlFormatError string + +func (e JxlFormatError) Error() string { return "invalid JPEG XL format: " + string(e) } + +func jxlReadJxlc(r io.Reader, boxDataSize uint64) ([]byte, error) { + if boxDataSize < jxlCodestreamHeaderMinSize { + return nil, JxlFormatError("invalid codestream box") + } + + toRead := boxDataSize + if toRead > jxlCodestreamHeaderMaxSize { + toRead = jxlCodestreamHeaderMaxSize + } + + return heifReadN(r, toRead) +} + +func jxlReadJxlp(r io.Reader, boxDataSize uint64, codestream []byte) ([]byte, bool, error) { + if boxDataSize < 4 { + return nil, false, JxlFormatError("invalid jxlp box") + } + + jxlpInd, err := heifReadN(r, 4) + if err != nil { + return nil, false, err + } + + last := jxlpInd[0] == 0x80 + + readLeft := jxlCodestreamHeaderMaxSize - len(codestream) + if readLeft <= 0 { + return codestream, last, nil + } + + toRead := boxDataSize - 4 + if uint64(readLeft) < toRead { + toRead = uint64(readLeft) + } + + data, err := heifReadN(r, toRead) + if err != nil { + return nil, last, err + } + + if codestream == nil { + codestream = make([]byte, 0, jxlCodestreamHeaderMaxSize) + } + + return append(codestream, data...), last, nil +} + +// We can reuse HEIF functions to read ISO BMFF boxes +func jxlFindCodestream(r io.Reader) ([]byte, error) { + var ( + codestream []byte + last bool + ) + + for { + boxType, boxDataSize, err := heifReadBoxHeader(r) + if err != nil { + return nil, err + } + + switch boxType { + // jxlc box contins full codestream. + // We can just read and return its header + case "jxlc": + codestream, err = jxlReadJxlc(r, boxDataSize) + return codestream, err + + // jxlp partial codestream. + // We should read its data until we read jxlCodestreamHeaderSize bytes + case "jxlp": + codestream, last, err = jxlReadJxlp(r, boxDataSize, codestream) + if err != nil { + return nil, err + } + + csLen := len(codestream) + if csLen >= jxlCodestreamHeaderMaxSize || (last && csLen >= jxlCodestreamHeaderMinSize) { + return codestream, nil + } + + if last { + return nil, JxlFormatError("invalid codestream box") + } + + // Skip other boxes + default: + if err := heifDiscardN(r, boxDataSize); err != nil { + return nil, err + } + } + } +} + +func jxlParseSize(br *jxlBitReader, small bool) (uint64, error) { + if small { + size, err := br.Read(5) + return (size + 1) * 8, err + } else { + selector, err := br.Read(2) + if err != nil { + return 0, err + } + + sizeSize := jxlSizeSizes[selector] + size, err := br.Read(sizeSize) + + return size + 1, err + } +} + +func jxlDecodeCodestreamHeader(buf []byte) (width, height uint64, err error) { + if len(buf) < jxlCodestreamHeaderMinSize { + return 0, 0, JxlFormatError("invalid codestream header") + } + + if !bytes.Equal(buf[0:2], jxlCodestreamMarker) { + return 0, 0, JxlFormatError("missing codestream marker") + } + + br := NewJxlBitReader(buf[2:]) + + smallBit, sbErr := br.Read(1) + if sbErr != nil { + return 0, 0, sbErr + } + + small := smallBit == 1 + + height, err = jxlParseSize(br, small) + if err != nil { + return 0, 0, err + } + + ratioIdx, riErr := br.Read(3) + if riErr != nil { + return 0, 0, riErr + } + + if ratioIdx == 0 { + width, err = jxlParseSize(br, small) + } else { + ratio := jxlRatios[ratioIdx-1] + width = height * ratio[0] / ratio[1] + } + + return +} + +func DecodeJxlMeta(r io.Reader) (Meta, error) { + var ( + tmp [12]byte + codestream []byte + width, height uint64 + err error + ) + + if _, err = io.ReadFull(r, tmp[:2]); err != nil { + return nil, err + } + + if bytes.Equal(tmp[0:2], jxlCodestreamMarker) { + if _, err = io.ReadFull(r, tmp[2:]); err != nil { + return nil, err + } + + codestream = tmp[:] + } else { + if _, err = io.ReadFull(r, tmp[2:12]); err != nil { + return nil, err + } + + if !bytes.Equal(tmp[0:12], jxlISOBMFFMarker) { + return nil, JxlFormatError("invalid header") + } + + codestream, err = jxlFindCodestream(r) + if err != nil { + return nil, err + } + } + + width, height, err = jxlDecodeCodestreamHeader(codestream) + if err != nil { + return nil, err + } + + return &meta{ + format: imagetype.JXL, + width: int(width), + height: int(height), + }, nil +} + +func init() { + RegisterFormat(string(jxlCodestreamMarker), DecodeJxlMeta) + RegisterFormat(string(jxlISOBMFFMarker), DecodeJxlMeta) +} diff --git a/imagetype/imagetype.go b/imagetype/imagetype.go index ba8576e9..f1e5df34 100644 --- a/imagetype/imagetype.go +++ b/imagetype/imagetype.go @@ -12,6 +12,7 @@ type Type int const ( Unknown Type = iota JPEG + JXL PNG WEBP GIF @@ -32,6 +33,7 @@ var ( Types = map[string]Type{ "jpeg": JPEG, "jpg": JPEG, + "jxl": JXL, "png": PNG, "webp": WEBP, "gif": GIF, @@ -45,6 +47,7 @@ var ( mimes = map[Type]string{ JPEG: "image/jpeg", + JXL: "image/jxl", PNG: "image/png", WEBP: "image/webp", GIF: "image/gif", @@ -58,6 +61,7 @@ var ( extensions = map[Type]string{ JPEG: ".jpg", + JXL: ".jxl", PNG: ".png", WEBP: ".webp", GIF: ".gif", @@ -149,6 +153,7 @@ func (it Type) SupportsAnimation() bool { func (it Type) SupportsColourProfile() bool { return it == JPEG || + it == JXL || it == PNG || it == WEBP || it == HEIC || diff --git a/options/processing_options.go b/options/processing_options.go index f387bd3c..c0bd20c0 100644 --- a/options/processing_options.go +++ b/options/processing_options.go @@ -108,6 +108,8 @@ type ProcessingOptions struct { EnforceWebP bool PreferAvif bool EnforceAvif bool + PreferJxl bool + EnforceJxl bool Filename string ReturnAttachment bool @@ -1088,6 +1090,11 @@ func defaultProcessingOptions(headers http.Header) (*ProcessingOptions, error) { po.EnforceAvif = config.EnforceAvif } + if strings.Contains(headerAccept, "image/jxl") { + po.PreferJxl = config.EnableJxlDetection || config.EnforceJxl + po.EnforceJxl = config.EnforceJxl + } + if config.EnableClientHints { headerDPR := headers.Get("Sec-CH-DPR") if len(headerDPR) == 0 { diff --git a/processing/processing.go b/processing/processing.go index 35c52129..d88866eb 100644 --- a/processing/processing.go +++ b/processing/processing.go @@ -281,6 +281,8 @@ func ProcessImage(ctx context.Context, imgdata *imagedata.ImageData, po *options switch { case po.Format == imagetype.Unknown: switch { + case po.PreferJxl && !animated: + po.Format = imagetype.JXL case po.PreferAvif && !animated: po.Format = imagetype.AVIF case po.PreferWebP: @@ -290,6 +292,8 @@ func ProcessImage(ctx context.Context, imgdata *imagedata.ImageData, po *options default: po.Format = findBestFormat(imgdata.Type, animated, expectAlpha) } + case po.EnforceJxl && !animated: + po.Format = imagetype.JXL case po.EnforceAvif && !animated: po.Format = imagetype.AVIF case po.EnforceWebP: diff --git a/vips/vips.c b/vips/vips.c index 3d7a27cb..417f9977 100644 --- a/vips/vips.c +++ b/vips/vips.c @@ -63,6 +63,12 @@ vips_jpegload_go(void *buf, size_t len, int shrink, VipsImage **out) return vips_jpegload_buffer(buf, len, out, "access", VIPS_ACCESS_SEQUENTIAL, NULL); } +int +vips_jxlload_go(void *buf, size_t len, VipsImage **out) +{ + return vips_jxlload_buffer(buf, len, out, "access", VIPS_ACCESS_SEQUENTIAL, NULL); +} + int vips_pngload_go(void *buf, size_t len, VipsImage **out, int unlimited) { @@ -913,6 +919,16 @@ vips_jpegsave_go(VipsImage *in, void **buf, size_t *len, int quality, int interl NULL); } +int +vips_jxlsave_go(VipsImage *in, void **buf, size_t *len, int quality, int effort) +{ + return vips_jxlsave_buffer( + in, buf, len, + "Q", quality, + "effort", effort, + NULL); +} + int vips_pngsave_go(VipsImage *in, void **buf, size_t *len, int interlace, int quantize, int colors) { diff --git a/vips/vips.go b/vips/vips.go index fa8fb1da..0415e20e 100644 --- a/vips/vips.go +++ b/vips/vips.go @@ -49,6 +49,7 @@ var vipsConf struct { PngQuantize C.int PngQuantizationColors C.int AvifSpeed C.int + JxlEffort C.int PngUnlimited C.int SvgUnlimited C.int } @@ -98,6 +99,7 @@ func Init() error { vipsConf.PngQuantize = gbool(config.PngQuantize) vipsConf.PngQuantizationColors = C.int(config.PngQuantizationColors) vipsConf.AvifSpeed = C.int(config.AvifSpeed) + vipsConf.JxlEffort = C.int(config.JxlEffort) vipsConf.PngUnlimited = gbool(config.PngUnlimited) vipsConf.SvgUnlimited = gbool(config.SvgUnlimited) @@ -231,6 +233,8 @@ func SupportsLoad(it imagetype.Type) bool { switch it { case imagetype.JPEG: sup = hasOperation("jpegload_buffer") + case imagetype.JXL: + sup = hasOperation("jxlload_buffer") case imagetype.PNG: sup = hasOperation("pngload_buffer") case imagetype.WEBP: @@ -262,6 +266,8 @@ func SupportsSave(it imagetype.Type) bool { switch it { case imagetype.JPEG: sup = hasOperation("jpegsave_buffer") + case imagetype.JXL: + sup = hasOperation("jxlsave_buffer") case imagetype.PNG, imagetype.ICO: sup = hasOperation("pngsave_buffer") case imagetype.WEBP: @@ -330,6 +336,8 @@ func (img *Image) Load(imgdata *imagedata.ImageData, shrink int, scale float64, switch imgdata.Type { case imagetype.JPEG: err = C.vips_jpegload_go(data, dataSize, C.int(shrink), &tmp) + case imagetype.JXL: + err = C.vips_jxlload_go(data, dataSize, &tmp) case imagetype.PNG: err = C.vips_pngload_go(data, dataSize, &tmp, vipsConf.PngUnlimited) case imagetype.WEBP: @@ -401,6 +409,8 @@ func (img *Image) Save(imgtype imagetype.Type, quality int) (*imagedata.ImageDat switch imgtype { case imagetype.JPEG: err = C.vips_jpegsave_go(img.VipsImage, &ptr, &imgsize, C.int(quality), vipsConf.JpegProgressive) + case imagetype.JXL: + err = C.vips_jxlsave_go(img.VipsImage, &ptr, &imgsize, C.int(quality), vipsConf.JxlEffort) case imagetype.PNG: err = C.vips_pngsave_go(img.VipsImage, &ptr, &imgsize, vipsConf.PngInterlaced, vipsConf.PngQuantize, vipsConf.PngQuantizationColors) case imagetype.WEBP: diff --git a/vips/vips.h b/vips/vips.h index 8e27f9be..5c7aa920 100644 --- a/vips/vips.h +++ b/vips/vips.h @@ -16,6 +16,7 @@ int gif_resolution_limit(); int vips_health(); int vips_jpegload_go(void *buf, size_t len, int shrink, VipsImage **out); +int vips_jxlload_go(void *buf, size_t len, VipsImage **out); int vips_pngload_go(void *buf, size_t len, VipsImage **out, int unlimited); int vips_webpload_go(void *buf, size_t len, double scale, int pages, VipsImage **out); int vips_gifload_go(void *buf, size_t len, int pages, VipsImage **out); @@ -81,6 +82,7 @@ int vips_strip(VipsImage *in, VipsImage **out, int keep_exif_copyright); int vips_strip_all(VipsImage *in, VipsImage **out); int vips_jpegsave_go(VipsImage *in, void **buf, size_t *len, int quality, int interlace); +int vips_jxlsave_go(VipsImage *in, void **buf, size_t *len, int quality, int effort); int vips_pngsave_go(VipsImage *in, void **buf, size_t *len, int interlace, int quantize, int colors); int vips_webpsave_go(VipsImage *in, void **buf, size_t *len, int quality);