Add fragile layering system

This commit is contained in:
2026-01-15 17:16:55 +01:00
parent 266022e6f6
commit 04800a0c09
12 changed files with 228 additions and 133 deletions

View File

@@ -32,6 +32,9 @@ public class Asset {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private Instant updatedAt; private Instant updatedAt;
@Column(name = "display_order")
private Integer displayOrder;
public Asset() {} public Asset() {}
public Asset(String broadcaster, AssetType assetType) { public Asset(String broadcaster, AssetType assetType) {
@@ -57,6 +60,9 @@ public class Asset {
if (this.assetType == null) { if (this.assetType == null) {
this.assetType = AssetType.OTHER; this.assetType = AssetType.OTHER;
} }
if (this.displayOrder != null && this.displayOrder < 1) {
this.displayOrder = 1;
}
} }
public String getId() { public String getId() {
@@ -95,6 +101,14 @@ public class Asset {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public Integer getDisplayOrder() {
return displayOrder;
}
public void setDisplayOrder(Integer displayOrder) {
this.displayOrder = displayOrder;
}
private static String normalize(String value) { private static String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }

View File

@@ -16,7 +16,7 @@ public record AssetPatch(
Double rotation, Double rotation,
Double speed, Double speed,
Boolean muted, Boolean muted,
Integer zIndex, Integer order,
Boolean hidden, Boolean hidden,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
@@ -37,7 +37,7 @@ public record AssetPatch(
request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null, request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null,
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, request.getOrder() != null ? changed(before.order(), request.getOrder()) : null,
null, null,
null, null,
null, null,
@@ -112,7 +112,7 @@ public record AssetPatch(
double rotation, double rotation,
double speed, double speed,
boolean muted, boolean muted,
int zIndex, int order,
double audioVolume double audioVolume
) {} ) {}

View File

@@ -23,7 +23,6 @@ public record AssetView(
String originalMediaType, String originalMediaType,
AssetType assetType, AssetType assetType,
List<ScriptAssetAttachmentView> scriptAttachments, List<ScriptAssetAttachmentView> scriptAttachments,
Integer zIndex,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
Double audioSpeed, Double audioSpeed,
@@ -56,7 +55,6 @@ public record AssetView(
visual.getOriginalMediaType(), visual.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
null, null,
visual.getZIndex(),
null, null,
null, null,
null, null,
@@ -90,7 +88,6 @@ public record AssetView(
audio.getOriginalMediaType(), audio.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
null, null,
null,
audio.isAudioLoop(), audio.isAudioLoop(),
audio.getAudioDelayMillis(), audio.getAudioDelayMillis(),
audio.getAudioSpeed(), audio.getAudioSpeed(),
@@ -126,7 +123,6 @@ public record AssetView(
script.getOriginalMediaType(), script.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
script.getAttachments(), script.getAttachments(),
script.getZIndex(),
null, null,
null, null,
null, null,

View File

@@ -33,9 +33,6 @@ public class ScriptAsset {
@Column(name = "source_file_id") @Column(name = "source_file_id")
private String sourceFileId; private String sourceFileId;
@Column(name = "z_index")
private Integer zIndex;
@Transient @Transient
private List<ScriptAssetAttachmentView> attachments = List.of(); private List<ScriptAssetAttachmentView> attachments = List.of();
@@ -52,9 +49,6 @@ public class ScriptAsset {
if (this.name == null || this.name.isBlank()) { if (this.name == null || this.name.isBlank()) {
this.name = this.id; this.name = this.id;
} }
if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 1;
}
} }
public String getId() { public String getId() {
@@ -121,14 +115,6 @@ public class ScriptAsset {
this.sourceFileId = sourceFileId; this.sourceFileId = sourceFileId;
} }
public Integer getZIndex() {
return zIndex == null ? 1 : Math.max(1, zIndex);
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
}
public List<ScriptAssetAttachmentView> getAttachments() { public List<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments; return attachments == null ? List.of() : attachments;
} }

View File

@@ -24,8 +24,8 @@ public class TransformRequest {
private Boolean muted; private Boolean muted;
@Positive(message = "zIndex must be at least 1") @Positive(message = "Order must be at least 1")
private Integer zIndex; private Integer order;
private Boolean audioLoop; private Boolean audioLoop;
@@ -100,12 +100,12 @@ public class TransformRequest {
this.muted = muted; this.muted = muted;
} }
public Integer getZIndex() { public Integer getOrder() {
return zIndex; return order;
} }
public void setZIndex(Integer zIndex) { public void setOrder(Integer order) {
this.zIndex = zIndex; this.order = order;
} }
public Boolean getAudioLoop() { public Boolean getAudioLoop() {

View File

@@ -28,7 +28,6 @@ public class VisualAsset {
private Boolean muted; private Boolean muted;
private String mediaType; private String mediaType;
private String originalMediaType; private String originalMediaType;
private Integer zIndex;
private Double audioVolume; private Double audioVolume;
private boolean hidden; private boolean hidden;
@@ -44,7 +43,6 @@ public class VisualAsset {
this.rotation = 0; this.rotation = 0;
this.speed = 1.0; this.speed = 1.0;
this.muted = false; this.muted = false;
this.zIndex = 1;
this.audioVolume = 1.0; this.audioVolume = 1.0;
this.hidden = true; this.hidden = true;
} }
@@ -58,9 +56,6 @@ public class VisualAsset {
if (this.muted == null) { if (this.muted == null) {
this.muted = Boolean.FALSE; this.muted = Boolean.FALSE;
} }
if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 1;
}
if (this.audioVolume == null) { if (this.audioVolume == null) {
this.audioVolume = 1.0; this.audioVolume = 1.0;
} }
@@ -165,14 +160,6 @@ public class VisualAsset {
this.originalMediaType = originalMediaType; this.originalMediaType = originalMediaType;
} }
public Integer getZIndex() {
return zIndex == null ? 1 : Math.max(1, zIndex);
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
}
public double getAudioVolume() { public double getAudioVolume() {
return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume)); return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume));
} }

View File

@@ -189,10 +189,11 @@ public class ChannelDirectoryService {
.stream() .stream()
.map((visual) -> { .map((visual) -> {
Asset asset = assetById.get(visual.getId()); Asset asset = assetById.get(visual.getId());
return asset == null ? null : AssetView.fromVisual(normalized, asset, visual); return asset == null ? null : Map.entry(asset, visual);
}) })
.filter(Objects::nonNull) .filter(Objects::nonNull)
.sorted(Comparator.comparingInt(AssetView::zIndex).reversed()) .sorted(Comparator.comparingInt((Map.Entry<Asset, VisualAsset> entry) -> displayOrderValue(entry.getKey())).reversed())
.map((entry) -> AssetView.fromVisual(normalized, entry.getKey(), entry.getValue()))
.toList(); .toList();
} }
@@ -318,6 +319,13 @@ public class ChannelDirectoryService {
boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType); boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType);
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
Asset asset = new Asset(channel.getBroadcaster(), assetType); Asset asset = new Asset(channel.getBroadcaster(), assetType);
if (!isAudio && isCode) {
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
} else if (!isAudio) {
asset.setDisplayOrder(
nextDisplayOrder(channel.getBroadcaster(), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
);
}
assetStorageService.storeAsset( assetStorageService.storeAsset(
channel.getBroadcaster(), channel.getBroadcaster(),
@@ -340,7 +348,6 @@ public class ChannelDirectoryService {
script.setMediaType(optimized.mediaType()); script.setMediaType(optimized.mediaType());
script.setOriginalMediaType(mediaType); script.setOriginalMediaType(mediaType);
script.setSourceFileId(asset.getId()); script.setSourceFileId(asset.getId());
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
@@ -358,7 +365,6 @@ public class ChannelDirectoryService {
visual.setOriginalMediaType(mediaType); visual.setOriginalMediaType(mediaType);
visual.setMediaType(optimized.mediaType()); visual.setMediaType(optimized.mediaType());
visual.setMuted(optimized.mediaType().startsWith("video/")); visual.setMuted(optimized.mediaType().startsWith("video/"));
visual.setZIndex(nextZIndex(channel.getBroadcaster()));
assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()); assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
visualAssetRepository.save(visual); visualAssetRepository.save(visual);
@@ -383,6 +389,7 @@ public class ChannelDirectoryService {
enforceUploadLimit(bytes.length); enforceUploadLimit(bytes.length);
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT); Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId()); sourceFile.setId(asset.getId());
sourceFile.setMediaType(DEFAULT_CODE_MEDIA_TYPE); sourceFile.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
@@ -407,7 +414,6 @@ public class ChannelDirectoryService {
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setDescription(normalizeDescription(request.getDescription())); script.setDescription(normalizeDescription(request.getDescription()));
script.setPublic(Boolean.TRUE.equals(request.getIsPublic())); script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
@@ -787,6 +793,7 @@ public class ChannelDirectoryService {
} }
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId()); sourceFile.setId(asset.getId());
sourceFile.setMediaType(sourceContent.mediaType()); sourceFile.setMediaType(sourceContent.mediaType());
@@ -811,7 +818,6 @@ public class ChannelDirectoryService {
script.setOriginalMediaType(sourceContent.mediaType()); script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setLogoFileId(sourceScript.getLogoFileId()); script.setLogoFileId(sourceScript.getLogoFileId());
script.setZIndex(nextScriptZIndex(targetBroadcaster));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
@@ -858,6 +864,7 @@ public class ChannelDirectoryService {
} }
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId()); sourceFile.setId(asset.getId());
sourceFile.setMediaType(sourceContent.mediaType()); sourceFile.setMediaType(sourceContent.mediaType());
@@ -881,7 +888,6 @@ public class ChannelDirectoryService {
script.setMediaType(sourceContent.mediaType()); script.setMediaType(sourceContent.mediaType());
script.setOriginalMediaType(sourceContent.mediaType()); script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setZIndex(nextScriptZIndex(targetBroadcaster));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
@@ -1009,14 +1015,19 @@ public class ChannelDirectoryService {
ScriptAsset script = scriptAssetRepository ScriptAsset script = scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
int beforeZIndex = script.getZIndex(); Integer beforeOrder = asset.getDisplayOrder();
if (req.getZIndex() != null) { List<Asset> orderUpdates = List.of();
if (req.getZIndex() < 1) { if (req.getOrder() != null) {
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1"); if (req.getOrder() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
} }
script.setZIndex(req.getZIndex()); orderUpdates = updateDisplayOrder(
scriptAssetRepository.save(script); normalized,
if (beforeZIndex != script.getZIndex()) { asset,
req.getOrder(),
EnumSet.of(AssetType.SCRIPT)
);
if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) {
AssetPatch patch = new AssetPatch( AssetPatch patch = new AssetPatch(
asset.getId(), asset.getId(),
null, null,
@@ -1026,7 +1037,7 @@ public class ChannelDirectoryService {
null, null,
null, null,
null, null,
script.getZIndex(), asset.getDisplayOrder(),
null, null,
null, null,
null, null,
@@ -1038,10 +1049,11 @@ public class ChannelDirectoryService {
auditLogService.recordEntry( auditLogService.recordEntry(
asset.getBroadcaster(), asset.getBroadcaster(),
actor, actor,
"SCRIPT_LAYER_UPDATED", "SCRIPT_ORDER_UPDATED",
formatScriptTransformDetails(asset.getId(), script.getZIndex()) formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
); );
} }
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
} }
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
return AssetView.fromScript(normalized, asset, script); return AssetView.fromScript(normalized, asset, script);
@@ -1058,17 +1070,28 @@ public class ChannelDirectoryService {
visual.getRotation(), visual.getRotation(),
visual.getSpeed(), visual.getSpeed(),
visual.isMuted(), visual.isMuted(),
visual.getZIndex(), displayOrderValue(asset),
visual.getAudioVolume() visual.getAudioVolume()
); );
validateVisualTransform(req); validateVisualTransform(req);
List<Asset> orderUpdates = List.of();
if (req.getX() != null) visual.setX(req.getX()); if (req.getX() != null) visual.setX(req.getX());
if (req.getY() != null) visual.setY(req.getY()); if (req.getY() != null) visual.setY(req.getY());
if (req.getWidth() != null) visual.setWidth(req.getWidth()); if (req.getWidth() != null) visual.setWidth(req.getWidth());
if (req.getHeight() != null) visual.setHeight(req.getHeight()); if (req.getHeight() != null) visual.setHeight(req.getHeight());
if (req.getRotation() != null) visual.setRotation(req.getRotation()); if (req.getRotation() != null) visual.setRotation(req.getRotation());
if (req.getZIndex() != null) visual.setZIndex(req.getZIndex()); if (req.getOrder() != null) {
orderUpdates = updateDisplayOrder(
normalized,
asset,
req.getOrder(),
EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
);
if (asset.getDisplayOrder() != null) {
req.setOrder(asset.getDisplayOrder());
}
}
if (req.getSpeed() != null) visual.setSpeed(req.getSpeed()); if (req.getSpeed() != null) visual.setSpeed(req.getSpeed());
if (req.getMuted() != null) visual.setMuted(req.getMuted()); if (req.getMuted() != null) visual.setMuted(req.getMuted());
if (req.getAudioVolume() != null) visual.setAudioVolume(req.getAudioVolume()); if (req.getAudioVolume() != null) visual.setAudioVolume(req.getAudioVolume());
@@ -1086,6 +1109,7 @@ public class ChannelDirectoryService {
formatVisualTransformDetails(asset.getId(), req) formatVisualTransformDetails(asset.getId(), req)
); );
} }
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
return view; return view;
}); });
} }
@@ -1113,9 +1137,9 @@ public class ChannelDirectoryService {
if ( if (
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed) req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); ) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException( if (req.getOrder() != null && req.getOrder() < 1) throw new ResponseStatusException(
BAD_REQUEST, BAD_REQUEST,
"zIndex must be >= 1" "Order must be >= 1"
); );
if ( if (
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume) req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
@@ -1580,51 +1604,100 @@ public class ChannelDirectoryService {
return assets return assets
.stream() .stream()
.sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments)) .map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments))
.filter(Objects::nonNull) .filter(Objects::nonNull)
.sorted(
Comparator.comparingInt((AssetView view) -> view.zIndex() == null ? Integer.MIN_VALUE : view.zIndex())
.reversed()
.thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.toList(); .toList();
} }
private int nextZIndex(String broadcaster) { private int nextDisplayOrder(String broadcaster, AssetType... types) {
return ( return (
visualAssetRepository assetRepository
.findByIdIn( .findByBroadcaster(normalize(broadcaster))
assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
)
.stream() .stream()
.mapToInt(VisualAsset::getZIndex) .filter((asset) -> Arrays.asList(types).contains(asset.getAssetType()))
.map(Asset::getDisplayOrder)
.filter(Objects::nonNull)
.mapToInt(Integer::intValue)
.max() .max()
.orElse(0) + .orElse(0) +
1 1
); );
} }
private int nextScriptZIndex(String broadcaster) { private int displayOrderValue(Asset asset) {
return ( Integer value = asset == null ? null : asset.getDisplayOrder();
scriptAssetRepository return value == null ? Integer.MIN_VALUE : value;
.findByIdIn(assetsWithType(normalize(broadcaster), AssetType.SCRIPT))
.stream()
.mapToInt(ScriptAsset::getZIndex)
.max()
.orElse(0) +
1
);
} }
private List<String> assetsWithType(String broadcaster, AssetType... types) { private List<Asset> updateDisplayOrder(String broadcaster, Asset target, int desiredOrder, Set<AssetType> types) {
Set<AssetType> typeSet = EnumSet.noneOf(AssetType.class); if (target == null || types == null || types.isEmpty()) {
typeSet.addAll(Arrays.asList(types)); return List.of();
return assetRepository }
List<Asset> ordered = assetRepository
.findByBroadcaster(normalize(broadcaster)) .findByBroadcaster(normalize(broadcaster))
.stream() .stream()
.filter((asset) -> typeSet.contains(asset.getAssetType())) .filter((asset) -> types.contains(asset.getAssetType()))
.map(Asset::getId) .sorted(
.toList(); Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.collect(Collectors.toCollection(ArrayList::new));
ordered.removeIf((asset) -> asset.getId().equals(target.getId()));
int insertIndex = Math.max(0, Math.min(ordered.size(), ordered.size() - desiredOrder));
ordered.add(insertIndex, target);
int size = ordered.size();
List<Asset> changed = new ArrayList<>();
for (int index = 0; index < size; index++) {
Asset asset = ordered.get(index);
int nextOrder = size - index;
if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) {
asset.setDisplayOrder(nextOrder);
changed.add(asset);
}
}
if (!changed.isEmpty()) {
assetRepository.saveAll(changed);
}
return changed;
}
private void publishOrderUpdates(String broadcaster, String targetAssetId, List<Asset> updates) {
if (updates == null || updates.isEmpty()) {
return;
}
updates
.stream()
.filter((asset) -> asset.getDisplayOrder() != null)
.filter((asset) -> !asset.getId().equals(targetAssetId))
.forEach((asset) -> {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
});
} }
private AssetView resolveAssetView(String broadcaster, Asset asset) { private AssetView resolveAssetView(String broadcaster, Asset asset) {
@@ -1818,7 +1891,7 @@ public class ChannelDirectoryService {
if (req.getWidth() != null) parts.add("width=" + req.getWidth()); if (req.getWidth() != null) parts.add("width=" + req.getWidth());
if (req.getHeight() != null) parts.add("height=" + req.getHeight()); if (req.getHeight() != null) parts.add("height=" + req.getHeight());
if (req.getRotation() != null) parts.add("rotation=" + req.getRotation()); if (req.getRotation() != null) parts.add("rotation=" + req.getRotation());
if (req.getZIndex() != null) parts.add("zIndex=" + req.getZIndex()); if (req.getOrder() != null) parts.add("order=" + req.getOrder());
if (req.getSpeed() != null) parts.add("speed=" + req.getSpeed()); if (req.getSpeed() != null) parts.add("speed=" + req.getSpeed());
if (req.getMuted() != null) parts.add("muted=" + req.getMuted()); if (req.getMuted() != null) parts.add("muted=" + req.getMuted());
if (req.getAudioVolume() != null) parts.add("audioVolume=" + req.getAudioVolume()); if (req.getAudioVolume() != null) parts.add("audioVolume=" + req.getAudioVolume());
@@ -1835,10 +1908,10 @@ public class ChannelDirectoryService {
return formatTransformDetails("Updated audio asset " + assetId, parts); return formatTransformDetails("Updated audio asset " + assetId, parts);
} }
private String formatScriptTransformDetails(String assetId, Integer zIndex) { private String formatScriptTransformDetails(String assetId, Integer order) {
String detail = "Updated script asset " + assetId; String detail = "Updated script asset " + assetId;
if (zIndex != null) { if (order != null) {
return detail + " (zIndex=" + zIndex + ")"; return detail + " (order=" + order + ")";
} }
return detail; return detail;
} }
@@ -1859,7 +1932,7 @@ public class ChannelDirectoryService {
patch.rotation() != null || patch.rotation() != null ||
patch.speed() != null || patch.speed() != null ||
patch.muted() != null || patch.muted() != null ||
patch.zIndex() != null || patch.order() != null ||
patch.hidden() != null || patch.hidden() != null ||
patch.audioLoop() != null || patch.audioLoop() != null ||
patch.audioDelayMillis() != null || patch.audioDelayMillis() != null ||

View File

@@ -0,0 +1,20 @@
ALTER TABLE assets ADD COLUMN display_order INTEGER;
UPDATE assets
SET display_order = visual_assets.z_index
FROM visual_assets
WHERE assets.id = visual_assets.id
AND assets.asset_type IN ('IMAGE', 'VIDEO', 'MODEL', 'OTHER');
UPDATE assets
SET display_order = script_assets.z_index
FROM script_assets
WHERE assets.id = script_assets.id
AND assets.asset_type = 'SCRIPT';
UPDATE assets
SET display_order = 1
WHERE display_order IS NULL;
ALTER TABLE visual_assets DROP COLUMN z_index;
ALTER TABLE script_assets DROP COLUMN z_index;

View File

@@ -47,7 +47,7 @@ export function createAdminConsole({
const speedLabel = document.getElementById("asset-speed-label"); const speedLabel = document.getElementById("asset-speed-label");
const volumeInput = document.getElementById("asset-volume"); const volumeInput = document.getElementById("asset-volume");
const volumeLabel = document.getElementById("asset-volume-label"); const volumeLabel = document.getElementById("asset-volume-label");
const selectedZLabel = document.getElementById("asset-z-level"); const selectedOrderLabel = document.getElementById("asset-order-position");
const playbackSection = document.getElementById("playback-section"); const playbackSection = document.getElementById("playback-section");
const volumeSection = document.getElementById("volume-section"); const volumeSection = document.getElementById("volume-section");
const audioSection = document.getElementById("audio-section"); const audioSection = document.getElementById("audio-section");
@@ -238,6 +238,20 @@ export function createAdminConsole({
return order.length - index; return order.length - index;
} }
function getLayerPosition(assetId) {
const order = getLayerOrder();
const index = order.indexOf(assetId);
if (index === -1) return 1;
return index + 1;
}
function getScriptLayerPosition(assetId) {
const order = getScriptLayerOrder();
const index = order.indexOf(assetId);
if (index === -1) return 1;
return index + 1;
}
function addPendingUpload(name) { function addPendingUpload(name) {
const pending = { const pending = {
id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`,
@@ -662,23 +676,16 @@ export function createAdminConsole({
clearMedia(assetId); clearMedia(assetId);
loopPlaybackState.delete(assetId); loopPlaybackState.delete(assetId);
} }
let targetLayer; const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
if (Number.isFinite(patch.layer)) { if (!isAudio && Number.isFinite(targetOrder)) {
targetLayer = patch.layer;
} else if (Number.isFinite(patch.zIndex)) {
targetLayer = patch.zIndex;
} else {
targetLayer = null;
}
if (!isAudio && Number.isFinite(targetLayer)) {
if (isScript) { if (isScript) {
const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId); const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
scriptLayerOrder = currentOrder; scriptLayerOrder = currentOrder;
} else { } else {
const currentOrder = getLayerOrder().filter((id) => id !== assetId); const currentOrder = getLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
layerOrder = currentOrder; layerOrder = currentOrder;
} }
@@ -1340,9 +1347,9 @@ export function createAdminConsole({
return null; return null;
} }
if (isCodeAsset(asset)) { if (isCodeAsset(asset)) {
return `Script layer ${getScriptLayerValue(asset.id)}`; return `Script order ${getScriptLayerPosition(asset.id)} (above canvas assets)`;
} }
return `Layer ${getLayerValue(asset.id)}`; return `Order ${getLayerPosition(asset.id)}`;
} }
function createSectionHeader(title) { function createSectionHeader(title) {
@@ -1392,11 +1399,16 @@ export function createAdminConsole({
} }
if (!isAudioAsset(asset)) { if (!isAudioAsset(asset)) {
const ordered = getLayeredAssets(asset);
const orderIndex = ordered.findIndex((item) => item.id === asset.id);
const canMoveUp = orderIndex > 0;
const canMoveDown = orderIndex !== -1 && orderIndex < ordered.length - 1;
const moveUp = document.createElement("button"); const moveUp = document.createElement("button");
moveUp.type = "button"; moveUp.type = "button";
moveUp.className = "ghost icon-button"; moveUp.className = "ghost icon-button";
moveUp.innerHTML = '<i class="fa-solid fa-arrow-up"></i>'; moveUp.innerHTML = '<i class="fa-solid fa-arrow-up"></i>';
moveUp.title = isCodeAsset(asset) ? "Move script up" : "Move layer up"; moveUp.title = isCodeAsset(asset) ? "Move script up" : "Move asset up";
moveUp.disabled = !canMoveUp;
moveUp.addEventListener("click", (e) => { moveUp.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
moveLayerItem(asset, "up"); moveLayerItem(asset, "up");
@@ -1405,7 +1417,8 @@ export function createAdminConsole({
moveDown.type = "button"; moveDown.type = "button";
moveDown.className = "ghost icon-button"; moveDown.className = "ghost icon-button";
moveDown.innerHTML = '<i class="fa-solid fa-arrow-down"></i>'; moveDown.innerHTML = '<i class="fa-solid fa-arrow-down"></i>';
moveDown.title = isCodeAsset(asset) ? "Move script down" : "Move layer down"; moveDown.title = isCodeAsset(asset) ? "Move script down" : "Move asset down";
moveDown.disabled = !canMoveDown;
moveDown.addEventListener("click", (e) => { moveDown.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
moveLayerItem(asset, "down"); moveLayerItem(asset, "down");
@@ -1802,8 +1815,10 @@ export function createAdminConsole({
controlsPanel.classList.remove("hidden"); controlsPanel.classList.remove("hidden");
lastSizeInputChanged = null; lastSizeInputChanged = null;
if (selectedZLabel) { if (selectedOrderLabel) {
selectedZLabel.textContent = getLayerValue(asset.id); selectedOrderLabel.textContent = isCodeAsset(asset)
? getScriptLayerPosition(asset.id)
: getLayerPosition(asset.id);
} }
if (widthInput) widthInput.value = Math.round(asset.width); if (widthInput) widthInput.value = Math.round(asset.width);
@@ -1921,9 +1936,12 @@ export function createAdminConsole({
if (asset) { if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
if (isCodeAsset(asset)) { if (isCodeAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Script layer ${getScriptLayerValue(asset.id)}`, "subtle")); selectedAssetBadges.appendChild(
createBadge(`Script order ${getScriptLayerPosition(asset.id)}`, "subtle"),
);
selectedAssetBadges.appendChild(createBadge("Above canvas assets", "subtle"));
} else if (!isAudioAsset(asset)) { } else if (!isAudioAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Layer ${getLayerValue(asset.id)}`, "subtle")); selectedAssetBadges.appendChild(createBadge(`Order ${getLayerPosition(asset.id)}`, "subtle"));
} }
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : ""; const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) { if (aspectLabel) {
@@ -2445,18 +2463,17 @@ export function createAdminConsole({
audioVolume: asset.audioVolume, audioVolume: asset.audioVolume,
}; };
if (isCodeAsset(asset)) { if (isCodeAsset(asset)) {
const layer = getScriptLayerValue(asset.id); const order = getScriptLayerValue(asset.id);
payload.zIndex = layer; payload.order = order;
} else if (!isAudioAsset(asset)) { } else if (!isAudioAsset(asset)) {
const layer = getLayerValue(asset.id); const order = getLayerValue(asset.id);
payload.x = asset.x; payload.x = asset.x;
payload.y = asset.y; payload.y = asset.y;
payload.width = asset.width; payload.width = asset.width;
payload.height = asset.height; payload.height = asset.height;
payload.rotation = asset.rotation; payload.rotation = asset.rotation;
payload.speed = asset.speed; payload.speed = asset.speed;
payload.layer = layer; payload.order = order;
payload.zIndex = layer;
if (isVideoAsset(asset)) { if (isVideoAsset(asset)) {
payload.muted = asset.muted; payload.muted = asset.muted;
} }

View File

@@ -291,21 +291,17 @@ export class BroadcastRenderer {
this.hideAssetWithTransition(merged); this.hideAssetWithTransition(merged);
return; return;
} }
const targetLayer = Number.isFinite(patch.layer) const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
? patch.layer if (Number.isFinite(targetOrder)) {
: Number.isFinite(patch.zIndex)
? patch.zIndex
: null;
if (Number.isFinite(targetLayer)) {
if (isScript) { if (isScript) {
const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId); const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
this.state.scriptLayerOrder = currentOrder; this.state.scriptLayerOrder = currentOrder;
this.applyScriptCanvasOrder(); this.applyScriptCanvasOrder();
} else if (isVisual) { } else if (isVisual) {
const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId); const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); const insertIndex = Math.max(0, currentOrder.length - Math.round(targetOrder));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
this.state.layerOrder = currentOrder; this.state.layerOrder = currentOrder;
} }
@@ -827,12 +823,14 @@ export class BroadcastRenderer {
return; return;
} }
const ordered = getScriptLayerOrder(this.state); const ordered = getScriptLayerOrder(this.state);
ordered.forEach((id, index) => { ordered
.slice()
.reverse()
.forEach((id) => {
const canvas = this.scriptCanvases.get(id); const canvas = this.scriptCanvases.get(id);
if (!canvas) { if (!canvas) {
return; return;
} }
canvas.style.zIndex = `${ordered.length - index}`;
this.scriptLayer.appendChild(canvas); this.scriptLayer.appendChild(canvas);
}); });
} }

View File

@@ -100,6 +100,10 @@
<div class="panel-section" id="layout-section"> <div class="panel-section" id="layout-section">
<div class="section-header"> <div class="section-header">
<h5>Layout & order</h5> <h5>Layout & order</h5>
<p class="subtle-text">
The top of the list renders on top in the broadcast. Script assets always render
above canvas assets.
</p>
</div> </div>
<div class="property-list"> <div class="property-list">
<div class="property-row"> <div class="property-row">
@@ -132,11 +136,11 @@
</label> </label>
</div> </div>
<div class="property-row"> <div class="property-row">
<span class="property-label">Layer</span> <span class="property-label">Order</span>
<div class="property-control"> <div class="property-control">
<div class="badge-row stacked"> <div class="badge-row stacked">
<span class="badge" <span class="badge"
>Layer <strong id="asset-z-level">1</strong></span >Position <strong id="asset-order-position">1</strong></span
> >
</div> </div>
</div> </div>

View File

@@ -198,13 +198,13 @@ class ChannelDirectoryServiceTest {
TransformRequest transform = validTransform(); TransformRequest transform = validTransform();
transform.setSpeed(0.1); transform.setSpeed(0.1);
transform.setAudioVolume(0.01); transform.setAudioVolume(0.01);
transform.setZIndex(1); transform.setOrder(1);
AssetView view = service.updateTransform(channel, id, transform, "caster").orElseThrow(); AssetView view = service.updateTransform(channel, id, transform, "caster").orElseThrow();
assertThat(view.speed()).isEqualTo(0.1); assertThat(view.speed()).isEqualTo(0.1);
assertThat(view.audioVolume()).isEqualTo(0.01); assertThat(view.audioVolume()).isEqualTo(0.01);
assertThat(view.zIndex()).isEqualTo(1); assertThat(assetRepository.findById(id).orElseThrow().getDisplayOrder()).isEqualTo(1);
} }
@Test @Test