Repository: pajlads/DinkPlugin (java)

Issue:
Add re-scaling settings for screenshots
A user in the Runelite discord reports being unable to get screenshots with an old discord notification plugin due to using an ultra-wide 4K monitor and Discord upload restrictions.

This issue would likely apply to our plugin as well, would be useful if we could offer a setting to scale down the image size, to bypass said restrictions,

Pull request:
feat: rescale images to comply with discord size limit
Closes #199
Related https://github.com/MidgetJake/UniversalDiscordNotifier/issues/35


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 688ac2d..7af25ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Rescale screenshots to comply with Discord's 8MB size limit. (#200)
 - Minor: Include combat level in skill notifications. (#203)
 
 ## 1.3.2
diff --git a/README.md b/README.md
index dc1706a..3aef1b5 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Some notifiers require in-game settings to be configured to send chat messages u
 * Multiple webhook urls are supported; simply place each on a separate line
 * Each notifier can send webhook messages to separate "override" urls
 * Screenshots can be individually configured for each notifier
+* Screenshots are compressed if needed to comply with Discord limits
 * The plugin can skip notifications if the current player name is on the user-configured RSN ignore list
 * Users can choose whether their webhook messages are sent in Discord's rich embed format or a traditional format
 * The player name in Discord rich embeds can be linked to various tracking services (from HiScores to Wise Old Man)
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 2e28f9e..c058ecb 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -169,11 +169,25 @@ public interface DinkPluginConfig extends Config {
         return 30; // elevated from okhttp default of 10
     }
 
+    @ConfigItem(
+        keyName = "screenshotScale",
+        name = "Screenshot Scale",
+        description = "Resizes screenshots in each dimension by the specified percentage.<br/>" +
+            "Useful to avoid Discord's max upload size of 8MB or reduce bandwidth",
+        position = 1003,
+        section = advancedSection
+    )
+    @Units(Units.PERCENT)
+    @Range(min = 1, max = 100)
+    default int screenshotScale() {
+        return 100;
+    }
+
     @ConfigItem(
         keyName = "discordRichEmbeds",
         name = "Use Rich Embeds",
         description = "Whether Discord's rich embed format should be used for webhooks",
-        position = 1003,
+        position = 1004,
         section = advancedSection
     )
     default boolean discordRichEmbeds() {
@@ -184,7 +198,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "embedFooterText",
         name = "Embed Footer Text",
         description = "The text in the footer of rich embed webhook messages. If empty, no footer will be sent",
-        position = 1004,
+        position = 1005,
         section = advancedSection
     )
     default String embedFooterText() {
@@ -195,7 +209,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "embedFooterIcon",
         name = "Embed Footer Icon",
         description = "The URL for the footer icon image of rich embed webhooks. Requires footer text to not be empty",
-        position = 1005,
+        position = 1006,
         section = advancedSection
     )
     default String embedFooterIcon() {
@@ -206,7 +220,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "ignoredNames",
         name = "Ignored RSNs",
         description = "Prevent notifications from the following player names (One name per line)",
-        position = 1006,
+        position = 1007,
         section = advancedSection
     )
     default String ignoredNames() {
@@ -217,7 +231,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "playerLookupService",
         name = "Player Lookup Service",
         description = "The service used to lookup a players account, to make their name clickable in Discord embeds",
-        position = 1007,
+        position = 1008,
         section = advancedSection
     )
     default PlayerLookupService playerLookupService() {
diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
index 9fa76ec..a0f7d6a 100644
--- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java
+++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
@@ -20,17 +20,27 @@ import okhttp3.Request;
 import okhttp3.RequestBody;
 import okhttp3.Response;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.VisibleForTesting;
 
+import javax.imageio.ImageIO;
 import javax.inject.Inject;
 import javax.inject.Singleton;
+import java.awt.Image;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -95,24 +105,22 @@ public class DiscordMessageHandler {
 
         if (sendImage) {
             String screenshotFile = mBody.getType().getScreenshot();
-            drawManager.requestNextFrameListener(image -> {
-                try {
-                    byte[] imageBytes = Utils.convertImageToByteArray(ImageUtil.bufferedImageFromImage(image));
-
-                    reqBodyBuilder.addFormDataPart(
+            captureScreenshot(drawManager, config.screenshotScale() / 100.0)
+                .thenApply(image -> reqBodyBuilder
+                    .addFormDataPart(
                         "file",
                         screenshotFile,
                         RequestBody.create(
-                            MediaType.parse("image/png"),
-                            imageBytes
+                            MediaType.parse("image/" + image.getKey()),
+                            image.getValue()
                         )
-                    );
-                } catch (Exception e) {
+                    )
+                )
+                .exceptionally(e -> {
                     log.warn("There was an error creating bytes from captured image", e);
-                } finally {
-                    sendToMultiple(urlList, reqBodyBuilder);
-                }
-            });
+                    return reqBodyBuilder;
+                })
+                .thenAcceptAsync(body -> sendToMultiple(urlList, body));
         } else {
             sendToMultiple(urlList, reqBodyBuilder);
         }
@@ -162,6 +170,49 @@ public class DiscordMessageHandler {
         });
     }
 
+    /**
+     * Captures the next frame and applies the specified rescaling
+     * while abiding by {@link Embed#MAX_IMAGE_SIZE}.
+     *
+     * @param drawManager  {@link DrawManager}
+     * @param scalePercent {@link DinkPluginConfig#screenshotScale()} divided by 100.0
+     * @return future of the image byte array by the image format name
+     * @apiNote scalePercent should be in (0, 1]
+     * @implNote the image format is either "png" (lossless) or "jpeg" (lossy), both of which can be used in MIME type
+     */
+    private static CompletableFuture<Map.Entry<String, byte[]>> captureScreenshot(DrawManager drawManager, double scalePercent) {
+        CompletableFuture<Image> future = new CompletableFuture<>();
+        drawManager.requestNextFrameListener(future::complete);
+        return future.thenApply(ImageUtil::bufferedImageFromImage)
+            .thenApply(input -> Utils.rescale(input, scalePercent))
+            .thenApply(image -> {
+                try {
+                    String format = "png"; // lossless
+                    return Pair.of(format, Utils.convertImageToByteArray(image, format));
+                } catch (IOException e) {
+                    throw new CompletionException("Could not convert image to byte array", e);
+                }
+            })
+            .thenApply(pair -> {
+                byte[] bytes = pair.getValue();
+                int n = bytes.length;
+                if (n <= Embed.MAX_IMAGE_SIZE)
+                    return pair; // already compliant; no further rescale necessary
+
+                // calculate scale factor to comply with MAX_IMAGE_SIZE
+                double factor = Math.sqrt(1.0 * Embed.MAX_IMAGE_SIZE / n);
+
+                // bytes => original image => rescaled image => updated bytes
+                try (InputStream is = new ByteArrayInputStream(bytes)) {
+                    String format = "jpeg"; // lossy
+                    BufferedImage rescaled = Utils.rescale(ImageIO.read(is), factor);
+                    return Pair.of(format, Utils.convertImageToByteArray(rescaled, format));
+                } catch (Exception e) {
+                    throw new CompletionException("Failed to resize image below Discord size limit", e);
+                }
+            });
+    }
+
     private static NotificationBody<?> injectContent(@NotNull NotificationBody<?> body, boolean screenshot, DinkPluginConfig config) {
         NotificationType type = body.getType();
         NotificationData extra = body.getExtra();
diff --git a/src/main/java/dinkplugin/message/Embed.java b/src/main/java/dinkplugin/message/Embed.java
index a400e48..8dff9da 100644
--- a/src/main/java/dinkplugin/message/Embed.java
+++ b/src/main/java/dinkplugin/message/Embed.java
@@ -18,6 +18,8 @@ import java.util.List;
 @Value
 @Builder
 public class Embed {
+    // The max size of the image before we rescale it to fit Discords file upload limits https://discord.com/developers/docs/reference#uploading-files
+    public static final int MAX_IMAGE_SIZE = 8_000_000; // 8MB
     public static final int MAX_DESCRIPTION_LENGTH = 4096;
     public static final int MAX_FOOTER_LENGTH = 2048;
 
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index e804eb3..5e45d0c 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -24,10 +24,13 @@ import java.awt.Toolkit;
 import java.awt.datatransfer.Clipboard;
 import java.awt.datatransfer.DataFlavor;
 import java.awt.datatransfer.StringSelection;
+import java.awt.geom.AffineTransform;
+import java.awt.image.AffineTransformOp;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.Reader;
+import java.util.Arrays;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
 
@@ -87,9 +90,23 @@ public class Utils {
         }
     }
 
-    public byte[] convertImageToByteArray(BufferedImage bufferedImage) throws IOException {
+    public BufferedImage rescale(BufferedImage input, double percent) {
+        if (percent + Math.ulp(1.0) >= 1.0)
+            return input;
+
+        AffineTransform rescale = AffineTransform.getScaleInstance(percent, percent);
+        AffineTransformOp operation = new AffineTransformOp(rescale, AffineTransformOp.TYPE_NEAREST_NEIGHBOR);
+
+        BufferedImage output = new BufferedImage((int) (input.getWidth() * percent), (int) (input.getHeight() * percent), input.getType());
+        operation.filter(input, output);
+        return output;
+    }
+
+    public byte[] convertImageToByteArray(BufferedImage bufferedImage, String format) throws IOException {
         ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
-        ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
+        boolean foundWriter = ImageIO.write(bufferedImage, format, byteArrayOutputStream);
+        if (!foundWriter)
+            throw new IllegalArgumentException(String.format("Specified format '%s' was not in supported formats: %s", format, Arrays.toString(ImageIO.getWriterFormatNames())));
         return byteArrayOutputStream.toByteArray();
     }
 


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
index 67566f1..5bf460a 100644
--- a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
@@ -111,6 +111,7 @@ abstract class MockedNotifierTest extends MockedTestBase {
         when(config.maxRetries()).thenReturn(0);
         when(config.baseRetryDelay()).thenReturn(2000);
         when(config.imageWriteTimeout()).thenReturn(30_000);
+        when(config.screenshotScale()).thenReturn(95);
         when(config.discordRichEmbeds()).thenReturn(!"false".equalsIgnoreCase(System.getenv("TEST_WEBHOOK_RICH")));
         when(config.embedFooterText()).thenReturn("Powered by Dink");
         when(config.embedFooterIcon()).thenReturn("https://github.com/pajlads/DinkPlugin/raw/master/icon.png");