mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Start custom script assets
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/main/resources/static/js/broadcast/script-worker.js
Normal file
150
src/main/resources/static/js/broadcast/script-worker.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user