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
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 { 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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user