width cap
Some checks failed
Build & Push Docker (latest) / build (push) Has been cancelled
Build & Push Docker (latest) / verify (push) Has been cancelled

This commit is contained in:
Aiden Wilson
2026-05-29 22:46:56 +10:00
parent 4595e782c8
commit a88306aec7
7 changed files with 575 additions and 20 deletions

View File

@@ -1,14 +1,28 @@
import { findProvider, loadProviders } from "./providers.js";
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 normalizedMaxWidth = normalizeMaxWidth(endpoint, maxWidth);
requestUrl.searchParams.set("url", targetUrl);
requestUrl.searchParams.set("format", "json");
if (maxWidth) {
requestUrl.searchParams.set("maxwidth", String(maxWidth));
if (normalizedMaxWidth) {
requestUrl.searchParams.set("maxwidth", String(normalizedMaxWidth));
}
if (maxHeight) {
requestUrl.searchParams.set("maxheight", String(maxHeight));
}
return requestUrl;
@@ -19,6 +33,7 @@ export async function fetchOembed(targetUrl, {
providers,
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
maxWidth,
maxHeight,
} = {}) {
let parsed;
@@ -49,7 +64,7 @@ export async function fetchOembed(targetUrl, {
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth), {
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth, maxHeight), {
headers: {
accept: "application/json",
"user-agent": "oembed-graphics/0.1",

View File

@@ -74,12 +74,18 @@ async function handleGraphic(requestUrl, response) {
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
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 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 { data } = await fetchOembed(url, { maxWidth });
const { data } = await fetchOembed(url, { maxWidth, maxHeight });
send(response, 200, graphicPage({
targetUrl: url,
@@ -88,6 +94,11 @@ async function handleGraphic(requestUrl, response) {
height,
fit,
transparent,
autoplay,
muted,
wait,
readyDelay,
maxWait,
scale,
chroma,
}));
@@ -122,8 +133,9 @@ async function handleRequest(request, response) {
if (requestUrl.pathname === "/api/oembed") {
const url = requestUrl.searchParams.get("url");
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 1280, 240, 3840);
const result = await fetchOembed(url, { maxWidth });
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
const result = await fetchOembed(url, { maxWidth, maxHeight });
sendJson(response, 200, result);
return;
}

View File

@@ -7,9 +7,9 @@ function escapeHtml(value = "") {
.replaceAll("'", "'");
}
function commonHead({ title = "oEmbed Graphics" } = {}) {
function commonHead({ title = "oEmbed Graphics", htmlClass = "" } = {}) {
return `<!doctype html>
<html lang="en">
<html lang="en"${htmlClass ? ` class="${escapeHtml(htmlClass)}"` : ""}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -18,6 +18,284 @@ function commonHead({ title = "oEmbed Graphics" } = {}) {
</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("&amp;", "&"));
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">
@@ -39,7 +317,10 @@ export function homePage({ providersCount = 0 } = {}) {
<option value="cover">cover</option>
</select>
</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>
</form>
<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,
scale,
chroma,
autoplay = true,
muted = true,
wait = true,
readyDelay = 1000,
maxWait = 10000,
}) {
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
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
: html;
return `${commonHead({ title })}
<body class="graphic ${transparent ? "transparent" : ""}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
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)}">
<section class="embed" data-source="${escapeHtml(targetUrl)}">
<section class="embed ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
${media}
</section>
</main>
${wait ? revealScript({ readyDelay, maxWait }) : ""}
${autoplay ? autoplayAssistScript({ muted }) : ""}
</body>
</html>`;
}