diff --git a/Dockerfile b/Dockerfile
index 3097345..0d08903 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,7 @@ RUN mvn -B package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
+RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/target/imgfloat-*.jar app.jar
EXPOSE 8080 8443
ENV JAVA_OPTS=""
diff --git a/pom.xml b/pom.xml
index 4d6a5f6..d1c8b69 100644
--- a/pom.xml
+++ b/pom.xml
@@ -81,18 +81,6 @@
spring-boot-starter-validation
-
- org.jcodec
- jcodec
- 0.2.5
-
-
-
- org.jcodec
- jcodec-javase
- 0.2.5
-
-
org.springframework.session
spring-session-jdbc
diff --git a/shell.nix b/shell.nix
index f2c7e25..5c39ff5 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,10 +1,13 @@
-{ pkgs ? import { } }:
+{
+ pkgs ? import { },
+}:
pkgs.mkShell {
packages = [
pkgs.jdt-language-server
pkgs.libxkbcommon
pkgs.maven
+ pkgs.ffmpeg
pkgs.openjdk
pkgs.git-lfs
];
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java
index f1115e5..12030f3 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java
@@ -3,8 +3,8 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import java.io.IOException;
import java.nio.file.*;
+import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
import java.util.Locale;
-import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
@@ -16,30 +16,7 @@ import org.springframework.stereotype.Service;
public class AssetStorageService {
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
- private static final Map EXTENSIONS = Map.ofEntries(
- Map.entry("image/png", ".png"),
- Map.entry("image/jpeg", ".jpg"),
- Map.entry("image/jpg", ".jpg"),
- Map.entry("image/gif", ".gif"),
- Map.entry("image/webp", ".webp"),
- Map.entry("image/bmp", ".bmp"),
- Map.entry("image/tiff", ".tiff"),
- Map.entry("video/mp4", ".mp4"),
- Map.entry("video/webm", ".webm"),
- Map.entry("video/quicktime", ".mov"),
- Map.entry("video/x-matroska", ".mkv"),
- Map.entry("audio/mpeg", ".mp3"),
- Map.entry("audio/mp3", ".mp3"),
- Map.entry("audio/wav", ".wav"),
- Map.entry("audio/ogg", ".ogg"),
- Map.entry("audio/webm", ".webm"),
- Map.entry("audio/flac", ".flac"),
- Map.entry("model/gltf-binary", ".glb"),
- Map.entry("model/gltf+json", ".gltf"),
- Map.entry("model/obj", ".obj"),
- Map.entry("application/javascript", ".js"),
- Map.entry("text/javascript", ".js")
- );
+ private static final String DEFAULT_PREVIEW_MEDIA_TYPE = "image/png";
private final Path assetRoot;
private final Path previewRoot;
@@ -119,7 +96,7 @@ public class AssetStorageService {
if (!Files.exists(file)) return Optional.empty();
byte[] bytes = Files.readAllBytes(file);
- return Optional.of(new AssetContent(bytes, "image/png"));
+ return Optional.of(new AssetContent(bytes, DEFAULT_PREVIEW_MEDIA_TYPE));
} catch (Exception e) {
logger.warn("Failed to load preview {}", assetId, e);
return Optional.empty();
@@ -196,10 +173,9 @@ public class AssetStorageService {
}
private String resolveExtension(String mediaType) throws IOException {
- if (mediaType == null || !EXTENSIONS.containsKey(mediaType)) {
- throw new IOException("Unsupported media type: " + mediaType);
- }
- return EXTENSIONS.get(mediaType);
+ return MediaTypeRegistry
+ .extensionForMediaType(mediaType)
+ .orElseThrow(() -> new IOException("Unsupported media type: " + mediaType));
}
private Path assetPath(String broadcaster, String assetId, String mediaType) throws IOException {
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
index 8a179f2..7745c8e 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java
@@ -37,6 +37,7 @@ import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
+import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -303,11 +304,11 @@ public class ChannelDirectoryService {
byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService
.detectAllowedMediaType(file, bytes)
- .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
+ .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage()));
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) {
- return Optional.empty();
+ throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage());
}
String safeName = Optional.ofNullable(file.getOriginalFilename())
@@ -499,10 +500,10 @@ public class ChannelDirectoryService {
enforceUploadLimit(bytes.length);
String mediaType = mediaDetectionService
.detectAllowedMediaType(file, bytes)
- .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
+ .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage()));
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) {
- return Optional.empty();
+ throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage());
}
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
if (assetType != AssetType.IMAGE) {
@@ -1344,11 +1345,11 @@ public class ChannelDirectoryService {
byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService
.detectAllowedMediaType(file, bytes)
- .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
+ .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, unsupportedMediaTypeMessage()));
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) {
- return Optional.empty();
+ throw new ResponseStatusException(BAD_REQUEST, mediaProcessingErrorMessage());
}
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
@@ -1504,6 +1505,14 @@ public class ChannelDirectoryService {
return normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript");
}
+ private String unsupportedMediaTypeMessage() {
+ return "Unsupported media type. Supported types: " + MediaTypeRegistry.supportedMediaTypesSummary();
+ }
+
+ private String mediaProcessingErrorMessage() {
+ return "Unable to process media. Ensure ffmpeg is installed on the server.";
+ }
+
private void validateCodeAssetSource(String source) {
if (source == null || source.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Script source is required");
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java
new file mode 100644
index 0000000..949574c
--- /dev/null
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/FfmpegService.java
@@ -0,0 +1,168 @@
+package dev.kruhlmann.imgfloat.service.media;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+public class FfmpegService {
+
+ private static final Logger logger = LoggerFactory.getLogger(FfmpegService.class);
+
+ public Optional extractVideoDimensions(byte[] bytes) {
+ return Optional.ofNullable(withTempFile(bytes, ".bin", (input) -> {
+ List command = List.of(
+ "ffprobe",
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=width,height",
+ "-of",
+ "csv=p=0:s=x",
+ input.toString()
+ );
+ ProcessResult result = run(command);
+ if (result.exitCode() != 0) {
+ logger.warn("ffprobe failed: {}", result.output());
+ return null;
+ }
+ String output = result.output().trim();
+ if (output.isBlank() || !output.contains("x")) {
+ return null;
+ }
+ String[] parts = output.split("x", 2);
+ try {
+ int width = Integer.parseInt(parts[0].trim());
+ int height = Integer.parseInt(parts[1].trim());
+ return new VideoDimensions(width, height);
+ } catch (NumberFormatException e) {
+ logger.warn("Unable to parse ffprobe output: {}", output, e);
+ return null;
+ }
+ }));
+ }
+
+ public Optional extractVideoPreview(byte[] bytes) {
+ return Optional.ofNullable(withTempFile(bytes, ".bin", (input) -> {
+ Path output = Files.createTempFile("imgfloat-preview", ".png");
+ try {
+ List command = List.of(
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ input.toString(),
+ "-frames:v",
+ "1",
+ "-f",
+ "image2",
+ "-vcodec",
+ "png",
+ output.toString()
+ );
+ ProcessResult result = run(command);
+ if (result.exitCode() != 0) {
+ logger.warn("ffmpeg preview failed: {}", result.output());
+ return null;
+ }
+ return Files.readAllBytes(output);
+ } finally {
+ Files.deleteIfExists(output);
+ }
+ }));
+ }
+
+ public Optional transcodeGifToMp4(byte[] bytes) {
+ return Optional.ofNullable(withTempFile(bytes, ".gif", (input) -> {
+ Path output = Files.createTempFile("imgfloat-transcode", ".mp4");
+ try {
+ List command = List.of(
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ input.toString(),
+ "-movflags",
+ "+faststart",
+ "-pix_fmt",
+ "yuv420p",
+ "-vf",
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
+ output.toString()
+ );
+ ProcessResult result = run(command);
+ if (result.exitCode() != 0) {
+ logger.warn("ffmpeg transcode failed: {}", result.output());
+ return null;
+ }
+ return Files.readAllBytes(output);
+ } finally {
+ Files.deleteIfExists(output);
+ }
+ }));
+ }
+
+ private T withTempFile(byte[] bytes, String suffix, TempFileHandler handler) {
+ Path input = null;
+ try {
+ input = Files.createTempFile("imgfloat-input", suffix);
+ Files.write(input, bytes);
+ return handler.handle(input);
+ } catch (IOException e) {
+ logger.warn("Unable to create temporary media file", e);
+ return null;
+ } finally {
+ if (input != null) {
+ try {
+ Files.deleteIfExists(input);
+ } catch (IOException e) {
+ logger.warn("Unable to delete temporary file {}", input, e);
+ }
+ }
+ }
+ }
+
+ private ProcessResult run(List command) throws IOException {
+ ProcessBuilder builder = new ProcessBuilder(new ArrayList<>(command));
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ String output;
+ try (InputStream stream = process.getInputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ stream.transferTo(baos);
+ output = baos.toString(StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ output = "";
+ }
+ int exitCode;
+ try {
+ exitCode = process.waitFor();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ exitCode = -1;
+ }
+ return new ProcessResult(exitCode, output);
+ }
+
+ private interface TempFileHandler {
+ T handle(Path input) throws IOException;
+ }
+
+ private record ProcessResult(int exitCode, String output) {}
+
+ public record VideoDimensions(int width, int height) {}
+}
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java
index 6b65b26..d93018f 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java
@@ -4,9 +4,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URLConnection;
import java.util.Locale;
-import java.util.Map;
import java.util.Optional;
-import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -16,46 +14,27 @@ import org.springframework.web.multipart.MultipartFile;
public class MediaDetectionService {
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
- private static final Map EXTENSION_TYPES = Map.ofEntries(
- Map.entry("png", "image/png"),
- Map.entry("jpg", "image/jpeg"),
- Map.entry("jpeg", "image/jpeg"),
- Map.entry("gif", "image/gif"),
- Map.entry("webp", "image/webp"),
- Map.entry("mp4", "video/mp4"),
- Map.entry("webm", "video/webm"),
- Map.entry("mov", "video/quicktime"),
- Map.entry("mp3", "audio/mpeg"),
- Map.entry("wav", "audio/wav"),
- Map.entry("ogg", "audio/ogg"),
- Map.entry("glb", "model/gltf-binary"),
- Map.entry("gltf", "model/gltf+json"),
- Map.entry("obj", "model/obj"),
- Map.entry("js", "application/javascript"),
- Map.entry("mjs", "text/javascript")
- );
- private static final Set ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
public Optional detectAllowedMediaType(MultipartFile file, byte[] bytes) {
Optional detected = detectMediaType(bytes)
- .map(MediaDetectionService::normalizeJavaScriptMediaType)
- .filter(MediaDetectionService::isAllowedMediaType);
+ .map(MediaTypeRegistry::normalizeJavaScriptMediaType)
+ .filter(MediaTypeRegistry::isSupportedMediaType);
if (detected.isPresent()) {
return detected;
}
Optional declared = Optional.ofNullable(file.getContentType())
- .map(MediaDetectionService::normalizeJavaScriptMediaType)
- .filter(MediaDetectionService::isAllowedMediaType);
+ .map(MediaTypeRegistry::normalizeJavaScriptMediaType)
+ .filter(MediaTypeRegistry::isSupportedMediaType);
if (declared.isPresent()) {
return declared;
}
return Optional.ofNullable(file.getOriginalFilename())
- .map((name) -> name.replaceAll("^.*\\.", "").toLowerCase())
- .map(EXTENSION_TYPES::get)
- .filter(MediaDetectionService::isAllowedMediaType);
+ .map((name) -> name.replaceAll("^.*\\.", "").toLowerCase(Locale.ROOT))
+ .flatMap(MediaTypeRegistry::mediaTypeForExtension)
+ .filter(MediaTypeRegistry::isSupportedMediaType);
}
private Optional detectMediaType(byte[] bytes) {
@@ -72,8 +51,7 @@ public class MediaDetectionService {
}
public static boolean isAllowedMediaType(String mediaType) {
- String normalized = normalizeJavaScriptMediaType(mediaType);
- return normalized != null && ALLOWED_MEDIA_TYPES.contains(normalized.toLowerCase());
+ return MediaTypeRegistry.isSupportedMediaType(mediaType);
}
public static boolean isInlineDisplayType(String mediaType) {
@@ -82,15 +60,4 @@ public class MediaDetectionService {
(mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"))
);
}
-
- private static String normalizeJavaScriptMediaType(String mediaType) {
- if (mediaType == null) {
- return null;
- }
- String normalized = mediaType.toLowerCase(Locale.ROOT);
- if (normalized.contains("javascript") || normalized.contains("ecmascript")) {
- return "application/javascript";
- }
- return mediaType;
- }
}
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java
index 3565d5d..46bfaa8 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java
@@ -1,25 +1,9 @@
package dev.kruhlmann.imgfloat.service.media;
-import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.file.Files;
-import java.util.List;
-import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
-import javax.imageio.ImageWriteParam;
-import javax.imageio.ImageWriter;
-import javax.imageio.metadata.IIOMetadata;
-import javax.imageio.stream.ImageInputStream;
-import javax.imageio.stream.ImageOutputStream;
-import org.jcodec.api.FrameGrab;
-import org.jcodec.api.JCodecException;
-import org.jcodec.common.io.ByteBufferSeekableByteChannel;
-import org.jcodec.common.model.Picture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -27,12 +11,18 @@ import org.springframework.stereotype.Service;
@Service
public class MediaOptimizationService {
- private static final int MIN_GIF_DELAY_MS = 20;
private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class);
- private final MediaPreviewService previewService;
+ private static final FfmpegService.VideoDimensions DEFAULT_VIDEO_DIMENSIONS = new FfmpegService.VideoDimensions(
+ 640,
+ 360
+ );
- public MediaOptimizationService(MediaPreviewService previewService) {
+ private final MediaPreviewService previewService;
+ private final FfmpegService ffmpegService;
+
+ public MediaOptimizationService(MediaPreviewService previewService, FfmpegService ffmpegService) {
this.previewService = previewService;
+ this.ffmpegService = ffmpegService;
}
public OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
@@ -46,15 +36,6 @@ public class MediaOptimizationService {
}
}
- if (mediaType.startsWith("image/") && !"image/gif".equalsIgnoreCase(mediaType)) {
- BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
- if (image == null) {
- return null;
- }
- byte[] compressed = compressPng(image);
- return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight(), null);
- }
-
if (mediaType.startsWith("image/")) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image == null) {
@@ -64,7 +45,9 @@ public class MediaOptimizationService {
}
if (mediaType.startsWith("video/")) {
- var dimensions = extractVideoDimensions(bytes);
+ FfmpegService.VideoDimensions dimensions = ffmpegService
+ .extractVideoDimensions(bytes)
+ .orElse(DEFAULT_VIDEO_DIMENSIONS);
byte[] preview = previewService.extractVideoPreview(bytes, mediaType);
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview);
}
@@ -89,165 +72,18 @@ public class MediaOptimizationService {
}
private OptimizedAsset transcodeGifToVideo(byte[] bytes) {
- try {
- List frames = readGifFrames(bytes);
- if (frames.isEmpty()) {
+ return ffmpegService
+ .transcodeGifToMp4(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);
+ })
+ .orElseGet(() -> {
+ logger.warn("Unable to transcode GIF to video via ffmpeg");
return null;
- }
- int baseDelay = frames
- .stream()
- .mapToInt((frame) -> normalizeDelay(frame.delayMs()))
- .reduce(this::greatestCommonDivisor)
- .orElse(100);
- int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay));
- File temp = File.createTempFile("gif-convert", ".mp4");
- temp.deleteOnExit();
- try {
- var encoder = org.jcodec.api.awt.AWTSequenceEncoder.createSequenceEncoder(temp, fps);
- for (GifFrame frame : frames) {
- BufferedImage image = ensureEvenDimensions(frame.image());
- int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay);
- for (int i = 0; i < repeats; i++) {
- encoder.encodeImage(image);
- }
- }
- encoder.finish();
- BufferedImage cover = ensureEvenDimensions(frames.get(0).image());
- byte[] video = Files.readAllBytes(temp.toPath());
- return new OptimizedAsset(
- video,
- "video/mp4",
- cover.getWidth(),
- cover.getHeight(),
- previewService.encodePreview(cover)
- );
- } finally {
- Files.deleteIfExists(temp.toPath());
- }
- } catch (IOException e) {
- logger.warn("Unable to transcode GIF to video", e);
- return null;
- }
+ });
}
-
- private List readGifFrames(byte[] bytes) throws IOException {
- try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
- var readers = ImageIO.getImageReadersByFormatName("gif");
- if (!readers.hasNext()) {
- return List.of();
- }
- var reader = readers.next();
- try {
- reader.setInput(stream, false, false);
- int count = reader.getNumImages(true);
- var frames = new java.util.ArrayList(count);
- for (int i = 0; i < count; i++) {
- BufferedImage image = reader.read(i);
- IIOMetadata metadata = reader.getImageMetadata(i);
- int delay = extractDelayMs(metadata);
- frames.add(new GifFrame(image, delay));
- }
- return frames;
- } finally {
- reader.dispose();
- }
- }
- }
-
- private int extractDelayMs(IIOMetadata metadata) {
- if (metadata == null) {
- return 100;
- }
- try {
- String format = metadata.getNativeMetadataFormatName();
- var root = metadata.getAsTree(format);
- var children = root.getChildNodes();
- for (int i = 0; i < children.getLength(); i++) {
- var node = children.item(i);
- if ("GraphicControlExtension".equals(node.getNodeName()) && node.getAttributes() != null) {
- var delay = node.getAttributes().getNamedItem("delayTime");
- if (delay != null) {
- int hundredths = Integer.parseInt(delay.getNodeValue());
- return Math.max(hundredths * 10, MIN_GIF_DELAY_MS);
- }
- }
- }
- } catch (Exception e) {
- logger.warn("Unable to parse GIF delay", e);
- }
- return 100;
- }
-
- private int normalizeDelay(int delayMs) {
- return Math.max(delayMs, MIN_GIF_DELAY_MS);
- }
-
- private int greatestCommonDivisor(int a, int b) {
- if (b == 0) {
- return Math.max(a, 1);
- }
- return greatestCommonDivisor(b, a % b);
- }
-
- private byte[] compressPng(BufferedImage image) throws IOException {
- var writers = ImageIO.getImageWritersByFormatName("png");
- if (!writers.hasNext()) {
- logger.warn("No PNG writer available; skipping compression");
- try (ByteArrayOutputStream fallback = new ByteArrayOutputStream()) {
- ImageIO.write(image, "png", fallback);
- return fallback.toByteArray();
- }
- }
- ImageWriter writer = writers.next();
- try (
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- ImageOutputStream ios = ImageIO.createImageOutputStream(baos)
- ) {
- writer.setOutput(ios);
- ImageWriteParam param = writer.getDefaultWriteParam();
- if (param.canWriteCompressed()) {
- param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
- param.setCompressionQuality(1.0f);
- }
- writer.write(null, new IIOImage(image, null, null), param);
- return baos.toByteArray();
- } finally {
- writer.dispose();
- }
- }
-
- private BufferedImage ensureEvenDimensions(BufferedImage image) {
- int width = image.getWidth();
- int height = image.getHeight();
- int evenWidth = width % 2 == 0 ? width : width + 1;
- int evenHeight = height % 2 == 0 ? height : height + 1;
- if (evenWidth == width && evenHeight == height) {
- return image;
- }
- BufferedImage padded = new BufferedImage(evenWidth, evenHeight, BufferedImage.TYPE_INT_ARGB);
- Graphics2D graphics = padded.createGraphics();
- try {
- graphics.drawImage(image, 0, 0, null);
- } finally {
- graphics.dispose();
- }
- return padded;
- }
-
- private Dimension extractVideoDimensions(byte[] bytes) {
- try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
- FrameGrab grab = FrameGrab.createFrameGrab(channel);
- Picture frame = grab.getNativeFrame();
- if (frame != null) {
- return new Dimension(frame.getWidth(), frame.getHeight());
- }
- } catch (IOException | JCodecException e) {
- logger.warn("Unable to read video dimensions", e);
- }
- return new Dimension(640, 360);
- }
-
- private record GifFrame(BufferedImage image, int delayMs) {}
-
- private record Dimension(int width, int height) {}
}
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java
index d9af534..0ca67ae 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java
@@ -1,15 +1,5 @@
package dev.kruhlmann.imgfloat.service.media;
-import java.awt.image.BufferedImage;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import javax.imageio.ImageIO;
-import org.jcodec.api.FrameGrab;
-import org.jcodec.api.JCodecException;
-import org.jcodec.common.io.ByteBufferSeekableByteChannel;
-import org.jcodec.common.model.Picture;
-import org.jcodec.scale.AWTUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -19,31 +9,18 @@ public class MediaPreviewService {
private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class);
- public byte[] encodePreview(BufferedImage image) {
- if (image == null) {
- return null;
- }
- try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
- ImageIO.write(image, "png", baos);
- return baos.toByteArray();
- } catch (IOException e) {
- logger.warn("Unable to encode preview image", e);
- return null;
- }
+ private final FfmpegService ffmpegService;
+
+ public MediaPreviewService(FfmpegService ffmpegService) {
+ this.ffmpegService = ffmpegService;
}
public byte[] extractVideoPreview(byte[] bytes, String mediaType) {
- try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
- FrameGrab grab = FrameGrab.createFrameGrab(channel);
- Picture frame = grab.getNativeFrame();
- if (frame == null) {
+ return ffmpegService
+ .extractVideoPreview(bytes)
+ .orElseGet(() -> {
+ logger.warn("Unable to capture video preview frame for {}", mediaType);
return null;
- }
- BufferedImage image = AWTUtil.toBufferedImage(frame);
- return encodePreview(image);
- } catch (IOException | JCodecException e) {
- logger.warn("Unable to capture video preview frame for {}", mediaType, e);
- return null;
- }
+ });
}
}
diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java
new file mode 100644
index 0000000..493d644
--- /dev/null
+++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaTypeRegistry.java
@@ -0,0 +1,111 @@
+package dev.kruhlmann.imgfloat.service.media;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+public final class MediaTypeRegistry {
+
+ private static final Map EXTENSION_TO_MEDIA_TYPE = buildExtensionMap();
+ private static final Map MEDIA_TYPE_TO_EXTENSION = buildMediaTypeMap();
+ private static final Set SUPPORTED_MEDIA_TYPES = Set.copyOf(MEDIA_TYPE_TO_EXTENSION.keySet());
+
+ private MediaTypeRegistry() {}
+
+ public static Optional mediaTypeForExtension(String extension) {
+ if (extension == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(EXTENSION_TO_MEDIA_TYPE.get(extension.toLowerCase(Locale.ROOT)));
+ }
+
+ public static Optional extensionForMediaType(String mediaType) {
+ if (mediaType == null) {
+ return Optional.empty();
+ }
+ String normalized = normalizeJavaScriptMediaType(mediaType).toLowerCase(Locale.ROOT);
+ return Optional.ofNullable(MEDIA_TYPE_TO_EXTENSION.get(normalized));
+ }
+
+ public static boolean isSupportedMediaType(String mediaType) {
+ if (mediaType == null || mediaType.isBlank()) {
+ return false;
+ }
+ String normalized = normalizeJavaScriptMediaType(mediaType).toLowerCase(Locale.ROOT);
+ return SUPPORTED_MEDIA_TYPES.contains(normalized);
+ }
+
+ public static List supportedMediaTypes() {
+ return SUPPORTED_MEDIA_TYPES.stream().sorted().toList();
+ }
+
+ public static String supportedMediaTypesSummary() {
+ return String.join(", ", supportedMediaTypes());
+ }
+
+ public static String normalizeJavaScriptMediaType(String mediaType) {
+ if (mediaType == null) {
+ return null;
+ }
+ String normalized = mediaType.toLowerCase(Locale.ROOT);
+ if (normalized.contains("javascript") || normalized.contains("ecmascript")) {
+ return "application/javascript";
+ }
+ return mediaType;
+ }
+
+ private static Map buildExtensionMap() {
+ Map map = new LinkedHashMap<>();
+ map.put("png", "image/png");
+ map.put("jpg", "image/jpeg");
+ map.put("jpeg", "image/jpeg");
+ map.put("gif", "image/gif");
+ map.put("webp", "image/webp");
+ map.put("bmp", "image/bmp");
+ map.put("tiff", "image/tiff");
+ map.put("mp4", "video/mp4");
+ map.put("webm", "video/webm");
+ map.put("mov", "video/quicktime");
+ map.put("mkv", "video/x-matroska");
+ map.put("mp3", "audio/mpeg");
+ map.put("wav", "audio/wav");
+ map.put("ogg", "audio/ogg");
+ map.put("flac", "audio/flac");
+ map.put("glb", "model/gltf-binary");
+ map.put("gltf", "model/gltf+json");
+ map.put("obj", "model/obj");
+ map.put("js", "application/javascript");
+ map.put("mjs", "text/javascript");
+ return Map.copyOf(map);
+ }
+
+ private static Map buildMediaTypeMap() {
+ Map map = new LinkedHashMap<>();
+ map.put("image/png", ".png");
+ map.put("image/jpeg", ".jpg");
+ map.put("image/jpg", ".jpg");
+ map.put("image/gif", ".gif");
+ map.put("image/webp", ".webp");
+ map.put("image/bmp", ".bmp");
+ map.put("image/tiff", ".tiff");
+ map.put("video/mp4", ".mp4");
+ map.put("video/webm", ".webm");
+ map.put("video/quicktime", ".mov");
+ map.put("video/x-matroska", ".mkv");
+ map.put("audio/mpeg", ".mp3");
+ map.put("audio/mp3", ".mp3");
+ map.put("audio/wav", ".wav");
+ map.put("audio/ogg", ".ogg");
+ map.put("audio/webm", ".webm");
+ map.put("audio/flac", ".flac");
+ map.put("model/gltf-binary", ".glb");
+ map.put("model/gltf+json", ".gltf");
+ map.put("model/obj", ".obj");
+ map.put("application/javascript", ".js");
+ map.put("text/javascript", ".js");
+ return Map.copyOf(map);
+ }
+}
diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js
index 4d178d7..4c75276 100644
--- a/src/main/resources/static/js/admin/console.js
+++ b/src/main/resources/static/js/admin/console.js
@@ -2406,7 +2406,9 @@ export function createAdminConsole({
})
.then((response) => {
if (!response.ok) {
- throw new Error("Upload failed");
+ return extractErrorMessage(response, "Upload failed").then((message) => {
+ throw new Error(message);
+ });
}
if (fileInput) {
fileInput.value = "";
@@ -2421,10 +2423,31 @@ export function createAdminConsole({
}
console.error(e);
removePendingUpload(pendingId);
- showToast("Upload failed. Please try again with a supported file.", "error");
+ showToast(e?.message || "Upload failed. Please try again with a supported file.", "error");
});
}
+ function extractErrorMessage(response, fallback) {
+ if (!response) {
+ return Promise.resolve(fallback);
+ }
+ return response
+ .json()
+ .then((data) => {
+ if (data?.message) {
+ return data.message;
+ }
+ if (data?.error) {
+ return data.error;
+ }
+ if (typeof data === "string" && data.trim()) {
+ return data;
+ }
+ return fallback;
+ })
+ .catch(() => response.text().then((text) => text?.trim() || fallback).catch(() => fallback));
+ }
+
function getCanvasPoint(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js
index 51f5a6b..bcf837f 100644
--- a/src/main/resources/static/js/customAssets.js
+++ b/src/main/resources/static/js/customAssets.js
@@ -390,7 +390,7 @@ export function createCustomAssetModal({
}
})
.catch((e) => {
- showToast?.("Unable to save custom asset. Please try again.", "error");
+ showToast?.(e?.message || "Unable to save custom asset. Please try again.", "error");
console.error(e);
})
.finally(() => {
@@ -498,7 +498,7 @@ export function createCustomAssetModal({
})
.catch((error) => {
console.error(error);
- showToast?.("Unable to upload attachment. Please try again.", "error");
+ showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error");
})
.finally(() => {
attachmentInput.value = "";
@@ -591,12 +591,35 @@ export function createCustomAssetModal({
body: payload,
}).then((response) => {
if (!response.ok) {
- throw new Error("Failed to upload attachment");
+ return extractErrorMessage(response, "Failed to upload attachment").then((message) => {
+ throw new Error(message);
+ });
}
return response.json();
});
}
+ function extractErrorMessage(response, fallback) {
+ if (!response) {
+ return Promise.resolve(fallback);
+ }
+ return response
+ .json()
+ .then((data) => {
+ if (data?.message) {
+ return data.message;
+ }
+ if (data?.error) {
+ return data.error;
+ }
+ if (typeof data === "string" && data.trim()) {
+ return data;
+ }
+ return fallback;
+ })
+ .catch(() => response.text().then((text) => text?.trim() || fallback).catch(() => fallback));
+ }
+
function removeAttachment(attachmentId) {
if (!attachmentId || !currentAssetId) {
return;
@@ -694,7 +717,9 @@ export function createCustomAssetModal({
body: payload,
}).then((response) => {
if (!response.ok) {
- throw new Error("Failed to upload logo");
+ return extractErrorMessage(response, "Failed to upload logo").then((message) => {
+ throw new Error(message);
+ });
}
pendingLogoFile = null;
return response.json();
diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java
index 4adacb6..d620bc7 100644
--- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java
+++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java
@@ -33,6 +33,7 @@ import dev.kruhlmann.imgfloat.service.SettingsService;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.MediaPreviewService;
+import dev.kruhlmann.imgfloat.service.media.FfmpegService;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -88,8 +89,9 @@ class ChannelDirectoryServiceTest {
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
Path previewRoot = Files.createTempDirectory("imgfloat-previews-test");
AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString());
- MediaPreviewService mediaPreviewService = new MediaPreviewService();
- MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
+ FfmpegService ffmpegService = new FfmpegService();
+ MediaPreviewService mediaPreviewService = new MediaPreviewService(ffmpegService);
+ MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService, ffmpegService);
MediaDetectionService mediaDetectionService = new MediaDetectionService();
long uploadLimitBytes = 5_000_000L;
Path marketplaceRoot = Files.createTempDirectory("imgfloat-marketplace-test");
diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java
index ef3bdf1..92fed98 100644
--- a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java
+++ b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java
@@ -15,7 +15,8 @@ class MediaOptimizationServiceTest {
@BeforeEach
void setUp() {
- service = new MediaOptimizationService(new MediaPreviewService());
+ FfmpegService ffmpegService = new FfmpegService();
+ service = new MediaOptimizationService(new MediaPreviewService(ffmpegService), ffmpegService);
}
@Test