(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)
+
+
+
+
+
+
+
+
+