oEmbed Graphics
+ +${providersCount} provider URL patterns loaded. Use /graphic?url=... from CasparCG, OBS Browser Source, or OGraf.
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dcb9a0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.git +.env +coverage diff --git a/.gitea/workflows/docker-latest.yml b/.gitea/workflows/docker-latest.yml new file mode 100644 index 0000000..9dc4d9a --- /dev/null +++ b/.gitea/workflows/docker-latest.yml @@ -0,0 +1,80 @@ +name: Build & Push Docker (latest) + +on: + push: + branches: ["main"] + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + shell: bash + run: | + set -euo pipefail + npm ci + + - name: Typecheck + shell: bash + run: | + set -euo pipefail + npm run typecheck + + - name: Test + shell: bash + run: | + set -euo pipefail + npm test + + - name: Build app + shell: bash + run: | + set -euo pipefail + npm run build + + build: + needs: verify + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set image name + shell: bash + run: | + set -euo pipefail + OWNER="${GITHUB_REPOSITORY%/*}" + REPO="${GITHUB_REPOSITORY#*/}" + echo "IMAGE=git.f-40.com/${OWNER}/${REPO}:latest" >> "$GITHUB_ENV" + + - name: Login to Gitea Container Registry + shell: bash + env: + REGISTRY_USER: ${{ secrets.USER }} + REGISTRY_TOKEN: ${{ secrets.TOKEN }} + run: | + set -euo pipefail + echo "$REGISTRY_TOKEN" | docker login git.f-40.com -u "$REGISTRY_USER" --password-stdin + + - name: Build + shell: bash + run: | + set -euo pipefail + docker build -t "$IMAGE" . + + - name: Push + shell: bash + run: | + set -euo pipefail + docker push "$IMAGE" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7399442 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +ENV NODE_ENV=production +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY public ./public +COPY src ./src + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/README.md b/README.md index 3d09366..bb7ac43 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ # oembed-graphics -Embed social media posts using Oembed in a format that's callable from Caspar/OGraf \ No newline at end of file +Docker-hosted oEmbed graphics for broadcast workflows. It is inspired by +[webrecorder/oembed.link](https://github.com/webrecorder/oembed.link), but runs +as a normal container and exposes graphic URLs that can be loaded by CasparCG, +OBS Browser Source, OGraf, or any HTML-capable character generator. + +## Run locally + +```sh +npm install +npm start +``` + +Open `http://localhost:3000`. + +## Run with Docker + +```sh +docker compose up --build +``` + +The service listens on `http://localhost:3000`. + +## Broadcast URL + +Use `/graphic` with a source URL: + +```txt +http://localhost:3000/graphic?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ&width=1920&height=1080&transparent=1 +``` + +Useful query parameters: + +- `url`: required source URL to resolve through oEmbed. +- `width`: stage width, default `1920`. +- `height`: stage height, default `1080`. +- `transparent`: `1` for transparent output, default `1`. +- `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. + +The service also supports the oembed.link-style form: + +```txt +http://localhost:3000/https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ +``` + +## API + +- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data. +- `GET /providers` returns the loaded provider patterns. +- `GET /healthz` returns a health check response. + +Provider data is loaded from `https://oembed.com/providers.json` and cached in +memory. Override with `PROVIDERS_URL`, `PROVIDERS_TTL_MS`, and +`OEMBED_TIMEOUT_MS` environment variables. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba8be96 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + oembed-graphics: + build: . + ports: + - "3000:3000" + environment: + PORT: "3000" + HOST: "0.0.0.0" + PROVIDERS_TTL_MS: "43200000" + OEMBED_TIMEOUT_MS: "8000" + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9264408 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "oembed-graphics", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oembed-graphics", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..23eddc8 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "oembed-graphics", + "version": "0.1.0", + "description": "Docker-hosted oEmbed graphics service for broadcast overlays.", + "type": "module", + "scripts": { + "dev": "node --watch 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", + "build": "npm run typecheck", + "test": "node --test" + }, + "engines": { + "node": ">=20" + }, + "license": "AGPL-3.0-or-later" +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..5dc27cf --- /dev/null +++ b/public/styles.css @@ -0,0 +1,177 @@ +:root { + color-scheme: light; + font-family: Arial, Helvetica, sans-serif; + background: #f4f6f8; + color: #1d252d; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + min-height: 100%; + margin: 0; +} + +.app { + display: grid; + min-height: 100vh; + place-items: center; + padding: 32px; +} + +.shell { + width: min(920px, 100%); +} + +.panel { + display: grid; + gap: 20px; + padding: 28px; + border: 1px solid #d9e0e7; + border-radius: 8px; + background: #fff; + box-shadow: 0 14px 38px rgb(18 28 45 / 10%); +} + +h1, +p { + margin: 0; +} + +h1 { + font-size: 28px; + line-height: 1.15; +} + +form { + display: grid; + gap: 14px; +} + +label { + display: grid; + gap: 6px; + font-size: 13px; + font-weight: 700; +} + +.row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +input, +select, +button { + min-height: 42px; + border: 1px solid #c8d2dc; + border-radius: 6px; + font: inherit; +} + +input, +select { + width: 100%; + padding: 0 12px; + background: #fff; +} + +button { + padding: 0 18px; + border-color: #153f66; + background: #153f66; + color: #fff; + font-weight: 700; + cursor: pointer; +} + +.controls { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} + +.controls label:last-child { + display: flex; + min-height: 42px; + align-items: center; + gap: 8px; +} + +.controls input[type="checkbox"] { + width: 16px; + min-height: 16px; +} + +code { + font-family: "Cascadia Mono", Consolas, monospace; +} + +.graphic { + width: 100vw; + height: 100vh; + overflow: hidden; + background: var(--chroma); +} + +.graphic.transparent { + background: transparent; +} + +.stage { + position: relative; + display: grid; + width: min(100vw, var(--stage-width)); + height: min(100vh, var(--stage-height)); + margin: 0 auto; + place-items: center; + overflow: hidden; +} + +.embed { + display: grid; + max-width: 92%; + max-height: 92%; + place-items: center; + transform: scale(var(--scale)); + transform-origin: center; +} + +.embed > iframe, +.embed > blockquote, +.embed > img, +.embed > video { + display: block; + max-width: 100%; + max-height: 100%; + border: 0; +} + +.fit-cover .embed > iframe, +.fit-cover .embed > img, +.fit-cover .embed > video { + width: 100vw; + height: 100vh; + object-fit: cover; +} + +.error { + border-color: #d77; +} + +@media (max-width: 720px) { + .app { + padding: 16px; + } + + .row, + .controls { + grid-template-columns: 1fr; + } +} diff --git a/src/oembed.js b/src/oembed.js new file mode 100644 index 0000000..767321f --- /dev/null +++ b/src/oembed.js @@ -0,0 +1,75 @@ +import { findProvider, loadProviders } from "./providers.js"; + +const DEFAULT_TIMEOUT_MS = 8000; + +export function buildOembedUrl(endpoint, targetUrl, maxWidth) { + const requestUrl = new URL(endpoint); + requestUrl.searchParams.set("url", targetUrl); + requestUrl.searchParams.set("format", "json"); + + if (maxWidth) { + requestUrl.searchParams.set("maxwidth", String(maxWidth)); + } + + return requestUrl; +} + +export async function fetchOembed(targetUrl, { + fetchImpl = fetch, + providers, + timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS), + maxWidth, +} = {}) { + let parsed; + + try { + parsed = new URL(targetUrl); + } catch { + const error = new Error("The url parameter must be an absolute URL."); + error.status = 400; + throw error; + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + const error = new Error("Only http and https URLs are supported."); + error.status = 400; + throw error; + } + + const availableProviders = providers || await loadProviders({ fetchImpl }); + const provider = findProvider(targetUrl, availableProviders); + + if (!provider) { + const error = new Error("No oEmbed provider matched this URL."); + error.status = 404; + throw error; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth), { + headers: { + accept: "application/json", + "user-agent": "oembed-graphics/0.1", + }, + signal: controller.signal, + }); + + if (!response.ok) { + const error = new Error(`oEmbed provider returned ${response.status}.`); + error.status = response.status; + throw error; + } + + const data = await response.json(); + + return { + provider, + data, + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/providers.js b/src/providers.js new file mode 100644 index 0000000..e778a03 --- /dev/null +++ b/src/providers.js @@ -0,0 +1,83 @@ +const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json"; +const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; + +let providerCache = { + expiresAt: 0, + providers: [], +}; + +export function wildcardToRegExp(pattern) { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +export function normalizeEndpoint(endpoint) { + return endpoint.replace("{format}", "json"); +} + +export function flattenProviders(rawProviders) { + const providers = []; + + for (const provider of rawProviders) { + for (const endpoint of provider.endpoints || []) { + const schemes = endpoint.schemes || []; + + for (const scheme of schemes) { + providers.push({ + providerName: provider.provider_name, + providerUrl: provider.provider_url, + endpoint: normalizeEndpoint(endpoint.url), + scheme, + regex: wildcardToRegExp(scheme), + }); + } + } + } + + return providers; +} + +export function findProvider(url, providers) { + return providers.find((provider) => provider.regex.test(url)); +} + +export async function loadProviders({ + providersUrl = process.env.PROVIDERS_URL || DEFAULT_PROVIDERS_URL, + ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS), + fetchImpl = fetch, + force = false, +} = {}) { + const now = Date.now(); + + if (!force && providerCache.providers.length && providerCache.expiresAt > now) { + return providerCache.providers; + } + + const response = await fetchImpl(providersUrl, { + headers: { + accept: "application/json", + "user-agent": "oembed-graphics/0.1", + }, + }); + + if (!response.ok) { + throw new Error(`Could not load providers: ${response.status} ${response.statusText}`); + } + + const rawProviders = await response.json(); + const providers = flattenProviders(rawProviders); + + providerCache = { + expiresAt: now + ttlMs, + providers, + }; + + return providers; +} + +export function clearProviderCache() { + providerCache = { + expiresAt: 0, + providers: [], + }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..686444a --- /dev/null +++ b/src/server.js @@ -0,0 +1,160 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fetchOembed } from "./oembed.js"; +import { loadProviders } from "./providers.js"; +import { errorPage, graphicPage, homePage } from "./templates.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const rootDir = join(__dirname, ".."); +const publicDir = join(rootDir, "public"); + +const port = Number(process.env.PORT || 3000); +const host = process.env.HOST || "0.0.0.0"; + +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; + +function send(response, status, body, contentType = "text/html; charset=utf-8") { + response.writeHead(status, { + "content-type": contentType, + "cache-control": status === 200 ? "public, max-age=60" : "no-store", + "x-content-type-options": "nosniff", + }); + response.end(body); +} + +function sendJson(response, status, body) { + send(response, status, JSON.stringify(body, null, 2), "application/json; charset=utf-8"); +} + +function getBoolean(searchParams, key, defaultValue = false) { + if (!searchParams.has(key)) { + return defaultValue; + } + + return ["1", "true", "yes", "on"].includes(searchParams.get(key).toLowerCase()); +} + +function getNumber(searchParams, key, defaultValue, min, max) { + const value = Number(searchParams.get(key) || defaultValue); + + if (!Number.isFinite(value)) { + return defaultValue; + } + + return Math.min(Math.max(value, min), max); +} + +async function serveStatic(requestUrl, response) { + const path = requestUrl.pathname === "/styles.css" ? "styles.css" : ""; + + if (!path) { + return false; + } + + const body = await readFile(join(publicDir, path), "utf8"); + send(response, 200, body, contentTypes[extname(path)] || "text/plain; charset=utf-8"); + return true; +} + +async function handleGraphic(requestUrl, response) { + const url = requestUrl.searchParams.get("url") || decodeURIComponent(requestUrl.pathname.replace(/^\/+/, "")); + + if (!url) { + send(response, 400, errorPage("Add a url query parameter, for example /graphic?url=https://...", 400), "text/html; charset=utf-8"); + return; + } + + 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 fit = requestUrl.searchParams.get("fit") === "cover" ? "cover" : "contain"; + const transparent = getBoolean(requestUrl.searchParams, "transparent", true); + const chroma = requestUrl.searchParams.get("chroma") || "#00ff00"; + + const { data } = await fetchOembed(url, { maxWidth }); + + send(response, 200, graphicPage({ + targetUrl: url, + embed: data, + width, + height, + fit, + transparent, + scale, + chroma, + })); +} + +async function handleRequest(request, response) { + const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`); + + try { + if (await serveStatic(requestUrl, response)) { + return; + } + + if (requestUrl.pathname === "/healthz") { + sendJson(response, 200, { ok: true }); + return; + } + + if (requestUrl.pathname === "/providers") { + const providers = await loadProviders(); + sendJson(response, 200, { + count: providers.length, + providers: providers.map(({ providerName, providerUrl, endpoint, scheme }) => ({ + providerName, + providerUrl, + endpoint, + scheme, + })), + }); + return; + } + + 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 }); + sendJson(response, 200, result); + return; + } + + if (requestUrl.pathname === "/" && !requestUrl.searchParams.has("url")) { + const providers = await loadProviders(); + send(response, 200, homePage({ providersCount: providers.length })); + return; + } + + if (requestUrl.pathname === "/" || requestUrl.pathname === "/graphic" || requestUrl.pathname.length > 1) { + await handleGraphic(requestUrl, response); + return; + } + + send(response, 404, errorPage("Not found.", 404)); + } catch (error) { + const status = error.status || 500; + const acceptsHtml = request.headers.accept?.includes("text/html"); + const message = error.name === "AbortError" ? "The oEmbed provider timed out." : error.message; + + if (acceptsHtml) { + send(response, status, errorPage(message, status)); + } else { + sendJson(response, status, { error: message }); + } + } +} + +const server = createServer(handleRequest); + +server.listen(port, host, () => { + console.log(`oembed-graphics listening on http://${host}:${port}`); +}); diff --git a/src/templates.js b/src/templates.js new file mode 100644 index 0000000..3119214 --- /dev/null +++ b/src/templates.js @@ -0,0 +1,90 @@ +function escapeHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function commonHead({ title = "oEmbed Graphics" } = {}) { + return ` + +
+ + +${providersCount} provider URL patterns loaded. Use /graphic?url=... from CasparCG, OBS Browser Source, or OGraf.