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);
|
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.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) -> {
|
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user