mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Improve media packets
This commit is contained in:
@@ -14,6 +14,7 @@ public class AssetEvent {
|
|||||||
private AssetView payload;
|
private AssetView payload;
|
||||||
private String assetId;
|
private String assetId;
|
||||||
private Boolean play;
|
private Boolean play;
|
||||||
|
private AssetPatch patch;
|
||||||
|
|
||||||
public static AssetEvent created(String channel, AssetView asset) {
|
public static AssetEvent created(String channel, AssetView asset) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
@@ -24,6 +25,15 @@ public class AssetEvent {
|
|||||||
return event;
|
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) {
|
public static AssetEvent play(String channel, AssetView asset, boolean play) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.PLAY;
|
event.type = Type.PLAY;
|
||||||
@@ -34,21 +44,12 @@ public class AssetEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AssetEvent updated(String channel, AssetView asset) {
|
public static AssetEvent visibility(String channel, AssetPatch patch) {
|
||||||
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) {
|
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.VISIBILITY;
|
event.type = Type.VISIBILITY;
|
||||||
event.channel = channel;
|
event.channel = channel;
|
||||||
event.payload = asset;
|
event.patch = patch;
|
||||||
event.assetId = asset.id();
|
event.assetId = patch.id();
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +80,8 @@ public class AssetEvent {
|
|||||||
public Boolean getPlay() {
|
public Boolean getPlay() {
|
||||||
return play;
|
return play;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AssetPatch getPatch() {
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/main/java/com/imgfloat/app/model/AssetPatch.java
Normal file
63
src/main/java/com/imgfloat/app/model/AssetPatch.java
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.imgfloat.app.service;
|
|||||||
|
|
||||||
import com.imgfloat.app.model.Asset;
|
import com.imgfloat.app.model.Asset;
|
||||||
import com.imgfloat.app.model.AssetEvent;
|
import com.imgfloat.app.model.AssetEvent;
|
||||||
|
import com.imgfloat.app.model.AssetPatch;
|
||||||
import com.imgfloat.app.model.Channel;
|
import com.imgfloat.app.model.Channel;
|
||||||
import com.imgfloat.app.model.AssetView;
|
import com.imgfloat.app.model.AssetView;
|
||||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||||
@@ -191,7 +192,8 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
assetRepository.save(asset);
|
assetRepository.save(asset);
|
||||||
AssetView view = AssetView.from(normalized, 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;
|
return view;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -216,7 +218,8 @@ public class ChannelDirectoryService {
|
|||||||
asset.setHidden(request.isHidden());
|
asset.setHidden(request.isHidden());
|
||||||
assetRepository.save(asset);
|
assetRepository.save(asset);
|
||||||
AssetView view = AssetView.from(normalized, 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;
|
return view;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,15 +314,18 @@ function updateRenderState(asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(assetId);
|
||||||
zOrderDirty = true;
|
zOrderDirty = true;
|
||||||
clearMedia(event.assetId);
|
clearMedia(assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(assetId);
|
||||||
loopPlaybackState.delete(event.assetId);
|
loopPlaybackState.delete(assetId);
|
||||||
if (selectedAssetId === event.assetId) {
|
if (selectedAssetId === assetId) {
|
||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
}
|
}
|
||||||
|
} else if (event.patch) {
|
||||||
|
applyPatch(assetId, event.patch);
|
||||||
} else if (event.payload) {
|
} else if (event.payload) {
|
||||||
storeAsset(event.payload);
|
storeAsset(event.payload);
|
||||||
if (!event.payload.hidden) {
|
if (!event.payload.hidden) {
|
||||||
@@ -338,6 +341,23 @@ function handleEvent(event) {
|
|||||||
drawAndList();
|
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() {
|
function drawAndList() {
|
||||||
requestDraw();
|
requestDraw();
|
||||||
renderAssetList();
|
renderAssetList();
|
||||||
|
|||||||
@@ -96,18 +96,21 @@ function resizeCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
if (event.type === 'DELETED') {
|
if (event.type === 'DELETED') {
|
||||||
assets.delete(event.assetId);
|
assets.delete(assetId);
|
||||||
clearMedia(event.assetId);
|
clearMedia(assetId);
|
||||||
renderStates.delete(event.assetId);
|
renderStates.delete(assetId);
|
||||||
|
} else if (event.patch) {
|
||||||
|
applyPatch(assetId, event.patch);
|
||||||
} else if (event.type === 'PLAY' && event.payload) {
|
} 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);
|
assets.set(payload.id, payload);
|
||||||
if (isAudioAsset(payload)) {
|
if (isAudioAsset(payload)) {
|
||||||
handleAudioPlay(payload, event.play !== false);
|
handleAudioPlay(payload, event.play !== false);
|
||||||
}
|
}
|
||||||
} else if (event.payload && !event.payload.hidden) {
|
} 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);
|
assets.set(payload.id, payload);
|
||||||
ensureMedia(payload);
|
ensureMedia(payload);
|
||||||
if (isAudioAsset(payload)) {
|
if (isAudioAsset(payload)) {
|
||||||
@@ -122,6 +125,40 @@ function handleEvent(event) {
|
|||||||
draw();
|
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() {
|
function draw() {
|
||||||
if (frameScheduled) {
|
if (frameScheduled) {
|
||||||
pendingDraw = true;
|
pendingDraw = true;
|
||||||
@@ -286,6 +323,10 @@ function clearMedia(assetId) {
|
|||||||
animatedCache.delete(assetId);
|
animatedCache.delete(assetId);
|
||||||
}
|
}
|
||||||
animationFailures.delete(assetId);
|
animationFailures.delete(assetId);
|
||||||
|
const cachedBlob = blobCache.get(assetId);
|
||||||
|
if (cachedBlob?.objectUrl) {
|
||||||
|
URL.revokeObjectURL(cachedBlob.objectUrl);
|
||||||
|
}
|
||||||
blobCache.delete(assetId);
|
blobCache.delete(assetId);
|
||||||
const audio = audioControllers.get(assetId);
|
const audio = audioControllers.get(assetId);
|
||||||
if (audio) {
|
if (audio) {
|
||||||
@@ -441,10 +482,11 @@ function autoStartAudio(asset) {
|
|||||||
|
|
||||||
function ensureMedia(asset) {
|
function ensureMedia(asset) {
|
||||||
const cached = mediaCache.get(asset.id);
|
const cached = mediaCache.get(asset.id);
|
||||||
if (cached && cached.src !== asset.url) {
|
const cachedSource = getCachedSource(cached);
|
||||||
|
if (cached && cachedSource !== asset.url) {
|
||||||
clearMedia(asset.id);
|
clearMedia(asset.id);
|
||||||
}
|
}
|
||||||
if (cached && cached.src === asset.url) {
|
if (cached && cachedSource === asset.url) {
|
||||||
applyMediaSettings(cached, asset);
|
applyMediaSettings(cached, asset);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
@@ -464,6 +506,7 @@ function ensureMedia(asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
|
||||||
|
element.dataset.sourceUrl = asset.url;
|
||||||
element.crossOrigin = 'anonymous';
|
element.crossOrigin = 'anonymous';
|
||||||
if (isVideoElement(element)) {
|
if (isVideoElement(element)) {
|
||||||
element.loop = true;
|
element.loop = true;
|
||||||
@@ -472,14 +515,9 @@ function ensureMedia(asset) {
|
|||||||
element.autoplay = true;
|
element.autoplay = true;
|
||||||
element.onloadeddata = draw;
|
element.onloadeddata = draw;
|
||||||
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
|
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
|
||||||
element.src = asset.url;
|
element.preload = 'auto';
|
||||||
const playback = asset.speed ?? 1;
|
element.addEventListener('error', () => clearMedia(asset.id));
|
||||||
element.playbackRate = Math.max(playback, 0.01);
|
setVideoSource(element, asset);
|
||||||
if (playback === 0) {
|
|
||||||
element.pause();
|
|
||||||
} else {
|
|
||||||
element.play().catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
element.onload = draw;
|
element.onload = draw;
|
||||||
element.src = asset.url;
|
element.src = asset.url;
|
||||||
@@ -547,13 +585,47 @@ function fetchAssetBlob(asset) {
|
|||||||
const pending = fetch(asset.url)
|
const pending = fetch(asset.url)
|
||||||
.then((r) => r.blob())
|
.then((r) => r.blob())
|
||||||
.then((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;
|
return blob;
|
||||||
});
|
});
|
||||||
blobCache.set(asset.id, { url: asset.url, pending });
|
blobCache.set(asset.id, { url: asset.url, pending });
|
||||||
return 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) {
|
function scheduleNextFrame(controller) {
|
||||||
if (controller.cancelled || !controller.decoder) {
|
if (controller.cancelled || !controller.decoder) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user