From 30cd5c7b13694c3e8611f3bc3b04c294d00eeabe Mon Sep 17 00:00:00 2001 From: Aiden Wilson <68633820+awils27@users.noreply.github.com> Date: Fri, 29 May 2026 23:45:52 +1000 Subject: [PATCH] collage fixes --- README.md | 14 ++- package.json | 2 +- public/collage.js | 279 +++++++++++++++++++++++++++++++++++++++++ public/styles.css | 45 ++++--- src/server.js | 7 +- src/templates.js | 58 +++++---- test/templates.test.js | 22 ++-- 7 files changed, 372 insertions(+), 55 deletions(-) create mode 100644 public/collage.js diff --git a/README.md b/README.md index dad58ae..7c9f527 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,9 @@ The template also accepts a raw URL string or standard CasparCG XML ## 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. +Use `/collage` to render multiple social posts as independently scrolling +columns. The browser keeps only a small window of live posts per column, removes +cards after they scroll off the top, and fills new random cards at the bottom. Repeated `url` parameters: @@ -114,12 +115,15 @@ 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`. +- `repeatDistance`: minimum pixel distance before the same post can be reused near another live copy, default `900`. +- `hydrateDelay`: milliseconds between hydrating newly inserted provider embeds, default `180`. - `duration`: seconds per scroll loop, default `360`. -- `repeat`: number of times to repeat the post list in each half of the loop, default `4`. +- `repeat`: virtual scroll buffer multiplier, default `2`. - `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. +Collage card width is calculated from `width`, `spacing`, and `columns` so the +columns fill the configured screen width with only the chosen spacing at the +edges. ## API diff --git a/package.json b/package.json index 06fe967..1cf37a2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "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 && node --check public/caspar.js", + "typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js && node --check public/collage.js", "build": "npm run typecheck", "test": "node --test" }, diff --git a/public/collage.js b/public/collage.js new file mode 100644 index 0000000..ef6c7e9 --- /dev/null +++ b/public/collage.js @@ -0,0 +1,279 @@ +const dataElement = document.getElementById("collage-data"); +const stage = document.querySelector(".collage-stage"); +const track = document.querySelector(".collage-track"); +const config = JSON.parse(dataElement.textContent); + +let lastFrame = performance.now(); +let itemCursor = 0; +const columns = []; +const deviceScale = window.devicePixelRatio || 1; +const hydrationQueue = []; +const hydrateDelay = Math.max(Number(config.hydrateDelay) || 0, 0); +const placeholderHeight = 160; +const cardColumns = new WeakMap(); +let trackTop = 0; +let hydrationTimer; +const cardObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const card = entry.target; + const column = cardColumns.get(card); + const previousExtent = cardExtent(card); + + card.dataset.height = String(Math.max(entry.contentRect.height, placeholderHeight)); + + if (column) { + column.contentHeight += cardExtent(card) - previousExtent; + } + } +}); + +function snapPixel(value) { + return Math.round(value * deviceScale) / deviceScale; +} + +function slugify(value = "") { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "unknown"; +} + +function executeScripts(root) { + for (const script of root.querySelectorAll("script")) { + const replacement = document.createElement("script"); + + for (const attribute of script.attributes) { + replacement.setAttribute(attribute.name, attribute.value); + } + + replacement.textContent = script.textContent; + script.replaceWith(replacement); + } +} + +function scheduleHydration(card, item) { + if (!hydrateDelay) { + hydrateCard(card, item); + return; + } + + hydrationQueue.push({ card, item }); + + if (!hydrationTimer) { + hydrationTimer = setInterval(hydrateNextCard, hydrateDelay); + } +} + +function hydrateNextCard() { + let next = hydrationQueue.shift(); + + while (next && !next.card.isConnected) { + next = hydrationQueue.shift(); + } + + if (!next) { + clearInterval(hydrationTimer); + hydrationTimer = undefined; + return; + } + + hydrateCard(next.card, next.item); +} + +function nudgeProviderWidgets(root) { + window.twttr?.widgets?.load?.(root); + window.instgrm?.Embeds?.process?.(); + window.bluesky?.scan?.(root); +} + +function hydrateCard(card, item) { + if (item.type === "photo" && item.url) { + const image = document.createElement("img"); + image.src = item.url; + image.alt = item.title || ""; + card.replaceChildren(image); + } else { + card.innerHTML = item.html || ""; + } + + executeScripts(card); + nudgeProviderWidgets(card); + card.classList.add("is-hydrated"); +} + +function createCard(column, item) { + const card = document.createElement("section"); + card.className = `embed collage-card provider-${slugify(item.providerName)}`; + card.dataset.source = item.targetUrl; + card.dataset.height = String(placeholderHeight); + card.style.setProperty("--embed-width", `${config.cardWidth}px`); + cardColumns.set(card, column); + column.contentHeight += cardExtent(card); + cardObserver.observe(card); + scheduleHydration(card, item); + return card; +} + +function createColumn(index) { + const element = document.createElement("div"); + element.className = "collage-column"; + element.dataset.column = String(index); + track.append(element); + + return { + element, + index, + offset: 0, + contentHeight: 0, + }; +} + +function cardExtent(card) { + return cardHeight(card) + config.spacing; +} + +function cardHeight(card) { + return Math.max(Number(card.dataset.height) || 0, placeholderHeight); +} + +function candidateY(column) { + return column.contentHeight - column.offset; +} + +function isTooCloseToSamePost(item, column) { + if (!config.repeatDistance || config.items.length < 2) { + return false; + } + + const minDistanceSquared = config.repeatDistance * config.repeatDistance; + const nextY = candidateY(column); + + for (const otherColumn of columns) { + const columnDistance = Math.abs(column.index - otherColumn.index) * (config.cardWidth + config.spacing); + + for (const card of otherColumn.element.children) { + if (card.dataset.source !== item.targetUrl) { + continue; + } + + const existingY = card.offsetTop - otherColumn.offset; + const yDistance = nextY - existingY; + const distanceSquared = (columnDistance * columnDistance) + (yDistance * yDistance); + + if (distanceSquared < minDistanceSquared) { + return true; + } + } + } + + return false; +} + +function randomCandidate() { + return config.items[Math.floor(Math.random() * config.items.length)]; +} + +function sequentialCandidate(attempt) { + return config.items[(itemCursor + attempt) % config.items.length]; +} + +function nextItem(column) { + const maxAttempts = Math.max(config.items.length * 2, 8); + let fallback = config.items[0]; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const item = config.shuffle ? randomCandidate() : sequentialCandidate(attempt); + fallback = item; + + if (!isTooCloseToSamePost(item, column)) { + if (!config.shuffle) { + itemCursor += attempt + 1; + } + + return item; + } + } + + if (!config.shuffle) { + itemCursor += 1; + } + + return fallback; +} + +function fillColumn(column) { + const targetHeight = stage.clientHeight + Math.max(config.cardWidth, 720) + config.spacing; + let guard = 0; + + while (column.contentHeight - column.offset < targetHeight && guard < 24) { + column.element.append(createCard(column, nextItem(column))); + guard += 1; + } +} + +function fillColumns() { + for (const column of columns) { + fillColumn(column); + } +} + +function recycleColumn(column) { + let firstCard = column.element.firstElementChild; + + while (firstCard && isFullyAboveStage(firstCard, column)) { + column.contentHeight -= cardExtent(firstCard); + column.offset -= cardExtent(firstCard); + cardObserver.unobserve(firstCard); + cardColumns.delete(firstCard); + firstCard.remove(); + column.element.append(createCard(column, nextItem(column))); + firstCard = column.element.firstElementChild; + } +} + +function isFullyAboveStage(card, column) { + const cardBottom = trackTop + cardHeight(card) - column.offset; + + return cardBottom <= 0; +} + +function scrollSpeed() { + const baseDistance = Math.max(stage.clientHeight, config.cardWidth * config.repeat); + return baseDistance / config.duration; +} + +function frame(now) { + const elapsedSeconds = Math.min((now - lastFrame) / 1000, 0.1); + lastFrame = now; + + for (const column of columns) { + column.offset += scrollSpeed() * elapsedSeconds; + recycleColumn(column); + fillColumn(column); + column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`; + } + + requestAnimationFrame(frame); +} + +for (let index = 0; index < config.columns; index += 1) { + columns.push(createColumn(index)); +} + +fillColumns(); +trackTop = track.offsetTop; + +for (const [index, column] of columns.entries()) { + const firstCard = column.element.firstElementChild; + const stagger = firstCard ? Math.min(index * config.spacing * 0.75, cardExtent(firstCard) * 0.4) : 0; + column.offset = stagger; + column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`; +} + +const observer = new ResizeObserver(() => { + trackTop = track.offsetTop; + fillColumns(); +}); +observer.observe(stage); + +requestAnimationFrame(frame); diff --git a/public/styles.css b/public/styles.css index e5d2026..e1156c6 100644 --- a/public/styles.css +++ b/public/styles.css @@ -215,6 +215,8 @@ code { display: grid; justify-items: center; padding: var(--collage-spacing); + contain: layout paint; + pointer-events: none; -webkit-mask-image: linear-gradient( to bottom, transparent 0, @@ -232,19 +234,23 @@ code { } .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); + width: max-content; + backface-visibility: hidden; + contain: layout style; +} + +.collage-column { + display: flex; + flex-direction: column; + gap: var(--collage-spacing); + width: var(--collage-card-width); + backface-visibility: hidden; + contain: layout style; + transform: translate3d(0, 0, 0); + will-change: transform; } .collage-card { @@ -252,10 +258,16 @@ code { width: 100%; height: auto; max-width: var(--collage-card-width); - overflow: hidden; + max-height: none; + contain: layout style; + overflow: visible; transform: none; } +.collage-card:not(.is-hydrated) { + min-height: 160px; +} + .collage-card > iframe, .collage-card > blockquote, .collage-card > img, @@ -274,16 +286,11 @@ code { width: 100% !important; max-width: 100% !important; box-sizing: border-box; + margin: 0 !important; } -@keyframes collage-scroll { - from { - transform: translateY(0); - } - - to { - transform: translateY(calc(-50% - (var(--collage-spacing) / 2))); - } +.collage-card > * { + margin-block: 0 !important; } .error { diff --git a/src/server.js b/src/server.js index 990d678..c82a393 100644 --- a/src/server.js +++ b/src/server.js @@ -84,6 +84,7 @@ async function serveStatic(requestUrl, response) { const staticPaths = new Map([ ["/styles.css", "styles.css"], ["/caspar.js", "caspar.js"], + ["/collage.js", "collage.js"], ]); const path = staticPaths.get(requestUrl.pathname) || ""; @@ -153,8 +154,10 @@ async function handleCollage(requestUrl, response) { 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 repeatDistance = getNumber(requestUrl.searchParams, "repeatDistance", 900, 0, 4000); + const hydrateDelay = getNumber(requestUrl.searchParams, "hydrateDelay", 180, 0, 2000); const duration = getNumber(requestUrl.searchParams, "duration", 360, 10, 1200); - const repeat = getNumber(requestUrl.searchParams, "repeat", 4, 2, 12); + const repeat = getNumber(requestUrl.searchParams, "repeat", 2, 1, 8); const shuffle = getBoolean(requestUrl.searchParams, "shuffle", true); const transparent = getBoolean(requestUrl.searchParams, "transparent", true); const autoplay = getBoolean(requestUrl.searchParams, "autoplay", true); @@ -189,6 +192,8 @@ async function handleCollage(requestUrl, response) { spacing, fade, columns, + repeatDistance, + hydrateDelay, duration, repeat, shuffle, diff --git a/src/templates.js b/src/templates.js index fbd74a1..3791cca 100644 --- a/src/templates.js +++ b/src/templates.js @@ -45,7 +45,7 @@ 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); + return Math.max(columnWidth, 120); } function shuffleItems(items) { @@ -59,6 +59,10 @@ function shuffleItems(items) { return shuffled; } +function safeJson(value) { + return JSON.stringify(value).replaceAll("", "<\\/"); +} + function addIframePermissions(tag) { const autoplayPermission = "autoplay"; @@ -350,8 +354,10 @@ export function homePage({ providersCount = 0 } = {}) { + + - + @@ -435,35 +441,43 @@ export function collagePage({ spacing = 48, fade = 0, columns = 3, + repeatDistance = 900, + hydrateDelay = 180, duration = 360, - repeat = 4, + repeat = 2, 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) => `