mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Optimize preview
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ local/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
previews/
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
import java.nio.file.Files;
|
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;
|
||||||
@@ -49,6 +53,8 @@ import org.w3c.dom.NodeList;
|
|||||||
@Service
|
@Service
|
||||||
public class ChannelDirectoryService {
|
public class ChannelDirectoryService {
|
||||||
private static final int MIN_GIF_DELAY_MS = 20;
|
private static final int MIN_GIF_DELAY_MS = 20;
|
||||||
|
private static final String PREVIEW_MEDIA_TYPE = "image/png";
|
||||||
|
private static final Path PREVIEW_ROOT = Paths.get("previews");
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
@@ -132,7 +138,7 @@ public class ChannelDirectoryService {
|
|||||||
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
||||||
asset.setOriginalMediaType(mediaType);
|
asset.setOriginalMediaType(mediaType);
|
||||||
asset.setMediaType(optimized.mediaType());
|
asset.setMediaType(optimized.mediaType());
|
||||||
asset.setPreview(optimized.previewDataUrl());
|
asset.setPreview(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);
|
||||||
@@ -220,6 +226,7 @@ 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 -> {
|
||||||
|
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;
|
||||||
@@ -248,7 +255,8 @@ 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 = decodeDataUrl(asset.getPreview());
|
Optional<AssetContent> preview = loadPreview(asset.getPreview())
|
||||||
|
.or(() -> decodeDataUrl(asset.getPreview()));
|
||||||
if (preview.isPresent()) {
|
if (preview.isPresent()) {
|
||||||
return preview.get();
|
return preview.get();
|
||||||
}
|
}
|
||||||
@@ -327,6 +335,54 @@ 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 String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
|
||||||
|
if (previewBytes == null || previewBytes.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path directory = PREVIEW_ROOT.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)
|
||||||
@@ -393,7 +449,7 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
if (mediaType.startsWith("video/")) {
|
if (mediaType.startsWith("video/")) {
|
||||||
var dimensions = extractVideoDimensions(bytes);
|
var dimensions = extractVideoDimensions(bytes);
|
||||||
String preview = extractVideoPreview(bytes, mediaType);
|
byte[] preview = extractVideoPreview(bytes, mediaType);
|
||||||
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview);
|
return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,13 +582,13 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String encodePreview(BufferedImage image) {
|
private byte[] encodePreview(BufferedImage image) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||||
ImageIO.write(image, "png", baos);
|
ImageIO.write(image, "png", baos);
|
||||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
|
return baos.toByteArray();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.warn("Unable to encode preview image", e);
|
logger.warn("Unable to encode preview image", e);
|
||||||
return null;
|
return null;
|
||||||
@@ -552,7 +608,7 @@ public class ChannelDirectoryService {
|
|||||||
return new Dimension(640, 360);
|
return new Dimension(640, 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractVideoPreview(byte[] bytes, String mediaType) {
|
private byte[] extractVideoPreview(byte[] bytes, String mediaType) {
|
||||||
try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
|
try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) {
|
||||||
FrameGrab grab = FrameGrab.createFrameGrab(channel);
|
FrameGrab grab = FrameGrab.createFrameGrab(channel);
|
||||||
Picture frame = grab.getNativeFrame();
|
Picture frame = grab.getNativeFrame();
|
||||||
@@ -569,7 +625,7 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||||
|
|
||||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, String previewDataUrl) { }
|
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { }
|
||||||
|
|
||||||
private record GifFrame(BufferedImage image, int delayMs) { }
|
private record GifFrame(BufferedImage image, int delayMs) { }
|
||||||
|
|
||||||
|
|||||||
@@ -1193,36 +1193,34 @@ function fetchPreviewData(asset) {
|
|||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = asset.previewUrl
|
const fallback = () => {
|
||||||
? fetch(asset.previewUrl)
|
const fallbackPromise = isVideoAsset(asset)
|
||||||
.then((r) => {
|
|
||||||
if (!r.ok) throw new Error('preview fetch failed');
|
|
||||||
return r.blob();
|
|
||||||
})
|
|
||||||
.then((blob) => URL.createObjectURL(blob))
|
|
||||||
.catch(() => null)
|
|
||||||
: Promise.resolve(null);
|
|
||||||
|
|
||||||
return primary
|
|
||||||
.then((dataUrl) => {
|
|
||||||
if (dataUrl) {
|
|
||||||
previewCache.set(asset.id, dataUrl);
|
|
||||||
return dataUrl;
|
|
||||||
}
|
|
||||||
const fallback = isVideoAsset(asset)
|
|
||||||
? captureVideoFrame(asset)
|
? captureVideoFrame(asset)
|
||||||
: isGifAsset(asset)
|
: isGifAsset(asset)
|
||||||
? captureGifFrame(asset)
|
? captureGifFrame(asset)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
return fallback.then((result) => {
|
return fallbackPromise.then((result) => {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
previewCache.set(asset.id, result);
|
previewCache.set(asset.id, result);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
})
|
};
|
||||||
.catch(() => null);
|
|
||||||
|
if (!asset.previewUrl) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
previewCache.set(asset.id, asset.previewUrl);
|
||||||
|
resolve(asset.previewUrl);
|
||||||
|
};
|
||||||
|
img.onerror = () => fallback().then(resolve);
|
||||||
|
img.src = asset.previewUrl;
|
||||||
|
}).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPreviewFrame(asset, element) {
|
function loadPreviewFrame(asset, element) {
|
||||||
|
|||||||
Reference in New Issue
Block a user