From 86e86d057db98738b55b76d5c765f8c5b924c38c Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 27 May 2026 12:53:00 +0200 Subject: [PATCH] add possibility of using libvips and imagemagick for image processing --- .../configs/MediaConfiguration.java | 6 + .../core/configuration/ConfigManagement.java | 13 +- .../configs/MediaConfiguration.java | 22 ++- .../com/condation/cms/media/MediaManager.java | 53 +++--- .../cms/media/processor/ImageIOProcessor.java | 152 ++++++++++++++++++ .../media/processor/ImageMagickProcessor.java | 134 +++++++++++++++ .../cms/media/processor/ImageProcessor.java | 56 +++++++ .../processor/ImageProcessorFactory.java | 77 +++++++++ .../cms/media/processor/LibVipsProcessor.java | 144 +++++++++++++++++ test-server/hosts/demo/config/media.toml | 1 + 10 files changed, 623 insertions(+), 35 deletions(-) create mode 100644 cms-media/src/main/java/com/condation/cms/media/processor/ImageIOProcessor.java create mode 100644 cms-media/src/main/java/com/condation/cms/media/processor/ImageMagickProcessor.java create mode 100644 cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessor.java create mode 100644 cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessorFactory.java create mode 100644 cms-media/src/main/java/com/condation/cms/media/processor/LibVipsProcessor.java diff --git a/cms-api/src/main/java/com/condation/cms/api/configuration/configs/MediaConfiguration.java b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/MediaConfiguration.java index a3b3a4d14..a3bde27ed 100644 --- a/cms-api/src/main/java/com/condation/cms/api/configuration/configs/MediaConfiguration.java +++ b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/MediaConfiguration.java @@ -37,4 +37,10 @@ public class MediaConfiguration implements Config { private final List formats; + /** + * Image processor to use. Possible values: libvips, imagemagick, imageio. + */ + private String processor = "imageio"; + + private String binPath; } diff --git a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java index f069258f2..cc527f981 100644 --- a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java +++ b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java @@ -72,12 +72,17 @@ public void initConfiguration (Configuration configuration) { .get()).getTaxonomies() ) ); - configuration.add( - com.condation.cms.api.configuration.configs.MediaConfiguration.class, - new com.condation.cms.api.configuration.configs.MediaConfiguration( + var mediaConfig = new com.condation.cms.api.configuration.configs.MediaConfiguration( ((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") .get()).getMediaFormats() - ) + ); + mediaConfig.setProcessor(((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") + .get()).getProcessor()); + mediaConfig.setBinPath(((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") + .get()).getValueOrDefault("bin_path", "")); + configuration.add( + com.condation.cms.api.configuration.configs.MediaConfiguration.class, + mediaConfig ); } diff --git a/cms-core/src/main/java/com/condation/cms/core/configuration/configs/MediaConfiguration.java b/cms-core/src/main/java/com/condation/cms/core/configuration/configs/MediaConfiguration.java index 429816082..022439e7d 100644 --- a/cms-core/src/main/java/com/condation/cms/core/configuration/configs/MediaConfiguration.java +++ b/cms-core/src/main/java/com/condation/cms/core/configuration/configs/MediaConfiguration.java @@ -28,13 +28,9 @@ import com.condation.cms.api.eventbus.events.ConfigurationReloadEvent; import com.condation.cms.api.media.MediaFormat; import com.condation.cms.api.media.MediaUtils; -import com.google.common.hash.HashCode; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Set; -import java.util.SortedSet; import java.util.TreeSet; import java.util.UUID; import lombok.Data; @@ -82,6 +78,24 @@ public void reload() { }); } + public String getProcessor() { + var processor = getSources().stream() + .filter(ConfigSource::exists) + .map(config -> config.getString("processor")) + .filter(Objects::nonNull) + .findFirst(); + return processor.orElse("imageio"); + } + + public String getValueOrDefault(String name, String defaultValue) { + var valueGetter = getSources().stream() + .filter(ConfigSource::exists) + .map(config -> config.getString(name)) + .filter(Objects::nonNull) + .findFirst(); + return valueGetter.orElse(defaultValue); + } + public List getFormats() { var sorted = new TreeSet((o1, o2) -> o1.name.compareTo(o2.name)); sorted.addAll(getList("formats", Format.class)); diff --git a/cms-media/src/main/java/com/condation/cms/media/MediaManager.java b/cms-media/src/main/java/com/condation/cms/media/MediaManager.java index bf8abc062..cd97620e6 100644 --- a/cms-media/src/main/java/com/condation/cms/media/MediaManager.java +++ b/cms-media/src/main/java/com/condation/cms/media/MediaManager.java @@ -31,7 +31,7 @@ import com.condation.cms.api.theme.Theme; import com.condation.cms.api.utils.FileUtils; import com.condation.cms.api.utils.PathUtil; -import java.io.File; +import com.condation.cms.media.processor.ImageProcessorFactory; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -41,7 +41,6 @@ import java.util.Map; import java.util.Optional; import lombok.extern.slf4j.Slf4j; -import net.coobird.thumbnailator.Thumbnails; import org.yaml.snakeyaml.Yaml; /** @@ -59,6 +58,8 @@ public abstract class MediaManager implements EventListener mediaFormats; protected Path tempDirectory; + private ImageProcessorFactory processorFactory; + protected MediaManager(List assetPath, Path tempFolder, Theme theme, Configuration configuration) { this.assetBase = assetPath; this.tempFolder = tempFolder; @@ -134,43 +135,41 @@ public Optional getScaledContent(final String mediaPath, final MediaForm return tempContent; } - Thumbnails.Builder scaleBuilder = Thumbnails - .of(resolve.get().toFile()) - .size(mediaFormat.width(), mediaFormat.height()); - - if (mediaFormat.cropped()) { - setupImageBuilder(scaleBuilder, resolve.get(), mediaFormat); - } - - byte[] data = Scale.toFormat(scaleBuilder.asBufferedImage(), mediaFormat); + CropCalculator.CropArea crop = mediaFormat.cropped() + ? resolveCrop(resolve.get(), mediaFormat) + : null; - writeTempContent(mediaPath, mediaFormat, data); + Path tempFile = writeTempPlaceholder(mediaPath, mediaFormat); + getProcessorFactory().get().process(resolve.get(), tempFile, mediaFormat, crop); - return Optional.of(data); + return Optional.of(Files.readAllBytes(tempFile)); } return Optional.empty(); } - private void setupImageBuilder(Thumbnails.Builder builder, Path media, MediaFormat format) { + private CropCalculator.CropArea resolveCrop(Path media, MediaFormat format) { var metaFileName = media.getFileName().toString() + ".meta.yaml"; var metaFile = media.getParent().resolve(metaFileName); var size = ImageSize.getSize(media); - double focal_x = 0.5; - double focal_y = 0.5; + double focalX = 0.5; + double focalY = 0.5; if (Files.exists(metaFile)) { try { final Meta meta = new Yaml().loadAs(Files.readString(metaFile, StandardCharsets.UTF_8), Meta.class); - focal_x = meta.getFocalPoint_x(); - focal_y = meta.getFocalPoint_y(); + focalX = meta.getFocalPoint_x(); + focalY = meta.getFocalPoint_y(); } catch (IOException ex) { log.warn("Could not read meta file: {}", metaFile, ex); } } - CropCalculator.CropArea crop = CropCalculator.calculateCrop( - size.width(), size.height(), - focal_x, focal_y, - format.width(), format.height()); - builder.sourceRegion(crop.toRectangle()); + return CropCalculator.calculateCrop(size.width(), size.height(), focalX, focalY, format.width(), format.height()); + } + + private ImageProcessorFactory getProcessorFactory() { + if (processorFactory == null) { + processorFactory = new ImageProcessorFactory(configuration.get(MediaConfiguration.class)); + } + return processorFactory; } public String getTempFilename(final String mediaPath, final MediaFormat mediaFormat) { @@ -180,13 +179,10 @@ public String getTempFilename(final String mediaPath, final MediaFormat mediaFor return tempFilename; } - private Path writeTempContent(final String mediaPath, final MediaFormat mediaFormat, byte[] content) throws IOException { + private Path writeTempPlaceholder(final String mediaPath, final MediaFormat mediaFormat) throws IOException { var tempFilename = getTempFilename(mediaPath, mediaFormat); - var tempFile = getTempDirectory().resolve(tempFilename); Files.deleteIfExists(tempFile); - Files.write(tempFile, content); - return tempFile; } @@ -221,6 +217,9 @@ public void consum(ConfigurationReloadEvent event) { return; } this.mediaFormats = null; + if (processorFactory != null) { + processorFactory.reset(); + } getMediaFormats(); } } diff --git a/cms-media/src/main/java/com/condation/cms/media/processor/ImageIOProcessor.java b/cms-media/src/main/java/com/condation/cms/media/processor/ImageIOProcessor.java new file mode 100644 index 000000000..f560d3477 --- /dev/null +++ b/cms-media/src/main/java/com/condation/cms/media/processor/ImageIOProcessor.java @@ -0,0 +1,152 @@ +package com.condation.cms.media.processor; + +/*- + * #%L + * CMS Media + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.media.MediaFormat; +import com.condation.cms.api.media.MediaUtils; +import com.condation.cms.media.CropCalculator.CropArea; +import com.luciad.imageio.webp.WebPWriteParam; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.plugins.jpeg.JPEGImageWriteParam; +import javax.imageio.stream.MemoryCacheImageOutputStream; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; + +/** + * Fallback processor using Thumbnailator + Java ImageIO. + * Always available — no external tools required. + */ +@Slf4j +public class ImageIOProcessor implements ImageProcessor { + + @Override + public String name() { + return "imageio"; + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public void process(Path source, Path target, MediaFormat format, CropArea crop) throws IOException { + Thumbnails.Builder builder = Thumbnails + .of(source.toFile()) + .size(format.width(), format.height()); + + if (crop != null) { + builder.sourceRegion(crop.toRectangle()); + } + + byte[] data = toFormat(builder.asBufferedImage(), format); + Files.write(target, data); + } + + private static byte[] toFormat(BufferedImage imageBuff, MediaFormat mediaFormat) throws IOException { + if (mediaFormat.format() == null) { + throw new IllegalArgumentException("unknown media format"); + } + return switch (mediaFormat.format()) { + case JPEG -> toJPG(imageBuff, !mediaFormat.compression()); + case WEBP -> toWEBP(imageBuff, !mediaFormat.compression()); + case PNG -> toPNG(imageBuff, !mediaFormat.compression()); + }; + } + + private static byte[] toPNG(BufferedImage imageBuff, boolean uncompressed) throws IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + if (uncompressed) { + ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next(); + try { + ImageWriteParam writeParam = writer.getDefaultWriteParam(); + if (writeParam.canWriteCompressed()) { + writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + writeParam.setCompressionQuality(1f); + } + try (MemoryCacheImageOutputStream out = new MemoryCacheImageOutputStream(buffer)) { + writer.setOutput(out); + writer.write(null, new IIOImage(imageBuff, null, null), writeParam); + } + return buffer.toByteArray(); + } finally { + writer.dispose(); + } + } else { + ImageIO.write(imageBuff, "png", buffer); + return buffer.toByteArray(); + } + } + } + + private static byte[] toJPG(BufferedImage imageBuff, boolean uncompressed) throws IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + if (uncompressed) { + JPEGImageWriteParam jpegParams = new JPEGImageWriteParam(null); + jpegParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + jpegParams.setCompressionQuality(1f); + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next(); + try (MemoryCacheImageOutputStream out = new MemoryCacheImageOutputStream(buffer)) { + writer.setOutput(out); + writer.write(null, new IIOImage(imageBuff, null, null), jpegParams); + return buffer.toByteArray(); + } finally { + writer.dispose(); + } + } else { + ImageIO.write(imageBuff, "jpg", buffer); + return buffer.toByteArray(); + } + } + } + + private static byte[] toWEBP(BufferedImage imageBuff, boolean uncompressed) throws IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + if (uncompressed) { + WebPWriteParam writeParam = new WebPWriteParam(Locale.getDefault()); + writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSLESS_COMPRESSION]); + ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next(); + try (MemoryCacheImageOutputStream out = new MemoryCacheImageOutputStream(buffer)) { + writer.setOutput(out); + writer.write(null, new IIOImage(imageBuff, null, null), writeParam); + return buffer.toByteArray(); + } finally { + writer.dispose(); + } + } else { + ImageIO.write(imageBuff, "webp", buffer); + return buffer.toByteArray(); + } + } + } +} diff --git a/cms-media/src/main/java/com/condation/cms/media/processor/ImageMagickProcessor.java b/cms-media/src/main/java/com/condation/cms/media/processor/ImageMagickProcessor.java new file mode 100644 index 000000000..c0235722b --- /dev/null +++ b/cms-media/src/main/java/com/condation/cms/media/processor/ImageMagickProcessor.java @@ -0,0 +1,134 @@ +package com.condation.cms.media.processor; + +/*- + * #%L + * CMS Media + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.media.MediaFormat; +import com.condation.cms.media.CropCalculator.CropArea; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Image processor using ImageMagick CLI. + * Uses the configured bin path, or auto-detects {@code magick} (IM 7+) / {@code convert} (IM 6). + */ +@Slf4j +public class ImageMagickProcessor implements ImageProcessor { + + private Boolean available = null; + private String executable; + + public ImageMagickProcessor() { + this(null); + } + + public ImageMagickProcessor(String binPath) { + this.executable = binPath; + } + + @Override + public String name() { + return "imagemagick"; + } + + @Override + public boolean isAvailable() { + if (available == null) { + available = checkAvailable(); + } + return available; + } + + private boolean checkAvailable() { + if (executable != null) { + try { + int exit = new ProcessBuilder(executable, "--version") + .redirectErrorStream(true) + .start() + .waitFor(); + if (exit == 0) { + log.debug("ImageMagick available as '{}'", executable); + return true; + } + } catch (Exception e) { + log.debug("ImageMagick not available at '{}': {}", executable, e.getMessage()); + } + return false; + } + for (String candidate : List.of("magick", "convert")) { + try { + int exit = new ProcessBuilder(candidate, "--version") + .redirectErrorStream(true) + .start() + .waitFor(); + if (exit == 0) { + executable = candidate; + log.debug("ImageMagick available as '{}'", executable); + return true; + } + } catch (Exception e) { + log.debug("ImageMagick candidate '{}' not available: {}", candidate, e.getMessage()); + } + } + return false; + } + + @Override + public void process(Path source, Path target, MediaFormat format, CropArea crop) throws IOException { + // magick input.jpg [-crop WxH+X+Y +repage] -resize WxH output.png + List cmd = new ArrayList<>(); + cmd.add(executable); + cmd.add(source.toString()); + + if (crop != null) { + cmd.add("-crop"); + cmd.add(crop.width() + "x" + crop.height() + "+" + crop.x() + "+" + crop.y()); + cmd.add("+repage"); + } + + cmd.add("-resize"); + cmd.add(format.width() + "x" + format.height()); + + if (!format.compression()) { + cmd.add("-quality"); + cmd.add("100"); + } + + cmd.add(target.toString()); + + log.debug("imagemagick: {}", String.join(" ", cmd)); + try { + int exit = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start() + .waitFor(); + if (exit != 0) { + throw new IOException("imagemagick exited with code " + exit + " for: " + String.join(" ", cmd)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("imagemagick interrupted", e); + } + } +} diff --git a/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessor.java b/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessor.java new file mode 100644 index 000000000..8f81f2f61 --- /dev/null +++ b/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessor.java @@ -0,0 +1,56 @@ +package com.condation.cms.media.processor; + +/*- + * #%L + * CMS Media + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.media.MediaFormat; +import com.condation.cms.media.CropCalculator.CropArea; +import java.io.IOException; +import java.nio.file.Path; + +/** + * Processes (scales + optionally crops) a source image into a target file. + * + * Implementations exist for ImageIO (always available), libvips, and ImageMagick. + * New image format support should be added to each implementation's format-mapping. + */ +public interface ImageProcessor { + + /** Unique name used for configuration and logging. */ + String name(); + + /** + * Returns true if this processor's underlying tool is available on the current system. + * The result should be cached after the first check. + */ + boolean isAvailable(); + + /** + * Processes a source image and writes the result to target. + * + * @param source source image path + * @param target output file path (will be created or overwritten) + * @param format target format including dimensions and output format + * @param crop optional pre-scale crop region, null means no crop + * @throws IOException on I/O or processing errors + */ + void process(Path source, Path target, MediaFormat format, CropArea crop) throws IOException; +} diff --git a/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessorFactory.java b/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessorFactory.java new file mode 100644 index 000000000..f5bc47ff7 --- /dev/null +++ b/cms-media/src/main/java/com/condation/cms/media/processor/ImageProcessorFactory.java @@ -0,0 +1,77 @@ +package com.condation.cms.media.processor; + +/*- + * #%L + * CMS Media + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.configs.MediaConfiguration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Selects and provides the active {@link ImageProcessor}. + * + *

Configured via the {@code processor} field in {@code media.toml}: + *

    + *
  • {@code auto} – tries libvips → imagemagick → imageio in order (default)
  • + *
  • {@code libvips} – use libvips
  • + *
  • {@code imagemagick} – use imagemagick
  • + *
  • {@code imageio} – use the built-in Java ImageIO processor
  • + *
+ * An optional {@code bin_path} overrides the executable path for the selected processor. + */ +@Slf4j +public class ImageProcessorFactory { + + private ImageProcessor resolved = null; + private final String configuredName; + private final String binPath; + + public ImageProcessorFactory(MediaConfiguration config) { + this.configuredName = config.getProcessor() == null ? "auto" : config.getProcessor().trim().toLowerCase(); + this.binPath = config.getBinPath(); + } + + public ImageProcessor get() { + if (resolved == null) { + resolved = resolve(); + } + return resolved; + } + + public void reset() { + resolved = null; + } + + private ImageProcessor resolve() { + ImageProcessor processor = create(configuredName); + log.info("Image processor: {} (configured)", processor.name()); + return processor; + } + + private ImageProcessor create(String name) { + return switch (name) { + case "libvips" -> new LibVipsProcessor(binPath != null ? binPath : "vips"); + case "imagemagick" -> new ImageMagickProcessor(binPath); + case "imageio" -> new ImageIOProcessor(); + default -> throw new IllegalArgumentException("Unknown image processor: '" + name + "'"); + }; + } +} diff --git a/cms-media/src/main/java/com/condation/cms/media/processor/LibVipsProcessor.java b/cms-media/src/main/java/com/condation/cms/media/processor/LibVipsProcessor.java new file mode 100644 index 000000000..d926d020b --- /dev/null +++ b/cms-media/src/main/java/com/condation/cms/media/processor/LibVipsProcessor.java @@ -0,0 +1,144 @@ +package com.condation.cms.media.processor; + +/*- + * #%L + * CMS Media + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.media.MediaFormat; +import com.condation.cms.media.CropCalculator.CropArea; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Image processor using libvips CLI ({@code vips} command). + * Crop is applied via {@code vips extract_area}, scaling via {@code vips thumbnail}. + */ +@Slf4j +public class LibVipsProcessor implements ImageProcessor { + + private Boolean available = null; + + private final String binPath; + + public LibVipsProcessor () { + this("vips"); + } + public LibVipsProcessor (String binPath) { + this.binPath = binPath; + } + + @Override + public String name() { + return "libvips"; + } + + @Override + public boolean isAvailable() { + if (available == null) { + available = checkAvailable(); + } + return available; + } + + private boolean checkAvailable() { + try { + int exit = new ProcessBuilder(binPath, "--version") + .redirectErrorStream(true) + .start() + .waitFor(); + return exit == 0; + } catch (Exception e) { + log.debug("libvips not available: {}", e.getMessage()); + return false; + } + } + + @Override + public void process(Path source, Path target, MediaFormat format, CropArea crop) throws IOException { + Path inputForScale = source; + + if (crop != null) { + inputForScale = target.resolveSibling(target.getFileName() + ".crop.tmp"); + runExtractArea(source, inputForScale, crop); + } + + try { + runThumbnail(inputForScale, target, format); + } finally { + if (crop != null) { + java.nio.file.Files.deleteIfExists(inputForScale); + } + } + } + + private void runExtractArea(Path source, Path target, CropArea crop) throws IOException { + // vips extract_area source.jpg target.jpg left top width height + List cmd = List.of( + binPath, "extract_area", + source.toString(), + target.toString(), + String.valueOf(crop.x()), + String.valueOf(crop.y()), + String.valueOf(crop.width()), + String.valueOf(crop.height()) + ); + runCommand(cmd); + } + + private void runThumbnail(Path source, Path target, MediaFormat format) throws IOException { + // vips thumbnail source.jpg target.jpg width --height height --size both + List cmd = new ArrayList<>(List.of( + binPath, "thumbnail", + source.toString(), + formatTarget(target, format), + String.valueOf(format.width()), + "--height", String.valueOf(format.height()), + "--size", "both" + )); + runCommand(cmd); + } + + /** + * libvips determines output format from the file extension. + * The target Path already has the correct extension from MediaUtils. + */ + private String formatTarget(Path target, MediaFormat format) { + return target.toString(); + } + + private void runCommand(List cmd) throws IOException { + log.debug("libvips: {}", String.join(" ", cmd)); + try { + int exit = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start() + .waitFor(); + if (exit != 0) { + throw new IOException("vips exited with code " + exit + " for command: " + String.join(" ", cmd)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("vips interrupted", e); + } + } +} diff --git a/test-server/hosts/demo/config/media.toml b/test-server/hosts/demo/config/media.toml index a44c0b62b..599c04bec 100644 --- a/test-server/hosts/demo/config/media.toml +++ b/test-server/hosts/demo/config/media.toml @@ -1,3 +1,4 @@ +processor = "imageio" [[formats]] name = "small" width = 256