From 9f59ea01c5fc55ffc3c444a4dae6a3430f5d50d3 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Mon, 14 Apr 2025 22:43:06 -0700 Subject: [PATCH 1/3] Remove unused methods and make log messages constant in ImageUtil --- .../net/rptools/tokentool/util/ImageUtil.java | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/main/java/net/rptools/tokentool/util/ImageUtil.java b/src/main/java/net/rptools/tokentool/util/ImageUtil.java index 275a214..b026eac 100644 --- a/src/main/java/net/rptools/tokentool/util/ImageUtil.java +++ b/src/main/java/net/rptools/tokentool/util/ImageUtil.java @@ -16,7 +16,6 @@ import com.twelvemonkeys.imageio.plugins.psd.PSDImageReader; import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FilenameFilter; import java.io.IOException; @@ -138,7 +137,7 @@ private static ImageView getImage( try (ImageInputStream is = ImageIO.createImageInputStream(file)) { if (is == null || is.length() == 0) { - log.info("Image from file " + file.getAbsolutePath() + " is null"); + log.info("Image from file {} is null", file.getAbsolutePath()); } Iterator iterator = ImageIO.getImageReaders(is); @@ -178,7 +177,7 @@ private static ImageView getImage( thumb = resizeCanvas(SwingFXUtils.toFXImage(thumbBI, null), width, height, x, y); } } catch (Exception e) { - log.error("Processing: " + file.getAbsolutePath(), e); + log.error("Processing: {}", file.getAbsolutePath(), e); } finally { // Dispose reader in finally block to avoid memory leaks if (reader != null) { @@ -225,18 +224,6 @@ public static Image resizeCanvas( return outputImage; } - /* - * Resize the overall image width/height scaled to the target width/height - */ - public static Image scaleImage( - Image source, double targetWidth, double targetHeight, boolean preserveRatio) { - ImageView imageView = new ImageView(source); - imageView.setPreserveRatio(preserveRatio); - imageView.setFitWidth(targetWidth); - imageView.setFitHeight(targetHeight); - return imageView.snapshot(null, null); - } - /* * Return the intersection between the source image and the mask. Note, the mask does not need to be magenta anymore, any non-transparent pixel is considering a mask */ @@ -402,14 +389,6 @@ public static Image composePreview( return finalImage; } - public static double getScaleXRatio(ImageView imageView) { - return imageView.getBoundsInParent().getWidth() / imageView.getImage().getWidth(); - } - - public static double getScaleYRatio(ImageView imageView) { - return imageView.getBoundsInParent().getHeight() / imageView.getImage().getHeight(); - } - /* * This is for Legacy support but can cause magenta bleed on edges if there is transparency overlap. The preferred overlay storage is now PhotoShop PSD format with layer 1 containing the mask and * layer 2 containing the image @@ -463,17 +442,6 @@ public static String getFileType(File imageFile) { } } - public static byte[] imageToBytes(BufferedImage image) throws IOException { - return imageToBytes(image, "png"); - } - - public static byte[] imageToBytes(BufferedImage image, String format) throws IOException { - ByteArrayOutputStream outStream = new ByteArrayOutputStream(10000); - ImageIO.write(image, format, outStream); - - return outStream.toByteArray(); - } - public static List GET_EXTENSION_FILTERS() { List extensionFilters = new ArrayList<>(); extensionFilters.add( From a87ec04a1240a711645e0cc0dbfe9adb8cf99b9c Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Tue, 15 Apr 2025 14:48:33 -0700 Subject: [PATCH 2/3] Keep `maskImageView` out of the scene proper It is never meant to be rendered in the main panel, but is only meant to be part of the composition of final results. Removing it from the scene is not only simpler (no need to toggle visibility), it avoids any possibility of flickering when we introduce async preview composition. --- .../controller/TokenTool_Controller.java | 24 ++++++++++++++++--- .../net/rptools/tokentool/util/ImageUtil.java | 10 ++++---- .../net/rptools/tokentool/view/TokenTool.fxml | 5 ---- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java b/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java index 1f29b2a..88924ff 100644 --- a/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java +++ b/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java @@ -157,7 +157,6 @@ protected boolean removeEldestEntry(Map.Entry> eldest) { @FXML private StackPane imagesStackPane; @FXML private ImageView backgroundImageView; // The background image layer @FXML private ImageView portraitImageView; // The bottom "Portrait" layer - @FXML private ImageView maskImageView; // The mask layer used to crop the Portrait layer @FXML private ImageView overlayImageView; // The overlay layer to apply on top of everything @FXML private ImageView tokenImageView; // The final token image created @FXML private CheckBox useFileNumberingCheckbox; @@ -192,6 +191,13 @@ protected boolean removeEldestEntry(Map.Entry> eldest) { @FXML private RadioMenuItem overlayMenuItem; private FileSaveUtil fileSaveUtil = new FileSaveUtil(); + /** + * The mask layer used to crop the Portrait layer. + * + *

This is not part of the main scene, but is used when compositing result images. + */ + private ImageView maskImageView; + // A custom set of Width/Height sizes to use for Overlays private NavigableSet overlaySpinnerSteps = new TreeSet<>( @@ -252,8 +258,6 @@ void initialize() { : "fx:id=\"backgroundImageView\" was not injected: check your FXML file 'TokenTool.fxml'."; assert portraitImageView != null : "fx:id=\"portraitImageView\" was not injected: check your FXML file 'TokenTool.fxml'."; - assert maskImageView != null - : "fx:id=\"maskImageView\" was not injected: check your FXML file 'TokenTool.fxml'."; assert overlayImageView != null : "fx:id=\"overlayImageView\" was not injected: check your FXML file 'TokenTool.fxml'."; assert tokenImageView != null @@ -325,6 +329,20 @@ void initialize() { assert overlayMenuItem != null : "fx:id=\"overlayMenuItem\" was not injected: check your FXML file 'TokenTool.fxml'."; + var defaultImageUrl = + getClass().getResource("/net/rptools/tokentool/image/gear-chrome-mask.png"); + maskImageView = + defaultImageUrl == null ? new ImageView() : new ImageView(defaultImageUrl.toExternalForm()); + maskImageView.setVisible(true); + maskImageView.setId("maskImageView"); + maskImageView.setFitWidth(256); + maskImageView.setFitHeight(256); + maskImageView.setLayoutX(1); + maskImageView.setLayoutY(1); + maskImageView.setMouseTransparent(true); + maskImageView.setPickOnBounds(true); + maskImageView.setPreserveRatio(true); + // We're getting the defaults set by the FXML before updating them with the saved preferences... AppConstants.DEFAULT_MASK_IMAGE = maskImageView.getImage(); AppConstants.DEFAULT_OVERLAY_IMAGE = overlayImageView.getImage(); diff --git a/src/main/java/net/rptools/tokentool/util/ImageUtil.java b/src/main/java/net/rptools/tokentool/util/ImageUtil.java index b026eac..93155ea 100644 --- a/src/main/java/net/rptools/tokentool/util/ImageUtil.java +++ b/src/main/java/net/rptools/tokentool/util/ImageUtil.java @@ -332,10 +332,10 @@ public static Image composePreview( // We will then get a snapshot of the background image, if any. double x, y, width, height; - x = maskImageView.getParent().getLayoutX(); - y = maskImageView.getParent().getLayoutY(); - width = maskImageView.getFitWidth(); - height = maskImageView.getFitHeight(); + x = overlayImageView.getParent().getLayoutX(); + y = overlayImageView.getParent().getLayoutY(); + width = overlayImageView.getFitWidth(); + height = overlayImageView.getFitHeight(); Rectangle2D viewPort = new Rectangle2D(x, y, width, height); Rectangle2D maskViewPort = new Rectangle2D(1, 1, width, height); @@ -355,9 +355,7 @@ public static Image composePreview( portraitImageView.snapshot(parameter, newImage); parameter.setViewport(maskViewPort); - maskImageView.setVisible(true); maskImageView.snapshot(parameter, newMaskImage); - maskImageView.setVisible(false); clippedImageView.setFitWidth(width); clippedImageView.setFitHeight(height); diff --git a/src/main/resources/net/rptools/tokentool/view/TokenTool.fxml b/src/main/resources/net/rptools/tokentool/view/TokenTool.fxml index 12fc304..15be1dc 100644 --- a/src/main/resources/net/rptools/tokentool/view/TokenTool.fxml +++ b/src/main/resources/net/rptools/tokentool/view/TokenTool.fxml @@ -117,11 +117,6 @@ - - - - - From dc7763af1220b604df1b488c5e8dae139a728118 Mon Sep 17 00:00:00 2001 From: Kenneth VanderLinde Date: Fri, 11 Apr 2025 13:00:12 -0700 Subject: [PATCH 3/3] Add async snapshotting to ImageUtil The new `ImageUtil.Async.snapshot()` is a convenience wrapper that adapts the callback-based `Node#snapshot()` into a future-returning method. This is then used by the other new async methods introduced in this commit: - `ImageUtil.Async.autoCrop()` for autocropping an image. This is optionally async since there is one use that would otherwise have to be completely rewritten for asynchronous operation. Mostly replaces `ImageUtil.autoCropImage()`, but `ImageUtil.autoCropImage()` still exists as a wrapper around the synchronous version. - `ImageUtil.Async.composeClippedPreview()` for creating a preview clipped to the overlay. Replaces the clipped branch of `ImageUtil.composePreview()`. - `ImageUtil.Async.composeUnclippedPreview()` for creating a preview that is not clipped to the overlay. Replaces the unclipped branch of `ImageUtil.composePreview()`. Make autoCropImage optionally async --- .../controller/TokenTool_Controller.java | 35 +- .../net/rptools/tokentool/util/ImageUtil.java | 317 ++++++++++-------- 2 files changed, 206 insertions(+), 146 deletions(-) diff --git a/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java b/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java index 88924ff..43a3900 100644 --- a/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java +++ b/src/main/java/net/rptools/tokentool/controller/TokenTool_Controller.java @@ -33,6 +33,7 @@ import java.util.NavigableSet; import java.util.Optional; import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -1250,17 +1251,29 @@ public void updateOverlayTreeview(TreeItem overlayTreeItems) { } public void updateTokenPreviewImageView() { - tokenImageView.setImage( - ImageUtil.composePreview( - compositeTokenPane, - backgroundImageView, - backgroundColorPicker.getValue(), - portraitImageView, - maskImageView, - overlayImageView, - overlayUseAsBaseCheckbox.isSelected(), - clipPortraitCheckbox.isSelected())); - tokenImageView.setPreserveRatio(true); + compositeTokenPane.layout(); + + boolean clip = + clipPortraitCheckbox.isSelected() + && maskImageView.getFitWidth() > 0 + && maskImageView.getFitHeight() > 0; + CompletableFuture future = + clip + ? ImageUtil.Async.composeClippedPreview( + backgroundImageView, + backgroundColorPicker.getValue(), + portraitImageView, + maskImageView, + overlayImageView, + overlayUseAsBaseCheckbox.isSelected()) + : ImageUtil.Async.composeUnclippedPreview(compositeTokenPane); + future + .thenCompose(i -> ImageUtil.Async.autoCrop(i)) + .thenAccept( + cropped -> { + tokenImageView.setImage(cropped); + tokenImageView.setPreserveRatio(true); + }); } private void saveToken() { diff --git a/src/main/java/net/rptools/tokentool/util/ImageUtil.java b/src/main/java/net/rptools/tokentool/util/ImageUtil.java index 93155ea..d6b0b75 100644 --- a/src/main/java/net/rptools/tokentool/util/ImageUtil.java +++ b/src/main/java/net/rptools/tokentool/util/ImageUtil.java @@ -24,9 +24,11 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.concurrent.CompletableFuture; import javafx.embed.swing.SwingFXUtils; import javafx.geometry.Rectangle2D; import javafx.scene.Group; +import javafx.scene.Node; import javafx.scene.SnapshotParameters; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -34,7 +36,6 @@ import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; import javafx.scene.image.WritablePixelFormat; -import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.stage.FileChooser.ExtensionFilter; import javax.imageio.ImageIO; @@ -249,142 +250,9 @@ private static Image clipImageWithMask(Image imageSource, Image imageMask) { return outputImage; } - /* - * Crop image to smallest width/height based on transparency - */ - private static Image autoCropImage(Image imageSource) { - return autoCropImage(imageSource, Color.TRANSPARENT, null); - } - public static Image autoCropImage( Image imageSource, Color backgroundColor, Image backgroundImage) { - ImageView croppedImageView = new ImageView(imageSource); - PixelReader pixelReader = imageSource.getPixelReader(); - - int imageWidth = (int) imageSource.getWidth(); - int imageHeight = (int) imageSource.getHeight(); - int minX = imageWidth, minY = imageHeight, maxX = 0, maxY = 0; - - // Find the first and last pixels that are not transparent to create a bounding viewport - for (int readY = 0; readY < imageHeight; readY++) { - for (int readX = 0; readX < imageWidth; readX++) { - Color pixelColor = pixelReader.getColor(readX, readY); - - if (!pixelColor.equals(Color.TRANSPARENT)) { - if (readX < minX) { - minX = readX; - } - if (readX > maxX) { - maxX = readX; - } - - if (readY < minY) { - minY = readY; - } - if (readY > maxY) { - maxY = readY; - } - } - } - } - - if (maxX - minX <= 0 || maxY - minY <= 0) { - return new WritableImage(1, 1); - } - - // Create a viewport to clip the image using snapshot - Rectangle2D viewPort = new Rectangle2D(minX, minY, maxX - minX, maxY - minY); - SnapshotParameters parameter = new SnapshotParameters(); - parameter.setViewport(viewPort); - parameter.setFill(backgroundColor); - - if (backgroundImage != null) { - return new Group(new ImageView(backgroundImage), croppedImageView).snapshot(parameter, null); - } else { - return croppedImageView.snapshot(parameter, null); - } - } - - public static Image composePreview( - StackPane compositeTokenPane, - ImageView backgroundImageView, - Color bgColor, - ImageView portraitImageView, - ImageView maskImageView, - ImageView overlayImageView, - boolean useAsBase, - boolean clipImage) { - - // Process layout as maskImage may have changed size if the overlay was changed - compositeTokenPane.layout(); - SnapshotParameters parameter = new SnapshotParameters(); - Image finalImage; - Group blend; - - // check if there is a mask image - if (maskImageView.getFitWidth() <= 0 || maskImageView.getFitHeight() <= 0) { - clipImage = false; - } - - if (clipImage) { - // We need to clip the portrait image first then blend the overlay image over it - // We will first get a snapshot of the portrait equal to the mask overlay image width/height - // We will then get a snapshot of the background image, if any. - double x, y, width, height; - - x = overlayImageView.getParent().getLayoutX(); - y = overlayImageView.getParent().getLayoutY(); - width = overlayImageView.getFitWidth(); - height = overlayImageView.getFitHeight(); - - Rectangle2D viewPort = new Rectangle2D(x, y, width, height); - Rectangle2D maskViewPort = new Rectangle2D(1, 1, width, height); - WritableImage newBackgroundImage = new WritableImage((int) width, (int) height); - WritableImage newImage = new WritableImage((int) width, (int) height); - WritableImage newMaskImage = new WritableImage((int) width, (int) height); - - ImageView newBackgroundImageView = new ImageView(); - ImageView overlayCopyImageView = new ImageView(); - ImageView clippedImageView = new ImageView(); - - parameter.setViewport(viewPort); - parameter.setFill(bgColor); - backgroundImageView.snapshot(parameter, newBackgroundImage); - - parameter.setFill(Color.TRANSPARENT); - portraitImageView.snapshot(parameter, newImage); - - parameter.setViewport(maskViewPort); - maskImageView.snapshot(parameter, newMaskImage); - - clippedImageView.setFitWidth(width); - clippedImageView.setFitHeight(height); - clippedImageView.setImage(clipImageWithMask(newImage, newMaskImage)); - newBackgroundImageView.setImage(clipImageWithMask(newBackgroundImage, newMaskImage)); - - // Our masked portrait image is now stored in clippedImageView, lets now blend the overlay - // image over it - // We'll create a temporary group to hold our temporary ImageViews's and blend them and take a - // snapshot - overlayCopyImageView.setImage(overlayImageView.getImage()); - overlayCopyImageView.setFitWidth(overlayImageView.getFitWidth()); - overlayCopyImageView.setFitHeight(overlayImageView.getFitHeight()); - overlayCopyImageView.setOpacity(overlayImageView.getOpacity()); - - if (useAsBase) { - blend = new Group(newBackgroundImageView, overlayCopyImageView, clippedImageView); - } else { - blend = new Group(newBackgroundImageView, clippedImageView, overlayCopyImageView); - } - - // Last, we'll clean up any excess transparent edges by cropping it - finalImage = autoCropImage(blend.snapshot(parameter, null)); - } else { - parameter.setFill(Color.TRANSPARENT); - finalImage = autoCropImage(compositeTokenPane.snapshot(parameter, null)); - } - - return finalImage; + return Async.autoCrop(imageSource, backgroundColor, backgroundImage, false).join(); } /* @@ -464,4 +332,183 @@ public static List GET_EXTENSION_FILTERS() { return extensionFilters; } + + public static final class Async { + public static CompletableFuture snapshot( + Node node, SnapshotParameters parameters, WritableImage image) { + CompletableFuture future = new CompletableFuture<>(); + node.snapshot( + result -> { + future.complete(result.getImage()); + return null; + }, + parameters, + image); + return future; + } + + public static CompletableFuture autoCrop(Image imageSource) { + return autoCrop(imageSource, Color.TRANSPARENT, null, true); + } + + private static CompletableFuture autoCrop( + Image imageSource, Color backgroundColor, Image backgroundImage, boolean async) { + PixelReader pixelReader = imageSource.getPixelReader(); + + int imageWidth = (int) imageSource.getWidth(); + int imageHeight = (int) imageSource.getHeight(); + int minX = imageWidth, minY = imageHeight, maxX = 0, maxY = 0; + + // Find the first and last pixels that are not transparent to create a bounding viewport + for (int readY = 0; readY < imageHeight; readY++) { + for (int readX = 0; readX < imageWidth; readX++) { + Color pixelColor = pixelReader.getColor(readX, readY); + + if (!pixelColor.equals(Color.TRANSPARENT)) { + if (readX < minX) { + minX = readX; + } + if (readX > maxX) { + maxX = readX; + } + + if (readY < minY) { + minY = readY; + } + if (readY > maxY) { + maxY = readY; + } + } + } + } + + if (maxX - minX <= 0 || maxY - minY <= 0) { + return CompletableFuture.completedFuture(new WritableImage(1, 1)); + } + + // Create a viewport to clip the image using snapshot + Rectangle2D viewPort = new Rectangle2D(minX, minY, maxX - minX, maxY - minY); + SnapshotParameters parameter = new SnapshotParameters(); + parameter.setViewport(viewPort); + parameter.setFill(backgroundColor); + + Node node = new ImageView(imageSource); + if (backgroundImage != null) { + node = new Group(new ImageView(backgroundImage), node); + } + + if (async) { + return snapshot(node, parameter, null); + } else { + return CompletableFuture.completedFuture(node.snapshot(parameter, null)); + } + } + + public static CompletableFuture composeUnclippedPreview(Node node) { + SnapshotParameters parameter = new SnapshotParameters(); + parameter.setFill(Color.TRANSPARENT); + return ImageUtil.Async.snapshot(node, parameter, null); + } + + public static CompletableFuture composeClippedPreview( + ImageView backgroundImageView, + Color bgColor, + ImageView portraitImageView, + ImageView maskImageView, + ImageView overlayImageView, + boolean useAsBase) { + // We need to clip the portrait image first then blend the overlay image over it + // We will first get a snapshot of the portrait equal to the mask overlay image width/height + // We will then get a snapshot of the background image, if any. + final var viewport = + new Rectangle2D( + overlayImageView.getParent().getLayoutX(), + overlayImageView.getParent().getLayoutY(), + overlayImageView.getFitWidth(), + overlayImageView.getFitHeight()); + final var maskViewport = new Rectangle2D(1, 1, viewport.getWidth(), viewport.getHeight()); + + final CompletableFuture backgroundFuture; + { + SnapshotParameters backgroundParameters = new SnapshotParameters(); + backgroundParameters.setViewport(viewport); + backgroundParameters.setFill(bgColor); + backgroundFuture = + snapshot( + backgroundImageView, + backgroundParameters, + null // new WritableImage((int) viewport.getWidth(), (int) viewport.getHeight()) + ); + } + + final CompletableFuture portraitFuture; + { + SnapshotParameters portraitParameters = new SnapshotParameters(); + portraitParameters.setViewport(viewport); + portraitParameters.setFill(Color.TRANSPARENT); + portraitFuture = + snapshot( + portraitImageView, + portraitParameters, + new WritableImage((int) viewport.getWidth(), (int) viewport.getHeight())); + } + + final CompletableFuture maskFuture; + { + // TODO Surely this is just a scaled version of the original mask image. Can we not somehow + // use that directly? + SnapshotParameters maskParameters = new SnapshotParameters(); + maskParameters.setViewport(maskViewport); + maskParameters.setFill(Color.TRANSPARENT); + maskFuture = + snapshot( + maskImageView, + maskParameters, + new WritableImage((int) viewport.getWidth(), (int) viewport.getHeight())); + } + + return CompletableFuture.allOf(backgroundFuture, portraitFuture, maskFuture) + .thenCompose( + ignored -> { + var newBackgroundImage = backgroundFuture.join(); + var newImage = portraitFuture.join(); + var newMaskImage = maskFuture.join(); + + ImageView clippedImageView = new ImageView(); + clippedImageView.setFitWidth(viewport.getWidth()); + clippedImageView.setFitHeight(viewport.getHeight()); + clippedImageView.setImage(clipImageWithMask(newImage, newMaskImage)); + + ImageView newBackgroundImageView = new ImageView(); + newBackgroundImageView.setImage( + clipImageWithMask(newBackgroundImage, newMaskImage)); + + // Our masked portrait image is now stored in clippedImageView, lets now blend the + // overlay + // image over it + // We'll create a temporary group to hold our temporary ImageViews's and blend them + // and take a + // snapshot + ImageView overlayCopyImageView = new ImageView(); + overlayCopyImageView.setImage(overlayImageView.getImage()); + overlayCopyImageView.setFitWidth(overlayImageView.getFitWidth()); + overlayCopyImageView.setFitHeight(overlayImageView.getFitHeight()); + overlayCopyImageView.setOpacity(overlayImageView.getOpacity()); + + Group blend; + if (useAsBase) { + blend = new Group(newBackgroundImageView, overlayCopyImageView, clippedImageView); + } else { + blend = new Group(newBackgroundImageView, clippedImageView, overlayCopyImageView); + } + + // Last, we'll clean up any excess transparent edges by cropping it + SnapshotParameters groupParameters = new SnapshotParameters(); + groupParameters.setViewport( + new Rectangle2D(1, 1, viewport.getWidth(), viewport.getHeight())); + groupParameters.setFill(Color.TRANSPARENT); + return snapshot(blend, groupParameters, null); + }); + } + } }