From 772f11dace4f50ba258b6ebf51ec699ece511c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 10 Dec 2025 16:20:08 +0100 Subject: [PATCH] Optimize preview --- .gitignore | 1 + .../app/service/ChannelDirectoryService.java | 70 +++++++++++++++++-- src/main/resources/static/js/admin.js | 56 +++++++-------- 3 files changed, 91 insertions(+), 36 deletions(-) diff --git a/.gitignore b/.gitignore index c32fc08..9271a05 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ local/ *.db *.db-shm *.db-wal +previews/ diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 4c3f171..baedc2f 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -29,7 +29,11 @@ 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; @@ -49,6 +53,8 @@ import org.w3c.dom.NodeList; @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 Path PREVIEW_ROOT = Paths.get("previews"); private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private final ChannelRepository channelRepository; private final AssetRepository assetRepository; @@ -132,7 +138,7 @@ public class ChannelDirectoryService { Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); - asset.setPreview(optimized.previewDataUrl()); + asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes())); asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setAudioLoop(false); @@ -220,6 +226,7 @@ public class ChannelDirectoryService { return assetRepository.findById(assetId) .filter(asset -> normalized.equals(asset.getBroadcaster())) .map(asset -> { + deletePreviewFile(asset.getPreview()); assetRepository.delete(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); return true; @@ -248,7 +255,8 @@ public class ChannelDirectoryService { .filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(asset -> includeHidden || !asset.isHidden()) .map(asset -> { - Optional preview = decodeDataUrl(asset.getPreview()); + Optional preview = loadPreview(asset.getPreview()) + .or(() -> decodeDataUrl(asset.getPreview())); if (preview.isPresent()) { return preview.get(); } @@ -327,6 +335,54 @@ public class ChannelDirectoryService { } } + private Optional loadPreview(String previewPath) { + if (previewPath == null || previewPath.isBlank()) { + return Optional.empty(); + } + try { + Path path = Paths.get(previewPath); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + return Optional.of(new AssetContent(Files.readAllBytes(path), PREVIEW_MEDIA_TYPE)); + } catch (IOException e) { + logger.warn("Unable to read preview from {}", previewPath, e); + return Optional.empty(); + } + } catch (InvalidPathException e) { + logger.debug("Preview path {} is not a file path; skipping", previewPath); + return Optional.empty(); + } + } + + private 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) { return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() .mapToInt(Asset::getZIndex) @@ -393,7 +449,7 @@ public class ChannelDirectoryService { if (mediaType.startsWith("video/")) { var dimensions = extractVideoDimensions(bytes); - String preview = extractVideoPreview(bytes, mediaType); + byte[] preview = extractVideoPreview(bytes, mediaType); 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) { return null; } try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { ImageIO.write(image, "png", baos); - return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + return baos.toByteArray(); } catch (IOException e) { logger.warn("Unable to encode preview image", e); return null; @@ -552,7 +608,7 @@ public class ChannelDirectoryService { 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)) { FrameGrab grab = FrameGrab.createFrameGrab(channel); Picture frame = grab.getNativeFrame(); @@ -569,7 +625,7 @@ public class ChannelDirectoryService { 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) { } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 8011c0d..aec7e93 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -1193,36 +1193,34 @@ function fetchPreviewData(asset) { return Promise.resolve(cached); } - const primary = asset.previewUrl - ? fetch(asset.previewUrl) - .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 = () => { + const fallbackPromise = isVideoAsset(asset) + ? captureVideoFrame(asset) + : isGifAsset(asset) + ? captureGifFrame(asset) + : Promise.resolve(null); + return fallbackPromise.then((result) => { + if (!result) { + return null; } - const fallback = isVideoAsset(asset) - ? captureVideoFrame(asset) - : isGifAsset(asset) - ? captureGifFrame(asset) - : Promise.resolve(null); - return fallback.then((result) => { - if (!result) { - return null; - } - previewCache.set(asset.id, result); - return result; - }); - }) - .catch(() => null); + previewCache.set(asset.id, result); + return result; + }); + }; + + 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) {