diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelPreviewWsController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelPreviewWsController.java new file mode 100644 index 0000000..6fdc462 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelPreviewWsController.java @@ -0,0 +1,69 @@ +package dev.kruhlmann.imgfloat.controller; + +import jakarta.validation.Valid; +import java.security.Principal; +import java.util.Locale; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Controller; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; +import dev.kruhlmann.imgfloat.model.api.response.AssetEvent; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; + +@Controller +public class ChannelPreviewWsController { + + private final ChannelDirectoryService channelDirectoryService; + private final AuthorizationService authorizationService; + private final SimpMessagingTemplate messagingTemplate; + + @Autowired + public ChannelPreviewWsController( + ChannelDirectoryService channelDirectoryService, + AuthorizationService authorizationService, + SimpMessagingTemplate messagingTemplate + ) { + this.channelDirectoryService = channelDirectoryService; + this.authorizationService = authorizationService; + this.messagingTemplate = messagingTemplate; + } + + @MessageMapping("/channel/{broadcaster}/assets/{assetId}/preview") + public void previewTransform( + @DestinationVariable String broadcaster, + @DestinationVariable String assetId, + @Payload @Valid TransformRequest request, + Principal principal + ) { + String sessionUsername = sessionUsername(principal); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + channelDirectoryService + .previewTransform(broadcaster, assetId, request) + .ifPresent((patch) -> messagingTemplate.convertAndSend( + topicFor(broadcaster), + AssetEvent.preview(broadcaster, assetId, patch) + )); + } + + private String sessionUsername(Principal principal) { + if (principal instanceof OAuth2AuthenticationToken token) { + OauthSessionUser user = OauthSessionUser.from(token); + return user == null ? null : user.login(); + } + return principal == null ? null : principal.getName(); + } + + private String topicFor(String broadcaster) { + return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/AssetEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/AssetEvent.java index b02b1a9..528d3c6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/AssetEvent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/AssetEvent.java @@ -10,6 +10,7 @@ public class AssetEvent { UPDATED, VISIBILITY, PLAY, + PREVIEW, DELETED, } @@ -67,6 +68,15 @@ public class AssetEvent { return event; } + public static AssetEvent preview(String channel, String assetId, AssetPatch patch) { + AssetEvent event = new AssetEvent(); + event.type = Type.PREVIEW; + event.channel = channel; + event.patch = patch; + event.assetId = assetId; + return event; + } + public static AssetEvent deleted(String channel, String assetId) { AssetEvent event = new AssetEvent(); event.type = Type.DELETED; diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 3744a64..91b855b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -67,6 +67,12 @@ public class ChannelDirectoryService { private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript"; private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32; private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$"); + private static final EnumSet VISUAL_ASSET_TYPES = EnumSet.of( + AssetType.IMAGE, + AssetType.VIDEO, + AssetType.MODEL, + AssetType.OTHER + ); private final ChannelRepository channelRepository; private final AssetRepository assetRepository; @@ -1164,6 +1170,75 @@ public class ChannelDirectoryService { }); } + public Optional previewTransform(String broadcaster, String assetId, TransformRequest request) { + String normalized = normalize(broadcaster); + + Asset asset = assetRepository + .findById(assetId) + .filter((stored) -> normalized.equals(stored.getBroadcaster())) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset not found")); + + if (!VISUAL_ASSET_TYPES.contains(asset.getAssetType())) { + throw new ResponseStatusException(BAD_REQUEST, "Asset is not visual"); + } + + VisualAsset visual = visualAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual")); + + TransformRequest previewRequest = copyVisualTransformRequest(request); + validateVisualBounds(previewRequest); + + AssetPatch.VisualSnapshot before = new AssetPatch.VisualSnapshot( + visual.getX(), + visual.getY(), + visual.getWidth(), + visual.getHeight(), + visual.getRotation(), + visual.getSpeed(), + visual.isMuted(), + displayOrderValue(asset), + visual.getAudioVolume() + ); + + VisualAsset previewState = new VisualAsset(); + previewState.setId(visual.getId()); + previewState.setName(visual.getName()); + previewState.setX(visual.getX()); + previewState.setY(visual.getY()); + previewState.setWidth(visual.getWidth()); + previewState.setHeight(visual.getHeight()); + previewState.setRotation(visual.getRotation()); + previewState.setSpeed(visual.getSpeed()); + previewState.setMuted(visual.isMuted()); + previewState.setAudioVolume(visual.getAudioVolume()); + + if (previewRequest.getX() != null) { + previewState.setX(previewRequest.getX()); + } + if (previewRequest.getY() != null) { + previewState.setY(previewRequest.getY()); + } + if (previewRequest.getWidth() != null) { + previewState.setWidth(previewRequest.getWidth()); + } + if (previewRequest.getHeight() != null) { + previewState.setHeight(previewRequest.getHeight()); + } + if (previewRequest.getRotation() != null) { + previewState.setRotation(previewRequest.getRotation()); + } + if (previewRequest.getSpeed() != null) { + previewState.setSpeed(previewRequest.getSpeed()); + } + if (previewRequest.getMuted() != null) { + previewState.setMuted(previewRequest.getMuted()); + } + + AssetPatch patch = AssetPatch.fromVisualTransform(before, previewState, previewRequest); + return hasPatchChanges(patch) ? Optional.of(patch) : Optional.empty(); + } + @Transactional public void reorderAssets( String broadcaster, @@ -1288,6 +1363,16 @@ public class ChannelDirectoryService { } private void validateVisualTransform(TransformRequest req) { + validateVisualBounds(req); + if (req.getOrder() != null && req.getOrder() < 1) { + throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1"); + } + } + + private void validateVisualBounds(TransformRequest req) { + if (req == null) { + return; + } Settings settings = settingsService.get(); double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); double minSpeed = settings.getMinAssetPlaybackSpeedFraction(); @@ -1297,29 +1382,48 @@ public class ChannelDirectoryService { if ( req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) - ) throw new ResponseStatusException( - BAD_REQUEST, - "Canvas width out of range [0 to " + canvasMaxSizePixels + "]" - ); + ) { + throw new ResponseStatusException( + BAD_REQUEST, + "Canvas width out of range [0 to " + canvasMaxSizePixels + "]" + ); + } if ( req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels) - ) throw new ResponseStatusException( - BAD_REQUEST, - "Canvas height out of range [0 to " + canvasMaxSizePixels + "]" - ); + ) { + throw new ResponseStatusException( + BAD_REQUEST, + "Canvas height out of range [0 to " + canvasMaxSizePixels + "]" + ); + } if ( req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed) - ) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); - if (req.getOrder() != null && req.getOrder() < 1) throw new ResponseStatusException( - BAD_REQUEST, - "Order must be >= 1" - ); + ) { + throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); + } if ( req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume) - ) throw new ResponseStatusException( - BAD_REQUEST, - "Audio volume out of range [" + minVolume + " to " + maxVolume + "]" - ); + ) { + throw new ResponseStatusException( + BAD_REQUEST, + "Audio volume out of range [" + minVolume + " to " + maxVolume + "]" + ); + } + } + + private TransformRequest copyVisualTransformRequest(TransformRequest source) { + TransformRequest copy = new TransformRequest(); + if (source == null) { + return copy; + } + copy.setX(source.getX()); + copy.setY(source.getY()); + copy.setWidth(source.getWidth()); + copy.setHeight(source.getHeight()); + copy.setRotation(source.getRotation()); + copy.setSpeed(source.getSpeed()); + copy.setMuted(source.getMuted()); + return copy; } private void validateAudioTransform(TransformRequest req) { diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index d42e4fa..c0cdcdc 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -34,6 +34,9 @@ export function createAdminConsole({ const previewCache = new Map(); const previewImageCache = new Map(); const pendingTransformSaves = new Map(); + const livePreviewQueue = new Map(); + const livePreviewLastSent = new Map(); + let livePreviewFrameScheduled = false; const HANDLE_SIZE = 10; const ROTATE_HANDLE_OFFSET = 32; const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100; @@ -181,6 +184,51 @@ export function createAdminConsole({ } } + function requestLiveTransform(asset) { + if (!asset?.id || isAudioAsset(asset)) { + return; + } + const payload = buildTransformPayload(asset); + if (!payload || !Object.keys(payload).length) { + livePreviewQueue.delete(asset.id); + return; + } + const serialized = JSON.stringify(payload); + if (livePreviewLastSent.get(asset.id) === serialized) { + return; + } + livePreviewQueue.set(asset.id, { payload, serialized }); + requestLivePreviewFrame(); + } + + function cancelLiveTransform(assetId) { + livePreviewQueue.delete(assetId); + livePreviewLastSent.delete(assetId); + } + + function requestLivePreviewFrame() { + if (livePreviewFrameScheduled) { + return; + } + livePreviewFrameScheduled = true; + requestAnimationFrame(sendQueuedLiveTransforms); + } + + function sendQueuedLiveTransforms() { + livePreviewFrameScheduled = false; + if (!livePreviewQueue.size) { + return; + } + if (!stompClient || (typeof stompClient.connected === "boolean" && !stompClient.connected)) { + return; + } + for (const [assetId, { payload, serialized }] of livePreviewQueue.entries()) { + stompClient.send(`/app/channel/${broadcaster}/assets/${assetId}/preview`, {}, JSON.stringify(payload)); + livePreviewLastSent.set(assetId, serialized); + } + livePreviewQueue.clear(); + } + function ensureLayerPosition(assetId, placement = "keep") { ensureLayerPositionForState(layerState, assetId, placement); } @@ -522,6 +570,9 @@ export function createAdminConsole({ handleEvent(body); }); fetchAssets(); + if (livePreviewQueue.size) { + requestLivePreviewFrame(); + } }, (error) => { console.warn("WebSocket connection issue", error); @@ -675,6 +726,11 @@ export function createAdminConsole({ return; } const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "PREVIEW" && event.patch) { + applyPreviewPatch(assetId, event.patch); + drawAndList(false); + return; + } if (event.type === "DELETED") { assets.delete(assetId); layerOrder = layerOrder.filter((id) => id !== assetId); @@ -684,6 +740,7 @@ export function createAdminConsole({ transformBaseline.delete(assetId); loopPlaybackState.delete(assetId); cancelPendingTransform(assetId); + cancelLiveTransform(assetId); if (selectedAssetId === assetId) { setSelectedAssetId(null); } @@ -708,6 +765,9 @@ export function createAdminConsole({ if (!event) { return true; } + if (event.type === "PREVIEW") { + return false; + } const { type, payload, patch } = event; if (type === "DELETED" || type === "VISIBILITY") { return true; @@ -761,6 +821,43 @@ export function createAdminConsole({ } } + function applyPreviewPatch(assetId, patch) { + if (!assetId || !patch) { + return; + } + const existing = assets.get(assetId); + if (!existing) { + return; + } + const merged = { ...existing, ...patch }; + const isAudio = isAudioAsset(merged); + const isScript = isCodeAsset(merged); + if (patch.hidden) { + clearMedia(assetId); + loopPlaybackState.delete(assetId); + } + const targetOrder = Number.isFinite(patch.order) ? patch.order : null; + if (!isAudio && Number.isFinite(targetOrder)) { + if (isScript) { + const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId); + const totalCount = currentOrder.length + 1; + const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder))); + currentOrder.splice(insertIndex, 0, assetId); + scriptLayerOrder = currentOrder; + } else { + const currentOrder = getLayerOrder().filter((id) => id !== assetId); + const totalCount = currentOrder.length + 1; + const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder))); + currentOrder.splice(insertIndex, 0, assetId); + layerOrder = currentOrder; + } + } + assets.set(assetId, merged); + if (!isAudio) { + updateRenderState(merged); + } + } + function markListDirty() { listNeedsRender = true; } @@ -1078,6 +1175,7 @@ export function createAdminConsole({ asset.width = nextWidth; asset.height = nextHeight; updateRenderState(asset); + requestLiveTransform(asset); requestDraw(); } @@ -2469,6 +2567,7 @@ export function createAdminConsole({ layerOrder = layerOrder.filter((id) => id !== asset.id); scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id); cancelPendingTransform(asset.id); + cancelLiveTransform(asset.id); if (selectedAssetId === asset.id) { setSelectedAssetId(null); } @@ -2605,6 +2704,7 @@ export function createAdminConsole({ return Promise.resolve(); } cancelPendingTransform(asset.id); + cancelLiveTransform(asset.id); const payload = buildTransformPayload(asset); if (!Object.keys(payload).length) { return Promise.resolve(); @@ -2749,6 +2849,7 @@ export function createAdminConsole({ asset.y = point.y - interactionState.offsetY; updateRenderState(asset); canvas.style.cursor = "grabbing"; + requestLiveTransform(asset); requestDraw(); } else if (interactionState.mode === "resize") { resizeFromHandle(interactionState, point); @@ -2758,6 +2859,7 @@ export function createAdminConsole({ asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); updateRenderState(asset); canvas.style.cursor = "grabbing"; + requestLiveTransform(asset); requestDraw(); } }); @@ -2771,6 +2873,7 @@ export function createAdminConsole({ canvas.style.cursor = "default"; drawAndList(); if (asset) { + cancelLiveTransform(asset.id); persistTransform(asset); } } diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index cd716e5..f91b879 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -177,6 +177,11 @@ export class BroadcastRenderer { return; } const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "PREVIEW" && event.patch) { + this.applyPreviewPatch(assetId, event.patch); + this.draw(); + return; + } if (event.type === "VISIBILITY") { this.handleVisibilityEvent(event); return; @@ -276,15 +281,12 @@ export class BroadcastRenderer { if (!assetId || !patch) { return; } - const sanitizedPatch = Object.fromEntries( - Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), - ); + const sanitizedPatch = this.sanitizePatch(patch); const existing = this.state.assets.get(assetId); if (!existing) { return; } const merged = this.normalizePayload({ ...existing, ...sanitizedPatch }); - console.log(merged); const isVisual = isVisualAsset(merged); const isScript = isCodeAsset(merged); if (sanitizedPatch.hidden) { @@ -316,6 +318,54 @@ export class BroadcastRenderer { } } + applyPreviewPatch(assetId, patch) { + if (!assetId || !patch) { + return; + } + const sanitizedPatch = this.sanitizePatch(patch); + if (!Object.keys(sanitizedPatch).length) { + return; + } + const existing = this.state.assets.get(assetId); + if (!existing) { + return; + } + const merged = this.normalizePayload({ ...existing, ...sanitizedPatch }); + if (sanitizedPatch.hidden) { + this.hideAssetWithTransition(merged); + return; + } + const isVisual = isVisualAsset(merged); + const isScript = isCodeAsset(merged); + const targetOrder = Number.isFinite(sanitizedPatch.order) ? sanitizedPatch.order : null; + if (Number.isFinite(targetOrder)) { + if (isScript) { + const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId); + const totalCount = currentOrder.length + 1; + const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder))); + currentOrder.splice(insertIndex, 0, assetId); + this.state.scriptLayerOrder = currentOrder; + this.applyScriptCanvasOrder(); + } else if (isVisual) { + const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId); + const totalCount = currentOrder.length + 1; + const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder))); + currentOrder.splice(insertIndex, 0, assetId); + this.state.layerOrder = currentOrder; + } + } + this.state.assets.set(assetId, merged); + } + + sanitizePatch(patch) { + if (!patch) { + return {}; + } + return Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), + ); + } + draw() { if (this.frameScheduled) { this.pendingDraw = true;