diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f31d5..439337d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Lumi changelog +## 0.2.14 + +- Added a native Lumi chat overlay source that combines live Twitch, YouTube, and Discord messages with optional per-channel filters. +- Added visual controls for typography, colors and transparency, placement, message limits, timeout removal, message details, and entry/exit animations, plus isolated custom CSS. +- Added live avatars, badges, Twitch emotes, editor preview messages, and bounded replay after temporary Browser Source connection loss. + ## 0.2.13 - Changed external overlay Website CSS from frame-only styling to document-level injection, matching OBS Browser Source Custom CSS behavior. diff --git a/README.md b/README.md index 9c7c8a4..7a1e4a3 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ Developer and modding conventions are documented in Admins can create live, token-protected OBS Browser Sources under **Admin → OBS overlays**, with server-controlled or fixed scenes and optional local OBS -WebSocket or zero-credential OBS Browser Bridge synchronization. See +WebSocket or zero-credential OBS Browser Bridge synchronization. Native sources +include a configurable combined Twitch, YouTube, and Discord chat overlay. See [`docs/obs-overlays.md`](docs/obs-overlays.md). ## Notes diff --git a/TODO.md b/TODO.md index 425fff5..6efac2c 100644 --- a/TODO.md +++ b/TODO.md @@ -25,7 +25,10 @@ defaults, and explicit Browser Source dimensions so responsive third-party alert render consistently in the preview and OBS. Website CSS is injected into the third-party document itself through scoped revocable render tickets, matching OBS Custom CSS behavior while keeping the page isolated from Lumi sessions and local -network targets. +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. Remaining work: @@ -683,6 +686,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done +- 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. - 2026-07-18: Fixed OBS overlay transparency and preview fidelity by applying OBS's exact default Browser Source CSS, rendering the editor and clean output from the same full-resolution canvas coordinate system, keeping iframe surfaces transparent, and showing the required OBS width/height plus copyable CSS in setup. diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index df9a4e2..d61c3a5 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -56,14 +56,38 @@ order, enable, activate, and delete scenes. Current built-in source types are: - Image URL - Video / media URL - Audio URL +- Lumi chat - Website or alert overlay Module instances can be created, edited, duplicated, ordered, enabled, and deleted. New module types can call `registerOverlayModuleType(...)` in `src/services/overlay-modules.js`; providers normalize their configuration and -select the safe built-in `text`, `image`, `video`, `audio`, or `web` render type while the overlay +select the safe built-in `text`, `image`, `video`, `audio`, `chat`, or `web` render type while the overlay service retains the same instance model. +### Lumi chat + +The built-in **Lumi chat** source combines messages already received by Lumi +from Twitch, YouTube, and Discord. Administrators can select any combination of +those services and optionally filter by channel name or ID. Channel filters +accept one value per line and may be prefixed with the service, such as +`twitch:cozycarnage` or `discord:community-chat`. + +Chat sources support transparent source and message backgrounds, font family, +size, weight and alignment, name/text colors, avatars, badges, platform labels, +timestamps, message spacing, rounded corners, maximum visible messages, +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. + +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`. +Messages arrive through the same token-scoped overlay event connection as scene +updates. Lumi keeps a small, short-lived in-memory replay window so a temporary +Browser Source reconnection can restore recent messages without writing chat +content to the database. + ### Video and audio Video sources support browser-playable media such as WebM and MP4; audio sources diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 6b5b156..b388eeb 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.13 +Version: 0.2.14 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 098a4d2..1079ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.13", + "version": "0.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.13", + "version": "0.2.14", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 83abddb..a86d422 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.13", + "version": "0.2.14", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 2d55975..3bde73b 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.14", + "ref": "refs/tags/v0.2.14", + "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": "Adds the native multi-platform Lumi chat overlay source with scoped live events and short-lived in-memory reconnect replay. 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.13", "ref": "refs/tags/v0.2.13", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index b4104ea..4fad93e 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -18,7 +18,8 @@ for (const file of [ "overlay-modules.js", "overlays.js", "overlay-permissions.js", - "overlay-connectors.js" + "overlay-connectors.js", + "overlay-chat.js" ]) { fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file)); } @@ -62,6 +63,7 @@ try { const sourceTypes = overlayModules.listOverlayModuleTypes(); assert(sourceTypes.some((type) => type.id === "video")); assert(sourceTypes.some((type) => type.id === "audio")); + assert(sourceTypes.some((type) => type.id === "chat")); const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" }); assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene"); @@ -138,6 +140,25 @@ try { assert.strictEqual(audioConfig.playBehavior, "manual"); assert.strictEqual(audioConfig.showControls, true); assert.strictEqual(audioConfig.volume, 1); + const chatConfig = overlayModules.normalizeModuleConfig("chat", { + platforms: ["twitch", "discord", "unsupported"], + channels: "twitch:cozycarnage\ndiscord:community-chat", + maxMessages: 100, + messageTimeoutSeconds: -1, + background: "#0000", + messageBackground: "#0f172bcc", + inAnimation: "scale", + outAnimation: "slide-down", + customCss: ".message { border: 1px solid white; }" + }); + assert.deepStrictEqual(chatConfig.platforms, ["twitch", "discord"]); + assert.deepStrictEqual(chatConfig.channels, ["twitch:cozycarnage", "discord:community-chat"]); + assert.strictEqual(chatConfig.maxMessages, 50); + assert.strictEqual(chatConfig.messageTimeoutSeconds, 0); + assert.strictEqual(chatConfig.background, "#0000"); + assert.strictEqual(chatConfig.inAnimation, "scale"); + assert(chatConfig.customCss.includes(".message")); + assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]); const styledWebConfig = overlayModules.normalizeModuleConfig("web", { url: "https://example.com/widget", cropLeft: 12.5, @@ -164,6 +185,29 @@ try { enabled: true, config: { url: "https://alerts.example/widget" } }); + const chatId = overlays.addModule(created.scenes[0].id, { + name: "Combined chat", + type: "chat", + enabled: true, + config: { platforms: ["twitch", "youtube"], maxMessages: 6 } + }); + const chatState = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === chatId); + assert.strictEqual(chatState.renderType, "chat"); + assert.deepStrictEqual(chatState.config.platforms, ["twitch", "youtube"]); + const overlayChat = require(path.join(serviceDir, "overlay-chat.js")); + const normalizedChat = overlayChat.normalizeChatMessage({ + id: "message-1", + platform: "twitch", + text: "Hello chat", + channel: { id: "123", name: "#cozycarnage" }, + author: { id: "viewer-1", name: "Viewer", color: "#abcdef", badges: ["Subscriber"] } + }); + 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); + 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); const websiteRenderUrl = websiteState.scene.modules.find((module) => module.id === websiteId).config.renderUrl; const websiteTicket = websiteRenderUrl.split("/").at(-1); @@ -187,6 +231,7 @@ try { assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken)); assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets"); overlays.deleteModule(websiteId); + overlays.deleteModule(chatId); overlays.deleteScene(second.id); assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/); @@ -228,6 +273,7 @@ try { 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="custom_css"') && editorView.includes('name="crop_left"')); assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website")); assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup")); @@ -236,8 +282,10 @@ try { assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets")); 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('frame.setAttribute("allow", "autoplay; fullscreen")')); assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")')); assert(rendererScript.includes("values.renderUrl || values.url")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 587f2f2..574f6fd 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.13"; -const previousCoreVersion = "0.2.12"; +const releaseVersion = "0.2.14"; +const previousCoreVersion = "0.2.13"; 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.13, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.14, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 15c374f..7a8be10 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -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.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.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.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 }, { version: "0.2.11", ref: "refs/tags/v0.2.11", rollback_safe: true }, @@ -73,7 +74,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.13"); +assert.equal(corrected.safe_target_version, "0.2.14"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/discord.js b/src/services/discord.js index 462e485..2fec448 100644 --- a/src/services/discord.js +++ b/src/services/discord.js @@ -8,6 +8,7 @@ const { getSetting, setSetting } = require("./settings"); const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); +const { publishOverlayChatMessage } = require("./overlay-chat"); const discordLog = createLogger("platform:discord", { category: "integration" }); @@ -50,10 +51,8 @@ async function startBot({ commandRouter } = {}) { } }); - client.on("messageCreate", async (message) => { - if (!message.guild || message.author.bot) { - return; - } + client.on("messageCreate", async (message) => { + if (!message.guild) return; const displayName = message.author.globalName || message.author.username || message.author.tag; let avatarUrl = null; @@ -64,13 +63,33 @@ async function startBot({ commandRouter } = {}) { avatarUrl = message.author.displayAvatarURL(); } } - const profile = ensureUserForIdentity({ - provider: "discord", - providerUserId: message.author.id, - displayName, - avatar: avatarUrl - }); - incrementMessages(profile.id); + publishOverlayChatMessage({ + id: message.id, + platform: "discord", + text: message.content, + timestamp: message.createdTimestamp, + channel: { + id: message.channel?.id, + name: message.channel?.name ? `#${message.channel.name}` : message.channel?.id, + key: `${message.guild.id}/${message.channel?.id || ""}` + }, + author: { + id: message.author.id, + name: message.member?.displayName || displayName, + username: message.author.username, + avatar: avatarUrl, + color: message.member?.displayHexColor === "#000000" ? null : message.member?.displayHexColor, + badges: [] + } + }); + if (message.author.bot) return; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: message.author.id, + displayName, + avatar: avatarUrl + }); + incrementMessages(profile.id); if (commandRouter) { await commandRouter.handleMessage({ diff --git a/src/services/overlay-chat.js b/src/services/overlay-chat.js new file mode 100644 index 0000000..1869eb8 --- /dev/null +++ b/src/services/overlay-chat.js @@ -0,0 +1,142 @@ +const crypto = require("crypto"); +const { db } = require("./db"); +const { publishWebEvent } = require("./web-events"); +const { getOverlayModuleType } = require("./overlay-modules"); + +const recentByOverlay = new Map(); +const MAX_RECENT_PER_OVERLAY = 100; +const MAX_RECENT_AGE_MS = 5 * 60 * 1000; + +function cleanText(value, max = 500) { + return String(value === undefined || value === null ? "" : value).trim().slice(0, max); +} + +function cleanMessageText(value, max = 2000) { + return String(value === undefined || value === null ? "" : value).slice(0, max); +} + +function cleanUrl(value) { + const candidate = cleanText(value, 2048); + if (!candidate) return null; + try { + const parsed = new URL(candidate); + return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : null; + } catch { + return null; + } +} + +function normalizeBadge(badge) { + if (typeof badge === "string") { + const label = cleanText(badge, 40); + return label ? { label, image: null } : null; + } + const label = cleanText(badge?.label || badge?.name, 40); + if (!label) return null; + return { label, image: cleanUrl(badge?.image || badge?.url) }; +} + +function normalizeEmote(emote) { + const parsedStart = Number(emote?.start); + const parsedEnd = Number(emote?.end); + if (!Number.isFinite(parsedStart) || !Number.isFinite(parsedEnd)) return null; + const start = Math.max(0, Math.round(parsedStart)); + const end = Math.max(start, Math.round(parsedEnd)); + const image = cleanUrl(emote?.image || emote?.url); + return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null; +} + +function normalizeChatMessage(input = {}) { + const platform = cleanText(input.platform, 24).toLowerCase(); + const text = cleanMessageText(input.text, 2000); + const authorId = cleanText(input.author?.id, 160); + if (!platform || !text.trim() || !authorId) return null; + const channelId = cleanText(input.channel?.id, 256); + const channelName = cleanText(input.channel?.name, 256); + const channelKey = cleanText(input.channel?.key || channelName || channelId, 256); + return { + id: cleanText(input.id, 256) || crypto.randomUUID(), + platform, + text, + timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(), + channel: { id: channelId, name: channelName, key: channelKey }, + emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100), + author: { + id: authorId, + name: cleanText(input.author?.name || input.author?.username || "Viewer", 160), + 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) + } + }; +} + +function normalizedChannel(value) { + return String(value || "").trim().toLowerCase().replace(/^#/, ""); +} + +function messageMatchesConfig(message, config = {}) { + const platforms = Array.isArray(config.platforms) ? config.platforms : ["twitch", "youtube", "discord"]; + if (!platforms.includes(message.platform)) 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); + 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 === message.platform) && (channel === "*" || aliases.includes(channel)); + }); +} + +function overlayTargets(message) { + const rows = db.prepare( + `SELECT s.overlay_id, m.type, m.config_json + FROM overlay_modules m + JOIN overlay_scenes s ON s.id = m.scene_id + JOIN overlays o ON o.id = s.overlay_id + WHERE m.enabled = 1 AND s.enabled = 1 AND o.enabled = 1` + ).all(); + const targets = new Set(); + for (const row of rows) { + if (getOverlayModuleType(row.type)?.renderType !== "chat") continue; + let config = {}; + try { config = JSON.parse(row.config_json || "{}"); } catch {} + if (messageMatchesConfig(message, config)) targets.add(row.overlay_id); + } + return [...targets]; +} + +function remember(overlayId, message) { + const cutoff = Date.now() - MAX_RECENT_AGE_MS; + const recent = (recentByOverlay.get(overlayId) || []).filter((entry) => entry.timestamp >= cutoff && entry.id !== message.id); + recent.push(message); + recentByOverlay.set(overlayId, recent.slice(-MAX_RECENT_PER_OVERLAY)); +} + +function publishOverlayChatMessage(input = {}) { + const message = normalizeChatMessage(input); + if (!message) return 0; + let delivered = 0; + for (const overlayId of overlayTargets(message)) { + remember(overlayId, message); + delivered += publishWebEvent("overlay:chat-message", message, { scope: `overlay:${overlayId}` }); + } + return delivered; +} + +function recentOverlayChatMessages(overlayId) { + const cutoff = Date.now() - MAX_RECENT_AGE_MS; + const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff); + recentByOverlay.set(String(overlayId), recent); + return recent.map((payload) => ({ event: "overlay:chat-message", payload })); +} + +module.exports = { + messageMatchesConfig, + normalizeChatMessage, + publishOverlayChatMessage, + recentOverlayChatMessages +}; diff --git a/src/services/overlay-modules.js b/src/services/overlay-modules.js index 45e18fe..8cbbf3c 100644 --- a/src/services/overlay-modules.js +++ b/src/services/overlay-modules.js @@ -67,8 +67,8 @@ function registerOverlayModuleType(type) { throw new Error("Overlay module types require an id and normalize function."); } const renderType = type.renderType || type.id; - if (!["text", "image", "video", "audio", "web"].includes(renderType)) { - throw new Error("Overlay module renderType must be text, image, video, audio, or web."); + if (!["text", "image", "video", "audio", "web", "chat"].includes(renderType)) { + throw new Error("Overlay module renderType must be text, image, video, audio, web, or chat."); } moduleTypes.set(type.id, Object.freeze({ ...type, renderType })); return () => moduleTypes.delete(type.id); @@ -125,6 +125,50 @@ registerOverlayModuleType({ } }); +registerOverlayModuleType({ + id: "chat", + label: "Lumi chat", + description: "Show live messages from one or more connected chat platforms and channels.", + normalize(input) { + const platforms = (Array.isArray(input.platforms) ? input.platforms : String(input.platforms || "twitch,youtube,discord").split(",")) + .map((entry) => text(entry, "", 24).trim().toLowerCase()) + .filter((entry, index, values) => ["twitch", "youtube", "discord"].includes(entry) && values.indexOf(entry) === index); + const channels = (Array.isArray(input.channels) ? input.channels : String(input.channels || "").split(/[\n,]+/)) + .map((entry) => text(entry, "", 256).trim()) + .filter((entry, index, values) => entry && values.indexOf(entry) === index) + .slice(0, 50); + 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, + 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", + horizontalAlign: ["left", "center", "right"].includes(input.horizontalAlign) ? input.horizontalAlign : "left", + fontFamily: text(input.fontFamily, "system-ui, sans-serif", 300).trim() || "system-ui, sans-serif", + fontSize: number(input.fontSize, 32, 8, 160), + fontWeight: number(input.fontWeight, 600, 100, 900), + lineHeight: number(input.lineHeight, 1.25, 0.8, 3), + color: color(input.color, "#ffffff"), + usernameColor: color(input.usernameColor, "#67e8f9"), + background: color(input.background, "transparent"), + messageBackground: color(input.messageBackground, "#0f172bcc"), + padding: number(input.padding, 16, 0, 100), + gap: number(input.gap, 10, 0, 100), + borderRadius: number(input.borderRadius, 12, 0, 100), + showAvatars: input.showAvatars !== false && input.showAvatars !== "false" && input.showAvatars !== "off", + showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off", + showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off", + showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true", + inAnimation: ["none", "fade", "slide-up", "slide-left", "scale"].includes(input.inAnimation) ? input.inAnimation : "slide-up", + outAnimation: ["none", "fade", "slide-left", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade", + animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)), + customCss: text(input.customCss, "", 12000) + }; + } +}); + registerOverlayModuleType({ id: "video", label: "Video / media", diff --git a/src/services/overlay-routes.js b/src/services/overlay-routes.js index 41d1b05..c681022 100644 --- a/src/services/overlay-routes.js +++ b/src/services/overlay-routes.js @@ -37,6 +37,7 @@ const { } = require("./overlays"); const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults"); const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents"); +const { recentOverlayChatMessages } = require("./overlay-chat"); const { listOverlayConnectorProviders, overlayConnectorManager @@ -207,7 +208,10 @@ function registerPublicOverlayRoutes(app) { if (!context) return res.status(404).end(); return subscribeWebEvents(req, res, { scopes: [`overlay:${context.overlay.id}`], - initialEvents: [{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } }] + initialEvents: [ + { event: "overlay:changed", payload: { revision: context.overlay.updated_at } }, + ...recentOverlayChatMessages(context.overlay.id) + ] }); }); @@ -345,7 +349,25 @@ function moduleInput(body) { cropBottom: body.crop_bottom, cropLeft: body.crop_left, zoom: body.zoom, - customCss: body.custom_css + platforms: ["twitch", "youtube", "discord"].filter((platform) => booleanField(body, `chat_platform_${platform}`)), + channels: body.chat_channels, + maxMessages: body.chat_max_messages, + messageTimeoutSeconds: body.chat_timeout_seconds, + newestAt: body.chat_newest_at, + fontFamily: body.chat_font_family, + lineHeight: body.chat_line_height, + usernameColor: body.chat_username_color, + messageBackground: body.chat_message_background, + gap: body.chat_gap, + borderRadius: body.chat_border_radius, + showAvatars: booleanField(body, "chat_show_avatars"), + showBadges: booleanField(body, "chat_show_badges"), + showPlatform: booleanField(body, "chat_show_platform"), + showTimestamp: booleanField(body, "chat_show_timestamp"), + inAnimation: body.chat_in_animation, + outAnimation: body.chat_out_animation, + animationDurationMs: body.chat_animation_duration_ms, + customCss: body.type === "chat" ? body.chat_custom_css : body.custom_css } }; } diff --git a/src/services/twitch.js b/src/services/twitch.js index 8878cd3..7c65263 100644 --- a/src/services/twitch.js +++ b/src/services/twitch.js @@ -3,6 +3,7 @@ const { getSetting } = require("./settings"); const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); +const { publishOverlayChatMessage } = require("./overlay-chat"); const twitchLog = createLogger("platform:twitch", { category: "integration" }); @@ -39,14 +40,30 @@ async function startTwitchBot({ commandRouter } = {}) { }); client.on("message", async (channel, tags, message, self) => { - if (self) { - return; - } const userId = tags["user-id"]; if (!userId) { return; } const displayName = tags["display-name"] || tags.username; + publishOverlayChatMessage({ + id: tags.id, + platform: "twitch", + text: message, + timestamp: Number(tags["tmi-sent-ts"]) || Date.now(), + channel: { id: String(tags["room-id"] || ""), name: channel, key: channel }, + author: { + id: userId, + name: displayName, + username: tags.username, + color: tags.color, + badges: Object.keys(tags.badges || {}) + }, + emotes: Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => { + const [start, end] = String(position).split("-").map(Number); + return { start, end, image: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/3.0` }; + })) + }); + if (self) return; const profile = ensureUserForIdentity({ provider: "twitch", providerUserId: userId, diff --git a/src/services/youtube.js b/src/services/youtube.js index e0e138c..0cfc792 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -2,6 +2,7 @@ const { getSetting, setSetting } = require("./settings"); const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); +const { publishOverlayChatMessage } = require("./overlay-chat"); const youtubeLog = createLogger("platform:youtube", { category: "integration" }); @@ -120,12 +121,27 @@ async function handleChatItem(state, liveChatId, item) { if (!messageText) { return; } - if (state.channelId && author.channelId === state.channelId) { - return; - } - const displayName = author.displayName || "YouTube User"; const avatar = author.profileImageUrl || null; + publishOverlayChatMessage({ + id: item.id, + platform: "youtube", + text: messageText, + timestamp: Date.parse(snippet.publishedAt) || Date.now(), + channel: { + id: liveChatId, + name: state.channelName || state.activeVideoId || "Live chat", + key: state.activeVideoId || liveChatId + }, + author: { + id: author.channelId, + name: displayName, + username: displayName, + avatar, + badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean) + } + }); + if (state.channelId && author.channelId === state.channelId) return; const profile = ensureUserForIdentity({ provider: "youtube", providerUserId: author.channelId, diff --git a/src/web/public/overlay-management.css b/src/web/public/overlay-management.css index e84a21b..f75559b 100644 --- a/src/web/public/overlay-management.css +++ b/src/web/public/overlay-management.css @@ -289,6 +289,37 @@ grid-column: 1 / -1; } +.overlay-chat-fields > .full, +.overlay-fieldset.full { + grid-column: 1 / -1; +} + +.overlay-fieldset { + min-width: 0; + margin: 0; + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-sm); +} + +.overlay-fieldset legend { + padding-inline: var(--lumi-space-1); + font-weight: 800; +} + +.overlay-check-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: var(--lumi-space-2) var(--lumi-space-3); +} + +.overlay-check-grid label { + display: flex; + align-items: center; + gap: var(--lumi-space-2); + min-height: var(--lumi-control-height); +} + .overlay-conditional-fields[hidden] { display: none !important; } diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 370ecc6..45ae4e6 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -1,4 +1,5 @@ (() => { + const chatStates = new Map(); const anchors = { "top-left": [0, 0], "top-center": [-50, 0], @@ -126,6 +127,197 @@ return root; } + function normalizedChannel(value) { + return String(value || "").trim().toLowerCase().replace(/^#/, ""); + } + + function chatAccepts(values, payload) { + if (!(values.platforms || []).includes(payload.platform)) 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 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("span"); + platform.className = "platform"; + platform.textContent = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1); + 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(badgeData.image ? "img" : "span"); + badge.className = "badge"; + if (badgeData.image) { + badge.src = badgeData.image; + badge.alt = badgeData.label || "Badge"; + } else { + badge.textContent = badgeData.label; + } + 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 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 >= payload.text.length) continue; + text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start))); + const emote = document.createElement("img"); + emote.className = "emote"; + emote.src = emoteData.image; + emote.alt = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote"; + emote.referrerPolicy = "no-referrer"; + text.appendChild(emote); + cursor = Math.min(payload.text.length, emoteData.end + 1); + } + text.append(document.createTextNode(payload.text.slice(cursor))); + content.append(meta, text); + 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); + } + + 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 || ""; + style.textContent = ` + :host { display:block; width:100%; 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%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; } + .message { display:flex; align-items:flex-start; gap:.55em; box-sizing:border-box; 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; 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; } + .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; } + .enter-fade, .leave-active { opacity:0; } + .enter-slide-up { opacity:0; transform:translateY(1em); } + .enter-slide-left { opacity:0; transform:translateX(1.5em); } + .enter-scale { opacity:0; transform:scale(.92); } + .enter-active { opacity:1; transform:none; } + .leave-slide-left.leave-active { transform:translateX(-1.5em); } + .leave-slide-down.leave-active { transform:translateY(1em); } + ${chatCustomCss} + `; + 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: [] }; + state.values = values; + state.list = list; + if (options.editor && !state.messages.length) { + state.messages = [ + { id: `${module.id}-preview-1`, platform: "twitch", text: "This is how a live chat message will look.", timestamp: Date.now() - 60000, channel: { name: "#stream" }, author: { name: "CozyViewer", color: "#f472b6", badges: [{ label: "Sub", image: null }] } }, + { id: `${module.id}-preview-2`, platform: "youtube", text: "Messages from your selected platforms appear together!", timestamp: Date.now(), channel: { name: "Live chat" }, author: { name: "Lumi Fan", color: null, badges: [] } } + ]; + } + state.messages = state.messages.slice(-Number(values.maxMessages || 8)); + const display = values.newestAt === "top" ? [...state.messages].reverse() : state.messages; + for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false })); + chatStates.set(module.id, state); + return root; + } + + function handleChatMessage(root, payload) { + if (!payload?.id || !payload?.platform || !payload?.text) 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) || null; @@ -161,6 +353,9 @@ 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"; @@ -243,5 +438,5 @@ }; } - window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, render, restartMediaElement, stageLayout }; + window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, restartMediaElement, stageLayout }; })(); diff --git a/src/web/public/overlay-runtime.css b/src/web/public/overlay-runtime.css index 6da4027..4b00cb3 100644 --- a/src/web/public/overlay-runtime.css +++ b/src/web/public/overlay-runtime.css @@ -73,7 +73,8 @@ body { .lumi-overlay-image, .lumi-overlay-video, -.lumi-overlay-web-root { +.lumi-overlay-web-root, +.lumi-overlay-chat-root { width: 100%; height: 100%; display: block; diff --git a/src/web/public/overlay-runtime.js b/src/web/public/overlay-runtime.js index 045caf9..c961f1d 100644 --- a/src/web/public/overlay-runtime.js +++ b/src/web/public/overlay-runtime.js @@ -224,6 +224,10 @@ if (payload.module_id) restartSource(payload.module_id); } catch {} }); + events.addEventListener("overlay:chat-message", (event) => { + try { renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); } + catch {} + }); events.addEventListener("overlay:obs-browser-command", (event) => { if (!obsBridge) return; try { diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index 83875ab..67640c0 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -113,6 +113,38 @@ +
+ <% }) %> diff --git a/update-manifest.json b/update-manifest.json index db8778d..4ae368f 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,14 +1,14 @@ { "name": "Lumi Core", - "version": "0.2.13", + "version": "0.2.14", "channel": "stable", - "released_at": "2026-07-18", + "released_at": "2026-07-19", "compatible_from": "0.1.9", "migration_kind": "patch", "replaces_versions": [ "1.2.0" ], - "migration_notes": "Includes the 1.2.0 version correction, production diagnostics, Windows/network-share-safe updates, Node.js 24-compatible dependency startup, structured redacted logging, OBS overlay layout parity, and OBS-style website document CSS injection. Lumi 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": "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.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -181,6 +181,18 @@ ], "rollback_safe": true, "migration_notes": "Adds OBS-style website document CSS injection with secure scoped render tickets; existing overlay sources, URLs, CSS, tokens, and all other local data remain preserved." + }, + { + "version": "0.2.14", + "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": "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." } ] }