diff --git a/README.md b/README.md index 3cb40b5..dad58ae 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/styles.css b/public/styles.css index dd51817..e5d2026 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/src/server.js b/src/server.js index 218e528..990d678 100644 --- a/src/server.js +++ b/src/server.js @@ -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, { diff --git a/src/templates.js b/src/templates.js index 0ece481..fbd74a1 100644 --- a/src/templates.js +++ b/src/templates.js @@ -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 } = {}) { +
+ + +
+ + + + + + + + + +
+ +

${providersCount} provider URL patterns loaded. Use /graphic?url=... from CasparCG, OBS Browser Source, or OGraf.

+ `; } @@ -341,6 +398,76 @@ export function casparTemplatePage() { `; } +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 + ? `${escapeHtml(embed.title || embed.provider_name || ` + : html; + + return `
+ ${media} +
`; +} + +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) => `
${cards}
`) + .join("\n"); + + return `${commonHead({ title: "oEmbed Collage", htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })} + +
+
+ ${groups} +
+
+ +`; +} + 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 - ? `${escapeHtml(title)}` - : html; return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
-
- ${media} -
+ ${embedCardHtml({ targetUrl, embed, autoplay, muted })}
${wait ? revealScript({ readyDelay, maxWait }) : ""} ${autoplay ? autoplayAssistScript({ muted }) : ""} diff --git a/test/templates.test.js b/test/templates.test.js index b905612..0f653e3 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -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(''); @@ -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, /
/); + assert.match(page, //); + assert.match(page, /