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..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; @@ -157,7 +158,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 +192,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 +259,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 +330,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(); @@ -1232,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 275a214..d6b0b75 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; @@ -25,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; @@ -35,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; @@ -138,7 +138,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 +178,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 +225,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 */ @@ -262,152 +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 = maskImageView.getParent().getLayoutX(); - y = maskImageView.getParent().getLayoutY(); - width = maskImageView.getFitWidth(); - height = maskImageView.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.setVisible(true); - maskImageView.snapshot(parameter, newMaskImage); - maskImageView.setVisible(false); - - 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; - } - - 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(); + return Async.autoCrop(imageSource, backgroundColor, backgroundImage, false).join(); } /* @@ -463,17 +308,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( @@ -498,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); + }); + } + } } 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 @@ - - - - -