From 9ae182f5d9a98e649babf5479f1247cda1d71eb8 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 18 Aug 2026 12:17:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20AVIF=20=E5=9B=BE=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCL/build.gradle.kts | 1 + .../jackhuang/hmcl/ui/image/ImageUtils.java | 130 ++++++++++++++++++ .../org/jackhuang/hmcl/modpack/Modpack.java | 4 +- gradle/libs.versions.toml | 2 + 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 1159d354729..77c47fcccc4 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(project(":HMCLBoot")) implementation("libs:JFoenix") implementation(libs.jwebp) + implementation(libs.javif) implementation(libs.fxsvgimage) implementation(libs.java.info) implementation(libs.monet.fx) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java index 69f476d3a9c..fd645e66007 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/image/ImageUtils.java @@ -28,6 +28,11 @@ import org.girod.javafx.svgimage.SVGImage; import org.girod.javafx.svgimage.SVGLoader; import org.girod.javafx.svgimage.ScaleQuality; +import org.glavo.avif.AvifFrame; +import org.glavo.avif.AvifImage; +import org.glavo.avif.AvifImageInfo; +import org.glavo.avif.AvifSequenceInfo; +import org.glavo.avif.javafx.AvifFXImage; import org.glavo.webp.WebPImage; import org.glavo.webp.WebPImageLoadOptions; import org.glavo.webp.javafx.WebPFXImage; @@ -42,6 +47,7 @@ import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.nio.ByteBuffer; @@ -78,6 +84,59 @@ public final class ImageUtils { return new WebPFXImage(WebPImage.read(input, options)); }; + public static final ImageLoader AVIF = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> { + AvifImage image = AvifImage.read(input); + AvifImageInfo info = image.info(); + + final int width = info.width(); + final int height = info.height(); + + // A request is only honored when both dimensions are given and at least one differs + // from the decoded size; otherwise the image is returned at its native resolution. + boolean doScale = requestedWidth > 0 && requestedHeight > 0 + && (requestedWidth != width || requestedHeight != height); + + int targetWidth = requestedWidth; + int targetHeight = requestedHeight; + if (doScale && preserveRatio) { + double scaleX = (double) requestedWidth / width; + double scaleY = (double) requestedHeight / height; + double scale = Math.min(scaleX, scaleY); + + targetWidth = (int) (width * scale); + targetHeight = (int) (height * scale); + } + + if (!doScale) + return new AvifFXImage(image); + + List frames = image.frames(); + if (frames.size() > 1) { + @Nullable AvifSequenceInfo sequenceInfo = info.sequenceInfo(); + AvifFrame firstFrame = frames.get(0); + int sourceWidth = firstFrame.width(); + int sourceHeight = firstFrame.height(); + + int[][] framePixels = new int[frames.size()][]; + for (int i = 0; i < frames.size(); i++) { + framePixels[i] = scale(frames.get(i).intPixels(), + sourceWidth, sourceHeight, + targetWidth, targetHeight); + } + + return new AnimationImageImpl(targetWidth, targetHeight, framePixels, + avifFrameDurations(sequenceInfo, frames.size()), + avifCycleCount(sequenceInfo)); + } + + int[] pixels = scale(image.firstFrame().intPixels(), width, height, targetWidth, targetHeight); + + WritableImage result = new WritableImage(targetWidth, targetHeight); + result.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight, + PixelFormat.getIntArgbInstance(), pixels, 0, targetWidth); + return result; + }; + public static final ImageLoader SVG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> { String content = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -181,6 +240,7 @@ public final class ImageUtils { public static final Map EXT_TO_LOADER = Map.of( "webp", WEBP, + "avif", AVIF, "svg", SVG, "apng", APNG ); @@ -729,6 +789,76 @@ private static int[] rgbaToArgb(ByteBuffer rgba) { return argb; } + // AVIF + + /// Computes per-frame display durations, in milliseconds, for an AVIF animation. + /// + /// When `sequenceInfo` supplies usable per-frame timing (see [#hasUsableAvifSequenceTiming]), + /// that timing is converted to milliseconds; otherwise a uniform 30 frames-per-second + /// fallback is used. This mirrors the behavior of `org.glavo.avif.javafx.AvifFXImage`. + /// + /// @param sequenceInfo sequence timing metadata, or `null` if unavailable + /// @param frameCount number of frames in the animation + /// @return an array of `frameCount` frame durations, in milliseconds + private static int @Unmodifiable [] avifFrameDurations(@Nullable AvifSequenceInfo sequenceInfo, int frameCount) { + int[] durations = new int[frameCount]; + + if (hasUsableAvifSequenceTiming(sequenceInfo, frameCount)) { + // hasUsableAvifSequenceTiming(...) returning true guarantees sequenceInfo is non-null. + + int[] frameDurations = sequenceInfo.frameDurations(); + int mediaTimescale = sequenceInfo.mediaTimescale(); + for (int i = 0; i < frameCount; i++) { + durations[i] = (int) (frameDurations[i] * 1000.0 / mediaTimescale); + } + } else { + Arrays.fill(durations, 1000 / 30); + } + + return durations; + } + + /// Returns whether `sequenceInfo` supplies per-frame timing that can be trusted to + /// drive an animation. + /// + /// @param sequenceInfo sequence timing metadata, or `null` if unavailable + /// @param frameCount number of decoded frames + /// @return whether `sequenceInfo` is non-`null`, has a positive + /// [AvifSequenceInfo#mediaTimescale()], its frame-duration array length matches + /// `frameCount`, and at least one duration is positive + private static boolean hasUsableAvifSequenceTiming(@Nullable AvifSequenceInfo sequenceInfo, int frameCount) { + if (sequenceInfo == null || sequenceInfo.mediaTimescale() <= 0) + return false; + + int[] frameDurations = sequenceInfo.frameDurations(); + if (frameDurations.length != frameCount) + return false; + + for (int frameDuration : frameDurations) { + if (frameDuration > 0) + return true; + } + return false; + } + + /// Computes the [Timeline] cycle count corresponding to an AVIF sequence's repeat count. + /// + /// @param sequenceInfo sequence timing metadata, or `null` if unavailable + /// @return [Timeline#INDEFINITE] if `sequenceInfo` is `null` or its repeat count is + /// [AvifSequenceInfo#REPETITION_COUNT_UNKNOWN] or [AvifSequenceInfo#REPETITION_COUNT_INFINITE]; + /// otherwise the repeat count plus one + private static int avifCycleCount(@Nullable AvifSequenceInfo sequenceInfo) { + if (sequenceInfo == null) + return Timeline.INDEFINITE; + + int repetitionCount = sequenceInfo.repetitionCount(); + if (repetitionCount == AvifSequenceInfo.REPETITION_COUNT_UNKNOWN + || repetitionCount == AvifSequenceInfo.REPETITION_COUNT_INFINITE) { + return Timeline.INDEFINITE; + } + return repetitionCount == Integer.MAX_VALUE ? Integer.MAX_VALUE : repetitionCount + 1; + } + /// Prevents instantiation. private ImageUtils() { } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java index d0dd38d9f52..01727c07497 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java @@ -32,8 +32,8 @@ */ public abstract class Modpack { - public static final Set SUPPORTED_ICON_EXTS = Set.of("png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"); - public static final Set SUPPORTED_ICON_NAMES = Set.of("icon.png", "icon.jpg", "icon.jpeg", "icon.bmp", "icon.gif", "icon.webp", "icon.apng"); + public static final Set SUPPORTED_ICON_EXTS = Set.of("png", "jpg", "jpeg", "bmp", "gif", "webp", "avif", "apng"); + public static final Set SUPPORTED_ICON_NAMES = Set.of("icon.png", "icon.jpg", "icon.jpeg", "icon.bmp", "icon.gif", "icon.webp", "icon.avif", "icon.apng"); private String name; private String author; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10aa4a25d33..da04a0a9d36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ monet-fx = "0.4.0" terracotta = "0.4.2" nayuki-qrcodegen = "1.8.0" jwebp = "0.2.0" +javif = "0.1.0" weburl = "0.2.0" uuid-tools = "0.2.0" commonmark = "0.27.1" @@ -61,6 +62,7 @@ lwjgl-unsafe-agent = { module = "org.glavo:lwjgl-unsafe-agent", version.ref = "l monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" } nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcodegen" } jwebp = { module = "org.glavo:webp", version.ref = "jwebp" } +javif = { module = "org.glavo:avif", version.ref = "javif" } weburl = { module = "org.glavo:weburl", version.ref = "weburl" } uuid-tools = { module = "org.glavo:uuid-tools", version.ref = "uuid-tools" } commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }