Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 5b8b43cf0182427e9e42365742a8cac6109b38a5
parent 2e37008f7bdf8e1d6f28cdce52dcf2f275f5ea56
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 16:58:22 -0400

report-to-video: posts.layout "feed" -- the posts as a column beside the footage for the whole cut, ticking in at each clip's start, never held

- deck.mjs: posts.layout ("popup" default | "feed"); feedGeometry (a posts.width
  column flush with the side and top down to the deck, the footage box beside
  it: 600x890 at (1320,0) and 1272x716 at (24,87) at 1920x1080); feedOn;
  postSchedule gives a feed post its tick-in `in` (clip start + D, a clip's
  next ones posts.seconds apart, closer on a short clip); no hold, no move,
  no windows under the feed; the schedule says layout "feed" only when there
  are posts to draw, so popup and no-posts schedules are byte-identical.
- chrome-feed.mjs: one HyperFrames page for the whole cut -- header, empty
  state, newest on top with the highlight, older cards pushed down, the
  oldest faded out on overflow, the column off the frame's edge with the deck
  over hidden segments; the plan (feedCues) embedded by embedFn.
- compose-chrome region "feed", cached like the deck's (feed, feed-preview,
  feed-from<s>).
- build-video: every footage segment of a feed cut is framed into the feed's
  box; each framed segment's cut.json records its framing, and --chrome-only
  refuses segments framed for another layout; the feed's frames are laid
  like the deck's.
- verify-build checks the feed's frames and that nothing is held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Aumtool/report-to-video/chrome-feed.mjs | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-feed.test.mjs | 394+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 32++++++++++++++++++++++++++------
Mumtool/report-to-video/deck.mjs | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mumtool/report-to-video/verify-build.mjs | 17+++++++++++++++++
6 files changed, 1185 insertions(+), 42 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -81,8 +81,8 @@ import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; // and live in deck.mjs. This file only frames segments into its box and writes // the schedule down -- it never has a copy of the arithmetic. import { - assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds, - playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle, + assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE, + muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle, validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, @@ -231,8 +231,11 @@ export function headerFilters(render, attribPath, channelPath = null) { * * Pure, and exported, so the numbers can be tested without an encoder. */ -export function deckFraming(render) { - const { W, H, footage: f } = deckGeometry(render); +export function deckFraming(render, { feed = false } = {}) { + const { W, H, footage: deckBox } = deckGeometry(render); + // The posts feed's footage box stands beside its column (feedGeometry); + // every footage segment of a feed cut is framed there, for the whole cut. + const f = feed ? feedGeometry(render).footage : deckBox; const bg = render.palette.bg; return { box: f, @@ -245,12 +248,58 @@ export function deckFraming(render) { } /** `deckFraming`'s two runs joined: a whole picture into the box, then the frame. */ -export function deckFramingFilter(render) { - const { fit, place } = deckFraming(render); +export function deckFramingFilter(render, opts = {}) { + const { fit, place } = deckFraming(render, opts); return [...fit, ...place].join(","); } /** + * The framing a cut's footage segments are built with under the deck -- the + * layout and its box -- recorded in each one's `<id>.cut.json` (`framing`) so + * that `--chrome-only`, which rebuilds no segment, can refuse segments framed + * for another layout. `feed` is feedOn's answer for the cut's WHOLE timeline. + */ +export function segmentFraming(render, feed = false) { + return { layout: feed ? "feed" : "deck", box: deckFraming(render, { feed }).box }; +} + +/** Is this entry's segment framed into the footage box under the deck (rather than full frame)? */ +export function framedUnderDeck(entry, render) { + if (entry.type === "clip" || entry.type === "image") return true; + return entry.type === "card" && !hidesDeck(entry, resolveDeck(render)); +} + +/** + * Why segments on disk cannot carry this cut's chrome, as sentences: each + * framed segment's record names the box it was framed into, and it is not the + * one this cut frames footage into (`want`, segmentFraming's). A segment with + * no framing record was built before records named it -- framed for the + * deck's box -- so it passes for the deck and fails for the feed. + * + * @param {{ entries: object[], records: Array<object|null>, render: object, want: { layout: string, box: object } }} args + * @returns {string[]} + */ +export function framingProblems({ entries, records, render, want }) { + const deckBox = deckGeometry(render).footage; + const same = (a, b) => a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + const where = (b) => `${b.width}×${b.height} at (${b.x}, ${b.y})`; + const wrong = []; + entries.forEach((e, i) => { + if (!framedUnderDeck(e, render)) return; + const got = records[i]?.framing ?? null; + const box = got?.box ?? deckBox; + if (same(box, want.box)) return; + wrong.push(`${e.id} (${got ? `framed for the ${got.layout}, ${where(box)}` : `no framing record: built for the deck's ${where(box)}`})`); + }); + if (!wrong.length) return []; + return [ + `${wrong.length} segment(s) are framed for another layout than this cut's ` + + `${want.layout === "feed" ? "posts feed" : "deck"} (footage ${where(want.box)}): ${wrong.join(", ")}. ` + + "Reframing rebuilds them -- run a normal build (with --skip-fetch it re-cuts from the cached windows), not --chrome-only.", + ]; +} + +/** * Where a variant's own working files live. * * `clips-raw` stays at the ROOT and is shared: it holds the only expensive @@ -696,7 +745,7 @@ const encodeArgsVideoOnly = (render) => [ "-movflags", "+faststart", ]; -async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance) { +async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance, framing = null) { const outDir = dirs.dir; const { path: raw, fetchStart } = await fetchClip(entry, meta, render, dirs.rawDir, opts); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); @@ -784,19 +833,21 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // The composition is overlaid on the whole concat later; nothing here knows // about it. Same cut, same audio map, same encode as every other segment. if (deckOn(render)) { + const fr = framing ?? segmentFraming(render); await execFileP( FFMPEG, [ "-nostdin", "-v", "error", "-y", ...cutArgs(raw, cutA, cutB), - "-filter_complex", `[0:v]${deckFramingFilter(render)}[v]`, + "-filter_complex", `[0:v]${deckFramingFilter(render, { feed: fr.layout === "feed" })}[v]`, "-map", "[v]", "-map", "0:a", ...encodeArgs(render), seg, ], { maxBuffer: 1 << 24 }, ); - await writeCutRecord(seg, cutRecord); + // The box it was framed into, so --chrome-only can refuse another layout's. + await writeCutRecord(seg, { ...cutRecord, framing: fr }); return seg; } @@ -974,7 +1025,7 @@ async function qrForEntry(entry, provenance, render, outDir) { return { png, url }; } -async function buildCardSegment(card, render, outDir, nodes) { +async function buildCardSegment(card, render, outDir, nodes, framing = null) { const png = await renderCard(card, render, outDir, nodes); const seg = path.join(outDir, "segments", `${card.id}.mp4`); const dur = String(card.seconds); @@ -982,8 +1033,10 @@ async function buildCardSegment(card, render, outDir, nodes) { // deck slides away over it, and the card encodes exactly as it always has -- // or framed into the footage box like a clip, so the deck can stay up over // it without covering its bottom rows. - const vf = deckOn(render) && resolveDeck(render).overCards === "show" - ? deckFramingFilter(render) + const framed = deckOn(render) && resolveDeck(render).overCards === "show"; + const fr = framed ? framing ?? segmentFraming(render) : null; + const vf = framed + ? deckFramingFilter(render, { feed: fr.layout === "feed" }) : `fps=${render.fps},setsar=1`; await execFileP( @@ -1003,6 +1056,7 @@ async function buildCardSegment(card, render, outDir, nodes) { ], { maxBuffer: 1 << 24 }, ); + if (fr) await writeCutRecord(seg, { version: 1, id: card.id, framing: fr }); return seg; } @@ -1180,7 +1234,7 @@ async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant // * It reserves the footer's rows but draws nothing in them. The marker's // position is a function of `section`, which a still does not have, and a // timeline strip whose marker vanishes for six seconds reads as a bug. -async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir) { +async function buildImageSegment(entry, render, outDir, chrome, provenance, baseDir, framing = null) { const seg = path.join(outDir, "segments", `${entry.id}.mp4`); const pal = render.palette; const { width, height } = render; @@ -1217,7 +1271,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base // Under the deck the picture area is the deck's footage box -- the box a clip // is framed into, so a still between two clips does not move either -- and // there is no header, footer or corner code: the deck carries all three. - const deck = deckOn(render) ? deckFraming(render) : null; + const fr = deckOn(render) ? framing ?? segmentFraming(render) : null; + const deck = fr ? deckFraming(render, { feed: fr.layout === "feed" }) : null; const HH = render.headerHeight ?? 56; const VW = deck ? deck.box.width : contentWidth(render); const FH = chrome.footerHeight; @@ -1389,6 +1444,8 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base const what = resolved.map((r) => r.src).join(", "); throw new Error(`${entry.id}: ffmpeg failed on ${what}\n${detail}`); } + // Under the deck, the box it was framed into (see segmentFraming). + if (fr) await writeCutRecord(seg, { version: 1, id: entry.id, framing: fr }); return seg; } @@ -1913,7 +1970,9 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts // (snapWindow), and nothing past the main's end is drawn: the deck's // `shortest=1` overlay ahead of it already ends the stream there. Same // frame-kind trap as the deck, same two guards. - const deck = r.name === "deck"; + // The posts feed (`name: "feed"`) is a whole-cut sequence like the deck's, + // laid exactly as the deck's is. + const deck = r.name === "deck" || r.name === "feed"; const posts = r.name === "posts"; inputs.push( ...(deck || posts ? ["-reinit_filter", "0"] : []), @@ -1973,6 +2032,11 @@ export function postsRegions(render, outDir, schedule, { shift = 0, clip = null })); } +/** The posts feed as an overlay region: its frames at feedGeometry's column. */ +export function feedRegion(render, frames) { + return { name: "feed", frames, ...feedGeometry(render).column }; +} + /** * Where each rendered chrome region sits in the frame. * @@ -2828,6 +2892,29 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro }); const regions = chromeRegions(render, outDir).map((g) => ({ ...g, frames: r.frames })); + // The posts FEED (a schedule with `layout: "feed"`): one sequence for the + // whole cut -- or the same window as the deck's -- at feedGeometry's column, + // laid like the deck's. postWindows has none for a feed, so the loop below + // adds nothing. + if (schedule.layout === "feed") { + EMIT("chrome", { phase: "compose", region: "feed", ...(duration != null ? { from, duration } : {}) }); + const t1 = Date.now(); + const f = await composeChrome({ + manifestPath, outDir, variant, region: "feed", doRender: true, + fps: render.fps, workers: 4, quality: "high", format: "png-sequence", + ...(duration != null ? { from, duration } : {}), + }); + if (f.frameCount !== want) { + throw new Error(`the feed's sequence is ${f.frameCount} frames but the deck's is ${want}`); + } + EMIT("chrome", { + phase: f.cached ? "cached" : "render", region: "feed", + frames: f.frameCount, key: f.key, dir: f.frames, + seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), + }); + regions.push(feedRegion(render, f.frames)); + } + // Then the posts: one short sequence per clip that carries them, each with // its own cache, laid over the deck at its own second. A preview window // (`duration` given) takes only the windows it intersects, shifted into its @@ -3218,6 +3305,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly const entries = manifest.timeline.filter((e) => !only || e.id === only); if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`); + // Under the deck, the box every footage segment is framed into: the posts + // feed's when this cut is a feed -- decided on the cut's WHOLE timeline, so + // an `--only` rebuild frames a clip as the full build would. + const framing = deck + ? segmentFraming(render, feedOn({ render, posts: manifest.posts ?? [], entries: manifest.timeline ?? [] })) + : null; // Read once, at the ROOT: a source's state is a fact about the manifest, not // about a variant. A stacked ledger card says why each claim is text rather @@ -3308,6 +3401,13 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!(await exists(seg))) throw new Error(`${what} needs ${seg}, which is missing — run a full build first`); } + // Segments framed for another layout (the posts feed switched on or off + // since they were built) cannot take this cut's chrome: refused, by name. + { + const records = await Promise.all(segs.map((sg) => readCutRecord(sg))); + const problems = framingProblems({ entries, records, render, want: framing }); + if (problems.length) throw new Error(`${what}: ${problems.join("; ")}`); + } const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); // The holds, the moves, the mutes and the end fade, joined on their @@ -3366,7 +3466,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly try { if (entry.type === "card") { EMIT("card", { id: entry.id, i, n: entries.length }); - segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes)); + segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes, framing)); } else if (entry.type === "teaser") { EMIT("card", { id: entry.id, i, n: entries.length }); segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant })); @@ -3376,7 +3476,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // third word there would show as an unknown step rather than as work. EMIT("card", { id: entry.id, i, n: entries.length }); segments.push( - await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir), + await buildImageSegment(entry, render, outDir, chrome, provenance, manifestDir, framing), ); } else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") { EMIT("card", { id: entry.id, i, n: entries.length }); @@ -3397,7 +3497,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly }); segments.push( await buildClipSegment( - entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance, + entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance, framing, ), ); } diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs @@ -0,0 +1,500 @@ +// 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 shows its header (who posted, on what) and an +// empty state. Each post TICKS IN at its `in` (deck.mjs postSchedule: its +// clip's start, after the incoming dissolve; 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, 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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +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, so the room opens as the card arrives. 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`. The empty state fades over `emptyOut`. + */ +export const FEED_MOTION = Object.freeze({ + gap: 16, push: 0.55, lag: 0.12, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8, + leave: 0.45, emptyOut: 0.35, +}); + +/** + * The column's inner layout, region-local px: the padding, the header's rows + * 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 = 66; + 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<j>` (autoAlpha, x, y), its highlight `h<j>` (opacity), the + * count `n<k>` (k posts in; autoAlpha), the empty state `empty`, the whole + * column `col` (x). + * + * @returns {{ init: Record<string, object>, gone: Array<number|null>, + * 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.55, lag = 0.12, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55, + calm = 0.8, leave = 0.45, emptyOut = 0.35, +}) { + const R = (v) => Math.round(v * 10000) / 10000; + const MIN = 0.001; + const n = posts.length; + const init = { col: { x: startsHidden ? slideX : 0 }, empty: { autoAlpha: 1 } }; + for (let j = 0; j < n; j += 1) { + init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 }; + init[`h${j}`] = { opacity: 0 }; + } + for (let k = 0; k <= n; k += 1) init[`n${k}`] = { autoAlpha: k === 0 ? 1 : 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] }, "power2.inOut", `push for ${id}`); + continue; + } + add(`c${j}`, t, Math.max(push, leave), { y: y[j], autoAlpha: 0 }, "power2.inOut", `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}`); + if (m === 0) add("empty", t, emptyOut, { autoAlpha: 0 }, "power1.out", `first ${id}`); + add(`n${m}`, t + lag, MIN, { autoAlpha: 0 }, "none", `count ${id}`); + add(`n${m + 1}`, t + lag, MIN, { autoAlpha: 1 }, "none", `count ${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. + * The region is `feedGeometry(render).column`, region-local, transparent + * outside the panel. `?still=<t>` 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 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]; + return ( + `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` + + `<div class="body">` + + `<div class="meta">` + + (who.single ? "" : (platform ? `<span class="platform">${esc(platform)}</span>` : "") + + `<span class="who"><span class="handle">${esc(name)}</span></span>`) + + `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + + `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + + `</div>` + + `<div class="plate">` + + (src ? `<img src="${esc(src)}" width="${c.qrSize}" height="${c.qrSize}" alt="">` : "") + + `</div>` + + `<div class="hl" data-k="h${j}"></div>` + + `</article>` + ); + }) + .join("\n "); + const countHtml = Array.from({ length: posts.length + 1 }, (_, k) => + `<span class="n" data-k="n${k}">${k} of ${posts.length}</span>`).join(""); + + 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 })), + }; + // `</script>` inside a JSON string would close the tag; an id is the manifest's. + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + + return `<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=${W}, height=${H}" /> + <script src="${esc(gsapSrc)}"></script> + <style> + /* The deck's faces, under the deck's private names -- see docs/quirks.md. */ + @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal; + src: url('${esc(fonts.regular ?? "")}'); } + @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal; + src: url('${esc(fonts.bold ?? "")}'); } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; } + body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg}; + -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } + #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; } + #feed-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + .col { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + /* The column's ground: the deck's surface carried up the side. */ + .ground { position: absolute; inset: 0; + background: linear-gradient(180deg, ${groundTop} 0%, ${deckTop} 100%); } + ${panel ? `.edge { position: absolute; ${left ? "right" : "left"}: 0; top: 0; width: 1px; height: ${H}px; + background: linear-gradient(0deg, ${rgba(pal.accent, 0.8)} 0px, ${rgba(pal.accent, 0.3)} ${Math.round(H * 0.16)}px, + ${rgba(pal.fg, 0.14)} ${Math.round(H * 0.4)}px, ${rgba(pal.fg, 0.14)} ${H}px); }` : ""} + .head { position: absolute; left: ${lay.padX}px; top: ${lay.header.top}px; width: ${W - 2 * lay.padX}px; + height: ${lay.header.height}px; } + .who-row { display: flex; align-items: center; gap: 12px; height: 36px; white-space: nowrap; } + .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: 16px; line-height: 23px; + letter-spacing: 0.03em; color: ${pal.fg}; padding: 1px 11px 2px; border-radius: 999px; + background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; } + .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; + font-family: 'DeckSansBold', sans-serif; font-size: 26px; line-height: 36px; letter-spacing: -0.005em; } + .count { flex: none; position: relative; width: 84px; height: 36px; font-size: 16px; line-height: 36px; + color: ${pal.muted}; font-variant-numeric: tabular-nums; } + .count .n { position: absolute; right: 0; top: 0; visibility: hidden; opacity: 0; } + .caption { margin-top: 8px; font-size: 14px; line-height: 20px; letter-spacing: 0.12em; text-transform: uppercase; + color: ${rgba(pal.muted, 0.9)}; white-space: nowrap; } + .rule { position: absolute; left: ${lay.padX}px; top: ${lay.header.ruleY}px; width: ${W - 2 * lay.padX}px; + height: 1px; background: ${rgba(pal.fg, 0.12)}; } + .stack { position: absolute; left: ${lay.stack.x}px; top: ${lay.stack.y}px; width: ${lay.stack.width}px; + height: ${lay.stack.height}px; overflow: hidden; } + .empty { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; height: ${c.plate}px; + border-radius: 10px; border: 1px dashed ${rgba(pal.fg, 0.22)}; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; + visibility: hidden; opacity: 0; } + .empty b { font-family: 'DeckSansBold', sans-serif; font-weight: 400; font-size: 20px; color: ${rgba(pal.fg, 0.7)}; } + .empty span { font-size: 16px; color: ${pal.muted}; } + /* A card: lifted off the column with a touch of the accent, its rail + dim until it is the newest. Hidden until the timeline places it. */ + .post { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; min-height: ${c.plate}px; + visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden; + border-${left ? "right" : "left"}: ${c.rail}px solid ${rgba(pal.accent, 0.38)}; + background: linear-gradient(180deg, ${cardTop} 0%, ${cardBottom} 100%); + box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; } + /* The newest's highlight: a lit rail and an accent rim that flare as it + lands and settle; out when the next one arrives. Clear of the code. */ + .hl { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none; + box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 16px ${rgba(pal.accent, 0.5)}; } + .hl::before { content: ""; position: absolute; ${left ? "right" : "left"}: 0; top: 0; bottom: 0; width: 4px; + background: ${pal.accent}; box-shadow: 0 0 14px 2px ${rgba(pal.accent, 0.6)}; } + .body { position: relative; width: ${lay.stack.width - c.plate - c.rail}px;${left ? ` margin-left: ${c.plate}px;` : ""} + padding: ${c.pad - 3}px ${c.pad + 2}px ${c.pad - 2}px ${c.pad + 2}px; } + .meta { display: flex; align-items: center; gap: 10px; + font-size: ${c.metaSize}px; line-height: ${Math.round(c.metaSize * 1.3)}px; white-space: nowrap; } + .meta .platform { font-size: ${c.metaSize - 3}px; line-height: ${c.metaSize + 3}px; padding: 1px 9px 2px; } + .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; } + .date { color: ${mix(pal.muted, pal.fg, 0.35)}; flex: none; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; } + .text { margin-top: 6px; font-size: ${c.textSize}px; line-height: ${c.lineH}px; color: ${pal.fg}; } + /* Each paragraph is clamped to what is left of maxLines when the page + measures (clampText), so the ellipsis always ends words, never a blank line. */ + .para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden; + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${c.maxLines}; } + .para + .para { margin-top: ${Math.round(c.lineH * 0.34)}px; } + .para.gone { display: none; } + /* The source cell: the QR in a cell a shade down, as on the deck. */ + .plate { position: absolute; ${left ? "left" : "right"}: 0; top: 0; bottom: 0; width: ${c.plate}px; + display: flex; align-items: center; justify-content: center; + background: linear-gradient(180deg, ${mix(cardTop, pal.bg, 0.55)} 0%, ${mix(cardBottom, pal.bg, 0.6)} 100%); + border-${left ? "right" : "left"}: 1px solid ${rgba(pal.fg, 0.07)}; } + .plate img { display: block; width: ${c.qrSize}px; height: ${c.qrSize}px; border-radius: 6px; + image-rendering: pixelated; + box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); } + </style> + </head> + <body> + <div id="root" data-composition-id="feed" data-start="0" data-duration="${dur}" + data-width="${W}" data-height="${H}"> + <div id="feed-clip" class="clip" data-start="0" data-duration="${dur}" data-track-index="1"> + <div class="col" data-k="col"> + <div class="ground"></div> + ${panel ? `<div class="edge"></div>` : ""} + <div class="head"> + <div class="who-row"> + ${who.platforms.map((p) => `<span class="platform">${esc(p)}</span>`).join("")} + <span class="title">${esc(who.title)}</span> + <span class="count">${countHtml}</span> + </div> + <div class="caption">Posts as the timeline reaches them</div> + </div> + <div class="rule"></div> + <div class="stack"> + <div class="empty" data-k="empty"><b>No posts yet</b><span>They appear here as the cut reaches them</span></div> + ${cardHtml} + </div> + </div> + </div> + </div> + + <script id="feed-data" type="application/json">${json}</script> + <script> + const F = JSON.parse(document.getElementById("feed-data").textContent); + const byK = {}; + for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; + ${embedFn("feedCues", feedCues)} + + const params = new URLSearchParams(location.search); + // A window plays the cut's [from, from + dur]; the page is seeked in its own seconds. + const local = (t) => { + const v = Number(t) || 0; + return F.window ? Math.max(0, Math.min(F.window.dur, v - F.window.from)) : Math.max(0, v); + }; + let tl = null; + let pending = null; + + // maxLines across a card's paragraphs: each is clamped to the lines + // still unspent; once they are spent the rest are dropped, and the last + // one drawn ends in an ellipsis when anything was dropped. + function clampText(card) { + let left = F.maxLines; + let shown = null; + let cut = false; + for (const p of card.querySelectorAll(".para")) { + if (left <= 0) { p.classList.add("gone"); cut = true; continue; } + p.style.webkitLineClamp = String(left); + const lines = Math.round(p.getBoundingClientRect().height / F.lineH); + if (p.scrollHeight > p.clientHeight + 1) cut = true; + left -= lines; + shown = { p, lines }; + } + if (cut && shown && !(shown.p.scrollHeight > shown.p.clientHeight + 1)) { + shown.p.textContent = shown.p.textContent.replace(/\\s+$/, "") + " …"; + shown.p.style.webkitLineClamp = String(shown.lines); + } + } + + // Measure once the faces are in, then plan, place, build and register. + // The renderer awaits document.fonts.ready before it seeks a frame. + const ready = Promise.all([ + document.fonts.load(F.textSize + "px DeckSans"), + document.fonts.load(F.metaSize + "px DeckSansBold"), + ]).catch(() => {}).then(() => { + const cards = F.ids.map((_, j) => byK["c" + j]); + cards.forEach(clampText); + const heights = cards.map((c) => c.offsetHeight); + const M = F.motion; + const plan = feedCues({ + posts: F.posts, heights, column: F.column, visibility: F.visibility, startsHidden: F.startsHidden, + slideX: F.slideX, enterX: F.enterX, gap: M.gap, push: M.push, lag: M.lag, enter: M.enter, + glowAt: M.glowAt, glowUp: M.glowUp, glowDown: M.glowDown, newest: M.newest, calm: M.calm, + leave: M.leave, emptyOut: M.emptyOut, + }); + for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]); + const inner = gsap.timeline({ paused: true }); + for (const c of plan.cues) { + const el = byK[c.k]; + if (!el) continue; + inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at); + } + // The timeline runs to the cut's end, whatever its last cue. + inner.set({}, {}, F.total); + tl = inner; + if (F.window) { + tl = gsap.timeline({ paused: true }); + tl.add(inner.tweenFromTo(F.window.from, F.window.from + F.window.dur, { duration: F.window.dur, ease: "none" }), 0); + } + window.__timelines = window.__timelines || {}; + window.__timelines["feed"] = tl; + if (typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind(); + document.documentElement.dataset.heights = heights.join(","); + tl.seek(pending ?? 0, false); + document.documentElement.dataset.ready = "1"; + }); + + // The review still, in cut seconds. + const still = params.get("still"); + if (still !== null) pending = local(still); + + // umtool's live preview: the parent seeks in cut seconds, as it seeks the deck. + if (params.get("preview") === "1") { + window.addEventListener("message", (e) => { + const m = e.data || {}; + if (m.type !== "deck:seek") return; + pending = local(m.t); + if (tl) tl.seek(pending, false); + }); + ready.then(() => { + if (window.parent !== window) { + window.parent.postMessage({ type: "feed:ready", total: F.total, ids: F.ids }, "*"); + } + }); + } + </script> + </body> +</html> +`; +} + +/** The feed's region in the frame, for the overlay: feedGeometry's column. */ +export const feedRegion = (render) => feedGeometry(render).column; diff --git a/umtool/report-to-video/chrome-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs @@ -0,0 +1,394 @@ +// Tests for the posts FEED (slice F1, `posts.layout: "feed"`): the core's +// geometry and schedule for it (deck.mjs), the page that draws it +// (chrome-feed.mjs) and the plan it runs, compose-chrome's feed region, the +// build's framing record and its refusal, and the overlay of the feed's frames. +// The popup and the deck without posts are held to what they always were. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { FEED_MOTION, feedCues, feedHtml, feedLayout, feedWho } from "./chrome-feed.mjs"; +import { + deckChoreography, deckGeometry, deckSchedule, estimateSchedule, feedGeometry, feedOn, postHolds, postSchedule, + postsGeometry, postWindows, validateChrome, validatePosts, +} from "./deck.mjs"; +import { + chromeOverlayChain, chromeRegions, deckFraming, deckFramingFilter, feedRegion, framedUnderDeck, framingProblems, + postsRegions, segmentFraming, segmentJoins, +} from "./build-video.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const CHROME = { engine: "hyperframes", layout: "deck" }; +const BASE = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE }; +const RENDER = { ...BASE, chrome: { ...CHROME, deck: {} } }; +const FEED = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } }; +const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" }; + +const CLIPS = [ + { id: "c1", type: "clip", video: "a", start: 0, end: 10, date: "2024-09-05" }, + { id: "c2", type: "clip", video: "b", start: 0, end: 20, date: "2024-10-01" }, + { id: "k1", type: "card", seconds: 4 }, + { id: "c3", type: "clip", video: "c", start: 0, end: 6, date: "2025-12-08" }, +]; +const DURS = [10, 20, 4, 6]; +const POST = (id, date, extra = {}) => ({ + id, platform: "bluesky", author: "Pirate Software", handle: "piratesoftware.live", date, + text: `words of ${id}`, url: `https://bsky.app/profile/piratesoftware.live/post/${id}`, ...extra, +}); +// c2 carries a, b, c (October/November 2024); c3 carries z. +const POSTS = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("c", "2024-12-01"), POST("z", "2026-01-22")]; +const sched = (render = FEED, posts = POSTS, durs = DURS, D = 0.5) => + deckSchedule({ entries: CLIPS, durs, D, render, provenance: PROV, posts }); + +// ---- the core --------------------------------------------------------------- + +test("feedGeometry: a column flush with the side and top down to the deck, the footage beside it", () => { + const g = feedGeometry(FEED); + assert.deepEqual(g.column, { x: 1320, y: 0, width: 600, height: 890 }); + // 1320 × 890 left of the column; inset 24 all round; the frame's aspect; centred. + assert.deepEqual(g.footage, { x: 24, y: 87, width: 1272, height: 716 }); + assert.deepEqual(g.deck, deckGeometry(FEED).deck); + // Nothing overlaps: footage, gap, column; footage above the deck. + assert.equal(g.column.x - (g.footage.x + g.footage.width), 24); + assert.ok(g.footage.y + g.footage.height <= g.deck.y); + // top-left mirrors it; a narrower column grows the footage. + const left = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", position: "top-left", width: 520 } } } }; + const l = feedGeometry(left); + assert.deepEqual(l.column, { x: 0, y: 0, width: 520, height: 890 }); + assert.deepEqual(l.footage, { x: 520 + 24, y: 65, width: 1352, height: 760 }); + // The posts region IS the column under the feed; the popup's is unchanged. + assert.deepEqual(postsGeometry(FEED), g.column); + assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 }); +}); + +test("validation: layout is popup or feed; the feed is held to the footage left beside it", () => { + assert.deepEqual(validateChrome(FEED.chrome, FEED), []); + assert.match(validateChrome({ ...CHROME, deck: { posts: { layout: "sidebar" } } }, BASE)[0], /posts.layout must be "popup" or "feed"/); + // 900 + 2·24 at 1920 leaves 972 px: allowed. At 1280×720 a 600 column leaves 632 < 640. + assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { layout: "feed", width: 900 } } }, BASE), []); + const small = { ...BASE, width: 1280, height: 720, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } }; + assert.match(validateChrome(small.chrome, small).join(" "), /leaves the footage 632px wide beside the feed/); + assert.match(validatePosts([POST("p", "2024-01-01")], CLIPS, small).join(" "), /beside the feed/); +}); + +test("feedOn: the deck, the feed layout, shown, and a post to draw on a clip", () => { + assert.equal(feedOn({ render: FEED, posts: POSTS, entries: CLIPS }), true); + assert.equal(feedOn({ render: RENDER, posts: POSTS, entries: CLIPS }), false); + assert.equal(feedOn({ render: FEED, posts: [], entries: CLIPS }), false); + assert.equal(feedOn({ render: FEED, posts: [POST("h", "2024-01-01", { hide: true })], entries: CLIPS }), false); + assert.equal(feedOn({ render: FEED, posts: POSTS, entries: [{ id: "k", type: "card" }] }), false); + const off = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } }; + assert.equal(feedOn({ render: off, posts: POSTS, entries: CLIPS }), false); + assert.equal(feedOn({ render: { ...FEED, chrome: undefined }, posts: POSTS, entries: CLIPS }), false); +}); + +test("the feed schedule: each post ticks in at its clip's start + D, a clip's next ones `seconds` apart", () => { + const s = sched(); + assert.equal(s.layout, "feed"); + // No hold, no move: the segments are their own lengths. + assert.deepEqual(s.segments.map((x) => x.duration), DURS); + assert.equal(s.segments.some((x) => "hold" in x), false); + assert.equal("moves" in s, false); + assert.equal(postHolds({ posts: POSTS, entries: CLIPS, render: FEED }).size, 0); + // c2 starts at 9.5: a at 10 (after the dissolve), b and c 4 s apart. + const at = Object.fromEntries(s.posts.map((p) => [p.id, [p.segment, p.slot, p.of, p.in]])); + assert.deepEqual(at, { a: ["c2", 0, 3, 10], b: ["c2", 1, 3, 14], c: ["c2", 2, 3, 18], z: ["c3", 0, 1, 33] }); + // A feed post has `in`, not the popup's appear/out. + for (const p of s.posts) assert.equal("appear" in p || "out" in p, false); + // No windows: the feed is one composition. + assert.deepEqual(postWindows(s), []); + assert.deepEqual(postsRegions(FEED, "/o", s), []); + assert.equal(segmentJoins(s), null); + // A clip too short for k × seconds shares what it has before its leave. + const short = sched(FEED, POSTS, [10, 8, 4, 6]); + assert.deepEqual(short.posts.filter((p) => p.segment === "c2").map((p) => p.in), [10, 12.333, 14.667]); + // A hard cut: D = 0, the post is in at the clip's first frame. + const hard = sched(FEED, POSTS, DURS, 0); + assert.deepEqual(hard.posts.map((p) => p.in), [10, 14, 18, 34]); + // The core function, unrounded, says the same. + const segs = s.segments.map((x) => ({ id: x.id, start: x.start, duration: x.duration })); + assert.equal(postSchedule({ posts: POSTS, entries: CLIPS, segments: segs, D: 0.5, total: s.total, render: FEED })[0].in, 10); +}); + +test("the popup and the deck without posts write the schedule they always did", () => { + // A feed with nothing to draw is the deck alone, byte for byte. + const plain = JSON.stringify(sched(RENDER, [])); + assert.equal(JSON.stringify(sched(FEED, [])), plain); + assert.equal(JSON.stringify(sched(FEED, [POST("h", "2024-10-19", { hide: true })])), plain); + const noShow = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "feed", show: false } } } }; + assert.equal(JSON.stringify(sched(noShow, POSTS)), plain); + // "popup", named, is the default: the same schedule, holds and moves and all. + const named = { ...BASE, chrome: { ...CHROME, deck: { posts: { layout: "popup" } } } }; + assert.equal(JSON.stringify(sched(named)), JSON.stringify(sched(RENDER))); + const popup = sched(RENDER); + assert.equal("layout" in popup, false); + assert.ok(popup.moves.length > 0); + assert.ok(popup.posts.every((p) => "appear" in p && "out" in p && !("in" in p))); + assert.equal(estimateSchedule({ render: FEED, provenance: PROV, timeline: CLIPS, posts: POSTS }).layout, "feed"); +}); + +// ---- the plan the page runs -------------------------------------------------- + +const P = (posts = sched().posts) => posts.map((p) => ({ id: p.id, in: p.in })); +const byWhy = (cues, re) => cues.filter((c) => re.test(c.why)); + +test("feedCues: each card ticks in at its time, at the top, and the ones in move down by its height", () => { + const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 1000, ...FEED_MOTION }); + const g = FEED_MOTION.gap; + // Every card enters `lag` after its `in`, from the side, to x 0. + P().forEach((p, j) => { + const [e] = byWhy(plan.cues, new RegExp(`^enter ${p.id}$`)); + assert.equal(e.k, `c${j}`); + assert.equal(e.at, Math.round((p.in + FEED_MOTION.lag) * 1e4) / 1e4); + assert.deepEqual(e.to, { autoAlpha: 1, x: 0 }); + }); + // b pushes a down by b's height; c pushes a and b by c's. + assert.deepEqual(byWhy(plan.cues, /^push for b$/).map((c) => [c.k, c.at, c.to.y]), [["c0", 14, 200 + g]]); + assert.deepEqual(byWhy(plan.cues, /^push for c$/).map((c) => [c.k, c.to.y]).sort(), [["c0", 200 + 160 + 2 * g], ["c1", 160 + g]]); + // The highlight: the newest flares and settles; the one before it goes out. + assert.deepEqual(byWhy(plan.cues, /^calm for b$/).map((c) => [c.k, c.to.opacity]), [["h0", 0]]); + assert.deepEqual(byWhy(plan.cues, /^settle z$/).map((c) => [c.k, c.to.opacity]), [["h3", FEED_MOTION.newest]]); + // The empty state leaves with the first post; the count follows each one. + assert.deepEqual(byWhy(plan.cues, /^first a$/).map((c) => [c.k, c.at, c.to.autoAlpha]), [["empty", 10, 0]]); + assert.deepEqual(byWhy(plan.cues, /^count z$/).map((c) => [c.k, c.to.autoAlpha]), [["n3", 0], ["n4", 1]]); + assert.equal(plan.gone.every((x) => x === null), true); +}); + +test("feedCues: when the column overflows the oldest fade out as they are pushed past it", () => { + // A column of 400: a (150) and b (200) fit; c (160) pushes a to 376 + 150 > 400. + const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, ...FEED_MOTION }); + const out = byWhy(plan.cues, /^out for /); + assert.deepEqual(out.map((c) => [c.k, c.why, c.to.autoAlpha]), [["c0", "out for c", 0], ["c1", "out for z", 0]]); + assert.deepEqual(plan.gone, [18, 33, null, null]); + // A gone card is never moved again. + assert.equal(plan.cues.some((c) => c.k === "c0" && c.at > 18), false); +}); + +test("feedCues: every cue states its from, the last to on its element -- seek-safe", () => { + const vis = deckChoreography({ ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) }, FEED).visibility; + const plan = feedCues({ posts: P(), heights: [150, 200, 160, 180], column: 400, visibility: vis, slideX: 624, ...FEED_MOTION }); + const state = Object.fromEntries(Object.entries(plan.init).map(([k, v]) => [k, { ...v }])); + let last = -Infinity; + const ends = {}; + for (const c of plan.cues) { + assert.ok(c.at >= last, "cues in time order"); + last = c.at; + for (const [q, v] of Object.entries(c.from)) assert.equal(v, state[c.k][q], `${c.k}.${q} from at ${c.at}`); + assert.ok(c.at >= (ends[c.k] ?? -Infinity) - 1e-9, `${c.k} overlaps itself at ${c.at}`); + ends[c.k] = c.at + c.dur; + Object.assign(state[c.k], c.to); + } + // The column slides off with the deck over the card and back after it. + const col = plan.cues.filter((c) => c.k === "col"); + assert.deepEqual(col.map((c) => c.to.x), [624, 0]); + assert.deepEqual(col.map((c) => c.at), vis.map((v) => v.at[0])); + // Hidden from the first frame: that is the init, no cue. + const first = feedCues({ posts: P(), heights: [1, 1, 1, 1], column: 400, startsHidden: true, slideX: 624, + visibility: [{ i: 0, hide: true, at: [0, 0] }] }); + assert.deepEqual(first.init.col, { x: 624 }); + assert.equal(first.cues.some((c) => c.k === "col"), false); +}); + +// ---- the page ------------------------------------------------------------------ + +const FONTS = { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" }; +const HOSTILE = POST("evil", "2024-11-27T02:36:13.745Z", { + platform: "x", handle: '"><img src=x onerror=alert(1)>', author: "<b>bold</b>", + text: "</script><script>alert(1)</script>\nsecond line & 'quotes'\n\nnew paragraph", +}); +const dataOf = (html) => JSON.parse(/<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]); +const page = (posts = POSTS, opts = {}) => { + const s = sched(FEED, posts); + const qrSrcs = Object.fromEntries(s.posts.map((p, i) => [p.id, `assets/qr0${i}.png`])); + return { s, qrSrcs, html: feedHtml(s, FEED, { fonts: FONTS, qrSrcs, ...opts }) }; +}; + +test("the page: one card per post with its date, words and QR; the header; the composition contract", () => { + const { s, html, qrSrcs } = page(); + const col = feedGeometry(FEED).column; + assert.match(html, new RegExp(`data-composition-id="feed" data-start="0" data-duration="${s.total}"\\s+data-width="${col.width}" data-height="${col.height}"`)); + assert.match(html, /window\.__timelines\["feed"\] = tl/); + s.posts.forEach((p, j) => { + const open = html.indexOf(`data-post="${p.id}" data-k="c${j}"`); + assert.ok(open > 0, `no card for ${p.id}`); + const next = html.indexOf("<article", open + 1); + const card = html.slice(open, next > 0 ? next : undefined); + assert.match(card, /class="date"/); + assert.match(card, /class="para">words of /); + assert.ok(card.includes(`<img src="${qrSrcs[p.id]}" width="120" height="120"`), `${p.id} qr`); + assert.ok(card.includes(`data-k="h${j}"`)); + }); + // One author: the header names them once, and the cards only date themselves. + assert.ok(html.includes('<span class="title">@piratesoftware.live</span>')); + assert.ok(html.includes('<span class="platform">Bluesky</span>')); + assert.equal((html.match(/class="handle"/g) ?? []).length, 0); + assert.ok(html.includes('<span class="date">Oct 19, 2024</span>')); + assert.ok(html.includes('data-k="empty"')); + assert.ok(html.includes('<span class="n" data-k="n4">4 of 4</span>')); + // The cue times are the schedule's. + assert.deepEqual(dataOf(html).posts, s.posts.map((p) => ({ id: p.id, in: p.in }))); + assert.equal(dataOf(html).column, feedLayout(FEED).stack.height); + // The planner is in the page under its fixed name. + assert.ok(html.includes("const feedCues = (function feedCues(")); + // Not a feed's schedule: refused. + assert.throws(() => feedHtml(sched(RENDER), FEED, { fonts: FONTS }), /not a feed/); +}); + +test("the page: post strings are text, escaped in elements, attributes and the inline JSON", () => { + const { html } = page([...POSTS, HOSTILE]); + assert.ok(html.includes("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;\nsecond line &amp; &#39;quotes&#39;")); + assert.ok(html.includes("@&quot;&gt;&lt;img src=x onerror=alert(1)&gt;")); + assert.doesNotMatch(html, /<img src=x/); + assert.doesNotMatch(html, /<b>bold<\/b>/); + // Two authors, two platforms: the header says "Posts" and each card its own. + assert.ok(html.includes('<span class="title">Posts</span>')); + assert.ok(html.includes('<span class="platform">X</span>')); + assert.equal((html.match(/<\/script>/g) ?? []).length, 3); + const json = /<script id="feed-data" type="application\/json">(.*?)<\/script>/s.exec(html)[1]; + assert.doesNotMatch(json, /</); + assert.doesNotMatch(json, /alert|words of/); + assert.deepEqual(feedWho([POST("a", "2024-01-01")]), { title: "@piratesoftware.live", platforms: ["Bluesky"], single: true }); +}); + +test("the page: nothing leaves the machine; the posts' links are only in their QRs", () => { + const { html } = page([POST("a", "2024-10-19", { text: "see https://example.com/x and ferrets.live" })]); + // The words may name a link; no element loads one. + assert.doesNotMatch(html, /(?:src|href)="(?:https?:)?\/\//, "a remote src or href"); + assert.doesNotMatch(html, /url\('(?:https?:)?\/\//, "a remote font"); + assert.doesNotMatch(html, /@import|fonts\.googleapis|cdn\.|<link/); + assert.match(html, /<script src="assets\/gsap\.min\.js"><\/script>/); + for (const m of html.matchAll(/font-family: ([^;]+);/g)) { + assert.match(m[1], /^'DeckSans(?:Bold)?'(?:, sans-serif)?$/, m[1]); + } +}); + +test("the page: a window of the cut plays the cut's own clock; visibility is the deck's", () => { + const { html } = page(POSTS, { from: 12, duration: 5 }); + const d = dataOf(html); + assert.deepEqual(d.window, { from: 12, dur: 5 }); + assert.match(html, /data-composition-id="feed" data-start="0" data-duration="5"/); + const hidden = { ...sched(), segments: sched().segments.map((s) => ({ ...s, hideDeck: s.id === "k1" })) }; + const v = dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).visibility; + assert.deepEqual(v.map((x) => [x.hide, x.at[0]]), deckChoreography(hidden, FEED).visibility.map((x) => [x.hide, x.at[0]])); + assert.equal(dataOf(feedHtml(hidden, FEED, { fonts: FONTS })).slideX, 600 + 24); +}); + +// ---- the build: framing, its record and the refusal ------------------------------ + +test("deckFraming: a feed frames footage into feedGeometry's box; the deck's is unchanged", () => { + const f = feedGeometry(FEED).footage; + assert.deepEqual(deckFraming(FEED, { feed: true }).box, f); + assert.equal( + deckFramingFilter(FEED, { feed: true }), + "scale=1272:716:force_original_aspect_ratio=decrease,pad=1272:716:(ow-iw)/2:(oh-ih)/2:color=#12101a," + + "pad=1920:1080:24:87:color=#12101a,setsar=1,fps=30", + ); + // Without the feed (or under the popup) it is the deck's box, the filter it always was. + assert.equal(deckFramingFilter(FEED), deckFramingFilter(RENDER)); + assert.deepEqual(segmentFraming(FEED, true), { layout: "feed", box: f }); + assert.deepEqual(segmentFraming(RENDER), { layout: "deck", box: deckGeometry(RENDER).footage }); + assert.equal(framedUnderDeck({ type: "clip" }, FEED), true); + assert.equal(framedUnderDeck({ type: "image" }, FEED), true); + assert.equal(framedUnderDeck({ type: "card" }, FEED), false); + assert.equal(framedUnderDeck({ type: "teaser" }, FEED), false); + const shown = { ...BASE, chrome: { ...CHROME, deck: { overCards: "show" } } }; + assert.equal(framedUnderDeck({ type: "card" }, shown), true); + assert.equal(framedUnderDeck({ type: "teaser" }, shown), false); +}); + +test("framingProblems: segments framed for another layout are named; a record-less one is the deck's", () => { + const entries = [...CLIPS, { id: "t", type: "teaser" }]; + const feed = segmentFraming(FEED, true); + const deck = segmentFraming(FEED, false); + const rec = (fr) => ({ version: 1, framing: fr }); + // All framed for the feed, wanted for the feed: nothing to say. Cards and teasers are full frame. + assert.deepEqual(framingProblems({ entries, records: [rec(feed), rec(feed), null, rec(feed), null], render: FEED, want: feed }), []); + // Built under the deck (with records, or before records named it) and now a feed: refused, by name. + const [msg] = framingProblems({ entries, records: [rec(deck), null, null, rec(feed), null], render: FEED, want: feed }); + assert.match(msg, /^2 segment\(s\) are framed for another layout than this cut's posts feed \(footage 1272×716 at \(24, 87\)\)/); + assert.match(msg, /c1 \(framed for the deck, 1574×886 at \(173, 2\)\)/); + assert.match(msg, /c2 \(no framing record: built for the deck's 1574×886 at \(173, 2\)\)/); + assert.match(msg, /run a normal build \(with --skip-fetch/); + // The other way: feed-framed segments under a popup deck. + assert.match(framingProblems({ entries, records: [rec(feed), rec(deck), null, rec(deck), null], render: RENDER, want: deck })[0], + /1 segment\(s\) are framed for another layout than this cut's deck .*c1 \(framed for the feed, 1272×716 at \(24, 87\)\)/); + // Legacy segments with no record pass under the deck. + assert.deepEqual(framingProblems({ entries, records: entries.map(() => null), render: RENDER, want: deck }), []); +}); + +test("the feed's overlay is laid like the deck's: whole cut, reinit off, rgba, shortest", () => { + const regions = [...chromeRegions(FEED, "/o"), feedRegion(FEED, "/o/chrome/feed-frames")]; + assert.deepEqual(regions[1], { name: "feed", frames: "/o/chrome/feed-frames", x: 1320, y: 0, width: 600, height: 890 }); + const hf = chromeOverlayChain(FEED, regions, "[v]", 3); + assert.deepEqual(hf.inputs.slice(8), [ + "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/chrome/feed-frames/frame_%06d.png", + ]); + assert.match(hf.chain, /\[4:v\]format=rgba\[hfa1\];\[hf0\]\[hfa1\]overlay=x=1320:y=0:format=yuv444:shortest=1\[hf1\]/); + // The deck alone writes the chain it always did. + assert.equal(chromeOverlayChain(RENDER, chromeRegions(RENDER, "/o"), "[v]", 3).chain, + "[3:v]format=rgba[hfa0];[v][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]"); +}); + +// ---- compose-chrome's feed region, with the renderer stubbed ---------------------- + +test("compose-chrome: the feed region composes, renders through the stub and caches by its key", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "feed-compose-")); + const env = { HYPERFRAMES_BIN: process.env.HYPERFRAMES_BIN, QRENCODE_BIN: process.env.QRENCODE_BIN }; + try { + // A renderer that writes the frames the root declares, and a qrencode + // that writes a PNG: what the e2e stubs do. + const stub = path.join(dir, "hf-stub.mjs"); + writeFileSync(stub, `#!/usr/bin/env node +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +const a = process.argv.slice(2); +const out = a[a.indexOf("--output") + 1]; +const fps = Number(a[a.indexOf("--fps") + 1]); +const html = readFileSync(a.at(-1) + "/index.html", "utf8"); +const dur = Number(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/.exec(html)[1]); +mkdirSync(out, { recursive: true }); +for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_" + String(i).padStart(6, "0") + ".png", "x"); +`); + chmodSync(stub, 0o755); + process.env.HYPERFRAMES_BIN = stub; + const s = sched(); + const font = "/usr/share/fonts/TTF/FiraSans-Regular.ttf"; + const manifest = { slug: "f", render: { ...FEED, fontRegular: font, fontBold: font, qr: { scale: 3, quiet: 3 } }, timeline: CLIPS, posts: POSTS }; + const mp = path.join(dir, "video.manifest.json"); + writeFileSync(mp, JSON.stringify(manifest)); + const out = path.join(dir, "out", "sourced"); + mkdirSync(out, { recursive: true }); + writeFileSync(path.join(out, "schedule.json"), JSON.stringify(s)); + const { composeChrome } = await import("./compose-chrome.mjs"); + let r; + try { + r = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 }); + } catch (e) { + // No qrencode or font on this machine: the page is what the tests above hold. + if (/qrencode|ENOENT|face/.test(String(e.message))) return; + throw e; + } + assert.equal(r.projDir, path.join(out, "chrome", "feed")); + assert.equal(r.frames, path.join(out, "chrome", "feed-frames")); + assert.equal(r.frameCount, Math.round(s.total * 30)); + assert.equal(r.cached, false); + const html = readFileSync(path.join(r.projDir, "index.html"), "utf8"); + assert.match(html, /src="assets\/qr00\.png"/); + const again = await composeChrome({ manifestPath: mp, region: "feed", doRender: true, fps: 30 }); + assert.equal(again.cached, true); + // The preview project and a window each have their own name. + const pv = await composeChrome({ manifestPath: mp, region: "feed", preview: true }); + assert.equal(pv.projDir, path.join(out, "chrome", "feed-preview")); + const w = await composeChrome({ manifestPath: mp, region: "feed", from: 9, duration: 4 }); + assert.equal(w.projDir, path.join(out, "chrome", "feed-from9")); + // A popup's schedule is not a feed's. + writeFileSync(path.join(out, "schedule.json"), JSON.stringify(sched(RENDER))); + await assert.rejects(composeChrome({ manifestPath: mp, region: "feed" }), /needs a feed's schedule/); + } finally { + for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -43,6 +43,7 @@ import { } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; +import { feedHtml } from "./chrome-feed.mjs"; const run = promisify(execFile); @@ -642,6 +643,16 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); return postsHtml(schedule, render, window, { fonts, qrSrcs }); } + if (region === "feed") { + // The posts feed: the deck's faces and QR maker, one page for the whole cut. + const render = manifest.render; + const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); + const posts = schedule.posts ?? []; + const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir); + const qrSrcs = {}; + for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); + return feedHtml(schedule, render, { fonts, qrSrcs, from, duration }); + } if (region === "teaser") { // A page module reached by a dynamic import, so nothing that imports this // file -- umtool's preview helper, the build -- loads its face's URL @@ -711,6 +722,11 @@ function runRenderer(cmd, args) { * their `.key`, cached exactly as the deck's are; * - `still` is in CUT seconds. * + * Feed (`region: "feed"`, a schedule with `layout: "feed"`): the posts column + * for the whole cut, exactly as the deck -- project `chrome/feed/` + * (`feed-preview/` when `preview`), frames `chrome/feed-frames/` and their + * `.key`, a window `feed-from<s>[-frames]`, cached as the deck's are. + * * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame, * `seconds` long, drawn from the entry alone (no schedule): * - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`), @@ -741,7 +757,7 @@ export async function composeChrome({ // The regions keyed by the render cache: the two drawn from the deck's // schedule, and a teaser, drawn from its own timeline entry. - const keyed = region === "deck" || region === "posts" || region === "teaser"; + const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser"; let teaser = null; if (region === "teaser") { teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null; @@ -750,7 +766,7 @@ export async function composeChrome({ if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`); } let sched = schedule; - if ((region === "deck" || region === "posts") && !sched) { + if ((region === "deck" || region === "feed" || region === "posts") && !sched) { const p = path.join(base, "schedule.json"); try { sched = JSON.parse(await readFile(p, "utf8")); @@ -759,10 +775,14 @@ export async function composeChrome({ } if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`); } + if (region === "feed" && sched.layout !== "feed") { + throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); + } const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null; const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const windowed = - region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); + (region === "deck" || region === "feed") && + (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); const suffix = windowed ? `-from${fmtSeconds(from)}` : ""; // A posts window: the one asked for, or the segment's from the schedule. @@ -783,7 +803,7 @@ export async function composeChrome({ ? `posts-${preview ? "preview-" : ""}${win.segment}` : region === "teaser" ? `teaser-${preview ? "preview-" : ""}${teaser.id}` - : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`; + : (region === "deck" || region === "feed") && preview ? `${region}-preview` : `${region}${suffix}`; const projDir = path.join(base, "chrome", projName); const assetsDir = path.join(projDir, "assets"); // The deck's assets are rebuilt every time: a QR from a clip that has since @@ -882,7 +902,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); if (!manifestPath) { console.error( - "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts|teaser] [--variant sourced|full]\n" + + "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|posts|teaser] [--variant sourced|full]\n" + " [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" + " [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" + " [--still <s> --png <path>]\n" + @@ -905,7 +925,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { still: num("--still"), png: flag("--png"), // The deck renders four-wide by default; the band keeps the renderer's own default. - workers: num("--workers") ?? (region === "deck" || region === "teaser" ? 4 : region === "posts" ? 2 : null), + workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" ? 2 : null), quality: flag("--quality") ?? "high", format: flag("--format") ?? "png-sequence", fps: num("--fps"), diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -42,9 +42,12 @@ export const DECK_DEFAULTS = Object.freeze({ // so the last of them can be read before the next clip; `shift` moves the // footage away from the posts column (scaled to `scale`, over `seconds`) // while they are up, or is `false`. + // `layout` "popup" draws them as above; "feed" keeps them in a column of + // their own beside the footage for the whole cut (feedGeometry), each one + // ticking in as its clip starts -- no hold, no move. posts: Object.freeze({ - show: true, seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24, - shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), + show: true, layout: "popup", seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, + inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), }), }); @@ -187,7 +190,10 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); - sub("posts", ["show", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => { + sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => { + if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) { + errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`); + } if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`); if (p.shift !== undefined && p.shift !== false) { if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`); @@ -509,7 +515,7 @@ export function deckSchedule({ // A clip that carries posts is held on its last frame for `posts.hold`: the // hold is part of the segment's length in the cut, so every start, the total // and the posts' timing below are measured with it. `durs` are the segments' - // own (probed or estimated) lengths. + // own (probed or estimated) lengths. (The feed holds nothing: postHolds.) const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map(); const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0)); const { starts, total } = scheduleFrom(full, D); @@ -517,7 +523,10 @@ export function deckSchedule({ const round = (v) => Math.round(v * 1000) / 1000; const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] })); const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : []; - const moves = placed.length ? footageMoves({ posts: placed, segments: segs, render }) : []; + // The feed is a layout only when it has posts to draw: a feed with none is + // the deck alone, and writes the schedule a deck without posts always did. + const feed = placed.length > 0 && deck.posts.layout === "feed"; + const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : []; return { version: 1, kind: "deck", @@ -526,6 +535,7 @@ export function deckSchedule({ transition: D, total: round(total), multiChannel, + ...(feed ? { layout: "feed" } : {}), segments: entries.map((e, i) => { const { title, subtitle } = deckText(e, metas[i] ?? null, provenance, deck, multiChannel); return { @@ -544,11 +554,21 @@ export function deckSchedule({ }), // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. - ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(placed.length ? { posts: roundPosts(placed) } : {}), ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } +/** + * `postSchedule`'s placements as a schedule writes them: their seconds to the + * millisecond -- a popup's `appear` and `out`, a feed's `in`. umtool's preview + * re-places posts and writes them through this too. + */ +export function roundPosts(placed) { + const round = (v) => Math.round(v * 1000) / 1000; + return placed.map((p) => ("in" in p ? { ...p, in: round(p.in) } : { ...p, appear: round(p.appear), out: p.out.map(round) })); +} + /** `deckSchedule` from the manifest alone, for a preview before any build. */ export function estimateSchedule(manifest, { metas = [], noXfade = false } = {}) { const render = manifest.render ?? {}; @@ -638,6 +658,8 @@ export function pipSegments(schedule) { // --------------------------------------------------------------------------- export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]); +/** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */ +export const POST_LAYOUTS = Object.freeze(["popup", "feed"]); const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"]; /** @@ -688,7 +710,17 @@ export function postsFitErrors(render) { const g = deckGeometry(render); const pp = resolveDeck(render).posts; const errors = []; - if (pp.width + 2 * pp.inset > g.footage.width) { + if (pp.layout === "feed") { + // The feed's column stands beside the footage, not over it: what has to + // fit is the footage left beside it. + const f = feedGeometry(render); + if (f.footage.width < g.W / 2) { + errors.push( + `${w}.posts.width ${pp.width} with inset ${pp.inset} leaves the footage ${f.footage.width}px wide beside the feed ` + + `(at least half the frame, ${g.W / 2}px)`, + ); + } + } else if (pp.width + 2 * pp.inset > g.footage.width) { errors.push(`${w}.posts.width ${pp.width} with inset ${pp.inset} does not fit the ${g.footage.width}px footage box`); } if (pp.qrSize > pp.width / 2) errors.push(`${w}.posts.qrSize ${pp.qrSize} is more than half the card's width`); @@ -739,7 +771,15 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) { /** * When each placed post is on screen, in the cut's clock. * - * A clip's posts (oldest first) share an anchor A: the start of the outgoing + * In the FEED (`posts.layout: "feed"`) a post has one time, `in`: when it + * ticks into the column, as early as it can be read -- its clip's start, after + * the incoming dissolve (start + D). A clip's posts (oldest first) follow one + * every `posts.seconds`, or closer when the clip is too short for that: post j + * of k ticks in at start + D + step·j, step = min(seconds, (A − start − D)/k), + * A the start of the outgoing transition as below -- so the last one is in at + * least `step` before its clip leaves. Once in, a post stays to the end. + * + * In the POPUP, a clip's posts (oldest first) share an anchor A: the start of the outgoing * transition -- the next segment's start with a crossfade, 0.3 s before the cut * on a hard cut, 0.3 s before the end on the last segment. Post j of k appears * at A − step·(k − j), step = `posts.seconds`, so each has its seconds alone @@ -752,6 +792,7 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) { */ export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) { const settings = resolveDeck(render).posts; + const feed = settings.layout === "feed"; const byId = new Map(posts.map((p) => [p.id, p])); const groups = new Map(); for (const a of attachPosts({ posts, entries, metas })) { @@ -770,8 +811,14 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot ? [segments[i + 1].start, segments[i + 1].start + D] : [segments[i + 1].start - 0.3, segments[i + 1].start]; const A = leave[0]; - const from = seg.start + (i > 0 ? D : 0); const k = group.length; + if (feed) { + const from = seg.start + D; + const step = Math.min(settings.seconds, Math.max(0, A - from) / k); + group.forEach((p, j) => out.push({ id: p.id, segment: seg.id, slot: j, of: k, in: from + step * j, ...postFields(p) })); + return; + } + const from = seg.start + (i > 0 ? D : 0); const step = Math.min(settings.seconds, Math.max(0, A - from) / k); group.forEach((p, j) => { out.push({ @@ -781,25 +828,34 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot of: k, appear: A - step * (k - j), out: leave, - date: p.date, - text: p.text, - author: p.author ?? "", - handle: p.handle ?? "", - platform: p.platform, - url: p.url, - qrUrl: p.url, + ...postFields(p), }); }); }); return out; } +/** What a placed post carries for the page that draws it. */ +function postFields(p) { + return { + date: p.date, + text: p.text, + author: p.author ?? "", + handle: p.handle ?? "", + platform: p.platform, + url: p.url, + qrUrl: p.url, + }; +} + /** * The windows the posts region is rendered for: one per clip that carries * posts, from its first post's appearance to the end of its leave. Frames are * only made for these seconds; the overlay places each at its `from`. */ export function postWindows(schedule) { + // The feed is one composition for the whole cut, not windows. + if (schedule.layout === "feed") return []; const by = new Map(); for (const p of schedule.posts ?? []) { const w = by.get(p.segment) ?? { segment: p.segment, from: Infinity, to: -Infinity }; @@ -834,6 +890,7 @@ export function snapWindow(window, { fps, total }) { export function postsGeometry(render) { const { W, footage: f } = deckGeometry(render); const p = resolveDeck(render).posts; + if (p.layout === "feed") return feedGeometry(render).column; const width = even(p.width); const height = even(f.height - 2 * p.inset); const left = p.position === "top-left"; @@ -862,6 +919,59 @@ export function shiftedFootage(render) { } /** + * The FEED's frame (`posts.layout: "feed"`): a column of posts for the whole + * cut on the `position` side, standing on the deck from the frame's top, and + * the footage box beside it. The deck stays full width at the bottom, so the + * column and the deck read as one L-shaped surface around the picture. + * + * - The column (`column`, the feed region) is `posts.width` wide, flush with + * the frame's side edge and top, down to the deck's top edge. + * - The footage keeps the frame's aspect and is as large as fits in what is + * left above the deck with `posts.inset` clear on every side (evened), and + * is centred there. At 1920×1080 with the defaults (190 px deck, 600 px + * column, inset 24): the column is 600×890 at (1320, 0) and the footage + * 1272×716 at (24, 87) -- 66 % of the frame's width, against the deck + * alone's 82 %. + * + * Nothing in it moves: every footage segment of a feed cut is framed into + * this box when it is built (build-video's deckFraming), and stays there. + * + * @returns {{ W: number, H: number, column: {x:number,y:number,width:number,height:number}, + * footage: {x:number,y:number,width:number,height:number}, deck: {x:number,y:number,width:number,height:number} }} + */ +export function feedGeometry(render) { + const { W, H, deck } = deckGeometry(render); + const p = resolveDeck(render).posts; + const left = p.position === "top-left"; + const cw = even(p.width); + const room = { x: left ? cw : 0, width: W - cw, height: H - deck.height }; + const fit = Math.min(room.width - 2 * p.inset, ((room.height - 2 * p.inset) * W) / H); + const fw = even(Math.max(2, fit)); + const fh = even((fw * H) / W); + return { + W, H, + column: { x: left ? 0 : W - cw, y: 0, width: cw, height: room.height }, + footage: { + x: room.x + Math.floor((room.width - fw) / 2), y: Math.floor((room.height - fh) / 2), width: fw, height: fh, + }, + deck, + }; +} + +/** + * Is this cut a FEED: the deck on, its posts shown in the `feed` layout, and + * at least one post to draw on a clip of `entries` (the cut's whole timeline, + * never an `--only` slice of it)? The build frames its footage into + * `feedGeometry` exactly when this is true, and the schedule says + * `layout: "feed"` exactly then -- a feed with nothing to draw is the deck alone. + */ +export function feedOn({ render, posts = [], entries = [] }) { + if (!deckOn(render)) return false; + const p = resolveDeck(render).posts; + return p.show && p.layout === "feed" && attachPosts({ posts: posts ?? [], entries }).length > 0; +} + +/** * How long each clip that carries posts is held on its last frame (entry id → * seconds), in WHOLE FRAMES: `tpad` clones a whole number of frames (2.5 s at * 25 fps is 63, not 62.5) while `apad` pads exactly, so an unrounded hold @@ -869,9 +979,11 @@ export function shiftedFootage(render) { */ export function postHolds({ posts = [], entries = [], metas = [], render }) { const fps = render?.fps ?? 30; - const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps; + const settings = resolveDeck(render).posts; + const hold = Math.round(settings.hold * fps) / fps; const out = new Map(); - if (!(hold > 0)) return out; + // The feed never pauses the cut: a post is in its column while the clip plays on. + if (!(hold > 0) || settings.layout === "feed") return out; for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold); return out; } diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -136,6 +136,19 @@ export async function verifyDeck(variantDir, render, file, problems) { if (!(Math.abs(videoFrames - schedule.total * fps) <= 1.5)) { problems.push(`the picture is ${videoFrames} frames for a ${schedule.total.toFixed(3)}s schedule (${want} frames)`); } + // The posts feed: one sequence for the whole cut, as long as the deck's -- + // and a cut that never pauses: no hold and no footage move in its schedule. + let feed = null; + if (schedule.layout === "feed") { + const dir = path.join(variantDir, "chrome", "feed-frames"); + const got = await countFrames(dir); + feed = { frames: got, expectedFrames: want, posts: (schedule.posts ?? []).length }; + if (got !== want) problems.push(`${dir} holds ${got} frames; the posts feed runs the whole cut, ${want}`); + const held = (schedule.segments ?? []).filter((s) => s.hold > 0).map((s) => s.id); + if (held.length || schedule.moves?.length) { + problems.push(`the posts feed never pauses the cut, but the schedule holds ${held.join(", ") || "nothing"} and moves ${schedule.moves?.length ?? 0}`); + } + } // The posts windows: each laid at its own second, each as long as snapWindow says. const posts = []; for (const r of postsRegions(render, variantDir, schedule)) { @@ -148,6 +161,7 @@ export async function verifyDeck(variantDir, render, file, problems) { const holds = await verifyHolds(file, schedule, render, problems); return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length, + ...(feed ? { feed } : {}), ...(posts.length ? { posts } : {}), ...(holds.length ? { holds } : {}), }; @@ -282,6 +296,9 @@ async function main() { ); if (res.deck) { console.log(` deck: ${res.deck.frames}/${res.deck.expectedFrames} frame(s) over ${res.deck.segments} segment(s), ${res.deck.total}s`); + if (res.deck.feed) { + console.log(` posts feed: ${res.deck.feed.frames}/${res.deck.feed.expectedFrames} frame(s), ${res.deck.feed.posts} post(s), no hold`); + } for (const w of res.deck.posts ?? []) { console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`); }