diff --git a/README.md b/README.md index bb7ac43..14a86ac 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,25 @@ Useful query parameters: - `chroma`: background color when transparent output is disabled, default `#00ff00`. - `fit`: `contain` or `cover`, default `contain`. - `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: diff --git a/public/styles.css b/public/styles.css index 5dc27cf..dd51817 100644 --- a/public/styles.css +++ b/public/styles.css @@ -16,6 +16,16 @@ body { margin: 0; } +.graphic-document { + width: 100%; + height: 100%; + background: var(--chroma); +} + +.graphic-document.transparent { + background: transparent; +} + .app { display: grid; min-height: 100vh; @@ -92,12 +102,12 @@ button { .controls { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 10px; align-items: end; } -.controls label:last-child { +.checkbox-label { display: flex; min-height: 42px; align-items: center; @@ -124,6 +134,14 @@ code { background: transparent; } +.graphic.is-loading .stage { + opacity: 0; +} + +.graphic.is-ready .stage { + opacity: 1; +} + .stage { position: relative; display: grid; @@ -132,10 +150,14 @@ code { margin: 0 auto; place-items: center; overflow: hidden; + opacity: 1; + transition: opacity 120ms linear; } .embed { display: grid; + width: var(--embed-width, auto); + height: var(--embed-height, auto); max-width: 92%; max-height: 92%; place-items: center; diff --git a/src/oembed.js b/src/oembed.js index 767321f..197911c 100644 --- a/src/oembed.js +++ b/src/oembed.js @@ -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", diff --git a/src/server.js b/src/server.js index 686444a..46a91a8 100644 --- a/src/server.js +++ b/src/server.js @@ -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; } diff --git a/src/templates.js b/src/templates.js index 3119214..90fd33e 100644 --- a/src/templates.js +++ b/src/templates.js @@ -7,9 +7,9 @@ function escapeHtml(value = "") { .replaceAll("'", "'"); } -function commonHead({ title = "oEmbed Graphics" } = {}) { +function commonHead({ title = "oEmbed Graphics", htmlClass = "" } = {}) { return ` - + @@ -18,6 +18,284 @@ function commonHead({ title = "oEmbed Graphics" } = {}) { `; } +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(/'); + + 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(''); + + assert.match(html, /]*playsinline/); + assert.match(html, /]*autoplay/); + assert.match(html, /]*muted/); +}); + +test("marks transparent graphic documents at the html and body level", () => { + const page = graphicPage({ + targetUrl: "https://example.com/video", + embed: { + type: "video", + html: '', + }, + width: 1920, + height: 1080, + fit: "contain", + transparent: true, + scale: 1, + chroma: "#00ff00", + }); + + assert.match(page, //); + assert.match(page, / { + const page = graphicPage({ + targetUrl: "https://example.com/video", + embed: { + type: "video", + html: '', + }, + 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: '', + }, + 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: '', + }, + width: 1920, + height: 1080, + fit: "contain", + transparent: true, + scale: 1, + chroma: "#00ff00", + wait: false, + }); + + assert.match(page, / { + const page = graphicPage({ + targetUrl: "https://example.com/video", + embed: { + type: "video", + html: '', + }, + 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: '
', + }, + width: 1920, + height: 1080, + fit: "contain", + transparent: true, + scale: 1, + chroma: "#00ff00", + }); + + assert.match(page, /
{ + const page = graphicPage({ + targetUrl: "https://example.com/video", + embed: { + provider_name: "Example", + type: "rich", + width: 1200, + html: '', + }, + 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: '', + }, + width: 1920, + height: 1080, + fit: "contain", + transparent: true, + scale: 1, + chroma: "#00ff00", + autoplay: false, + }); + + assert.doesNotMatch(page, /const playVideo = \(video\) =>/); +});