mirror of
https://github.com/imgfloat/server.git
synced 2026-02-04 19:29:26 +00:00
Add APNG support
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user