525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
function escapeHtml(value = "") {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function commonHead({ title = "oEmbed Graphics", htmlClass = "" } = {}) {
|
|
return `<!doctype html>
|
|
<html lang="en"${htmlClass ? ` class="${escapeHtml(htmlClass)}"` : ""}>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>${escapeHtml(title)}</title>
|
|
<link rel="stylesheet" href="/styles.css">
|
|
</head>`;
|
|
}
|
|
|
|
function slugify(value = "") {
|
|
return String(value)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "") || "unknown";
|
|
}
|
|
|
|
function numericPixelValue(value) {
|
|
const number = Number(value);
|
|
|
|
return Number.isFinite(number) && number > 0 ? `${number}px` : "";
|
|
}
|
|
|
|
function cappedPixelValue(value, cap) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
return `${cap}px`;
|
|
}
|
|
|
|
return `${Math.min(number, cap)}px`;
|
|
}
|
|
|
|
function collageCardWidth({ width, spacing, columns }) {
|
|
const availableWidth = width - (spacing * 2) - (spacing * Math.max(columns - 1, 0));
|
|
const columnWidth = Math.floor(availableWidth / columns);
|
|
|
|
return Math.max(columnWidth, 120);
|
|
}
|
|
|
|
function shuffleItems(items) {
|
|
const shuffled = [...items];
|
|
|
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
const randomIndex = Math.floor(Math.random() * (index + 1));
|
|
[shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]];
|
|
}
|
|
|
|
return shuffled;
|
|
}
|
|
|
|
function safeJson(value) {
|
|
return JSON.stringify(value).replaceAll("</", "<\\/");
|
|
}
|
|
|
|
function addIframePermissions(tag) {
|
|
const autoplayPermission = "autoplay";
|
|
|
|
if (/allow=/i.test(tag)) {
|
|
return tag.replace(/\sallow=(["'])(.*?)\1/i, (_match, quote, value) => {
|
|
const permissions = value
|
|
.split(";")
|
|
.map((permission) => permission.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!permissions.includes(autoplayPermission)) {
|
|
permissions.push(autoplayPermission);
|
|
}
|
|
|
|
return ` allow=${quote}${permissions.join("; ")}${quote}`;
|
|
});
|
|
}
|
|
|
|
return tag.replace(/<iframe/i, '<iframe allow="autoplay; fullscreen; picture-in-picture"');
|
|
}
|
|
|
|
function addAutoplayToIframeSrc(tag, { autoplay, muted }) {
|
|
if (!autoplay) {
|
|
return tag;
|
|
}
|
|
|
|
return tag.replace(/\ssrc=(["'])(.*?)\1/i, (_match, quote, src) => {
|
|
try {
|
|
const url = new URL(src.replaceAll("&", "&"));
|
|
url.searchParams.set("autoplay", "1");
|
|
url.searchParams.set("playsinline", "1");
|
|
|
|
if (muted) {
|
|
url.searchParams.set("mute", "1");
|
|
url.searchParams.set("muted", "1");
|
|
}
|
|
|
|
return ` src=${quote}${escapeHtml(url.toString())}${quote}`;
|
|
} catch {
|
|
return ` src=${quote}${src}${quote}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
function addBooleanAttribute(tag, attribute, enabled) {
|
|
if (!enabled || new RegExp(`\\s${attribute}(\\s|>|=)`, "i").test(tag)) {
|
|
return tag;
|
|
}
|
|
|
|
return tag.replace(/>$/, ` ${attribute}>`);
|
|
}
|
|
|
|
export function prepareEmbedHtml(html, { autoplay = true, muted = true } = {}) {
|
|
return html
|
|
.replace(/<iframe\b[^>]*>/gi, (tag) => addIframePermissions(addAutoplayToIframeSrc(tag, { autoplay, muted })))
|
|
.replace(/<video\b[^>]*>/gi, (tag) => {
|
|
let videoTag = addBooleanAttribute(tag, "playsinline", autoplay);
|
|
videoTag = addBooleanAttribute(videoTag, "autoplay", autoplay);
|
|
videoTag = addBooleanAttribute(videoTag, "muted", muted);
|
|
return videoTag;
|
|
});
|
|
}
|
|
|
|
function revealScript({ readyDelay, maxWait }) {
|
|
return `<script>
|
|
(() => {
|
|
const body = document.body;
|
|
const embed = document.querySelector(".embed");
|
|
const readyDelay = ${JSON.stringify(readyDelay)};
|
|
const maxWait = ${JSON.stringify(maxWait)};
|
|
const pending = new Set();
|
|
|
|
let revealed = false;
|
|
let quietTimer;
|
|
|
|
const reveal = () => {
|
|
if (revealed) {
|
|
return;
|
|
}
|
|
|
|
revealed = true;
|
|
clearTimeout(quietTimer);
|
|
observer.disconnect();
|
|
body.classList.add("is-ready");
|
|
};
|
|
|
|
const scheduleReveal = () => {
|
|
clearTimeout(quietTimer);
|
|
quietTimer = setTimeout(reveal, readyDelay);
|
|
};
|
|
|
|
const markReady = (element) => {
|
|
pending.delete(element);
|
|
if (!pending.size) {
|
|
scheduleReveal();
|
|
}
|
|
};
|
|
|
|
const watchMedia = (element) => {
|
|
if (!element || pending.has(element)) {
|
|
return;
|
|
}
|
|
|
|
pending.add(element);
|
|
|
|
if (element.tagName === "IMG" && element.complete) {
|
|
markReady(element);
|
|
return;
|
|
}
|
|
|
|
if (element.tagName === "VIDEO" && element.readyState >= 2) {
|
|
markReady(element);
|
|
return;
|
|
}
|
|
|
|
element.addEventListener("load", () => markReady(element), { once: true });
|
|
element.addEventListener("loadeddata", () => markReady(element), { once: true });
|
|
element.addEventListener("error", () => markReady(element), { once: true });
|
|
};
|
|
|
|
const watchNode = (node) => {
|
|
if (!(node instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (node.matches("iframe, img, video")) {
|
|
watchMedia(node);
|
|
}
|
|
|
|
for (const element of node.querySelectorAll("iframe, img, video")) {
|
|
watchMedia(element);
|
|
}
|
|
};
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
watchNode(node);
|
|
}
|
|
}
|
|
|
|
if (!pending.size) {
|
|
scheduleReveal();
|
|
}
|
|
});
|
|
|
|
observer.observe(embed, { childList: true, subtree: true });
|
|
|
|
for (const element of embed.querySelectorAll("iframe, img, video")) {
|
|
watchMedia(element);
|
|
}
|
|
|
|
if (!pending.size) {
|
|
scheduleReveal();
|
|
}
|
|
|
|
window.addEventListener("load", scheduleReveal, { once: true });
|
|
setTimeout(reveal, maxWait);
|
|
})();
|
|
</script>`;
|
|
}
|
|
|
|
function autoplayAssistScript({ muted }) {
|
|
return `<script>
|
|
(() => {
|
|
const muted = ${JSON.stringify(muted)};
|
|
const root = document.querySelector(".embed");
|
|
const attempts = new WeakMap();
|
|
|
|
const playVideo = (video) => {
|
|
if (!(video instanceof HTMLVideoElement)) {
|
|
return;
|
|
}
|
|
|
|
video.autoplay = true;
|
|
video.playsInline = true;
|
|
video.setAttribute("autoplay", "");
|
|
video.setAttribute("playsinline", "");
|
|
|
|
if (muted) {
|
|
video.muted = true;
|
|
video.defaultMuted = true;
|
|
video.setAttribute("muted", "");
|
|
}
|
|
|
|
const count = attempts.get(video) || 0;
|
|
if (count >= 12) {
|
|
return;
|
|
}
|
|
|
|
attempts.set(video, count + 1);
|
|
const promise = video.play();
|
|
|
|
if (promise && typeof promise.catch === "function") {
|
|
promise.catch(() => {
|
|
setTimeout(() => playVideo(video), 500);
|
|
});
|
|
}
|
|
};
|
|
|
|
const nudgeIframe = (iframe) => {
|
|
iframe.setAttribute("allow", [
|
|
iframe.getAttribute("allow") || "",
|
|
"autoplay",
|
|
"fullscreen",
|
|
"picture-in-picture",
|
|
].join("; "));
|
|
|
|
try {
|
|
for (const video of iframe.contentDocument.querySelectorAll("video")) {
|
|
playVideo(video);
|
|
}
|
|
} catch {
|
|
// Cross-origin provider frames cannot be inspected from this page.
|
|
}
|
|
};
|
|
|
|
const scan = (node = root) => {
|
|
if (!node || !(node instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (node.matches("video")) {
|
|
playVideo(node);
|
|
}
|
|
|
|
if (node.matches("iframe")) {
|
|
nudgeIframe(node);
|
|
}
|
|
|
|
for (const video of node.querySelectorAll("video")) {
|
|
playVideo(video);
|
|
}
|
|
|
|
for (const iframe of node.querySelectorAll("iframe")) {
|
|
nudgeIframe(iframe);
|
|
}
|
|
};
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
scan(node);
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(root, { childList: true, subtree: true });
|
|
scan();
|
|
window.addEventListener("load", scan, { once: true });
|
|
setInterval(scan, 1000);
|
|
})();
|
|
</script>`;
|
|
}
|
|
|
|
export function homePage({ providersCount = 0 } = {}) {
|
|
return `${commonHead()}
|
|
<body class="app">
|
|
<main class="shell">
|
|
<section class="panel">
|
|
<h1>oEmbed Graphics</h1>
|
|
<form action="/graphic" method="get">
|
|
<label for="url">Source URL</label>
|
|
<div class="row">
|
|
<input id="url" name="url" type="url" required placeholder="https://www.youtube.com/watch?v=..." autocomplete="off">
|
|
<button type="submit">Load</button>
|
|
</div>
|
|
<div class="controls">
|
|
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
|
|
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
|
|
<label>Fit
|
|
<select name="fit">
|
|
<option value="contain">contain</option>
|
|
<option value="cover">cover</option>
|
|
</select>
|
|
</label>
|
|
<label class="checkbox-label"><input name="wait" value="1" type="checkbox" checked> wait</label>
|
|
<label class="checkbox-label"><input name="autoplay" value="1" type="checkbox" checked> autoplay</label>
|
|
<label class="checkbox-label"><input name="muted" value="1" type="checkbox" checked> muted</label>
|
|
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
|
</div>
|
|
</form>
|
|
<form class="collage-form" action="/collage" method="get">
|
|
<label for="urls">Collage URLs</label>
|
|
<textarea id="urls" name="urls" rows="6" placeholder="https://bsky.app/...\nhttps://x.com/...\nhttps://www.instagram.com/..."></textarea>
|
|
<div class="controls collage-controls">
|
|
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
|
|
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
|
|
<label>Spacing <input name="spacing" type="number" value="48" min="0" max="400"></label>
|
|
<label>Fade <input name="fade" type="number" value="0" min="0" max="600"></label>
|
|
<label>Columns <input name="columns" type="number" value="3" min="1" max="8"></label>
|
|
<label>Separation <input name="repeatDistance" type="number" value="900" min="0" max="4000"></label>
|
|
<label>Hydrate <input name="hydrateDelay" type="number" value="180" min="0" max="2000"></label>
|
|
<label>Duration <input name="duration" type="number" value="360" min="10" max="1200"></label>
|
|
<label>Repeat <input name="repeat" type="number" value="2" min="1" max="8"></label>
|
|
<label class="checkbox-label"><input name="shuffle" value="1" type="checkbox" checked> shuffle</label>
|
|
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
|
</div>
|
|
<button type="submit">Open collage</button>
|
|
</form>
|
|
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
document.querySelector(".collage-form").addEventListener("submit", (event) => {
|
|
const form = event.currentTarget;
|
|
const textarea = form.querySelector("textarea[name='urls']");
|
|
const urls = textarea.value.split(/[\\n,]+/).map((url) => url.trim()).filter(Boolean);
|
|
|
|
if (!urls.length) {
|
|
event.preventDefault();
|
|
textarea.focus();
|
|
return;
|
|
}
|
|
|
|
const data = new FormData(form);
|
|
data.delete("urls");
|
|
|
|
for (const url of urls) {
|
|
data.append("url", url);
|
|
}
|
|
|
|
event.preventDefault();
|
|
window.location.href = form.action + "?" + new URLSearchParams(data).toString();
|
|
});
|
|
</script>
|
|
</body>
|
|
</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 type="module" src="/caspar.js"></script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function embedCardHtml({
|
|
targetUrl,
|
|
embed,
|
|
autoplay,
|
|
muted,
|
|
className = "embed",
|
|
widthCap = 500,
|
|
includeHeight = true,
|
|
}) {
|
|
const providerClass = `provider-${slugify(embed.provider_name)}`;
|
|
const embedWidth = cappedPixelValue(embed.width, widthCap);
|
|
const embedHeight = numericPixelValue(embed.height);
|
|
const embedStyle = [
|
|
embedWidth ? `--embed-width:${embedWidth}` : "",
|
|
includeHeight && embedHeight ? `--embed-height:${embedHeight}` : "",
|
|
].filter(Boolean).join(";");
|
|
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
|
|
const media = embed.type === "photo" && embed.url
|
|
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(embed.title || embed.provider_name || "")}">`
|
|
: html;
|
|
|
|
return `<section class="${escapeHtml(className)} ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
|
|
${media}
|
|
</section>`;
|
|
}
|
|
|
|
export function collagePage({
|
|
items,
|
|
width,
|
|
height,
|
|
transparent,
|
|
chroma,
|
|
autoplay = true,
|
|
muted = true,
|
|
spacing = 48,
|
|
fade = 0,
|
|
columns = 3,
|
|
repeatDistance = 900,
|
|
hydrateDelay = 180,
|
|
duration = 360,
|
|
repeat = 2,
|
|
shuffle = true,
|
|
}) {
|
|
const cardWidth = collageCardWidth({ width, spacing, columns });
|
|
const orderedItems = shuffle ? shuffleItems(items) : [...items];
|
|
const collageItems = orderedItems.map((item) => ({
|
|
targetUrl: item.targetUrl,
|
|
providerName: item.embed.provider_name || "",
|
|
type: item.embed.type || "rich",
|
|
title: item.embed.title || item.embed.provider_name || "",
|
|
url: item.embed.url || "",
|
|
html: prepareEmbedHtml(item.embed.html || "", { autoplay, muted }),
|
|
}));
|
|
const collageData = {
|
|
items: collageItems,
|
|
cardWidth,
|
|
columns,
|
|
repeatDistance,
|
|
hydrateDelay,
|
|
spacing,
|
|
duration,
|
|
repeat,
|
|
shuffle,
|
|
autoplay,
|
|
muted,
|
|
};
|
|
|
|
return `${commonHead({ title: "oEmbed Collage", htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
|
<body class="graphic collage-page ${transparent ? "transparent" : ""} is-ready" style="--stage-width:${width}px; --stage-height:${height}px; --chroma:${escapeHtml(chroma)}; --collage-spacing:${spacing}px; --collage-fade:${fade}px; --collage-columns:${columns}; --collage-card-width:${cardWidth}px; --collage-duration:${duration}s;">
|
|
<main class="collage-stage">
|
|
<div class="collage-track"></div>
|
|
</main>
|
|
<script id="collage-data" type="application/json">${safeJson(collageData)}</script>
|
|
<script type="module" src="/collage.js"></script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function graphicPage({
|
|
targetUrl,
|
|
embed,
|
|
width,
|
|
height,
|
|
fit,
|
|
transparent,
|
|
scale,
|
|
chroma,
|
|
autoplay = true,
|
|
muted = true,
|
|
wait = true,
|
|
readyDelay = 1000,
|
|
maxWait = 10000,
|
|
}) {
|
|
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
|
|
|
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
|
<body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
|
<main class="stage fit-${escapeHtml(fit)}">
|
|
${embedCardHtml({ targetUrl, embed, autoplay, muted })}
|
|
</main>
|
|
${wait ? revealScript({ readyDelay, maxWait }) : ""}
|
|
${autoplay ? autoplayAssistScript({ muted }) : ""}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function errorPage(message, status = 500) {
|
|
return `${commonHead({ title: `Error ${status}` })}
|
|
<body class="app">
|
|
<main class="shell">
|
|
<section class="panel error">
|
|
<h1>Error ${status}</h1>
|
|
<p>${escapeHtml(message)}</p>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|