Lumi/src/web/public/overlay-renderer.js
2026-07-24 22:12:39 +02:00

599 lines
29 KiB
JavaScript

(() => {
const chatStates = new Map();
const anchors = {
"top-left": [0, 0],
"top-center": [-50, 0],
"top-right": [-100, 0],
"center-left": [0, -50],
center: [-50, -50],
"center-right": [-100, -50],
"bottom-left": [0, -100],
"bottom-center": [-50, -100],
"bottom-right": [-100, -100]
};
function finite(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function applyBox(element, config) {
const [translateX, translateY] = anchors[config.anchor] || anchors["top-left"];
element.style.left = `${finite(config.x, 0)}%`;
element.style.top = `${finite(config.y, 0)}%`;
element.style.width = `${finite(config.width, 100)}%`;
element.style.height = `${finite(config.height, 100)}%`;
element.style.opacity = `${finite(config.opacity, 1)}`;
element.style.transform = `translate(${translateX}%, ${translateY}%) translateZ(0)`;
}
function shrinkText(box, content, preferredSize, minimumSize, scale) {
let size = preferredSize;
const fit = () => {
content.style.fontSize = `${size * scale}px`;
if (size <= minimumSize || (content.scrollWidth <= box.clientWidth && content.scrollHeight <= box.clientHeight)) return;
size = Math.max(minimumSize, size - Math.max(1, preferredSize / 24));
requestAnimationFrame(fit);
};
requestAnimationFrame(fit);
}
function restartMediaElement(media) {
if (!media) return false;
media.dispatchEvent(new CustomEvent("lumi-media-restart"));
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
try { media.currentTime = startAt; } catch {}
const playback = media.play?.();
playback?.catch?.(() => {});
return true;
}
function buildMedia(module, values, options, renderType) {
const media = document.createElement(renderType);
media.className = `lumi-overlay-${renderType}`;
media.src = values.url;
media.dataset.originalUrl = values.url;
media.dataset.moduleId = module.id;
media.dataset.startAt = `${finite(values.startAt, 0)}`;
media.preload = "auto";
media.playsInline = true;
media.loop = values.playBehavior === "loop";
media.controls = false;
media.muted = values.muted === true;
media.volume = Math.min(1, Math.max(0, finite(values.volume, 1)));
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
media.autoplay = !options.editor && values.playBehavior !== "manual";
media.referrerPolicy = "no-referrer";
media.style.pointerEvents = "none";
if (renderType === "video") {
media.style.objectFit = values.fit || "contain";
media.hidden = true;
media.dataset.playbackState = "waiting";
const show = () => {
media.hidden = false;
media.dataset.playbackState = "playing";
};
const hide = () => {
media.hidden = true;
media.dataset.playbackState = "stopped";
};
media.addEventListener("playing", show);
for (const eventName of ["ended", "error", "abort", "emptied"]) media.addEventListener(eventName, hide);
media.addEventListener("lumi-media-stop", hide);
media.addEventListener("lumi-media-restart", hide);
} else {
media.hidden = true;
media.setAttribute("aria-hidden", "true");
}
media.addEventListener("loadedmetadata", () => {
const startAt = Math.max(0, finite(values.startAt, 0));
if (startAt && startAt < finite(media.duration, Infinity)) {
try { media.currentTime = startAt; } catch {}
}
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
if (media.autoplay) media.play().catch(() => {});
}, { once: true });
return media;
}
function webFrameLayout(values) {
const left = Math.min(95, Math.max(0, finite(values.cropLeft, 0)));
const right = Math.min(95, Math.max(0, finite(values.cropRight, 0)));
const top = Math.min(95, Math.max(0, finite(values.cropTop, 0)));
const bottom = Math.min(95, Math.max(0, finite(values.cropBottom, 0)));
const zoom = Math.min(5, Math.max(0.1, finite(values.zoom, 1)));
const visibleWidth = Math.max(5, 100 - left - right);
const visibleHeight = Math.max(5, 100 - top - bottom);
return {
width: (10000 * zoom) / visibleWidth,
height: (10000 * zoom) / visibleHeight,
left: (-100 * left * zoom) / visibleWidth,
top: (-100 * top * zoom) / visibleHeight
};
}
function buildWebsite(module, values, options) {
const frame = document.createElement("iframe");
const sourceUrl = values.renderUrl || values.url;
const injectedDocument = Boolean(values.renderUrl);
frame.className = "lumi-overlay-frame";
frame.src = sourceUrl;
frame.dataset.originalUrl = sourceUrl;
frame.dataset.moduleId = module.id;
let sandbox = "allow-scripts allow-forms allow-popups allow-presentation";
try {
if (!injectedDocument && new URL(sourceUrl, window.location.href).origin !== window.location.origin) sandbox += " allow-same-origin";
} catch {}
frame.sandbox = sandbox;
frame.referrerPolicy = "no-referrer";
frame.setAttribute("allow", "autoplay; fullscreen");
frame.setAttribute("allowtransparency", "true");
frame.style.pointerEvents = options.editor ? "none" : (values.allowPointerEvents ? "auto" : "none");
const layout = webFrameLayout(values);
const root = document.createElement("div");
root.className = "lumi-overlay-web-root";
const shadow = root.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; background-color: rgba(0, 0, 0, 0); }
.lumi-overlay-frame {
position: absolute; display: block; border: 0; background-color: rgba(0, 0, 0, 0);
width: ${layout.width}%; height: ${layout.height}%;
left: ${layout.left}%; top: ${layout.top}%;
}
`;
shadow.append(style, frame);
return root;
}
function normalizedChannel(value) {
return String(value || "").trim().toLowerCase().replace(/^#/, "");
}
function chatAccepts(values, payload) {
if (!(values.platforms || []).includes(payload.platform) || chatUserBlocked(values, payload)) return false;
const filters = Array.isArray(values.channels) ? values.channels : [];
if (!filters.length) return true;
const aliases = [payload.channel?.id, payload.channel?.name, payload.channel?.key]
.map(normalizedChannel)
.filter(Boolean);
return filters.some((entry) => {
const raw = String(entry || "").trim();
const separator = raw.indexOf(":");
const platform = separator > 0 ? raw.slice(0, separator).trim().toLowerCase() : "";
const channel = normalizedChannel(separator > 0 ? raw.slice(separator + 1) : raw);
return (!platform || platform === payload.platform) && (channel === "*" || aliases.includes(channel));
});
}
function normalizedIdentity(value) {
return String(value || "").trim().toLowerCase().replace(/^@/, "");
}
function chatUserBlocked(values, payload) {
return (values.blockedUsers || []).some((entry) => {
const raw = String(entry || "").trim();
const separator = raw.indexOf(":");
if (separator < 1) return false;
const platform = raw.slice(0, separator).toLowerCase();
const blocked = raw.slice(separator + 1).split("|").map(normalizedIdentity).filter(Boolean);
const aliases = platform === "lumi"
? [payload.author?.lumi?.id, payload.author?.lumi?.username]
: platform === payload.platform
? [payload.author?.id, payload.author?.username, payload.author?.name]
: [];
const normalizedAliases = aliases.map(normalizedIdentity).filter(Boolean);
return blocked.some((value) => normalizedAliases.includes(value));
});
}
function chatMessageElement(payload, values, { animate = true } = {}) {
const row = document.createElement("article");
row.className = `message platform-${payload.platform}`;
row.dataset.messageId = payload.id;
row.dataset.platform = payload.platform;
if (values.showAvatars && payload.author?.avatar) {
const avatar = document.createElement("img");
avatar.className = "avatar";
avatar.src = payload.author.avatar;
avatar.alt = "";
avatar.referrerPolicy = "no-referrer";
avatar.addEventListener("error", () => avatar.remove(), { once: true });
row.appendChild(avatar);
}
const content = document.createElement("div");
content.className = "content";
const meta = document.createElement("div");
meta.className = "meta";
if (values.showPlatform) {
const platform = document.createElement("img");
platform.className = "platform";
platform.src = `/icons/platforms/${["twitch", "youtube", "discord"].includes(payload.platform) ? payload.platform : "lumi"}.svg`;
platform.alt = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1);
platform.title = platform.alt;
meta.appendChild(platform);
}
const author = document.createElement("span");
author.className = "author";
author.textContent = payload.author?.name || "Viewer";
author.style.color = payload.author?.color || values.usernameColor || "#67e8f9";
meta.appendChild(author);
if (values.showBadges) {
for (const badgeData of payload.author?.badges || []) {
const badge = document.createElement("img");
badge.className = "badge";
if (badgeData.image) {
badge.src = badgeData.image;
} else {
const label = String(badgeData.label || "").toLowerCase();
const kind = /owner|broadcaster/.test(label) ? "owner" : /moderator|mod|staff/.test(label) ? "moderator" : /member|subscriber|sub|vip/.test(label) ? "member" : "generic";
badge.src = `/icons/badges/${kind}.svg`;
}
badge.alt = badgeData.label || "Badge";
badge.title = badge.alt;
badge.referrerPolicy = "no-referrer";
meta.appendChild(badge);
}
}
if (values.showTimestamp) {
const timestamp = document.createElement("time");
timestamp.className = "timestamp";
timestamp.dateTime = new Date(payload.timestamp).toISOString();
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
meta.appendChild(timestamp);
}
const messageText = String(payload.text || "");
if (messageText) {
const text = document.createElement("div");
text.className = "text";
let cursor = 0;
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
for (const emoteData of emotes) {
if (emoteData.start < cursor || emoteData.start >= messageText.length) continue;
text.append(document.createTextNode(messageText.slice(cursor, emoteData.start)));
const emote = document.createElement("img");
emote.className = "emote";
emote.src = emoteData.image;
emote.alt = emoteData.label || messageText.slice(emoteData.start, Math.min(messageText.length, emoteData.end + 1)) || "Emote";
emote.referrerPolicy = "no-referrer";
text.appendChild(emote);
cursor = Math.min(messageText.length, emoteData.end + 1);
}
text.append(document.createTextNode(messageText.slice(cursor)));
content.appendChild(text);
}
if (payload.media?.length) {
const mediaRow = document.createElement("div");
mediaRow.className = "media-row";
for (const mediaData of payload.media) {
const media = document.createElement(mediaData.type === "video" ? "video" : "img");
media.className = "media";
media.src = mediaData.url;
media.setAttribute("aria-label", mediaData.alt || "Animated image");
media.referrerPolicy = "no-referrer";
if (media.tagName === "VIDEO") {
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
} else media.alt = mediaData.alt || "Animated image";
media.addEventListener("error", () => media.remove(), { once: true });
mediaRow.appendChild(media);
}
content.appendChild(mediaRow);
}
content.prepend(meta);
row.appendChild(content);
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
row.classList.add("enter", `enter-${values.inAnimation}`);
requestAnimationFrame(() => requestAnimationFrame(() => row.classList.add("enter-active")));
setTimeout(() => row.classList.remove("enter", "enter-active", `enter-${values.inAnimation}`), Number(values.animationDurationMs));
}
return row;
}
function removeChatMessage(moduleId, messageId) {
const state = chatStates.get(moduleId);
if (!state) return;
state.messages = state.messages.filter((message) => message.id !== messageId);
const row = state.list?.querySelector?.(`[data-message-id="${CSS.escape(messageId)}"]`);
if (!row) return;
const values = state.values || {};
if (values.outAnimation !== "none" && Number(values.animationDurationMs) > 0) {
row.classList.add("leave", `leave-${values.outAnimation}`);
requestAnimationFrame(() => row.classList.add("leave-active"));
setTimeout(() => row.remove(), Number(values.animationDurationMs));
} else row.remove();
}
function scheduleChatRemoval(moduleId, payload, values) {
if (!(Number(values.messageTimeoutSeconds) > 0)) return;
const remaining = Math.max(0, payload.timestamp + Number(values.messageTimeoutSeconds) * 1000 - Date.now());
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
}
const previewChatMessages = [
{ platform: "twitch", text: "Hi chat!", author: { name: "CozyViewer", color: "#f472b6", avatar: "/icons/avatars/preview-pink.svg", badges: [{ label: "Subscriber", image: null }] } },
{ platform: "discord", text: "The badges, avatar, and service icon move together.", author: { name: "PackMember", color: "#a78bfa", avatar: "/icons/avatars/preview-purple.svg", badges: [{ label: "Moderator", image: null }] } },
{ platform: "youtube", text: "Short message", author: { name: "Lumi Fan", color: "#fb7185", avatar: "/icons/avatars/preview-red.svg", badges: [{ label: "Member", image: null }] } },
{ platform: "twitch", text: "This longer sample wraps naturally so you can compare it with compact one-line messages.", author: { name: "StreamFriend", color: "#22d3ee", avatar: "/icons/avatars/preview-cyan.svg", badges: [] } },
{ platform: "discord", text: "Animated arrivals and departures repeat in this preview.", author: { name: "Lumi", color: "#67e8f9", avatar: "/icons/platforms/lumi.svg", badges: [{ label: "Owner", image: null }] } },
{ platform: "youtube", text: "Looks good!", author: { name: "ChatRegular", color: "#fbbf24", avatar: "/icons/avatars/preview-gold.svg", badges: [] } }
];
function nextPreviewMessage(moduleId, state) {
const allowed = previewChatMessages.filter((message) => (state.values.platforms || []).includes(message.platform));
const samples = allowed.length ? allowed : previewChatMessages;
const sample = samples[state.previewIndex % samples.length];
state.previewIndex += 1;
return {
...sample,
id: `${moduleId}-preview-${state.previewIndex}-${Date.now()}`,
preview: true,
timestamp: Date.now(),
channel: { name: sample.platform === "discord" ? "#community" : "Live chat" },
author: { ...sample.author }
};
}
function schedulePreviewChat(moduleId, state) {
clearTimeout(state.previewTimer);
const delay = Math.max(1400, Number(state.values.animationDurationMs || 0) * 2 + 500);
state.previewTimer = setTimeout(() => {
if (!state.editor || !state.list?.isConnected) {
state.previewTimer = null;
return;
}
const payload = nextPreviewMessage(moduleId, state);
state.messages.push(payload);
const row = chatMessageElement(payload, state.values);
if (state.values.newestAt === "top") state.list.prepend(row);
else state.list.appendChild(row);
const visibleLimit = Math.max(1, Math.min(Number(state.values.maxMessages || 8), 5));
while (state.messages.length > visibleLimit) removeChatMessage(moduleId, state.messages[0].id);
schedulePreviewChat(moduleId, state);
}, delay);
}
function buildChat(module, values, options) {
const root = document.createElement("div");
root.className = "lumi-overlay-chat-root";
const shadow = root.attachShadow({ mode: "open" });
const style = document.createElement("style");
const chatCustomCss = values.customCss || "";
const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start";
const dock = Boolean(options.chatDock);
style.textContent = `
:host { display:block; width:100%; ${dock ? "min-height:100%; height:auto; overflow:visible;" : "height:100%; overflow:hidden;"} box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; }
.chat { width:100%; ${dock ? "min-height:100vh; height:auto; overflow:visible;" : "height:100%; overflow:hidden;"} box-sizing:border-box; display:flex; flex-direction:column; align-items:${alignment}; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; text-align:${values.horizontalAlign}; }
.message { display:inline-flex; align-items:flex-start; align-self:${alignment}; gap:.55em; box-sizing:border-box; width:fit-content; max-width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; }
.avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; }
.content { min-width:0; max-width:100%; flex:0 1 auto; }
.meta { display:flex; align-items:center; justify-content:${alignment}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; }
.author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; }
.platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; }
.badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; }
.timestamp { flex:0 0 auto; opacity:.65; font-size:.85em; }
.text { overflow-wrap:anywhere; white-space:pre-wrap; }
.emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; }
.media-row { display:flex; flex-wrap:wrap; justify-content:${alignment}; gap:.3em; margin-top:.2em; max-width:100%; }
.media { display:block; width:auto; height:${Math.max(1.6, Number(values.lineHeight || 1.25) * 2)}em; max-width:100%; border-radius:.25em; object-fit:contain; }
.enter-fade, .leave-active { opacity:0; }
.enter-slide-left { opacity:0; transform:translateX(-1.5em); }
.enter-slide-right { opacity:0; transform:translateX(1.5em); }
.enter-slide-up { opacity:0; transform:translateY(-1em); }
.enter-slide-down { opacity:0; transform:translateY(1em); }
.enter-scale { opacity:0; transform:scale(.92); }
.enter-active { opacity:1; transform:none; }
.leave-slide-left.leave-active { transform:translateX(-1.5em); }
.leave-slide-right.leave-active { transform:translateX(1.5em); }
.leave-slide-up.leave-active { transform:translateY(-1em); }
.leave-slide-down.leave-active { transform:translateY(1em); }
.leave-scale.leave-active { transform:scale(.92); }
${chatCustomCss}
${dock ? ":host { width:100% !important; min-height:100% !important; height:auto !important; overflow:visible !important; } .chat { width:100% !important; min-height:100vh !important; height:auto !important; overflow:visible !important; } .message, .message * { animation:none !important; transition:none !important; } .message { transform:none !important; opacity:1 !important; }" : ""}
`;
const list = document.createElement("div");
list.className = "chat";
list.setAttribute("role", "log");
list.setAttribute("aria-live", "off");
shadow.append(style, list);
let state = chatStates.get(module.id);
if (!state) state = { messages: [], previewIndex: 0 };
state.values = values;
state.list = list;
state.editor = Boolean(options.editor);
if (options.editor && !state.messages.length) {
const initialCount = Math.max(1, Math.min(Number(values.maxMessages || 8), 5));
for (let index = 0; index < initialCount; index += 1) state.messages.push(nextPreviewMessage(module.id, state));
}
state.messages = state.messages.slice(-Number(values.maxMessages || 8));
const acceptedMessages = state.messages.filter((message) => message.preview
? (values.platforms || []).includes(message.platform)
: chatAccepts(values, message));
const display = values.newestAt === "top" ? [...acceptedMessages].reverse() : acceptedMessages;
for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false }));
chatStates.set(module.id, state);
if (options.editor) schedulePreviewChat(module.id, state);
else clearTimeout(state.previewTimer);
return root;
}
function handleChatMessage(root, payload) {
if (!payload?.id || !payload?.platform || (!payload?.text && !payload?.media?.length)) return 0;
let accepted = 0;
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
const moduleId = box.dataset.moduleId;
const state = chatStates.get(moduleId);
if (!state?.list || !chatAccepts(state.values, payload) || state.messages.some((message) => message.id === payload.id)) return;
state.messages.push(payload);
const row = chatMessageElement(payload, state.values);
if (state.values.newestAt === "top") state.list.prepend(row);
else state.list.appendChild(row);
while (state.messages.length > Number(state.values.maxMessages || 8)) removeChatMessage(moduleId, state.messages[0].id);
scheduleChatRemoval(moduleId, payload, state.values);
accepted += 1;
});
return accepted;
}
function findSourceContent(root, moduleId, selector) {
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
return box?.querySelector?.(selector)
|| box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector)
|| root?.querySelector?.(`.lumi-overlay-managed-media[data-module-id="${CSS.escape(moduleId)}"]${selector === "video, audio" ? "" : selector}`)
|| null;
}
function buildModule(module, options = {}) {
const renderType = module.renderType || module.type;
const values = module.config || {};
if (renderType === "audio") return null;
const scale = finite(options.scale, 1);
const box = document.createElement("section");
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
if (options.editor) box.classList.add("is-editor-source");
box.dataset.moduleId = module.id;
box.dataset.moduleType = module.type;
box.dataset.renderType = renderType;
box.dataset.moduleName = module.name || "Source";
applyBox(box, values);
if (values.eventOnly && !options.editor) {
box.hidden = true;
box.dataset.eventOnly = "true";
}
if (renderType === "text") {
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);
const content = document.createElement("span");
content.className = "lumi-overlay-text-content";
content.textContent = values.text || "";
const horizontal = values.horizontalAlign || values.align || "left";
const vertical = values.verticalAlign || "center";
box.style.textAlign = horizontal;
box.style.justifyContent = horizontal === "center" ? "center" : horizontal === "right" ? "flex-end" : "flex-start";
box.style.alignItems = vertical === "center" ? "center" : vertical === "bottom" ? "flex-end" : "flex-start";
box.style.color = values.color || "#ffffff";
box.style.background = values.background || "transparent";
box.style.padding = `${finite(values.padding, 0) * scale}px`;
content.style.fontWeight = `${finite(values.fontWeight, 700)}`;
content.style.fontSize = `${finite(values.fontSize, 48) * scale}px`;
box.appendChild(content);
if (values.overflow === "shrink") shrinkText(box, content, finite(values.fontSize, 48), 8, scale);
} else if (renderType === "chat") {
box.dataset.renderType = "chat";
box.appendChild(buildChat(module, values, options));
} else if (renderType === "image" && values.url) {
const image = document.createElement("img");
image.className = "lumi-overlay-image";
image.alt = "";
image.src = values.url;
image.referrerPolicy = "no-referrer";
image.style.objectFit = values.fit || "contain";
box.appendChild(image);
} else if (renderType === "video" && values.url) {
const media = buildMedia(module, values, options, renderType);
box.appendChild(media);
} else if (renderType === "web" && values.url) {
box.appendChild(buildWebsite(module, values, options));
}
if (options.editor) {
const tag = document.createElement("span");
tag.className = "overlay-canvas-source-label";
tag.textContent = module.name || "Source";
box.appendChild(tag);
for (const direction of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
const handle = document.createElement("button");
handle.type = "button";
handle.tabIndex = -1;
handle.className = `overlay-resize-handle handle-${direction}`;
handle.dataset.resizeHandle = direction;
handle.setAttribute("aria-label", `Resize ${module.name || "source"} from ${direction}`);
box.appendChild(handle);
}
}
return box;
}
function render(root, state, options = {}) {
const canvas = options.canvas || state?.canvas || { width: 1920, height: 1080 };
const layout = stageLayout(root, canvas, options.scale);
const stage = document.createElement("div");
stage.className = "lumi-overlay-stage";
stage.style.width = `${layout.width}px`;
stage.style.height = `${layout.height}px`;
stage.style.transform = `scale(${layout.scaleX}, ${layout.scaleY})`;
if (options.editor) {
const editorScale = Math.max(0.01, Math.min(layout.scaleX, layout.scaleY));
const handleSize = window.matchMedia?.("(max-width: 700px)").matches ? 14 : 10;
stage.style.setProperty("--lumi-editor-handle-size", `${handleSize / editorScale}px`);
stage.style.setProperty("--lumi-editor-outline-size", `${2 / editorScale}px`);
stage.style.setProperty("--lumi-editor-label-font-size", `${11.2 / editorScale}px`);
stage.style.setProperty("--lumi-editor-label-max-width", `${192 / editorScale}px`);
stage.style.setProperty("--lumi-editor-label-padding-y", `${3.2 / editorScale}px`);
stage.style.setProperty("--lumi-editor-label-padding-x", `${6.4 / editorScale}px`);
stage.style.setProperty("--lumi-editor-label-radius", `${4 / editorScale}px`);
}
const elements = new Map();
if (state?.enabled !== false && state?.scene) {
for (const module of state.scene.modules || []) {
if (module.enabled === false) continue;
const renderType = module.renderType || module.type;
if (renderType === "audio") {
if (module.config?.url) {
const media = buildMedia(module, module.config, options, "audio");
media.classList.add("lumi-overlay-managed-media");
stage.appendChild(media);
}
continue;
}
const element = buildModule(module, { ...options, canvas, scale: 1 });
if (!element) continue;
elements.set(module.id, element);
stage.appendChild(element);
}
}
root.replaceChildren(stage);
return elements;
}
function renderChatDock(root, module) {
if (!root) return null;
if (!module || (module.renderType || module.type) !== "chat") {
root.replaceChildren();
return null;
}
const wrapper = document.createElement("section");
wrapper.className = "lumi-overlay-module lumi-overlay-chat-dock";
wrapper.dataset.moduleId = module.id;
wrapper.dataset.moduleType = module.type;
wrapper.dataset.renderType = "chat";
wrapper.appendChild(buildChat({
...module,
config: { ...(module.config || {}), inAnimation: "none", outAnimation: "none", animationDurationMs: 0 }
}, { chatDock: true }));
root.replaceChildren(wrapper);
return wrapper;
}
function stageLayout(root, canvas, scaleOverride) {
const width = Math.max(1, finite(canvas?.width, 1920));
const height = Math.max(1, finite(canvas?.height, 1080));
const override = finite(scaleOverride, 0);
return {
width,
height,
scaleX: override > 0 ? override : (root?.clientWidth ? root.clientWidth / width : 1),
scaleY: override > 0 ? override : (root?.clientHeight ? root.clientHeight / height : 1)
};
}
window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, renderChatDock, restartMediaElement, stageLayout };
})();