diff --git a/Makefile b/Makefile index cf14a0f..d101d6f 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,10 @@ APP_NAME=imgfloat .PHONY: run test package docker-build docker-run ssl run: - test -f .env && . ./.env; mvn spring-boot:run -Dspring-boot.run.fork=true + test -f .env && . ./.env; mvn spring-boot:run + +dev: + test -f .env && . ./.env; ./devserver test: mvn test diff --git a/devserver b/devserver new file mode 100755 index 0000000..e8c3d46 --- /dev/null +++ b/devserver @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -e + +cleanup() { + echo "Stopping dev server..." + kill "$SERVER_PID" 2>/dev/null || true + exit +} + +trap cleanup INT TERM + +echo "Starting Spring Boot dev server..." +mvn spring-boot:run & +SERVER_PID=$! + +echo "Dev server PID: $SERVER_PID" +echo "Watching for file changes..." + +while kill -0 "$SERVER_PID" 2>/dev/null; do + find src/main/java -name "*.java" | + entr -d mvn -q compile +done + +echo "Dev server exited." +exit 0 diff --git a/src/main/java/com/imgfloat/app/config/SchemaMigration.java b/src/main/java/com/imgfloat/app/config/SchemaMigration.java index e42d63b..a682a6f 100644 --- a/src/main/java/com/imgfloat/app/config/SchemaMigration.java +++ b/src/main/java/com/imgfloat/app/config/SchemaMigration.java @@ -66,6 +66,7 @@ public class SchemaMigration implements ApplicationRunner { addColumnIfMissing("assets", columns, "audio_speed", "REAL", "1.0"); addColumnIfMissing("assets", columns, "audio_pitch", "REAL", "1.0"); addColumnIfMissing("assets", columns, "audio_volume", "REAL", "1.0"); + addColumnIfMissing("assets", columns, "preview", "TEXT", "NULL"); } private void addColumnIfMissing(String tableName, List existingColumns, String columnName, String dataType, String defaultValue) { diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index 624b768..f9e3015 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -231,6 +231,33 @@ public class ChannelApiController { .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available")); } + @GetMapping("/assets/{assetId}/preview") + public ResponseEntity getAssetPreview(@PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken authentication) { + boolean authorized = false; + if (authentication != null) { + String login = TwitchUser.from(authentication).login(); + authorized = channelDirectoryService.isBroadcaster(broadcaster, login) + || channelDirectoryService.isAdmin(broadcaster, login); + } + + if (authorized) { + LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); + return channelDirectoryService.getAssetPreview(broadcaster, assetId, true) + .map(content -> ResponseEntity.ok() + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes())) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); + } + + return channelDirectoryService.getAssetPreview(broadcaster, assetId, false) + .map(content -> ResponseEntity.ok() + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes())) + .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available")); + } + @DeleteMapping("/assets/{assetId}") public ResponseEntity delete(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, diff --git a/src/main/java/com/imgfloat/app/model/Asset.java b/src/main/java/com/imgfloat/app/model/Asset.java index c879fd0..620f340 100644 --- a/src/main/java/com/imgfloat/app/model/Asset.java +++ b/src/main/java/com/imgfloat/app/model/Asset.java @@ -34,6 +34,8 @@ public class Asset { private Boolean muted; private String mediaType; private String originalMediaType; + @Column(columnDefinition = "TEXT") + private String preview; private Integer zIndex; private Boolean audioLoop; private Integer audioDelayMillis; @@ -202,6 +204,14 @@ public class Asset { this.originalMediaType = originalMediaType; } + public String getPreview() { + return preview; + } + + public void setPreview(String preview) { + this.preview = preview; + } + public boolean isVideo() { return mediaType != null && mediaType.toLowerCase(Locale.ROOT).startsWith("video/"); } diff --git a/src/main/java/com/imgfloat/app/model/AssetView.java b/src/main/java/com/imgfloat/app/model/AssetView.java index a6cf708..0d0bdb8 100644 --- a/src/main/java/com/imgfloat/app/model/AssetView.java +++ b/src/main/java/com/imgfloat/app/model/AssetView.java @@ -7,6 +7,7 @@ public record AssetView( String broadcaster, String name, String url, + String previewUrl, double x, double y, double width, @@ -23,6 +24,7 @@ public record AssetView( Double audioPitch, Double audioVolume, boolean hidden, + boolean hasPreview, Instant createdAt ) { public static AssetView from(String broadcaster, Asset asset) { @@ -31,6 +33,7 @@ public record AssetView( asset.getBroadcaster(), asset.getName(), "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", + asset.getPreview() != null ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null, asset.getX(), asset.getY(), asset.getWidth(), @@ -47,6 +50,7 @@ public record AssetView( asset.getAudioPitch(), asset.getAudioVolume(), asset.isHidden(), + asset.getPreview() != null, asset.getCreatedAt() ); } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 759116a..4c3f171 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -15,6 +15,7 @@ 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.jcodec.scale.AWTUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -131,6 +132,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.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setAudioLoop(false); @@ -240,6 +242,24 @@ public class ChannelDirectoryService { .flatMap(this::decodeAssetData); } + public Optional getAssetPreview(String broadcaster, String assetId, boolean includeHidden) { + String normalized = normalize(broadcaster); + return assetRepository.findById(assetId) + .filter(asset -> normalized.equals(asset.getBroadcaster())) + .filter(asset -> includeHidden || !asset.isHidden()) + .map(asset -> { + Optional preview = decodeDataUrl(asset.getPreview()); + if (preview.isPresent()) { + return preview.get(); + } + if (asset.getMediaType() != null && asset.getMediaType().startsWith("image/")) { + return decodeAssetData(asset).orElse(null); + } + return null; + }) + .flatMap(Optional::ofNullable); + } + public boolean isBroadcaster(String broadcaster, String username) { return broadcaster != null && broadcaster.equalsIgnoreCase(username); } @@ -279,23 +299,30 @@ public class ChannelDirectoryService { } private Optional decodeAssetData(Asset asset) { - String url = asset.getUrl(); - if (url == null || !url.startsWith("data:")) { + return decodeDataUrl(asset.getUrl()) + .or(() -> { + logger.warn("Unable to decode asset data for {}", asset.getId()); + return Optional.empty(); + }); + } + + private Optional decodeDataUrl(String dataUrl) { + if (dataUrl == null || !dataUrl.startsWith("data:")) { return Optional.empty(); } - int commaIndex = url.indexOf(','); + int commaIndex = dataUrl.indexOf(','); if (commaIndex < 0) { return Optional.empty(); } - String metadata = url.substring(5, commaIndex); + String metadata = dataUrl.substring(5, commaIndex); String[] parts = metadata.split(";", 2); String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream"; - String encoded = url.substring(commaIndex + 1); + String encoded = dataUrl.substring(commaIndex + 1); try { byte[] bytes = Base64.getDecoder().decode(encoded); return Optional.of(new AssetContent(bytes, mediaType)); } catch (IllegalArgumentException e) { - logger.warn("Unable to decode asset data for {}", asset.getId(), e); + logger.warn("Unable to decode data url", e); return Optional.empty(); } } @@ -353,7 +380,7 @@ public class ChannelDirectoryService { return null; } byte[] compressed = compressPng(image); - return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight()); + return new OptimizedAsset(compressed, "image/png", image.getWidth(), image.getHeight(), null); } if (mediaType.startsWith("image/")) { @@ -361,21 +388,22 @@ public class ChannelDirectoryService { if (image == null) { return null; } - return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight()); + return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); } if (mediaType.startsWith("video/")) { var dimensions = extractVideoDimensions(bytes); - return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height()); + String preview = extractVideoPreview(bytes, mediaType); + return new OptimizedAsset(bytes, mediaType, dimensions.width(), dimensions.height(), preview); } if (mediaType.startsWith("audio/")) { - return new OptimizedAsset(bytes, mediaType, 0, 0); + return new OptimizedAsset(bytes, mediaType, 0, 0, null); } BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); if (image != null) { - return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight()); + return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); } return null; } @@ -404,7 +432,7 @@ public class ChannelDirectoryService { encoder.finish(); BufferedImage cover = frames.get(0).image(); byte[] video = Files.readAllBytes(temp.toPath()); - return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight()); + return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), encodePreview(cover)); } finally { Files.deleteIfExists(temp.toPath()); } @@ -498,6 +526,19 @@ public class ChannelDirectoryService { } } + private String 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()); + } catch (IOException e) { + logger.warn("Unable to encode preview image", e); + return null; + } + } + private Dimension extractVideoDimensions(byte[] bytes) { try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) { FrameGrab grab = FrameGrab.createFrameGrab(channel); @@ -511,9 +552,24 @@ public class ChannelDirectoryService { return new Dimension(640, 360); } + private String extractVideoPreview(byte[] bytes, String mediaType) { + try (var channel = new ByteBufferSeekableByteChannel(ByteBuffer.wrap(bytes), bytes.length)) { + FrameGrab grab = FrameGrab.createFrameGrab(channel); + Picture frame = grab.getNativeFrame(); + if (frame == null) { + return null; + } + BufferedImage image = AWTUtil.toBufferedImage(frame); + return encodePreview(image); + } catch (IOException | JCodecException e) { + logger.warn("Unable to capture video preview frame for {}", mediaType, e); + return null; + } + } + public record AssetContent(byte[] bytes, String mediaType) { } - private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { } + private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, String previewDataUrl) { } private record GifFrame(BufferedImage image, int delayMs) { } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 10d8dbe..b88cb0a 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -695,6 +695,11 @@ body { margin: 6px 0 0; } +.subtle-text { + color: #94a3b8; + font-size: 12px; +} + .panel-section { margin-top: 12px; padding: 14px; @@ -720,6 +725,31 @@ body { margin: 0; } +.field-note { + margin: 0; + color: #94a3b8; + font-size: 12px; +} + +.stacked-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.value-hint { + color: #cbd5e1; + font-size: 12px; +} + .asset-list { display: flex; flex-direction: column; @@ -938,6 +968,11 @@ body { margin-top: 8px; } +.control-grid.split-row { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + margin-top: 6px; +} + .control-grid.three-col { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } @@ -949,6 +984,11 @@ body { color: #cbd5e1; } +.control-grid .inline-toggle { + align-items: center; + justify-content: space-between; +} + .control-grid input[type="number"], .control-grid input[type="range"] { padding: 8px; diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 562b0ee..ba773a6 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -15,6 +15,7 @@ const audioControllers = new Map(); const pendingAudioUnlock = new Set(); const loopPlaybackState = new Map(); const previewCache = new Map(); +const previewImageCache = new Map(); let drawPending = false; let zOrderDirty = true; let zOrderCache = []; @@ -30,6 +31,7 @@ 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 speedLabel = document.getElementById('asset-speed-label'); const selectedZLabel = document.getElementById('asset-z-level'); const playbackSection = document.getElementById('playback-section'); const audioSection = document.getElementById('audio-section'); @@ -37,6 +39,7 @@ const layoutSection = document.getElementById('layout-section'); const audioLoopInput = document.getElementById('asset-audio-loop'); const audioDelayInput = document.getElementById('asset-audio-delay'); const audioSpeedInput = document.getElementById('asset-audio-speed'); +const audioSpeedLabel = document.getElementById('asset-audio-speed-label'); const audioPitchInput = document.getElementById('asset-audio-pitch'); const audioVolumeInput = document.getElementById('asset-audio-volume'); const controlsPlaceholder = document.getElementById('asset-controls-placeholder'); @@ -44,6 +47,7 @@ const fileNameLabel = document.getElementById('asset-file-name'); const assetInspector = document.getElementById('asset-inspector'); const selectedAssetName = document.getElementById('selected-asset-name'); const selectedAssetMeta = document.getElementById('selected-asset-meta'); +const selectedAssetIdLabel = document.getElementById('selected-asset-id'); const selectedAssetBadges = document.getElementById('selected-asset-badges'); const selectedVisibilityBtn = document.getElementById('selected-asset-visibility'); const selectedDeleteBtn = document.getElementById('selected-asset-delete'); @@ -144,6 +148,18 @@ function getDurationBadge(asset) { return formatDurationLabel(asset.durationMs); } +function setSpeedLabel(percent) { + if (!speedLabel) return; + speedLabel.textContent = `${Math.round(percent)}%`; +} + +function setAudioSpeedLabel(percentValue) { + if (!audioSpeedLabel) return; + const multiplier = Math.max(0, percentValue) / 100; + const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2); + audioSpeedLabel.textContent = `${formatted}x`; +} + function queueAudioForUnlock(controller) { if (!controller) return; pendingAudioUnlock.add(controller); @@ -266,20 +282,22 @@ function renderAssets(list) { function storeAsset(asset) { if (!asset) return; const existing = assets.get(asset.id); - if (existing && existing.url !== asset.url) { + const merged = existing ? { ...existing, ...asset } : { ...asset }; + const mediaChanged = existing && existing.url !== merged.url; + const previewChanged = existing && existing.previewUrl !== merged.previewUrl; + if (mediaChanged || previewChanged) { clearMedia(asset.id); - previewCache.delete(asset.id); } - asset.zIndex = Math.max(1, asset.zIndex ?? 1); - const parsedCreatedAt = asset.createdAt ? new Date(asset.createdAt).getTime() : NaN; - const hasCreatedAtMs = typeof asset.createdAtMs === 'number' && Number.isFinite(asset.createdAtMs); + merged.zIndex = Math.max(1, merged.zIndex ?? 1); + const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN; + const hasCreatedAtMs = typeof merged.createdAtMs === 'number' && Number.isFinite(merged.createdAtMs); if (!hasCreatedAtMs) { - asset.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); + merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); } - assets.set(asset.id, asset); + assets.set(asset.id, merged); zOrderDirty = true; if (!renderStates.has(asset.id)) { - renderStates.set(asset.id, { ...asset }); + renderStates.set(asset.id, { ...merged }); } resolvePendingUploadByName(asset.name); } @@ -376,9 +394,18 @@ function drawAsset(asset) { return; } - const media = ensureMedia(asset); - const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = isDrawable(media); + let drawSource = null; + let ready = false; + let showPlayOverlay = false; + if (isVideoAsset(asset) || isGifAsset(asset)) { + drawSource = ensureCanvasPreview(asset); + ready = isDrawable(drawSource); + showPlayOverlay = true; + } else { + const media = ensureMedia(asset); + drawSource = media?.isAnimated ? media.bitmap : media; + ready = isDrawable(media); + } if (ready && drawSource) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); @@ -398,6 +425,9 @@ function drawAsset(asset) { ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + if (showPlayOverlay) { + drawPlayOverlay(renderState); + } if (asset.id === selectedAssetId) { drawSelectionOverlay(renderState); } @@ -425,6 +455,24 @@ function lerp(a, b, t) { return a + (b - a) * t; } +function drawPlayOverlay(asset) { + const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2); + ctx.save(); + ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; + ctx.beginPath(); + ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.moveTo(-size * 0.3, -size * 0.45); + ctx.lineTo(size * 0.55, 0); + ctx.lineTo(-size * 0.3, size * 0.45); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} + function drawSelectionOverlay(asset) { const halfWidth = asset.width / 2; const halfHeight = asset.height / 2; @@ -658,7 +706,12 @@ function isDrawable(element) { function clearMedia(assetId) { mediaCache.delete(assetId); + const cachedPreview = previewCache.get(assetId); + if (cachedPreview && cachedPreview.startsWith('blob:')) { + URL.revokeObjectURL(cachedPreview); + } previewCache.delete(assetId); + previewImageCache.delete(assetId); const animated = animatedCache.get(assetId); if (animated) { animated.cancelled = true; @@ -964,10 +1017,12 @@ function renderAssetList() { const badges = document.createElement('div'); badges.className = 'badge-row asset-meta-badges'; - badges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : '')); badges.appendChild(createBadge(getDisplayMediaType(asset))); - badges.appendChild(createBadge(`Z ${asset.zIndex ?? 1}`)); - const aspectLabel = formatAspectRatioLabel(asset); + if (!isAudioAsset(asset)) { + badges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : '')); + badges.appendChild(createBadge(`Z ${asset.zIndex ?? 1}`)); + } + const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ''; if (aspectLabel) { badges.appendChild(createBadge(aspectLabel, 'subtle')); } @@ -980,17 +1035,6 @@ function renderAssetList() { const actions = document.createElement('div'); actions.className = 'actions'; - const toggleBtn = document.createElement('button'); - toggleBtn.type = 'button'; - toggleBtn.className = 'ghost icon-button'; - toggleBtn.innerHTML = ``; - toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - selectedAssetId = asset.id; - updateVisibility(asset, !asset.hidden); - }); - if (isAudioAsset(asset)) { const playBtn = document.createElement('button'); playBtn.type = 'button'; @@ -1016,6 +1060,20 @@ function renderAssetList() { actions.appendChild(playBtn); } + if (!isAudioAsset(asset)) { + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'ghost icon-button'; + toggleBtn.innerHTML = ``; + toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset'; + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + selectedAssetId = asset.id; + updateVisibility(asset, !asset.hidden); + }); + actions.appendChild(toggleBtn); + } + const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'ghost danger icon-button'; @@ -1026,7 +1084,6 @@ function renderAssetList() { deleteAsset(asset); }); - actions.appendChild(toggleBtn); actions.appendChild(deleteBtn); row.appendChild(preview); @@ -1136,26 +1193,50 @@ function createPreviewElement(asset) { return img; } -function loadPreviewFrame(asset, element) { - if (!asset || !element) return; +function fetchPreviewData(asset) { + if (!asset) return Promise.resolve(null); const cached = previewCache.get(asset.id); if (cached) { - applyPreviewFrame(element, cached); - return; + return Promise.resolve(cached); } - const source = isVideoAsset(asset) - ? captureVideoFrame(asset) - : isGifAsset(asset) - ? captureGifFrame(asset) - : Promise.resolve(null); + 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); - source + return primary .then((dataUrl) => { - if (!dataUrl) { - return; + if (dataUrl) { + previewCache.set(asset.id, dataUrl); + return dataUrl; } - previewCache.set(asset.id, dataUrl); + 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); +} + +function loadPreviewFrame(asset, element) { + if (!asset || !element) return; + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; applyPreviewFrame(element, dataUrl); }) .catch(() => { }); @@ -1167,6 +1248,36 @@ function applyPreviewFrame(element, dataUrl) { element.classList.add('has-image'); } +function ensureCanvasPreview(asset) { + const cachedData = previewCache.get(asset.id); + const cachedImage = previewImageCache.get(asset.id); + if (cachedData && cachedImage?.src === cachedData) { + return cachedImage.image; + } + + if (cachedData) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = requestDraw; + img.src = cachedData; + previewImageCache.set(asset.id, { src: cachedData, image: img }); + return img; + } + + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = requestDraw; + img.src = dataUrl; + previewImageCache.set(asset.id, { src: dataUrl, image: img }); + }) + .catch(() => { }); + + return null; +} + function captureVideoFrame(asset) { return new Promise((resolve) => { const video = document.createElement('video'); @@ -1273,6 +1384,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { if (speedInput) { const percent = Math.round((asset.speed ?? 1) * 100); speedInput.value = Math.min(1000, Math.max(0, percent)); + setSpeedLabel(speedInput.value); } if (playbackSection) { const shouldShowPlayback = isVideoAsset(asset); @@ -1297,6 +1409,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) { audioLoopInput.checked = !!asset.audioLoop; audioDelayInput.value = Math.max(0, asset.audioDelayMillis ?? 0); audioSpeedInput.value = Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100); + setAudioSpeedLabel(audioSpeedInput.value); audioPitchInput.value = Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100); audioVolumeInput.value = Math.round(Math.max(0, Math.min(1, asset.audioVolume ?? 1)) * 100); } @@ -1312,16 +1425,29 @@ function updateSelectedAssetSummary(asset) { selectedAssetName.textContent = asset ? (asset.name || `Asset ${asset.id.slice(0, 6)}`) : 'Choose an asset'; } if (selectedAssetMeta) { + const baseMeta = asset ? `${Math.round(asset.width)}x${Math.round(asset.height)}` : null; + const layerMeta = asset && !isAudioAsset(asset) ? ` · Layer ${asset.zIndex ?? 1}` : ''; selectedAssetMeta.textContent = asset - ? `${Math.round(asset.width)}x${Math.round(asset.height)} · Layer ${asset.zIndex ?? 1}` + ? `${baseMeta}${layerMeta}` : 'Pick an asset in the list to adjust its placement and playback.'; } + if (selectedAssetIdLabel) { + if (asset) { + selectedAssetIdLabel.textContent = `ID: ${asset.id}`; + selectedAssetIdLabel.classList.remove('hidden'); + } else { + selectedAssetIdLabel.classList.add('hidden'); + selectedAssetIdLabel.textContent = ''; + } + } if (selectedAssetBadges) { selectedAssetBadges.innerHTML = ''; if (asset) { - selectedAssetBadges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : '')); selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); - const aspectLabel = formatAspectRatioLabel(asset); + if (!isAudioAsset(asset)) { + selectedAssetBadges.appendChild(createBadge(asset.hidden ? 'Hidden' : 'Visible', asset.hidden ? 'danger' : '')); + } + const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ''; if (aspectLabel) { selectedAssetBadges.appendChild(createBadge(aspectLabel, 'subtle')); } @@ -1373,6 +1499,7 @@ function updatePlaybackFromInputs() { const asset = getSelectedAsset(); if (!asset || !isVideoAsset(asset)) return; const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); + setSpeedLabel(percent); asset.speed = percent / 100; updateRenderState(asset); persistTransform(asset); @@ -1401,7 +1528,9 @@ function updateAudioSettingsFromInputs() { if (!asset || !isAudioAsset(asset)) return; asset.audioLoop = !!audioLoopInput?.checked; asset.audioDelayMillis = Math.max(0, parseInt(audioDelayInput?.value || '0', 10)); - asset.audioSpeed = Math.max(0.25, (parseInt(audioSpeedInput?.value || '100', 10) / 100)); + const nextAudioSpeedPercent = Math.max(25, parseInt(audioSpeedInput?.value || '100', 10)); + setAudioSpeedLabel(nextAudioSpeedPercent); + asset.audioSpeed = Math.max(0.25, (nextAudioSpeedPercent / 100)); asset.audioPitch = Math.max(0.5, (parseInt(audioPitchInput?.value || '100', 10) / 100)); asset.audioVolume = Math.max(0, Math.min(1, (parseInt(audioVolumeInput?.value || '100', 10) / 100))); const controller = ensureAudioController(asset); @@ -1499,6 +1628,9 @@ function getAssetAspectRatio(asset) { } function formatAspectRatioLabel(asset) { + if (isAudioAsset(asset)) { + return ''; + } const ratio = getAssetAspectRatio(asset); if (!ratio) { return ''; diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 2fa8872..2998b02 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -7,6 +7,8 @@ const assets = new Map(); const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); +const blobCache = new Map(); +const animationFailures = new Map(); const audioControllers = new Map(); const pendingAudioUnlock = new Set(); const TARGET_FPS = 60; @@ -283,6 +285,8 @@ function clearMedia(assetId) { animated.decoder?.close?.(); animatedCache.delete(assetId); } + animationFailures.delete(assetId); + blobCache.delete(assetId); const audio = audioControllers.get(assetId); if (audio) { if (audio.delayTimeout) { @@ -485,11 +489,17 @@ function ensureMedia(asset) { } function ensureAnimatedImage(asset) { + const failedAt = animationFailures.get(asset.id); + if (failedAt && Date.now() - failedAt < 15000) { + return null; + } const cached = animatedCache.get(asset.id); if (cached && cached.url === asset.url) { return cached; } + animationFailures.delete(asset.id); + if (cached) { clearMedia(asset.id); } @@ -505,8 +515,7 @@ function ensureAnimatedImage(asset) { isAnimated: true }; - fetch(asset.url) - .then((r) => r.blob()) + fetchAssetBlob(asset) .then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' })) .then((decoder) => { if (controller.cancelled) { @@ -519,12 +528,32 @@ function ensureAnimatedImage(asset) { }) .catch(() => { animatedCache.delete(asset.id); + animationFailures.set(asset.id, Date.now()); }); animatedCache.set(asset.id, controller); return controller; } +function fetchAssetBlob(asset) { + const cached = blobCache.get(asset.id); + if (cached && cached.url === asset.url && cached.blob) { + return Promise.resolve(cached.blob); + } + if (cached && cached.url === asset.url && cached.pending) { + return cached.pending; + } + + const pending = fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => { + blobCache.set(asset.id, { url: asset.url, blob }); + return blob; + }); + blobCache.set(asset.id, { url: asset.url, pending }); + return pending; +} + function scheduleNextFrame(controller) { if (controller.cancelled || !controller.decoder) { return; @@ -559,6 +588,7 @@ function scheduleNextFrame(controller) { }).catch(() => { // If decoding fails, clear animated cache so static fallback is used next render animatedCache.delete(controller.id); + animationFailures.set(controller.id, Date.now()); }); } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 334060e..c218c0c 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -57,6 +57,7 @@ Choose an asset

Pick an asset in the list to adjust its placement and playback.

+