add head endpoint and finish the body renderer
All checks were successful
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Successful in 1m28s

This commit is contained in:
Lee 2024-04-11 06:10:37 +01:00
parent 1a74b0099b
commit f63d1cc3ec
8 changed files with 230 additions and 64 deletions

@ -24,4 +24,18 @@ public class ImageUtils {
graphics.dispose();
return scaled;
}
/**
* Flip an image.
*
* @param src the source image
* @return the flipped image
*/
public static BufferedImage flip(@NotNull final BufferedImage src) {
BufferedImage flipped = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = flipped.createGraphics();
graphics.drawImage(src, src.getWidth(), 0, 0, src.getHeight(), 0, 0, src.getWidth(), src.getHeight(), null);
graphics.dispose();
return flipped;
}
}

@ -27,8 +27,8 @@ public final class CachedPlayer extends Player implements Serializable {
*/
private long cached;
public CachedPlayer(UUID uniqueId, String username, Skin skin, Cape cape, MojangProfile.ProfileProperty[] rawProperties, long cached) {
super(uniqueId, username, skin, cape, rawProperties);
public CachedPlayer(UUID uniqueId, String trimmedUniqueId, String username, Skin skin, Cape cape, MojangProfile.ProfileProperty[] rawProperties, long cached) {
super(uniqueId, trimmedUniqueId, username, skin, cape, rawProperties);
this.cached = cached;
}
}

@ -4,7 +4,8 @@ import cc.fascinated.common.PlayerUtils;
import cc.fascinated.config.Config;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.service.skin.SkinRenderer;
import cc.fascinated.service.skin.impl.SquareRenderer;
import cc.fascinated.service.skin.impl.BodyRenderer;
import cc.fascinated.service.skin.impl.HeadRenderer;
import cc.fascinated.service.skin.impl.IsometricHeadRenderer;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -14,6 +15,9 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
@ -36,6 +40,11 @@ public class Skin {
*/
private Model model;
/**
* The legacy status of the skin
*/
private boolean isLegacy = false;
/**
* The skin image for the skin
*/
@ -53,6 +62,14 @@ public class Skin {
this.model = model;
this.skinImage = PlayerUtils.getSkinImage(url);
if (this.skinImage != null) {
try {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(this.skinImage));
if (image.getWidth() == 64 && image.getHeight() == 32) { // Using the old skin format
this.isLegacy = true;
}
} catch (Exception ignored) {}
}
}
/**
@ -91,17 +108,14 @@ public class Skin {
*/
@Getter @AllArgsConstructor
public enum Parts {
FACE(new HeadRenderer()),
HEAD(new IsometricHeadRenderer()),
BODY(new BodyRenderer());
/**
* Head parts
* The skin part renderer for the part.
*/
HEAD(new SquareRenderer(8, 8, 8)),
HEAD_ISOMETRIC(new IsometricHeadRenderer());
/**
* The skin part parser for the part.
*/
private final SkinRenderer skinPartParser;
private final SkinRenderer skinRenderer;
/**
* Gets the name of the part.
@ -129,6 +143,77 @@ public class Skin {
}
}
@AllArgsConstructor @Getter
public enum PartPosition {
/**
* Skin postions
*/
HEAD(8, 8, 8, 8, new LegacyPartPositionData(8, 8, false)),
HEAD_TOP(8, 0, 8, 8, null),
HEAD_FRONT(8, 8, 8, 8, null),
HEAD_RIGHT(0, 8, 8, 8, null),
BODY(20, 20, 8, 12, new LegacyPartPositionData(20, 20, false)),
BODY_BACK(20, 36, 8, 12, null),
BODY_LEFT(32, 52, 8, 12, null),
BODY_RIGHT(44, 20, 8, 12, null),
RIGHT_ARM(44, 20, 4, 12, new LegacyPartPositionData(44, 20, false)),
LEFT_ARM(36, 52, 4, 12, new LegacyPartPositionData(43, 20, true)),
RIGHT_LEG(4, 20, 4, 12, new LegacyPartPositionData(4, 20, false)),
LEFT_LEG(20, 52, 4, 12, new LegacyPartPositionData(3, 20, true)),
/**
* Skin overlay (layer) positions
*/
HEAD_OVERLAY(40, 8, 8, 8, null),
// todo: finish these below
HEAD_OVERLAY_TOP(40, 0, 40, 0, null),
HEAD_OVERLAY_FRONT(40, 8, 40, 8, null),
HEAD_OVERLAY_RIGHT(32, 8, 32, 8, null),
BODY_OVERLAY(20, 32, 20, 12, null),
BODY_OVERLAY_BACK(20, 36, 20, 12, null),
BODY_OVERLAY_LEFT(32, 52, 32, 12, null),
BODY_OVERLAY_RIGHT(44, 32, 44, 12, null),
RIGHT_ARM_OVERLAY(44, 32, 44, 12, null),
LEFT_ARM_OVERLAY(36, 52, 36, 12, null),
RIGHT_LEG_OVERLAY(4, 32, 4, 12, null),
LEFT_LEG_OVERLAY(20, 52, 20, 12, null);
/**
* The x, and y position of the part.
*/
private final int x, y;
/**
* The width and height of the part.
*/
private final int width, height;
/*
* The part position data for legacy skins.
* This can be null to use the default position.
*/
private final LegacyPartPositionData legacyData;
}
@AllArgsConstructor @Getter
public static class LegacyPartPositionData {
/**
* The x, and y position of the part.
*/
private int x, y;
/**
* Should the part be flipped?
*/
private boolean flipped;
}
/**
* The model of the skin.
*/

@ -71,6 +71,7 @@ public class PlayerService {
Tuple<Skin, Cape> skinAndCape = mojangProfile.getSkinAndCape();
CachedPlayer player = new CachedPlayer(
uuid, // Player UUID
UUIDUtils.removeDashes(uuid), // Trimmed UUID
mojangProfile.getName(), // Player Name
skinAndCape.getLeft(), // Skin
skinAndCape.getRight(), // Cape
@ -134,7 +135,7 @@ public class PlayerService {
}
long before = System.currentTimeMillis();
byte[] skinPartBytes = part.getSkinPartParser().renderPart(player.getSkin(), part.getName(), renderOverlay, size);
byte[] skinPartBytes = part.getSkinRenderer().renderPart(player.getSkin(), part.getName(), renderOverlay, size);
log.info("Took {}ms to render skin part {} for player: {}", System.currentTimeMillis() - before, part.getName(), player.getUniqueId());
CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart(
key,

@ -8,6 +8,7 @@ import lombok.Getter;
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;
@ -15,21 +16,47 @@ import java.io.ByteArrayOutputStream;
@AllArgsConstructor @Getter @Log4j2
public abstract class SkinRenderer {
/**
* Gets the skin image.
*
* @param skin the skin
* @return the skin image
*/
public BufferedImage getSkinImage(Skin skin) {
try {
return ImageIO.read(new ByteArrayInputStream(skin.getSkinImage()));
} catch (Exception ex) {
return null;
}
}
/**
* 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 position the part position information
* @param scale the scale
* @return the skin part image
*/
public BufferedImage getSkinPart(Skin skin, int x, int y, int width, int height, double scale) {
public BufferedImage getSkinPart(Skin skin, Skin.PartPosition position, double scale) {
try {
BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage()));
BufferedImage part = skinImage.getSubimage(x, y, width, height);
BufferedImage skinImage = this.getSkinImage(skin);
if (skinImage == null) {
return null;
}
BufferedImage part;
Skin.LegacyPartPositionData legacyData = position.getLegacyData();
if (skin.isLegacy() && legacyData != null) {
part = skinImage.getSubimage(legacyData.getX(), legacyData.getY(), position.getWidth(), position.getHeight());
if (legacyData.isFlipped()) {
part = ImageUtils.flip(part);
}
} else {
part = skinImage.getSubimage(position.getX(), position.getY(), position.getWidth(), position.getHeight());
}
if (part == null) {
return null;
}
return ImageUtils.resize(part, scale);
} catch (Exception ex) {
return null;
@ -56,6 +83,20 @@ public abstract class SkinRenderer {
}
}
/**
* Applies an overlay (skin layer) to the head part.
*
* @param graphics the graphics
* @param part the part
*/
public void applyOverlay(Graphics2D graphics, BufferedImage part) {
if (part == null) {
return;
}
graphics.drawImage(part, 0, 0, null);
graphics.dispose();
}
/**
* Renders a skin part.
*

@ -0,0 +1,55 @@
package cc.fascinated.service.skin.impl;
import cc.fascinated.common.ImageUtils;
import cc.fascinated.model.player.Skin;
import cc.fascinated.service.skin.SkinRenderer;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
@Getter @Log4j2
public class BodyRenderer extends SkinRenderer {
private static final int WIDTH = 16;
private static final int HEIGHT = 32;
@Override
public byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) {
log.info("Getting {} part bytes for {} with size {}", partName, skin.getUrl(), size);
try {
BufferedImage outputImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = outputImage.createGraphics();
// Get all the required body parts
BufferedImage head = this.getSkinPart(skin, Skin.PartPosition.HEAD, 1);
BufferedImage body = this.getSkinPart(skin, Skin.PartPosition.BODY, 1);
BufferedImage rightArm = this.getSkinPart(skin, Skin.PartPosition.RIGHT_ARM, 1);
BufferedImage leftArm = this.getSkinPart(skin, Skin.PartPosition.LEFT_ARM, 1);
BufferedImage rightLeg = this.getSkinPart(skin, Skin.PartPosition.RIGHT_LEG, 1);
BufferedImage leftLeg = this.getSkinPart(skin, Skin.PartPosition.LEFT_LEG, 1);
if (renderOverlay) { // Render the skin layers
Graphics2D overlayGraphics = head.createGraphics();
applyOverlay(overlayGraphics, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY, 1));
}
// Draw the body
graphics.drawImage(head, 4, 0, null);
graphics.drawImage(body, 4, 8, null);
graphics.drawImage(rightArm, 0, 8, null);
graphics.drawImage(leftArm, 12, 8, null);
graphics.drawImage(rightLeg, 4, 20, null);
graphics.drawImage(leftLeg, 8, 20, null);
graphics.dispose(); // Clean up
return super.getBytes(ImageUtils.resize(outputImage, (double) size / HEIGHT), skin, partName);
} catch (Exception ex) {
log.error("Failed to get {} part bytes for {}", partName, skin.getUrl(), ex);
throw new RuntimeException("Failed to get " + partName + " part for " + skin.getUrl());
}
}
}

@ -10,34 +10,11 @@ import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
@Getter @Log4j2
public class SquareRenderer extends SkinRenderer {
/**
* 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 SquareRenderer}.
*
* @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 SquareRenderer(int x, int y, int widthAndHeight) {
this.x = x;
this.y = y;
this.widthAndHeight = widthAndHeight;
}
public class HeadRenderer extends SkinRenderer {
@Override
public byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) {
double scale = (double) size / this.widthAndHeight;
double scale = (double) size / 8d;
log.info("Getting {} part bytes for {} with size {} and scale {}", partName, skin.getUrl(), size, scale);
try {
@ -45,7 +22,11 @@ public class SquareRenderer extends SkinRenderer {
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);
graphics.drawImage(this.getSkinPart(skin, Skin.PartPosition.HEAD, 1), 0, 0, null);
if (renderOverlay) { // Render the skin layers
applyOverlay(outputImage.createGraphics(), this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY, 1));
}
return super.getBytes(outputImage, skin, partName);
} catch (Exception ex) {

@ -36,19 +36,19 @@ public class IsometricHeadRenderer extends SkinRenderer {
Graphics2D graphics = outputImage.createGraphics();
// Get all the required head parts
BufferedImage headTop = ImageUtils.resize(this.getSkinPart(skin, 8, 0, 8, 8, 1), scale);
BufferedImage headFront = ImageUtils.resize(this.getSkinPart(skin, 8, 8, 8, 8, 1), scale);
BufferedImage headRight = ImageUtils.resize(this.getSkinPart(skin, 0, 8, 8, 8, 1), scale);
BufferedImage headTop = ImageUtils.resize(this.getSkinPart(skin, Skin.PartPosition.HEAD_TOP, 1), scale);
BufferedImage headFront = ImageUtils.resize(this.getSkinPart(skin, Skin.PartPosition.HEAD_FRONT, 1), scale);
BufferedImage headRight = ImageUtils.resize(this.getSkinPart(skin, Skin.PartPosition.HEAD_RIGHT, 1), scale);
if (renderOverlay) {
if (renderOverlay) { // Render the skin layers
Graphics2D headGraphics = headTop.createGraphics();
applyOverlay(headGraphics,this.getSkinPart(skin, 40, 0, 8, 8, 1));
applyOverlay(headGraphics, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY, 1));
headGraphics = headFront.createGraphics();
applyOverlay(headGraphics, this.getSkinPart(skin, 16, 8, 8, 8, 1));
applyOverlay(headGraphics, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_FRONT, 1));
headGraphics = headRight.createGraphics();
applyOverlay(headGraphics, this.getSkinPart(skin, 32, 8, 8, 8, 1));
applyOverlay(headGraphics, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_RIGHT, 1));
}
// Draw the head
@ -77,17 +77,6 @@ public class IsometricHeadRenderer extends SkinRenderer {
}
}
/**
* Applies an overlay (skin layer) to the head part.
*
* @param graphics the graphics
* @param part the part
*/
private void applyOverlay(Graphics2D graphics, BufferedImage part) {
graphics.drawImage(part, 0, 0, null);
graphics.dispose();
}
/**
* Draws a part of the head.
*