mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Add script asset sub-assets
This commit is contained in:
Binary file not shown.
@@ -49,3 +49,54 @@
|
||||
border: 1px solid #a00;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions .file-input-trigger.small {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-meta span {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-empty {
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -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 || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
<textarea
|
||||
class="text-input"
|
||||
id="custom-asset-code"
|
||||
placeholder="exports.init = ({ surface, assets, channel }) => { }; exports.tick = () => { };"
|
||||
placeholder="exports.init = (context) => { const { assets } = context; }; exports.tick = () => { };"
|
||||
rows="25"
|
||||
required
|
||||
></textarea>
|
||||
@@ -395,6 +395,29 @@
|
||||
By submitting your script, you agree to release it under the MIT License to the public.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Script attachments</label>
|
||||
<div class="attachment-actions">
|
||||
<input
|
||||
id="custom-asset-attachment-file"
|
||||
class="file-input-field"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
<label for="custom-asset-attachment-file" class="file-input-trigger small">
|
||||
<span class="file-input-icon"><i class="fa-solid fa-paperclip"></i></span>
|
||||
<span class="file-input-copy">
|
||||
<strong>Add attachment</strong>
|
||||
<small>Images, video, or audio</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-note" id="custom-asset-attachment-hint">
|
||||
Attachments are stored with the script and are available for scripts to render. Save the
|
||||
script before adding attachments.
|
||||
</p>
|
||||
<ul id="custom-asset-attachment-list" class="attachment-list"></ul>
|
||||
</div>
|
||||
<div class="form-error hidden" id="custom-asset-error">
|
||||
<strong>JavaScript error: <span id="js-error-title"></span></strong>
|
||||
<pre id="js-error-details"></pre>
|
||||
|
||||
Reference in New Issue
Block a user