Fix layering and add release ci

This commit is contained in:
2026-01-15 14:36:45 +01:00
parent d43bd985c6
commit c481b105c5
12 changed files with 476 additions and 152 deletions

View File

@@ -39,14 +39,46 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
outputs:
pom_version: ${{ steps.read_version.outputs.pom_version }}
version_changed: ${{ steps.version_check.outputs.version_changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
lfs: true lfs: true
- name: Check for pom.xml changes
id: version_check
run: |
if [ "${{ github.event_name }}" != "push" ]; then
echo "version_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- pom.xml | grep -q '^pom.xml$'; then
echo "version_changed=true" >> "$GITHUB_OUTPUT"
else
echo "version_changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Read pom.xml version
id: read_version
run: |
VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version)
echo "pom_version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Set Docker tags
id: docker_tags
run: |
TAGS="kruhlmann/imgfloat-j:latest,kruhlmann/imgfloat-j:${{ github.sha }}"
if [ "${{ steps.version_check.outputs.version_changed }}" = "true" ]; then
TAGS="$TAGS,kruhlmann/imgfloat-j:${{ steps.read_version.outputs.pom_version }}"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/master' if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -59,6 +91,21 @@ jobs:
with: with:
context: . context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
tags: | tags: ${{ steps.docker_tags.outputs.tags }}
kruhlmann/imgfloat-j:latest
kruhlmann/imgfloat-j:${{ github.sha }} release:
timeout-minutes: 10
runs-on: ubuntu-latest
needs: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.docker.outputs.version_changed == 'true'
steps:
- uses: actions/checkout@v4
with:
lfs: false
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.docker.outputs.pom_version }}
name: v${{ needs.docker.outputs.pom_version }}
generate_release_notes: true

View File

@@ -5,7 +5,7 @@
<groupId>dev.kruhlmann</groupId> <groupId>dev.kruhlmann</groupId>
<artifactId>imgfloat</artifactId> <artifactId>imgfloat</artifactId>
<version>0.0.1</version> <version>1.0.0</version>
<name>Imgfloat</name> <name>Imgfloat</name>
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description> <description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
<packaging>jar</packaging> <packaging>jar</packaging>

View File

@@ -126,7 +126,7 @@ public record AssetView(
script.getOriginalMediaType(), script.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
script.getAttachments(), script.getAttachments(),
null, script.getZIndex(),
null, null,
null, null,
null, null,

View File

@@ -33,6 +33,9 @@ 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();
@@ -49,6 +52,9 @@ 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() {
@@ -115,6 +121,14 @@ 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

@@ -175,7 +175,7 @@ public class ChannelDirectoryService {
return asset == null ? null : AssetView.fromVisual(normalized, asset, visual); return asset == null ? null : AssetView.fromVisual(normalized, asset, visual);
}) })
.filter(Objects::nonNull) .filter(Objects::nonNull)
.sorted(Comparator.comparingInt(AssetView::zIndex)) .sorted(Comparator.comparingInt(AssetView::zIndex).reversed())
.toList(); .toList();
} }
@@ -273,6 +273,7 @@ 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);
@@ -333,6 +334,7 @@ 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);
@@ -711,6 +713,7 @@ 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);
@@ -770,6 +773,7 @@ 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);
@@ -891,6 +895,34 @@ 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();
if (req.getZIndex() != null) {
if (req.getZIndex() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
}
script.setZIndex(req.getZIndex());
scriptAssetRepository.save(script);
if (beforeZIndex != script.getZIndex()) {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
script.getZIndex(),
null,
null,
null,
null,
null,
null
);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
}
}
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);
} }
@@ -1377,9 +1409,9 @@ public class ChannelDirectoryService {
.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( .sorted(
Comparator.comparing((AssetView view) -> Comparator.comparingInt((AssetView view) -> view.zIndex() == null ? Integer.MIN_VALUE : view.zIndex())
view.zIndex() == null ? Integer.MAX_VALUE : view.zIndex() .reversed()
).thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(AssetView::createdAt, Comparator.nullsFirst(Comparator.naturalOrder()))
) )
.toList(); .toList();
} }
@@ -1398,6 +1430,18 @@ public class ChannelDirectoryService {
); );
} }
private int nextScriptZIndex(String broadcaster) {
return (
scriptAssetRepository
.findByIdIn(assetsWithType(normalize(broadcaster), AssetType.SCRIPT))
.stream()
.mapToInt(ScriptAsset::getZIndex)
.max()
.orElse(0) +
1
);
}
private List<String> assetsWithType(String broadcaster, AssetType... types) { private List<String> assetsWithType(String broadcaster, AssetType... types) {
Set<AssetType> typeSet = EnumSet.noneOf(AssetType.class); Set<AssetType> typeSet = EnumSet.noneOf(AssetType.class);
typeSet.addAll(Arrays.asList(types)); typeSet.addAll(Arrays.asList(types));

View File

@@ -0,0 +1,5 @@
ALTER TABLE script_assets ADD COLUMN IF NOT EXISTS z_index INTEGER NOT NULL DEFAULT 1;
UPDATE script_assets
SET z_index = 1
WHERE z_index IS NULL;

View File

@@ -1740,10 +1740,19 @@ button:disabled:hover {
.asset-list { .asset-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.asset-section {
font-size: 12px;
letter-spacing: 0.3px;
text-transform: uppercase;
color: rgba(148, 163, 184, 0.9);
padding: 4px 10px 0;
}
.asset-item { .asset-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -5,6 +5,7 @@ import {
ensureLayerPosition as ensureLayerPositionForState, ensureLayerPosition as ensureLayerPositionForState,
getLayerOrder as getLayerOrderForState, getLayerOrder as getLayerOrderForState,
getRenderOrder as getRenderOrderForState, getRenderOrder as getRenderOrderForState,
getScriptLayerOrder as getScriptLayerOrderForState,
} from "../broadcast/layers.js"; } from "../broadcast/layers.js";
export function createAdminConsole({ export function createAdminConsole({
@@ -80,6 +81,7 @@ export function createAdminConsole({
let drawPending = false; let drawPending = false;
let layerOrder = []; let layerOrder = [];
let scriptLayerOrder = [];
const layerState = { const layerState = {
assets, assets,
get layerOrder() { get layerOrder() {
@@ -88,6 +90,12 @@ export function createAdminConsole({
set layerOrder(value) { set layerOrder(value) {
layerOrder = value; layerOrder = value;
}, },
get scriptLayerOrder() {
return scriptLayerOrder;
},
set scriptLayerOrder(value) {
scriptLayerOrder = value;
},
}; };
let pendingUploads = []; let pendingUploads = [];
let selectedAssetId = null; let selectedAssetId = null;
@@ -178,12 +186,22 @@ export function createAdminConsole({
return getLayerOrderForState(layerState); return getLayerOrderForState(layerState);
} }
function getScriptLayerOrder() {
return getScriptLayerOrderForState(layerState);
}
function getAssetsByLayer() { function getAssetsByLayer() {
return getLayerOrder() return getLayerOrder()
.map((id) => assets.get(id)) .map((id) => assets.get(id))
.filter(Boolean); .filter(Boolean);
} }
function getScriptAssetsByLayer() {
return getScriptLayerOrder()
.map((id) => assets.get(id))
.filter(Boolean);
}
function getAudioAssets() { function getAudioAssets() {
return Array.from(assets.values()) return Array.from(assets.values())
.filter((asset) => isAudioAsset(asset)) .filter((asset) => isAudioAsset(asset))
@@ -191,9 +209,7 @@ export function createAdminConsole({
} }
function getCodeAssets() { function getCodeAssets() {
return Array.from(assets.values()) return getScriptAssetsByLayer();
.filter((asset) => isCodeAsset(asset))
.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
} }
function getRenderOrder() { function getRenderOrder() {
@@ -211,6 +227,17 @@ export function createAdminConsole({
return order.length - index; return order.length - index;
} }
function getScriptLayerValue(assetId) {
const asset = assets.get(assetId);
if (!asset || !isCodeAsset(asset)) {
return 0;
}
const order = getScriptLayerOrder();
const index = order.indexOf(assetId);
if (index === -1) return 1;
return order.length - index;
}
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)}`,
@@ -547,6 +574,7 @@ export function createAdminConsole({
function renderAssets(list) { function renderAssets(list) {
layerOrder = []; layerOrder = [];
scriptLayerOrder = [];
list.forEach((item) => storeAsset(item, { placement: "append" })); list.forEach((item) => storeAsset(item, { placement: "append" }));
drawAndList(); drawAndList();
} }
@@ -594,6 +622,7 @@ export function createAdminConsole({
if (event.type === "DELETED") { if (event.type === "DELETED") {
assets.delete(assetId); assets.delete(assetId);
layerOrder = layerOrder.filter((id) => id !== assetId); layerOrder = layerOrder.filter((id) => id !== assetId);
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
clearMedia(assetId); clearMedia(assetId);
renderStates.delete(assetId); renderStates.delete(assetId);
loopPlaybackState.delete(assetId); loopPlaybackState.delete(assetId);
@@ -628,6 +657,7 @@ export function createAdminConsole({
} }
const merged = { ...existing, ...patch }; const merged = { ...existing, ...patch };
const isAudio = isAudioAsset(merged); const isAudio = isAudioAsset(merged);
const isScript = isCodeAsset(merged);
if (patch.hidden) { if (patch.hidden) {
clearMedia(assetId); clearMedia(assetId);
loopPlaybackState.delete(assetId); loopPlaybackState.delete(assetId);
@@ -641,11 +671,18 @@ export function createAdminConsole({
targetLayer = null; targetLayer = null;
} }
if (!isAudio && Number.isFinite(targetLayer)) { if (!isAudio && Number.isFinite(targetLayer)) {
if (isScript) {
const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
currentOrder.splice(insertIndex, 0, assetId);
scriptLayerOrder = currentOrder;
} 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(targetLayer));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
layerOrder = currentOrder; layerOrder = currentOrder;
} }
}
storeAsset(merged); storeAsset(merged);
if (!isAudio) { if (!isAudio) {
updateRenderState(merged); updateRenderState(merged);
@@ -1298,44 +1335,24 @@ export function createAdminConsole({
} }
} }
function renderAssetList() { function getLayerDetail(asset) {
const list = document.getElementById("asset-list"); if (!asset || isAudioAsset(asset)) {
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { return null;
controlsPlaceholder.appendChild(controlsPanel);
} }
if (controlsPanel) { if (isCodeAsset(asset)) {
controlsPanel.classList.add("hidden"); return `Script layer ${getScriptLayerValue(asset.id)}`;
} }
list.innerHTML = ""; return `Layer ${getLayerValue(asset.id)}`;
const hasAssets = assets.size > 0;
const hasPending = pendingUploads.length > 0;
if (!hasAssets && !hasPending) {
selectedAssetId = null;
if (assetInspector) {
assetInspector.classList.add("hidden");
}
const empty = document.createElement("li");
empty.textContent = "";
list.appendChild(empty);
updateSelectedAssetControls();
return;
} }
if (assetInspector) { function createSectionHeader(title) {
assetInspector.classList.toggle("hidden", !hasAssets); const li = document.createElement("li");
li.className = "asset-section";
li.textContent = title;
return li;
} }
const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); function appendAssetListItem(list, asset) {
pendingItems.forEach((pending) => {
list.appendChild(createPendingListItem(pending));
});
const codeAssets = getCodeAssets();
const audioAssets = getAudioAssets();
const sortedAssets = [...codeAssets, ...audioAssets, ...getAssetsByLayer()];
sortedAssets.forEach((asset) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "asset-item"; li.className = "asset-item";
if (asset.id === selectedAssetId) { if (asset.id === selectedAssetId) {
@@ -1353,7 +1370,8 @@ export function createAdminConsole({
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
const details = document.createElement("small"); const details = document.createElement("small");
details.textContent = getAssetTypeLabel(asset); const layerDetail = getLayerDetail(asset);
details.textContent = layerDetail ? `${getAssetTypeLabel(asset)} · ${layerDetail}` : getAssetTypeLabel(asset);
meta.appendChild(name); meta.appendChild(name);
meta.appendChild(details); meta.appendChild(details);
@@ -1373,6 +1391,29 @@ export function createAdminConsole({
actions.appendChild(editBtn); actions.appendChild(editBtn);
} }
if (!isAudioAsset(asset)) {
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.addEventListener("click", (e) => {
e.stopPropagation();
moveLayerItem(asset, "up");
});
const moveDown = document.createElement("button");
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.addEventListener("click", (e) => {
e.stopPropagation();
moveLayerItem(asset, "down");
});
actions.appendChild(moveUp);
actions.appendChild(moveDown);
}
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
const playBtn = document.createElement("button"); const playBtn = document.createElement("button");
playBtn.type = "button"; playBtn.type = "button";
@@ -1387,9 +1428,7 @@ export function createAdminConsole({
: "Play audio"; : "Play audio";
playBtn.addEventListener("click", (e) => { playBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const nextPlay = isLooping const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true;
? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset))
: true;
if (isLooping) { if (isLooping) {
loopPlaybackState.set(asset.id, nextPlay); loopPlaybackState.set(asset.id, nextPlay);
updatePlayButtonIcon(playBtn, true, nextPlay); updatePlayButtonIcon(playBtn, true, nextPlay);
@@ -1426,8 +1465,59 @@ export function createAdminConsole({
li.appendChild(row); li.appendChild(row);
list.appendChild(li); list.appendChild(li);
}
function renderAssetList() {
const list = document.getElementById("asset-list");
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
controlsPlaceholder.appendChild(controlsPanel);
}
if (controlsPanel) {
controlsPanel.classList.add("hidden");
}
list.innerHTML = "";
const hasAssets = assets.size > 0;
const hasPending = pendingUploads.length > 0;
if (!hasAssets && !hasPending) {
selectedAssetId = null;
if (assetInspector) {
assetInspector.classList.add("hidden");
}
const empty = document.createElement("li");
empty.textContent = "";
list.appendChild(empty);
updateSelectedAssetControls();
return;
}
if (assetInspector) {
assetInspector.classList.toggle("hidden", !hasAssets);
}
const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0));
pendingItems.forEach((pending) => {
list.appendChild(createPendingListItem(pending));
}); });
const codeAssets = getCodeAssets();
const audioAssets = getAudioAssets();
const visualAssets = getAssetsByLayer();
if (visualAssets.length) {
list.appendChild(createSectionHeader("Canvas assets"));
visualAssets.forEach((asset) => appendAssetListItem(list, asset));
}
if (audioAssets.length) {
list.appendChild(createSectionHeader("Audio assets"));
audioAssets.forEach((asset) => appendAssetListItem(list, asset));
}
if (codeAssets.length) {
list.appendChild(createSectionHeader("Script assets (always on top)"));
codeAssets.forEach((asset) => appendAssetListItem(list, asset));
}
updateSelectedAssetControls(); updateSelectedAssetControls();
} }
@@ -1830,6 +1920,11 @@ export function createAdminConsole({
selectedAssetBadges.innerHTML = ""; selectedAssetBadges.innerHTML = "";
if (asset) { if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
if (isCodeAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Script layer ${getScriptLayerValue(asset.id)}`, "subtle"));
} else if (!isAudioAsset(asset)) {
selectedAssetBadges.appendChild(createBadge(`Layer ${getLayerValue(asset.id)}`, "subtle"));
}
const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : ""; const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) { if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle"));
@@ -2028,40 +2123,61 @@ export function createAdminConsole({
drawAndList(); drawAndList();
} }
function getLayeredAssets(asset) {
if (!asset) {
return [];
}
return isCodeAsset(asset) ? getScriptAssetsByLayer() : getAssetsByLayer();
}
function applyOrderForAsset(asset, ordered) {
if (!asset) return;
if (isCodeAsset(asset)) {
applyScriptLayerOrder(ordered);
} else {
applyLayerOrder(ordered);
}
}
function moveLayerItem(asset, direction) {
if (!asset) return;
const ordered = getLayeredAssets(asset);
const index = ordered.findIndex((item) => item.id === asset.id);
if (index === -1) return;
const nextIndex = direction === "up" ? index - 1 : index + 1;
if (nextIndex < 0 || nextIndex >= ordered.length) {
return;
}
[ordered[index], ordered[nextIndex]] = [ordered[nextIndex], ordered[index]];
applyOrderForAsset(asset, ordered);
}
function bringForward() { function bringForward() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset) return; if (!asset || isAudioAsset(asset)) return;
const ordered = getAssetsByLayer(); moveLayerItem(asset, "up");
const index = ordered.findIndex((item) => item.id === asset.id);
if (index <= 0) return;
[ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]];
applyLayerOrder(ordered);
} }
function bringBackward() { function bringBackward() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset) return; if (!asset || isAudioAsset(asset)) return;
const ordered = getAssetsByLayer(); moveLayerItem(asset, "down");
const index = ordered.findIndex((item) => item.id === asset.id);
if (index === -1 || index === ordered.length - 1) return;
[ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]];
applyLayerOrder(ordered);
} }
function bringToFront() { function bringToFront() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset) return; if (!asset || isAudioAsset(asset)) return;
const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); const ordered = getLayeredAssets(asset).filter((item) => item.id !== asset.id);
ordered.unshift(asset); ordered.unshift(asset);
applyLayerOrder(ordered); applyOrderForAsset(asset, ordered);
} }
function sendToBack() { function sendToBack() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset) return; if (!asset || isAudioAsset(asset)) return;
const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); const ordered = getLayeredAssets(asset).filter((item) => item.id !== asset.id);
ordered.push(asset); ordered.push(asset);
applyLayerOrder(ordered); applyOrderForAsset(asset, ordered);
} }
globalThis.handleFileSelection = handleFileSelection; globalThis.handleFileSelection = handleFileSelection;
@@ -2081,6 +2197,14 @@ export function createAdminConsole({
drawAndList(); drawAndList();
} }
function applyScriptLayerOrder(ordered) {
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));
drawAndList();
}
function getAssetAspectRatio(asset) { function getAssetAspectRatio(asset) {
const media = ensureMedia(asset); const media = ensureMedia(asset);
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
@@ -2202,6 +2326,7 @@ export function createAdminConsole({
assets.delete(asset.id); assets.delete(asset.id);
renderStates.delete(asset.id); renderStates.delete(asset.id);
layerOrder = layerOrder.filter((id) => id !== asset.id); layerOrder = layerOrder.filter((id) => id !== asset.id);
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
cancelPendingTransform(asset.id); cancelPendingTransform(asset.id);
if (selectedAssetId === asset.id) { if (selectedAssetId === asset.id) {
selectedAssetId = null; selectedAssetId = null;
@@ -2319,7 +2444,10 @@ export function createAdminConsole({
audioPitch: asset.audioPitch, audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume, audioVolume: asset.audioVolume,
}; };
if (!isAudioAsset(asset) && !isCodeAsset(asset)) { if (isCodeAsset(asset)) {
const layer = getScriptLayerValue(asset.id);
payload.zIndex = layer;
} else if (!isAudioAsset(asset)) {
const layer = getLayerValue(asset.id); const layer = getLayerValue(asset.id);
payload.x = asset.x; payload.x = asset.x;
payload.y = asset.y; payload.y = asset.y;

View File

@@ -1,41 +1,83 @@
import { isVisualAsset } from "./assetKinds.js"; import { isCodeAsset, isVisualAsset } from "./assetKinds.js";
function isScriptAsset(asset) {
return isCodeAsset(asset);
}
function isLayerableVisual(asset) {
return isVisualAsset(asset) && !isScriptAsset(asset);
}
function getLayerBucket(state, asset) {
if (isScriptAsset(asset)) {
if (!Array.isArray(state.scriptLayerOrder)) {
state.scriptLayerOrder = [];
}
return state.scriptLayerOrder;
}
if (isLayerableVisual(asset)) {
return state.layerOrder;
}
return null;
}
function normalizeOrder(state, predicate, existing) {
const filtered = existing.filter((id) => {
const asset = state.assets.get(id);
return asset && predicate(asset);
});
state.assets.forEach((asset, id) => {
if (!predicate(asset)) {
return;
}
if (!filtered.includes(id)) {
filtered.push(id);
}
});
return filtered;
}
export function ensureLayerPosition(state, assetId, placement = "keep") { export function ensureLayerPosition(state, assetId, placement = "keep") {
const asset = state.assets.get(assetId); const asset = state.assets.get(assetId);
if (asset && !isVisualAsset(asset)) { if (!asset) {
return; return;
} }
const existingIndex = state.layerOrder.indexOf(assetId); const bucket = getLayerBucket(state, asset);
if (!bucket) {
return;
}
const existingIndex = bucket.indexOf(assetId);
if (existingIndex !== -1 && placement === "keep") { if (existingIndex !== -1 && placement === "keep") {
return; return;
} }
if (existingIndex !== -1) { if (existingIndex !== -1) {
state.layerOrder.splice(existingIndex, 1); bucket.splice(existingIndex, 1);
} }
if (placement === "append") { if (placement === "append") {
state.layerOrder.push(assetId); bucket.push(assetId);
} else { } else {
state.layerOrder.unshift(assetId); bucket.unshift(assetId);
}
if (bucket === state.layerOrder) {
state.layerOrder = normalizeOrder(state, isLayerableVisual, bucket);
} else {
state.scriptLayerOrder = normalizeOrder(state, isScriptAsset, bucket);
} }
state.layerOrder = state.layerOrder.filter((id) => state.assets.has(id));
} }
export function getLayerOrder(state) { export function getLayerOrder(state) {
state.layerOrder = state.layerOrder.filter((id) => { state.layerOrder = normalizeOrder(state, isLayerableVisual, state.layerOrder);
const asset = state.assets.get(id);
return asset && isVisualAsset(asset);
});
state.assets.forEach((asset, id) => {
if (!isVisualAsset(asset)) {
return;
}
if (!state.layerOrder.includes(id)) {
state.layerOrder.unshift(id);
}
});
return state.layerOrder; return state.layerOrder;
} }
export function getScriptLayerOrder(state) {
if (!Array.isArray(state.scriptLayerOrder)) {
state.scriptLayerOrder = [];
}
state.scriptLayerOrder = normalizeOrder(state, isScriptAsset, state.scriptLayerOrder);
return state.scriptLayerOrder;
}
export function getRenderOrder(state) { export function getRenderOrder(state) {
return [...getLayerOrder(state)] return [...getLayerOrder(state)]
.reverse() .reverse()

View File

@@ -1,7 +1,7 @@
import { AssetKind, MIN_FRAME_TIME, VISIBILITY_THRESHOLD } from "./constants.js"; import { AssetKind, MIN_FRAME_TIME, VISIBILITY_THRESHOLD } from "./constants.js";
import { createBroadcastState } from "./state.js"; import { createBroadcastState } from "./state.js";
import { getAssetKind, isCodeAsset, isModelAsset, isVisualAsset, isVideoElement } from "./assetKinds.js"; import { getAssetKind, isCodeAsset, isModelAsset, isVisualAsset, isVideoElement } from "./assetKinds.js";
import { ensureLayerPosition, getLayerOrder, getRenderOrder } from "./layers.js"; import { ensureLayerPosition, getLayerOrder, getRenderOrder, getScriptLayerOrder } from "./layers.js";
import { getVisibilityState, smoothState } from "./visibility.js"; import { getVisibilityState, smoothState } from "./visibility.js";
import { createAudioManager } from "./audioManager.js"; import { createAudioManager } from "./audioManager.js";
import { createMediaManager } from "./mediaManager.js"; import { createMediaManager } from "./mediaManager.js";
@@ -89,6 +89,7 @@ export class BroadcastRenderer {
renderAssets(list) { renderAssets(list) {
this.state.layerOrder = []; this.state.layerOrder = [];
this.state.scriptLayerOrder = [];
list.forEach((asset) => { list.forEach((asset) => {
this.storeAsset(asset, "append"); this.storeAsset(asset, "append");
if (isCodeAsset(asset)) { if (isCodeAsset(asset)) {
@@ -119,6 +120,7 @@ export class BroadcastRenderer {
removeAsset(assetId) { removeAsset(assetId) {
this.state.assets.delete(assetId); this.state.assets.delete(assetId);
this.state.layerOrder = this.state.layerOrder.filter((id) => id !== assetId); this.state.layerOrder = this.state.layerOrder.filter((id) => id !== assetId);
this.state.scriptLayerOrder = this.state.scriptLayerOrder.filter((id) => id !== assetId);
this.mediaManager.clearMedia(assetId); this.mediaManager.clearMedia(assetId);
this.modelManager.clearModel(assetId); this.modelManager.clearModel(assetId);
this.stopUserJavaScriptWorker(assetId); this.stopUserJavaScriptWorker(assetId);
@@ -284,6 +286,7 @@ export class BroadcastRenderer {
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch }); const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
console.log(merged); console.log(merged);
const isVisual = isVisualAsset(merged); const isVisual = isVisualAsset(merged);
const isScript = isCodeAsset(merged);
if (sanitizedPatch.hidden) { if (sanitizedPatch.hidden) {
this.hideAssetWithTransition(merged); this.hideAssetWithTransition(merged);
return; return;
@@ -293,12 +296,20 @@ export class BroadcastRenderer {
: Number.isFinite(patch.zIndex) : Number.isFinite(patch.zIndex)
? patch.zIndex ? patch.zIndex
: null; : null;
if (isVisual && Number.isFinite(targetLayer)) { if (Number.isFinite(targetLayer)) {
if (isScript) {
const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
currentOrder.splice(insertIndex, 0, assetId);
this.state.scriptLayerOrder = currentOrder;
this.applyScriptCanvasOrder();
} 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(targetLayer));
currentOrder.splice(insertIndex, 0, assetId); currentOrder.splice(insertIndex, 0, assetId);
this.state.layerOrder = currentOrder; this.state.layerOrder = currentOrder;
} }
}
this.storeAsset(merged); this.storeAsset(merged);
this.mediaManager.ensureMedia(merged); this.mediaManager.ensureMedia(merged);
if (isCodeAsset(merged)) { if (isCodeAsset(merged)) {
@@ -767,6 +778,7 @@ export class BroadcastRenderer {
} }
const existing = this.scriptCanvases.get(assetId); const existing = this.scriptCanvases.get(assetId);
if (existing) { if (existing) {
this.applyScriptCanvasOrder();
return existing; return existing;
} }
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@@ -775,6 +787,7 @@ export class BroadcastRenderer {
this.applyScriptCanvasSize(canvas); this.applyScriptCanvasSize(canvas);
this.scriptLayer.appendChild(canvas); this.scriptLayer.appendChild(canvas);
this.scriptCanvases.set(assetId, canvas); this.scriptCanvases.set(assetId, canvas);
this.applyScriptCanvasOrder();
return canvas; return canvas;
} }
@@ -785,12 +798,14 @@ export class BroadcastRenderer {
} }
canvas.remove(); canvas.remove();
this.scriptCanvases.delete(assetId); this.scriptCanvases.delete(assetId);
this.applyScriptCanvasOrder();
} }
resizeScriptCanvases() { resizeScriptCanvases() {
this.scriptCanvases.forEach((canvas) => { this.scriptCanvases.forEach((canvas) => {
this.applyScriptCanvasSize(canvas); this.applyScriptCanvasSize(canvas);
}); });
this.applyScriptCanvasOrder();
} }
applyScriptCanvasSize(canvas) { applyScriptCanvasSize(canvas) {
@@ -807,6 +822,21 @@ export class BroadcastRenderer {
} }
} }
applyScriptCanvasOrder() {
if (!this.scriptLayer) {
return;
}
const ordered = getScriptLayerOrder(this.state);
ordered.forEach((id, index) => {
const canvas = this.scriptCanvases.get(id);
if (!canvas) {
return;
}
canvas.style.zIndex = `${ordered.length - index}`;
this.scriptLayer.appendChild(canvas);
});
}
async resolveScriptAttachments(attachments) { async resolveScriptAttachments(attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) { if (!Array.isArray(attachments) || attachments.length === 0) {
return []; return [];

View File

@@ -10,5 +10,6 @@ export function createBroadcastState() {
animationFailures: new Map(), animationFailures: new Map(),
videoPlaybackStates: new WeakMap(), videoPlaybackStates: new WeakMap(),
layerOrder: [], layerOrder: [],
scriptLayerOrder: [],
}; };
} }

View File

@@ -251,6 +251,7 @@
onclick="sendToBack()" onclick="sendToBack()"
class="secondary" class="secondary"
title="Send to back" title="Send to back"
data-code-enabled="true"
> >
<i class="fa-solid fa-angles-down"></i> <i class="fa-solid fa-angles-down"></i>
</button> </button>
@@ -259,6 +260,7 @@
onclick="bringBackward()" onclick="bringBackward()"
class="secondary" class="secondary"
title="Move backward" title="Move backward"
data-code-enabled="true"
> >
<i class="fa-solid fa-arrow-down"></i> <i class="fa-solid fa-arrow-down"></i>
</button> </button>
@@ -267,6 +269,7 @@
onclick="bringForward()" onclick="bringForward()"
class="secondary" class="secondary"
title="Move forward" title="Move forward"
data-code-enabled="true"
> >
<i class="fa-solid fa-arrow-up"></i> <i class="fa-solid fa-arrow-up"></i>
</button> </button>
@@ -275,6 +278,7 @@
onclick="bringToFront()" onclick="bringToFront()"
class="secondary" class="secondary"
title="Bring to front" title="Bring to front"
data-code-enabled="true"
> >
<i class="fa-solid fa-angles-up"></i> <i class="fa-solid fa-angles-up"></i>
</button> </button>