Lumi/src/services/overlay-modules.js
2026-07-18 22:18:35 +02:00

178 lines
6.0 KiB
JavaScript

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
};