This commit is contained in:
Aiden Wilson
2026-05-29 23:19:20 +10:00
parent bba1ab5cee
commit 4b488913e4
5 changed files with 449 additions and 18 deletions

View File

@@ -92,10 +92,40 @@ The template also accepts a raw URL string or standard CasparCG XML
`templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`,
`muted`, `readyDelay`, and `maxWait`.
## Collage
Use `/collage` to render multiple social posts as a vertically scrolling strip.
Posts are repeated in the track so the motion keeps filling gaps.
Repeated `url` parameters:
```txt
http://localhost:3000/collage?url=https%3A%2F%2Fbsky.app%2F...&url=https%3A%2F%2Fx.com%2F...
```
Or a JSON array:
```txt
http://localhost:3000/collage?urls=%5B%22https%3A%2F%2Fbsky.app%2F...%22%2C%22https%3A%2F%2Fx.com%2F...%22%5D
```
Useful collage parameters:
- `spacing`: pixels between a post and anything else, including screen edges and other posts, default `48`.
- `fade`: optional pixels used to fade posts in/out at the top and bottom edges, default `0`.
- `columns`: number of post columns, default `3`.
- `duration`: seconds per scroll loop, default `360`.
- `repeat`: number of times to repeat the post list in each half of the loop, default `4`.
- `shuffle`: `1` to randomize post order on each page load, default `1`.
Collage card width is calculated from `width`, `spacing`, and `columns`,
then capped at `500px`, so all columns fit within the configured screen width.
## API
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data.
- `GET /caspar` returns the CasparCG update-driven HTML template.
- `GET /collage?url=...&url=...` returns a scrolling social post collage.
- `GET /providers` returns the loaded provider patterns.
- `GET /healthz` returns a health check response.

View File

@@ -77,6 +77,7 @@ label {
input,
select,
textarea,
button {
min-height: 42px;
border: 1px solid #c8d2dc;
@@ -85,12 +86,19 @@ button {
}
input,
select {
select,
textarea {
width: 100%;
padding: 0 12px;
background: #fff;
}
textarea {
min-height: 128px;
padding-block: 10px;
resize: vertical;
}
button {
padding: 0 18px;
border-color: #153f66;
@@ -107,6 +115,15 @@ button {
align-items: end;
}
.collage-form {
padding-top: 20px;
border-top: 1px solid #d9e0e7;
}
.collage-controls {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.checkbox-label {
display: flex;
min-height: 42px;
@@ -183,6 +200,92 @@ code {
object-fit: cover;
}
.collage-page {
background: var(--chroma);
}
.collage-page.transparent {
background: transparent;
}
.collage-stage {
width: min(100vw, var(--stage-width));
height: min(100vh, var(--stage-height));
overflow: hidden;
display: grid;
justify-items: center;
padding: var(--collage-spacing);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 var(--collage-fade),
#000 calc(100% - var(--collage-fade)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 var(--collage-fade),
#000 calc(100% - var(--collage-fade)),
transparent 100%
);
}
.collage-track {
display: flex;
flex-direction: column;
width: max-content;
align-items: stretch;
gap: var(--collage-spacing);
animation: collage-scroll var(--collage-duration) linear infinite;
will-change: transform;
}
.collage-group {
display: grid;
grid-template-columns: repeat(var(--collage-columns), minmax(0, var(--collage-card-width)));
gap: var(--collage-spacing);
}
.collage-card {
align-self: start;
width: 100%;
height: auto;
max-width: var(--collage-card-width);
overflow: hidden;
transform: none;
}
.collage-card > iframe,
.collage-card > blockquote,
.collage-card > img,
.collage-card > video {
width: 100% !important;
max-width: 100% !important;
}
.collage-card iframe,
.collage-card blockquote,
.collage-card img,
.collage-card video,
.collage-card twitter-widget,
.collage-card .twitter-tweet,
.collage-card .bluesky-embed {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
@keyframes collage-scroll {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-50% - (var(--collage-spacing) / 2)));
}
}
.error {
border-color: #d77;
}

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { fetchOembed } from "./oembed.js";
import { loadProviders } from "./providers.js";
import { casparTemplatePage, errorPage, graphicPage, homePage } from "./templates.js";
import { casparTemplatePage, collagePage, errorPage, graphicPage, homePage } from "./templates.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const rootDir = join(__dirname, "..");
@@ -51,6 +51,35 @@ function getNumber(searchParams, key, defaultValue, min, max) {
return Math.min(Math.max(value, min), max);
}
function parseUrlList(searchParams) {
const repeatedUrls = searchParams.getAll("url").filter(Boolean);
if (repeatedUrls.length) {
return repeatedUrls;
}
const urlsValue = searchParams.get("urls") || "";
if (!urlsValue) {
return [];
}
try {
const parsed = JSON.parse(urlsValue);
if (Array.isArray(parsed)) {
return parsed.filter((url) => typeof url === "string" && url.trim());
}
} catch {
// Fall through to comma/newline parsing.
}
return urlsValue
.split(/[\n,]/)
.map((url) => url.trim())
.filter(Boolean);
}
async function serveStatic(requestUrl, response) {
const staticPaths = new Map([
["/styles.css", "styles.css"],
@@ -108,6 +137,64 @@ async function handleGraphic(requestUrl, response) {
}));
}
async function handleCollage(requestUrl, response) {
const urls = parseUrlList(requestUrl.searchParams).slice(0, 24);
if (!urls.length) {
send(response, 400, errorPage("Add repeated url parameters or a urls JSON array.", 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 maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
const legacySpacing = requestUrl.searchParams.get("gap") || requestUrl.searchParams.get("padding");
const spacing = getNumber(requestUrl.searchParams, "spacing", Number(legacySpacing || 48), 0, 400);
const fade = getNumber(requestUrl.searchParams, "fade", 0, 0, 600);
const columns = getNumber(requestUrl.searchParams, "columns", 3, 1, 8);
const duration = getNumber(requestUrl.searchParams, "duration", 360, 10, 1200);
const repeat = getNumber(requestUrl.searchParams, "repeat", 4, 2, 12);
const shuffle = getBoolean(requestUrl.searchParams, "shuffle", true);
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
const autoplay = getBoolean(requestUrl.searchParams, "autoplay", true);
const muted = getBoolean(requestUrl.searchParams, "muted", true);
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
const results = await Promise.allSettled(
urls.map(async (url) => {
const { data } = await fetchOembed(url, { maxWidth, maxHeight });
return {
targetUrl: url,
embed: data,
};
}),
);
const items = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
if (!items.length) {
throw new Error("No collage URLs could be resolved through oEmbed.");
}
send(response, 200, collagePage({
items,
width,
height,
transparent,
chroma,
autoplay,
muted,
spacing,
fade,
columns,
duration,
repeat,
shuffle,
}));
}
async function handleRequest(request, response) {
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
@@ -126,6 +213,11 @@ async function handleRequest(request, response) {
return;
}
if (requestUrl.pathname === "/collage") {
await handleCollage(requestUrl, response);
return;
}
if (requestUrl.pathname === "/providers") {
const providers = await loadProviders();
sendJson(response, 200, {

View File

@@ -41,6 +41,24 @@ function cappedPixelValue(value, cap) {
return `${Math.min(number, cap)}px`;
}
function collageCardWidth({ width, spacing, columns }) {
const availableWidth = width - (spacing * 2) - (spacing * Math.max(columns - 1, 0));
const columnWidth = Math.floor(availableWidth / columns);
return Math.max(Math.min(columnWidth, 500), 120);
}
function shuffleItems(items) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]];
}
return shuffled;
}
function addIframePermissions(tag) {
const autoplayPermission = "autoplay";
@@ -323,9 +341,48 @@ export function homePage({ providersCount = 0 } = {}) {
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
</div>
</form>
<form class="collage-form" action="/collage" method="get">
<label for="urls">Collage URLs</label>
<textarea id="urls" name="urls" rows="6" placeholder="https://bsky.app/...\nhttps://x.com/...\nhttps://www.instagram.com/..."></textarea>
<div class="controls collage-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>Spacing <input name="spacing" type="number" value="48" min="0" max="400"></label>
<label>Fade <input name="fade" type="number" value="0" min="0" max="600"></label>
<label>Columns <input name="columns" type="number" value="3" min="1" max="8"></label>
<label>Duration <input name="duration" type="number" value="360" min="10" max="1200"></label>
<label>Repeat <input name="repeat" type="number" value="4" min="2" max="12"></label>
<label class="checkbox-label"><input name="shuffle" value="1" type="checkbox" checked> shuffle</label>
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
</div>
<button type="submit">Open collage</button>
</form>
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
</section>
</main>
<script>
document.querySelector(".collage-form").addEventListener("submit", (event) => {
const form = event.currentTarget;
const textarea = form.querySelector("textarea[name='urls']");
const urls = textarea.value.split(/[\\n,]+/).map((url) => url.trim()).filter(Boolean);
if (!urls.length) {
event.preventDefault();
textarea.focus();
return;
}
const data = new FormData(form);
data.delete("urls");
for (const url of urls) {
data.append("url", url);
}
event.preventDefault();
window.location.href = form.action + "?" + new URLSearchParams(data).toString();
});
</script>
</body>
</html>`;
}
@@ -341,6 +398,76 @@ export function casparTemplatePage() {
</html>`;
}
function embedCardHtml({
targetUrl,
embed,
autoplay,
muted,
className = "embed",
widthCap = 500,
includeHeight = true,
}) {
const providerClass = `provider-${slugify(embed.provider_name)}`;
const embedWidth = cappedPixelValue(embed.width, widthCap);
const embedHeight = numericPixelValue(embed.height);
const embedStyle = [
embedWidth ? `--embed-width:${embedWidth}` : "",
includeHeight && 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(embed.title || embed.provider_name || "")}">`
: html;
return `<section class="${escapeHtml(className)} ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
${media}
</section>`;
}
export function collagePage({
items,
width,
height,
transparent,
chroma,
autoplay = true,
muted = true,
spacing = 48,
fade = 0,
columns = 3,
duration = 360,
repeat = 4,
shuffle = true,
}) {
const cardWidth = collageCardWidth({ width, spacing, columns });
const orderedItems = shuffle ? shuffleItems(items) : items;
const groupItems = Array.from({ length: repeat }, () => orderedItems).flat();
const groupCards = groupItems
.map((item) => embedCardHtml({
targetUrl: item.targetUrl,
embed: item.embed,
autoplay,
muted,
className: "embed collage-card",
widthCap: cardWidth,
includeHeight: false,
}))
.join("\n");
const groups = [groupCards, groupCards]
.map((cards) => `<div class="collage-group">${cards}</div>`)
.join("\n");
return `${commonHead({ title: "oEmbed Collage", htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
<body class="graphic collage-page ${transparent ? "transparent" : ""} is-ready" style="--stage-width:${width}px; --stage-height:${height}px; --chroma:${escapeHtml(chroma)}; --collage-spacing:${spacing}px; --collage-fade:${fade}px; --collage-columns:${columns}; --collage-card-width:${cardWidth}px; --collage-duration:${duration}s;">
<main class="collage-stage">
<div class="collage-track">
${groups}
</div>
</main>
</body>
</html>`;
}
export function graphicPage({
targetUrl,
embed,
@@ -357,24 +484,11 @@ export function graphicPage({
maxWait = 10000,
}) {
const title = embed.title || embed.provider_name || "oEmbed Graphic";
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, 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 ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
${media}
</section>
${embedCardHtml({ targetUrl, embed, autoplay, muted })}
</main>
${wait ? revealScript({ readyDelay, maxWait }) : ""}
${autoplay ? autoplayAssistScript({ muted }) : ""}

View File

@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { casparTemplatePage, graphicPage, prepareEmbedHtml } from "../src/templates.js";
import { casparTemplatePage, collagePage, graphicPage, homePage, 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>');
@@ -13,6 +13,19 @@ test("adds autoplay parameters and permissions to iframe embeds", () => {
assert.match(html, /muted=1/);
});
test("home page includes single graphic and collage forms", () => {
const page = homePage({ providersCount: 12 });
assert.match(page, /<form action="\/graphic" method="get">/);
assert.match(page, /<form class="collage-form" action="\/collage" method="get">/);
assert.match(page, /<textarea id="urls" name="urls"/);
assert.match(page, /name="spacing" type="number" value="48"/);
assert.match(page, /name="fade" type="number" value="0"/);
assert.match(page, /name="columns" type="number" value="3"/);
assert.match(page, /name="shuffle" value="1" type="checkbox" checked/);
assert.match(page, /Open collage/);
});
test("adds autoplay attributes to video tags", () => {
const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>');
@@ -143,7 +156,8 @@ test("adds a provider class for Bluesky embeds", () => {
});
assert.match(page, /<section class="embed provider-bluesky"/);
assert.match(page, /--embed-width:500px;--embed-height:480px/);
assert.match(page, /--embed-width:500px/);
assert.doesNotMatch(page, /collage-card provider-example"[^>]*--embed-height/);
});
test("caps rendered embed width at 500px", () => {
@@ -192,3 +206,81 @@ test("renders a CasparCG template shell", () => {
assert.match(page, /<section class="embed provider-empty"/);
assert.match(page, /<script src="\/caspar\.js"><\/script>/);
});
test("renders a vertical scrolling collage page with repeated embed cards", () => {
const page = collagePage({
items: [
{
targetUrl: "https://example.com/post/1",
embed: {
provider_name: "Example",
type: "rich",
width: 700,
height: 480,
html: '<iframe src="https://player.example.com/1"></iframe>',
},
},
{
targetUrl: "https://example.com/post/2",
embed: {
provider_name: "Example",
type: "rich",
width: 500,
height: 480,
html: '<iframe src="https://player.example.com/2"></iframe>',
},
},
],
width: 1920,
height: 1080,
transparent: true,
chroma: "#00ff00",
spacing: 48,
fade: 0,
columns: 3,
duration: 360,
repeat: 3,
shuffle: false,
});
assert.match(page, /class="graphic collage-page transparent is-ready"/);
assert.match(page, /--collage-spacing:48px/);
assert.match(page, /--collage-fade:0px/);
assert.match(page, /--collage-columns:3/);
assert.match(page, /--collage-card-width:500px/);
assert.match(page, /--collage-duration:360s/);
assert.equal((page.match(/class="embed collage-card provider-example"/g) || []).length, 12);
assert.equal((page.match(/class="collage-group"/g) || []).length, 2);
assert.match(page, /--embed-width:500px/);
assert.doesNotMatch(page, /collage-card provider-example"[^>]*--embed-height/);
});
test("shrinks collage card width to fit the configured screen width", () => {
const page = collagePage({
items: [
{
targetUrl: "https://example.com/post/1",
embed: {
provider_name: "Example",
type: "rich",
width: 500,
height: 480,
html: '<iframe src="https://player.example.com/1"></iframe>',
},
},
],
width: 1280,
height: 720,
transparent: true,
chroma: "#00ff00",
spacing: 48,
columns: 4,
duration: 360,
repeat: 2,
shuffle: false,
});
assert.match(page, /--collage-card-width:260px/);
assert.match(page, /--embed-width:260px/);
assert.doesNotMatch(page, /collage-card provider-example"[^>]*--embed-height/);
});