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");