const moduleTypes = new Map(); const { obsBrowserSourceCss } = require("./obs-browser-defaults"); function number(value, fallback, min, max) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function text(value, fallback = "", max = 2000) { return String(value === undefined || value === null ? fallback : value).slice(0, max); } function color(value, fallback = "#ffffff") { const normalized = text(value, fallback, 32).trim(); return /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([0-9.,%\s]+\)|transparent)$/i.test(normalized) ? normalized : fallback; } function url(value, { allowEmpty = true } = {}) { const normalized = text(value, "", 2048).trim(); if (!normalized && allowEmpty) return ""; try { const parsed = new URL(normalized); return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : ""; } catch { return ""; } } function commonConfig(input = {}) { const anchors = [ "top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right" ]; return { x: number(input.x, 0, 0, 100), y: number(input.y, 0, 0, 100), width: number(input.width, 100, 0.1, 100), height: number(input.height, 100, 0.1, 100), opacity: number(input.opacity, 1, 0, 1), anchor: anchors.includes(input.anchor) ? input.anchor : "top-left" }; } function mediaConfig(input = {}, { includeFit = false } = {}) { const playBehavior = ["once", "loop", "manual"].includes(input.playBehavior) ? input.playBehavior : "once"; return { ...commonConfig(input), url: url(input.url), playBehavior, muted: input.muted === true || input.muted === "on", volume: number(input.volume, 1, 0, 1), playbackRate: number(input.playbackRate, 1, 0.25, 4), startAt: number(input.startAt, 0, 0, 86400), showControls: playBehavior === "manual" || input.showControls === true || input.showControls === "on", ...(includeFit ? { fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain" } : {}) }; } function registerOverlayModuleType(type) { if (!type?.id || typeof type.normalize !== "function") { 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."); } moduleTypes.set(type.id, Object.freeze({ ...type, renderType })); return () => moduleTypes.delete(type.id); } function getOverlayModuleType(id) { return moduleTypes.get(String(id || "")) || null; } function listOverlayModuleTypes() { return Array.from(moduleTypes.values()).map(({ id, label, description }) => ({ id, label, description })); } function normalizeModuleConfig(typeId, input) { const type = getOverlayModuleType(typeId); if (!type) throw new Error("Unsupported overlay module type."); return type.normalize(input || {}); } registerOverlayModuleType({ id: "text", label: "Text", description: "Positioned text with configurable colors and sizing.", normalize(input) { return { ...commonConfig(input), text: text(input.text, "New text", 4000), fontSize: number(input.fontSize, 48, 8, 400), fontWeight: number(input.fontWeight, 700, 100, 900), color: color(input.color, "#ffffff"), background: color(input.background, "transparent"), horizontalAlign: ["left", "center", "right"].includes(input.horizontalAlign) ? input.horizontalAlign : (["left", "center", "right"].includes(input.align) ? input.align : "left"), verticalAlign: ["top", "center", "bottom"].includes(input.verticalAlign) ? input.verticalAlign : "center", overflow: ["wrap", "shrink", "clip", "single-line", "scroll"].includes(input.overflow) ? input.overflow : "wrap", padding: number(input.padding, 0, 0, 200) }; } }); registerOverlayModuleType({ id: "image", label: "Image", description: "A responsive image from an HTTPS or HTTP URL.", normalize(input) { return { ...commonConfig(input), url: url(input.url), fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain" }; } }); registerOverlayModuleType({ id: "video", label: "Video / media", description: "Play a video file, including its audio track, with scene-aware playback controls.", renderType: "video", normalize(input) { return mediaConfig(input, { includeFit: true }); } }); registerOverlayModuleType({ id: "audio", label: "Audio", description: "Play an audio file through the OBS Browser Source.", renderType: "audio", normalize(input) { return mediaConfig(input); } }); registerOverlayModuleType({ id: "web", label: "Website or alert overlay", description: "Add a third-party alert box, chat overlay, or other web page.", normalize(input) { return { ...commonConfig(input), url: url(input.url), allowPointerEvents: input.allowPointerEvents === true || input.allowPointerEvents === "on", autoRefresh: input.autoRefresh === true || input.autoRefresh === "on", healthCheck: input.healthCheck !== false && input.healthCheck !== "false" && input.healthCheck !== "off", refreshIntervalSeconds: number(input.refreshIntervalSeconds, 300, 30, 3600), retryLimit: Math.round(number(input.retryLimit, 3, 0, 5)), cropTop: number(input.cropTop, 0, 0, 95), cropRight: number(input.cropRight, 0, 0, 95), cropBottom: number(input.cropBottom, 0, 0, 95), 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", customCss: obsBrowserSourceCss(text(input.customCss, "", 12000)) }; } }); module.exports = { getOverlayModuleType, listOverlayModuleTypes, normalizeModuleConfig, registerOverlayModuleType };