diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index c310fa2..fcd1094 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1163,9 +1163,20 @@ button:disabled:hover { } .broadcast-body canvas { + position: absolute; + top: 0; + left: 0; pointer-events: none; } +#broadcast-canvas { + z-index: 1; +} + +#broadcast-script-canvas { + z-index: 2; +} + .broadcast-body { margin: 0; overflow: hidden; diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index c8de031..8ff1d4e 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -1,6 +1,7 @@ import { BroadcastRenderer } from "./broadcast/renderer.js"; 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(); diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index f22afd9..27507bd 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -7,9 +7,10 @@ import { createAudioManager } from "./audioManager.js"; import { createMediaManager } from "./mediaManager.js"; export class BroadcastRenderer { - constructor({ canvas, broadcaster, showToast }) { + constructor({ canvas, scriptCanvas, broadcaster, showToast }) { this.canvas = canvas; this.ctx = canvas.getContext("2d"); + this.scriptCanvas = scriptCanvas; this.broadcaster = broadcaster; this.showToast = showToast; this.state = createBroadcastState(); @@ -17,6 +18,8 @@ export class BroadcastRenderer { this.frameScheduled = false; this.pendingDraw = false; this.renderIntervalId = null; + this.scriptWorker = null; + this.scriptWorkerReady = false; this.obsBrowser = !!globalThis.obsstudio; this.supportsAnimatedDecode = @@ -135,7 +138,14 @@ export class BroadcastRenderer { this.canvas.height = this.state.canvasSettings.height; this.canvas.style.width = `${this.state.canvasSettings.width}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(); } @@ -368,7 +378,79 @@ export class BroadcastRenderer { }, 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 }, + }); + } } diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js new file mode 100644 index 0000000..e9b83e0 --- /dev/null +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -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(); + } +}); diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index 886136e..b161a32 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -37,7 +37,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho userSourceTextArea.disabled = false; userSourceTextArea.dataset.assetId = ""; 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(); openModal(); @@ -192,7 +192,43 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho let hasInit = 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) { + if (node.type === "FunctionDeclaration") { + markFunctionName(node.id?.name); + continue; + } + if (node.type !== "ExpressionStatement") continue; const expr = node.expression; @@ -201,29 +237,37 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho const left = expr.left; const right = expr.right; - if ( - left.type === "MemberExpression" && - left.object.type === "Identifier" && - left.object.name === "exports" && - left.property.type === "Identifier" && - (right.type === "FunctionExpression" || right.type === "ArrowFunctionExpression") - ) { - if (left.property.name === "init") hasInit = true; - if (left.property.name === "tick") hasTick = true; + 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 ( + (left.object.type === "Identifier" && left.object.name === "exports") || + isModuleExportsMember(left.object) + ) { + markFunctionName(left.property.name); + } } } if (!hasInit) { return { 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) { return { 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.", }; } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 3c916dd..5103075 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -62,10 +62,7 @@
-