mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +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.model.VisibilityRequest;
|
||||||
import com.imgfloat.app.repository.AssetRepository;
|
import com.imgfloat.app.repository.AssetRepository;
|
||||||
import com.imgfloat.app.repository.ChannelRepository;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
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.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.Base64;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import javax.imageio.ImageReader;
|
import com.imgfloat.app.service.media.AssetContent;
|
||||||
import javax.imageio.ImageWriteParam;
|
import com.imgfloat.app.service.media.MediaDetectionService;
|
||||||
import javax.imageio.ImageWriter;
|
import com.imgfloat.app.service.media.MediaOptimizationService;
|
||||||
import javax.imageio.IIOImage;
|
import com.imgfloat.app.service.media.OptimizedAsset;
|
||||||
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 static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ChannelDirectoryService {
|
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 MAX_SPEED = 4.0;
|
||||||
private static final double MIN_AUDIO_SPEED = 0.1;
|
private static final double MIN_AUDIO_SPEED = 0.1;
|
||||||
private static final double MAX_AUDIO_SPEED = 4.0;
|
private static final double MAX_AUDIO_SPEED = 4.0;
|
||||||
@@ -70,19 +45,22 @@ public class ChannelDirectoryService {
|
|||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
private final Path assetRoot;
|
private final AssetStorageService assetStorageService;
|
||||||
private final Path previewRoot;
|
private final MediaDetectionService mediaDetectionService;
|
||||||
|
private final MediaOptimizationService mediaOptimizationService;
|
||||||
|
|
||||||
public ChannelDirectoryService(ChannelRepository channelRepository,
|
public ChannelDirectoryService(ChannelRepository channelRepository,
|
||||||
AssetRepository assetRepository,
|
AssetRepository assetRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
@Value("${IMGFLOAT_ASSETS_PATH:assets}") String assetRoot,
|
AssetStorageService assetStorageService,
|
||||||
@Value("${IMGFLOAT_PREVIEWS_PATH:previews}") String previewRoot) {
|
MediaDetectionService mediaDetectionService,
|
||||||
|
MediaOptimizationService mediaOptimizationService) {
|
||||||
this.channelRepository = channelRepository;
|
this.channelRepository = channelRepository;
|
||||||
this.assetRepository = assetRepository;
|
this.assetRepository = assetRepository;
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.assetRoot = Paths.get(assetRoot);
|
this.assetStorageService = assetStorageService;
|
||||||
this.previewRoot = Paths.get(previewRoot);
|
this.mediaDetectionService = mediaDetectionService;
|
||||||
|
this.mediaOptimizationService = mediaOptimizationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Channel getOrCreateChannel(String broadcaster) {
|
public Channel getOrCreateChannel(String broadcaster) {
|
||||||
@@ -146,9 +124,9 @@ public class ChannelDirectoryService {
|
|||||||
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||||
Channel channel = getOrCreateChannel(broadcaster);
|
Channel channel = getOrCreateChannel(broadcaster);
|
||||||
byte[] bytes = file.getBytes();
|
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) {
|
if (optimized == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -163,8 +141,8 @@ public class ChannelDirectoryService {
|
|||||||
Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height);
|
Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height);
|
||||||
asset.setOriginalMediaType(mediaType);
|
asset.setOriginalMediaType(mediaType);
|
||||||
asset.setMediaType(optimized.mediaType());
|
asset.setMediaType(optimized.mediaType());
|
||||||
asset.setUrl(storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType()));
|
asset.setUrl(assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType()));
|
||||||
asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()));
|
asset.setPreview(assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()));
|
||||||
asset.setSpeed(1.0);
|
asset.setSpeed(1.0);
|
||||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||||
asset.setAudioLoop(false);
|
asset.setAudioLoop(false);
|
||||||
@@ -281,8 +259,8 @@ public class ChannelDirectoryService {
|
|||||||
return assetRepository.findById(assetId)
|
return assetRepository.findById(assetId)
|
||||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||||
.map(asset -> {
|
.map(asset -> {
|
||||||
deleteAssetFile(asset.getUrl());
|
assetStorageService.deleteAssetFile(asset.getUrl());
|
||||||
deletePreviewFile(asset.getPreview());
|
assetStorageService.deletePreviewFile(asset.getPreview());
|
||||||
assetRepository.delete(asset);
|
assetRepository.delete(asset);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
|
||||||
return true;
|
return true;
|
||||||
@@ -311,7 +289,7 @@ public class ChannelDirectoryService {
|
|||||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||||
.filter(asset -> includeHidden || !asset.isHidden())
|
.filter(asset -> includeHidden || !asset.isHidden())
|
||||||
.map(asset -> {
|
.map(asset -> {
|
||||||
Optional<AssetContent> preview = loadPreview(asset.getPreview())
|
Optional<AssetContent> preview = assetStorageService.loadPreview(asset.getPreview())
|
||||||
.or(() -> decodeDataUrl(asset.getPreview()));
|
.or(() -> decodeDataUrl(asset.getPreview()));
|
||||||
if (preview.isPresent()) {
|
if (preview.isPresent()) {
|
||||||
return preview.get();
|
return preview.get();
|
||||||
@@ -363,7 +341,7 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
||||||
return loadAssetFile(asset.getUrl(), asset.getMediaType())
|
return assetStorageService.loadAssetFile(asset.getUrl(), asset.getMediaType())
|
||||||
.or(() -> decodeDataUrl(asset.getUrl()))
|
.or(() -> decodeDataUrl(asset.getUrl()))
|
||||||
.or(() -> {
|
.or(() -> {
|
||||||
logger.warn("Unable to decode asset data for {}", asset.getId());
|
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) {
|
private int nextZIndex(String broadcaster) {
|
||||||
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
|
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
|
||||||
.mapToInt(Asset::getZIndex)
|
.mapToInt(Asset::getZIndex)
|
||||||
@@ -527,244 +377,4 @@ public class ChannelDirectoryService {
|
|||||||
.orElse(0) + 1;
|
.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.AssetRepository;
|
||||||
import com.imgfloat.app.repository.ChannelRepository;
|
import com.imgfloat.app.repository.ChannelRepository;
|
||||||
import com.imgfloat.app.service.ChannelDirectoryService;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -52,8 +56,12 @@ class ChannelDirectoryServiceTest {
|
|||||||
setupInMemoryPersistence();
|
setupInMemoryPersistence();
|
||||||
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
|
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
|
||||||
Path previewRoot = Files.createTempDirectory("imgfloat-previews-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,
|
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate,
|
||||||
assetRoot.toString(), previewRoot.toString());
|
assetStorageService, mediaDetectionService, mediaOptimizationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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