Improve media packets

This commit is contained in:
2025-12-10 16:30:42 +01:00
parent 772f11dace
commit 519ebbaaff
5 changed files with 198 additions and 35 deletions

View File

@@ -14,6 +14,7 @@ public class AssetEvent {
private AssetView payload;
private String assetId;
private Boolean play;
private AssetPatch patch;
public static AssetEvent created(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
@@ -24,6 +25,15 @@ public class AssetEvent {
return event;
}
public static AssetEvent updated(String channel, AssetPatch patch) {
AssetEvent event = new AssetEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.assetId = patch.id();
event.patch = patch;
return event;
}
public static AssetEvent play(String channel, AssetView asset, boolean play) {
AssetEvent event = new AssetEvent();
event.type = Type.PLAY;
@@ -34,21 +44,12 @@ public class AssetEvent {
return event;
}
public static AssetEvent updated(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.id();
return event;
}
public static AssetEvent visibility(String channel, AssetView asset) {
public static AssetEvent visibility(String channel, AssetPatch patch) {
AssetEvent event = new AssetEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = asset;
event.assetId = asset.id();
event.patch = patch;
event.assetId = patch.id();
return event;
}
@@ -79,4 +80,8 @@ public class AssetEvent {
public Boolean getPlay() {
return play;
}
public AssetPatch getPatch() {
return patch;
}
}

View File

@@ -0,0 +1,63 @@
package com.imgfloat.app.model;
/**
* Represents a partial update for an {@link Asset}. Only the fields that changed
* for a given operation are populated to reduce payload sizes sent over WebSocket.
*/
public record AssetPatch(
String id,
Double x,
Double y,
Double width,
Double height,
Double rotation,
Double speed,
Boolean muted,
Integer zIndex,
Boolean hidden,
Boolean audioLoop,
Integer audioDelayMillis,
Double audioSpeed,
Double audioPitch,
Double audioVolume
) {
public static AssetPatch fromTransform(Asset asset) {
return new AssetPatch(
asset.getId(),
asset.getX(),
asset.getY(),
asset.getWidth(),
asset.getHeight(),
asset.getRotation(),
asset.getSpeed(),
asset.isMuted(),
asset.getZIndex(),
null,
asset.isAudioLoop(),
asset.getAudioDelayMillis(),
asset.getAudioSpeed(),
asset.getAudioPitch(),
asset.getAudioVolume()
);
}
public static AssetPatch fromVisibility(Asset asset) {
return new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
null,
asset.isHidden(),
null,
null,
null,
null,
null
);
}
}

View File

@@ -2,6 +2,7 @@ package com.imgfloat.app.service;
import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.AssetPatch;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.CanvasSettingsRequest;
@@ -191,7 +192,8 @@ public class ChannelDirectoryService {
}
assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
AssetPatch patch = AssetPatch.fromTransform(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
return view;
});
}
@@ -216,7 +218,8 @@ public class ChannelDirectoryService {
asset.setHidden(request.isHidden());
assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, view));
AssetPatch patch = AssetPatch.fromVisibility(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch));
return view;
});
}

View File

@@ -314,15 +314,18 @@ function updateRenderState(asset) {
}
function handleEvent(event) {
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === 'DELETED') {
assets.delete(event.assetId);
assets.delete(assetId);
zOrderDirty = true;
clearMedia(event.assetId);
renderStates.delete(event.assetId);
loopPlaybackState.delete(event.assetId);
if (selectedAssetId === event.assetId) {
clearMedia(assetId);
renderStates.delete(assetId);
loopPlaybackState.delete(assetId);
if (selectedAssetId === assetId) {
selectedAssetId = null;
}
} else if (event.patch) {
applyPatch(assetId, event.patch);
} else if (event.payload) {
storeAsset(event.payload);
if (!event.payload.hidden) {
@@ -338,6 +341,23 @@ function handleEvent(event) {
drawAndList();
}
function applyPatch(assetId, patch) {
if (!assetId || !patch) {
return;
}
const existing = assets.get(assetId);
if (!existing) {
return;
}
const merged = { ...existing, ...patch };
if (patch.hidden) {
clearMedia(assetId);
loopPlaybackState.delete(assetId);
}
storeAsset(merged);
updateRenderState(merged);
}
function drawAndList() {
requestDraw();
renderAssetList();

View File

@@ -96,18 +96,21 @@ function resizeCanvas() {
}
function handleEvent(event) {
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === 'DELETED') {
assets.delete(event.assetId);
clearMedia(event.assetId);
renderStates.delete(event.assetId);
assets.delete(assetId);
clearMedia(assetId);
renderStates.delete(assetId);
} else if (event.patch) {
applyPatch(assetId, event.patch);
} else if (event.type === 'PLAY' && event.payload) {
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
const payload = normalizePayload(event.payload);
assets.set(payload.id, payload);
if (isAudioAsset(payload)) {
handleAudioPlay(payload, event.play !== false);
}
} else if (event.payload && !event.payload.hidden) {
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
const payload = normalizePayload(event.payload);
assets.set(payload.id, payload);
ensureMedia(payload);
if (isAudioAsset(payload)) {
@@ -122,6 +125,40 @@ function handleEvent(event) {
draw();
}
function normalizePayload(payload) {
return { ...payload, zIndex: Math.max(1, payload.zIndex ?? 1) };
}
function applyPatch(assetId, patch) {
if (!assetId || !patch) {
return;
}
const existing = assets.get(assetId);
if (!existing) {
return;
}
const merged = normalizePayload({ ...existing, ...patch });
if (patch.hidden) {
assets.delete(assetId);
clearMedia(assetId);
renderStates.delete(assetId);
return;
}
assets.set(assetId, merged);
ensureMedia(merged);
renderStates.set(assetId, { ...renderStates.get(assetId), ...pickTransform(merged) });
}
function pickTransform(asset) {
return {
x: asset.x,
y: asset.y,
width: asset.width,
height: asset.height,
rotation: asset.rotation
};
}
function draw() {
if (frameScheduled) {
pendingDraw = true;
@@ -286,6 +323,10 @@ function clearMedia(assetId) {
animatedCache.delete(assetId);
}
animationFailures.delete(assetId);
const cachedBlob = blobCache.get(assetId);
if (cachedBlob?.objectUrl) {
URL.revokeObjectURL(cachedBlob.objectUrl);
}
blobCache.delete(assetId);
const audio = audioControllers.get(assetId);
if (audio) {
@@ -441,10 +482,11 @@ function autoStartAudio(asset) {
function ensureMedia(asset) {
const cached = mediaCache.get(asset.id);
if (cached && cached.src !== asset.url) {
const cachedSource = getCachedSource(cached);
if (cached && cachedSource !== asset.url) {
clearMedia(asset.id);
}
if (cached && cached.src === asset.url) {
if (cached && cachedSource === asset.url) {
applyMediaSettings(cached, asset);
return cached;
}
@@ -464,6 +506,7 @@ function ensureMedia(asset) {
}
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
element.dataset.sourceUrl = asset.url;
element.crossOrigin = 'anonymous';
if (isVideoElement(element)) {
element.loop = true;
@@ -472,14 +515,9 @@ function ensureMedia(asset) {
element.autoplay = true;
element.onloadeddata = draw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.src = asset.url;
const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01);
if (playback === 0) {
element.pause();
} else {
element.play().catch(() => {});
}
element.preload = 'auto';
element.addEventListener('error', () => clearMedia(asset.id));
setVideoSource(element, asset);
} else {
element.onload = draw;
element.src = asset.url;
@@ -547,13 +585,47 @@ function fetchAssetBlob(asset) {
const pending = fetch(asset.url)
.then((r) => r.blob())
.then((blob) => {
blobCache.set(asset.id, { url: asset.url, blob });
const previous = blobCache.get(asset.id);
const existingUrl = previous?.url === asset.url ? previous.objectUrl : null;
const objectUrl = existingUrl || URL.createObjectURL(blob);
blobCache.set(asset.id, { url: asset.url, blob, objectUrl });
return blob;
});
blobCache.set(asset.id, { url: asset.url, pending });
return pending;
}
function setVideoSource(element, asset) {
const cached = blobCache.get(asset.id);
if (cached?.url === asset.url && cached.objectUrl) {
applyVideoSource(element, cached.objectUrl, asset);
return;
}
fetchAssetBlob(asset).then(() => {
const next = blobCache.get(asset.id);
if (next?.url !== asset.url || !next.objectUrl) {
return;
}
applyVideoSource(element, next.objectUrl, asset);
}).catch(() => {});
}
function applyVideoSource(element, objectUrl, asset) {
element.src = objectUrl;
const playback = asset.speed ?? 1;
element.playbackRate = Math.max(playback, 0.01);
if (playback === 0) {
element.pause();
} else {
element.play().catch(() => {});
}
}
function getCachedSource(element) {
return element?.dataset?.sourceUrl || element?.src;
}
function scheduleNextFrame(controller) {
if (controller.cancelled || !controller.decoder) {
return;