mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add fragile layering system
This commit is contained in:
@@ -32,6 +32,9 @@ public class Asset {
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "display_order")
|
||||
private Integer displayOrder;
|
||||
|
||||
public Asset() {}
|
||||
|
||||
public Asset(String broadcaster, AssetType assetType) {
|
||||
@@ -57,6 +60,9 @@ public class Asset {
|
||||
if (this.assetType == null) {
|
||||
this.assetType = AssetType.OTHER;
|
||||
}
|
||||
if (this.displayOrder != null && this.displayOrder < 1) {
|
||||
this.displayOrder = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -95,6 +101,14 @@ public class Asset {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public Integer getDisplayOrder() {
|
||||
return displayOrder;
|
||||
}
|
||||
|
||||
public void setDisplayOrder(Integer displayOrder) {
|
||||
this.displayOrder = displayOrder;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public record AssetPatch(
|
||||
Double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
Integer zIndex,
|
||||
Integer order,
|
||||
Boolean hidden,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
@@ -37,7 +37,7 @@ public record AssetPatch(
|
||||
request.getRotation() != null ? changed(before.rotation(), asset.getRotation()) : null,
|
||||
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : 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,
|
||||
@@ -112,7 +112,7 @@ public record AssetPatch(
|
||||
double rotation,
|
||||
double speed,
|
||||
boolean muted,
|
||||
int zIndex,
|
||||
int order,
|
||||
double audioVolume
|
||||
) {}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ public record AssetView(
|
||||
String originalMediaType,
|
||||
AssetType assetType,
|
||||
List<ScriptAssetAttachmentView> scriptAttachments,
|
||||
Integer zIndex,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
Double audioSpeed,
|
||||
@@ -56,7 +55,6 @@ public record AssetView(
|
||||
visual.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
null,
|
||||
visual.getZIndex(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -90,7 +88,6 @@ public record AssetView(
|
||||
audio.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
null,
|
||||
null,
|
||||
audio.isAudioLoop(),
|
||||
audio.getAudioDelayMillis(),
|
||||
audio.getAudioSpeed(),
|
||||
@@ -126,7 +123,6 @@ public record AssetView(
|
||||
script.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
script.getAttachments(),
|
||||
script.getZIndex(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -33,9 +33,6 @@ public class ScriptAsset {
|
||||
@Column(name = "source_file_id")
|
||||
private String sourceFileId;
|
||||
|
||||
@Column(name = "z_index")
|
||||
private Integer zIndex;
|
||||
|
||||
@Transient
|
||||
private List<ScriptAssetAttachmentView> attachments = List.of();
|
||||
|
||||
@@ -52,9 +49,6 @@ public class ScriptAsset {
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
if (this.zIndex == null || this.zIndex < 1) {
|
||||
this.zIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -121,14 +115,6 @@ public class ScriptAsset {
|
||||
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() {
|
||||
return attachments == null ? List.of() : attachments;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ public class TransformRequest {
|
||||
|
||||
private Boolean muted;
|
||||
|
||||
@Positive(message = "zIndex must be at least 1")
|
||||
private Integer zIndex;
|
||||
@Positive(message = "Order must be at least 1")
|
||||
private Integer order;
|
||||
|
||||
private Boolean audioLoop;
|
||||
|
||||
@@ -100,12 +100,12 @@ public class TransformRequest {
|
||||
this.muted = muted;
|
||||
}
|
||||
|
||||
public Integer getZIndex() {
|
||||
return zIndex;
|
||||
public Integer getOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
public void setZIndex(Integer zIndex) {
|
||||
this.zIndex = zIndex;
|
||||
public void setOrder(Integer order) {
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
public Boolean getAudioLoop() {
|
||||
|
||||
@@ -28,7 +28,6 @@ public class VisualAsset {
|
||||
private Boolean muted;
|
||||
private String mediaType;
|
||||
private String originalMediaType;
|
||||
private Integer zIndex;
|
||||
private Double audioVolume;
|
||||
private boolean hidden;
|
||||
|
||||
@@ -44,7 +43,6 @@ public class VisualAsset {
|
||||
this.rotation = 0;
|
||||
this.speed = 1.0;
|
||||
this.muted = false;
|
||||
this.zIndex = 1;
|
||||
this.audioVolume = 1.0;
|
||||
this.hidden = true;
|
||||
}
|
||||
@@ -58,9 +56,6 @@ public class VisualAsset {
|
||||
if (this.muted == null) {
|
||||
this.muted = Boolean.FALSE;
|
||||
}
|
||||
if (this.zIndex == null || this.zIndex < 1) {
|
||||
this.zIndex = 1;
|
||||
}
|
||||
if (this.audioVolume == null) {
|
||||
this.audioVolume = 1.0;
|
||||
}
|
||||
@@ -165,14 +160,6 @@ public class VisualAsset {
|
||||
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() {
|
||||
return audioVolume == null ? 1.0 : Math.max(0.0, Math.min(1.0, audioVolume));
|
||||
}
|
||||
|
||||
@@ -189,10 +189,11 @@ public class ChannelDirectoryService {
|
||||
.stream()
|
||||
.map((visual) -> {
|
||||
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)
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -318,6 +319,13 @@ public class ChannelDirectoryService {
|
||||
boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType);
|
||||
AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType);
|
||||
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(
|
||||
channel.getBroadcaster(),
|
||||
@@ -340,7 +348,6 @@ public class ChannelDirectoryService {
|
||||
script.setMediaType(optimized.mediaType());
|
||||
script.setOriginalMediaType(mediaType);
|
||||
script.setSourceFileId(asset.getId());
|
||||
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
@@ -358,7 +365,6 @@ public class ChannelDirectoryService {
|
||||
visual.setOriginalMediaType(mediaType);
|
||||
visual.setMediaType(optimized.mediaType());
|
||||
visual.setMuted(optimized.mediaType().startsWith("video/"));
|
||||
visual.setZIndex(nextZIndex(channel.getBroadcaster()));
|
||||
assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
|
||||
visual.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
|
||||
visualAssetRepository.save(visual);
|
||||
@@ -383,6 +389,7 @@ public class ChannelDirectoryService {
|
||||
enforceUploadLimit(bytes.length);
|
||||
|
||||
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
|
||||
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
sourceFile.setId(asset.getId());
|
||||
sourceFile.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
|
||||
@@ -407,7 +414,6 @@ public class ChannelDirectoryService {
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setDescription(normalizeDescription(request.getDescription()));
|
||||
script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
|
||||
script.setZIndex(nextScriptZIndex(channel.getBroadcaster()));
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
||||
@@ -787,6 +793,7 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
|
||||
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
sourceFile.setId(asset.getId());
|
||||
sourceFile.setMediaType(sourceContent.mediaType());
|
||||
@@ -811,7 +818,6 @@ public class ChannelDirectoryService {
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setLogoFileId(sourceScript.getLogoFileId());
|
||||
script.setZIndex(nextScriptZIndex(targetBroadcaster));
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
@@ -858,6 +864,7 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
|
||||
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
sourceFile.setId(asset.getId());
|
||||
sourceFile.setMediaType(sourceContent.mediaType());
|
||||
@@ -881,7 +888,6 @@ public class ChannelDirectoryService {
|
||||
script.setMediaType(sourceContent.mediaType());
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setZIndex(nextScriptZIndex(targetBroadcaster));
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
@@ -1009,14 +1015,19 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
int beforeZIndex = script.getZIndex();
|
||||
if (req.getZIndex() != null) {
|
||||
if (req.getZIndex() < 1) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
|
||||
Integer beforeOrder = asset.getDisplayOrder();
|
||||
List<Asset> orderUpdates = List.of();
|
||||
if (req.getOrder() != null) {
|
||||
if (req.getOrder() < 1) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
||||
}
|
||||
script.setZIndex(req.getZIndex());
|
||||
scriptAssetRepository.save(script);
|
||||
if (beforeZIndex != script.getZIndex()) {
|
||||
orderUpdates = updateDisplayOrder(
|
||||
normalized,
|
||||
asset,
|
||||
req.getOrder(),
|
||||
EnumSet.of(AssetType.SCRIPT)
|
||||
);
|
||||
if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) {
|
||||
AssetPatch patch = new AssetPatch(
|
||||
asset.getId(),
|
||||
null,
|
||||
@@ -1026,7 +1037,7 @@ public class ChannelDirectoryService {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
script.getZIndex(),
|
||||
asset.getDisplayOrder(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -1038,10 +1049,11 @@ public class ChannelDirectoryService {
|
||||
auditLogService.recordEntry(
|
||||
asset.getBroadcaster(),
|
||||
actor,
|
||||
"SCRIPT_LAYER_UPDATED",
|
||||
formatScriptTransformDetails(asset.getId(), script.getZIndex())
|
||||
"SCRIPT_ORDER_UPDATED",
|
||||
formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
|
||||
);
|
||||
}
|
||||
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
|
||||
}
|
||||
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||
return AssetView.fromScript(normalized, asset, script);
|
||||
@@ -1058,17 +1070,28 @@ public class ChannelDirectoryService {
|
||||
visual.getRotation(),
|
||||
visual.getSpeed(),
|
||||
visual.isMuted(),
|
||||
visual.getZIndex(),
|
||||
displayOrderValue(asset),
|
||||
visual.getAudioVolume()
|
||||
);
|
||||
validateVisualTransform(req);
|
||||
List<Asset> orderUpdates = List.of();
|
||||
|
||||
if (req.getX() != null) visual.setX(req.getX());
|
||||
if (req.getY() != null) visual.setY(req.getY());
|
||||
if (req.getWidth() != null) visual.setWidth(req.getWidth());
|
||||
if (req.getHeight() != null) visual.setHeight(req.getHeight());
|
||||
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.getMuted() != null) visual.setMuted(req.getMuted());
|
||||
if (req.getAudioVolume() != null) visual.setAudioVolume(req.getAudioVolume());
|
||||
@@ -1086,6 +1109,7 @@ public class ChannelDirectoryService {
|
||||
formatVisualTransformDetails(asset.getId(), req)
|
||||
);
|
||||
}
|
||||
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
|
||||
return view;
|
||||
});
|
||||
}
|
||||
@@ -1113,9 +1137,9 @@ public class ChannelDirectoryService {
|
||||
if (
|
||||
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > 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,
|
||||
"zIndex must be >= 1"
|
||||
"Order must be >= 1"
|
||||
);
|
||||
if (
|
||||
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
||||
@@ -1580,51 +1604,100 @@ public class ChannelDirectoryService {
|
||||
|
||||
return assets
|
||||
.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))
|
||||
.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();
|
||||
}
|
||||
|
||||
private int nextZIndex(String broadcaster) {
|
||||
private int nextDisplayOrder(String broadcaster, AssetType... types) {
|
||||
return (
|
||||
visualAssetRepository
|
||||
.findByIdIn(
|
||||
assetsWithType(normalize(broadcaster), AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER)
|
||||
)
|
||||
assetRepository
|
||||
.findByBroadcaster(normalize(broadcaster))
|
||||
.stream()
|
||||
.mapToInt(VisualAsset::getZIndex)
|
||||
.filter((asset) -> Arrays.asList(types).contains(asset.getAssetType()))
|
||||
.map(Asset::getDisplayOrder)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue)
|
||||
.max()
|
||||
.orElse(0) +
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private int nextScriptZIndex(String broadcaster) {
|
||||
return (
|
||||
scriptAssetRepository
|
||||
.findByIdIn(assetsWithType(normalize(broadcaster), AssetType.SCRIPT))
|
||||
.stream()
|
||||
.mapToInt(ScriptAsset::getZIndex)
|
||||
.max()
|
||||
.orElse(0) +
|
||||
1
|
||||
);
|
||||
private int displayOrderValue(Asset asset) {
|
||||
Integer value = asset == null ? null : asset.getDisplayOrder();
|
||||
return value == null ? Integer.MIN_VALUE : value;
|
||||
}
|
||||
|
||||
private List<String> assetsWithType(String broadcaster, AssetType... types) {
|
||||
Set<AssetType> typeSet = EnumSet.noneOf(AssetType.class);
|
||||
typeSet.addAll(Arrays.asList(types));
|
||||
return assetRepository
|
||||
private List<Asset> updateDisplayOrder(String broadcaster, Asset target, int desiredOrder, Set<AssetType> types) {
|
||||
if (target == null || types == null || types.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Asset> ordered = assetRepository
|
||||
.findByBroadcaster(normalize(broadcaster))
|
||||
.stream()
|
||||
.filter((asset) -> typeSet.contains(asset.getAssetType()))
|
||||
.map(Asset::getId)
|
||||
.toList();
|
||||
.filter((asset) -> types.contains(asset.getAssetType()))
|
||||
.sorted(
|
||||
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) {
|
||||
@@ -1818,7 +1891,7 @@ public class ChannelDirectoryService {
|
||||
if (req.getWidth() != null) parts.add("width=" + req.getWidth());
|
||||
if (req.getHeight() != null) parts.add("height=" + req.getHeight());
|
||||
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.getMuted() != null) parts.add("muted=" + req.getMuted());
|
||||
if (req.getAudioVolume() != null) parts.add("audioVolume=" + req.getAudioVolume());
|
||||
@@ -1835,10 +1908,10 @@ public class ChannelDirectoryService {
|
||||
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;
|
||||
if (zIndex != null) {
|
||||
return detail + " (zIndex=" + zIndex + ")";
|
||||
if (order != null) {
|
||||
return detail + " (order=" + order + ")";
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -1859,7 +1932,7 @@ public class ChannelDirectoryService {
|
||||
patch.rotation() != null ||
|
||||
patch.speed() != null ||
|
||||
patch.muted() != null ||
|
||||
patch.zIndex() != null ||
|
||||
patch.order() != null ||
|
||||
patch.hidden() != null ||
|
||||
patch.audioLoop() != null ||
|
||||
patch.audioDelayMillis() != null ||
|
||||
|
||||
20
src/main/resources/db/migration/V8__asset_display_order.sql
Normal file
20
src/main/resources/db/migration/V8__asset_display_order.sql
Normal 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;
|
||||
@@ -47,7 +47,7 @@ export function createAdminConsole({
|
||||
const speedLabel = document.getElementById("asset-speed-label");
|
||||
const volumeInput = document.getElementById("asset-volume");
|
||||
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 volumeSection = document.getElementById("volume-section");
|
||||
const audioSection = document.getElementById("audio-section");
|
||||
@@ -238,6 +238,20 @@ export function createAdminConsole({
|
||||
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) {
|
||||
const pending = {
|
||||
id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`,
|
||||
@@ -662,23 +676,16 @@ export function createAdminConsole({
|
||||
clearMedia(assetId);
|
||||
loopPlaybackState.delete(assetId);
|
||||
}
|
||||
let targetLayer;
|
||||
if (Number.isFinite(patch.layer)) {
|
||||
targetLayer = patch.layer;
|
||||
} else if (Number.isFinite(patch.zIndex)) {
|
||||
targetLayer = patch.zIndex;
|
||||
} else {
|
||||
targetLayer = null;
|
||||
}
|
||||
if (!isAudio && Number.isFinite(targetLayer)) {
|
||||
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
|
||||
if (!isAudio && Number.isFinite(targetOrder)) {
|
||||
if (isScript) {
|
||||
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);
|
||||
scriptLayerOrder = currentOrder;
|
||||
} else {
|
||||
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);
|
||||
layerOrder = currentOrder;
|
||||
}
|
||||
@@ -1340,9 +1347,9 @@ export function createAdminConsole({
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
@@ -1392,11 +1399,16 @@ export function createAdminConsole({
|
||||
}
|
||||
|
||||
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");
|
||||
moveUp.type = "button";
|
||||
moveUp.className = "ghost icon-button";
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
moveLayerItem(asset, "up");
|
||||
@@ -1405,7 +1417,8 @@ export function createAdminConsole({
|
||||
moveDown.type = "button";
|
||||
moveDown.className = "ghost icon-button";
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
moveLayerItem(asset, "down");
|
||||
@@ -1802,8 +1815,10 @@ export function createAdminConsole({
|
||||
|
||||
controlsPanel.classList.remove("hidden");
|
||||
lastSizeInputChanged = null;
|
||||
if (selectedZLabel) {
|
||||
selectedZLabel.textContent = getLayerValue(asset.id);
|
||||
if (selectedOrderLabel) {
|
||||
selectedOrderLabel.textContent = isCodeAsset(asset)
|
||||
? getScriptLayerPosition(asset.id)
|
||||
: getLayerPosition(asset.id);
|
||||
}
|
||||
|
||||
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||
@@ -1921,9 +1936,12 @@ export function createAdminConsole({
|
||||
if (asset) {
|
||||
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(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)) {
|
||||
selectedAssetBadges.appendChild(createBadge(`Layer ${getLayerValue(asset.id)}`, "subtle"));
|
||||
selectedAssetBadges.appendChild(createBadge(`Order ${getLayerPosition(asset.id)}`, "subtle"));
|
||||
}
|
||||
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
|
||||
if (aspectLabel) {
|
||||
@@ -2445,18 +2463,17 @@ export function createAdminConsole({
|
||||
audioVolume: asset.audioVolume,
|
||||
};
|
||||
if (isCodeAsset(asset)) {
|
||||
const layer = getScriptLayerValue(asset.id);
|
||||
payload.zIndex = layer;
|
||||
const order = getScriptLayerValue(asset.id);
|
||||
payload.order = order;
|
||||
} else if (!isAudioAsset(asset)) {
|
||||
const layer = getLayerValue(asset.id);
|
||||
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.layer = layer;
|
||||
payload.zIndex = layer;
|
||||
payload.order = order;
|
||||
if (isVideoAsset(asset)) {
|
||||
payload.muted = asset.muted;
|
||||
}
|
||||
|
||||
@@ -291,21 +291,17 @@ export class BroadcastRenderer {
|
||||
this.hideAssetWithTransition(merged);
|
||||
return;
|
||||
}
|
||||
const targetLayer = Number.isFinite(patch.layer)
|
||||
? patch.layer
|
||||
: Number.isFinite(patch.zIndex)
|
||||
? patch.zIndex
|
||||
: null;
|
||||
if (Number.isFinite(targetLayer)) {
|
||||
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
|
||||
if (Number.isFinite(targetOrder)) {
|
||||
if (isScript) {
|
||||
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);
|
||||
this.state.scriptLayerOrder = currentOrder;
|
||||
this.applyScriptCanvasOrder();
|
||||
} else if (isVisual) {
|
||||
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);
|
||||
this.state.layerOrder = currentOrder;
|
||||
}
|
||||
@@ -827,12 +823,14 @@ export class BroadcastRenderer {
|
||||
return;
|
||||
}
|
||||
const ordered = getScriptLayerOrder(this.state);
|
||||
ordered.forEach((id, index) => {
|
||||
ordered
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach((id) => {
|
||||
const canvas = this.scriptCanvases.get(id);
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.style.zIndex = `${ordered.length - index}`;
|
||||
this.scriptLayer.appendChild(canvas);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@
|
||||
<div class="panel-section" id="layout-section">
|
||||
<div class="section-header">
|
||||
<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 class="property-list">
|
||||
<div class="property-row">
|
||||
@@ -132,11 +136,11 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Layer</span>
|
||||
<span class="property-label">Order</span>
|
||||
<div class="property-control">
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge"
|
||||
>Layer <strong id="asset-z-level">1</strong></span
|
||||
>Position <strong id="asset-order-position">1</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,13 +198,13 @@ class ChannelDirectoryServiceTest {
|
||||
TransformRequest transform = validTransform();
|
||||
transform.setSpeed(0.1);
|
||||
transform.setAudioVolume(0.01);
|
||||
transform.setZIndex(1);
|
||||
transform.setOrder(1);
|
||||
|
||||
AssetView view = service.updateTransform(channel, id, transform, "caster").orElseThrow();
|
||||
|
||||
assertThat(view.speed()).isEqualTo(0.1);
|
||||
assertThat(view.audioVolume()).isEqualTo(0.01);
|
||||
assertThat(view.zIndex()).isEqualTo(1);
|
||||
assertThat(assetRepository.findById(id).orElseThrow().getDisplayOrder()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user