Improve overlay chat and live editing

This commit is contained in:
Franz Rolfsvaag 2026-07-19 12:15:43 +02:00
parent ac6f364327
commit 97e6615183
32 changed files with 558 additions and 83 deletions

View File

@ -1,5 +1,12 @@
# Lumi changelog
## 0.2.15
- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking.
- Replaced text platform/badge labels with platform artwork, official Twitch badge images where available, and image fallbacks for other badge types.
- Added server-normalized unsaved source previews, temporary website-CSS previews, visible draft/revert controls, contrast backgrounds, an alignment grid, and reachable sticky source actions.
- Hardened alert-site transparency with an optional page/root guard that remains effective when apps such as alert providers modify their document after loading.
## 0.2.14
- Added a native Lumi chat overlay source that combines live Twitch, YouTube, and Discord messages with optional per-channel filters.

View File

@ -28,7 +28,12 @@ Custom CSS behavior while keeping the page isolated from Lumi sessions and local
network targets. A native multi-platform Lumi chat source now combines connected
Twitch, YouTube, and Discord chats with channel filters, full visual controls,
animations, timeout removal, short reconnect replay, Twitch emotes, and isolated
custom CSS.
custom CSS. The editor now previews server-normalized unsaved changes (including
temporary website CSS), marks/reverts drafts, provides contrast and grid views,
and keeps save actions reachable. Alert documents also retain transparent page
and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
platform messages, image-based platform/badge marks, and server-enforced platform
or linked-Lumi-account user blocking.
Remaining work:
@ -686,6 +691,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done
- 2026-07-19: Fixed native chat overlays to include Lumi's own platform messages; added `platform:username|id` and linked `lumi:username|id` blocking before scoped delivery; replaced text platform/badge labels with images and official Twitch badge catalog artwork; added normalized unsaved source previews, secured website-CSS drafts, visible dirty/revert state, sticky save controls, contrast backgrounds, and a placement grid; and hardened transparency against late-loading alert-app DOM/style changes.
- 2026-07-19: Added the native Lumi chat overlay source with combined Twitch, YouTube, and Discord messages; optional platform/channel filters; avatars, badges, Twitch emotes, timestamps, typography, transparent colors, alignment and spacing; entry/exit animations; bounded timeout/capacity removal; isolated CSS; representative editor previews; and short-lived reconnect replay through the existing scoped overlay event stream.
- 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets.

View File

@ -80,6 +80,18 @@ top-or-bottom placement, timeout removal, and separate entry and exit
animations. Twitch emotes are displayed as images. The editor canvas supplies
sample messages so appearance can be configured before the stream is live.
Lumi includes messages sent by its own connected bot accounts. Platform marks
are shown as icons, and chat badges are rendered as images. Twitch badge artwork
is resolved from Twitch's global and channel badge catalogs; a local image
fallback is used when a provider does not expose badge artwork.
The optional hidden-user list accepts one rule per line in the form
`platform:username|user-id`. Twitch, YouTube, and Discord rules match only that
platform. A `lumi:internal-name|user-id` rule uses Lumi's linked identity model,
so one rule hides every connected platform identity belonging to that Lumi user.
Filtering is enforced before a message enters an overlay's scoped event stream
and is also checked by the renderer.
Advanced CSS is isolated to that chat source and is appended after the visual
settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`,
`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, and `.emote`.
@ -134,6 +146,12 @@ compatibility mode is available for private/local pages and unusual sites that
cannot run through document injection; direct mode intentionally cannot change
cross-origin page content.
**Keep the website background transparent** is enabled by default. It protects
the page, body, and common application roots with a transparent background and
reapplies that protection when a late-loading alert application changes its DOM
or inline styles. It can be disabled for a website that intentionally needs an
opaque page background.
Lumi applies OBS's standard transparent Browser Source CSS to every clean render
page and shows the same copyable value in the Browser Source setup section:
@ -145,6 +163,21 @@ The Lumi document, canvas stage, embedded frame surface, and injected website
body are transparent by default. Administrators can extend or override the
default stylesheet per source just as they do in OBS Browser Source properties.
## Visual editing workflow
Existing sources use a draft preview workflow. Changing a field sends the draft
through the same server normalizer used for saving and updates the canvas after a
short delay without changing the stored source. Website URLs and CSS receive a
short-lived, access-controlled preview document, so their unsaved appearance is
also visible. Each changed card is marked as unsaved and offers **Save source**
or **Undo unsaved changes**.
The preview can switch between transparency checkerboard, dark, and light
backgrounds to expose unwanted opaque areas or low-contrast text. An optional
grid adds five-percent lines and canvas midpoints. Source save controls remain
sticky at the bottom of long settings panels, while drag, resize, keyboard
nudge, anchors, snapping, crop, zoom, and precise number fields remain available.
## OBS WebSocket
The connector is disabled by default. Enable OBS WebSocket in OBS, keep its

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.14
Version: 0.2.15
## Routes
- GET /api/events
- POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.14",
"version": "0.2.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.14",
"version": "0.2.15",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.14",
"version": "0.2.15",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.15",
"ref": "refs/tags/v0.2.15",
"released_at": "2026-07-19",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Improves native chat delivery, linked-user filtering and artwork, secured unsaved overlay previews, editing controls, and alert transparency. Existing overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.2.14",
"ref": "refs/tags/v0.2.14",

View File

@ -1,7 +1,8 @@
const assert = require("assert");
const {
injectDocumentCss,
loadInjectedOverlayDocument
loadInjectedOverlayDocument,
TRANSPARENT_WEBSITE_GUARD
} = require("../src/services/overlay-web-documents");
const { OBS_BROWSER_DEFAULT_CSS } = require("../src/services/obs-browser-defaults");
@ -14,9 +15,20 @@ async function run() {
assert(injected.includes('<base href="https://alerts.example/widgets/main/">'));
assert(injected.includes('<style id="lumi-obs-browser-css">'));
assert(injected.includes(OBS_BROWSER_DEFAULT_CSS));
assert(injected.includes(TRANSPARENT_WEBSITE_GUARD));
assert(injected.includes(".alert { font-size: 64px; }"));
assert(injected.indexOf("lumi-obs-browser-css") > injected.indexOf("<title>Alerts</title>"), "website CSS must be appended after the website head content like OBS");
assert(injected.includes('window.addEventListener("load",apply,{once:true})'), "website CSS must be re-applied when the main document finishes loading like OBS");
assert(injected.includes("new MutationObserver(schedule)"), "late-loading alert apps must not be able to remove the transparency/CSS guard");
assert(injected.includes('setProperty(\"background-color\",\"rgba(0, 0, 0, 0)\",\"important\")'));
const opaqueAllowed = injectDocumentCss(
"<html><head></head><body></body></html>",
"https://alerts.example/widget",
"body { background: white; }",
{ forceTransparentBackground: false }
);
assert(!opaqueAllowed.includes(TRANSPARENT_WEBSITE_GUARD));
const withBase = injectDocumentCss(
'<html><head><base href="../assets/"></head><body></body></html>',

View File

@ -117,6 +117,7 @@ try {
assert.strictEqual(webConfig.refreshIntervalSeconds, 30);
assert.strictEqual(webConfig.retryLimit, 5);
assert.strictEqual(webConfig.injectPageCss, true);
assert.strictEqual(webConfig.forceTransparentBackground, true);
assert.strictEqual(webConfig.customCss, "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }");
const videoConfig = overlayModules.normalizeModuleConfig("video", {
url: "https://example.com/alert.webm",
@ -143,6 +144,7 @@ try {
const chatConfig = overlayModules.normalizeModuleConfig("chat", {
platforms: ["twitch", "discord", "unsupported"],
channels: "twitch:cozycarnage\ndiscord:community-chat",
blockedUsers: "twitch:hidden_viewer|viewer-2\nlumi:linked-user|lumi-user-2",
maxMessages: 100,
messageTimeoutSeconds: -1,
background: "#0000",
@ -153,6 +155,7 @@ try {
});
assert.deepStrictEqual(chatConfig.platforms, ["twitch", "discord"]);
assert.deepStrictEqual(chatConfig.channels, ["twitch:cozycarnage", "discord:community-chat"]);
assert.deepStrictEqual(chatConfig.blockedUsers, ["twitch:hidden_viewer|viewer-2", "lumi:linked-user|lumi-user-2"]);
assert.strictEqual(chatConfig.maxMessages, 50);
assert.strictEqual(chatConfig.messageTimeoutSeconds, 0);
assert.strictEqual(chatConfig.background, "#0000");
@ -200,12 +203,15 @@ try {
platform: "twitch",
text: "Hello chat",
channel: { id: "123", name: "#cozycarnage" },
author: { id: "viewer-1", name: "Viewer", color: "#abcdef", badges: ["Subscriber"] }
author: { id: "viewer-1", name: "Viewer", username: "viewer", color: "#abcdef", badges: ["Subscriber"], lumi: { id: "lumi-user-1", username: "linked-user" } }
});
assert.strictEqual(normalizedChat.text, "Hello chat");
assert.strictEqual(normalizedChat.author.badges[0].label, "Subscriber");
assert.strictEqual(overlayChat.messageMatchesConfig(normalizedChat, chatState.config), true);
assert.strictEqual(overlayChat.messageMatchesConfig({ ...normalizedChat, platform: "discord" }, chatState.config), false);
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
overlayChat.publishOverlayChatMessage(normalizedChat);
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
@ -269,23 +275,30 @@ try {
const renderView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-render.ejs"), "utf8");
const { OBS_BROWSER_DEFAULT_CSS } = require(path.join(root, "src", "services", "obs-browser-defaults.js"));
const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8");
const twitchBadgeScript = fs.readFileSync(path.join(root, "src", "services", "twitch-chat-assets.js"), "utf8");
const routeScript = fs.readFileSync(path.join(root, "src", "services", "overlay-routes.js"), "utf8");
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
assert(editorView.includes('data-module-fields="chat"') && editorView.includes('name="chat_custom_css"'));
assert(editorView.includes('name="chat_blocked_users"') && editorView.includes("lumi:internal_name|user-id"));
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website"));
assert(editorView.includes('name="force_transparent_background"') && editorView.includes("Keep the website background transparent"));
assert(editorView.includes("data-preview-background") && editorView.includes("data-source-revert"));
assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup"));
assert(editorView.includes("data-color-picker") && editorView.includes("overlay-transform-disclosure"));
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
assert(rendererScript.includes("/icons/badges/"));
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
assert(rendererScript.includes("values.renderUrl || values.url"));
@ -310,8 +323,10 @@ try {
assert.strictEqual(obsLayout.scaleX, 1);
assert.strictEqual(obsLayout.scaleY, 1);
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
assert(twitchBadgeScript.includes("api.twitch.tv/helix/chat/badges") && twitchBadgeScript.includes("image_url_2x"));
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
console.log("Overlay verification passed: CRUD, fixed-canvas preview parity, transparent OBS defaults, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions.");
} finally {

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.14";
const previousCoreVersion = "0.2.13";
const releaseVersion = "0.2.15";
const previousCoreVersion = "0.2.14";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.14, Lumi AI 0.8.5, and synchronized package metadata.");
console.log("Release metadata verification passed: core 0.2.15, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true },
{ version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true },
{ version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true },
{ version: "0.2.12", ref: "refs/tags/v0.2.12", rollback_safe: true },
@ -74,7 +75,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.14");
assert.equal(corrected.safe_target_version, "0.2.15");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -63,6 +63,12 @@ async function startBot({ commandRouter } = {}) {
avatarUrl = message.author.displayAvatarURL();
}
}
const profile = message.author.bot ? null : ensureUserForIdentity({
provider: "discord",
providerUserId: message.author.id,
displayName,
avatar: avatarUrl
});
publishOverlayChatMessage({
id: message.id,
platform: "discord",
@ -79,16 +85,11 @@ async function startBot({ commandRouter } = {}) {
username: message.author.username,
avatar: avatarUrl,
color: message.member?.displayHexColor === "#000000" ? null : message.member?.displayHexColor,
badges: []
badges: [],
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
}
});
if (message.author.bot) return;
const profile = ensureUserForIdentity({
provider: "discord",
providerUserId: message.author.id,
displayName,
avatar: avatarUrl
});
incrementMessages(profile.id);
if (commandRouter) {

View File

@ -67,7 +67,11 @@ function normalizeChatMessage(input = {}) {
username: cleanText(input.author?.username, 160),
avatar: cleanUrl(input.author?.avatar),
color: /^#[0-9a-f]{6}$/i.test(cleanText(input.author?.color, 7)) ? cleanText(input.author.color, 7) : null,
badges: (Array.isArray(input.author?.badges) ? input.author.badges : []).map(normalizeBadge).filter(Boolean).slice(0, 12)
badges: (Array.isArray(input.author?.badges) ? input.author.badges : []).map(normalizeBadge).filter(Boolean).slice(0, 12),
lumi: input.author?.lumi?.id ? {
id: cleanText(input.author.lumi.id, 160),
username: cleanText(input.author.lumi.username, 160)
} : null
}
};
}
@ -78,7 +82,7 @@ function normalizedChannel(value) {
function messageMatchesConfig(message, config = {}) {
const platforms = Array.isArray(config.platforms) ? config.platforms : ["twitch", "youtube", "discord"];
if (!platforms.includes(message.platform)) return false;
if (!platforms.includes(message.platform) || messageBlockedByConfig(message, config)) return false;
const filters = Array.isArray(config.channels) ? config.channels : [];
if (!filters.length) return true;
const aliases = [message.channel?.id, message.channel?.name, message.channel?.key].map(normalizedChannel).filter(Boolean);
@ -91,6 +95,28 @@ function messageMatchesConfig(message, config = {}) {
});
}
function messageBlockedByConfig(message, config = {}) {
const rules = Array.isArray(config.blockedUsers) ? config.blockedUsers : [];
return rules.some((entry) => {
const raw = String(entry || "").trim();
const separator = raw.indexOf(":");
if (separator < 1) return false;
const platform = raw.slice(0, separator).toLowerCase();
const values = raw.slice(separator + 1).split("|").map((value) => normalizedIdentity(value)).filter(Boolean);
const aliases = platform === "lumi"
? [message.author?.lumi?.id, message.author?.lumi?.username]
: platform === message.platform
? [message.author?.id, message.author?.username, message.author?.name]
: [];
const normalizedAliases = aliases.map(normalizedIdentity).filter(Boolean);
return values.some((value) => normalizedAliases.includes(value));
});
}
function normalizedIdentity(value) {
return String(value || "").trim().toLowerCase().replace(/^@/, "");
}
function overlayTargets(message) {
const rows = db.prepare(
`SELECT s.overlay_id, m.type, m.config_json
@ -135,6 +161,7 @@ function recentOverlayChatMessages(overlayId) {
}
module.exports = {
messageBlockedByConfig,
messageMatchesConfig,
normalizeChatMessage,
publishOverlayChatMessage,

View File

@ -79,7 +79,7 @@ function getOverlayModuleType(id) {
}
function listOverlayModuleTypes() {
return Array.from(moduleTypes.values()).map(({ id, label, description }) => ({ id, label, description }));
return Array.from(moduleTypes.values()).map(({ id, label, description, renderType }) => ({ id, label, description, renderType }));
}
function normalizeModuleConfig(typeId, input) {
@ -137,11 +137,16 @@ registerOverlayModuleType({
.map((entry) => text(entry, "", 256).trim())
.filter((entry, index, values) => entry && values.indexOf(entry) === index)
.slice(0, 50);
const blockedUsers = (Array.isArray(input.blockedUsers) ? input.blockedUsers : String(input.blockedUsers || "").split(/[\n,]+/))
.map((entry) => text(entry, "", 320).trim())
.filter((entry, index, values) => /^(?:twitch|youtube|discord|lumi):[^:]+$/i.test(entry) && values.indexOf(entry) === index)
.slice(0, 100);
if (input.platforms !== undefined && !platforms.length) throw new Error("Choose at least one chat service.");
return {
...commonConfig(input),
platforms: input.platforms === undefined ? ["twitch", "youtube", "discord"] : platforms,
channels,
blockedUsers,
maxMessages: Math.round(number(input.maxMessages, 8, 1, 50)),
messageTimeoutSeconds: number(input.messageTimeoutSeconds, 30, 0, 3600),
newestAt: ["top", "bottom"].includes(input.newestAt) ? input.newestAt : "bottom",
@ -208,6 +213,7 @@ registerOverlayModuleType({
cropLeft: number(input.cropLeft, 0, 0, 95),
zoom: number(input.zoom, 1, 0.1, 5),
injectPageCss: input.injectPageCss !== false && input.injectPageCss !== "false" && input.injectPageCss !== "off",
forceTransparentBackground: input.forceTransparentBackground !== false && input.forceTransparentBackground !== "false" && input.forceTransparentBackground !== "off",
customCss: obsBrowserSourceCss(text(input.customCss, "", 12000))
};
}

View File

@ -1,10 +1,11 @@
const crypto = require("crypto");
const { subscribeWebEvents } = require("./web-events");
const { createLogger } = require("./logger");
const {
OVERLAY_CAPABILITIES,
canAccessOverlay
} = require("./overlay-permissions");
const { getOverlayModuleType, listOverlayModuleTypes } = require("./overlay-modules");
const { getOverlayModuleType, listOverlayModuleTypes, normalizeModuleConfig } = require("./overlay-modules");
const {
addModule,
addScene,
@ -45,6 +46,17 @@ const {
const externalHealthCache = new Map();
const overlayWebLog = createLogger("core:overlay-web", { category: "integration" });
const overlayPreviewDrafts = new Map();
function createOverlayPreviewDraft({ overlayId, moduleId, config }) {
const now = Date.now();
for (const [ticket, draft] of overlayPreviewDrafts) {
if (draft.expiresAt <= now || (draft.overlayId === overlayId && draft.moduleId === moduleId)) overlayPreviewDrafts.delete(ticket);
}
const ticket = crypto.randomBytes(24).toString("base64url");
overlayPreviewDrafts.set(ticket, { overlayId, moduleId, config, expiresAt: now + 10 * 60 * 1000 });
return ticket;
}
async function probeExternalOverlay(url) {
const controller = new AbortController();
@ -149,7 +161,8 @@ function registerPublicOverlayRoutes(app) {
try {
const document = await loadInjectedOverlayDocument(source.config.url, source.config.customCss, {
userAgent: req.get("user-agent"),
acceptLanguage: req.get("accept-language")
acceptLanguage: req.get("accept-language"),
forceTransparentBackground: source.config.forceTransparentBackground !== false
});
return res.type("html").send(document.html);
} catch (error) {
@ -336,6 +349,7 @@ function moduleInput(body) {
showControls: booleanField(body, "show_controls"),
allowPointerEvents: booleanField(body, "allow_pointer_events"),
injectPageCss: booleanField(body, "inject_page_css"),
forceTransparentBackground: booleanField(body, "force_transparent_background"),
anchor: body.anchor,
horizontalAlign: body.horizontal_align,
verticalAlign: body.vertical_align,
@ -351,6 +365,7 @@ function moduleInput(body) {
zoom: body.zoom,
platforms: ["twitch", "youtube", "discord"].filter((platform) => booleanField(body, `chat_platform_${platform}`)),
channels: body.chat_channels,
blockedUsers: body.chat_blocked_users,
maxMessages: body.chat_max_messages,
messageTimeoutSeconds: body.chat_timeout_seconds,
newestAt: body.chat_newest_at,
@ -377,6 +392,25 @@ function baseUrl(req) {
}
function registerOverlayAdminRoutes(app) {
app.get("/admin/overlays/:id/modules/:moduleId/web-preview/:ticket", requireOverlayAccess(), requireModuleInOverlay, async (req, res) => {
browserSourceResponseHeaders(res);
const draft = overlayPreviewDrafts.get(req.params.ticket);
if (!draft || draft.expiresAt <= Date.now() || draft.overlayId !== req.params.id || draft.moduleId !== req.params.moduleId) {
overlayPreviewDrafts.delete(req.params.ticket);
return res.status(404).send(browserSourceErrorHtml("This unsaved website preview expired."));
}
try {
const document = await loadInjectedOverlayDocument(draft.config.url, draft.config.customCss, {
userAgent: req.get("user-agent"),
acceptLanguage: req.get("accept-language"),
forceTransparentBackground: draft.config.forceTransparentBackground !== false
});
return res.type("html").send(document.html);
} catch (error) {
return res.status(error?.code === "PRIVATE_TARGET" ? 422 : 502).type("html").send(browserSourceErrorHtml(error.message));
}
});
app.get("/admin/overlays", adminOnly, (req, res) => {
noStore(res);
res.render("admin-overlays", {
@ -475,6 +509,32 @@ function registerOverlayAdminRoutes(app) {
}
});
app.post("/api/admin/overlays/:id/modules/:moduleId/preview", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {
const input = moduleInput(req.body || {});
const config = normalizeModuleConfig(input.type, input.config);
const renderType = getOverlayModuleType(input.type)?.renderType || input.type;
if (renderType === "web" && config.url && config.injectPageCss !== false) {
const ticket = createOverlayPreviewDraft({ overlayId: req.params.id, moduleId: req.params.moduleId, config });
config.renderUrl = `/admin/overlays/${encodeURIComponent(req.params.id)}/modules/${encodeURIComponent(req.params.moduleId)}/web-preview/${ticket}`;
}
return res.json({
ok: true,
module: {
id: req.params.moduleId,
name: String(input.name || "Source").trim().slice(0, 120) || "Source",
type: input.type,
renderType,
enabled: input.enabled,
config
}
});
} catch (error) {
return res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/api/admin/overlays/:id/modules/:moduleId/refresh", requireOverlayAccess(), requireModuleInOverlay, (req, res) => {
noStore(res);
try {

View File

@ -97,16 +97,24 @@ function htmlAttribute(value) {
.replaceAll(">", "&gt;");
}
function safeStyleText(value) {
return obsBrowserSourceCss(value).replace(/<\/style/gi, "<\\/style");
const TRANSPARENT_WEBSITE_GUARD = "html, body, #root, #app, [data-reactroot] { background-color: rgba(0, 0, 0, 0) !important; }";
function websiteCss(value, forceTransparentBackground = true) {
const css = obsBrowserSourceCss(value);
return forceTransparentBackground ? `${TRANSPARENT_WEBSITE_GUARD}\n${css}` : css;
}
function loadEndCssScript(value) {
const encoded = JSON.stringify(obsBrowserSourceCss(value)).replaceAll("<", "\\u003c");
return `<script id="lumi-obs-browser-css-loader">(()=>{const css=${encoded};const apply=()=>{document.getElementById("lumi-obs-browser-css")?.remove();const style=document.createElement("style");style.id="lumi-obs-browser-css";style.appendChild(document.createTextNode(css));(document.head||document.documentElement).appendChild(style)};document.readyState==="complete"?apply():window.addEventListener("load",apply,{once:true})})()</script>`;
function safeStyleText(value, forceTransparentBackground) {
return websiteCss(value, forceTransparentBackground).replace(/<\/style/gi, "<\\/style");
}
function injectDocumentCss(html, upstreamUrl, customCss) {
function loadEndCssScript(value, forceTransparentBackground) {
const encoded = JSON.stringify(websiteCss(value, forceTransparentBackground)).replaceAll("<", "\\u003c");
const force = forceTransparentBackground !== false ? "true" : "false";
return `<script id="lumi-obs-browser-css-loader">(()=>{const css=${encoded},force=${force};let queued=false;const apply=()=>{queued=false;let style=document.getElementById("lumi-obs-browser-css");if(!style){style=document.createElement("style");style.id="lumi-obs-browser-css";style.appendChild(document.createTextNode(css))}const target=document.head||document.documentElement;if(style.parentNode!==target||style!==target.lastElementChild)target.appendChild(style);if(force)for(const node of document.querySelectorAll("html,body,#root,#app,[data-reactroot]"))if(node.style.getPropertyValue("background-color")!=="rgba(0, 0, 0, 0)"||node.style.getPropertyPriority("background-color")!=="important")node.style.setProperty("background-color","rgba(0, 0, 0, 0)","important")};const schedule=()=>{if(!queued){queued=true;requestAnimationFrame(apply)}};document.readyState==="complete"?apply():window.addEventListener("load",apply,{once:true});new MutationObserver(schedule).observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:["class","style"]})})()</script>`;
}
function injectDocumentCss(html, upstreamUrl, customCss, options = {}) {
let document = String(html || "");
const existingBase = document.match(/<base\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/i)?.[1];
const documentBase = existingBase ? new URL(existingBase, upstreamUrl).toString() : upstreamUrl;
@ -114,8 +122,9 @@ function injectDocumentCss(html, upstreamUrl, customCss) {
.replace(/<base\b[^>]*>/gi, "")
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, "");
const headStart = `<base href="${htmlAttribute(documentBase)}"><meta name="referrer" content="no-referrer">`;
const style = `<style id="lumi-obs-browser-css">${safeStyleText(customCss)}</style>`;
const loadEndScript = loadEndCssScript(customCss);
const forceTransparentBackground = options.forceTransparentBackground !== false;
const style = `<style id="lumi-obs-browser-css">${safeStyleText(customCss, forceTransparentBackground)}</style>`;
const loadEndScript = loadEndCssScript(customCss, forceTransparentBackground);
if (/<head\b[^>]*>/i.test(document)) {
document = document.replace(/<head\b[^>]*>/i, (match) => `${match}${headStart}`);
document = /<\/head\s*>/i.test(document)
@ -151,7 +160,7 @@ async function loadInjectedOverlayDocument(url, customCss, options = {}) {
const html = await boundedText(response, Number(options.maxBytes) || MAX_DOCUMENT_BYTES);
if (!html.trim()) throw new Error("The website returned an empty document.");
return {
html: injectDocumentCss(html, response.url || url, customCss),
html: injectDocumentCss(html, response.url || url, customCss, options),
upstreamUrl: response.url || url
};
} finally {
@ -164,5 +173,6 @@ module.exports = {
injectDocumentCss,
isPrivateAddress,
isPrivateTarget,
loadInjectedOverlayDocument
loadInjectedOverlayDocument,
TRANSPARENT_WEBSITE_GUARD
};

View File

@ -0,0 +1,71 @@
const { getSetting } = require("./settings");
const badgeCatalogs = new Map();
const CACHE_MS = 30 * 60 * 1000;
function fallbackBadges(badges = {}) {
return Object.entries(badges).map(([label]) => ({ label, image: null }));
}
async function badgeRequest(path) {
const clientId = String(getSetting("twitch_client_id", "") || "").trim();
const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, "");
if (!clientId || !token) throw new Error("Twitch badge credentials are unavailable.");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(`https://api.twitch.tv/helix/chat/badges${path}`, {
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` },
signal: controller.signal
});
if (!response.ok) throw new Error(`Twitch badge request returned HTTP ${response.status}.`);
return response.json();
} finally {
clearTimeout(timeout);
}
}
function indexCatalog(payload) {
const catalog = new Map();
for (const set of payload?.data || []) {
for (const version of set.versions || []) {
catalog.set(`${set.set_id}/${version.id}`, {
label: version.title || set.set_id,
image: version.image_url_2x || version.image_url_1x || version.image_url_4x || null
});
}
}
return catalog;
}
async function loadCatalog(roomId) {
const key = String(roomId || "global");
const cached = badgeCatalogs.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.promise;
const promise = Promise.allSettled([
badgeRequest("/global"),
roomId ? badgeRequest(`?broadcaster_id=${encodeURIComponent(roomId)}`) : Promise.resolve({ data: [] })
]).then((results) => {
const catalog = new Map();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const [id, badge] of indexCatalog(result.value)) catalog.set(id, badge);
}
return catalog;
});
badgeCatalogs.set(key, { expiresAt: Date.now() + CACHE_MS, promise });
return promise;
}
async function resolveTwitchBadges(roomId, badges = {}) {
const entries = Object.entries(badges || {});
if (!entries.length) return [];
try {
const catalog = await loadCatalog(roomId);
return entries.map(([setId, version]) => catalog.get(`${setId}/${version}`) || { label: setId, image: null });
} catch {
return fallbackBadges(badges);
}
}
module.exports = { resolveTwitchBadges };

View File

@ -4,6 +4,7 @@ const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { resolveTwitchBadges } = require("./twitch-chat-assets");
const twitchLog = createLogger("platform:twitch", { category: "integration" });
@ -40,11 +41,17 @@ async function startTwitchBot({ commandRouter } = {}) {
});
client.on("message", async (channel, tags, message, self) => {
const userId = tags["user-id"];
const userId = tags["user-id"] || (self ? `lumi:${username.toLowerCase()}` : "");
if (!userId) {
return;
}
const displayName = tags["display-name"] || tags.username;
const displayName = tags["display-name"] || tags.username || username;
const profile = self ? null : ensureUserForIdentity({
provider: "twitch",
providerUserId: userId,
displayName
});
const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {});
publishOverlayChatMessage({
id: tags.id,
platform: "twitch",
@ -56,7 +63,8 @@ async function startTwitchBot({ commandRouter } = {}) {
name: displayName,
username: tags.username,
color: tags.color,
badges: Object.keys(tags.badges || {})
badges,
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
},
emotes: Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => {
const [start, end] = String(position).split("-").map(Number);
@ -64,11 +72,6 @@ async function startTwitchBot({ commandRouter } = {}) {
}))
});
if (self) return;
const profile = ensureUserForIdentity({
provider: "twitch",
providerUserId: userId,
displayName
});
incrementMessages(profile.id);
if (commandRouter) {

View File

@ -123,6 +123,13 @@ async function handleChatItem(state, liveChatId, item) {
}
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
const profile = isSelf ? null : ensureUserForIdentity({
provider: "youtube",
providerUserId: author.channelId,
displayName,
avatar
});
publishOverlayChatMessage({
id: item.id,
platform: "youtube",
@ -138,16 +145,11 @@ async function handleChatItem(state, liveChatId, item) {
name: displayName,
username: displayName,
avatar,
badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean)
badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean),
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
}
});
if (state.channelId && author.channelId === state.channelId) return;
const profile = ensureUserForIdentity({
provider: "youtube",
providerUserId: author.channelId,
displayName,
avatar
});
if (isSelf) return;
incrementMessages(profile.id);
if (!state.commandRouter) {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#64748b"/><path fill="#fff" d="m16 4 3.4 7 7.6 1.1-5.5 5.3 1.3 7.6-6.8-3.6L9.2 25l1.3-7.6L5 12.1l7.6-1.1L16 4Z"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ec4899"/><path fill="#fff" d="M16 26S6 20.4 6 12.8C6 8.7 11.2 6.4 16 11c4.8-4.6 10-2.3 10 1.8C26 20.4 16 26 16 26Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#22c55e"/><path fill="#fff" d="M16 4 27 8v7c0 7-4.6 11.2-11 13-6.4-1.8-11-6-11-13V8l11-4Zm-2 16.2 8-8-2.2-2.2-5.8 5.8-2.8-2.8L9 15.2l5 5Z"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#f59e0b"/><path fill="#fff" d="m6 10 6 5 4-8 4 8 6-5-2 14H8L6 10Zm3 16h14v2H9v-2Z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#5865f2"/><path fill="#fff" d="M10 9.5c4-2 8-2 12 0 2.2 3 3.3 6.2 3.4 9.5-2 2.5-4.2 3.8-6.5 4.4l-1.2-1.7c.8-.3 1.5-.7 2.2-1.2-2.6 1.2-5.2 1.2-7.8 0 .7.5 1.4.9 2.2 1.2l-1.2 1.7C10.8 22.8 8.6 21.5 6.6 19 6.7 15.7 7.8 12.5 10 9.5Zm3 8.5c1 0 1.8-1 1.8-2.2S14 13.6 13 13.6s-1.8 1-1.8 2.2S12 18 13 18Zm6 0c1 0 1.8-1 1.8-2.2s-.8-2.2-1.8-2.2-1.8 1-1.8 2.2S18 18 19 18Z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4fb6c2"/><path fill="#fff" d="M16 4.5 18.7 13l8.8 3-8.8 3L16 27.5 13.3 19l-8.8-3 8.8-3L16 4.5Z"/></svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#9146ff"/><path fill="#fff" d="M7 5h20v14l-6 6h-5l-3 3v-3H7V5Zm3 3v14h5v3l3-3h4l3-3V8H10Zm5 3h3v7h-3v-7Zm6 0h3v7h-3v-7Z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ff0033"/><path fill="#fff" d="M13 10.5 22 16l-9 5.5v-11Z"/></svg>

After

Width:  |  Height:  |  Size: 169 B

View File

@ -387,6 +387,28 @@
margin-top: var(--lumi-space-1);
}
.overlay-preview-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-2);
margin-bottom: var(--lumi-space-2);
color: var(--lumi-text-muted);
font-size: 0.82rem;
}
.overlay-preview-tools .button {
min-height: 2rem;
padding-block: 0.3rem;
font-size: 0.78rem;
}
.overlay-preview-tools [aria-pressed="true"] {
border-color: var(--lumi-primary);
background: color-mix(in srgb, var(--lumi-primary) 20%, var(--lumi-surface));
}
.overlay-canvas-frame {
width: 100%;
aspect-ratio: var(--overlay-aspect, 16 / 9);
@ -404,6 +426,44 @@
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.overlay-canvas-frame.preview-bg-dark {
background: #070b12;
}
.overlay-canvas-frame.preview-bg-light {
background: #f4f7fb;
}
.overlay-canvas-frame.show-grid .overlay-canvas::after {
content: "";
position: absolute;
z-index: 900;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgba(56,189,248,.28) 1px, transparent 1px),
linear-gradient(to bottom, rgba(56,189,248,.28) 1px, transparent 1px),
linear-gradient(to right, rgba(56,189,248,.6) 1px, transparent 1px),
linear-gradient(to bottom, rgba(56,189,248,.6) 1px, transparent 1px);
background-size: 5% 5%, 5% 5%, 50% 50%, 50% 50%;
}
.overlay-source-card.is-dirty {
border-color: color-mix(in srgb, var(--lumi-accent, var(--lumi-primary)) 70%, var(--lumi-border));
box-shadow: inset 3px 0 color-mix(in srgb, var(--lumi-accent, var(--lumi-primary)) 75%, transparent);
}
.overlay-source-save-row {
position: sticky;
z-index: 10;
bottom: 0;
margin: 0 calc(-1 * var(--lumi-space-3)) calc(-1 * var(--lumi-space-3));
padding: var(--lumi-space-3);
border-top: 1px solid var(--lumi-border);
background: color-mix(in srgb, var(--lumi-surface) 94%, transparent);
backdrop-filter: blur(10px);
}
.overlay-canvas {
position: relative;
width: 100%;

View File

@ -114,18 +114,20 @@
sync();
});
document.querySelectorAll("[data-module-editor]").forEach((form) => {
function syncModuleFields(form) {
const select = form.querySelector("[data-module-type-select]");
if (!select) return;
const sync = () => {
form.querySelectorAll("[data-module-fields]").forEach((group) => {
const visible = group.dataset.moduleFields === select.value;
group.hidden = !visible;
group.querySelectorAll("input, select, textarea, button").forEach((field) => { field.disabled = !visible; });
});
};
select.addEventListener("change", sync);
sync();
form.querySelectorAll("[data-module-fields]").forEach((group) => {
const visible = group.dataset.moduleFields === select.value;
group.hidden = !visible;
group.querySelectorAll("input, select, textarea, button").forEach((field) => { field.disabled = !visible; });
});
}
document.querySelectorAll("[data-module-editor]").forEach((form) => {
const select = form.querySelector("[data-module-type-select]");
select?.addEventListener("change", () => syncModuleFields(form));
syncModuleFields(form);
});
const editorRoot = document.querySelector("[data-overlay-editor]");
@ -135,6 +137,7 @@
if (editorRoot && editorData && renderer) {
const state = JSON.parse(editorData.textContent || "{}");
const savedModules = new Map(state.scenes.flatMap((scene) => scene.modules).map((module) => [module.id, JSON.parse(JSON.stringify(module))]));
const canvas = editorRoot.querySelector("[data-overlay-canvas]");
const layer = editorRoot.querySelector("[data-overlay-canvas-layer]");
const sceneSelect = editorRoot.querySelector("[data-editor-scene]");
@ -154,6 +157,7 @@
let selectedId = null;
let drag = null;
let elements = new Map();
const previewRequests = new Map();
const currentScene = () => state.scenes.find((scene) => scene.id === sceneSelect.value) || state.scenes[0];
const selectedModule = () => currentScene()?.modules.find((module) => module.id === selectedId) || null;
@ -202,6 +206,73 @@
}
}
function formBody(form) {
const body = {};
for (const [key, value] of new FormData(form).entries()) body[key] = value;
return body;
}
function setPreviewStatus(form, message, stateName = "") {
const status = form.querySelector("[data-source-preview-status]");
if (status) status.textContent = message;
form.closest("[data-module-card]")?.classList.toggle("is-dirty", stateName === "dirty");
if (stateName === "dirty") saveState.textContent = "Unsaved preview";
}
function moduleForId(moduleId) {
return state.scenes.flatMap((scene) => scene.modules).find((module) => module.id === moduleId) || null;
}
function previewForm(form) {
const moduleId = form.dataset.moduleId;
const module = moduleForId(moduleId);
if (!module) return;
clearTimeout(previewRequests.get(moduleId)?.timer);
previewRequests.get(moduleId)?.controller?.abort();
setPreviewStatus(form, "Previewing unsaved changes…", "dirty");
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const payload = await jsonRequest(`/api/admin/overlays/${state.overlayId}/modules/${moduleId}/preview`, {
method: "POST",
body: JSON.stringify(formBody(form)),
signal: controller.signal
});
Object.assign(module, payload.module);
selectedId = moduleId;
renderCanvas();
setPreviewStatus(form, "Live preview · not saved", "dirty");
} catch (error) {
if (error.name !== "AbortError") setPreviewStatus(form, `Preview problem: ${error.message}`, "dirty");
}
}, 280);
previewRequests.set(moduleId, { controller, timer });
}
document.querySelectorAll("[data-module-editor][data-module-id]").forEach((form) => {
const update = () => previewForm(form);
form.addEventListener("input", update);
form.addEventListener("change", update);
form.addEventListener("submit", () => {
setPreviewStatus(form, "Saving…");
saveState.textContent = "Saving…";
});
form.querySelector("[data-source-revert]")?.addEventListener("click", () => {
const saved = savedModules.get(form.dataset.moduleId);
const module = moduleForId(form.dataset.moduleId);
if (!saved || !module) return;
previewRequests.get(module.id)?.controller?.abort();
clearTimeout(previewRequests.get(module.id)?.timer);
form.reset();
syncModuleFields(form);
Object.assign(module, JSON.parse(JSON.stringify(saved)));
selectedId = module.id;
renderCanvas();
setPreviewStatus(form, "Saved settings restored");
saveState.textContent = "Ready";
});
});
function selectSource(moduleId, { openSettings = false } = {}) {
selectedId = moduleId;
layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId));
@ -389,6 +460,18 @@
editorRoot.querySelector("[data-editor-center]")?.addEventListener("click", () => transformSelected({ x: 50, y: 50, anchor: "center" }));
editorRoot.querySelector("[data-editor-fit]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 100, height: 100, anchor: "top-left" }));
editorRoot.querySelector("[data-editor-reset]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 50, height: 25, anchor: "top-left" }));
const previewFrame = editorRoot.querySelector("[data-preview-frame]");
editorRoot.querySelectorAll("[data-preview-background]").forEach((button) => button.addEventListener("click", () => {
const background = button.dataset.previewBackground;
previewFrame?.classList.toggle("preview-bg-dark", background === "dark");
previewFrame?.classList.toggle("preview-bg-light", background === "light");
button.parentElement?.querySelectorAll("[data-preview-background]").forEach((item) => item.setAttribute("aria-pressed", item === button ? "true" : "false"));
}));
editorRoot.querySelector("[data-preview-grid]")?.addEventListener("click", (event) => {
const active = !previewFrame?.classList.contains("show-grid");
previewFrame?.classList.toggle("show-grid", active);
event.currentTarget.setAttribute("aria-pressed", active ? "true" : "false");
});
snapToggle.addEventListener("change", () => { snapHint.textContent = snapToggle.checked ? "off" : "on"; });
sceneSelect.addEventListener("change", () => { selectedId = null; renderCanvas(); });
window.addEventListener("resize", () => renderCanvas());

View File

@ -132,7 +132,7 @@
}
function chatAccepts(values, payload) {
if (!(values.platforms || []).includes(payload.platform)) return false;
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]
@ -147,6 +147,27 @@
});
}
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}`;
@ -168,9 +189,11 @@
const meta = document.createElement("div");
meta.className = "meta";
if (values.showPlatform) {
const platform = document.createElement("span");
const platform = document.createElement("img");
platform.className = "platform";
platform.textContent = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1);
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");
@ -180,14 +203,18 @@
meta.appendChild(author);
if (values.showBadges) {
for (const badgeData of payload.author?.badges || []) {
const badge = document.createElement(badgeData.image ? "img" : "span");
const badge = document.createElement("img");
badge.className = "badge";
if (badgeData.image) {
badge.src = badgeData.image;
badge.alt = badgeData.label || "Badge";
} else {
badge.textContent = badgeData.label;
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);
}
}
@ -259,12 +286,8 @@
.content { min-width:0; flex:1; }
.meta { display:flex; align-items:center; justify-content:${values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"}; 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; padding:.12em .38em; border-radius:.3em; background:rgba(255,255,255,.14); font-size:.72em; text-transform:uppercase; letter-spacing:.035em; }
.platform-twitch .platform { background:#9146ff; color:#fff; }
.platform-youtube .platform { background:#ff0033; color:#fff; }
.platform-discord .platform { background:#5865f2; color:#fff; }
.badge { flex:0 0 auto; font-size:.68em; line-height:1; padding:.18em .32em; border-radius:.25em; background:rgba(255,255,255,.14); }
img.badge { width:1.1em; height:1.1em; padding:0; object-fit:contain; background:transparent; }
.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; margin-left: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; }

View File

@ -97,7 +97,7 @@
<summary><span><strong><%= module.name %></strong><small><%= moduleTypes.find((item) => item.id === module.type)?.label || module.type %> · <%= module.enabled ? "Visible" : "Hidden" %></small></span></summary>
<form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>" class="form-grid overlay-source-form" data-module-editor data-module-id="<%= module.id %>">
<div class="field"><label>Name</label><input name="name" value="<%= module.name %>" required /></div>
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>" <%= type.id === module.type ? "selected" : "" %>><%= type.label %></option><% }) %></select></div>
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>" data-render-type="<%= type.renderType || type.id %>" <%= type.id === module.type ? "selected" : "" %>><%= type.label %></option><% }) %></select></div>
<div class="field"><label>Visible</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= module.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= module.enabled ? "Shown" : "Hidden" %></span></label></div>
<div class="field"><label>Anchor point <button type="button" class="overlay-help" data-tooltip="The anchor is the point held in place when the source is positioned or resized.">?</button></label><select name="anchor"><% anchors.forEach(([id, label]) => { %><option value="<%= id %>" <%= value(config, "anchor", "top-left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></div>
@ -118,6 +118,7 @@
<% [["twitch","Twitch"],["youtube","YouTube"],["discord","Discord"]].forEach(([id,label]) => { %><label><input type="checkbox" name="chat_platform_<%= id %>" <%= (value(config,"platforms",["twitch","youtube","discord"])).includes(id) ? "checked" : "" %> /> <%= label %></label><% }) %>
</div></fieldset>
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="3" placeholder="twitch:cozycarnage&#10;discord:community-chat"><%= (value(config,"channels",[])).join("\n") %></textarea><small class="hint">Leave empty to include every channel Lumi receives from the selected services. Use one channel name or ID per line; add the service name when needed, such as <code>twitch:channel</code>.</small></label>
<label class="full">Hidden users (optional)<textarea name="chat_blocked_users" rows="3" placeholder="twitch:some_user|123456&#10;lumi:internal_name|user-id"><%= (value(config,"blockedUsers",[])).join("\n") %></textarea><small class="hint">One rule per line. Use <code>platform:username|user ID</code>. Use <code>lumi:</code> with an internal username or Lumi user ID to hide every linked account belonging to that person.</small></label>
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -136,7 +137,7 @@
<fieldset class="overlay-fieldset full"><legend>Message details</legend><div class="overlay-check-grid">
<label><input type="checkbox" name="chat_show_avatars" <%= value(config,"showAvatars",true) ? "checked" : "" %> /> Avatars</label>
<label><input type="checkbox" name="chat_show_badges" <%= value(config,"showBadges",true) ? "checked" : "" %> /> Badges</label>
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service name</label>
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label>
<label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
</div></fieldset>
<label>New-message animation<select name="chat_in_animation"><% [["slide-up","Slide up"],["slide-left","Slide in"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -175,6 +176,7 @@
<div class="field full overlay-conditional-fields" data-module-fields="web">
<label>Overlay website address <button type="button" class="overlay-help" data-tooltip="Paste a StreamElements alert link or another browser-overlay URL. Lumi keeps it isolated from your admin login.">?</button><input name="url" value="<%= module.type === "web" ? value(config, "url") : "" %>" placeholder="https://streamelements.com/overlay/..." /></label>
<div class="overlay-switch-field"><span class="overlay-field-label">Website CSS</span><label class="switch"><input class="switch-input" type="checkbox" name="inject_page_css" <%= value(config,"injectPageCss",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Apply CSS inside the website</span></label><p class="hint">Recommended. Turn this off only if a website will not load through CSS injection.</p></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Transparent page</span><label class="switch"><input class="switch-input" type="checkbox" name="force_transparent_background" <%= value(config,"forceTransparentBackground",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Keep the website background transparent</span></label><p class="hint">Recommended for alerts. Lumi reapplies transparency if the website changes its page after loading.</p></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Automatic recovery</span><label class="switch"><input class="switch-input" type="checkbox" name="auto_refresh" <%= value(config,"autoRefresh",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Refresh stale or broken content</span></label></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Check website replies</span><label class="switch"><input class="switch-input" type="checkbox" name="health_check" <%= value(config,"healthCheck",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Check for errors</span></label></div>
<label>Refresh every<select name="refresh_interval_seconds"><% [[60,"1 minute"],[300,"5 minutes"],[900,"15 minutes"],[1800,"30 minutes"],[3600,"1 hour"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"refreshIntervalSeconds",300)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -194,7 +196,7 @@
<label>X % <input type="number" step="0.1" name="x" value="<%= value(config, "x", 0) %>" /></label><label>Y % <input type="number" step="0.1" name="y" value="<%= value(config, "y", 0) %>" /></label><label>Width % <input type="number" step="0.1" name="width" value="<%= value(config, "width", 100) %>" /></label><label>Height % <input type="number" step="0.1" name="height" value="<%= value(config, "height", 100) %>" /></label><label>Opacity <input type="number" min="0" max="1" step="0.05" name="opacity" value="<%= value(config, "opacity", 1) %>" /></label>
</fieldset>
</details>
<div class="form-actions"><button class="button" type="submit">Save source</button></div>
<div class="form-actions overlay-source-save-row"><button class="button" type="submit" data-source-save>Save source</button><button class="button subtle" type="button" data-source-revert>Undo unsaved changes</button><span class="hint" data-source-preview-status>Saved settings</span></div>
</form>
<div class="overlay-module-actions"><button type="button" class="button subtle" data-select-source="<%= module.id %>">Select on canvas</button><form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy source</button></form><button type="button" class="button danger" data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete source" data-confirm-text="Delete this source?" data-confirm-label="Delete source">Delete source</button></div>
</details>
@ -213,12 +215,13 @@
<p class="hint full">Lumi combines live messages from the services you select. You can adjust the appearance, animations, timeout, and CSS after adding it.</p>
<fieldset class="overlay-fieldset full"><legend>Chats to include</legend><div class="overlay-check-grid"><label><input type="checkbox" name="chat_platform_twitch" checked /> Twitch</label><label><input type="checkbox" name="chat_platform_youtube" checked /> YouTube</label><label><input type="checkbox" name="chat_platform_discord" checked /> Discord</label></div></fieldset>
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="2" placeholder="Leave empty for all connected channels"></textarea></label>
<label class="full">Hidden users (optional)<textarea name="chat_blocked_users" rows="2" placeholder="twitch:username|user-id&#10;lumi:internal-name|user-id"></textarea></label>
<input type="hidden" name="chat_show_avatars" value="on" /><input type="hidden" name="chat_show_badges" value="on" /><input type="hidden" name="chat_show_platform" value="on" />
</div>
<div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label><input type="checkbox" name="force_transparent_background" checked /> Keep the website background transparent</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div>
<div class="form-actions"><button class="button" type="submit">Add source</button></div>
</form>
</details>
@ -248,10 +251,11 @@
<aside class="overlay-preview-panel card" data-overlay-editor>
<div class="section-header"><div><span class="eyebrow">Live editor</span><h2>Scene preview</h2></div><span class="badge" data-editor-save-state>Ready</span></div>
<div class="overlay-editor-toolbar"><label>Editing <select data-editor-scene><% overlay.scenes.forEach((scene) => { %><option value="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "selected" : "" %>><%= scene.name %></option><% }) %></select></label><label class="compact-field"><input type="checkbox" data-editor-snap checked /> Snap</label></div>
<div class="overlay-canvas-frame" style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>"><div class="overlay-canvas" data-overlay-canvas tabindex="0" aria-label="Visual overlay editor"><div class="overlay-canvas-layer" data-overlay-canvas-layer></div><div class="overlay-snap-guide guide-x" data-guide-x></div><div class="overlay-snap-guide guide-y" data-guide-y></div></div></div>
<div class="overlay-preview-tools" aria-label="Preview background"><span>Check contrast</span><div class="button-group"><button type="button" class="button subtle" data-preview-background="transparent" aria-pressed="true">Transparency</button><button type="button" class="button subtle" data-preview-background="dark" aria-pressed="false">Dark</button><button type="button" class="button subtle" data-preview-background="light" aria-pressed="false">Light</button><button type="button" class="button subtle" data-preview-grid aria-pressed="false">Grid</button></div></div>
<div class="overlay-canvas-frame" data-preview-frame style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>"><div class="overlay-canvas" data-overlay-canvas tabindex="0" aria-label="Visual overlay editor"><div class="overlay-canvas-layer" data-overlay-canvas-layer></div><div class="overlay-snap-guide guide-x" data-guide-x></div><div class="overlay-snap-guide guide-y" data-guide-y></div></div></div>
<div class="overlay-editor-readout"><span data-selected-name>No source selected</span><span data-selected-position></span></div>
<div class="overlay-editor-actions"><button type="button" class="button subtle" data-editor-center>Center</button><button type="button" class="button subtle" data-editor-fit>Fit to canvas</button><button type="button" class="button subtle" data-editor-reset>Reset box</button></div>
<p class="hint">Rendered at <strong><%= overlay.canvas_width || 1920 %> × <%= overlay.canvas_height || 1080 %></strong>, then scaled down to fit here. Drag a source to move it. Drag its red handles to resize. Arrow keys nudge it; Shift moves farther. Hold Ctrl or Cmd to temporarily turn snapping <span data-snap-hint>off</span>.</p>
<p class="hint">This is the same <strong><%= overlay.canvas_width || 1920 %> × <%= overlay.canvas_height || 1080 %></strong> canvas OBS receives. Source settings preview here while you type; use <strong>Save source</strong> only when you want to keep them. Drag a source to move it, use the red handles to resize it, or use arrow keys for precise nudging. Hold Ctrl or Cmd to temporarily turn snapping <span data-snap-hint>off</span>.</p>
<div class="overlay-canvas-source-list" data-canvas-source-list></div>
</aside>
</div>

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.14",
"version": "0.2.15",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds the native multi-platform Lumi chat overlay source and preserves existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.",
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -193,6 +193,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds the native multi-platform Lumi chat overlay source; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
},
{
"version": "0.2.15",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
}
]
}