From 2758dc92f1776b993657e6407fae0cf42975c6c9 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Wed, 17 Jun 2026 22:16:11 -0400 Subject: [PATCH] feat(image): add JPEG XL (jxl) image filter backed by libvips Adds a libvips-only JPEG XL encoder filter, mirroring the existing AVIF filter. Encodes via libvips' libjxl delegate (inferred from the .jxl extension) with q/lossless/effort parameters under the jxl_ prefix. Registered under both "jxl" and "jpegxl" keys in VipsImageFilterApiImpl, so it is only reachable when the libvips engine is active (IMAGE_API_USE_LIBVIPS=true and native libvips available, via ImageEngine.resolve()). The legacy engine has no knowledge of these keys, so the filter is simply unavailable when libvips is disabled. No VipsLegacyFilters fallback is wired since JPEG XL has no pure-JVM equivalent. Adds a parity test validating the JPEG XL output signature that skips cleanly when the host libvips lacks the libjxl delegate. Fixes #36223 Co-Authored-By: Claude Opus 4.8 --- .../image/vips/VipsImageFilterApiImpl.java | 2 + .../image/vips/VipsJpegXlImageFilter.java | 47 +++++++++++++++++++ .../image/vips/VipsParityTest.java | 31 ++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 dotCMS/src/main/java/com/dotmarketing/image/vips/VipsJpegXlImageFilter.java diff --git a/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsImageFilterApiImpl.java b/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsImageFilterApiImpl.java index 538314e129de..1fef2d8e3cad 100644 --- a/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsImageFilterApiImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsImageFilterApiImpl.java @@ -69,6 +69,8 @@ public class VipsImageFilterApiImpl implements ImageFilterAPI { // libvips-only capabilities with no legacy equivalent .put("smartcrop", VipsSmartCropImageFilter.class) .put("avif", VipsAvifImageFilter.class) + .put("jxl", VipsJpegXlImageFilter.class) + .put("jpegxl", VipsJpegXlImageFilter.class) .build(); public VipsImageFilterApiImpl() { diff --git a/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsJpegXlImageFilter.java b/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsJpegXlImageFilter.java new file mode 100644 index 000000000000..85c64d94eae3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/image/vips/VipsJpegXlImageFilter.java @@ -0,0 +1,47 @@ +package com.dotmarketing.image.vips; + +import app.photofox.vipsffm.VImage; +import app.photofox.vipsffm.VipsOption; +import java.io.File; +import java.util.Map; + +/** + * JPEG XL encoder — a modern format the legacy engine cannot produce. Encodes via libvips' libjxl + * delegate, typically smaller than JPEG at equal quality with support for lossless re-compression of + * existing JPEGs. + * + *

URL contract: {@code filter=jxl&jxl_q=75}. {@code q} is 0-100 quality; {@code lossless=true} + * switches to mathematically lossless encoding; {@code effort} (1-9) trades encode speed for size. + * Requires the host libvips to be built with libjxl; if absent the request fails (no legacy + * fallback — JPEG XL is libvips-only).

+ */ +public class VipsJpegXlImageFilter extends VipsImageFilter { + + @Override + public String[] getAcceptedParameters() { + return new String[] { + "q (int) 0-100 quality", + "lossless (present) lossless encode", + "effort (int) 1-9 encoder effort/speed tradeoff" + }; + } + + @Override + protected void transform(final File in, final File out, final Map parameters) { + final int quality = intParam(parameters, "q", 75); + final boolean lossless = parameters.get(getPrefix() + "lossless") != null; + final int effort = intParam(parameters, "effort", 7); + VipsManager.run(arena -> { + final VImage src = VipsManager.load(arena, in); + src.writeToFile(out.getAbsolutePath(), + VipsOption.Int("Q", quality), + VipsOption.Boolean("lossless", lossless), + VipsOption.Int("effort", effort)); + }); + } + + @Override + public File getResultsFile(final File file, final Map parameters) { + return getResultsFile(file, parameters, "jxl"); + } +} diff --git a/dotCMS/src/test/java/com/dotmarketing/image/vips/VipsParityTest.java b/dotCMS/src/test/java/com/dotmarketing/image/vips/VipsParityTest.java index b526527e0c9d..95b8c2f9c479 100644 --- a/dotCMS/src/test/java/com/dotmarketing/image/vips/VipsParityTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/image/vips/VipsParityTest.java @@ -337,6 +337,37 @@ public void avif_encoder_produces_valid_avif() throws Exception { assertTrue("expected avif brand, got '" + brand + "'", brand.startsWith("avi")); } + /** JPEG XL encode requires the host libvips to be built with the libjxl delegate. */ + private boolean jxlEncodeSupported() { + try { + final File probeIn = image("test.png"); + final File probeOut = tempOut("jxl"); + new VipsJpegXlImageFilter().transform(probeIn, probeOut, params("jxl_q", "75")); + return probeOut.exists() && probeOut.length() > 50; + } catch (Exception e) { + return false; + } + } + + @Test + public void jxl_encoder_produces_valid_jxl() throws Exception { + Assume.assumeTrue("host libvips lacks a JPEG XL encoder (libjxl)", jxlEncodeSupported()); + final File in = image("test.png"); + final File out = tempOut("jxl"); + new VipsJpegXlImageFilter().transform(in, out, params("jxl_q", "75")); + assertTrue("jxl written", out.exists() && out.length() > 50); + // JPEG XL signatures: the raw codestream starts with FF 0A; the ISO-BMFF container starts + // with a 'JXL ' box (00 00 00 0C 4A 58 4C 20). + final byte[] head = new byte[12]; + try (java.io.InputStream is = new java.io.FileInputStream(out)) { + assertEquals(12, is.read(head)); + } + final boolean rawCodestream = (head[0] & 0xFF) == 0xFF && (head[1] & 0xFF) == 0x0A; + final boolean container = (head[4] & 0xFF) == 0x4A && (head[5] & 0xFF) == 0x58 + && (head[6] & 0xFF) == 0x4C && (head[7] & 0xFF) == 0x20; + assertTrue("expected a JPEG XL signature", rawCodestream || container); + } + @Test public void smartcrop_produces_exact_box_from_salient_region() throws Exception { final File in = image("test.jpg");