diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java index dff200b..9be8a81 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java @@ -1,5 +1,6 @@ package dev.kruhlmann.imgfloat.service.media; +import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -100,13 +101,14 @@ public class MediaOptimizationService { try { var encoder = org.jcodec.api.awt.AWTSequenceEncoder.createSequenceEncoder(temp, fps); for (GifFrame frame : frames) { + BufferedImage image = ensureEvenDimensions(frame.image()); int repeats = Math.max(1, normalizeDelay(frame.delayMs()) / baseDelay); for (int i = 0; i < repeats; i++) { - encoder.encodeImage(frame.image()); + encoder.encodeImage(image); } } encoder.finish(); - BufferedImage cover = frames.get(0).image(); + BufferedImage cover = ensureEvenDimensions(frames.get(0).image()); byte[] video = Files.readAllBytes(temp.toPath()); return new OptimizedAsset( video, @@ -210,6 +212,24 @@ public class MediaOptimizationService { } } + private BufferedImage ensureEvenDimensions(BufferedImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + int evenWidth = width % 2 == 0 ? width : width + 1; + int evenHeight = height % 2 == 0 ? height : height + 1; + if (evenWidth == width && evenHeight == height) { + return image; + } + BufferedImage padded = new BufferedImage(evenWidth, evenHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = padded.createGraphics(); + try { + graphics.drawImage(image, 0, 0, null); + } finally { + graphics.dispose(); + } + return padded; + } + private Dimension extractVideoDimensions(byte[] bytes) { try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) { FrameGrab grab = FrameGrab.createFrameGrab(channel); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 4b68f28..f7f636f 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -22,7 +22,6 @@ let lastRenderTime = 0; let frameScheduled = false; let pendingDraw = false; let renderIntervalId = null; -const pendingRemovals = new Set(); const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; let layerOrder = []; @@ -79,12 +78,6 @@ function getRenderOrder() { .filter(Boolean); } -function queueRemoval(assetId) { - if (assetId) { - pendingRemovals.add(assetId); - } -} - function removeAsset(assetId) { assets.delete(assetId); layerOrder = layerOrder.filter((id) => id !== assetId); @@ -94,12 +87,6 @@ function removeAsset(assetId) { visibilityStates.delete(assetId); } -function flushPendingRemovals() { - if (!pendingRemovals.size) return; - pendingRemovals.forEach((id) => removeAsset(id)); - pendingRemovals.clear(); -} - function connect() { const socket = new SockJS("/ws"); const stompClient = Stomp.over(socket); @@ -108,7 +95,7 @@ function connect() { const body = JSON.parse(payload.body); handleEvent(body); }); - fetch(`/api/channels/${broadcaster}/assets/visible`) + fetch(`/api/channels/${broadcaster}/assets`) .then((r) => { if (!r.ok) { throw new Error("Failed to load assets"); @@ -344,13 +331,11 @@ function draw() { function renderFrame() { ctx.clearRect(0, 0, canvas.width, canvas.height); getRenderOrder().forEach(drawAsset); - flushPendingRemovals(); } function drawAsset(asset) { const visibility = getVisibilityState(asset); if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { - queueRemoval(asset.id); return; } const renderState = smoothState(asset);