Caspar Template
All checks were successful
Build & Push Docker (latest) / verify (push) Successful in 9m29s
Build & Push Docker (latest) / build (push) Successful in 8s

This commit is contained in:
Aiden Wilson
2026-05-29 22:51:32 +10:00
parent a88306aec7
commit bba1ab5cee
6 changed files with 375 additions and 4 deletions

View File

@@ -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 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 ## API
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data. - `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 /providers` returns the loaded provider patterns.
- `GET /healthz` returns a health check response. - `GET /healthz` returns a health check response.

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"start": "node 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", "build": "npm run typecheck",
"test": "node --test" "test": "node --test"
}, },

315
public/caspar.js Normal file
View File

@@ -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");
};

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { fetchOembed } from "./oembed.js"; import { fetchOembed } from "./oembed.js";
import { loadProviders } from "./providers.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 __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, ".."); const rootDir = join(__dirname, "..");
@@ -52,7 +52,11 @@ function getNumber(searchParams, key, defaultValue, min, max) {
} }
async function serveStatic(requestUrl, response) { 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) { if (!path) {
return false; return false;
@@ -117,6 +121,11 @@ async function handleRequest(request, response) {
return; return;
} }
if (requestUrl.pathname === "/caspar") {
send(response, 200, casparTemplatePage());
return;
}
if (requestUrl.pathname === "/providers") { if (requestUrl.pathname === "/providers") {
const providers = await loadProviders(); const providers = await loadProviders();
sendJson(response, 200, { sendJson(response, 200, {

View File

@@ -330,6 +330,17 @@ export function homePage({ providersCount = 0 } = {}) {
</html>`; </html>`;
} }
export function casparTemplatePage() {
return `${commonHead({ title: "oEmbed CasparCG Template", htmlClass: "graphic-document transparent" })}
<body class="graphic transparent is-loading" style="--stage-width:1920px; --stage-height:1080px; --scale:1; --chroma:#00ff00;">
<main class="stage fit-contain">
<section class="embed provider-empty" data-source="" style="--embed-width:500px"></section>
</main>
<script src="/caspar.js"></script>
</body>
</html>`;
}
export function graphicPage({ export function graphicPage({
targetUrl, targetUrl,
embed, embed,

View File

@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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", () => { test("adds autoplay parameters and permissions to iframe embeds", () => {
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>'); const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');
@@ -184,3 +184,11 @@ test("omits autoplay assist when autoplay is disabled", () => {
assert.doesNotMatch(page, /const playVideo = \(video\) =>/); assert.doesNotMatch(page, /const playVideo = \(video\) =>/);
}); });
test("renders a CasparCG template shell", () => {
const page = casparTemplatePage();
assert.match(page, /<body class="graphic transparent is-loading"/);
assert.match(page, /<section class="embed provider-empty"/);
assert.match(page, /<script src="\/caspar\.js"><\/script>/);
});