From 3e6d5fa596fc28c10658ffea25b565a6596a3962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 11 Dec 2025 02:34:56 +0100 Subject: [PATCH] Refactor --- .../app/service/AssetStorageService.java | 161 +++++++ .../app/service/ChannelDirectoryService.java | 434 +----------------- .../app/service/media/AssetContent.java | 3 + .../service/media/MediaDetectionService.java | 48 ++ .../media/MediaOptimizationService.java | 217 +++++++++ .../service/media/MediaPreviewService.java | 49 ++ .../app/service/media/OptimizedAsset.java | 3 + .../app/ChannelDirectoryServiceTest.java | 10 +- .../app/service/AssetStorageServiceTest.java | 57 +++ .../media/MediaDetectionServiceTest.java | 34 ++ .../media/MediaOptimizationServiceTest.java | 53 +++ 11 files changed, 656 insertions(+), 413 deletions(-) create mode 100644 src/main/java/com/imgfloat/app/service/AssetStorageService.java create mode 100644 src/main/java/com/imgfloat/app/service/media/AssetContent.java create mode 100644 src/main/java/com/imgfloat/app/service/media/MediaDetectionService.java create mode 100644 src/main/java/com/imgfloat/app/service/media/MediaOptimizationService.java create mode 100644 src/main/java/com/imgfloat/app/service/media/MediaPreviewService.java create mode 100644 src/main/java/com/imgfloat/app/service/media/OptimizedAsset.java create mode 100644 src/test/java/com/imgfloat/app/service/AssetStorageServiceTest.java create mode 100644 src/test/java/com/imgfloat/app/service/media/MediaDetectionServiceTest.java create mode 100644 src/test/java/com/imgfloat/app/service/media/MediaOptimizationServiceTest.java diff --git a/src/main/java/com/imgfloat/app/service/AssetStorageService.java b/src/main/java/com/imgfloat/app/service/AssetStorageService.java new file mode 100644 index 0000000..74554c2 --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/AssetStorageService.java @@ -0,0 +1,161 @@ +package com.imgfloat.app.service; + +import com.imgfloat.app.service.media.AssetContent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Locale; +import java.util.Optional; + +@Service +public class AssetStorageService { + private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class); + private final Path assetRoot; + private final Path previewRoot; + + public AssetStorageService(@Value("${IMGFLOAT_ASSETS_PATH:assets}") String assetRoot, + @Value("${IMGFLOAT_PREVIEWS_PATH:previews}") String previewRoot) { + this.assetRoot = Paths.get(assetRoot); + this.previewRoot = Paths.get(previewRoot); + } + + public String storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException { + if (assetBytes == null || assetBytes.length == 0) { + throw new IOException("Asset content is empty"); + } + Path directory = assetRoot.resolve(normalize(broadcaster)); + Files.createDirectories(directory); + String extension = extensionForMediaType(mediaType); + Path assetFile = directory.resolve(assetId + extension); + Files.write(assetFile, assetBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + return assetFile.toString(); + } + + public String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { + if (previewBytes == null || previewBytes.length == 0) { + return null; + } + Path directory = previewRoot.resolve(normalize(broadcaster)); + Files.createDirectories(directory); + Path previewFile = directory.resolve(assetId + ".png"); + Files.write(previewFile, previewBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + return previewFile.toString(); + } + + public Optional loadPreview(String previewPath) { + if (previewPath == null || previewPath.isBlank()) { + return Optional.empty(); + } + try { + Path path = Paths.get(previewPath); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + return Optional.of(new AssetContent(Files.readAllBytes(path), "image/png")); + } catch (IOException e) { + logger.warn("Unable to read preview from {}", previewPath, e); + return Optional.empty(); + } + } catch (InvalidPathException e) { + logger.debug("Preview path {} is not a file path; skipping", previewPath); + return Optional.empty(); + } + } + + public Optional loadAssetFile(String assetPath, String mediaType) { + if (assetPath == null || assetPath.isBlank()) { + return Optional.empty(); + } + try { + Path path = Paths.get(assetPath); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + String resolvedMediaType = mediaType; + if (resolvedMediaType == null || resolvedMediaType.isBlank()) { + resolvedMediaType = Files.probeContentType(path); + } + if (resolvedMediaType == null || resolvedMediaType.isBlank()) { + resolvedMediaType = "application/octet-stream"; + } + return Optional.of(new AssetContent(Files.readAllBytes(path), resolvedMediaType)); + } catch (IOException e) { + logger.warn("Unable to read asset from {}", assetPath, e); + return Optional.empty(); + } + } catch (InvalidPathException e) { + logger.debug("Asset path {} is not a file path; skipping", assetPath); + return Optional.empty(); + } + } + + public void deleteAssetFile(String assetPath) { + if (assetPath == null || assetPath.isBlank()) { + return; + } + try { + Path path = Paths.get(assetPath); + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Unable to delete asset file {}", assetPath, e); + } + } catch (InvalidPathException e) { + logger.debug("Asset value {} is not a file path; nothing to delete", assetPath); + } + } + + public void deletePreviewFile(String previewPath) { + if (previewPath == null || previewPath.isBlank()) { + return; + } + try { + Path path = Paths.get(previewPath); + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Unable to delete preview file {}", previewPath, e); + } + } catch (InvalidPathException e) { + logger.debug("Preview value {} is not a file path; nothing to delete", previewPath); + } + } + + private String extensionForMediaType(String mediaType) { + if (mediaType == null || mediaType.isBlank()) { + return ".bin"; + } + return switch (mediaType.toLowerCase(Locale.ROOT)) { + case "image/png" -> ".png"; + case "image/jpeg", "image/jpg" -> ".jpg"; + case "image/gif" -> ".gif"; + case "video/mp4" -> ".mp4"; + case "video/webm" -> ".webm"; + case "video/quicktime" -> ".mov"; + case "audio/mpeg" -> ".mp3"; + case "audio/wav" -> ".wav"; + case "audio/ogg" -> ".ogg"; + default -> { + int slash = mediaType.indexOf('/'); + if (slash > -1 && slash < mediaType.length() - 1) { + yield "." + mediaType.substring(slash + 1).replaceAll("[^a-z0-9.+-]", ""); + } + yield ".bin"; + } + }; + } + + private String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index ed20038..e02900e 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -11,55 +11,30 @@ import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.repository.AssetRepository; import com.imgfloat.app.repository.ChannelRepository; -import org.jcodec.api.FrameGrab; -import org.jcodec.api.JCodecException; -import org.jcodec.api.awt.AWTSequenceEncoder; -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.beans.factory.annotation.Value; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; -import java.net.URLConnection; -import java.nio.ByteBuffer; -import java.nio.file.InvalidPathException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; import java.util.Base64; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Optional; -import javax.imageio.ImageIO; -import javax.imageio.ImageReader; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.IIOImage; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.stream.ImageOutputStream; -import javax.imageio.stream.ImageInputStream; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; + +import com.imgfloat.app.service.media.AssetContent; +import com.imgfloat.app.service.media.MediaDetectionService; +import com.imgfloat.app.service.media.MediaOptimizationService; +import com.imgfloat.app.service.media.OptimizedAsset; import static org.springframework.http.HttpStatus.BAD_REQUEST; @Service public class ChannelDirectoryService { - private static final int MIN_GIF_DELAY_MS = 20; - private static final String PREVIEW_MEDIA_TYPE = "image/png"; private static final double MAX_SPEED = 4.0; private static final double MIN_AUDIO_SPEED = 0.1; private static final double MAX_AUDIO_SPEED = 4.0; @@ -70,19 +45,22 @@ public class ChannelDirectoryService { private final ChannelRepository channelRepository; private final AssetRepository assetRepository; private final SimpMessagingTemplate messagingTemplate; - private final Path assetRoot; - private final Path previewRoot; + private final AssetStorageService assetStorageService; + private final MediaDetectionService mediaDetectionService; + private final MediaOptimizationService mediaOptimizationService; public ChannelDirectoryService(ChannelRepository channelRepository, AssetRepository assetRepository, SimpMessagingTemplate messagingTemplate, - @Value("${IMGFLOAT_ASSETS_PATH:assets}") String assetRoot, - @Value("${IMGFLOAT_PREVIEWS_PATH:previews}") String previewRoot) { + AssetStorageService assetStorageService, + MediaDetectionService mediaDetectionService, + MediaOptimizationService mediaOptimizationService) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; this.messagingTemplate = messagingTemplate; - this.assetRoot = Paths.get(assetRoot); - this.previewRoot = Paths.get(previewRoot); + this.assetStorageService = assetStorageService; + this.mediaDetectionService = mediaDetectionService; + this.mediaOptimizationService = mediaOptimizationService; } public Channel getOrCreateChannel(String broadcaster) { @@ -146,9 +124,9 @@ public class ChannelDirectoryService { public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = file.getBytes(); - String mediaType = detectMediaType(file, bytes); + String mediaType = mediaDetectionService.detectMediaType(file, bytes); - OptimizedAsset optimized = optimizeAsset(bytes, mediaType); + OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { return Optional.empty(); } @@ -163,8 +141,8 @@ public class ChannelDirectoryService { Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); - asset.setUrl(storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType())); - asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes())); + asset.setUrl(assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType())); + asset.setPreview(assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes())); asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setAudioLoop(false); @@ -281,8 +259,8 @@ public class ChannelDirectoryService { return assetRepository.findById(assetId) .filter(asset -> normalized.equals(asset.getBroadcaster())) .map(asset -> { - deleteAssetFile(asset.getUrl()); - deletePreviewFile(asset.getPreview()); + assetStorageService.deleteAssetFile(asset.getUrl()); + assetStorageService.deletePreviewFile(asset.getPreview()); assetRepository.delete(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); return true; @@ -311,7 +289,7 @@ public class ChannelDirectoryService { .filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(asset -> includeHidden || !asset.isHidden()) .map(asset -> { - Optional preview = loadPreview(asset.getPreview()) + Optional preview = assetStorageService.loadPreview(asset.getPreview()) .or(() -> decodeDataUrl(asset.getPreview())); if (preview.isPresent()) { return preview.get(); @@ -363,7 +341,7 @@ public class ChannelDirectoryService { } private Optional decodeAssetData(Asset asset) { - return loadAssetFile(asset.getUrl(), asset.getMediaType()) + return assetStorageService.loadAssetFile(asset.getUrl(), asset.getMediaType()) .or(() -> decodeDataUrl(asset.getUrl())) .or(() -> { logger.warn("Unable to decode asset data for {}", asset.getId()); @@ -392,134 +370,6 @@ public class ChannelDirectoryService { } } - private Optional loadPreview(String previewPath) { - if (previewPath == null || previewPath.isBlank()) { - return Optional.empty(); - } - try { - Path path = Paths.get(previewPath); - if (!Files.exists(path)) { - return Optional.empty(); - } - try { - return Optional.of(new AssetContent(Files.readAllBytes(path), PREVIEW_MEDIA_TYPE)); - } catch (IOException e) { - logger.warn("Unable to read preview from {}", previewPath, e); - return Optional.empty(); - } - } catch (InvalidPathException e) { - logger.debug("Preview path {} is not a file path; skipping", previewPath); - return Optional.empty(); - } - } - - private Optional loadAssetFile(String assetPath, String mediaType) { - if (assetPath == null || assetPath.isBlank()) { - return Optional.empty(); - } - try { - Path path = Paths.get(assetPath); - if (!Files.exists(path)) { - return Optional.empty(); - } - try { - String resolvedMediaType = mediaType; - if (resolvedMediaType == null || resolvedMediaType.isBlank()) { - resolvedMediaType = Files.probeContentType(path); - } - if (resolvedMediaType == null || resolvedMediaType.isBlank()) { - resolvedMediaType = "application/octet-stream"; - } - return Optional.of(new AssetContent(Files.readAllBytes(path), resolvedMediaType)); - } catch (IOException e) { - logger.warn("Unable to read asset from {}", assetPath, e); - return Optional.empty(); - } - } catch (InvalidPathException e) { - logger.debug("Asset path {} is not a file path; skipping", assetPath); - return Optional.empty(); - } - } - - private String storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException { - if (assetBytes == null || assetBytes.length == 0) { - throw new IOException("Asset content is empty"); - } - Path directory = assetRoot.resolve(normalize(broadcaster)); - Files.createDirectories(directory); - String extension = extensionForMediaType(mediaType); - Path assetFile = directory.resolve(assetId + extension); - Files.write(assetFile, assetBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - return assetFile.toString(); - } - - private void deleteAssetFile(String assetPath) { - if (assetPath == null || assetPath.isBlank()) { - return; - } - try { - Path path = Paths.get(assetPath); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - logger.warn("Unable to delete asset file {}", assetPath, e); - } - } catch (InvalidPathException e) { - logger.debug("Asset value {} is not a file path; nothing to delete", assetPath); - } - } - - private String extensionForMediaType(String mediaType) { - if (mediaType == null || mediaType.isBlank()) { - return ".bin"; - } - return switch (mediaType.toLowerCase(Locale.ROOT)) { - case "image/png" -> ".png"; - case "image/jpeg", "image/jpg" -> ".jpg"; - case "image/gif" -> ".gif"; - case "video/mp4" -> ".mp4"; - case "video/webm" -> ".webm"; - case "video/quicktime" -> ".mov"; - case "audio/mpeg" -> ".mp3"; - case "audio/wav" -> ".wav"; - case "audio/ogg" -> ".ogg"; - default -> { - int slash = mediaType.indexOf('/'); - if (slash > -1 && slash < mediaType.length() - 1) { - yield "." + mediaType.substring(slash + 1).replaceAll("[^a-z0-9.+-]", ""); - } - yield ".bin"; - } - }; - } - - private String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { - if (previewBytes == null || previewBytes.length == 0) { - return null; - } - Path directory = previewRoot.resolve(normalize(broadcaster)); - Files.createDirectories(directory); - Path previewFile = directory.resolve(assetId + ".png"); - Files.write(previewFile, previewBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - return previewFile.toString(); - } - - private void deletePreviewFile(String previewPath) { - if (previewPath == null || previewPath.isBlank()) { - return; - } - try { - Path path = Paths.get(previewPath); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - logger.warn("Unable to delete preview file {}", previewPath, e); - } - } catch (InvalidPathException e) { - logger.debug("Preview value {} is not a file path; nothing to delete", previewPath); - } - } - private int nextZIndex(String broadcaster) { return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() .mapToInt(Asset::getZIndex) @@ -527,244 +377,4 @@ public class ChannelDirectoryService { .orElse(0) + 1; } - private String detectMediaType(MultipartFile file, byte[] bytes) { - String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream"); - if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) { - return contentType; - } - - try (var stream = new ByteArrayInputStream(bytes)) { - String guessed = URLConnection.guessContentTypeFromStream(stream); - if (guessed != null && !guessed.isBlank()) { - return guessed; - } - } catch (IOException e) { - logger.warn("Unable to detect content type from stream", e); - } - - return Optional.ofNullable(file.getOriginalFilename()) - .map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) - .map(ext -> switch (ext) { - case "png" -> "image/png"; - case "jpg", "jpeg" -> "image/jpeg"; - case "gif" -> "image/gif"; - case "mp4" -> "video/mp4"; - case "webm" -> "video/webm"; - case "mov" -> "video/quicktime"; - case "mp3" -> "audio/mpeg"; - case "wav" -> "audio/wav"; - case "ogg" -> "audio/ogg"; - default -> "application/octet-stream"; - }) - .orElse("application/octet-stream"); - } - - private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException { - if ("image/gif".equalsIgnoreCase(mediaType)) { - OptimizedAsset transcoded = transcodeGifToVideo(bytes); - if (transcoded != null) { - return transcoded; - } - } - - 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) { - return null; - } - return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); - } - - if (mediaType.startsWith("video/")) { - var dimensions = extractVideoDimensions(bytes); - byte[] preview = extractVideoPreview(bytes, mediaType); - return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview); - } - - if (mediaType.startsWith("audio/")) { - 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 null; - } - - private OptimizedAsset transcodeGifToVideo(byte[] bytes) { - try { - List frames = readGifFrames(bytes); - if (frames.isEmpty()) { - 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 { - AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, fps); - for (GifFrame frame : frames) { - int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay); - for (int i = 0; i < repeats; i++) { - encoder.encodeImage(frame.image()); - } - } - encoder.finish(); - BufferedImage cover = frames.get(0).image(); - byte[] video = Files.readAllBytes(temp.toPath()); - return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), 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(); - } - ImageReader 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(); - Node root = metadata.getAsTree(format); - NodeList children = root.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) { - Node node = children.item(i); - if ("GraphicControlExtension".equals(node.getNodeName()) && node.getAttributes() != null) { - Node 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 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 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 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 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; - } - } - - public record AssetContent(byte[] bytes, String mediaType) { } - - private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { } - - private record GifFrame(BufferedImage image, int delayMs) { } - - private record Dimension(int width, int height) { } } diff --git a/src/main/java/com/imgfloat/app/service/media/AssetContent.java b/src/main/java/com/imgfloat/app/service/media/AssetContent.java new file mode 100644 index 0000000..aacd42e --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/media/AssetContent.java @@ -0,0 +1,3 @@ +package com.imgfloat.app.service.media; + +public record AssetContent(byte[] bytes, String mediaType) { } diff --git a/src/main/java/com/imgfloat/app/service/media/MediaDetectionService.java b/src/main/java/com/imgfloat/app/service/media/MediaDetectionService.java new file mode 100644 index 0000000..ce11ab3 --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/media/MediaDetectionService.java @@ -0,0 +1,48 @@ +package com.imgfloat.app.service.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URLConnection; +import java.util.Optional; + +@Service +public class MediaDetectionService { + private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class); + + public String detectMediaType(MultipartFile file, byte[] bytes) { + String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream"); + if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) { + return contentType; + } + + try (var stream = new ByteArrayInputStream(bytes)) { + String guessed = URLConnection.guessContentTypeFromStream(stream); + if (guessed != null && !guessed.isBlank()) { + return guessed; + } + } catch (IOException e) { + logger.warn("Unable to detect content type from stream", e); + } + + return Optional.ofNullable(file.getOriginalFilename()) + .map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) + .map(ext -> switch (ext) { + case "png" -> "image/png"; + case "jpg", "jpeg" -> "image/jpeg"; + case "gif" -> "image/gif"; + case "mp4" -> "video/mp4"; + case "webm" -> "video/webm"; + case "mov" -> "video/quicktime"; + case "mp3" -> "audio/mpeg"; + case "wav" -> "audio/wav"; + case "ogg" -> "audio/ogg"; + default -> "application/octet-stream"; + }) + .orElse("application/octet-stream"); + } +} diff --git a/src/main/java/com/imgfloat/app/service/media/MediaOptimizationService.java b/src/main/java/com/imgfloat/app/service/media/MediaOptimizationService.java new file mode 100644 index 0000000..2f62cae --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/media/MediaOptimizationService.java @@ -0,0 +1,217 @@ +package com.imgfloat.app.service.media; + +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; + +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 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 java.util.Optional; + +@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; + + public MediaOptimizationService(MediaPreviewService previewService) { + this.previewService = previewService; + } + + public OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException { + if (mediaType == null || mediaType.isBlank() || bytes == null || bytes.length == 0) { + return null; + } + if ("image/gif".equalsIgnoreCase(mediaType)) { + OptimizedAsset transcoded = transcodeGifToVideo(bytes); + if (transcoded != null) { + return transcoded; + } + } + + 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) { + return null; + } + return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); + } + + if (mediaType.startsWith("video/")) { + var dimensions = extractVideoDimensions(bytes); + byte[] preview = previewService.extractVideoPreview(bytes, mediaType); + return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview); + } + + if (mediaType.startsWith("audio/")) { + 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 null; + } + + private OptimizedAsset transcodeGifToVideo(byte[] bytes) { + try { + List frames = readGifFrames(bytes); + if (frames.isEmpty()) { + 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) { + int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay); + for (int i = 0; i < repeats; i++) { + encoder.encodeImage(frame.image()); + } + } + encoder.finish(); + BufferedImage cover = 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 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/com/imgfloat/app/service/media/MediaPreviewService.java b/src/main/java/com/imgfloat/app/service/media/MediaPreviewService.java new file mode 100644 index 0000000..e645bd7 --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/media/MediaPreviewService.java @@ -0,0 +1,49 @@ +package com.imgfloat.app.service.media; + +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; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +@Service +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; + } + } + + 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 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/com/imgfloat/app/service/media/OptimizedAsset.java b/src/main/java/com/imgfloat/app/service/media/OptimizedAsset.java new file mode 100644 index 0000000..0397bc6 --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/media/OptimizedAsset.java @@ -0,0 +1,3 @@ +package com.imgfloat.app.service.media; + +public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { } diff --git a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java index 99dcf6f..3b6e280 100644 --- a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java +++ b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java @@ -8,6 +8,10 @@ import com.imgfloat.app.model.Channel; import com.imgfloat.app.repository.AssetRepository; import com.imgfloat.app.repository.ChannelRepository; import com.imgfloat.app.service.ChannelDirectoryService; +import com.imgfloat.app.service.AssetStorageService; +import com.imgfloat.app.service.media.MediaDetectionService; +import com.imgfloat.app.service.media.MediaOptimizationService; +import com.imgfloat.app.service.media.MediaPreviewService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -52,8 +56,12 @@ class ChannelDirectoryServiceTest { setupInMemoryPersistence(); 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); + MediaDetectionService mediaDetectionService = new MediaDetectionService(); service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, - assetRoot.toString(), previewRoot.toString()); + assetStorageService, mediaDetectionService, mediaOptimizationService); } @Test diff --git a/src/test/java/com/imgfloat/app/service/AssetStorageServiceTest.java b/src/test/java/com/imgfloat/app/service/AssetStorageServiceTest.java new file mode 100644 index 0000000..4659779 --- /dev/null +++ b/src/test/java/com/imgfloat/app/service/AssetStorageServiceTest.java @@ -0,0 +1,57 @@ +package com.imgfloat.app.service; + +import com.imgfloat.app.service.media.AssetContent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AssetStorageServiceTest { + private AssetStorageService service; + private Path assets; + private Path previews; + + @BeforeEach + void setUp() throws IOException { + assets = Files.createTempDirectory("asset-storage-service"); + previews = Files.createTempDirectory("preview-storage-service"); + service = new AssetStorageService(assets.toString(), previews.toString()); + } + + @Test + void refusesToStoreEmptyAsset() { + assertThatThrownBy(() -> service.storeAsset("caster", "id", new byte[0], "image/png")) + .isInstanceOf(IOException.class) + .hasMessageContaining("empty"); + } + + @Test + void storesAndLoadsAssets() throws IOException { + byte[] bytes = new byte[]{1, 2, 3}; + + String path = service.storeAsset("caster", "id", bytes, "image/png"); + assertThat(Files.exists(Path.of(path))).isTrue(); + + AssetContent loaded = service.loadAssetFile(path, "image/png").orElseThrow(); + assertThat(loaded.bytes()).containsExactly(bytes); + assertThat(loaded.mediaType()).isEqualTo("image/png"); + } + + @Test + void ignoresEmptyPreview() throws IOException { + assertThat(service.storePreview("caster", "id", new byte[0])).isNull(); + } + + @Test + void storesAndLoadsPreviews() throws IOException { + byte[] preview = new byte[]{9, 8, 7}; + + String path = service.storePreview("caster", "id", preview); + assertThat(service.loadPreview(path)).isPresent(); + } +} diff --git a/src/test/java/com/imgfloat/app/service/media/MediaDetectionServiceTest.java b/src/test/java/com/imgfloat/app/service/media/MediaDetectionServiceTest.java new file mode 100644 index 0000000..a0eba87 --- /dev/null +++ b/src/test/java/com/imgfloat/app/service/media/MediaDetectionServiceTest.java @@ -0,0 +1,34 @@ +package com.imgfloat.app.service.media; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class MediaDetectionServiceTest { + private final MediaDetectionService service = new MediaDetectionService(); + + @Test + void prefersProvidedContentType() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", new byte[]{1, 2, 3}); + + assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png"); + } + + @Test + void fallsBackToFilenameAndStream() throws IOException { + byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}; + MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, png); + + assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png"); + } + + @Test + void returnsOctetStreamForUnknownType() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3}); + + assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("application/octet-stream"); + } +} diff --git a/src/test/java/com/imgfloat/app/service/media/MediaOptimizationServiceTest.java b/src/test/java/com/imgfloat/app/service/media/MediaOptimizationServiceTest.java new file mode 100644 index 0000000..7ad523e --- /dev/null +++ b/src/test/java/com/imgfloat/app/service/media/MediaOptimizationServiceTest.java @@ -0,0 +1,53 @@ +package com.imgfloat.app.service.media; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class MediaOptimizationServiceTest { + private MediaOptimizationService service; + + @BeforeEach + void setUp() { + service = new MediaOptimizationService(new MediaPreviewService()); + } + + @Test + void returnsNullForEmptyInput() throws IOException { + assertThat(service.optimizeAsset(new byte[0], "image/png")).isNull(); + } + + @Test + void optimizesPngImages() throws IOException { + byte[] png = samplePng(); + + OptimizedAsset optimized = service.optimizeAsset(png, "image/png"); + + assertThat(optimized).isNotNull(); + assertThat(optimized.mediaType()).isEqualTo("image/png"); + assertThat(optimized.width()).isEqualTo(2); + assertThat(optimized.height()).isEqualTo(2); + assertThat(optimized.previewBytes()).isNull(); + } + + @Test + void returnsNullForUnsupportedBytes() throws IOException { + OptimizedAsset optimized = service.optimizeAsset(new byte[]{1, 2, 3}, "application/octet-stream"); + + assertThat(optimized).isNull(); + } + + private byte[] samplePng() throws IOException { + BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", out); + return out.toByteArray(); + } + } +}