width cap
This commit is contained in:
20
README.md
20
README.md
@@ -39,7 +39,25 @@ Useful query parameters:
|
|||||||
- `chroma`: background color when transparent output is disabled, default `#00ff00`.
|
- `chroma`: background color when transparent output is disabled, default `#00ff00`.
|
||||||
- `fit`: `contain` or `cover`, default `contain`.
|
- `fit`: `contain` or `cover`, default `contain`.
|
||||||
- `scale`: graphic scale multiplier, default `1`.
|
- `scale`: graphic scale multiplier, default `1`.
|
||||||
- `maxwidth`: sent to the oEmbed provider, default is based on stage width.
|
- `wait`: `1` to keep the graphic hidden until embed media loads, default `1`.
|
||||||
|
- `readyDelay`: extra milliseconds to wait after media load before reveal, default `1000`.
|
||||||
|
- `maxWait`: safety timeout before reveal, default `10000`.
|
||||||
|
- `autoplay`: `1` to request autoplay for iframe and video embeds, default `1`.
|
||||||
|
- `muted`: `1` to mute embeds, default `1`. Most browsers require this for autoplay.
|
||||||
|
- `maxwidth`: sent to the oEmbed provider and capped at `500`, default `500`.
|
||||||
|
- `maxheight`: sent to the oEmbed provider, default `480`.
|
||||||
|
|
||||||
|
Autoplay is best-effort for social embeds. The graphic page keeps the original
|
||||||
|
social post HTML, adds provider autoplay parameters where possible, and runs a
|
||||||
|
small assist script that calls `play()` on any video element it can access.
|
||||||
|
Browsers still block scripts from controlling video inside cross-origin iframes,
|
||||||
|
which includes Twitter/X widget frames. For broadcast runtimes based on Chromium
|
||||||
|
or CEF, also allow autoplay at the browser layer when possible, for example with
|
||||||
|
`--autoplay-policy=no-user-gesture-required`.
|
||||||
|
|
||||||
|
The renderer caps every social card at `500px` wide so different providers line
|
||||||
|
up more consistently on a broadcast canvas. It still uses the oEmbed response's
|
||||||
|
height when present.
|
||||||
|
|
||||||
The service also supports the oembed.link-style form:
|
The service also supports the oembed.link-style form:
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graphic-document {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--chroma);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic-document.transparent {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -92,12 +102,12 @@ button {
|
|||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls label:last-child {
|
.checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -124,6 +134,14 @@ code {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graphic.is-loading .stage {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic.is-ready .stage {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -132,10 +150,14 @@ code {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 120ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed {
|
.embed {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: var(--embed-width, auto);
|
||||||
|
height: var(--embed-height, auto);
|
||||||
max-width: 92%;
|
max-width: 92%;
|
||||||
max-height: 92%;
|
max-height: 92%;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { findProvider, loadProviders } from "./providers.js";
|
import { findProvider, loadProviders } from "./providers.js";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 8000;
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
|
const DEFAULT_MAX_WIDTH = 500;
|
||||||
|
|
||||||
export function buildOembedUrl(endpoint, targetUrl, maxWidth) {
|
function normalizeMaxWidth(endpoint, maxWidth) {
|
||||||
|
if (!maxWidth) {
|
||||||
|
return maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(maxWidth, DEFAULT_MAX_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOembedUrl(endpoint, targetUrl, maxWidth, maxHeight) {
|
||||||
const requestUrl = new URL(endpoint);
|
const requestUrl = new URL(endpoint);
|
||||||
|
const normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth);
|
||||||
requestUrl.searchParams.set("url", targetUrl);
|
requestUrl.searchParams.set("url", targetUrl);
|
||||||
requestUrl.searchParams.set("format", "json");
|
requestUrl.searchParams.set("format", "json");
|
||||||
|
|
||||||
if (maxWidth) {
|
if (normalizedMaxWidth) {
|
||||||
requestUrl.searchParams.set("maxwidth", String(maxWidth));
|
requestUrl.searchParams.set("maxwidth", String(normalizedMaxWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxHeight) {
|
||||||
|
requestUrl.searchParams.set("maxheight", String(maxHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestUrl;
|
return requestUrl;
|
||||||
@@ -19,6 +33,7 @@ export async function fetchOembed(targetUrl, {
|
|||||||
providers,
|
providers,
|
||||||
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
let parsed;
|
let parsed;
|
||||||
|
|
||||||
@@ -49,7 +64,7 @@ export async function fetchOembed(targetUrl, {
|
|||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth), {
|
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth, maxHeight), {
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"user-agent": "oembed-graphics/0.1",
|
"user-agent": "oembed-graphics/0.1",
|
||||||
|
|||||||
@@ -74,12 +74,18 @@ async function handleGraphic(requestUrl, response) {
|
|||||||
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
|
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
|
||||||
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
|
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
|
||||||
const scale = getNumber(requestUrl.searchParams, "scale", 1, 0.25, 3);
|
const scale = getNumber(requestUrl.searchParams, "scale", 1, 0.25, 3);
|
||||||
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", Math.round(width * 0.72), 240, 3840);
|
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
|
||||||
|
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
|
||||||
|
const readyDelay = getNumber(requestUrl.searchParams, "readyDelay", 1000, 0, 30000);
|
||||||
|
const maxWait = getNumber(requestUrl.searchParams, "maxWait", 10000, 1000, 60000);
|
||||||
const fit = requestUrl.searchParams.get("fit") === "cover" ? "cover" : "contain";
|
const fit = requestUrl.searchParams.get("fit") === "cover" ? "cover" : "contain";
|
||||||
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
|
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
|
||||||
|
const autoplay = getBoolean(requestUrl.searchParams, "autoplay", true);
|
||||||
|
const muted = getBoolean(requestUrl.searchParams, "muted", true);
|
||||||
|
const wait = getBoolean(requestUrl.searchParams, "wait", true);
|
||||||
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
|
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
|
||||||
|
|
||||||
const { data } = await fetchOembed(url, { maxWidth });
|
const { data } = await fetchOembed(url, { maxWidth, maxHeight });
|
||||||
|
|
||||||
send(response, 200, graphicPage({
|
send(response, 200, graphicPage({
|
||||||
targetUrl: url,
|
targetUrl: url,
|
||||||
@@ -88,6 +94,11 @@ async function handleGraphic(requestUrl, response) {
|
|||||||
height,
|
height,
|
||||||
fit,
|
fit,
|
||||||
transparent,
|
transparent,
|
||||||
|
autoplay,
|
||||||
|
muted,
|
||||||
|
wait,
|
||||||
|
readyDelay,
|
||||||
|
maxWait,
|
||||||
scale,
|
scale,
|
||||||
chroma,
|
chroma,
|
||||||
}));
|
}));
|
||||||
@@ -122,8 +133,9 @@ async function handleRequest(request, response) {
|
|||||||
|
|
||||||
if (requestUrl.pathname === "/api/oembed") {
|
if (requestUrl.pathname === "/api/oembed") {
|
||||||
const url = requestUrl.searchParams.get("url");
|
const url = requestUrl.searchParams.get("url");
|
||||||
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 1280, 240, 3840);
|
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
|
||||||
const result = await fetchOembed(url, { maxWidth });
|
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
|
||||||
|
const result = await fetchOembed(url, { maxWidth, maxHeight });
|
||||||
sendJson(response, 200, result);
|
sendJson(response, 200, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
309
src/templates.js
309
src/templates.js
@@ -7,9 +7,9 @@ function escapeHtml(value = "") {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function commonHead({ title = "oEmbed Graphics" } = {}) {
|
function commonHead({ title = "oEmbed Graphics", htmlClass = "" } = {}) {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en"${htmlClass ? ` class="${escapeHtml(htmlClass)}"` : ""}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -18,6 +18,284 @@ function commonHead({ title = "oEmbed Graphics" } = {}) {
|
|||||||
</head>`;
|
</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 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 } = {}) {
|
export function homePage({ providersCount = 0 } = {}) {
|
||||||
return `${commonHead()}
|
return `${commonHead()}
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -39,7 +317,10 @@ export function homePage({ providersCount = 0 } = {}) {
|
|||||||
<option value="cover">cover</option>
|
<option value="cover">cover</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label><input name="transparent" value="1" type="checkbox" checked> transparent</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
||||||
@@ -58,20 +339,34 @@ export function graphicPage({
|
|||||||
transparent,
|
transparent,
|
||||||
scale,
|
scale,
|
||||||
chroma,
|
chroma,
|
||||||
|
autoplay = true,
|
||||||
|
muted = true,
|
||||||
|
wait = true,
|
||||||
|
readyDelay = 1000,
|
||||||
|
maxWait = 10000,
|
||||||
}) {
|
}) {
|
||||||
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
||||||
const html = embed.html || "";
|
const providerClass = `provider-${slugify(embed.provider_name)}`;
|
||||||
|
const embedWidth = cappedPixelValue(embed.width, 500);
|
||||||
|
const embedHeight = numericPixelValue(embed.height);
|
||||||
|
const embedStyle = [
|
||||||
|
embedWidth ? `--embed-width:${embedWidth}` : "",
|
||||||
|
embedHeight ? `--embed-height:${embedHeight}` : "",
|
||||||
|
].filter(Boolean).join(";");
|
||||||
|
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
|
||||||
const media = embed.type === "photo" && embed.url
|
const media = embed.type === "photo" && embed.url
|
||||||
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
|
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
|
||||||
: html;
|
: html;
|
||||||
|
|
||||||
return `${commonHead({ title })}
|
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
||||||
<body class="graphic ${transparent ? "transparent" : ""}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
<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)}">
|
<main class="stage fit-${escapeHtml(fit)}">
|
||||||
<section class="embed" data-source="${escapeHtml(targetUrl)}">
|
<section class="embed ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
|
||||||
${media}
|
${media}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
${wait ? revealScript({ readyDelay, maxWait }) : ""}
|
||||||
|
${autoplay ? autoplayAssistScript({ muted }) : ""}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,16 @@ test("flattens providers and finds a matching provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("builds oEmbed provider request URLs", () => {
|
test("builds oEmbed provider request URLs", () => {
|
||||||
const url = buildOembedUrl("https://example.com/oembed", "https://video.example.com/watch?id=1", 1280);
|
const url = buildOembedUrl("https://example.com/oembed", "https://video.example.com/watch?id=1", 500, 480);
|
||||||
|
|
||||||
assert.equal(url.searchParams.get("url"), "https://video.example.com/watch?id=1");
|
assert.equal(url.searchParams.get("url"), "https://video.example.com/watch?id=1");
|
||||||
assert.equal(url.searchParams.get("format"), "json");
|
assert.equal(url.searchParams.get("format"), "json");
|
||||||
assert.equal(url.searchParams.get("maxwidth"), "1280");
|
assert.equal(url.searchParams.get("maxwidth"), "500");
|
||||||
|
assert.equal(url.searchParams.get("maxheight"), "480");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps all oEmbed maxwidth values to 500", () => {
|
||||||
|
const url = buildOembedUrl("https://example.com/oembed", "https://video.example.com/watch?id=1", 1280);
|
||||||
|
|
||||||
|
assert.equal(url.searchParams.get("maxwidth"), "500");
|
||||||
});
|
});
|
||||||
|
|||||||
186
test/templates.test.js
Normal file
186
test/templates.test.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { 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>');
|
||||||
|
|
||||||
|
assert.match(html, /allow="autoplay; fullscreen; picture-in-picture"/);
|
||||||
|
assert.match(html, /autoplay=1/);
|
||||||
|
assert.match(html, /playsinline=1/);
|
||||||
|
assert.match(html, /mute=1/);
|
||||||
|
assert.match(html, /muted=1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds autoplay attributes to video tags", () => {
|
||||||
|
const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>');
|
||||||
|
|
||||||
|
assert.match(html, /<video\b[^>]*playsinline/);
|
||||||
|
assert.match(html, /<video\b[^>]*autoplay/);
|
||||||
|
assert.match(html, /<video\b[^>]*muted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks transparent graphic documents at the html and body level", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<iframe src="https://player.example.com/video"></iframe>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /<html lang="en" class="graphic-document transparent">/);
|
||||||
|
assert.match(page, /<body class="graphic transparent is-loading"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes readiness script when wait mode is enabled", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<iframe src="https://player.example.com/video"></iframe>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /new MutationObserver/);
|
||||||
|
assert.match(page, /node\.querySelectorAll\("iframe, img, video"\)/);
|
||||||
|
assert.match(page, /body\.classList\.add\("is-ready"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes configurable reveal delay values", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<iframe src="https://player.example.com/video"></iframe>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
wait: true,
|
||||||
|
readyDelay: 2500,
|
||||||
|
maxWait: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /const readyDelay = 2500;/);
|
||||||
|
assert.match(page, /const maxWait = 15000;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can render immediately when wait mode is disabled", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<iframe src="https://player.example.com/video"></iframe>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
wait: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /<body class="graphic transparent is-ready"/);
|
||||||
|
assert.doesNotMatch(page, /document\.querySelectorAll/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes autoplay assist when autoplay is enabled", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<blockquote class="twitter-tweet"></blockquote>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
autoplay: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /const playVideo = \(video\) =>/);
|
||||||
|
assert.match(page, /iframe\.contentDocument\.querySelectorAll\("video"\)/);
|
||||||
|
assert.match(page, /Cross-origin provider frames cannot be inspected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds a provider class for Bluesky embeds", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://bsky.app/profile/example.com/post/abc",
|
||||||
|
embed: {
|
||||||
|
provider_name: "Bluesky",
|
||||||
|
type: "rich",
|
||||||
|
width: 600,
|
||||||
|
height: 480,
|
||||||
|
html: '<blockquote class="bluesky-embed"></blockquote>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /<section class="embed provider-bluesky"/);
|
||||||
|
assert.match(page, /--embed-width:500px;--embed-height:480px/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps rendered embed width at 500px", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
provider_name: "Example",
|
||||||
|
type: "rich",
|
||||||
|
width: 1200,
|
||||||
|
html: '<iframe src="https://player.example.com/video"></iframe>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /--embed-width:500px/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits autoplay assist when autoplay is disabled", () => {
|
||||||
|
const page = graphicPage({
|
||||||
|
targetUrl: "https://example.com/video",
|
||||||
|
embed: {
|
||||||
|
type: "video",
|
||||||
|
html: '<video src="https://cdn.example.com/video.mp4"></video>',
|
||||||
|
},
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fit: "contain",
|
||||||
|
transparent: true,
|
||||||
|
scale: 1,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
autoplay: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotMatch(page, /const playVideo = \(video\) =>/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user