Add instant updates to assset transformations

This commit is contained in:
2026-02-09 17:15:29 +01:00
parent 0f088dc83b
commit ed5007538b
5 changed files with 357 additions and 21 deletions

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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<AssetType> 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<AssetPatch> 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) {

View File

@@ -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);
}
}

View File

@@ -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;