// The posts FEED's composition (`posts.layout: "feed"`): one HyperFrames page // for the whole cut -- a column of posts beside the footage, standing on the // deck, so the two make an L around the picture. // // PURE, like chrome-deck.mjs and chrome-posts.mjs: a schedule and a render // block in, an HTML string out. compose-chrome.mjs copies the assets in beside // it, writes it and renders it; nothing here touches a file. // // --------------------------------------------------------------------------- // What the column does // --------------------------------------------------------------------------- // Before the first post it is its header (who posted, on what) over its // ground, and nothing else. Each post TICKS IN at its `in` (deck.mjs postSchedule: its // clip's start + D, the transition's length -- the first clip's too, which // has no dissolve into it; several on one clip a `posts.seconds` apart): it lands at the TOP, newest first, as a timeline // reads, and every card already in slides DOWN by its height. The newest // wears the highlight -- an accent flare that settles to a lit rail and rim -- // until the next one takes it, then rests. A card that no longer fits the // column fades out as the stack pushes it past the bottom: the oldest scroll // out. Over a segment the deck hides for (a full-frame card, the teaser) the // whole column slides off the frame's side edge with it, and back after. // // The cut is never paused for it, and nothing moves the footage: the feed's // footage box is fixed for the whole cut (deck.mjs feedGeometry). // // --------------------------------------------------------------------------- // Why the stack is planned in the page, by a function that lives here // --------------------------------------------------------------------------- // How far a card pushes the others, and which ones fall out of the column, // depend on how tall each card is -- how its words wrap in the deck's own // face. So the page measures every card once the faces are in and hands the // heights to `feedCues`, THIS module's function written into the page under a // fixed name (`embedFn`, docs/quirks.md); the tests call the same function // with heights of their choosing. Every cue is a fromTo whose FROM is stated: // a render is a seek per frame, from parallel workers, in any order. import { deckChoreography, feedGeometry, pageDuration, resolveDeck } from "./deck.mjs"; import { mix, rgba } from "./chrome-deck.mjs"; import { embedFn, PLATFORM_LABEL, postDate, postParagraphs, postWho } from "./chrome-posts.mjs"; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const r4 = (v) => Math.round(v * 10000) / 10000; /** * The feed's motion, in seconds (and the gap between cards, in px). A new * card starts entering `lag` after its `in`, from the column's outer edge, * over `enter`; the cards below are pushed down over `push` from the `in` * itself, front-loaded, so the room is open (≈ 90 %) before the card comes * in over it -- the two never overlap on screen. Its highlight flares `glowAt` * into the entrance over `glowUp`, settles to `newest` over `glowDown`, and * goes out over `calm` when the next post arrives. A card pushed past the * column's bottom fades over `leave`. */ export const FEED_MOTION = Object.freeze({ gap: 16, push: 0.4, lag: 0.22, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8, leave: 0.45, }); /** * The column's inner layout, region-local px: the padding, the header (one * row: who posted, on what -- nothing a viewer can't see for themselves) and * the stack area under it (`stack`: where cards are, and its height -- * what `feedCues` fits them to). The card's own sizes: its text, meta and QR * cell. */ export function feedLayout(render) { const g = feedGeometry(render); const p = resolveDeck(render).posts; const W = g.column.width, H = g.column.height; const padX = 22; const headerTop = 26; const headerH = 36; const ruleY = headerTop + headerH + 10; const stackY = ruleY + 18; const bottom = 22; const textSize = 24; return { width: W, height: H, padX, header: { top: headerTop, height: headerH, ruleY }, stack: { x: padX, y: stackY, width: W - 2 * padX, height: H - stackY - bottom }, card: { rail: 6, pad: 16, qrSize: p.qrSize, plate: p.qrSize + 28, metaSize: 18, textSize, lineH: Math.round(textSize * 1.36), maxLines: p.maxLines, }, }; } /** * Everything the feed's timeline does, as data. PURE and SELF-CONTAINED: the * page carries this function's own source text (`embedFn`) and calls it with * the heights it measured, so it may reference nothing outside its own body. * * `posts` are `[{ id, in }]` oldest first, `in` in the CUT's clock; `heights` * the cards' heights in px; `column` the stack area's height. `visibility` is * deckChoreography's (`[{ hide, at: [a, b] }]`): the column slides `slideX` px * sideways out of the frame with the deck and back. `enterX` is where a card * enters from (the column's outer edge). * * Card j of n sits at y = Σ (height + gap) of the cards newer than it that * are in; it is `c` (autoAlpha, x, y), its highlight `h` (opacity), the * whole column `col` (x). * * @returns {{ init: Record, gone: Array, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ export function feedCues({ posts, heights, column, visibility = [], startsHidden = false, slideX = 600, enterX = 600, gap = 16, push = 0.4, lag = 0.22, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55, calm = 0.8, leave = 0.45, }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const n = posts.length; const init = { col: { x: startsHidden ? slideX : 0 } }; for (let j = 0; j < n; j += 1) { init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 }; init[`h${j}`] = { opacity: 0 }; } const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why }); const y = new Array(n).fill(0); const visible = []; const gone = new Array(n).fill(null); for (let m = 0; m < n; m += 1) { const t = posts[m].in; const id = posts[m].id; const room = (heights[m] || 0) + gap; // Room at the top: every card in moves down by the new one's height, and // the oldest that no longer fit fade as they are pushed out of the column // (one cue each, so the fade rides the push). for (let v = visible.length - 1; v >= 0; v -= 1) { const j = visible[v]; y[j] += room; if (y[j] + (heights[j] || 0) <= column) { add(`c${j}`, t, push, { y: y[j] }, "power3.out", `push for ${id}`); continue; } add(`c${j}`, t, Math.max(push, leave), { y: y[j], autoAlpha: 0 }, "power2.out", `out for ${id}`); gone[j] = t; visible.splice(v, 1); } // The newest hands its highlight on. if (m > 0 && gone[m - 1] === null) add(`h${m - 1}`, t, calm, { opacity: 0 }, "power1.out", `calm for ${id}`); add(`c${m}`, t + lag, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${id}`); add(`h${m}`, t + lag + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${id}`); add(`h${m}`, t + lag + glowAt + glowUp, glowDown, { opacity: newest }, "power2.inOut", `settle ${id}`); visible.unshift(m); } for (const v of visibility) { if (v.at[1] - v.at[0] <= 0 && v.i === 0) continue; // hidden from the first frame: that is init add("col", v.at[0], v.at[1] - v.at[0], { x: v.hide ? slideX : 0 }, v.hide ? "power2.in" : "power3.out", `${v.hide ? "hide" : "show"} @${v.i}`); } // Order, clamp (a cue never starts before the last one on its element has // ended), and state every from. ev.forEach((e, i) => { e.n = i; }); ev.sort((a, b) => a.at - b.at || a.n - b.n); const state = {}; for (const k of Object.keys(init)) state[k] = { ...init[k] }; const freeAt = {}; const cues = []; for (const e of ev) { let { at, dur } = e; const free = freeAt[e.k] ?? -Infinity; if (at < free) { const end = at + dur; at = R(free); dur = R(Math.max(MIN, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const q of Object.keys(e.to)) from[q] = cur[q]; Object.assign(cur, e.to); freeAt[e.k] = R(at + dur); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why, i: cues.length }); } cues.sort((a, b) => a.at - b.at || a.i - b.i); for (const c of cues) delete c.i; return { init, gone, cues }; } /** * Who the feed is, for its header: the one author's name and platform when * every post is theirs, else "Posts" and every platform there is. */ export function feedWho(posts) { const names = [...new Set(posts.map((p) => postWho(p).name).filter(Boolean))]; const platforms = [...new Set(posts.map((p) => PLATFORM_LABEL[p.platform] ?? String(p.platform ?? "")).filter(Boolean))]; const single = names.length === 1 && platforms.length === 1; return { title: single ? names[0] : "Posts", platforms, single }; } /** * The feed composition's HTML, for the whole cut -- or a window of it * (`from`/`duration`, a `--chrome-preview`): the root then declares * `duration` seconds and the timeline plays the cut's [from, from + duration]. * * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold), * `qrSrcs` = `{ [postId]: "assets/qrNN.png" }`, `gsap` = the vendored script. * `shotSrcs` = `{ [postId]: "assets/shotNN.png" }`: a post with a screenshot * (`posts[].shot`) draws it in place of its text card, its QR cell kept. * The region is `feedGeometry(render).column`, region-local, transparent * outside the panel. `?still=` and the preview's `deck:seek` take CUT seconds. */ export function feedHtml(schedule, render, opts = {}) { if (schedule.layout !== "feed") throw new Error("feed: the schedule is not a feed's (layout \"feed\")"); const deck = resolveDeck(render); const lay = feedLayout(render); const g = feedGeometry(render); const pal = render.palette; const W = lay.width, H = lay.height; const fonts = opts.fonts ?? {}; const qrSrcs = opts.qrSrcs ?? {}; const shotSrcs = opts.shotSrcs ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const total = schedule.total; const from = Number(opts.from ?? 0); const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from); if (!(dur > 0)) throw new Error(`feed: nothing to render from ${from}s of a ${total}s cut`); const windowed = from > 0 || Math.abs(dur - total) > 1e-6; const posts = [...(schedule.posts ?? [])].sort((a, b) => a.in - b.in || a.slot - b.slot); if (!posts.length) throw new Error("feed: the schedule places no posts"); const left = deck.posts.position === "top-left"; const c = lay.card; const who = feedWho(posts); const panel = deck.background === "panel"; // The column's ground meets the deck's top edge in the deck's own colour, // so the two read as one surface; the cards stand a step up from it. const deckTop = panel ? mix(pal.bg, pal.fg, 0.095) : pal.bg; const groundTop = panel ? mix(pal.bg, pal.fg, 0.05) : pal.bg; const cardTop = mix(mix(pal.bg, pal.fg, 0.14), pal.accent, 0.06); const cardBottom = mix(mix(pal.bg, pal.fg, 0.11), pal.accent, 0.03); const cardHtml = posts .map((p, j) => { const { name, platform } = postWho(p); const src = qrSrcs[p.id]; const shot = shotSrcs[p.id]; return ( `
` + (shot ? `
` : `
` + `
` + (who.single ? "" : (platform ? `${esc(platform)}` : "") + `${esc(name)}`) + `${esc(postDate(p, deck.subtitle.dateFormat))}
` + `
${postParagraphs(p.text).map((t) => `

${esc(t)}

`).join("")}
` + `
`) + `
` + (src ? `` : "") + `
` + `
` + `
` ); }) .join("\n "); const vis = deckChoreography(schedule, render).visibility; const data = { total: r4(total), window: windowed ? { from: r4(from), dur } : null, column: lay.stack.height, maxLines: c.maxLines, lineH: c.lineH, textSize: c.textSize, metaSize: c.metaSize, startsHidden: !!schedule.segments?.[0]?.hideDeck, // Off the frame's own side edge, a little past it. slideX: left ? -(W + 24) : W + 24, enterX: left ? -(lay.stack.width + lay.padX) : lay.stack.width + lay.padX, visibility: vis.map((v) => ({ i: v.i, hide: v.hide, at: v.at.map(r4) })), motion: FEED_MOTION, ids: posts.map((p) => p.id), posts: posts.map((p) => ({ id: p.id, in: p.in })), }; // `` inside a JSON string would close the tag; an id is the manifest's. const json = JSON.stringify(data).replace(/
${panel ? `
` : ""}
${who.platforms.map((p) => `${esc(p)}`).join("")} ${esc(who.title)}
${cardHtml}
`; } /** The feed's region in the frame, for the overlay: feedGeometry's column. */ export const feedRegion = (render) => feedGeometry(render).column;