Start custom script assets

This commit is contained in:
2026-01-09 01:16:05 +01:00
parent d4f0131473
commit 6b673a7781
7 changed files with 306 additions and 20 deletions

View File

@@ -1163,9 +1163,20 @@ button:disabled:hover {
} }
.broadcast-body canvas { .broadcast-body canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none; pointer-events: none;
} }
#broadcast-canvas {
z-index: 1;
}
#broadcast-script-canvas {
z-index: 2;
}
.broadcast-body { .broadcast-body {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;

View File

@@ -1,6 +1,7 @@
import { BroadcastRenderer } from "./broadcast/renderer.js"; import { BroadcastRenderer } from "./broadcast/renderer.js";
const canvas = document.getElementById("broadcast-canvas"); const canvas = document.getElementById("broadcast-canvas");
const renderer = new BroadcastRenderer({ canvas, broadcaster, showToast }); const scriptCanvas = document.getElementById("broadcast-script-canvas");
const renderer = new BroadcastRenderer({ canvas, scriptCanvas, broadcaster, showToast });
renderer.start(); renderer.start();

View File

@@ -7,9 +7,10 @@ import { createAudioManager } from "./audioManager.js";
import { createMediaManager } from "./mediaManager.js"; import { createMediaManager } from "./mediaManager.js";
export class BroadcastRenderer { export class BroadcastRenderer {
constructor({ canvas, broadcaster, showToast }) { constructor({ canvas, scriptCanvas, broadcaster, showToast }) {
this.canvas = canvas; this.canvas = canvas;
this.ctx = canvas.getContext("2d"); this.ctx = canvas.getContext("2d");
this.scriptCanvas = scriptCanvas;
this.broadcaster = broadcaster; this.broadcaster = broadcaster;
this.showToast = showToast; this.showToast = showToast;
this.state = createBroadcastState(); this.state = createBroadcastState();
@@ -17,6 +18,8 @@ export class BroadcastRenderer {
this.frameScheduled = false; this.frameScheduled = false;
this.pendingDraw = false; this.pendingDraw = false;
this.renderIntervalId = null; this.renderIntervalId = null;
this.scriptWorker = null;
this.scriptWorkerReady = false;
this.obsBrowser = !!globalThis.obsstudio; this.obsBrowser = !!globalThis.obsstudio;
this.supportsAnimatedDecode = this.supportsAnimatedDecode =
@@ -135,7 +138,14 @@ export class BroadcastRenderer {
this.canvas.height = this.state.canvasSettings.height; this.canvas.height = this.state.canvasSettings.height;
this.canvas.style.width = `${this.state.canvasSettings.width}px`; this.canvas.style.width = `${this.state.canvasSettings.width}px`;
this.canvas.style.height = `${this.state.canvasSettings.height}px`; this.canvas.style.height = `${this.state.canvasSettings.height}px`;
if (this.scriptCanvas) {
this.scriptCanvas.width = this.state.canvasSettings.width;
this.scriptCanvas.height = this.state.canvasSettings.height;
this.scriptCanvas.style.width = `${this.state.canvasSettings.width}px`;
this.scriptCanvas.style.height = `${this.state.canvasSettings.height}px`;
} }
}
this.updateScriptWorkerCanvas();
this.draw(); this.draw();
} }
@@ -368,7 +378,79 @@ export class BroadcastRenderer {
}, MIN_FRAME_TIME); }, MIN_FRAME_TIME);
} }
spawnUserJavaScriptWorker() {} ensureScriptWorker() {
if (this.scriptWorker || !this.scriptCanvas) {
return;
}
if (typeof this.scriptCanvas.transferControlToOffscreen !== "function") {
console.warn("OffscreenCanvas is not supported in this environment.");
return;
}
const offscreen = this.scriptCanvas.transferControlToOffscreen();
this.scriptWorker = new Worker("/js/broadcast/script-worker.js");
this.scriptWorker.postMessage(
{
type: "init",
payload: {
canvas: offscreen,
width: this.scriptCanvas.width,
height: this.scriptCanvas.height,
channelName: this.broadcaster,
},
},
[offscreen],
);
this.scriptWorkerReady = true;
}
stopUserJavaScriptWorker() {} updateScriptWorkerCanvas() {
if (!this.scriptWorker || !this.scriptWorkerReady || !this.scriptCanvas) {
return;
}
this.scriptWorker.postMessage({
type: "resize",
payload: {
width: this.scriptCanvas.width,
height: this.scriptCanvas.height,
},
});
}
async spawnUserJavaScriptWorker(asset) {
if (!asset?.id || !asset?.url) {
return;
}
this.ensureScriptWorker();
if (!this.scriptWorkerReady) {
return;
}
let assetSource;
try {
const response = await fetch(asset.url);
if (!response.ok) {
throw new Error(`Failed to load script asset ${asset.id}`);
}
assetSource = await response.text();
} catch (error) {
console.error(`Unable to fetch asset ${asset.id} from ${asset.url}`, error);
return;
}
this.scriptWorker.postMessage({
type: "addScript",
payload: {
id: asset.id,
source: assetSource,
},
});
}
stopUserJavaScriptWorker(assetId) {
if (!this.scriptWorker || !assetId) {
return;
}
this.scriptWorker.postMessage({
type: "removeScript",
payload: { id: assetId },
});
}
} }

View File

@@ -0,0 +1,150 @@
const scripts = new Map();
let canvas = null;
let ctx = null;
let channelName = "";
let tickIntervalId = null;
let lastTick = 0;
let startTime = 0;
function updateScriptContexts() {
scripts.forEach((script) => {
if (!script.context) {
return;
}
script.context.canvas = canvas;
script.context.ctx = ctx;
script.context.channelName = channelName;
script.context.width = canvas?.width ?? 0;
script.context.height = canvas?.height ?? 0;
});
}
function ensureTickLoop() {
if (tickIntervalId) {
return;
}
startTime = performance.now();
lastTick = startTime;
tickIntervalId = setInterval(() => {
if (!ctx || scripts.size === 0) {
return;
}
const now = performance.now();
const deltaMs = now - lastTick;
const elapsedMs = now - startTime;
lastTick = now;
scripts.forEach((script) => {
if (!script.tick) {
return;
}
script.context.now = now;
script.context.deltaMs = deltaMs;
script.context.elapsedMs = elapsedMs;
try {
script.tick(script.context, script.state);
} catch (error) {
console.error(`Script ${script.id} tick failed`, error);
}
});
}, 100);
}
function stopTickLoopIfIdle() {
if (scripts.size === 0 && tickIntervalId) {
clearInterval(tickIntervalId);
tickIntervalId = null;
}
}
function createScriptHandlers(source, context, state) {
const factory = new Function(
"context",
"state",
"module",
"exports",
`${source}\nconst resolved = (module && module.exports) || exports || {};\nreturn {\n init: typeof resolved.init === "function" ? resolved.init : typeof init === "function" ? init : null,\n tick: typeof resolved.tick === "function" ? resolved.tick : typeof tick === "function" ? tick : null,\n};`,
);
const module = { exports: {} };
const exports = module.exports;
return factory(context, state, module, exports);
}
self.addEventListener("message", (event) => {
const { type, payload } = event.data || {};
if (type === "init") {
canvas = payload.canvas;
channelName = payload.channelName || "";
if (canvas) {
canvas.width = payload.width || canvas.width;
canvas.height = payload.height || canvas.height;
ctx = canvas.getContext("2d");
}
updateScriptContexts();
return;
}
if (type === "resize") {
if (canvas) {
canvas.width = payload.width || canvas.width;
canvas.height = payload.height || canvas.height;
}
updateScriptContexts();
return;
}
if (type === "channel") {
channelName = payload.channelName || channelName;
updateScriptContexts();
return;
}
if (type === "addScript") {
if (!payload?.id || !payload?.source) {
return;
}
const state = {};
const context = {
canvas,
ctx,
channelName,
width: canvas?.width ?? 0,
height: canvas?.height ?? 0,
now: 0,
deltaMs: 0,
elapsedMs: 0,
};
let handlers = {};
try {
handlers = createScriptHandlers(payload.source, context, state);
} catch (error) {
console.error(`Script ${payload.id} failed to initialize`, error);
return;
}
const script = {
id: payload.id,
context,
state,
init: handlers.init,
tick: handlers.tick,
};
scripts.set(payload.id, script);
if (script.init) {
try {
script.init(script.context, script.state);
} catch (error) {
console.error(`Script ${payload.id} init failed`, error);
}
}
ensureTickLoop();
return;
}
if (type === "removeScript") {
if (!payload?.id) {
return;
}
scripts.delete(payload.id);
stopTickLoopIfIdle();
}
});

View File

@@ -37,7 +37,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
userSourceTextArea.disabled = false; userSourceTextArea.disabled = false;
userSourceTextArea.dataset.assetId = ""; userSourceTextArea.dataset.assetId = "";
userSourceTextArea.placeholder = userSourceTextArea.placeholder =
"function init({ surface, assets, channel }) {\n\n}\n\nfunction tick() {\n\n}"; "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) => {};";
} }
resetErrors(); resetErrors();
openModal(); openModal();
@@ -192,7 +192,43 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
let hasInit = false; let hasInit = false;
let hasTick = false; let hasTick = false;
const isFunctionNode = (node) =>
node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
const markFunctionName = (name) => {
if (name === "init") hasInit = true;
if (name === "tick") hasTick = true;
};
const isModuleExportsMember = (node) =>
node &&
node.type === "MemberExpression" &&
node.object?.type === "Identifier" &&
node.object.name === "module" &&
node.property?.type === "Identifier" &&
node.property.name === "exports";
const checkObjectExpression = (objectExpression) => {
if (!objectExpression || objectExpression.type !== "ObjectExpression") {
return;
}
for (const property of objectExpression.properties || []) {
if (property.type !== "Property") {
continue;
}
const keyName = property.key?.type === "Identifier" ? property.key.name : property.key?.value;
if (keyName && isFunctionNode(property.value)) {
markFunctionName(keyName);
}
}
};
for (const node of ast.body) { for (const node of ast.body) {
if (node.type === "FunctionDeclaration") {
markFunctionName(node.id?.name);
continue;
}
if (node.type !== "ExpressionStatement") continue; if (node.type !== "ExpressionStatement") continue;
const expr = node.expression; const expr = node.expression;
@@ -201,29 +237,37 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
const left = expr.left; const left = expr.left;
const right = expr.right; const right = expr.right;
if (left.type === "Identifier" && left.name === "exports" && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (isModuleExportsMember(left) && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (left.type === "MemberExpression" && left.property.type === "Identifier" && isFunctionNode(right)) {
if ( if (
left.type === "MemberExpression" && (left.object.type === "Identifier" && left.object.name === "exports") ||
left.object.type === "Identifier" && isModuleExportsMember(left.object)
left.object.name === "exports" &&
left.property.type === "Identifier" &&
(right.type === "FunctionExpression" || right.type === "ArrowFunctionExpression")
) { ) {
if (left.property.name === "init") hasInit = true; markFunctionName(left.property.name);
if (left.property.name === "tick") hasTick = true; }
} }
} }
if (!hasInit) { if (!hasInit) {
return { return {
title: "Missing function: init", title: "Missing function: init",
details: "You must assign a function to exports.init", details: "Define a function named init or assign a function to exports.init/module.exports.init.",
}; };
} }
if (!hasTick) { if (!hasTick) {
return { return {
title: "Missing function: tick", title: "Missing function: tick",
details: "You must assign a function to exports.tick", details: "Define a function named tick or assign a function to exports.tick/module.exports.tick.",
}; };
} }

View File

@@ -62,10 +62,7 @@
</label> </label>
</div> </div>
<div class="upload-row"> <div class="upload-row">
<label <label class="file-input-trigger" id="custom-asset-button">
class="file-input-trigger"
id="custom-asset-button"
>
<span class="file-input-icon"><i class="fa-solid fa-code"></i></span> <span class="file-input-icon"><i class="fa-solid fa-code"></i></span>
<span class="file-input-copy"> <span class="file-input-copy">
<strong>Create custom asset</strong> <strong>Create custom asset</strong>

View File

@@ -10,6 +10,7 @@
</head> </head>
<body class="broadcast-body"> <body class="broadcast-body">
<canvas id="broadcast-canvas"></canvas> <canvas id="broadcast-canvas"></canvas>
<canvas id="broadcast-script-canvas"></canvas>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ""; const broadcaster = /*[[${broadcaster}]]*/ "";
</script> </script>