Add APNG support

This commit is contained in:
2026-01-25 11:38:08 +01:00
parent 92a5578e06
commit d22a2ca93c
9 changed files with 190 additions and 25 deletions

View File

@@ -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.";
}

View File

@@ -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)
);
}
}

View File

@@ -85,9 +85,9 @@ public class FfmpegService {
}));
}
public Optional<byte[]> transcodeGifToMp4(byte[] bytes) {
public Optional<byte[]> 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<String> 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<byte[]> transcodeApngToGif(byte[] bytes) {
return Optional.ofNullable(withTempFile(bytes, ".png", (input) -> {
Path output = Files.createTempFile("imgfloat-transcode", ".gif");
try {
List<String> 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> T withTempFile(byte[] bytes, String suffix, TempFileHandler<T> handler) {
Path input = null;
try {

View File

@@ -39,6 +39,9 @@ public class MediaDetectionService {
private Optional<String> 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);

View File

@@ -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);
}
}

View File

@@ -60,6 +60,7 @@ public final class MediaTypeRegistry {
private static Map<String, String> buildExtensionMap() {
Map<String, String> 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<String, String> buildMediaTypeMap() {
Map<String, String> 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");

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 });