From 8e5adf337adc505cc2195448c281351105872028 Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 11 Apr 2024 03:08:17 +0100 Subject: [PATCH] add isometric head renderer --- Dockerfile | 3 + src/main/java/cc.fascinated/Main.java | 1 - .../java/cc.fascinated/common/ImageUtils.java | 25 +++++ .../cc.fascinated/common/PlayerUtils.java | 40 ------- .../java/cc.fascinated/config/Config.java | 16 ++- .../controller/PlayerController.java | 4 +- .../model/mojang/MojangProfile.java | 9 +- .../java/cc.fascinated/model/player/Skin.java | 25 ++--- .../cc.fascinated/service/PlayerService.java | 12 ++- .../cc.fascinated/service/ServerService.java | 3 +- .../service/skin/SkinPartParser.java | 48 +++++++++ .../service/skin/impl/FlatParser.java | 66 ++++++++++++ .../skin/impl/IsometricHeadParser.java | 102 ++++++++++++++++++ 13 files changed, 284 insertions(+), 70 deletions(-) create mode 100644 src/main/java/cc.fascinated/common/ImageUtils.java create mode 100644 src/main/java/cc.fascinated/service/skin/SkinPartParser.java create mode 100644 src/main/java/cc.fascinated/service/skin/impl/FlatParser.java create mode 100644 src/main/java/cc.fascinated/service/skin/impl/IsometricHeadParser.java diff --git a/Dockerfile b/Dockerfile index f1a20de..c37e661 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,8 @@ RUN mvn package -q -DskipTests EXPOSE 80 ENV PORT=80 +# Indicate that we're running in production +ENV ENVIRONMENT=production + # Run the jar file CMD ["java", "-jar", "target/Minecraft-Utilities.jar"] \ No newline at end of file diff --git a/src/main/java/cc.fascinated/Main.java b/src/main/java/cc.fascinated/Main.java index 495a788..a55a529 100644 --- a/src/main/java/cc.fascinated/Main.java +++ b/src/main/java/cc.fascinated/Main.java @@ -8,7 +8,6 @@ import lombok.extern.log4j.Log4j2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; -import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; diff --git a/src/main/java/cc.fascinated/common/ImageUtils.java b/src/main/java/cc.fascinated/common/ImageUtils.java new file mode 100644 index 0000000..1a4342b --- /dev/null +++ b/src/main/java/cc.fascinated/common/ImageUtils.java @@ -0,0 +1,25 @@ +package cc.fascinated.common; + +import jakarta.validation.constraints.NotNull; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; + +public class ImageUtils { + + /** + * Resize an image. + * + * @param src the source image + * @param scale the scale factor + * @return the scaled image + */ + public static BufferedImage resize(@NotNull final BufferedImage src, final double scale) { + BufferedImage scaled = new BufferedImage((int) (src.getWidth() * scale), (int) (src.getHeight() * scale), BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = scaled.createGraphics(); + graphics.drawImage(src, AffineTransform.getScaleInstance(scale, scale), null); + graphics.dispose(); + return scaled; + } +} diff --git a/src/main/java/cc.fascinated/common/PlayerUtils.java b/src/main/java/cc.fascinated/common/PlayerUtils.java index 8020dda..b09fe6b 100644 --- a/src/main/java/cc.fascinated/common/PlayerUtils.java +++ b/src/main/java/cc.fascinated/common/PlayerUtils.java @@ -2,17 +2,11 @@ package cc.fascinated.common; import cc.fascinated.Main; import cc.fascinated.exception.impl.BadRequestException; -import cc.fascinated.model.player.Skin; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.SneakyThrows; import lombok.experimental.UtilityClass; import lombok.extern.log4j.Log4j2; -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -53,38 +47,4 @@ public class PlayerUtils { HttpResponse.BodyHandlers.ofByteArray()); return response.body(); } - - /** - * Gets the part data from the skin. - * - * @return the part data - */ - public static byte[] getSkinPartBytes(Skin skin, Skin.Parts part, int size) { - if (size <= 0) { - size = part.getDefaultSize(); - } - - try { - BufferedImage image = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); - if (image == null) { - image = ImageIO.read(new ByteArrayInputStream(Skin.DEFAULT_SKIN.getSkinImage())); // Fallback to the default skin - } - // Get the part of the image (e.g. the head) - BufferedImage partImage = image.getSubimage(part.getX(), part.getY(), part.getWidth(), part.getHeight()); - - // Scale the image - BufferedImage scaledImage = new BufferedImage(size, size, partImage.getType()); - Graphics2D graphics2D = scaledImage.createGraphics(); - graphics2D.drawImage(partImage, 0, 0, size, size, null); - graphics2D.dispose(); - partImage = scaledImage; - - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - ImageIO.write(partImage, "png", byteArrayOutputStream); - return byteArrayOutputStream.toByteArray(); - } catch (Exception ex) { - log.error("Failed to get {} part bytes for {}", part.name(), skin.getUrl(), ex); - return null; - } - } } diff --git a/src/main/java/cc.fascinated/config/Config.java b/src/main/java/cc.fascinated/config/Config.java index 4da4c21..e7af0d0 100644 --- a/src/main/java/cc.fascinated/config/Config.java +++ b/src/main/java/cc.fascinated/config/Config.java @@ -2,19 +2,33 @@ package cc.fascinated.config; import jakarta.annotation.PostConstruct; import lombok.Getter; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; -@Getter +@Getter @Log4j2 @Configuration public class Config { public static Config INSTANCE; + @Autowired + private Environment environment; + @Value("${public-url}") private String webPublicUrl; + /** + * Whether the server is in production mode. + */ + private boolean production = false; + @PostConstruct public void onInitialize() { INSTANCE = this; + String environmentProperty = environment.getProperty("ENVIRONMENT", "development"); + production = environmentProperty.equalsIgnoreCase("production"); // Set the production mode + log.info("Server is running in {} mode", production ? "production" : "development"); } } \ No newline at end of file diff --git a/src/main/java/cc.fascinated/controller/PlayerController.java b/src/main/java/cc.fascinated/controller/PlayerController.java index d8d7b95..992fade 100644 --- a/src/main/java/cc.fascinated/controller/PlayerController.java +++ b/src/main/java/cc.fascinated/controller/PlayerController.java @@ -13,7 +13,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; import java.util.concurrent.TimeUnit; @RestController @@ -50,6 +49,7 @@ public class PlayerController { @Parameter(description = "The part of the skin", example = "head") @PathVariable String part, @Parameter(description = "The UUID or Username of the player", example = "ImFascinated") @PathVariable String id, @Parameter(description = "The size of the image", example = "256") @RequestParam(required = false, defaultValue = "256") int size, + @Parameter(description = "Whether to render the skin overlay (skin layers)", example = "false") @RequestParam(required = false, defaultValue = "false") boolean renderOverlay, @Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) { CachedPlayer player = playerService.getPlayer(id); Skin.Parts skinPart = Skin.Parts.fromName(part); @@ -60,6 +60,6 @@ public class PlayerController { .cacheControl(cacheControl) .contentType(MediaType.IMAGE_PNG) .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(player.getUsername())) - .body(playerService.getSkinPart(player, skinPart, size).getBytes()); + .body(playerService.getSkinPart(player, skinPart, renderOverlay, size).getBytes()); } } diff --git a/src/main/java/cc.fascinated/model/mojang/MojangProfile.java b/src/main/java/cc.fascinated/model/mojang/MojangProfile.java index f147c74..2a17c40 100644 --- a/src/main/java/cc.fascinated/model/mojang/MojangProfile.java +++ b/src/main/java/cc.fascinated/model/mojang/MojangProfile.java @@ -41,10 +41,7 @@ public class MojangProfile { if (textureProperty == null) { return null; } - - JsonObject json = Main.GSON.fromJson(textureProperty.getDecodedValue(), JsonObject.class); // Decode the texture property - JsonObject texturesJson = json.getAsJsonObject("textures"); // Parse the decoded JSON and get the textures object - + JsonObject texturesJson = textureProperty.getDecodedValue().getAsJsonObject("textures"); // Parse the decoded JSON and get the texture object return new Tuple<>(Skin.fromJson(texturesJson.getAsJsonObject("SKIN")).populatePartUrls(this.getFormattedUuid()), Cape.fromJson(texturesJson.getAsJsonObject("CAPE"))); } @@ -95,8 +92,8 @@ public class MojangProfile { * @return the decoded value */ @JsonIgnore - public String getDecodedValue() { - return new String(Base64.getDecoder().decode(this.value)); + public JsonObject getDecodedValue() { + return Main.GSON.fromJson(new String(Base64.getDecoder().decode(this.value)), JsonObject.class); } /** diff --git a/src/main/java/cc.fascinated/model/player/Skin.java b/src/main/java/cc.fascinated/model/player/Skin.java index 8832da8..5211d0b 100644 --- a/src/main/java/cc.fascinated/model/player/Skin.java +++ b/src/main/java/cc.fascinated/model/player/Skin.java @@ -3,6 +3,9 @@ package cc.fascinated.model.player; import cc.fascinated.common.PlayerUtils; import cc.fascinated.config.Config; import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.service.skin.SkinPartParser; +import cc.fascinated.service.skin.impl.FlatParser; +import cc.fascinated.service.skin.impl.IsometricHeadParser; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.gson.JsonObject; @@ -77,7 +80,7 @@ public class Skin { public Skin populatePartUrls(String playerUuid) { for (Parts part : Parts.values()) { String partName = part.name().toLowerCase(); - this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid + "?size=" + part.getDefaultSize()); + this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid); } return this; } @@ -89,22 +92,16 @@ public class Skin { @Getter @AllArgsConstructor public enum Parts { - HEAD(8, 8, 8, 8, 256); + /** + * Head parts + */ + HEAD(new FlatParser(8, 8, 8)), + HEAD_ISOMETRIC(new IsometricHeadParser()); /** - * The x and y position of the part. + * The skin part parser for the part. */ - private final int x, y; - - /** - * The width and height of the part. - */ - private final int width, height; - - /** - * The scale of the part. - */ - private final int defaultSize; + private final SkinPartParser skinPartParser; /** * Gets the name of the part. diff --git a/src/main/java/cc.fascinated/service/PlayerService.java b/src/main/java/cc.fascinated/service/PlayerService.java index 84cabf1..e0407a5 100644 --- a/src/main/java/cc.fascinated/service/PlayerService.java +++ b/src/main/java/cc.fascinated/service/PlayerService.java @@ -3,6 +3,7 @@ package cc.fascinated.service; import cc.fascinated.common.PlayerUtils; import cc.fascinated.common.Tuple; import cc.fascinated.common.UUIDUtils; +import cc.fascinated.config.Config; import cc.fascinated.exception.impl.MojangAPIRateLimitException; import cc.fascinated.exception.impl.RateLimitException; import cc.fascinated.exception.impl.ResourceNotFoundException; @@ -58,7 +59,7 @@ public class PlayerService { } Optional cachedPlayer = playerCacheRepository.findById(uuid); - if (cachedPlayer.isPresent()) { // Return the cached player if it exists + if (cachedPlayer.isPresent() && Config.INSTANCE.isProduction()) { // Return the cached player if it exists log.info("Player {} is cached", originalId); return cachedPlayer.get(); } @@ -94,7 +95,7 @@ public class PlayerService { public CachedPlayerName usernameToUuid(String username) { log.info("Getting UUID from username: {}", username); Optional cachedPlayerName = playerNameCacheRepository.findById(username.toUpperCase()); - if (cachedPlayerName.isPresent()) { + if (cachedPlayerName.isPresent() && Config.INSTANCE.isProduction()) { return cachedPlayerName.get(); } try { @@ -118,20 +119,21 @@ public class PlayerService { * * @param player the player * @param part the part of the skin + * @param renderOverlay whether to render the overlay * @return the skin part */ - public CachedPlayerSkinPart getSkinPart(Player player, Skin.Parts part, int size) { + public CachedPlayerSkinPart getSkinPart(Player player, Skin.Parts part, boolean renderOverlay, int size) { log.info("Getting skin part: {} for player: {}", part.getName(), player.getUniqueId()); String key = "%s-%s-%s".formatted(player.getUniqueId(), part.getName(), size); Optional cache = playerSkinPartCacheRepository.findById(key); // The skin part is cached - if (cache.isPresent()) { + if (cache.isPresent() && Config.INSTANCE.isProduction()) { log.info("Skin part {} for player {} is cached", part.getName(), player.getUniqueId()); return cache.get(); } - byte[] skinPartBytes = PlayerUtils.getSkinPartBytes(player.getSkin(), part, size); + byte[] skinPartBytes = part.getSkinPartParser().getPart(player.getSkin(), part.getName(), renderOverlay, size); CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart( key, skinPartBytes diff --git a/src/main/java/cc.fascinated/service/ServerService.java b/src/main/java/cc.fascinated/service/ServerService.java index ccdc4a1..3dd7ff7 100644 --- a/src/main/java/cc.fascinated/service/ServerService.java +++ b/src/main/java/cc.fascinated/service/ServerService.java @@ -2,6 +2,7 @@ package cc.fascinated.service; import cc.fascinated.common.DNSUtils; import cc.fascinated.common.EnumUtils; +import cc.fascinated.config.Config; import cc.fascinated.exception.impl.BadRequestException; import cc.fascinated.exception.impl.ResourceNotFoundException; import cc.fascinated.model.cache.CachedMinecraftServer; @@ -63,7 +64,7 @@ public class ServerService { // Check if the server is cached Optional cached = serverCacheRepository.findById(key); - if (cached.isPresent()) { + if (cached.isPresent() && Config.INSTANCE.isProduction()) { log.info("Server {}:{} is cached", hostname, port); return cached.get(); } diff --git a/src/main/java/cc.fascinated/service/skin/SkinPartParser.java b/src/main/java/cc.fascinated/service/skin/SkinPartParser.java new file mode 100644 index 0000000..9ea16b9 --- /dev/null +++ b/src/main/java/cc.fascinated/service/skin/SkinPartParser.java @@ -0,0 +1,48 @@ +package cc.fascinated.service.skin; + +import cc.fascinated.common.ImageUtils; +import cc.fascinated.model.player.Skin; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.SneakyThrows; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +@AllArgsConstructor @Getter +public abstract class SkinPartParser { + + /** + * Gets the skin part image. + * + * @param skin the skin + * @param x the x position + * @param y the y position + * @param width the width + * @param height the height + * @param scale the scale + * @return the skin part image + */ + public BufferedImage getSkinPart(Skin skin, int x, int y, int width, int height, double scale) { + try { + BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); + BufferedImage part = skinImage.getSubimage(x, y, width, height); + return ImageUtils.resize(part, scale); + } catch (Exception ex) { + return null; + } + } + + /** + * Get the skin part image. + * + * @param skin the skin + * @param partName the skin part name + * @param renderOverlay whether to render the overlay + * @param size the output size + * @return the skin part image + */ + public abstract byte[] getPart(Skin skin, String partName, boolean renderOverlay, int size); +} diff --git a/src/main/java/cc.fascinated/service/skin/impl/FlatParser.java b/src/main/java/cc.fascinated/service/skin/impl/FlatParser.java new file mode 100644 index 0000000..d5aad07 --- /dev/null +++ b/src/main/java/cc.fascinated/service/skin/impl/FlatParser.java @@ -0,0 +1,66 @@ +package cc.fascinated.service.skin.impl; + +import cc.fascinated.common.ImageUtils; +import cc.fascinated.model.player.Skin; +import cc.fascinated.service.skin.SkinPartParser; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +@Getter @Log4j2 +public class FlatParser extends SkinPartParser { + + /** + * The x and y position of the part. + */ + private final int x, y; + + /** + * The width and height of the part. + */ + private final int widthAndHeight; + + /** + * Constructs a new {@link FlatParser}. + * + * @param x the x position of the part + * @param y the y position of the part + * @param widthAndHeight the width and height of the part + */ + public FlatParser(int x, int y, int widthAndHeight) { + this.x = x; + this.y = y; + this.widthAndHeight = widthAndHeight; + } + + @Override + public byte[] getPart(Skin skin, String partName, boolean renderOverlay, int size) { + double scale = (double) size / this.widthAndHeight; + log.info("Getting {} part bytes for {} with size {} and scale {}", partName, skin.getUrl(), size, scale); + + try { + BufferedImage outputImage = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = outputImage.createGraphics(); + + graphics.setTransform(AffineTransform.getScaleInstance(scale, scale)); + graphics.drawImage(this.getSkinPart(skin, this.x, this.y, this.widthAndHeight, this.widthAndHeight, 1), 0, 0, null); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(outputImage, "png", outputStream); + // Cleanup + outputStream.flush(); + graphics.dispose(); + log.info("Successfully got {} part bytes for {}", partName, skin.getUrl()); + return outputStream.toByteArray(); + } + } catch (Exception ex) { + log.error("Failed to get {} part bytes for {}", partName, skin.getUrl(), ex); + return null; + } + } +} diff --git a/src/main/java/cc.fascinated/service/skin/impl/IsometricHeadParser.java b/src/main/java/cc.fascinated/service/skin/impl/IsometricHeadParser.java new file mode 100644 index 0000000..15542f2 --- /dev/null +++ b/src/main/java/cc.fascinated/service/skin/impl/IsometricHeadParser.java @@ -0,0 +1,102 @@ +package cc.fascinated.service.skin.impl; + +import cc.fascinated.common.ImageUtils; +import cc.fascinated.model.player.Skin; +import cc.fascinated.service.skin.SkinPartParser; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +@Getter @Log4j2 +public class IsometricHeadParser extends SkinPartParser { + + private static final double SKEW_A = 26d / 45d; // 0.57777777 + private static final double SKEW_B = SKEW_A * 2d; // 1.15555555 + + @Override + public byte[] getPart(Skin skin, String partName, boolean renderOverlay, int size) { + double scale = (size / 8d) / 2.5; + log.info("Getting {} part bytes for {} with size {} and scale {}", partName, skin.getUrl(), size, scale); + + try { + final BufferedImage outputImage = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + + // Get all the required head parts + final BufferedImage headTop = ImageUtils.resize(this.getSkinPart(skin, 8, 0, 8, 8, 1), scale); + final BufferedImage headFront = ImageUtils.resize(this.getSkinPart(skin, 8, 8, 8, 8, 1), scale); + final BufferedImage headRight = ImageUtils.resize(this.getSkinPart(skin, 0, 8, 8, 8, 1), scale); + + if (renderOverlay) { + // Draw the overlay on top of the gathered skin parts + + // Top overlay + Graphics2D g = headTop.createGraphics(); + g.drawImage(this.getSkinPart(skin, 40, 0, 8, 8, 1), 0, 0, null); + g.dispose(); + + // Front overlay + g = headFront.createGraphics(); + g.drawImage(this.getSkinPart(skin, 16, 8, 8, 8, 1), 0, 0, null); + g.dispose(); + + // Right side overlay + g = headRight.createGraphics(); + g.drawImage(this.getSkinPart(skin, 32, 8, 8, 8, 1), 0, 0, null); + g.dispose(); + } + + // Declare pos + double x; + double y; + double z; + + // Declare offsets + final double z_offset = scale * 3.5d; + final double x_offset = scale * 2d; + + // Create graphics + final Graphics2D outGraphics = outputImage.createGraphics(); + + // head top + x = x_offset; + y = -0.5; + z = z_offset; + outGraphics.setTransform(new AffineTransform(1d, -SKEW_A, 1, SKEW_A, 0, 0)); + outGraphics.drawImage(headTop, (int) (y - z), (int) (x + z), headTop.getWidth(), headTop.getHeight() + 1, null); + + // head front + x = x_offset + 8 * scale; + y = 0; + z = z_offset - 0.5; + outGraphics.setTransform(new AffineTransform(1d, -SKEW_A, 0d, SKEW_B, 0d, SKEW_A)); + outGraphics.drawImage(headFront, (int) (y + x), (int) (x + z), headFront.getWidth(), headFront.getHeight(), null); + + // head right + x = x_offset; + y = 0; + z = z_offset; + outGraphics.setTransform(new AffineTransform(1d, SKEW_A, 0d, SKEW_B, 0d, 0d)); + outGraphics.drawImage(headRight, (int) (x + y + 1), (int) (z - y - 0.5), headRight.getWidth(), headRight.getHeight() + 1, null); + + // Cleanup and return + outGraphics.dispose(); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(outputImage, "png", outputStream); + // Cleanup + outputStream.flush(); + outGraphics.dispose(); + log.info("Successfully got {} part bytes for {}", partName, skin.getUrl()); + return outputStream.toByteArray(); + } + } catch (Exception ex) { + log.error("Failed to get {} part bytes for {}", partName, skin.getUrl(), ex); + return null; + } + } +}