Fix ordering

This commit is contained in:
2026-02-09 16:28:05 +01:00
parent 1c118aab0c
commit 1c6d115181
5 changed files with 387 additions and 60 deletions

View File

@@ -9,6 +9,7 @@ import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
@@ -333,6 +334,24 @@ public class ChannelApiController {
});
}
@PostMapping("/assets/order")
public ResponseEntity<Void> reorderAssets(
@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AssetOrderRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.debug("Reordering assets for {} by {}", logBroadcaster, logSessionUsername);
channelDirectoryService.reorderAssets(broadcaster, request.getUpdates(), sessionUsername);
return ResponseEntity.noContent().build();
}
@PostMapping("/assets/{assetId}/play")
public ResponseEntity<AssetView> play(
@PathVariable("broadcaster") String broadcaster,

View File

@@ -0,0 +1,29 @@
package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public class AssetOrderRequest {
@Valid
@NotEmpty
private List<AssetOrderUpdate> updates;
public List<AssetOrderUpdate> getUpdates() {
return updates;
}
public void setUpdates(List<AssetOrderUpdate> updates) {
this.updates = updates;
}
public static record AssetOrderUpdate(
@NotBlank
String assetId,
@NotNull
Integer order
) {}
}

View File

@@ -4,9 +4,11 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
@@ -1162,6 +1164,129 @@ public class ChannelDirectoryService {
});
}
@Transactional
public void reorderAssets(
String broadcaster,
List<AssetOrderRequest.AssetOrderUpdate> updates,
String actor
) {
if (updates == null || updates.isEmpty()) {
return;
}
String normalized = normalize(broadcaster);
applyBulkOrderUpdates(
broadcaster,
normalized,
updates,
EnumSet.of(AssetType.SCRIPT),
actor,
true
);
applyBulkOrderUpdates(
broadcaster,
normalized,
updates,
EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER),
actor,
false
);
}
private void applyBulkOrderUpdates(
String broadcaster,
String normalized,
List<AssetOrderRequest.AssetOrderUpdate> updates,
EnumSet<AssetType> types,
String actor,
boolean script
) {
if (updates == null || updates.isEmpty()) {
return;
}
List<Asset> bucket = assetRepository
.findByBroadcaster(normalized)
.stream()
.filter((asset) -> types.contains(asset.getAssetType()))
.sorted(
Comparator.comparingInt(this::displayOrderValue)
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.toList();
if (bucket.isEmpty()) {
return;
}
Map<String, Integer> desiredOrder = new HashMap<>();
Set<String> bucketIds = bucket.stream().map(Asset::getId).collect(Collectors.toSet());
for (AssetOrderRequest.AssetOrderUpdate update : updates) {
if (update == null) {
continue;
}
String assetId = update.assetId();
if (!bucketIds.contains(assetId)) {
continue;
}
Integer order = update.order();
if (order == null) {
continue;
}
if (order < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
}
desiredOrder.put(assetId, order);
}
if (desiredOrder.isEmpty()) {
return;
}
Map<String, Integer> originalIndex = new HashMap<>();
for (int index = 0; index < bucket.size(); index++) {
originalIndex.put(bucket.get(index).getId(), index);
}
List<Asset> ordered = new ArrayList<>(bucket);
ordered.sort((a, b) -> {
int orderA = desiredOrder.getOrDefault(a.getId(), a.getDisplayOrder() != null ? a.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(a.getId(), bucket.size()));
int orderB = desiredOrder.getOrDefault(b.getId(), b.getDisplayOrder() != null ? b.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(b.getId(), bucket.size()));
int cmp = Integer.compare(orderB, orderA);
if (cmp != 0) {
return cmp;
}
return Integer.compare(originalIndex.getOrDefault(a.getId(), Integer.MAX_VALUE), originalIndex.getOrDefault(b.getId(), Integer.MAX_VALUE));
});
List<Asset> changed = new ArrayList<>();
for (int index = 0; index < ordered.size(); index++) {
Asset asset = ordered.get(index);
int nextOrder = ordered.size() - index;
if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) {
asset.setDisplayOrder(nextOrder);
changed.add(asset);
}
}
if (changed.isEmpty()) {
return;
}
assetRepository.saveAll(changed);
publishOrderUpdates(broadcaster, null, changed);
for (Asset asset : changed) {
if (script) {
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"SCRIPT_ORDER_UPDATED",
formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
);
} else {
TransformRequest logDetails = new TransformRequest();
logDetails.setOrder(asset.getDisplayOrder());
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"VISUAL_UPDATED",
formatVisualTransformDetails(asset.getId(), logDetails)
);
}
}
}
private void validateVisualTransform(TransformRequest req) {
Settings settings = settingsService.get();
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
@@ -1171,13 +1296,13 @@ public class ChannelDirectoryService {
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
if (
req.getWidth() == null || req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels
req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
) throw new ResponseStatusException(
BAD_REQUEST,
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
);
if (
req.getHeight() == null || req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels
req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels)
) throw new ResponseStatusException(
BAD_REQUEST,
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"

View File

@@ -26,6 +26,7 @@ export function createAdminConsole({
const assets = new Map();
const mediaCache = new Map();
const renderStates = new Map();
const transformBaseline = new Map();
const animatedCache = new Map();
const audioControllers = new Map();
const pendingAudioUnlock = new Set();
@@ -506,7 +507,7 @@ export function createAdminConsole({
event.preventDefault();
updateRenderState(asset);
schedulePersistTransform(asset);
drawAndList();
drawAndList(false);
}
});
function connect() {
@@ -621,6 +622,7 @@ export function createAdminConsole({
if (!renderStates.has(asset.id)) {
renderStates.set(asset.id, { ...merged });
}
updateTransformBaseline(merged);
resolvePendingUploadByName(asset.name);
}
@@ -635,6 +637,35 @@ export function createAdminConsole({
renderStates.set(asset.id, state);
}
function updateTransformBaseline(asset) {
if (!asset?.id) {
return;
}
const snapshot = {};
snapshot.audioVolume = Number.isFinite(asset.audioVolume) ? asset.audioVolume : undefined;
if (isAudioAsset(asset)) {
snapshot.audioLoop = asset.audioLoop;
snapshot.audioDelayMillis = Number.isFinite(asset.audioDelayMillis)
? asset.audioDelayMillis
: undefined;
snapshot.audioSpeed = Number.isFinite(asset.audioSpeed) ? asset.audioSpeed : undefined;
snapshot.audioPitch = Number.isFinite(asset.audioPitch) ? asset.audioPitch : undefined;
} else {
snapshot.x = Number.isFinite(asset.x) ? asset.x : undefined;
snapshot.y = Number.isFinite(asset.y) ? asset.y : undefined;
snapshot.width = Number.isFinite(asset.width) ? asset.width : undefined;
snapshot.height = Number.isFinite(asset.height) ? asset.height : undefined;
snapshot.rotation = Number.isFinite(asset.rotation) ? asset.rotation : undefined;
snapshot.speed = Number.isFinite(asset.speed) ? asset.speed : undefined;
snapshot.muted = asset.muted;
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
if (Number.isFinite(order)) {
snapshot.order = order;
}
}
transformBaseline.set(asset.id, snapshot);
}
function handleEvent(event) {
if (event.type === "CANVAS" && event.payload) {
applyCanvasSettings(event.payload);
@@ -647,6 +678,7 @@ export function createAdminConsole({
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
clearMedia(assetId);
renderStates.delete(assetId);
transformBaseline.delete(assetId);
loopPlaybackState.delete(assetId);
cancelPendingTransform(assetId);
if (selectedAssetId === assetId) {
@@ -666,7 +698,27 @@ export function createAdminConsole({
loopPlaybackState.delete(event.payload.id);
}
}
drawAndList();
drawAndList(shouldRenderAssetList(event));
}
function shouldRenderAssetList(event) {
if (!event) {
return true;
}
const { type, payload, patch } = event;
if (type === "DELETED" || type === "VISIBILITY") {
return true;
}
if (payload) {
return true;
}
if (patch) {
if (patch.hidden != null || patch.order != null) {
return true;
}
return false;
}
return true;
}
function applyPatch(assetId, patch) {
@@ -706,10 +758,12 @@ export function createAdminConsole({
}
}
function drawAndList() {
function drawAndList(renderList = true) {
requestDraw();
if (renderList) {
renderAssetList();
}
}
function requestDraw() {
if (drawPending) {
@@ -2055,7 +2109,7 @@ export function createAdminConsole({
if (media) {
applyMediaSettings(media, asset);
}
drawAndList();
drawAndList(false);
}
function updateVolumeFromInput() {
@@ -2074,7 +2128,7 @@ export function createAdminConsole({
applyAudioSettings(controller, asset);
}
schedulePersistTransform(asset);
drawAndList();
drawAndList(false);
}
function updateAudioSettingsFromInputs() {
@@ -2104,7 +2158,7 @@ export function createAdminConsole({
const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset);
schedulePersistTransform(asset);
drawAndList();
drawAndList(false);
}
function nudgeRotation(delta) {
@@ -2114,7 +2168,7 @@ export function createAdminConsole({
asset.rotation = next;
updateRenderState(asset);
persistTransform(asset);
drawAndList();
drawAndList(false);
}
function recenterSelectedAsset() {
@@ -2126,7 +2180,7 @@ export function createAdminConsole({
asset.y = centerY;
updateRenderState(asset);
persistTransform(asset);
drawAndList();
drawAndList(false);
}
function getLayeredAssets(asset) {
@@ -2195,20 +2249,90 @@ export function createAdminConsole({
globalThis.sendToBack = sendToBack;
function applyLayerOrder(ordered) {
if (!ordered || !ordered.length) {
return;
}
const previousOrder = [...layerOrder];
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
layerOrder = newOrder;
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
changed.forEach((item) => updateRenderState(item));
changed.forEach((item) => schedulePersistTransform(item, true));
const orderUpdates = buildOrderUpdates(ordered, previousOrder);
sendOrderUpdates(orderUpdates, () => {
layerOrder = previousOrder;
drawAndList();
});
drawAndList();
}
function applyScriptLayerOrder(ordered) {
if (!ordered || !ordered.length) {
return;
}
const previousOrder = [...scriptLayerOrder];
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
scriptLayerOrder = newOrder;
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
changed.forEach((item) => schedulePersistTransform(item, true));
const orderUpdates = buildOrderUpdates(ordered, previousOrder);
sendOrderUpdates(orderUpdates, () => {
scriptLayerOrder = previousOrder;
drawAndList();
});
drawAndList();
}
function buildOrderUpdates(ordered, previousOrderIds) {
if (!ordered || !ordered.length) {
return [];
}
const previousLength = previousOrderIds.length || ordered.length;
const previousOrderMap = new Map();
previousOrderIds.forEach((id, index) => {
previousOrderMap.set(id, previousLength - index);
});
const updates = [];
const newLength = ordered.length;
ordered.forEach((asset, index) => {
if (!asset) {
return;
}
const newOrderValue = newLength - index;
const previousValue = previousOrderMap.get(asset.id);
if (previousValue !== newOrderValue) {
updates.push({ assetId: asset.id, order: newOrderValue });
}
});
return updates;
}
function sendOrderUpdates(updates, onError) {
if (!updates || !updates.length) {
return;
}
fetch(`/api/channels/${broadcaster}/assets/order`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
})
.then((response) => {
if (!response.ok) {
return extractErrorMessage(response, "Unable to reorder assets right now.").then((message) => {
throw new Error(message);
});
}
})
.catch((error) => {
if (onError) {
onError();
}
const assetDetails = updates
.map((update) => {
const asset = assets.get(update.assetId);
return `${asset?.name ?? "unknown"} (${update.assetId})`;
})
.join(", ");
console.warn("Asset reorder failed for", assetDetails, error?.message || error);
showToast(error?.message || "Unable to reorder assets right now.", "error");
});
}
function getAssetAspectRatio(asset) {
@@ -2318,6 +2442,7 @@ export function createAdminConsole({
clearMedia(asset.id);
assets.delete(asset.id);
renderStates.delete(asset.id);
transformBaseline.delete(asset.id);
layerOrder = layerOrder.filter((id) => id !== asset.id);
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
cancelPendingTransform(asset.id);
@@ -2452,29 +2577,13 @@ export function createAdminConsole({
}
function persistTransform(asset, silent = false) {
cancelPendingTransform(asset.id);
const payload = {
audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed,
audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume,
};
if (isCodeAsset(asset)) {
const order = getScriptLayerValue(asset.id);
payload.order = order;
} else if (!isAudioAsset(asset)) {
const order = getLayerValue(asset.id);
payload.x = asset.x;
payload.y = asset.y;
payload.width = asset.width;
payload.height = asset.height;
payload.rotation = asset.rotation;
payload.speed = asset.speed;
payload.order = order;
if (isVideoAsset(asset)) {
payload.muted = asset.muted;
if (!asset || !asset.id) {
return Promise.resolve();
}
cancelPendingTransform(asset.id);
const payload = buildTransformPayload(asset);
if (!Object.keys(payload).length) {
return Promise.resolve();
}
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
method: "PUT",
@@ -2493,7 +2602,7 @@ export function createAdminConsole({
storeAsset(updated);
updateRenderState(updated);
if (!silent) {
drawAndList();
drawAndList(false);
}
})
.catch((error) => {
@@ -2503,6 +2612,57 @@ export function createAdminConsole({
});
}
function buildTransformPayload(asset) {
if (!asset?.id) {
return {};
}
const baseline = transformBaseline.get(asset.id) || {};
const payload = {};
const addNumber = (key, value) => {
if (!Number.isFinite(value)) {
return;
}
const previous = baseline[key];
if (Number.isFinite(previous) && previous === value) {
return;
}
payload[key] = value;
};
const addBoolean = (key, value) => {
if (value == null) {
return;
}
if (baseline[key] === value) {
return;
}
payload[key] = value;
};
addBoolean("audioLoop", asset.audioLoop);
addNumber("audioDelayMillis", asset.audioDelayMillis);
addNumber("audioSpeed", asset.audioSpeed);
addNumber("audioPitch", asset.audioPitch);
addNumber("audioVolume", asset.audioVolume);
if (!isAudioAsset(asset)) {
addNumber("x", asset.x);
addNumber("y", asset.y);
addNumber("width", asset.width);
addNumber("height", asset.height);
addNumber("rotation", asset.rotation);
addNumber("speed", asset.speed);
if (isVideoAsset(asset)) {
addBoolean("muted", asset.muted);
}
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
if (Number.isFinite(order) && baseline.order !== order) {
payload.order = order;
}
}
return payload;
}
canvas.addEventListener("mousedown", (event) => {
const point = getCanvasPoint(event);
const current = getSelectedAsset();

View File

@@ -10,24 +10,18 @@ export function getVisibilityState(state, asset) {
}
export function smoothState(state, asset) {
const previous = state.renderStates.get(asset.id) || { ...asset };
const factor = 0.15;
const previous = state.renderStates.get(asset.id) || {};
const next = {
x: lerp(previous.x, asset.x, factor),
y: lerp(previous.y, asset.y, factor),
width: lerp(previous.width, asset.width, factor),
height: lerp(previous.height, asset.height, factor),
rotation: smoothAngle(previous.rotation, asset.rotation, factor),
x: Number.isFinite(asset.x) ? asset.x : previous.x ?? 0,
y: Number.isFinite(asset.y) ? asset.y : previous.y ?? 0,
width: Number.isFinite(asset.width) ? asset.width : previous.width ?? 0,
height: Number.isFinite(asset.height) ? asset.height : previous.height ?? 0,
rotation: Number.isFinite(asset.rotation) ? asset.rotation : previous.rotation ?? 0,
};
state.renderStates.set(asset.id, next);
return next;
}
function smoothAngle(current, target, factor) {
const delta = ((target - current + 180) % 360) - 180;
return current + delta * factor;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}