From e3fa7428bf0ef82d75cfb9478956bc0645a26682 Mon Sep 17 00:00:00 2001 From: Haowei Wen Date: Wed, 17 Feb 2021 02:34:40 +0800 Subject: [PATCH] feat: automatically detect skin model when uploading --- .../hmcl/ui/account/AccountListItem.java | 55 ++++--- .../hmcl/ui/account/AccountListItemSkin.java | 22 ++- .../hmcl/ui/account/AddAccountPane.java | 3 + .../resources/assets/lang/I18N.properties | 5 +- .../resources/assets/lang/I18N_zh.properties | 5 +- .../assets/lang/I18N_zh_CN.properties | 5 +- .../hmcl/util/skin/InvalidSkinException.java | 35 ++++ .../hmcl/util/skin/NormalizedSkin.java | 152 ++++++++++++++++++ 8 files changed, 251 insertions(+), 31 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/InvalidSkinException.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/NormalizedSkin.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java index deda746bb..f8e4eb043 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java @@ -41,14 +41,21 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; -import org.jackhuang.hmcl.ui.construct.PromptDialogPane; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; +import org.jackhuang.hmcl.util.skin.NormalizedSkin; +import org.jetbrains.annotations.Nullable; +import java.awt.image.BufferedImage; import java.io.File; +import java.io.IOException; import java.util.Optional; import java.util.Set; import java.util.concurrent.CancellationException; import java.util.logging.Level; +import javax.imageio.ImageIO; + import static java.util.Collections.emptySet; import static javafx.beans.binding.Bindings.createBooleanBinding; import static org.jackhuang.hmcl.util.Logging.LOG; @@ -137,9 +144,13 @@ public class AccountListItem extends RadioButton { } } - public void uploadSkin() { + /** + * @return the skin upload task, null if no file is selected + */ + @Nullable + public Task uploadSkin() { if (!(account instanceof YggdrasilAccount)) { - return; + return null; } FileChooser chooser = new FileChooser(); @@ -147,23 +158,31 @@ public class AccountListItem extends RadioButton { chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); File selectedFile = chooser.showOpenDialog(Controllers.getStage()); if (selectedFile == null) { - return; + return null; } - Controllers.prompt(new PromptDialogPane.Builder(i18n("account.skin.upload"), (questions, resolve, reject) -> { - PromptDialogPane.Builder.CandidatesQuestion q = (PromptDialogPane.Builder.CandidatesQuestion) questions.get(0); - String model = q.getValue() == 0 ? "" : "slim"; - refreshAsync() - .thenRunAsync(() -> ((YggdrasilAccount) account).uploadSkin(model, selectedFile.toPath())) - .thenComposeAsync(this::refreshAsync) - .thenRunAsync(Schedulers.javafx(), resolve::run) - .whenComplete(Schedulers.javafx(), e -> { - if (e != null) { - reject.accept(AddAccountPane.accountException(e)); - } - }).start(); - }).addQuestion(new PromptDialogPane.Builder.CandidatesQuestion(i18n("account.skin.model"), - i18n("account.skin.model.default"), i18n("account.skin.model.slim")))); + return refreshAsync() + .thenRunAsync(() -> { + BufferedImage skinImg; + try { + skinImg = ImageIO.read(selectedFile); + } catch (IOException e) { + throw new InvalidSkinException("Failed to read skin image", e); + } + if (skinImg == null) { + throw new InvalidSkinException("Failed to read skin image"); + } + NormalizedSkin skin = new NormalizedSkin(skinImg); + String model = skin.isSlim() ? "slim" : ""; + LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]"); + ((YggdrasilAccount) account).uploadSkin(model, selectedFile.toPath()); + }) + .thenComposeAsync(refreshAsync()) + .whenComplete(Schedulers.javafx(), e -> { + if (e != null) { + Controllers.dialog(AddAccountPane.accountException(e), i18n("account.skin.upload.failed"), MessageType.ERROR); + } + }); } public void remove() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java index f11694e64..45785a01b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java @@ -31,8 +31,11 @@ import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.setting.Theme; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.util.javafx.BindingMapping; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; @@ -93,13 +96,24 @@ public class AccountListItemSkin extends SkinBase { right.getChildren().add(btnRefresh); JFXButton btnUpload = new JFXButton(); - btnUpload.setOnMouseClicked(e -> skinnable.uploadSkin()); + SpinnerPane spinnerUpload = new SpinnerPane(); + btnUpload.setOnMouseClicked(e -> { + Task uploadTask = skinnable.uploadSkin(); + if (uploadTask != null) { + spinnerUpload.showSpinner(); + uploadTask + .whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()) + .start(); + } + }); btnUpload.getStyleClass().add("toggle-icon4"); btnUpload.setGraphic(SVG.hanger(Theme.blackFillBinding(), -1, -1)); runInFX(() -> FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload"))); - btnUpload.managedProperty().bind(btnUpload.visibleProperty()); - btnUpload.visibleProperty().bind(skinnable.canUploadSkin()); - right.getChildren().add(btnUpload); + spinnerUpload.managedProperty().bind(spinnerUpload.visibleProperty()); + spinnerUpload.visibleProperty().bind(skinnable.canUploadSkin()); + spinnerUpload.setContent(btnUpload); + spinnerUpload.getStyleClass().add("small-spinner-pane"); + right.getChildren().add(spinnerUpload); JFXButton btnRemove = new JFXButton(); btnRemove.setOnMouseClicked(e -> skinnable.remove()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java index b408bc425..6b30d150f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAccountPane.java @@ -49,6 +49,7 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.javafx.BindingMapping; +import org.jackhuang.hmcl.util.skin.InvalidSkinException; import java.util.ArrayList; import java.util.List; @@ -342,6 +343,8 @@ public class AddAccountPane extends StackPane { return i18n("account.failed.injector_download_failure"); } else if (exception instanceof CharacterDeletedException) { return i18n("account.failed.character_deleted"); + } else if (exception instanceof InvalidSkinException) { + return i18n("account.skin.invalid_skin"); } else if (exception.getClass() == AuthenticationException.class) { return exception.getLocalizedMessage(); } else { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index b4f23f4ee..f32bb3ec6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -64,10 +64,9 @@ account.missing=No Account account.missing.add=Click here to add account.password=Password account.skin.file=Skin file -account.skin.model=Character model -account.skin.model.default=Steve -account.skin.model.slim=Alex account.skin.upload=Upload skin +account.skin.upload.failed=Failed to upload skin +account.skin.invalid_skin=Unrecognized skin file account.username=Name archive.author=Authors diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index a5509d81a..7c2b6fea4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -64,10 +64,9 @@ account.missing=沒有遊戲帳戶 account.missing.add=按一下此處加入帳戶 account.password=密碼 account.skin.file=皮膚圖片檔案 -account.skin.model=人物模型 -account.skin.model.default=Steve -account.skin.model.slim=Alex account.skin.upload=上傳皮膚 +account.skin.upload=皮膚上傳失敗 +account.skin.invalid_skin=無法識別的皮膚文件 account.username=使用者名稱 archive.author=作者 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 5a0345f79..004c72873 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -64,10 +64,9 @@ account.missing=没有游戏账户 account.missing.add=点击此处添加账户 account.password=密码 account.skin.file=皮肤图片文件 -account.skin.model=人物模型 -account.skin.model.default=Steve -account.skin.model.slim=Alex account.skin.upload=上传皮肤 +account.skin.upload.failed=皮肤上传失败 +account.skin.invalid_skin=无法识别的皮肤文件 account.username=用户名 archive.author=作者 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/InvalidSkinException.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/InvalidSkinException.java new file mode 100644 index 000000000..f864cc908 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/InvalidSkinException.java @@ -0,0 +1,35 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.skin; + +public class InvalidSkinException extends Exception { + + public InvalidSkinException() {} + + public InvalidSkinException(String message) { + super(message); + } + + public InvalidSkinException(Throwable cause) { + super(cause); + } + + public InvalidSkinException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/NormalizedSkin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/NormalizedSkin.java new file mode 100644 index 000000000..92f82dc06 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/skin/NormalizedSkin.java @@ -0,0 +1,152 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.skin; + +import java.awt.image.BufferedImage; + +/** + * Describes a Minecraft 1.8+ skin (64x64). + * Old format skins are converted to the new format. + * + * @author yushijinhun + */ +public class NormalizedSkin { + + private static void copyImage(BufferedImage src, BufferedImage dst, int sx, int sy, int dx, int dy, int w, int h, boolean flipHorizontal) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int pixel = src.getRGB(sx + x, sy + y); + dst.setRGB(dx + (flipHorizontal ? w - x - 1 : x), dy + y, pixel); + } + } + } + + private final BufferedImage texture; + private final BufferedImage normalizedTexture; + private final int scale; + private final boolean oldFormat; + + public NormalizedSkin(BufferedImage texture) throws InvalidSkinException { + this.texture = texture; + + // check format + int w = texture.getWidth(); + int h = texture.getHeight(); + if (w % 64 != 0) { + throw new InvalidSkinException("Invalid size " + w + "x" + h); + } + if (w == h) { + oldFormat = false; + } else if (w == h * 2) { + oldFormat = true; + } else { + throw new InvalidSkinException("Invalid size " + w + "x" + h); + } + + // compute scale + scale = w / 64; + + normalizedTexture = new BufferedImage(w, w, BufferedImage.TYPE_INT_ARGB); + copyImage(texture, normalizedTexture, 0, 0, 0, 0, w, h, false); + if (oldFormat) { + convertOldSkin(); + } + } + + private void convertOldSkin() { + copyImageRelative(4, 16, 20, 48, 4, 4, true); // Top Leg + copyImageRelative(8, 16, 24, 48, 4, 4, true); // Bottom Leg + copyImageRelative(0, 20, 24, 52, 4, 12, true); // Outer Leg + copyImageRelative(4, 20, 20, 52, 4, 12, true); // Front Leg + copyImageRelative(8, 20, 16, 52, 4, 12, true); // Inner Leg + copyImageRelative(12, 20, 28, 52, 4, 12, true); // Back Leg + copyImageRelative(44, 16, 36, 48, 4, 4, true); // Top Arm + copyImageRelative(48, 16, 40, 48, 4, 4, true); // Bottom Arm + copyImageRelative(40, 20, 40, 52, 4, 12, true); // Outer Arm + copyImageRelative(44, 20, 36, 52, 4, 12, true); // Front Arm + copyImageRelative(48, 20, 32, 52, 4, 12, true); // Inner Arm + copyImageRelative(52, 20, 44, 52, 4, 12, true); // Back Arm + } + + private void copyImageRelative(int sx, int sy, int dx, int dy, int w, int h, boolean flipHorizontal) { + copyImage(normalizedTexture, normalizedTexture, sx * scale, sy * scale, dx * scale, dy * scale, w * scale, h * scale, flipHorizontal); + } + + public BufferedImage getOriginalTexture() { + return texture; + } + + public BufferedImage getNormalizedTexture() { + return normalizedTexture; + } + + public int getScale() { + return scale; + } + + public boolean isOldFormat() { + return oldFormat; + } + + /** + * Tests whether the skin is slim. + * Note that this method doesn't guarantee the result is correct. + */ + public boolean isSlim() { + return (hasTransparencyRelative(50, 16, 2, 4) || + hasTransparencyRelative(54, 20, 2, 12) || + hasTransparencyRelative(42, 48, 2, 4) || + hasTransparencyRelative(46, 52, 2, 12)) || + (isAreaBlackRelative(50, 16, 2, 4) && + isAreaBlackRelative(54, 20, 2, 12) && + isAreaBlackRelative(42, 48, 2, 4) && + isAreaBlackRelative(46, 52, 2, 12)); + } + + private boolean hasTransparencyRelative(int x0, int y0, int w, int h) { + x0 *= scale; + y0 *= scale; + w *= scale; + h *= scale; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int pixel = normalizedTexture.getRGB(x0 + x, y0 + y); + if (pixel >>> 24 != 0xff) { + return true; + } + } + } + return false; + } + + private boolean isAreaBlackRelative(int x0, int y0, int w, int h) { + x0 *= scale; + y0 *= scale; + w *= scale; + h *= scale; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int pixel = normalizedTexture.getRGB(x0 + x, y0 + y); + if (pixel != 0xff000000) { + return false; + } + } + } + return true; + } +}