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); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) { if (optimized == null) {
throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType));
} }
String safeName = Optional.ofNullable(file.getOriginalFilename()) String safeName = Optional.ofNullable(file.getOriginalFilename())
@@ -528,7 +528,7 @@ public class ChannelDirectoryService {
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage())); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage()));
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) { if (optimized == null) {
throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType));
} }
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
if (assetType != AssetType.IMAGE) { if (assetType != AssetType.IMAGE) {
@@ -1374,7 +1374,7 @@ public class ChannelDirectoryService {
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) { if (optimized == null) {
throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage()); throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage(mediaType));
} }
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
@@ -1534,7 +1534,10 @@ public class ChannelDirectoryService {
return "Unsupported media type. Supported types: " + MediaTypeRegistry.supportedMediaTypesSummary(); 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."; 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) -> { return Optional.ofNullable(withTempFile(bytes, ".gif", (input) -> {
Path output = Files.createTempFile("imgfloat-transcode", ".mp4"); Path output = Files.createTempFile("imgfloat-transcode", ".webm");
try { try {
List<String> command = List.of( List<String> command = List.of(
"ffmpeg", "ffmpeg",
@@ -97,12 +97,16 @@ public class FfmpegService {
"error", "error",
"-i", "-i",
input.toString(), input.toString(),
"-movflags", "-c:v",
"+faststart", "libvpx-vp9",
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuva420p",
"-vf", "-auto-alt-ref",
"scale=trunc(iw/2)*2:trunc(ih/2)*2", "0",
"-crf",
"30",
"-b:v",
"0",
output.toString() output.toString()
); );
ProcessResult result = run(command); 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) { private <T> T withTempFile(byte[] bytes, String suffix, TempFileHandler<T> handler) {
Path input = null; Path input = null;
try { try {

View File

@@ -39,6 +39,9 @@ public class MediaDetectionService {
private Optional<String> detectMediaType(byte[] bytes) { private Optional<String> detectMediaType(byte[] bytes) {
try (var stream = new ByteArrayInputStream(bytes)) { try (var stream = new ByteArrayInputStream(bytes)) {
if (ApngDetector.isApng(bytes)) {
return Optional.of("image/apng");
}
String guessed = URLConnection.guessContentTypeFromStream(stream); String guessed = URLConnection.guessContentTypeFromStream(stream);
if (guessed != null && !guessed.isBlank()) { if (guessed != null && !guessed.isBlank()) {
return Optional.of(guessed); return Optional.of(guessed);

View File

@@ -29,19 +29,24 @@ public class MediaOptimizationService {
if (mediaType == null || mediaType.isBlank() || bytes == null || bytes.length == 0) { if (mediaType == null || mediaType.isBlank() || bytes == null || bytes.length == 0) {
return null; return null;
} }
if (isApng(mediaType, bytes)) {
OptimizedAsset apngAsset = optimizeApng(bytes, mediaType);
if (apngAsset != null) {
return apngAsset;
}
}
if ("image/gif".equalsIgnoreCase(mediaType)) { if ("image/gif".equalsIgnoreCase(mediaType)) {
OptimizedAsset transcoded = transcodeGifToVideo(bytes); OptimizedAsset transcoded = transcodeGifToVideo(bytes);
if (transcoded != null) { if (transcoded != null) {
return transcoded; return transcoded;
} }
} }
if (mediaType.startsWith("image/")) { if (mediaType.startsWith("image/")) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); OptimizedAsset imageAsset = optimizeImage(bytes, mediaType);
if (image == null) { if (imageAsset == null) {
return null; return null;
} }
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); return imageAsset;
} }
if (mediaType.startsWith("video/")) { if (mediaType.startsWith("video/")) {
@@ -64,26 +69,50 @@ public class MediaOptimizationService {
return new OptimizedAsset(bytes, mediaType, 0, 0, null); return new OptimizedAsset(bytes, mediaType, 0, 0, null);
} }
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); return optimizeImage(bytes, mediaType);
if (image != null) { }
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null);
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) { private OptimizedAsset transcodeGifToVideo(byte[] bytes) {
return ffmpegService return ffmpegService
.transcodeGifToMp4(bytes) .transcodeGifToWebm(bytes)
.map((videoBytes) -> { .map((videoBytes) -> {
FfmpegService.VideoDimensions dimensions = ffmpegService FfmpegService.VideoDimensions dimensions = ffmpegService
.extractVideoDimensions(videoBytes) .extractVideoDimensions(videoBytes)
.orElse(DEFAULT_VIDEO_DIMENSIONS); .orElse(DEFAULT_VIDEO_DIMENSIONS);
byte[] preview = previewService.extractVideoPreview(videoBytes, "video/mp4"); byte[] preview = previewService.extractVideoPreview(videoBytes, "video/webm");
return new OptimizedAsset(videoBytes, "video/mp4", dimensions.width(), dimensions.height(), preview); return new OptimizedAsset(videoBytes, "video/webm", dimensions.width(), dimensions.height(), preview);
}) })
.orElseGet(() -> { .orElseGet(() -> {
logger.warn("Unable to transcode GIF to video via ffmpeg"); logger.warn("Unable to transcode GIF to video via ffmpeg");
return null; 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() { private static Map<String, String> buildExtensionMap() {
Map<String, String> map = new LinkedHashMap<>(); Map<String, String> map = new LinkedHashMap<>();
map.put("png", "image/png"); map.put("png", "image/png");
map.put("apng", "image/apng");
map.put("jpg", "image/jpeg"); map.put("jpg", "image/jpeg");
map.put("jpeg", "image/jpeg"); map.put("jpeg", "image/jpeg");
map.put("gif", "image/gif"); map.put("gif", "image/gif");
@@ -86,6 +87,7 @@ public final class MediaTypeRegistry {
private static Map<String, String> buildMediaTypeMap() { private static Map<String, String> buildMediaTypeMap() {
Map<String, String> map = new LinkedHashMap<>(); Map<String, String> map = new LinkedHashMap<>();
map.put("image/png", ".png"); map.put("image/png", ".png");
map.put("image/apng", ".apng");
map.put("image/jpeg", ".jpg"); map.put("image/jpeg", ".jpg");
map.put("image/jpg", ".jpg"); map.put("image/jpg", ".jpg");
map.put("image/gif", ".gif"); map.put("image/gif", ".gif");

View File

@@ -1,5 +1,5 @@
import { isAudioAsset } from "../media/audio.js"; 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 { createModelManager } from "../media/modelManager.js";
import { import {
ensureLayerPosition as ensureLayerPositionForState, ensureLayerPosition as ensureLayerPositionForState,
@@ -753,7 +753,7 @@ export function createAdminConsole({
const model = modelManager.ensureModel(asset); const model = modelManager.ensureModel(asset);
drawSource = model?.canvas || null; drawSource = model?.canvas || null;
ready = !!model?.ready; ready = !!model?.ready;
} else if (isVideoAsset(asset) || isGifAsset(asset)) { } else if (isVideoAsset(asset)) {
drawSource = ensureCanvasPreview(asset); drawSource = ensureCanvasPreview(asset);
ready = isDrawable(drawSource); ready = isDrawable(drawSource);
showPlayOverlay = true; showPlayOverlay = true;
@@ -1213,7 +1213,7 @@ export function createAdminConsole({
return null; return null;
} }
if (isGifAsset(asset) && "ImageDecoder" in globalThis) { if ((isGifAsset(asset) || isApngAsset(asset)) && "ImageDecoder" in globalThis) {
const animated = ensureAnimatedImage(asset); const animated = ensureAnimatedImage(asset);
if (animated) { if (animated) {
mediaCache.set(asset.id, animated); mediaCache.set(asset.id, animated);

View File

@@ -31,6 +31,11 @@ export function isGifAsset(asset) {
return asset?.mediaType?.toLowerCase() === "image/gif"; return asset?.mediaType?.toLowerCase() === "image/gif";
} }
export function isApngAsset(asset) {
const type = (asset?.mediaType || "").toLowerCase();
return type === "image/apng";
}
export function getAssetKind(asset) { export function getAssetKind(asset) {
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
return AssetKind.AUDIO; return AssetKind.AUDIO;

View File

@@ -18,6 +18,35 @@ class MediaDetectionServiceTest {
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); 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 @Test
void fallsBackToFilenameAllowlist() throws IOException { void fallsBackToFilenameAllowlist() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[] { 1, 2, 3 }); MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[] { 1, 2, 3 });