Add script asset sub-assets

This commit is contained in:
2026-01-09 18:42:37 +01:00
parent c4354782a8
commit 96b1cf501c
16 changed files with 770 additions and 17 deletions

View File

@@ -88,6 +88,9 @@ export class BroadcastRenderer {
const wasExisting = this.state.assets.has(asset.id);
this.state.assets.set(asset.id, asset);
ensureLayerPosition(this.state, asset.id, placement);
if (isCodeAsset(asset)) {
this.updateScriptWorkerAttachments(asset);
}
if (!wasExisting && !this.state.visibilityStates.has(asset.id)) {
const initialAlpha = 0; // Fade in newly discovered assets
this.state.visibilityStates.set(asset.id, {
@@ -484,6 +487,20 @@ export class BroadcastRenderer {
payload: {
id: asset.id,
source: assetSource,
attachments: asset.scriptAttachments || [],
},
});
}
updateScriptWorkerAttachments(asset) {
if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) {
return;
}
this.scriptWorker.postMessage({
type: "updateAttachments",
payload: {
id: asset.id,
attachments: asset.scriptAttachments || [],
},
});
}

View File

@@ -1,4 +1,5 @@
const scripts = new Map();
const allowedFetchUrls = new Set();
let canvas = null;
let ctx = null;
let channelName = "";
@@ -9,9 +10,18 @@ const tickIntervalMs = 1000 / 60;
const errorKeys = new Set();
function disableNetworkApis() {
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
const blockedApis = {
fetch: () => {
throw new Error("Network access is disabled in asset scripts.");
fetch: (...args) => {
if (!nativeFetch) {
throw new Error("Network access is disabled in asset scripts.");
}
const request = new Request(...args);
const url = normalizeUrl(request.url);
if (!allowedFetchUrls.has(url)) {
throw new Error("Network access is disabled in asset scripts.");
}
return nativeFetch(request);
},
XMLHttpRequest: undefined,
WebSocket: undefined,
@@ -43,6 +53,32 @@ function disableNetworkApis() {
disableNetworkApis();
function normalizeUrl(url) {
try {
return new URL(url, self.location?.href || "http://localhost").toString();
} catch (_error) {
return "";
}
}
function refreshAllowedFetchUrls() {
allowedFetchUrls.clear();
scripts.forEach((script) => {
const assets = script?.context?.assets;
if (!Array.isArray(assets)) {
return;
}
assets.forEach((asset) => {
if (asset?.url) {
const normalized = normalizeUrl(asset.url);
if (normalized) {
allowedFetchUrls.add(normalized);
}
}
});
});
}
function reportScriptError(id, stage, error) {
if (!id) {
return;
@@ -115,7 +151,8 @@ function stopTickLoopIfIdle() {
}
function createScriptHandlers(source, context, state, sourceLabel = "") {
const contextPrelude = "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs } = context;";
const contextPrelude =
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets } = context;";
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
const factory = new Function(
"context",
@@ -172,6 +209,7 @@ self.addEventListener("message", (event) => {
now: 0,
deltaMs: 0,
elapsedMs: 0,
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
};
let handlers = {};
try {
@@ -189,6 +227,7 @@ self.addEventListener("message", (event) => {
tick: handlers.tick,
};
scripts.set(payload.id, script);
refreshAllowedFetchUrls();
if (script.init) {
try {
script.init(script.context, script.state);
@@ -206,6 +245,19 @@ self.addEventListener("message", (event) => {
return;
}
scripts.delete(payload.id);
refreshAllowedFetchUrls();
stopTickLoopIfIdle();
}
if (type === "updateAttachments") {
if (!payload?.id) {
return;
}
const script = scripts.get(payload.id);
if (!script) {
return;
}
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
refreshAllowedFetchUrls();
}
});

View File

@@ -7,6 +7,11 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
const jsErrorDetails = document.getElementById("js-error-details");
const form = document.getElementById("custom-asset-form");
const cancelButton = document.getElementById("custom-asset-cancel");
const attachmentInput = document.getElementById("custom-asset-attachment-file");
const attachmentList = document.getElementById("custom-asset-attachment-list");
const attachmentHint = document.getElementById("custom-asset-attachment-hint");
let currentAssetId = null;
let attachmentState = [];
const resetErrors = () => {
if (formErrorWrapper) {
@@ -37,8 +42,9 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
userSourceTextArea.disabled = false;
userSourceTextArea.dataset.assetId = "";
userSourceTextArea.placeholder =
"function init(context, state) {\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};";
"function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};";
}
setAttachmentState(null, []);
resetErrors();
openModal();
};
@@ -57,6 +63,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
userSourceTextArea.disabled = true;
userSourceTextArea.dataset.assetId = asset.id;
}
setAttachmentState(asset.id, asset.scriptAttachments || []);
openModal();
fetch(asset.url)
@@ -151,9 +158,148 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
if (cancelButton) {
cancelButton.addEventListener("click", () => closeModal());
}
if (attachmentInput) {
attachmentInput.addEventListener("change", (event) => {
const file = event.target?.files?.[0];
if (!file) {
return;
}
if (!currentAssetId) {
showToast?.("Save the script before adding attachments.", "info");
attachmentInput.value = "";
return;
}
uploadAttachment(file)
.then((attachment) => {
if (attachment) {
attachmentState = [...attachmentState, attachment];
renderAttachmentList();
showToast?.("Attachment added.", "success");
}
})
.catch((error) => {
console.error(error);
showToast?.("Unable to upload attachment. Please try again.", "error");
})
.finally(() => {
attachmentInput.value = "";
});
});
}
return { openNew, openEditor };
function setAttachmentState(assetId, attachments) {
currentAssetId = assetId || null;
attachmentState = Array.isArray(attachments) ? [...attachments] : [];
renderAttachmentList();
}
function renderAttachmentList() {
if (!attachmentList) {
return;
}
attachmentList.innerHTML = "";
if (!currentAssetId) {
if (attachmentInput) {
attachmentInput.disabled = true;
}
if (attachmentHint) {
attachmentHint.textContent = "Save the script before adding attachments.";
}
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "Attachments will appear here once the script is saved.";
attachmentList.appendChild(empty);
return;
}
if (attachmentInput) {
attachmentInput.disabled = false;
}
if (attachmentHint) {
attachmentHint.textContent =
"Attachments are available to this script only and are not visible on the canvas.";
}
if (!attachmentState.length) {
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "No attachments yet.";
attachmentList.appendChild(empty);
return;
}
attachmentState.forEach((attachment) => {
const item = document.createElement("li");
item.className = "attachment-item";
const meta = document.createElement("div");
meta.className = "attachment-meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Untitled";
const type = document.createElement("span");
type.textContent = attachment.assetType || attachment.mediaType || "Attachment";
meta.appendChild(name);
meta.appendChild(type);
const actions = document.createElement("div");
actions.className = "attachment-actions-row";
if (attachment.url) {
const link = document.createElement("a");
link.href = attachment.url;
link.target = "_blank";
link.rel = "noopener";
link.className = "button ghost";
link.textContent = "Open";
actions.appendChild(link);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "secondary danger";
remove.textContent = "Remove";
remove.addEventListener("click", () => removeAttachment(attachment.id));
actions.appendChild(remove);
item.appendChild(meta);
item.appendChild(actions);
attachmentList.appendChild(item);
});
}
function uploadAttachment(file) {
const payload = new FormData();
payload.append("file", file);
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments`, {
method: "POST",
body: payload,
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to upload attachment");
}
return response.json();
});
}
function removeAttachment(attachmentId) {
if (!attachmentId || !currentAssetId) {
return;
}
fetch(
`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments/${attachmentId}`,
{ method: "DELETE" },
)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to delete attachment");
}
attachmentState = attachmentState.filter((attachment) => attachment.id !== attachmentId);
renderAttachmentList();
showToast?.("Attachment removed.", "success");
})
.catch((error) => {
console.error(error);
showToast?.("Unable to remove attachment. Please try again.", "error");
});
}
function saveCodeAsset({ name, src, assetId }) {
const payload = { name, source: src };
const method = assetId ? "PUT" : "POST";