Caspar Template
This commit is contained in:
28
README.md
28
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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
315
public/caspar.js
Normal file
315
public/caspar.js
Normal 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");
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
@@ -330,6 +330,17 @@ export function homePage({ providersCount = 0 } = {}) {
|
||||
</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({
|
||||
targetUrl,
|
||||
embed,
|
||||
|
||||
@@ -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('<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\) =>/);
|
||||
});
|
||||
|
||||
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>/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user