mirror of
https://github.com/imgfloat/server.git
synced 2026-03-22 23:10:38 +00:00
Add instant updates to assset transformations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user