mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39: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");
|
||||
|
||||
Reference in New Issue
Block a user