mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Refactor
This commit is contained in:
161
src/main/java/com/imgfloat/app/service/AssetStorageService.java
Normal file
161
src/main/java/com/imgfloat/app/service/AssetStorageService.java
Normal file
@@ -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<AssetContent> 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<AssetContent> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AssetView> 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<AssetContent> preview = loadPreview(asset.getPreview())
|
||||
Optional<AssetContent> preview = assetStorageService.loadPreview(asset.getPreview())
|
||||
.or(() -> decodeDataUrl(asset.getPreview()));
|
||||
if (preview.isPresent()) {
|
||||
return preview.get();
|
||||
@@ -363,7 +341,7 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private Optional<AssetContent> 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<AssetContent> 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<AssetContent> 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<GifFrame> 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<GifFrame> 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<GifFrame>(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) { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.imgfloat.app.service.media;
|
||||
|
||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<GifFrame> 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<GifFrame> 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<GifFrame>(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) { }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.imgfloat.app.service.media;
|
||||
|
||||
public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { }
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user