diff --git a/README.md b/README.md index 14a86ac..3cb40b5 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,37 @@ The service also supports the oembed.link-style form: http://localhost:3000/https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ ``` +## CasparCG template + +Load `/caspar` as the browser template URL. It starts transparent and blank, then +renders when CasparCG sends `UPDATE` data. + +Example template URL: + +```txt +http://localhost:3000/caspar +``` + +Example JSON update payload: + +```json +{ + "url": "https://bsky.app/profile/bsky.app/post/3k...", + "width": 1920, + "height": 1080, + "scale": 1, + "transparent": true +} +``` + +The template also accepts a raw URL string or standard CasparCG XML +`templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`, +`muted`, `readyDelay`, and `maxWait`. + ## API - `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data. +- `GET /caspar` returns the CasparCG update-driven HTML template. - `GET /providers` returns the loaded provider patterns. - `GET /healthz` returns a health check response. diff --git a/package.json b/package.json index 23eddc8..06fe967 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node --watch src/server.js", "start": "node src/server.js", - "typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js", + "typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js", "build": "npm run typecheck", "test": "node --test" }, diff --git a/public/caspar.js b/public/caspar.js new file mode 100644 index 0000000..a381962 --- /dev/null +++ b/public/caspar.js @@ -0,0 +1,315 @@ +const DEFAULTS = { + width: 1920, + height: 1080, + scale: 1, + fit: "contain", + maxwidth: 500, + maxheight: 480, + autoplay: true, + muted: true, + transparent: true, + chroma: "#00ff00", + readyDelay: 1000, + maxWait: 10000, +}; + +let currentReadyTimer; +let currentMaxWaitTimer; +let autoplayTimer; + +function toBoolean(value, defaultValue = false) { + if (value === undefined || value === null || value === "") { + return defaultValue; + } + + if (typeof value === "boolean") { + return value; + } + + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +} + +function toNumber(value, defaultValue, min, max) { + const number = Number(value); + + if (!Number.isFinite(number)) { + return defaultValue; + } + + return Math.min(Math.max(number, min), max); +} + +function slugify(value = "") { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "unknown"; +} + +function parseJsonPayload(payload) { + try { + const parsed = JSON.parse(payload); + + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch { + return null; + } + + return null; +} + +function parseXmlPayload(payload) { + const data = {}; + const documentXml = new DOMParser().parseFromString(payload, "application/xml"); + + if (documentXml.querySelector("parsererror")) { + return data; + } + + for (const component of documentXml.querySelectorAll("componentData")) { + const key = component.getAttribute("id"); + const valueNode = component.querySelector("data[id='text'], data[id='value'], data"); + const value = valueNode?.getAttribute("value") || valueNode?.textContent; + + if (key && value) { + data[key] = value; + } + } + + for (const element of documentXml.documentElement.querySelectorAll("*")) { + if (element.children.length === 0 && element.textContent.trim()) { + data[element.tagName] = element.textContent.trim(); + } + } + + return data; +} + +function parseUpdatePayload(payload) { + if (payload && typeof payload === "object") { + return payload; + } + + const text = String(payload || "").trim(); + + if (!text) { + return {}; + } + + const json = parseJsonPayload(text); + if (json) { + return json; + } + + if (/^https?:\/\//i.test(text)) { + return { url: text }; + } + + return parseXmlPayload(text); +} + +function normalizeOptions(data) { + return { + url: data.url || data.URL || data.link || data.source || "", + width: toNumber(data.width, DEFAULTS.width, 320, 3840), + height: toNumber(data.height, DEFAULTS.height, 240, 2160), + scale: toNumber(data.scale, DEFAULTS.scale, 0.25, 3), + maxwidth: toNumber(data.maxwidth || data.maxWidth, DEFAULTS.maxwidth, 220, 500), + maxheight: toNumber(data.maxheight || data.maxHeight, DEFAULTS.maxheight, 220, 2160), + readyDelay: toNumber(data.readyDelay, DEFAULTS.readyDelay, 0, 30000), + maxWait: toNumber(data.maxWait, DEFAULTS.maxWait, 1000, 60000), + fit: data.fit === "cover" ? "cover" : "contain", + autoplay: toBoolean(data.autoplay, DEFAULTS.autoplay), + muted: toBoolean(data.muted, DEFAULTS.muted), + transparent: toBoolean(data.transparent, DEFAULTS.transparent), + chroma: data.chroma || DEFAULTS.chroma, + }; +} + +function addAutoplayToUrl(src, options) { + try { + const url = new URL(src, window.location.href); + url.searchParams.set("autoplay", "1"); + url.searchParams.set("playsinline", "1"); + + if (options.muted) { + url.searchParams.set("mute", "1"); + url.searchParams.set("muted", "1"); + } + + return url.toString(); + } catch { + return src; + } +} + +function normalizeInsertedMedia(root, options) { + for (const iframe of root.querySelectorAll("iframe")) { + const permissions = new Set( + (iframe.getAttribute("allow") || "") + .split(";") + .map((permission) => permission.trim()) + .filter(Boolean), + ); + + permissions.add("autoplay"); + permissions.add("fullscreen"); + permissions.add("picture-in-picture"); + iframe.setAttribute("allow", Array.from(permissions).join("; ")); + + if (options.autoplay && iframe.src) { + iframe.src = addAutoplayToUrl(iframe.src, options); + } + } + + for (const video of root.querySelectorAll("video")) { + video.autoplay = options.autoplay; + video.playsInline = true; + video.setAttribute("playsinline", ""); + + if (options.autoplay) { + video.setAttribute("autoplay", ""); + } + + if (options.muted) { + video.muted = true; + video.defaultMuted = true; + video.setAttribute("muted", ""); + } + } +} + +function executeScripts(root) { + for (const script of root.querySelectorAll("script")) { + const replacement = document.createElement("script"); + + for (const attribute of script.attributes) { + replacement.setAttribute(attribute.name, attribute.value); + } + + replacement.textContent = script.textContent; + script.replaceWith(replacement); + } +} + +function applyStageOptions(options) { + document.body.style.setProperty("--stage-width", `${options.width}px`); + document.body.style.setProperty("--stage-height", `${options.height}px`); + document.body.style.setProperty("--scale", String(options.scale)); + document.body.style.setProperty("--chroma", options.chroma); + document.body.classList.toggle("transparent", options.transparent); + document.documentElement.classList.toggle("transparent", options.transparent); + + const stage = document.querySelector(".stage"); + stage.classList.toggle("fit-cover", options.fit === "cover"); + stage.classList.toggle("fit-contain", options.fit !== "cover"); +} + +function hideUntilReady(options) { + clearTimeout(currentReadyTimer); + clearTimeout(currentMaxWaitTimer); + + document.body.classList.remove("is-ready"); + document.body.classList.add("is-loading"); + + const reveal = () => { + document.body.classList.remove("is-loading"); + document.body.classList.add("is-ready"); + }; + + currentReadyTimer = setTimeout(reveal, options.readyDelay); + currentMaxWaitTimer = setTimeout(reveal, options.maxWait); +} + +function startAutoplayAssist(root, options) { + clearInterval(autoplayTimer); + + if (!options.autoplay) { + return; + } + + const scan = () => { + for (const video of root.querySelectorAll("video")) { + const promise = video.play?.(); + promise?.catch?.(() => {}); + } + }; + + scan(); + autoplayTimer = setInterval(scan, 1000); +} + +function renderEmbed(oembed, sourceUrl, options) { + const embed = document.querySelector(".embed"); + const providerClass = `provider-${slugify(oembed.provider_name)}`; + const embedWidth = Math.min(Number(oembed.width) || 500, 500); + const embedHeight = Number(oembed.height); + + embed.className = `embed ${providerClass}`; + embed.dataset.source = sourceUrl; + embed.style.setProperty("--embed-width", `${embedWidth}px`); + + if (Number.isFinite(embedHeight) && embedHeight > 0) { + embed.style.setProperty("--embed-height", `${embedHeight}px`); + } else { + embed.style.removeProperty("--embed-height"); + } + + if (oembed.type === "photo" && oembed.url) { + const image = document.createElement("img"); + image.src = oembed.url; + image.alt = oembed.title || ""; + embed.replaceChildren(image); + } else { + embed.innerHTML = oembed.html || ""; + } + + normalizeInsertedMedia(embed, options); + executeScripts(embed); + startAutoplayAssist(embed, options); + hideUntilReady(options); +} + +async function loadEmbed(options) { + if (!options.url) { + return; + } + + applyStageOptions(options); + + const requestUrl = new URL("/api/oembed", window.location.origin); + requestUrl.searchParams.set("url", options.url); + requestUrl.searchParams.set("maxwidth", String(options.maxwidth)); + requestUrl.searchParams.set("maxheight", String(options.maxheight)); + + const response = await fetch(requestUrl); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Could not load oEmbed data."); + } + + renderEmbed(result.data, options.url, options); +} + +window.update = (payload) => { + const options = normalizeOptions(parseUpdatePayload(payload)); + loadEmbed(options).catch((error) => { + console.error(error); + document.body.classList.remove("is-ready"); + document.body.classList.add("is-loading"); + }); +}; + +window.play = () => { + document.body.classList.remove("is-loading"); + document.body.classList.add("is-ready"); +}; + +window.stop = () => { + clearInterval(autoplayTimer); + document.body.classList.remove("is-ready"); + document.body.classList.add("is-loading"); +}; diff --git a/src/server.js b/src/server.js index 46a91a8..218e528 100644 --- a/src/server.js +++ b/src/server.js @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { fetchOembed } from "./oembed.js"; import { loadProviders } from "./providers.js"; -import { errorPage, graphicPage, homePage } from "./templates.js"; +import { casparTemplatePage, errorPage, graphicPage, homePage } from "./templates.js"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); const rootDir = join(__dirname, ".."); @@ -52,7 +52,11 @@ function getNumber(searchParams, key, defaultValue, min, max) { } async function serveStatic(requestUrl, response) { - const path = requestUrl.pathname === "/styles.css" ? "styles.css" : ""; + const staticPaths = new Map([ + ["/styles.css", "styles.css"], + ["/caspar.js", "caspar.js"], + ]); + const path = staticPaths.get(requestUrl.pathname) || ""; if (!path) { return false; @@ -117,6 +121,11 @@ async function handleRequest(request, response) { return; } + if (requestUrl.pathname === "/caspar") { + send(response, 200, casparTemplatePage()); + return; + } + if (requestUrl.pathname === "/providers") { const providers = await loadProviders(); sendJson(response, 200, { diff --git a/src/templates.js b/src/templates.js index 90fd33e..0ece481 100644 --- a/src/templates.js +++ b/src/templates.js @@ -330,6 +330,17 @@ export function homePage({ providersCount = 0 } = {}) { `; } +export function casparTemplatePage() { + return `${commonHead({ title: "oEmbed CasparCG Template", htmlClass: "graphic-document transparent" })} + +
+
+
+ + +`; +} + export function graphicPage({ targetUrl, embed, diff --git a/test/templates.test.js b/test/templates.test.js index 4acdcb9..b905612 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { graphicPage, prepareEmbedHtml } from "../src/templates.js"; +import { casparTemplatePage, graphicPage, prepareEmbedHtml } from "../src/templates.js"; test("adds autoplay parameters and permissions to iframe embeds", () => { const html = prepareEmbedHtml(''); @@ -184,3 +184,11 @@ test("omits autoplay assist when autoplay is disabled", () => { assert.doesNotMatch(page, /const playVideo = \(video\) =>/); }); + +test("renders a CasparCG template shell", () => { + const page = casparTemplatePage(); + + assert.match(page, /<\/script>/); +});