From 750cb227ffcc2a1217e5d4a84f3351ed8966771f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 9 Dec 2025 15:59:05 +0100 Subject: [PATCH] Add gif -> video support --- pom.xml | 6 + .../java/com/imgfloat/app/model/Asset.java | 22 ++ .../imgfloat/app/model/TransformRequest.java | 9 + .../app/service/ChannelDirectoryService.java | 135 ++++++++++- src/main/resources/static/css/styles.css | 8 + src/main/resources/static/js/admin.js | 226 +++++++++++++++++- src/main/resources/static/js/broadcast.js | 143 ++++++++++- src/main/resources/templates/admin.html | 15 ++ 8 files changed, 544 insertions(+), 20 deletions(-) diff --git a/pom.xml b/pom.xml index 4a43399..cf82630 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,12 @@ 0.2.5 + + org.jcodec + jcodec-javase + 0.2.5 + + org.springframework.session spring-session-jdbc diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index ba4e764..684cbd4 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -33,6 +33,8 @@ public class Asset { private Double speed; private Boolean muted; private String mediaType; + private String originalMediaType; + private Integer zIndex; private boolean hidden; private Instant createdAt; @@ -51,6 +53,7 @@ public class Asset { this.rotation = 0; this.speed = 1.0; this.muted = false; + this.zIndex = 0; this.hidden = false; this.createdAt = Instant.now(); } @@ -74,6 +77,9 @@ public class Asset { if (this.muted == null) { this.muted = Boolean.FALSE; } + if (this.zIndex == null) { + this.zIndex = 0; + } } public String getId() { @@ -168,6 +174,14 @@ public class Asset { this.mediaType = mediaType; } + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } + public boolean isVideo() { return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/"); } @@ -188,6 +202,14 @@ public class Asset { this.createdAt = createdAt; } + public Integer getZIndex() { + return zIndex == null ? 0 : zIndex; + } + + public void setZIndex(Integer zIndex) { + this.zIndex = zIndex; + } + private static String normalize(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } diff --git a/src/main/java/com/imgfloat/app/model/TransformRequest.java b/src/main/java/com/imgfloat/app/model/TransformRequest.java index 7233836..d1627c9 100644 --- a/src/main/java/com/imgfloat/app/model/TransformRequest.java +++ b/src/main/java/com/imgfloat/app/model/TransformRequest.java @@ -8,6 +8,7 @@ public class TransformRequest { private double rotation; private Double speed; private Boolean muted; + private Integer zIndex; public double getX() { return x; @@ -64,4 +65,12 @@ public class TransformRequest { public void setMuted(Boolean muted) { this.muted = muted; } + + public Integer getZIndex() { + return zIndex; + } + + public void setZIndex(Integer zIndex) { + this.zIndex = zIndex; + } } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index fbb3fff..13a2744 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -10,6 +10,7 @@ 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.slf4j.Logger; @@ -21,21 +22,30 @@ import org.springframework.web.multipart.MultipartFile; 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.Files; import java.util.Base64; import java.util.Collection; +import java.util.Comparator; import java.util.List; 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; @Service public class ChannelDirectoryService { + private static final int MIN_GIF_DELAY_MS = 20; private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private final ChannelRepository channelRepository; private final AssetRepository assetRepository; @@ -76,11 +86,11 @@ public class ChannelDirectoryService { } public Collection getAssetsForAdmin(String broadcaster) { - return assetRepository.findByBroadcaster(normalize(broadcaster)); + return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster))); } public Collection getVisibleAssets(String broadcaster) { - return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)); + return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); } public CanvasSettingsRequest getCanvasSettings(String broadcaster) { @@ -115,9 +125,11 @@ public class ChannelDirectoryService { double width = optimized.width() > 0 ? optimized.width() : 640; double height = optimized.height() > 0 ? optimized.height() : 360; Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height); + asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); + asset.setZIndex(nextZIndex(channel.getBroadcaster())); assetRepository.save(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); @@ -134,6 +146,9 @@ public class ChannelDirectoryService { asset.setWidth(request.getWidth()); asset.setHeight(request.getHeight()); asset.setRotation(request.getRotation()); + if (request.getZIndex() != null) { + asset.setZIndex(request.getZIndex()); + } if (request.getSpeed() != null && request.getSpeed() > 0) { asset.setSpeed(request.getSpeed()); } @@ -200,6 +215,20 @@ public class ChannelDirectoryService { return value == null ? null : value.toLowerCase(); } + private List sortByZIndex(Collection assets) { + return assets.stream() + .sorted(Comparator.comparingInt(Asset::getZIndex) + .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + } + + private int nextZIndex(String broadcaster) { + return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() + .mapToInt(Asset::getZIndex) + .max() + .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()) { @@ -230,6 +259,13 @@ public class ChannelDirectoryService { } 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) { @@ -259,6 +295,99 @@ public class ChannelDirectoryService { return null; } + private OptimizedAsset transcodeGifToVideo(byte[] bytes) { + try { + List frames = readGifFrames(bytes); + if (frames.isEmpty()) { + return null; + } + int baseDelay = frames.stream() + .mapToInt(frame -> normalizeDelay(frame.delayMs())) + .reduce(this::greatestCommonDivisor) + .orElse(100); + int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay)); + File temp = File.createTempFile("gif-convert", ".mp4"); + temp.deleteOnExit(); + try { + AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, fps); + for (GifFrame frame : frames) { + int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay); + for (int i = 0; i < repeats; i++) { + encoder.encodeImage(frame.image()); + } + } + encoder.finish(); + BufferedImage cover = frames.get(0).image(); + byte[] video = Files.readAllBytes(temp.toPath()); + return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight()); + } finally { + Files.deleteIfExists(temp.toPath()); + } + } catch (IOException e) { + logger.warn("Unable to transcode GIF to video", e); + return null; + } + } + + private List readGifFrames(byte[] bytes) throws IOException { + try (ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) { + var readers = ImageIO.getImageReadersByFormatName("gif"); + if (!readers.hasNext()) { + return List.of(); + } + ImageReader reader = readers.next(); + try { + reader.setInput(stream, false, false); + int count = reader.getNumImages(true); + var frames = new java.util.ArrayList(count); + for (int i = 0; i < count; i++) { + BufferedImage image = reader.read(i); + IIOMetadata metadata = reader.getImageMetadata(i); + int delay = extractDelayMs(metadata); + frames.add(new GifFrame(image, delay)); + } + return frames; + } finally { + reader.dispose(); + } + } + } + + private int extractDelayMs(IIOMetadata metadata) { + if (metadata == null) { + return 100; + } + try { + String format = metadata.getNativeMetadataFormatName(); + Node root = metadata.getAsTree(format); + NodeList children = root.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node node = children.item(i); + if ("GraphicControlExtension".equals(node.getNodeName()) && node.getAttributes() != null) { + Node delay = node.getAttributes().getNamedItem("delayTime"); + if (delay != null) { + int hundredths = Integer.parseInt(delay.getNodeValue()); + return Math.max(hundredths * 10, MIN_GIF_DELAY_MS); + } + } + } + } catch (Exception e) { + logger.warn("Unable to parse GIF delay", e); + } + return 100; + } + + private int normalizeDelay(int delayMs) { + return Math.max(delayMs, MIN_GIF_DELAY_MS); + } + + private int greatestCommonDivisor(int a, int b) { + if (b == 0) { + return Math.max(a, 1); + } + return greatestCommonDivisor(b, a % b); + } + private byte[] compressPng(BufferedImage image) throws IOException { var writers = ImageIO.getImageWritersByFormatName("png"); if (!writers.hasNext()) { @@ -299,5 +428,7 @@ public class ChannelDirectoryService { private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { } + private record GifFrame(BufferedImage image, int delayMs) { } + private record Dimension(int width, int height) { } } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 2f4792c..00eca33 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -172,6 +172,14 @@ body { border-color: rgba(148, 163, 184, 0.2); } +.badge-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-top: 6px; +} + .feature-list { list-style: none; padding: 0; diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index ea82929..9b1ea71 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -9,6 +9,7 @@ canvas.height = canvasSettings.height; const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); +const animatedCache = new Map(); let selectedAssetId = null; let interactionState = null; let animationFrameId = null; @@ -24,6 +25,8 @@ const speedInput = document.getElementById('asset-speed'); const muteInput = document.getElementById('asset-muted'); const selectedAssetName = document.getElementById('selected-asset-name'); const selectedAssetMeta = document.getElementById('selected-asset-meta'); +const selectedZLabel = document.getElementById('asset-z-level'); +const selectedTypeLabel = document.getElementById('asset-type-label'); const aspectLockState = new Map(); if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); @@ -88,7 +91,7 @@ function renderAssets(list) { function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); - mediaCache.delete(event.assetId); + clearMedia(event.assetId); renderStates.delete(event.assetId); if (selectedAssetId === event.assetId) { selectedAssetId = null; @@ -107,7 +110,20 @@ function drawAndList() { function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); - assets.forEach((asset) => drawAsset(asset)); + getZOrderedAssets().forEach((asset) => drawAsset(asset)); +} + +function getZOrderedAssets() { + return Array.from(assets.values()).sort(zComparator); +} + +function zComparator(a, b) { + const aZ = a?.zIndex ?? 0; + const bZ = b?.zIndex ?? 0; + if (aZ !== bZ) { + return aZ - bZ; + } + return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0); } function drawAsset(asset) { @@ -119,10 +135,11 @@ function drawAsset(asset) { ctx.rotate(renderState.rotation * Math.PI / 180); const media = ensureMedia(asset); - const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); if (ready) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height); + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); } else { ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; @@ -375,6 +392,47 @@ function isVideoElement(element) { return element && element.tagName === 'VIDEO'; } +function getDisplayMediaType(asset) { + const raw = asset.originalMediaType || asset.mediaType || ''; + if (!raw) { + return 'Unknown'; + } + const parts = raw.split('/'); + return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); +} + +function isGifAsset(asset) { + return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif'); +} + +function isDrawable(element) { + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; +} + +function clearMedia(assetId) { + mediaCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } +} + function ensureMedia(asset) { const cached = mediaCache.get(asset.id); if (cached && cached.src === asset.url) { @@ -382,6 +440,14 @@ function ensureMedia(asset) { return cached; } + if (isGifAsset(asset) && 'ImageDecoder' in window) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; + } + } + const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); if (isVideoElement(element)) { element.loop = true; @@ -400,6 +466,83 @@ function ensureMedia(asset) { return element; } +function ensureAnimatedImage(asset) { + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } + + if (cached) { + clearMedia(asset.id); + } + + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true + }; + + fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); + return null; + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + }); + + animatedCache.set(asset.id, controller); + return controller; +} + +function scheduleNextFrame(controller) { + if (controller.cancelled || !controller.decoder) { + return; + } + controller.decoder.decode().then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); + return; + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + draw(); + }) + .finally(() => image.close?.()); + + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }).catch(() => { + animatedCache.delete(controller.id); + }); +} + function applyMediaSettings(element, asset) { if (!isVideoElement(element)) { return; @@ -429,9 +572,7 @@ function renderAssetList() { return; } - const sortedAssets = Array.from(assets.values()).sort( - (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0) - ); + const sortedAssets = getZOrderedAssets().reverse(); sortedAssets.forEach((asset) => { const li = document.createElement('li'); li.className = 'asset-item'; @@ -449,7 +590,7 @@ function renderAssetList() { const name = document.createElement('strong'); name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; const details = document.createElement('small'); - details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; + details.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; meta.appendChild(name); meta.appendChild(details); @@ -530,7 +671,13 @@ function updateSelectedAssetControls() { controlsPanel.classList.remove('hidden'); lastSizeInputChanged = null; selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; - selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; + selectedAssetMeta.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; + if (selectedZLabel) { + selectedZLabel.textContent = asset.zIndex ?? 0; + } + if (selectedTypeLabel) { + selectedTypeLabel.textContent = getDisplayMediaType(asset); + } if (widthInput) widthInput.value = Math.round(asset.width); if (heightInput) heightInput.value = Math.round(asset.height); @@ -618,6 +765,56 @@ function recenterSelectedAsset() { drawAndList(); } +function bringForward() { + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getZOrderedAssets(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index === -1 || index === ordered.length - 1) return; + [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]]; + applyZOrder(ordered); +} + +function bringBackward() { + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getZOrderedAssets(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index <= 0) return; + [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]]; + applyZOrder(ordered); +} + +function bringToFront() { + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id); + ordered.push(asset); + applyZOrder(ordered); +} + +function sendToBack() { + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getZOrderedAssets().filter((item) => item.id !== asset.id); + ordered.unshift(asset); + applyZOrder(ordered); +} + +function applyZOrder(ordered) { + const changed = []; + ordered.forEach((item, index) => { + if ((item.zIndex ?? 0) !== index) { + item.zIndex = index; + changed.push(item); + } + assets.set(item.id, item); + renderStates.set(item.id, { ...item }); + }); + changed.forEach((item) => persistTransform(item, true)); + drawAndList(); +} + function getAssetAspectRatio(asset) { const media = ensureMedia(asset); if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { @@ -726,11 +923,11 @@ function isPointOnAsset(asset, x, y) { } function findAssetAtPoint(x, y) { - const ordered = Array.from(assets.values()).reverse(); + const ordered = getZOrderedAssets().reverse(); return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null; } -function persistTransform(asset) { +function persistTransform(asset, silent = false) { fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -741,11 +938,14 @@ function persistTransform(asset) { height: asset.height, rotation: asset.rotation, speed: asset.speed, - muted: asset.muted + muted: asset.muted, + zIndex: asset.zIndex }) }).then((r) => r.json()).then((updated) => { assets.set(updated.id, updated); - drawAndList(); + if (!silent) { + drawAndList(); + } }); } diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 965f0bc..9d01f7e 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -6,6 +6,7 @@ canvas.height = canvasSettings.height; const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); +const animatedCache = new Map(); let animationFrameId = null; function connect() { @@ -51,14 +52,14 @@ function resizeCanvas() { function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); - mediaCache.delete(event.assetId); + clearMedia(event.assetId); renderStates.delete(event.assetId); } else if (event.payload && !event.payload.hidden) { assets.set(event.payload.id, event.payload); ensureMedia(event.payload); } else if (event.payload && event.payload.hidden) { assets.delete(event.payload.id); - mediaCache.delete(event.payload.id); + clearMedia(event.payload.id); renderStates.delete(event.payload.id); } draw(); @@ -66,7 +67,20 @@ function handleEvent(event) { function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); - assets.forEach(drawAsset); + getZOrderedAssets().forEach(drawAsset); +} + +function getZOrderedAssets() { + return Array.from(assets.values()).sort(zComparator); +} + +function zComparator(a, b) { + const aZ = a?.zIndex ?? 0; + const bZ = b?.zIndex ?? 0; + if (aZ !== bZ) { + return aZ - bZ; + } + return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0); } function drawAsset(asset) { @@ -78,9 +92,10 @@ function drawAsset(asset) { ctx.rotate(renderState.rotation * Math.PI / 180); const media = ensureMedia(asset); - const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); if (ready) { - ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height); + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); } ctx.restore(); @@ -117,6 +132,38 @@ function isVideoElement(element) { return element && element.tagName === 'VIDEO'; } +function isGifAsset(asset) { + return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif'); +} + +function isDrawable(element) { + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; +} + +function clearMedia(assetId) { + mediaCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } +} + function ensureMedia(asset) { const cached = mediaCache.get(asset.id); if (cached && cached.src === asset.url) { @@ -124,6 +171,14 @@ function ensureMedia(asset) { return cached; } + if (isGifAsset(asset) && 'ImageDecoder' in window) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; + } + } + const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); if (isVideoElement(element)) { element.loop = true; @@ -142,6 +197,84 @@ function ensureMedia(asset) { return element; } +function ensureAnimatedImage(asset) { + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } + + if (cached) { + clearMedia(asset.id); + } + + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true + }; + + fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); + return null; + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + }); + + animatedCache.set(asset.id, controller); + return controller; +} + +function scheduleNextFrame(controller) { + if (controller.cancelled || !controller.decoder) { + return; + } + controller.decoder.decode().then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); + return; + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + draw(); + }) + .finally(() => image.close?.()); + + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }).catch(() => { + // If decoding fails, clear animated cache so static fallback is used next render + animatedCache.delete(controller.id); + }); +} + function applyMediaSettings(element, asset) { if (!isVideoElement(element)) { return; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 95d4167..4530ae0 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -54,12 +54,27 @@ Muted (videos) +
+ +
+
+ + + + +