From 033498f630c760ea212d6556d00e7cb1c14de176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 9 Dec 2025 15:35:59 +0100 Subject: [PATCH] Add video support --- pom.xml | 6 + .../imgfloat/app/config/SchemaMigration.java | 32 +++- .../java/com/imgfloat/app/model/Asset.java | 39 +++++ .../imgfloat/app/model/TransformRequest.java | 18 +++ .../app/service/ChannelDirectoryService.java | 140 ++++++++++++++++- src/main/resources/application.yml | 4 + src/main/resources/static/js/admin.js | 141 +++++++++++++++--- src/main/resources/static/js/broadcast.js | 66 ++++++-- src/main/resources/templates/admin.html | 12 +- 9 files changed, 409 insertions(+), 49 deletions(-) diff --git a/pom.xml b/pom.xml index 24aa381..4a43399 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,12 @@ spring-boot-starter-validation + + org.jcodec + jcodec + 0.2.5 + + org.springframework.session spring-session-jdbc diff --git a/src/main/java/com/imgfloat/app/config/SchemaMigration.java b/src/main/java/com/imgfloat/app/config/SchemaMigration.java index c2384a3..24add1f 100644 --- a/src/main/java/com/imgfloat/app/config/SchemaMigration.java +++ b/src/main/java/com/imgfloat/app/config/SchemaMigration.java @@ -24,6 +24,7 @@ public class SchemaMigration implements ApplicationRunner { @Override public void run(ApplicationArguments args) { ensureChannelCanvasColumns(); + ensureAssetMediaColumns(); } private void ensureChannelCanvasColumns() { @@ -40,20 +41,39 @@ public class SchemaMigration implements ApplicationRunner { return; } - addColumnIfMissing(columns, "canvas_width", "REAL", "1920"); - addColumnIfMissing(columns, "canvas_height", "REAL", "1080"); + addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920"); + addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080"); } - private void addColumnIfMissing(List existingColumns, String columnName, String dataType, String defaultValue) { + private void ensureAssetMediaColumns() { + List columns; + try { + columns = jdbcTemplate.query("PRAGMA table_info(assets)", (rs, rowNum) -> rs.getString("name")); + } catch (DataAccessException ex) { + logger.warn("Unable to inspect assets table for media columns", ex); + return; + } + + if (columns.isEmpty()) { + return; + } + + addColumnIfMissing("assets", columns, "speed", "REAL", "1.0"); + addColumnIfMissing("assets", columns, "muted", "BOOLEAN", "0"); + addColumnIfMissing("assets", columns, "media_type", "TEXT", "'application/octet-stream'"); + } + + private void addColumnIfMissing(String tableName, List existingColumns, String columnName, String dataType, String defaultValue) { if (existingColumns.contains(columnName)) { return; } try { - jdbcTemplate.execute("ALTER TABLE channels ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue); - logger.info("Added missing column '{}' to channels table", columnName); + jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue); + jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL"); + logger.info("Added missing column '{}' to {} table", columnName, tableName); } catch (DataAccessException ex) { - logger.warn("Failed to add column '{}' to channels table", columnName, ex); + logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex); } } } diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index c55312c..ba4e764 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -30,6 +30,9 @@ public class Asset { private double width; private double height; private double rotation; + private Double speed; + private Boolean muted; + private String mediaType; private boolean hidden; private Instant createdAt; @@ -46,6 +49,8 @@ public class Asset { this.x = 0; this.y = 0; this.rotation = 0; + this.speed = 1.0; + this.muted = false; this.hidden = false; this.createdAt = Instant.now(); } @@ -63,6 +68,12 @@ public class Asset { if (this.name == null || this.name.isBlank()) { this.name = this.id; } + if (this.speed == null || this.speed <= 0) { + this.speed = 1.0; + } + if (this.muted == null) { + this.muted = Boolean.FALSE; + } } public String getId() { @@ -133,6 +144,34 @@ public class Asset { this.rotation = rotation; } + public double getSpeed() { + return speed == null ? 1.0 : speed; + } + + public void setSpeed(double speed) { + this.speed = speed; + } + + public boolean isMuted() { + return muted != null && muted; + } + + public void setMuted(boolean muted) { + this.muted = muted; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public boolean isVideo() { + return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/"); + } + public boolean isHidden() { return hidden; } diff --git a/src/main/java/com/imgfloat/app/model/TransformRequest.java b/src/main/java/com/imgfloat/app/model/TransformRequest.java index 5e9774e..7233836 100644 --- a/src/main/java/com/imgfloat/app/model/TransformRequest.java +++ b/src/main/java/com/imgfloat/app/model/TransformRequest.java @@ -6,6 +6,8 @@ public class TransformRequest { private double width; private double height; private double rotation; + private Double speed; + private Boolean muted; public double getX() { return x; @@ -46,4 +48,20 @@ public class TransformRequest { public void setRotation(double rotation) { this.rotation = rotation; } + + public Double getSpeed() { + return speed; + } + + public void setSpeed(Double speed) { + this.speed = speed; + } + + public Boolean getMuted() { + return muted; + } + + public void setMuted(Boolean muted) { + this.muted = muted; + } } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index b707776..fbb3fff 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -8,21 +8,35 @@ import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.repository.AssetRepository; import com.imgfloat.app.repository.ChannelRepository; +import org.jcodec.api.FrameGrab; +import org.jcodec.api.JCodecException; +import org.jcodec.common.io.ByteBufferSeekableByteChannel; +import org.jcodec.common.model.Picture; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URLConnection; +import java.nio.ByteBuffer; import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.Optional; import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.IIOImage; +import javax.imageio.stream.ImageOutputStream; @Service public class ChannelDirectoryService { + private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private final ChannelRepository channelRepository; private final AssetRepository assetRepository; private final SimpMessagingTemplate messagingTemplate; @@ -85,17 +99,26 @@ public class ChannelDirectoryService { public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = file.getBytes(); - BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); - if (image == null) { + String mediaType = detectMediaType(file, bytes); + + OptimizedAsset optimized = optimizeAsset(bytes, mediaType); + if (optimized == null) { return Optional.empty(); } + String name = Optional.ofNullable(file.getOriginalFilename()) .map(filename -> filename.replaceAll("^.*[/\\\\]", "")) .filter(s -> !s.isBlank()) .orElse("Asset " + System.currentTimeMillis()); - String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream"); - String dataUrl = "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(bytes); - Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, image.getWidth(), image.getHeight()); + + String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes()); + 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.setMediaType(optimized.mediaType()); + asset.setSpeed(1.0); + asset.setMuted(optimized.mediaType().startsWith("video/")); + assetRepository.save(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); return Optional.of(asset); @@ -111,6 +134,12 @@ public class ChannelDirectoryService { asset.setWidth(request.getWidth()); asset.setHeight(request.getHeight()); asset.setRotation(request.getRotation()); + if (request.getSpeed() != null && request.getSpeed() > 0) { + asset.setSpeed(request.getSpeed()); + } + if (request.getMuted() != null && asset.isVideo()) { + asset.setMuted(request.getMuted()); + } assetRepository.save(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset)); return asset; @@ -170,4 +199,105 @@ public class ChannelDirectoryService { private String normalize(String value) { return value == null ? null : value.toLowerCase(); } + + 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"; + default -> "application/octet-stream"; + }) + .orElse("application/octet-stream"); + } + + private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException { + 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()); + } + + 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()); + } + + if (mediaType.startsWith("video/")) { + var dimensions = extractVideoDimensions(bytes); + return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height()); + } + + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image != null) { + return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight()); + } + return null; + } + + 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 OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { } + + private record Dimension(int width, int height) { } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 02f8ea9..234f594 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,10 @@ spring: import: optional:file:.env[.properties] application: name: imgfloat + servlet: + multipart: + max-file-size: 256MB + max-request-size: 256MB thymeleaf: cache: false datasource: diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index d8193ad..ea82929 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -7,7 +7,7 @@ let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; const assets = new Map(); -const imageCache = new Map(); +const mediaCache = new Map(); const renderStates = new Map(); let selectedAssetId = null; let interactionState = null; @@ -20,12 +20,16 @@ const controlsPanel = document.getElementById('asset-controls'); const widthInput = document.getElementById('asset-width'); const heightInput = document.getElementById('asset-height'); const aspectLockInput = document.getElementById('maintain-aspect'); +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 aspectLockState = new Map(); if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); +if (speedInput) speedInput.addEventListener('change', updatePlaybackFromInputs); +if (muteInput) muteInput.addEventListener('change', updateMuteFromInput); function connect() { const socket = new SockJS('/ws'); @@ -84,14 +88,14 @@ function renderAssets(list) { function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); - imageCache.delete(event.assetId); + mediaCache.delete(event.assetId); renderStates.delete(event.assetId); if (selectedAssetId === event.assetId) { selectedAssetId = null; } } else if (event.payload) { assets.set(event.payload.id, event.payload); - ensureImage(event.payload); + ensureMedia(event.payload); } drawAndList(); } @@ -114,10 +118,11 @@ function drawAsset(asset) { ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate(renderState.rotation * Math.PI / 180); - const image = ensureImage(asset); - if (image?.complete) { + const media = ensureMedia(asset); + const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete); + if (ready) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height); + ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height); } else { ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; @@ -362,17 +367,54 @@ function startRenderLoop() { animationFrameId = requestAnimationFrame(tick); } -function ensureImage(asset) { - const cached = imageCache.get(asset.id); +function isVideoAsset(asset) { + return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/'); +} + +function isVideoElement(element) { + return element && element.tagName === 'VIDEO'; +} + +function ensureMedia(asset) { + const cached = mediaCache.get(asset.id); if (cached && cached.src === asset.url) { + applyMediaSettings(cached, asset); return cached; } - const image = new Image(); - image.onload = draw; - image.src = asset.url; - imageCache.set(asset.id, image); - return image; + const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); + if (isVideoElement(element)) { + element.loop = true; + element.muted = asset.muted ?? true; + element.playsInline = true; + element.autoplay = true; + element.onloadeddata = draw; + element.src = asset.url; + element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1; + element.play().catch(() => {}); + } else { + element.onload = draw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; +} + +function applyMediaSettings(element, asset) { + if (!isVideoElement(element)) { + return; + } + const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; + if (element.playbackRate !== nextSpeed) { + element.playbackRate = nextSpeed; + } + const shouldMute = asset.muted ?? true; + if (element.muted !== shouldMute) { + element.muted = shouldMute; + } + if (element.paused) { + element.play().catch(() => {}); + } } function renderAssetList() { @@ -400,10 +442,7 @@ function renderAssetList() { li.classList.add('hidden'); } - const preview = document.createElement('img'); - preview.className = 'asset-preview'; - preview.src = asset.url; - preview.alt = asset.name || 'Asset preview'; + const preview = createPreviewElement(asset); const meta = document.createElement('div'); meta.className = 'meta'; @@ -454,6 +493,26 @@ function renderAssetList() { updateSelectedAssetControls(); } +function createPreviewElement(asset) { + if (isVideoAsset(asset)) { + const video = document.createElement('video'); + video.className = 'asset-preview'; + video.src = asset.url; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.autoplay = true; + video.play().catch(() => {}); + return video; + } + + const img = document.createElement('img'); + img.className = 'asset-preview'; + img.src = asset.url; + img.alt = asset.name || 'Asset preview'; + return img; +} + function getSelectedAsset() { return selectedAssetId ? assets.get(selectedAssetId) : null; } @@ -479,6 +538,14 @@ function updateSelectedAssetControls() { aspectLockInput.checked = isAspectLocked(asset.id); aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); } + if (speedInput) { + speedInput.value = Math.round((asset.speed && asset.speed > 0 ? asset.speed : 1) * 100) / 100; + } + if (muteInput) { + muteInput.checked = !!asset.muted; + muteInput.disabled = !isVideoAsset(asset); + muteInput.parentElement?.classList.toggle('disabled', !isVideoAsset(asset)); + } } function applyTransformFromInputs() { @@ -506,6 +573,29 @@ function applyTransformFromInputs() { drawAndList(); } +function updatePlaybackFromInputs() { + const asset = getSelectedAsset(); + if (!asset) return; + const nextSpeed = Math.max(0.1, parseFloat(speedInput?.value) || asset.speed || 1); + asset.speed = nextSpeed; + renderStates.set(asset.id, { ...asset }); + persistTransform(asset); + drawAndList(); +} + +function updateMuteFromInput() { + const asset = getSelectedAsset(); + if (!asset || !isVideoAsset(asset)) return; + asset.muted = !!muteInput?.checked; + renderStates.set(asset.id, { ...asset }); + persistTransform(asset); + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } + drawAndList(); +} + function nudgeRotation(delta) { const asset = getSelectedAsset(); if (!asset) return; @@ -529,9 +619,12 @@ function recenterSelectedAsset() { } function getAssetAspectRatio(asset) { - const image = ensureImage(asset); - if (image?.naturalWidth && image?.naturalHeight) { - return image.naturalWidth / image.naturalHeight; + const media = ensureMedia(asset); + if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { + return media.videoWidth / media.videoHeight; + } + if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) { + return media.naturalWidth / media.naturalHeight; } if (asset.width && asset.height) { return asset.width / asset.height; @@ -584,7 +677,7 @@ function updateVisibility(asset, hidden) { function deleteAsset(asset) { fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => { assets.delete(asset.id); - imageCache.delete(asset.id); + mediaCache.delete(asset.id); renderStates.delete(asset.id); if (selectedAssetId === asset.id) { selectedAssetId = null; @@ -596,7 +689,7 @@ function deleteAsset(asset) { function uploadAsset() { const fileInput = document.getElementById('asset-file'); if (!fileInput || !fileInput.files || fileInput.files.length === 0) { - alert('Please choose an image to upload.'); + alert('Please choose an image, GIF, or video to upload.'); return; } const data = new FormData(); @@ -646,7 +739,9 @@ function persistTransform(asset) { y: asset.y, width: asset.width, height: asset.height, - rotation: asset.rotation + rotation: asset.rotation, + speed: asset.speed, + muted: asset.muted }) }).then((r) => r.json()).then((updated) => { assets.set(updated.id, updated); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 5c4e1d5..965f0bc 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -4,7 +4,7 @@ let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; const assets = new Map(); -const imageCache = new Map(); +const mediaCache = new Map(); const renderStates = new Map(); let animationFrameId = null; @@ -51,14 +51,14 @@ function resizeCanvas() { function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); - imageCache.delete(event.assetId); + mediaCache.delete(event.assetId); renderStates.delete(event.assetId); } else if (event.payload && !event.payload.hidden) { assets.set(event.payload.id, event.payload); - ensureImage(event.payload); + ensureMedia(event.payload); } else if (event.payload && event.payload.hidden) { assets.delete(event.payload.id); - imageCache.delete(event.payload.id); + mediaCache.delete(event.payload.id); renderStates.delete(event.payload.id); } draw(); @@ -77,9 +77,10 @@ function drawAsset(asset) { ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate(renderState.rotation * Math.PI / 180); - const image = ensureImage(asset); - if (image?.complete) { - ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height); + const media = ensureMedia(asset); + const ready = media && (isVideoElement(media) ? media.readyState >= 2 : media.complete); + if (ready) { + ctx.drawImage(media, -halfWidth, -halfHeight, renderState.width, renderState.height); } ctx.restore(); @@ -108,17 +109,54 @@ function lerp(a, b, t) { return a + (b - a) * t; } -function ensureImage(asset) { - const cached = imageCache.get(asset.id); +function isVideoAsset(asset) { + return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/'); +} + +function isVideoElement(element) { + return element && element.tagName === 'VIDEO'; +} + +function ensureMedia(asset) { + const cached = mediaCache.get(asset.id); if (cached && cached.src === asset.url) { + applyMediaSettings(cached, asset); return cached; } - const image = new Image(); - image.onload = draw; - image.src = asset.url; - imageCache.set(asset.id, image); - return image; + const element = isVideoAsset(asset) ? document.createElement('video') : new Image(); + if (isVideoElement(element)) { + element.loop = true; + element.muted = asset.muted ?? true; + element.playsInline = true; + element.autoplay = true; + element.onloadeddata = draw; + element.src = asset.url; + element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1; + element.play().catch(() => {}); + } else { + element.onload = draw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; +} + +function applyMediaSettings(element, asset) { + if (!isVideoElement(element)) { + return; + } + const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; + if (element.playbackRate !== nextSpeed) { + element.playbackRate = nextSpeed; + } + const shouldMute = asset.muted ?? true; + if (element.muted !== shouldMute) { + element.muted = shouldMute; + } + if (element.paused) { + element.play().catch(() => {}); + } } function startRenderLoop() { diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index ec81895..95d4167 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -22,7 +22,7 @@

Overlay assets

Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.

- +
    +
    + + +