From 722711fc1f7c8f889df21b5a90a97b85d63cd6d5 Mon Sep 17 00:00:00 2001 From: Vinrobot Date: Thu, 7 Sep 2023 19:55:57 +0200 Subject: [PATCH] Add support for animated GIF using ImageIO --- build.gradle | 11 +- gradle.properties | 14 ++- .../client/MinecraftEmoteModClient.java | 9 -- .../mcemote/client/font/AnimatedGlyph.java | 8 +- .../vinrobot/mcemote/client/font/Emote.java | 11 +- .../mcemote/client/font/EmoteFontStorage.java | 22 ++-- .../mcemote/client/imageio/BufferedFrame.java | 13 +++ .../mcemote/client/imageio/NativeFrame.java | 11 ++ .../NativeImageHelper.java | 8 +- .../mcemote/client/imageio/NativeImageIO.java | 46 ++++++++ .../imageio/plugins/gif/DisposalMethod.java | 24 ++++ .../client/imageio/plugins/gif/GifReader.java | 109 ++++++++++++++++++ .../imageio/plugins/gif/GlobalColorTable.java | 27 +++++ .../plugins/gif/GraphicControlExtension.java | 18 +++ .../imageio/plugins/gif/ImageDescriptor.java | 16 +++ .../plugins/gif/LogicalScreenDescriptor.java | 16 +++ .../imageio/plugins/gif/NodeHelper.java | 47 ++++++++ .../imageio/plugins/webp/ImageMetadata.java | 14 +++ .../imageio/plugins/webp/WebPReader.java | 41 +++++++ .../mcemote/client/providers/BTTVEmote.java | 27 +---- .../mcemote/client/providers/FFZEmote.java | 13 +-- .../client/providers/SevenTVEmote.java | 30 +---- 22 files changed, 432 insertions(+), 103 deletions(-) create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/BufferedFrame.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/NativeFrame.java rename src/client/java/net/vinrobot/mcemote/client/{helpers => imageio}/NativeImageHelper.java (76%) create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageIO.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/DisposalMethod.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GifReader.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GlobalColorTable.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GraphicControlExtension.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/ImageDescriptor.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/LogicalScreenDescriptor.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/NodeHelper.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/ImageMetadata.java create mode 100644 src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/WebPReader.java diff --git a/build.gradle b/build.gradle index d50495a..5c32014 100644 --- a/build.gradle +++ b/build.gradle @@ -46,20 +46,17 @@ dependencies { // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc:fabric-loader:${project.fabric_loader_version}" // Uncomment the following line to enable the deprecated Fabric API modules. // These are included in the Fabric API production distribution and allow you to update your mod to the latest modules at a later more convenient time. - // modImplementation "net.fabricmc.fabric-api:fabric-api-deprecated:${project.fabric_version}" + // modImplementation "net.fabricmc.fabric-api:fabric-api-deprecated:${project.fabric_api_version}" - modClientCompileOnly "com.terraformersmc:modmenu:7.2.1" - - // WebP support for ImageIO, not fully working with animated WebP - modClientImplementation "com.twelvemonkeys.imageio:imageio-webp:3.9.4" + modClientCompileOnly "com.terraformersmc:modmenu:${project.modmenu_version}" // WebP support for animated WebP using official Google's native library - modClientImplementation "com.github.Vinrobot.WebPDecoderJN:lib:1.3" + modClientImplementation "com.github.Vinrobot.WebPDecoderJN:lib:${project.webpdecoderjn_version}" } processResources { diff --git a/gradle.properties b/gradle.properties index ada7418..0302ec2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,16 +2,20 @@ org.gradle.jvmargs=-Xmx1G org.gradle.parallel=true -# Fabric Properties -# check these on https://fabricmc.net/develop +# Minecraft Properties minecraft_version=1.20 yarn_mappings=1.20+build.1 -loader_version=0.14.21 # Mod Properties mod_version=1.1.0 maven_group=net.vinrobot archives_base_name=mcemote -# Dependencies -fabric_version=0.83.0+1.20 +# Fabric Properties +# check these on https://fabricmc.net/develop +fabric_loader_version=0.14.21 +fabric_api_version=0.83.0+1.20 + +# Dependencies versions +modmenu_version=7.2.1 +webpdecoderjn_version=1.4 diff --git a/src/client/java/net/vinrobot/mcemote/client/MinecraftEmoteModClient.java b/src/client/java/net/vinrobot/mcemote/client/MinecraftEmoteModClient.java index 7461014..a0a9cd7 100644 --- a/src/client/java/net/vinrobot/mcemote/client/MinecraftEmoteModClient.java +++ b/src/client/java/net/vinrobot/mcemote/client/MinecraftEmoteModClient.java @@ -9,9 +9,7 @@ import net.vinrobot.mcemote.client.text.EmotesManager; import net.vinrobot.mcemote.config.Configuration; import net.vinrobot.mcemote.config.ConfigurationManager; -import webpdecoderjn.WebPLoader; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.ServiceLoader; @@ -21,13 +19,6 @@ public class MinecraftEmoteModClient implements ClientModInitializer { @Override public void onInitializeClient() { - // This entrypoint is suitable for setting up client-specific logic, such as rendering. - try { - WebPLoader.init(); - } catch (IOException e) { - MinecraftEmoteMod.LOGGER.error("Failed to initialize WebPDecoder", e); - } - final ConfigurationManager configManager = MinecraftEmote.getInstance().getConfigManager(); configManager.onChange((config) -> { diff --git a/src/client/java/net/vinrobot/mcemote/client/font/AnimatedGlyph.java b/src/client/java/net/vinrobot/mcemote/client/font/AnimatedGlyph.java index 1016036..86acfad 100644 --- a/src/client/java/net/vinrobot/mcemote/client/font/AnimatedGlyph.java +++ b/src/client/java/net/vinrobot/mcemote/client/font/AnimatedGlyph.java @@ -1,9 +1,10 @@ package net.vinrobot.mcemote.client.font; +import net.minecraft.client.font.Glyph; + import java.time.Duration; import java.time.Instant; import java.util.stream.Stream; -import net.minecraft.client.font.Glyph; public class AnimatedGlyph { private final Frame[] frames; @@ -14,6 +15,7 @@ public AnimatedGlyph(Frame[] frames) { this.loopTime = Stream.of(frames) .map(Frame::duration) .reduce(Duration::plus) + .filter(d -> !d.isZero()) .orElse(Duration.ofDays(1)); } @@ -23,6 +25,10 @@ private static Duration modulo(Instant a, Duration b) { } public Glyph getGlyphAt(Instant at) { + if (this.frames.length == 1) { + return this.frames[0].image(); + } + final Duration time = modulo(at, loopTime); Duration current = Duration.ZERO; diff --git a/src/client/java/net/vinrobot/mcemote/client/font/Emote.java b/src/client/java/net/vinrobot/mcemote/client/font/Emote.java index 8307971..5468c79 100644 --- a/src/client/java/net/vinrobot/mcemote/client/font/Emote.java +++ b/src/client/java/net/vinrobot/mcemote/client/font/Emote.java @@ -1,9 +1,8 @@ package net.vinrobot.mcemote.client.font; -import net.minecraft.client.texture.NativeImage; +import net.vinrobot.mcemote.client.imageio.NativeFrame; import java.io.IOException; -import java.time.Duration; public interface Emote { String getName(); @@ -12,11 +11,5 @@ public interface Emote { int getHeight(); - Frame[] loadFrames() throws IOException; - - record Frame(NativeImage image, Duration duration) { - public Frame(NativeImage image) { - this(image, Duration.ofDays(1)); - } - } + NativeFrame[] loadFrames() throws IOException; } diff --git a/src/client/java/net/vinrobot/mcemote/client/font/EmoteFontStorage.java b/src/client/java/net/vinrobot/mcemote/client/font/EmoteFontStorage.java index 2e2bb07..83b3eb5 100644 --- a/src/client/java/net/vinrobot/mcemote/client/font/EmoteFontStorage.java +++ b/src/client/java/net/vinrobot/mcemote/client/font/EmoteFontStorage.java @@ -1,13 +1,5 @@ package net.vinrobot.mcemote.client.font; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.minecraft.client.font.BuiltinEmptyGlyph; @@ -18,8 +10,18 @@ import net.minecraft.util.Identifier; import net.vinrobot.mcemote.MinecraftEmoteMod; import net.vinrobot.mcemote.client.helpers.FutureHelper; +import net.vinrobot.mcemote.client.imageio.NativeFrame; import net.vinrobot.mcemote.client.text.EmotesManager; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + @Environment(EnvType.CLIENT) public class EmoteFontStorage extends FontStorage { public static final Identifier IDENTIFIER = new Identifier("mcemote.fonts", "emotes"); @@ -58,11 +60,11 @@ private AnimatedGlyph loadAnimatedGlyph(int codePoint) { final int height = emote.getHeight(); final float advance = width * GLYPH_HEIGHT / height; final float oversample = height / GLYPH_HEIGHT; - final Emote.Frame[] frames = emote.loadFrames(); + final NativeFrame[] frames = emote.loadFrames(); final AnimatedGlyph.Frame[] animatedFrames = new AnimatedGlyph.Frame[frames.length]; for (int i = 0; i < frames.length; i++) { - final Emote.Frame frame = frames[i]; + final NativeFrame frame = frames[i]; final Glyph glyph = new NativeImageGlyph(frame.image(), advance, oversample); animatedFrames[i] = new AnimatedGlyph.Frame(glyph, frame.duration()); } diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/BufferedFrame.java b/src/client/java/net/vinrobot/mcemote/client/imageio/BufferedFrame.java new file mode 100644 index 0000000..9ab383b --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/BufferedFrame.java @@ -0,0 +1,13 @@ +package net.vinrobot.mcemote.client.imageio; + +import java.awt.image.BufferedImage; +import java.time.Duration; + +public record BufferedFrame( + BufferedImage image, + Duration duration +) { + NativeFrame toNativeFrame() { + return new NativeFrame(NativeImageHelper.fromBufferedImage(image), duration); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/NativeFrame.java b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeFrame.java new file mode 100644 index 0000000..2bdbbed --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeFrame.java @@ -0,0 +1,11 @@ +package net.vinrobot.mcemote.client.imageio; + +import net.minecraft.client.texture.NativeImage; + +import java.time.Duration; + +public record NativeFrame( + NativeImage image, + Duration duration +) { +} diff --git a/src/client/java/net/vinrobot/mcemote/client/helpers/NativeImageHelper.java b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageHelper.java similarity index 76% rename from src/client/java/net/vinrobot/mcemote/client/helpers/NativeImageHelper.java rename to src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageHelper.java index 1def093..705043a 100644 --- a/src/client/java/net/vinrobot/mcemote/client/helpers/NativeImageHelper.java +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageHelper.java @@ -1,4 +1,4 @@ -package net.vinrobot.mcemote.client.helpers; +package net.vinrobot.mcemote.client.imageio; import net.minecraft.client.texture.NativeImage; @@ -6,7 +6,7 @@ import java.awt.image.Raster; public final class NativeImageHelper { - public static NativeImage fromBufferedImage(BufferedImage bufferedImage) throws UnsupportedOperationException { + public static NativeImage fromBufferedImage(final BufferedImage bufferedImage) throws UnsupportedOperationException { final Raster raster = bufferedImage.getTile(0, 0); final NativeImage.Format imageFormat = switch (raster.getNumBands()) { @@ -20,10 +20,10 @@ public static NativeImage fromBufferedImage(BufferedImage bufferedImage) throws final NativeImage nativeImage = new NativeImage(imageFormat, width, height, false); // PERF: find a way to transfer directly - int[] pixel = new int[]{0, 0, 0, 255/*ALPHA*/}; + final int[] pixel = new int[]{0, 0, 0, 255/*ALPHA*/}; for (int u = 0; u < height; ++u) { for (int v = 0; v < width; ++v) { - pixel = raster.getPixel(v, u, pixel); + raster.getPixel(v, u, pixel); nativeImage.setColor(v, u, pixel[0] | (pixel[1] << 8) | (pixel[2] << 16) | (pixel[3] << 24)); } } diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageIO.java b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageIO.java new file mode 100644 index 0000000..b69e16b --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/NativeImageIO.java @@ -0,0 +1,46 @@ +package net.vinrobot.mcemote.client.imageio; + +import net.vinrobot.mcemote.client.imageio.plugins.gif.GifReader; +import net.vinrobot.mcemote.client.imageio.plugins.webp.WebPReader; + +import javax.imageio.IIOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.time.Duration; +import java.util.List; + +public class NativeImageIO { + public static NativeFrame[] readAll(final URL url) throws IOException { + try (final InputStream input = url.openStream()) { + return readAll(input); + } + } + + public static NativeFrame[] readAll(final InputStream input) throws IOException { + return readBufferedFrames(input).stream().map(BufferedFrame::toNativeFrame).toArray(NativeFrame[]::new); + } + + private static List readBufferedFrames(final InputStream input) throws IOException { + try (final ImageInputStream stream = ImageIO.createImageInputStream(input)) { + if (stream == null) { + throw new IIOException("Can't create an ImageInputStream!"); + } + + final ImageReader reader = ImageIO.getImageReaders(stream).next(); + try { + reader.setInput(stream, true, false); + return switch (reader.getFormatName()) { + case "gif" -> GifReader.readFrames(reader); + case "webp" -> WebPReader.readFrames(reader); + default -> List.of(new BufferedFrame(reader.read(0), Duration.ofDays(1))); + }; + } finally { + reader.dispose(); + } + } + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/DisposalMethod.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/DisposalMethod.java new file mode 100644 index 0000000..f90b487 --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/DisposalMethod.java @@ -0,0 +1,24 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +public enum DisposalMethod { + RESTORE_TO_BACKGROUND("restoreToBackgroundColor"), + RESTORE_TO_PREVIOUS("restoreToPrevious"), + DO_NOT_DISPOSE("doNotDispose"), + NONE("none"), + UNSPECIFIED(null); + + private final String identifier; + + DisposalMethod(final String identifier) { + this.identifier = identifier; + } + + public static DisposalMethod getByIdentifier(String identifier) { + for (DisposalMethod method : values()) { + if (method.identifier.equals(identifier)) { + return method; + } + } + return UNSPECIFIED; + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GifReader.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GifReader.java new file mode 100644 index 0000000..a662dca --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GifReader.java @@ -0,0 +1,109 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import net.vinrobot.mcemote.client.imageio.BufferedFrame; +import org.w3c.dom.Node; + +import javax.imageio.ImageReader; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.WritableRaster; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Reads GIF frames. + * + * @see GIF Metadata Format Specification + */ +public final class GifReader { + public static final String IMAGEIO_GIF_STREAM_METADATA_FORMAT = "javax_imageio_gif_stream_1.0"; + public static final String IMAGEIO_GIF_IMAGE_METADATA_FORMAT = "javax_imageio_gif_image_1.0"; + public static final Color DEFAULT_BACKGROUND_COLOR = new Color(0, 0, 0, 0); // Transparent + + public static List readFrames(final ImageReader reader) throws IOException { + final List frames = new ArrayList<>(); + + int width = -1, height = -1; + Color backgroundColor = DEFAULT_BACKGROUND_COLOR; + + final Node streamMetadata = reader.getStreamMetadata().getAsTree(IMAGEIO_GIF_STREAM_METADATA_FORMAT); + for (final Node nodeItem : NodeHelper.getChildren(streamMetadata)) { + switch (nodeItem.getNodeName()) { + case "LogicalScreenDescriptor": + LogicalScreenDescriptor logicalScreenDescriptor = LogicalScreenDescriptor.parseNode(nodeItem); + width = logicalScreenDescriptor.logicalScreenWidth(); + height = logicalScreenDescriptor.logicalScreenHeight(); + break; + case "GlobalColorTable": + backgroundColor = GlobalColorTable.parseBackgroundColor(nodeItem, DEFAULT_BACKGROUND_COLOR); + break; + } + } + + BufferedImage master = null; + Graphics2D masterGraphics = null; + + for (int frameIndex = 0; ; ++frameIndex) { + final BufferedImage image; + try { + image = reader.read(frameIndex); + } catch (final IndexOutOfBoundsException io) { + break; + } + + if (master == null) { + if (width == -1 || height == -1) { + width = image.getWidth(); + height = image.getHeight(); + } + + master = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + masterGraphics = master.createGraphics(); + masterGraphics.setBackground(backgroundColor); + } + + int x = 0, y = 0; + GraphicControlExtension graphicControlExtension = null; + + final Node imageMetadata = reader.getImageMetadata(frameIndex).getAsTree(IMAGEIO_GIF_IMAGE_METADATA_FORMAT); + for (final Node nodeItem : NodeHelper.getChildren(imageMetadata)) { + switch (nodeItem.getNodeName()) { + case "ImageDescriptor": + ImageDescriptor imageDescriptor = ImageDescriptor.parseNode(nodeItem); + x = imageDescriptor.imageLeftPosition(); + y = imageDescriptor.imageTopPosition(); + break; + case "GraphicControlExtension": + graphicControlExtension = GraphicControlExtension.parseNode(nodeItem); + break; + } + } + + masterGraphics.drawImage(image, x, y, null); + frames.add(new BufferedFrame(copy(master), graphicControlExtension.delayTime())); + + switch (graphicControlExtension.disposalMethod()) { + case RESTORE_TO_BACKGROUND: + masterGraphics.clearRect(x, y, image.getWidth(), image.getHeight()); + break; + case RESTORE_TO_PREVIOUS: + master = copy(frames.get(frameIndex - 1).image()); + masterGraphics = master.createGraphics(); + masterGraphics.setBackground(backgroundColor); + break; + } + } + + return frames; + } + + private static BufferedImage copy(final BufferedImage source) { + final ColorModel model = source.getColorModel(); + final WritableRaster raster = source.copyData(null); + final boolean alpha = source.isAlphaPremultiplied(); + return new BufferedImage(model, raster, alpha, null); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GlobalColorTable.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GlobalColorTable.java new file mode 100644 index 0000000..9d2e46c --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GlobalColorTable.java @@ -0,0 +1,27 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.awt.Color; + +public final class GlobalColorTable { + public static Color parseBackgroundColor(Node node, Color defaultColor) { + final NamedNodeMap attr = node.getAttributes(); + final int backgroundColorIndex = NodeHelper.getIntValue(attr.getNamedItem("backgroundColorIndex")); + + final NodeList colorNodes = node.getChildNodes(); + for (int i = 0, l = colorNodes.getLength(); i < l; ++i) { + final NamedNodeMap colorAttr = colorNodes.item(i).getAttributes(); + final int index = NodeHelper.getIntValue(colorAttr.getNamedItem("index")); + if (index == backgroundColorIndex) { + final int red = NodeHelper.getIntValue(colorAttr.getNamedItem("red")); + final int green = NodeHelper.getIntValue(colorAttr.getNamedItem("green")); + final int blue = NodeHelper.getIntValue(colorAttr.getNamedItem("blue")); + return new Color(red, green, blue); + } + } + return defaultColor; + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GraphicControlExtension.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GraphicControlExtension.java new file mode 100644 index 0000000..39d1bf1 --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/GraphicControlExtension.java @@ -0,0 +1,18 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.time.Duration; + +public record GraphicControlExtension( + DisposalMethod disposalMethod, + Duration delayTime +) { + public static GraphicControlExtension parseNode(Node node) { + final NamedNodeMap attr = node.getAttributes(); + final DisposalMethod disposalMethod = DisposalMethod.getByIdentifier(NodeHelper.getNodeValue(attr.getNamedItem("disposalMethod"))); + final Duration delayTime = Duration.ofMillis(NodeHelper.getIntValue(attr.getNamedItem("delayTime")) * 10L); + return new GraphicControlExtension(disposalMethod, delayTime); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/ImageDescriptor.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/ImageDescriptor.java new file mode 100644 index 0000000..b026d37 --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/ImageDescriptor.java @@ -0,0 +1,16 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +public record ImageDescriptor( + int imageLeftPosition, + int imageTopPosition +) { + public static ImageDescriptor parseNode(final Node node) { + final NamedNodeMap attr = node.getAttributes(); + final int imageLeftPosition = NodeHelper.getIntValue(attr.getNamedItem("imageLeftPosition")); + final int imageTopPosition = NodeHelper.getIntValue(attr.getNamedItem("imageTopPosition")); + return new ImageDescriptor(imageLeftPosition, imageTopPosition); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/LogicalScreenDescriptor.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/LogicalScreenDescriptor.java new file mode 100644 index 0000000..c8ea3ed --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/LogicalScreenDescriptor.java @@ -0,0 +1,16 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +public record LogicalScreenDescriptor( + int logicalScreenWidth, + int logicalScreenHeight +) { + public static LogicalScreenDescriptor parseNode(final Node node) { + final NamedNodeMap attr = node.getAttributes(); + final int logicalScreenWidth = NodeHelper.getIntValue(attr.getNamedItem("logicalScreenWidth")); + final int logicalScreenHeight = NodeHelper.getIntValue(attr.getNamedItem("logicalScreenHeight")); + return new LogicalScreenDescriptor(logicalScreenWidth, logicalScreenHeight); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/NodeHelper.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/NodeHelper.java new file mode 100644 index 0000000..07a588b --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/gif/NodeHelper.java @@ -0,0 +1,47 @@ +package net.vinrobot.mcemote.client.imageio.plugins.gif; + +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +class NodeHelper { + public static String getNodeValue(Node node) { + return node == null ? null : node.getNodeValue(); + } + + public static int getIntValue(Node node) { + return node == null ? 0 : getIntValue(node.getNodeValue()); + } + + public static boolean getBooleanValue(Node node) { + return node != null && getBooleanValue(node.getNodeValue()); + } + + public static int getIntValue(String value) { + return value == null ? 0 : Integer.parseInt(value); + } + + public static boolean getBooleanValue(String value) { + return value != null && Boolean.parseBoolean(value); + } + + public static Iterable getChildren(final Node node) { + final NodeList nodeList = node.getChildNodes(); + return () -> new Iterator<>() { + private int index = 0; + + @Override + public boolean hasNext() { + return index < nodeList.getLength(); + } + + @Override + public Node next() { + if (!hasNext()) throw new NoSuchElementException(); + return nodeList.item(index++); + } + }; + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/ImageMetadata.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/ImageMetadata.java new file mode 100644 index 0000000..795ebe8 --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/ImageMetadata.java @@ -0,0 +1,14 @@ +package net.vinrobot.mcemote.client.imageio.plugins.webp; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +public record ImageMetadata( + int delay +) { + public static ImageMetadata parseNode(final Node node) { + final NamedNodeMap attr = node.getAttributes(); + final int duration = Integer.parseInt(attr.getNamedItem("Delay").getNodeValue()); + return new ImageMetadata(duration); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/WebPReader.java b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/WebPReader.java new file mode 100644 index 0000000..f5dbf97 --- /dev/null +++ b/src/client/java/net/vinrobot/mcemote/client/imageio/plugins/webp/WebPReader.java @@ -0,0 +1,41 @@ +package net.vinrobot.mcemote.client.imageio.plugins.webp; + +import net.vinrobot.mcemote.client.imageio.BufferedFrame; +import org.w3c.dom.Node; + +import javax.imageio.ImageReader; +import javax.imageio.metadata.IIOMetadata; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +public final class WebPReader { + public static final String IMAGEIO_WEBP_IMAGE_METADATA_FORMAT = "net_vinrobot_imageio_webp_image_1.0"; + + public static List readFrames(ImageReader reader) throws IOException { + final List frames = new ArrayList<>(); + for (int frameIndex = 0; ; ++frameIndex) { + final BufferedImage frame; + try { + frame = reader.read(frameIndex); + } catch (IndexOutOfBoundsException e) { + break; + } + + final IIOMetadata imageMetadata = reader.getImageMetadata(frameIndex); + final Duration delay = getDuration(imageMetadata); + + frames.add(new BufferedFrame(frame, delay)); + } + + return frames; + } + + private static Duration getDuration(final IIOMetadata metadata) { + final Node metadataNode = metadata.getAsTree(IMAGEIO_WEBP_IMAGE_METADATA_FORMAT); + final ImageMetadata imageMetadata = ImageMetadata.parseNode(metadataNode); + return Duration.ofMillis(imageMetadata.delay()); + } +} diff --git a/src/client/java/net/vinrobot/mcemote/client/providers/BTTVEmote.java b/src/client/java/net/vinrobot/mcemote/client/providers/BTTVEmote.java index 0d5c363..d94d47b 100644 --- a/src/client/java/net/vinrobot/mcemote/client/providers/BTTVEmote.java +++ b/src/client/java/net/vinrobot/mcemote/client/providers/BTTVEmote.java @@ -1,16 +1,13 @@ package net.vinrobot.mcemote.client.providers; -import net.minecraft.client.texture.NativeImage; import net.vinrobot.mcemote.api.bttv.Emote; -import net.vinrobot.mcemote.client.helpers.NativeImageHelper; +import net.vinrobot.mcemote.client.imageio.NativeFrame; +import net.vinrobot.mcemote.client.imageio.NativeImageIO; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; import java.io.IOException; import java.net.URL; -import java.util.Objects; public class BTTVEmote implements net.vinrobot.mcemote.client.font.Emote { private static final Logger LOGGER = LoggerFactory.getLogger(BTTVEmote.class); @@ -40,23 +37,7 @@ public int getHeight() { } @Override - public Frame[] loadFrames() throws IOException { - if (this.emote.animated()) { - LOGGER.error("Animated BTTV emotes are not supported yet."); - } - - final URL url = new URL("https://cdn.betterttv.net/emote/" + this.emote.id() + "/1x"); - final BufferedImage image = Objects.requireNonNull(ImageIO.read(url)); - - final int expectedWidth = getWidth(), expectedHeight = getHeight(); - final int actualWidth = image.getWidth(), actualHeight = image.getHeight(); - if (expectedWidth != actualWidth || expectedHeight != actualHeight) { - final String expectedSize = expectedWidth + "x" + expectedHeight; - final String actualSize = actualWidth + "x" + actualHeight; - LOGGER.error("BTTV emote " + getName() + " has unexpected size " + actualSize + " (expected " + expectedSize + ")."); - } - - final NativeImage nativeImage = NativeImageHelper.fromBufferedImage(image); - return new Frame[]{new Frame(nativeImage)}; + public NativeFrame[] loadFrames() throws IOException { + return NativeImageIO.readAll(new URL("https://cdn.betterttv.net/emote/" + this.emote.id() + "/1x")); } } diff --git a/src/client/java/net/vinrobot/mcemote/client/providers/FFZEmote.java b/src/client/java/net/vinrobot/mcemote/client/providers/FFZEmote.java index 438ce61..4a6d69a 100644 --- a/src/client/java/net/vinrobot/mcemote/client/providers/FFZEmote.java +++ b/src/client/java/net/vinrobot/mcemote/client/providers/FFZEmote.java @@ -2,17 +2,14 @@ import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; -import net.minecraft.client.texture.NativeImage; import net.vinrobot.mcemote.api.ffz.Emoticon; import net.vinrobot.mcemote.client.font.Emote; -import net.vinrobot.mcemote.client.helpers.NativeImageHelper; +import net.vinrobot.mcemote.client.imageio.NativeFrame; +import net.vinrobot.mcemote.client.imageio.NativeImageIO; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; import java.io.IOException; import java.net.URL; import java.util.Map; -import java.util.Objects; @Environment(EnvType.CLIENT) public class FFZEmote implements Emote { @@ -38,11 +35,9 @@ public int getHeight() { } @Override - public Frame[] loadFrames() throws IOException { + public NativeFrame[] loadFrames() throws IOException { final Map urls = this.emoticon.urls(); final String url = urls.containsKey("1") ? urls.get("1") : urls.values().iterator().next(); - final BufferedImage image = Objects.requireNonNull(ImageIO.read(new URL(url))); - final NativeImage nativeImage = NativeImageHelper.fromBufferedImage(image); - return new Frame[]{new Frame(nativeImage)}; + return NativeImageIO.readAll(new URL(url)); } } diff --git a/src/client/java/net/vinrobot/mcemote/client/providers/SevenTVEmote.java b/src/client/java/net/vinrobot/mcemote/client/providers/SevenTVEmote.java index cdf3a00..d5df5f4 100644 --- a/src/client/java/net/vinrobot/mcemote/client/providers/SevenTVEmote.java +++ b/src/client/java/net/vinrobot/mcemote/client/providers/SevenTVEmote.java @@ -2,23 +2,16 @@ import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; -import net.minecraft.client.texture.NativeImage; import net.vinrobot.mcemote.api.seventv.Emote; import net.vinrobot.mcemote.api.seventv.EmoteData; import net.vinrobot.mcemote.api.seventv.EmoteFile; import net.vinrobot.mcemote.api.seventv.EmoteHost; -import net.vinrobot.mcemote.client.helpers.NativeImageHelper; -import webpdecoderjn.WebPDecoder; -import webpdecoderjn.WebPImage; -import webpdecoderjn.WebPImageFrame; +import net.vinrobot.mcemote.client.imageio.NativeFrame; +import net.vinrobot.mcemote.client.imageio.NativeImageIO; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; import java.io.IOException; import java.net.URL; -import java.time.Duration; import java.util.Comparator; -import java.util.Objects; @Environment(EnvType.CLIENT) public class SevenTVEmote implements net.vinrobot.mcemote.client.font.Emote { @@ -53,26 +46,11 @@ public int getHeight() { } @Override - public Frame[] loadFrames() throws IOException { + public NativeFrame[] loadFrames() throws IOException { final EmoteData data = this.emote.data(); final EmoteHost host = data.host(); final EmoteFile file = getFile(); final String url = "https:" + host.url() + "/" + file.name(); - if (data.animated()) { - final WebPImage image = WebPDecoder.decode(new URL(url)); - - final int frameCount = image.frames.size(); - final Frame[] frames = new Frame[frameCount]; - for (int i = 0; i < frameCount; ++i) { - final WebPImageFrame frame = image.frames.get(i); - final NativeImage nativeImage = NativeImageHelper.fromBufferedImage(frame.img); - frames[i] = new Frame(nativeImage, Duration.ofMillis(frame.delay)); - } - return frames; - } else { - final BufferedImage image = Objects.requireNonNull(ImageIO.read(new URL(url))); - final NativeImage nativeImage = NativeImageHelper.fromBufferedImage(image); - return new Frame[]{new Frame(nativeImage)}; - } + return NativeImageIO.readAll(new URL(url)); } }