From d22a2ca93c12d4d973a05d94638dee7d628364c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Sun, 25 Jan 2026 11:38:08 +0100 Subject: [PATCH] Add APNG support --- .../service/ChannelDirectoryService.java | 11 ++-- .../imgfloat/service/media/ApngDetector.java | 62 +++++++++++++++++++ .../imgfloat/service/media/FfmpegService.java | 46 +++++++++++--- .../service/media/MediaDetectionService.java | 3 + .../media/MediaOptimizationService.java | 51 +++++++++++---- .../service/media/MediaTypeRegistry.java | 2 + src/main/resources/static/js/admin/console.js | 6 +- .../static/js/broadcast/assetKinds.js | 5 ++ .../media/MediaDetectionServiceTest.java | 29 +++++++++ 9 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/media/ApngDetector.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 6163c5d..1167ca5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -333,7 +333,7 @@ public class ChannelDirectoryService { OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { - throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); + throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType)); } String safeName = Optional.ofNullable(file.getOriginalFilename()) @@ -528,7 +528,7 @@ public class ChannelDirectoryService { .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage())); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { - throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); + throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType)); } AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); if (assetType != AssetType.IMAGE) { @@ -1374,7 +1374,7 @@ public class ChannelDirectoryService { OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { - throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); + throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType)); } AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); @@ -1534,7 +1534,10 @@ public class ChannelDirectoryService { return "Unsupported media type. Supported types: " + MediaTypeRegistry.supportedMediaTypesSummary(); } - private String mediaProcessingErrorMessage() { + private String mediaProcessingErrorMessage(String mediaType) { + if (mediaType != null && mediaType.equalsIgnoreCase("image/apng")) { + return "Unable to convert APNG to GIF. Ensure ffmpeg is installed on the server."; + } return "Unable to process media. Ensure ffmpeg is installed on the server."; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/ApngDetector.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/ApngDetector.java new file mode 100644 index 0000000..f834789 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/ApngDetector.java @@ -0,0 +1,62 @@ +package dev.kruhlmann.imgfloat.service.media; + +final class ApngDetector { + + private static final byte[] PNG_SIGNATURE = new byte[] { + (byte) 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + }; + + private ApngDetector() {} + + static boolean isApng(byte[] bytes) { + if (bytes == null || bytes.length < PNG_SIGNATURE.length + 8) { + return false; + } + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] != PNG_SIGNATURE[i]) { + return false; + } + } + int offset = PNG_SIGNATURE.length; + while (offset + 8 <= bytes.length) { + int length = readInt(bytes, offset); + if (length < 0) { + return false; + } + int typeOffset = offset + 4; + if (typeOffset + 4 > bytes.length) { + return false; + } + if ( + bytes[typeOffset] == 'a' && + bytes[typeOffset + 1] == 'c' && + bytes[typeOffset + 2] == 'T' && + bytes[typeOffset + 3] == 'L' + ) { + return true; + } + long next = (long) offset + 12 + length; + if (next > bytes.length) { + return false; + } + offset = (int) next; + } + return false; + } + + private static int readInt(byte[] bytes, int offset) { + return ( + ((bytes[offset] & 0xFF) << 24) | + ((bytes[offset + 1] & 0xFF) << 16) | + ((bytes[offset + 2] & 0xFF) << 8) | + (bytes[offset + 3] & 0xFF) + ); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java index 949574c..6e937f8 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java @@ -85,9 +85,9 @@ public class FfmpegService { })); } - public Optional transcodeGifToMp4(byte[] bytes) { + public Optional transcodeGifToWebm(byte[] bytes) { return Optional.ofNullable(withTempFile(bytes, ".gif", (input) -> { - Path output = Files.createTempFile("imgfloat-transcode", ".mp4"); + Path output = Files.createTempFile("imgfloat-transcode", ".webm"); try { List command = List.of( "ffmpeg", @@ -97,12 +97,16 @@ public class FfmpegService { "error", "-i", input.toString(), - "-movflags", - "+faststart", + "-c:v", + "libvpx-vp9", "-pix_fmt", - "yuv420p", - "-vf", - "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "yuva420p", + "-auto-alt-ref", + "0", + "-crf", + "30", + "-b:v", + "0", output.toString() ); ProcessResult result = run(command); @@ -117,6 +121,34 @@ public class FfmpegService { })); } + public Optional transcodeApngToGif(byte[] bytes) { + return Optional.ofNullable(withTempFile(bytes, ".png", (input) -> { + Path output = Files.createTempFile("imgfloat-transcode", ".gif"); + try { + List command = List.of( + "ffmpeg", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + input.toString(), + "-filter_complex", + "[0:v]split[s0][s1];[s0]palettegen=reserve_transparent=1[p];[s1][p]paletteuse", + output.toString() + ); + ProcessResult result = run(command); + if (result.exitCode() != 0) { + logger.warn("ffmpeg APNG transcode failed: {}", result.output()); + return null; + } + return Files.readAllBytes(output); + } finally { + Files.deleteIfExists(output); + } + })); + } + private T withTempFile(byte[] bytes, String suffix, TempFileHandler handler) { Path input = null; try { diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java index d93018f..786a82c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java @@ -39,6 +39,9 @@ public class MediaDetectionService { private Optional detectMediaType(byte[] bytes) { try (var stream = new ByteArrayInputStream(bytes)) { + if (ApngDetector.isApng(bytes)) { + return Optional.of("image/apng"); + } String guessed = URLConnection.guessContentTypeFromStream(stream); if (guessed != null && !guessed.isBlank()) { return Optional.of(guessed); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java index 46bfaa8..6412081 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java @@ -29,19 +29,24 @@ public class MediaOptimizationService { if (mediaType == null || mediaType.isBlank() || bytes == null || bytes.length == 0) { return null; } + if (isApng(mediaType, bytes)) { + OptimizedAsset apngAsset = optimizeApng(bytes, mediaType); + if (apngAsset != null) { + return apngAsset; + } + } if ("image/gif".equalsIgnoreCase(mediaType)) { OptimizedAsset transcoded = transcodeGifToVideo(bytes); if (transcoded != null) { return transcoded; } } - if (mediaType.startsWith("image/")) { - BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); - if (image == null) { + OptimizedAsset imageAsset = optimizeImage(bytes, mediaType); + if (imageAsset == null) { return null; } - return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); + return imageAsset; } if (mediaType.startsWith("video/")) { @@ -64,26 +69,50 @@ public class MediaOptimizationService { return new OptimizedAsset(bytes, mediaType, 0, 0, null); } - BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); - if (image != null) { - return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); + return optimizeImage(bytes, mediaType); + } + + private boolean isApng(String mediaType, byte[] bytes) { + if (mediaType == null) { + return false; } - return null; + if ("image/apng".equalsIgnoreCase(mediaType)) { + return true; + } + return "image/png".equalsIgnoreCase(mediaType) && ApngDetector.isApng(bytes); + } + + private OptimizedAsset optimizeApng(byte[] bytes, String mediaType) throws IOException { + return ffmpegService + .transcodeApngToGif(bytes) + .map(this::transcodeGifToVideo) + .orElseGet(() -> { + logger.warn("Unable to transcode APNG to GIF via ffmpeg"); + return null; + }); } private OptimizedAsset transcodeGifToVideo(byte[] bytes) { return ffmpegService - .transcodeGifToMp4(bytes) + .transcodeGifToWebm(bytes) .map((videoBytes) -> { FfmpegService.VideoDimensions dimensions = ffmpegService .extractVideoDimensions(videoBytes) .orElse(DEFAULT_VIDEO_DIMENSIONS); - byte[] preview = previewService.extractVideoPreview(videoBytes, "video/mp4"); - return new OptimizedAsset(videoBytes, "video/mp4", dimensions.width(), dimensions.height(), preview); + byte[] preview = previewService.extractVideoPreview(videoBytes, "video/webm"); + return new OptimizedAsset(videoBytes, "video/webm", dimensions.width(), dimensions.height(), preview); }) .orElseGet(() -> { logger.warn("Unable to transcode GIF to video via ffmpeg"); return null; }); } + + private OptimizedAsset optimizeImage(byte[] bytes, String mediaType) throws IOException { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image == null) { + return null; + } + return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java index 942b558..7c3e72a 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java @@ -60,6 +60,7 @@ public final class MediaTypeRegistry { private static Map buildExtensionMap() { Map map = new LinkedHashMap<>(); map.put("png", "image/png"); + map.put("apng", "image/apng"); map.put("jpg", "image/jpeg"); map.put("jpeg", "image/jpeg"); map.put("gif", "image/gif"); @@ -86,6 +87,7 @@ public final class MediaTypeRegistry { private static Map buildMediaTypeMap() { Map map = new LinkedHashMap<>(); map.put("image/png", ".png"); + map.put("image/apng", ".apng"); map.put("image/jpeg", ".jpg"); map.put("image/jpg", ".jpg"); map.put("image/gif", ".gif"); diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index e41c729..bce4d82 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -1,5 +1,5 @@ import { isAudioAsset } from "../media/audio.js"; -import { isCodeAsset, isGifAsset, isModelAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js"; +import { isApngAsset, isCodeAsset, isGifAsset, isModelAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js"; import { createModelManager } from "../media/modelManager.js"; import { ensureLayerPosition as ensureLayerPositionForState, @@ -753,7 +753,7 @@ export function createAdminConsole({ const model = modelManager.ensureModel(asset); drawSource = model?.canvas || null; ready = !!model?.ready; - } else if (isVideoAsset(asset) || isGifAsset(asset)) { + } else if (isVideoAsset(asset)) { drawSource = ensureCanvasPreview(asset); ready = isDrawable(drawSource); showPlayOverlay = true; @@ -1213,7 +1213,7 @@ export function createAdminConsole({ return null; } - if (isGifAsset(asset) && "ImageDecoder" in globalThis) { + if ((isGifAsset(asset) || isApngAsset(asset)) && "ImageDecoder" in globalThis) { const animated = ensureAnimatedImage(asset); if (animated) { mediaCache.set(asset.id, animated); diff --git a/src/main/resources/static/js/broadcast/assetKinds.js b/src/main/resources/static/js/broadcast/assetKinds.js index 5b550f7..3c64d86 100644 --- a/src/main/resources/static/js/broadcast/assetKinds.js +++ b/src/main/resources/static/js/broadcast/assetKinds.js @@ -31,6 +31,11 @@ export function isGifAsset(asset) { return asset?.mediaType?.toLowerCase() === "image/gif"; } +export function isApngAsset(asset) { + const type = (asset?.mediaType || "").toLowerCase(); + return type === "image/apng"; +} + export function getAssetKind(asset) { if (isAudioAsset(asset)) { return AssetKind.AUDIO; diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java index b93af0d..39d0fd4 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java @@ -18,6 +18,35 @@ class MediaDetectionServiceTest { assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); } + @Test + void detectsApngEvenWhenNamedPng() throws IOException { + byte[] apng = new byte[] { + (byte) 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x00, + 'a', + 'c', + 'T', + 'L', + 0x00, + 0x00, + 0x00, + 0x00, + }; + MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", apng); + + assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/apng"); + } + @Test void fallsBackToFilenameAllowlist() throws IOException { MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[] { 1, 2, 3 });