修复无法加载 WebP 图标的问题 (#4171)

This commit is contained in:
Glavo 2025-08-02 15:37:48 +08:00 committed by GitHub
parent e0425102c8
commit 2617dd3630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 230 additions and 109 deletions

View File

@ -29,6 +29,7 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.*;
import javafx.collections.ObservableMap;
import javafx.event.Event;
@ -44,10 +45,7 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
@ -57,9 +55,8 @@ import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.png.PNGType;
import org.glavo.png.PNGWriter;
import org.glavo.png.javafx.PNGJavaFXUtils;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.util.*;
@ -82,13 +79,18 @@ import javax.imageio.stream.ImageInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.ref.WeakReference;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@ -169,18 +171,8 @@ public final class FXUtils {
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
private static final Map<String, Path> remoteImageCache = new ConcurrentHashMap<>();
public static void shutdown() {
for (Map.Entry<String, Path> entry : remoteImageCache.entrySet()) {
try {
Files.deleteIfExists(entry.getValue());
} catch (IOException e) {
LOG.warning(String.format("Failed to delete cache file %s.", entry.getValue()), e);
}
remoteImageCache.remove(entry.getKey());
}
builtinImageCache.clear();
}
@ -903,78 +895,55 @@ public final class FXUtils {
}
}
/**
* Load image from the internet. It will cache the data of images for the further usage.
* The cached data will be deleted when HMCL is closed or hidden.
*
* @param url the url of image. The image resource should be a file on the internet.
* @return the image resource within the jar.
*/
public static Image newRemoteImage(String url) {
return newRemoteImage(url, 0, 0, false, false, false);
public static Task<Image> getRemoteImageTask(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(URI.create(url))
.thenApplyAsync(file -> {
try (var channel = FileChannel.open(file, StandardOpenOption.READ)) {
var header = new byte[12];
var buffer = ByteBuffer.wrap(header);
//noinspection StatementWithEmptyBody
while (channel.read(buffer) > 0) {
}
channel.position(0L);
if (!buffer.hasRemaining()) {
// WebP File
if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F' &&
header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P') {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(Channels.newInputStream(channel))) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
}
}
Image image = new Image(Channels.newInputStream(channel), requestedWidth, requestedHeight, preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
}
});
}
/**
* Load image from the internet. It will cache the data of images for the further usage.
* The cached data will be deleted when HMCL is closed or hidden.
*
* @param url the url of image. The image resource should be a file on the internet.
* @param requestedWidth the image's bounding box width
* @param requestedHeight the image's bounding box height
* @param preserveRatio indicates whether to preserve the aspect ratio of
* the original image when scaling to fit the image within the
* specified bounding box
* @param smooth indicates whether to use a better quality filtering
* algorithm or a faster one when scaling this image to fit within
* the specified bounding box
* @return the image resource within the jar.
*/
public static Image newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth, boolean backgroundLoading) {
Path currentPath = remoteImageCache.get(url);
if (currentPath != null) {
if (Files.isReadable(currentPath)) {
try (InputStream inputStream = Files.newInputStream(currentPath)) {
return new Image(inputStream, requestedWidth, requestedHeight, preserveRatio, smooth);
} catch (IOException e) {
LOG.warning("An exception encountered while reading data from cached image file.", e);
}
}
// The file is unavailable or unreadable.
remoteImageCache.remove(url);
try {
Files.deleteIfExists(currentPath);
} catch (IOException e) {
LOG.warning("An exception encountered while deleting broken cached image file.", e);
}
}
Image image = new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth, backgroundLoading);
image.progressProperty().addListener((observable, oldValue, newValue) -> {
if (newValue.doubleValue() >= 1.0 && !image.isError() && image.getPixelReader() != null && image.getWidth() > 0.0 && image.getHeight() > 0.0) {
Task.runAsync(() -> {
Path newPath = Files.createTempFile("hmcl-net-resource-cache-", ".cache");
try ( // Make sure the file is released from JVM before we put the path into remoteImageCache.
OutputStream outputStream = Files.newOutputStream(newPath);
PNGWriter writer = new PNGWriter(outputStream, PNGType.RGBA, PNGWriter.DEFAULT_COMPRESS_LEVEL)
) {
writer.write(PNGJavaFXUtils.asArgbImage(image));
} catch (IOException e) {
try {
Files.delete(newPath);
} catch (IOException e2) {
e2.addSuppressed(e);
throw e2;
}
throw e;
public static ObservableValue<Image> newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
var image = new SimpleObjectProperty<Image>();
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
image.set(result);
} else {
LOG.warning("An exception encountered while loading remote image: " + url, exception);
}
if (remoteImageCache.putIfAbsent(url, newPath) != null) {
Files.delete(newPath); // The image has been loaded in another task. Delete the image here in order not to pollute the tmp folder.
}
}).start();
}
});
})
.start();
return image;
}

View File

@ -190,10 +190,12 @@ public final class HTMLRenderer {
}
}
Image image = FXUtils.newRemoteImage(uri.toString(), width, height, true, true, false);
if (image.isError()) {
LOG.warning("Failed to load image: " + uri, image.getException());
} else {
try {
Image image = FXUtils.getRemoteImageTask(uri.toString(), width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
ImageView imageView = new ImageView(image);
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
@ -204,6 +206,8 @@ public final class HTMLRenderer {
}
children.add(imageView);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + uri, e);
}
}

View File

@ -68,6 +68,7 @@ import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadListPage extends Control implements DecoratorPage, VersionPage.VersionLoadable {
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
@ -542,7 +543,8 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
.collect(Collectors.toList()));
if (StringUtils.isNotBlank(dataItem.getIconUrl())) {
imageView.setImage(FXUtils.newRemoteImage(dataItem.getIconUrl(), 40, 40, true, true, true));
LOG.debug("Icon: " + dataItem.getIconUrl());
imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 40, 40, true, true));
}
}
});

View File

@ -220,7 +220,7 @@ public class DownloadPage extends Control implements DecoratorPage {
{
ImageView imageView = new ImageView();
if (StringUtils.isNotBlank(getSkinnable().addon.getIconUrl())) {
imageView.setImage(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 40, 40, true, true, true));
imageView.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 40, 40, true, true));
}
descriptionPane.getChildren().add(FXUtils.limitingSize(imageView, 40, 40));
@ -359,7 +359,7 @@ public class DownloadPage extends Control implements DecoratorPage {
.collect(Collectors.toList()));
if (StringUtils.isNotBlank(addon.getIconUrl())) {
imageView.setImage(FXUtils.newRemoteImage(addon.getIconUrl(), 40, 40, true, true, true));
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 40, 40, true, true));
}
} else {
content.setTitle(i18n("mods.broken_dependency.title"));

View File

@ -120,5 +120,43 @@ public final class SwingFXUtils {
pw.setPixels(0, 0, bw, bh, pf, data, offset, scan);
return wimg;
}
public static WritableImage toFXImage(BufferedImage bimg, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
if (requestedWidth <= 0. || requestedHeight <= 0.) {
return toFXImage(bimg, null);
}
int width = (int) requestedWidth;
int height = (int) requestedHeight;
// Calculate actual dimensions if preserveRatio is true
if (preserveRatio) {
double originalWidth = bimg.getWidth();
double originalHeight = bimg.getHeight();
double scaleX = requestedWidth / originalWidth;
double scaleY = requestedHeight / originalHeight;
double scale = Math.min(scaleX, scaleY);
width = (int) (originalWidth * scale);
height = (int) (originalHeight * scale);
}
// Create scaled BufferedImage
BufferedImage scaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D g2d = scaledImage.createGraphics();
try {
if (smooth) {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
}
g2d.drawImage(bimg, 0, 0, width, height, null);
} finally {
g2d.dispose();
}
return toFXImage(scaledImage, null);
}
}

View File

@ -0,0 +1,107 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.task;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* Download a file to cache repository.
*
* @author Glavo
*/
public final class CacheFileTask extends FetchTask<Path> {
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri), DEFAULT_RETRY);
}
public CacheFileTask(@NotNull URI uri, int retry) {
super(List.of(uri), retry);
}
@Override
protected EnumCheckETag shouldCheckETag() {
// Check cache
for (URI uri : uris) {
try {
setResult(repository.getCachedRemoteFile(uri));
return EnumCheckETag.CACHED;
} catch (IOException ignored) {
}
}
return EnumCheckETag.CHECK_E_TAG;
}
@Override
protected void useCachedResult(Path cache) {
setResult(cache);
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag) throws IOException {
assert checkETag;
Path temp = Files.createTempFile("hmcl-download-", null);
OutputStream fileOutput = Files.newOutputStream(temp);
return new Context() {
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
fileOutput.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
try {
fileOutput.close();
} catch (IOException e) {
LOG.warning("Failed to close file: " + temp, e);
}
if (!isSuccess()) {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
return;
}
try {
setResult(repository.cacheRemoteFile(connection, temp));
} finally {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
}
}
};
}
}

View File

@ -44,9 +44,10 @@ import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class FetchTask<T> extends Task<T> {
protected static final int DEFAULT_RETRY = 3;
protected final List<URI> uris;
protected final int retry;
protected boolean caching;
protected CacheRepository repository = CacheRepository.getInstance();
public FetchTask(@NotNull List<@NotNull URI> uris, int retry) {
@ -61,10 +62,6 @@ public abstract class FetchTask<T> extends Task<T> {
setExecutor(download());
}
public void setCaching(boolean caching) {
this.caching = caching;
}
public void setCacheRepository(CacheRepository repository) {
this.repository = repository;
}

View File

@ -84,6 +84,7 @@ public class FileDownloadTask extends FetchTask<Void> {
private final Path file;
private final IntegrityCheck integrityCheck;
private boolean caching;
private Path candidate;
private final ArrayList<IntegrityCheckHandler> integrityCheckHandlers = new ArrayList<>();
@ -132,7 +133,7 @@ public class FileDownloadTask extends FetchTask<Void> {
* @param integrityCheck the integrity check to perform, null if no integrity check is to be performed
*/
public FileDownloadTask(List<URI> uris, Path path, IntegrityCheck integrityCheck) {
this(uris, path, integrityCheck, 3);
this(uris, path, integrityCheck, DEFAULT_RETRY);
}
/**
@ -155,6 +156,10 @@ public class FileDownloadTask extends FetchTask<Void> {
return file;
}
public void setCaching(boolean caching) {
this.caching = caching;
}
public FileDownloadTask setCandidate(Path candidate) {
this.candidate = candidate;
return this;

View File

@ -36,8 +36,6 @@ import static java.nio.charset.StandardCharsets.UTF_8;
*/
public final class GetTask extends FetchTask<String> {
private static final int DEFAULT_RETRY = 3;
private final Charset charset;
public GetTask(URI url) {

View File

@ -200,20 +200,20 @@ public class CacheRepository {
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
}
public void cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException {
cacheData(connection, () -> {
public Path cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException {
return cacheData(connection, () -> {
String hash = DigestUtils.digestToString(SHA1, downloaded);
Path cached = cacheFile(downloaded, SHA1, hash);
return new CacheResult(hash, cached);
});
}
public void cacheText(URLConnection connection, String text) throws IOException {
cacheBytes(connection, text.getBytes(UTF_8));
public Path cacheText(URLConnection connection, String text) throws IOException {
return cacheBytes(connection, text.getBytes(UTF_8));
}
public void cacheBytes(URLConnection connection, byte[] bytes) throws IOException {
cacheData(connection, () -> {
public Path cacheBytes(URLConnection connection, byte[] bytes) throws IOException {
return cacheData(connection, () -> {
String hash = DigestUtils.digestToString(SHA1, bytes);
Path cached = getFile(SHA1, hash);
FileUtils.writeBytes(cached, bytes);
@ -221,9 +221,9 @@ public class CacheRepository {
});
}
private void cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = connection.getHeaderField("ETag");
if (StringUtils.isBlank(eTag)) return;
if (StringUtils.isBlank(eTag)) return null;
URI uri;
try {
uri = NetworkUtils.dropQuery(connection.getURL().toURI());
@ -240,6 +240,7 @@ public class CacheRepository {
} finally {
lock.writeLock().unlock();
}
return cacheResult.cachedFile;
}
private static final class CacheResult {