initial commit
All checks were successful
Build & Push Docker (latest) / verify (push) Successful in 9m26s
Build & Push Docker (latest) / build (push) Successful in 14s

This commit is contained in:
Aiden Wilson
2026-05-29 22:24:09 +10:00
parent b667f143ab
commit 4595e782c8
13 changed files with 823 additions and 1 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
npm-debug.log
.git
.env
coverage

View File

@@ -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"

13
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,3 +1,58 @@
# oembed-graphics
Embed social media posts using Oembed in a format that's callable from Caspar/OGraf
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.

11
docker-compose.yml Normal file
View File

@@ -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

16
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

17
package.json Normal file
View File

@@ -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"
}

177
public/styles.css Normal file
View File

@@ -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;
}
}

75
src/oembed.js Normal file
View File

@@ -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);
}
}

83
src/providers.js Normal file
View File

@@ -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: [],
};
}

160
src/server.js Normal file
View File

@@ -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}`);
});

90
src/templates.js Normal file
View File

@@ -0,0 +1,90 @@
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function commonHead({ title = "oEmbed Graphics" } = {}) {
return `<!doctype html>
<html lang="en">
<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>`;
}
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><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>
</section>
</main>
</body>
</html>`;
}
export function graphicPage({
targetUrl,
embed,
width,
height,
fit,
transparent,
scale,
chroma,
}) {
const title = embed.title || embed.provider_name || "oEmbed Graphic";
const html = embed.html || "";
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)};">
<main class="stage fit-${escapeHtml(fit)}">
<section class="embed" data-source="${escapeHtml(targetUrl)}">
${media}
</section>
</main>
</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>`;
}

40
test/providers.test.js Normal file
View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildOembedUrl } from "../src/oembed.js";
import { findProvider, flattenProviders, wildcardToRegExp } from "../src/providers.js";
test("wildcard provider schemes match target URLs", () => {
const regex = wildcardToRegExp("https://*.example.com/watch*");
assert.equal(regex.test("https://video.example.com/watch?v=abc"), true);
assert.equal(regex.test("https://example.org/watch?v=abc"), false);
});
test("flattens providers and finds a matching provider", () => {
const providers = flattenProviders([
{
provider_name: "Example",
provider_url: "https://example.com",
endpoints: [
{
schemes: ["https://*.example.com/watch*"],
url: "https://example.com/oembed.{format}",
},
],
},
]);
const provider = findProvider("https://video.example.com/watch?id=1", providers);
assert.equal(provider.providerName, "Example");
assert.equal(provider.endpoint, "https://example.com/oembed.json");
});
test("builds oEmbed provider request URLs", () => {
const url = buildOembedUrl("https://example.com/oembed", "https://video.example.com/watch?id=1", 1280);
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("maxwidth"), "1280");
});