Archilyzer · Source

archilyzer

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

commit c83389e06967a52149a336d97bd784f72c32bd87
parent 3784a11633f0483716c15b9b5d92bc2d64d9ca25
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:44:31 -0400

report-to-video: a flips panel -- THEN and NOW, back to back

render.chrome.flips: pairs of a THEN entry and a NOW entry, each with its when and a few of its words. A panel down the frame's left (the rail's region; a cut has one or the other) holds the pair while it plays: the topic and its place, the THEN card lit, the NOW card slamming in under it with a flash as the second clip starts, the years between rolling up on an odometer; the pair lifts away as it ends. The build checks the pairs against the timeline (entries exist, THEN first, one pair each).

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

Diffstat:
Mumtool/report-to-video/build-video.mjs | 28+++++++++++++++++++++++++---
Aumtool/report-to-video/chrome-flips.mjs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 17+++++++++++++----
Mumtool/report-to-video/deck.mjs | 21++++++++++++++++-----
Aumtool/report-to-video/flips.mjs | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/flips.test.mjs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 542 insertions(+), 12 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -114,6 +114,7 @@ import { } from "./deck.mjs"; import { validateClaims } from "./factcheck.mjs"; import { validateThreadEntries } from "./threads.mjs"; +import { validateFlipEntries } from "./flips.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; @@ -2299,7 +2300,7 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts // The posts feed (`name: "feed"`) and the fact-check stamps (`name: // "stamp"`) are whole-cut sequences like the deck's, laid exactly as the // deck's is. - const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads"; + const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads" || r.name === "flips"; const posts = r.name === "posts"; inputs.push( ...(deck || posts ? ["-reinit_filter", "0"] : []), @@ -2365,9 +2366,9 @@ export function feedRegion(render, frames) { } /** The thread rail as an overlay region: its frames at threadsGeometry's box. */ -export function threadsRegion(render, frames) { +export function threadsRegion(render, frames, name = "threads") { const { cards: _c, ...box } = threadsGeometry(render); - return { name: "threads", frames, ...box }; + return { name, frames, ...box }; } /** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */ @@ -3612,6 +3613,26 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro }); regions.push(threadsRegion(render, th.frames)); } + + // The flips panel (a schedule with `flips`): the same region as the rail. + if (schedule.flips?.pairs?.length) { + EMIT("chrome", { phase: "compose", region: "flips", ...(duration != null ? { from, duration } : {}) }); + const t1 = Date.now(); + const fl = await composeChrome({ + manifestPath, outDir, variant, region: "flips", doRender: true, + fps: render.fps, workers: 2, quality: "high", format: "png-sequence", + ...(duration != null ? { from, duration } : {}), + }); + if (fl.frameCount !== want) { + throw new Error(`the flips panel's sequence is ${fl.frameCount} frames but the deck's is ${want}`); + } + EMIT("chrome", { + phase: fl.cached ? "cached" : "render", region: "flips", + frames: fl.frameCount, key: fl.key, dir: fl.frames, + seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), + }); + regions.push(threadsRegion(render, fl.frames, "flips")); + } return { regions, outLabel: "[hfout]" }; } @@ -3912,6 +3933,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly { const errors = [ ...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole), ...validateThreadEntries(whole), + ...validateFlipEntries(whole), ]; if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); } diff --git a/umtool/report-to-video/chrome-flips.mjs b/umtool/report-to-video/chrome-flips.mjs @@ -0,0 +1,190 @@ +// The FLIPS panel's composition: one HyperFrames page for a whole cut, the +// frame's left side beside the footage (deck.mjs threadsGeometry -- the region +// the thread rail would take), one pair on it at a time (flips.mjs): its +// topic, the THEN card, the years between, the NOW card slamming in. +// +// PURE, like chrome-threads.mjs: a schedule and a render block in, an HTML +// string out; compose-chrome.mjs renders it (`region: "flips"`). +import { pageDuration, threadsGeometry } from "./deck.mjs"; +import { mix, rgba } from "./chrome-deck.mjs"; +import { flipCues } from "./flips.mjs"; + +const esc = (s) => + String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;"); + +const r4 = (v) => Math.round(v * 10000) / 10000; + +/** THEN's colour when the palette names none (`palette.then`): a cool blue against the accent's NOW. */ +export const THEN_COLOR = "#6fa8dc"; + +/** + * The panel's HTML, for the whole cut -- or a window of it (`from`/`duration`). + * `fonts` = `{ regular, bold }`; `gsap` the vendored script. `?still=<t>` and + * the preview's `deck:seek` take CUT seconds. + */ +export function flipsHtml(schedule, render, opts = {}) { + const sched = schedule.flips; + if (!sched?.pairs?.length) throw new Error("flips: the schedule has no pairs"); + const geo = threadsGeometry(render); + const pal = render.palette; + const W = geo.width, H = geo.height; + const C = geo.cards; + const fonts = opts.fonts ?? {}; + 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(`flips: nothing to render from ${from}s of a ${total}s cut`); + const windowed = from > 0 || Math.abs(dur - total) > 1e-6; + const { init, cues } = flipCues(sched); + const thenC = /^#[0-9a-fA-F]{6}$/.test(pal.then ?? "") ? pal.then : THEN_COLOR; + const nowC = pal.accent; + // Type from the panel's width: 298 px at the deck's default footage, wider with less footage. + const k = Math.max(0.8, Math.min(1.4, C.width / 300)); + const S = (v) => Math.round(v * k); + const topicSize = S(30), idxSize = S(15), whenSize = S(50), wordsSize = S(27), tagSize = S(14), gapSize = S(22); + const wordsLine = Math.round(wordsSize * 1.22); + const gapLine = Math.round(gapSize * 1.2); + + const odo = (years) => + Array.from({ length: years + 1 }, (_, i) => `<span class="digit">+${i}</span>`).join(""); + const sideHtml = (cls, tag, s, color, key) => + `<div class="card ${cls}" data-k="${key}" style="--c:${color}; --cg:${rgba(color, 0.55)}; --cb:${rgba(mix(pal.bg, color, 0.16), 0.95)}">` + + `<div class="row"><span class="tag">${tag}</span><span class="when">${esc(s.when)}</span></div>` + + `<div class="words">“${esc(s.words)}”</div>`; + + const pairsHtml = sched.pairs + .map((p, j) => + `<div class="pair" data-pair="${esc(p.id)}" data-k="p${j}">` + + `<div class="top"><span class="idx">${String(p.index).padStart(2, "0")} / ${String(p.of).padStart(2, "0")}</span>` + + `<div class="topic">${esc(p.topic)}</div></div>` + + sideHtml("then", "THEN", p.then, thenC, `t${j}`) + `</div>` + + (p.years != null + ? `<div class="gap" data-k="g${j}"><span class="odo"><span class="strip" data-k="gc${j}">${odo(p.years)}</span></span>` + + `<span class="unit">${p.years === 1 ? "year" : "years"} later</span></div>` + : `<div class="gap blank"></div>`) + + sideHtml("now", "NOW", p.now, nowC, `n${j}`) + `<div class="flash" data-k="nf${j}"></div></div>` + + `</div>`) + .join("\n "); + + const data = { + total: r4(total), + window: windowed ? { from: r4(from), dur } : null, + wordsSize, + ids: sched.pairs.map((p) => p.id), + init, + cues: cues.map(({ why, ...c }) => c), + }; + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + const fps = schedule.fps ?? render.fps ?? 30; + + 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> + @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; } + #flips-clip, .panel { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + /* One pair at a time, centred in the panel's height. */ + .pair { position: absolute; left: ${C.x}px; top: 0; width: ${C.width}px; height: ${H}px; visibility: hidden; opacity: 0; + display: flex; flex-direction: column; justify-content: center; gap: ${S(10)}px; } + .top { margin-bottom: ${S(6)}px; } + .idx { font-family: 'DeckSansBold', sans-serif; font-size: ${idxSize}px; letter-spacing: 0.14em; color: ${pal.muted}; + font-variant-numeric: tabular-nums; } + .topic { margin-top: ${S(4)}px; font-family: 'DeckSansBold', sans-serif; font-size: ${topicSize}px; + line-height: ${Math.round(topicSize * 1.1)}px; letter-spacing: 0.04em; text-transform: uppercase; color: ${pal.fg}; + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } + /* A side: its tag and when on a row, then the words. */ + .card { position: relative; border-radius: 10px; padding: ${S(12)}px ${S(14)}px ${S(14)}px; + background: var(--cb); border-left: 6px solid var(--c); + box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.08)}, 0 10px 26px rgba(0, 0, 0, 0.35); transform-origin: 20% 50%; } + .now { visibility: hidden; opacity: 0; } + .row { display: flex; align-items: baseline; gap: ${S(10)}px; } + .tag { font-family: 'DeckSansBold', sans-serif; font-size: ${tagSize}px; line-height: ${tagSize + 8}px; letter-spacing: 0.14em; + padding: 0 ${S(8)}px; border-radius: 4px; color: ${pal.bg}; background: var(--c); } + .when { font-family: 'DeckSansBold', sans-serif; font-size: ${whenSize}px; line-height: ${Math.round(whenSize * 1.05)}px; + color: var(--c); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; } + .words { margin-top: ${S(6)}px; font-family: 'DeckSansBold', sans-serif; font-size: ${wordsSize}px; line-height: ${wordsLine}px; + color: ${pal.fg}; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; } + .flash { position: absolute; inset: -4px; border-radius: 12px; opacity: 0; pointer-events: none; + box-shadow: 0 0 0 3px ${nowC}, 0 0 34px 10px ${rgba(nowC, 0.6)}; } + /* The years between: an odometer, then the unit. */ + .gap { display: flex; align-items: center; justify-content: center; gap: ${S(8)}px; height: ${gapLine + S(6)}px; + visibility: hidden; opacity: 0; } + .gap.blank { visibility: hidden; } + .odo { display: inline-block; height: ${gapLine}px; overflow: hidden; } + .strip { display: flex; flex-direction: column; } + .digit { display: block; height: ${gapLine}px; font-family: 'DeckSansBold', sans-serif; font-size: ${gapSize}px; + line-height: ${gapLine}px; color: ${nowC}; font-variant-numeric: tabular-nums; text-align: right; } + .unit { font-size: ${gapSize}px; line-height: ${gapLine}px; color: ${pal.muted}; letter-spacing: 0.02em; } + </style> + </head> + <body> + <div id="root" data-composition-id="flips" data-start="0" data-duration="${pageDuration(dur, fps)}" + data-width="${W}" data-height="${H}"> + <div id="flips-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, fps)}" data-track-index="1"> + <div class="panel" data-k="panel"> + ${pairsHtml} + </div> + </div> + </div> + + <script id="flips-data" type="application/json">${json}</script> + <script> + const S = JSON.parse(document.getElementById("flips-data").textContent); + const byK = {}; + for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; + for (const k of Object.keys(S.init)) if (byK[k]) gsap.set(byK[k], S.init[k]); + const inner = gsap.timeline({ paused: true }); + for (const c of S.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); + } + inner.set({}, {}, S.total); + let tl = inner; + if (S.window) { + tl = gsap.timeline({ paused: true }); + tl.add(inner.tweenFromTo(S.window.from, S.window.from + S.window.dur, { duration: S.window.dur, ease: "none" }), 0); + } + window.__timelines = window.__timelines || {}; + window.__timelines["flips"] = tl; + const ready = document.fonts.load(S.wordsSize + "px DeckSansBold").catch(() => {}).then(() => { + document.documentElement.dataset.fit = "1"; + }); + const params = new URLSearchParams(location.search); + const local = (t) => { + const v = Number(t) || 0; + return S.window ? Math.max(0, Math.min(S.window.dur, v - S.window.from)) : Math.max(0, v); + }; + const still = params.get("still"); + if (still !== null) { + tl.seek(local(still), false); + ready.then(() => tl.seek(local(still), false)); + } + if (params.get("preview") === "1") { + window.addEventListener("message", (e) => { + const m = e.data || {}; + if (m.type === "deck:seek") tl.seek(local(m.t), false); + }); + ready.then(() => { + if (window.parent !== window) window.parent.postMessage({ type: "flips:ready", total: S.total, ids: S.ids }, "*"); + }); + } + </script> + </body> +</html> +`; +} diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -48,6 +48,7 @@ import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; import { feedHtml } from "./chrome-feed.mjs"; import { stampHtml } from "./chrome-stamp.mjs"; import { threadsHtml } from "./chrome-threads.mjs"; +import { flipsHtml } from "./chrome-flips.mjs"; const run = promisify(execFile); @@ -698,6 +699,11 @@ async function regionHtml(region, { manifest, manifestDir, base, projDir, assets const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); return threadsHtml(schedule, manifest.render, { fonts, from, duration }); } + if (region === "flips") { + // The flips panel (chrome-flips.mjs): the deck's faces, no QR. + const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); + return flipsHtml(schedule, manifest.render, { fonts, 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 @@ -820,9 +826,9 @@ 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 === "feed" || region === "posts" || region === "teaser" || region === "stamp" || - region === "threads"; + region === "threads" || region === "flips"; // The regions drawn over the whole cut from its schedule, windowable alike. - const wholeCut = region === "deck" || region === "feed" || region === "stamp" || region === "threads"; + const wholeCut = region === "deck" || region === "feed" || region === "stamp" || region === "threads" || region === "flips"; let teaser = null; if (region === "teaser") { teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null; @@ -843,6 +849,9 @@ export async function composeChrome({ 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)"); } + if (region === "flips" && !sched.flips?.pairs?.length) { + throw new Error("the flips region needs a schedule with flip pairs (render.chrome.flips)"); + } if (region === "threads" && !sched.threads?.threads?.length) { throw new Error("the threads region needs a schedule with a thread rail (render.chrome.threads)"); } @@ -974,7 +983,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|feed|stamp|threads|posts|teaser] [--variant sourced|full]\n" + + "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|threads|flips|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" + @@ -997,7 +1006,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 === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" || region === "threads" ? 2 : null), + workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" || region === "threads" || region === "flips" ? 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 @@ -21,6 +21,10 @@ import { claimOf, originalUrlAt, resolveFactcheck, roundStamps, stampSchedule, validateFactcheck, } from "./factcheck.mjs"; import { threadOf, threadSchedule, threadsOn, validateThreads } from "./threads.mjs"; +import { flipSchedule, flipsOn, validateFlips } from "./flips.mjs"; + +/** Is the left panel on: the thread rail (threads.mjs) or the flips panel (flips.mjs) -- one region, one or the other. */ +export const railOn = (render) => threadsOn(render) || flipsOn(render); /** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */ export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24"; @@ -133,7 +137,7 @@ export function validateChrome(chrome, render = {}) { const errors = []; if (chrome === undefined || chrome === null) return errors; if (!isObj(chrome)) return ["render.chrome must be an object"]; - unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads"], "render.chrome", errors); + unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads", "flips"], "render.chrome", errors); if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"'); if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"'); if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set"); @@ -147,6 +151,9 @@ export function validateChrome(chrome, render = {}) { errors.push(...validateFactcheck(chrome.factcheck)); // The thread rail (threads.mjs): the deck's layout only, beside the footage. errors.push(...validateThreads(chrome.threads)); + // The flips panel (flips.mjs): the same region as the rail, so not both. + errors.push(...validateFlips(chrome.flips)); + if (threadsOn({ chrome }) && flipsOn({ chrome })) errors.push("render.chrome.threads and render.chrome.flips share the left panel: set one"); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; @@ -269,11 +276,12 @@ export function validateChrome(chrome, render = {}) { // sees the posts. if (d.posts !== undefined && resolveDeck({ chrome }).posts.show) errors.push(...postsFitErrors({ ...render, chrome })); const room = g.H - g.deck.height; - if (threadsOn({ chrome })) { - if (resolveDeck({ chrome }).posts.layout === "feed") errors.push("render.chrome.threads needs the popup posts, not the feed"); + if (railOn({ chrome })) { + const which = threadsOn({ chrome }) ? "render.chrome.threads" : "render.chrome.flips"; + if (resolveDeck({ chrome }).posts.layout === "feed") errors.push(`${which} needs the popup posts, not the feed`); const rail = threadsGeometry({ ...render, chrome }); if (rail.cards.width < 220) { - errors.push(`render.chrome.threads leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`); + errors.push(`${which} leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`); } } if (g.footage.height > room) { @@ -346,7 +354,7 @@ export function deckGeometry(render) { const dh = deck.height; const fw = even(W * deck.footageScale); const fh = even((fw * H) / W); - const fx = threadsOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2); + const fx = railOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2); return { W, H, footage: { x: fx, y: Math.floor((H - dh - fh) / 2), width: fw, height: fh }, @@ -642,6 +650,7 @@ export function deckSchedule({ const threads = threadsOn(render) ? threadSchedule({ segments: segs.map((s, i) => ({ ...s, thread: threadOf(entries[i]) })), D, total, render, moves }) : null; + const flips = flipsOn(render) ? flipSchedule({ segments: segs, total, render, moves }) : null; return { version: 1, kind: "deck", @@ -675,6 +684,8 @@ export function deckSchedule({ ...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}), // The thread rail: present only when it is on. ...(threads ? { threads } : {}), + // The flips panel: present only when it is on. + ...(flips ? { flips } : {}), // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. ...(placed.length ? { posts: roundPosts(placed) } : {}), diff --git a/umtool/report-to-video/flips.mjs b/umtool/report-to-video/flips.mjs @@ -0,0 +1,188 @@ +// FLIPS: a cut built of pairs -- a line from THEN and the opposite line from +// NOW, back to back -- with a panel down the frame's left that holds the pair +// while it plays: the topic, the THEN card (when, and the words) lit as the +// first clip plays, then the NOW card slamming in under it as the second +// starts, the years between them counting up. When the pair ends it lifts +// away and the next one comes in. Nothing is said for her: the panel's words +// are hers, verbatim, a few of them. +// +// render.chrome: "flips": { "pairs": [ { "id": "vax", "topic": "Vaccines", +// "then": { "entry": "f-vax-then", "when": "2019", "words": "…" }, +// "now": { "entry": "f-vax-now", "when": "2025", "words": "…" } } ] } +// +// With flips on, the footage box moves to the frame's right edge and the panel +// takes the left, as the thread rail's does (deck.mjs deckGeometry, +// threadsGeometry); the two are one region and a cut has one or the other. +// +// PURE, like threads.mjs. chrome-flips.mjs draws the panel. +import { planCues } from "./threads.mjs"; + +/** The limits: pairs, a topic's, a `when`'s and a side's words' characters. */ +export const FLIPS_LIMITS = Object.freeze({ pairs: Object.freeze([1, 24]), topic: 32, when: 18, words: 110 }); + +/** + * The panel's motion, in seconds: a pair rises in over `enter`, its NOW card + * slams over `slam` (from `slamScale`) with a flash, the gap counts over + * `count`, the THEN card dims over `dim`; a pair lifts away over `leave`. + */ +export const FLIP_MOTION = Object.freeze({ + enter: 0.35, slam: 0.22, slamScale: 1.35, flashUp: 0.05, flashDown: 0.5, count: 0.6, dim: 0.3, leave: 0.3, aside: 0.4, +}); + +/** How bright the THEN card rests once NOW has landed. */ +export const THEN_DIM = 0.5; + +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/; +const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const oneLine = (v, max) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v) && v.length <= max; + +/** Is the flips panel on: a `render.chrome.flips` with at least one pair. */ +export function flipsOn(render) { + const f = render?.chrome?.flips; + return isObj(f) && Array.isArray(f.pairs) && f.pairs.length > 0; +} + +/** + * Every reason `render.chrome.flips` cannot be built, as sentences. Shape + * only; `validateFlipEntries` checks the pairs against the timeline. + * + * @returns {string[]} + */ +export function validateFlips(f, where = "render.chrome.flips") { + if (f === undefined) return []; + if (!isObj(f)) return [`${where} must be an object`]; + const errors = []; + for (const k of Object.keys(f)) if (k !== "pairs") errors.push(`${where}.${k} is not a flips setting`); + const [lo, hi] = FLIPS_LIMITS.pairs; + if (!Array.isArray(f.pairs) || f.pairs.length < lo || f.pairs.length > hi) return [...errors, `${where}.pairs must be ${lo} to ${hi} pairs`]; + const ids = new Set(); + f.pairs.forEach((p, i) => { + const w = `${where}.pairs[${i}]`; + if (!isObj(p)) { errors.push(`${w} must be an object`); return; } + for (const k of Object.keys(p)) if (!["id", "topic", "then", "now"].includes(k)) errors.push(`${w}.${k} is not a pair key`); + if (typeof p.id !== "string" || !ID_RE.test(p.id)) errors.push(`${w}.id must be letters, digits, _ or -`); + else if (ids.has(p.id)) errors.push(`${w}.id ${p.id} is listed twice`); + else ids.add(p.id); + if (!oneLine(p.topic, FLIPS_LIMITS.topic)) errors.push(`${w}.topic must be one line of at most ${FLIPS_LIMITS.topic} characters`); + for (const side of ["then", "now"]) { + const s = p[side]; + const ws = `${w}.${side}`; + if (!isObj(s)) { errors.push(`${ws} must be an object`); continue; } + for (const k of Object.keys(s)) if (!["entry", "when", "words"].includes(k)) errors.push(`${ws}.${k} is not a side key`); + if (typeof s.entry !== "string" || !s.entry) errors.push(`${ws}.entry must name a timeline entry`); + if (!oneLine(s.when, FLIPS_LIMITS.when)) errors.push(`${ws}.when must be one line of at most ${FLIPS_LIMITS.when} characters`); + if (!oneLine(s.words, FLIPS_LIMITS.words)) errors.push(`${ws}.words must be one line of at most ${FLIPS_LIMITS.words} characters`); + } + }); + return errors; +} + +/** + * The pairs against the timeline: each side names an entry of the cut, a clip + * (not a teaser), the THEN side before the NOW side, and no entry in two pairs. + * + * @returns {string[]} + */ +export function validateFlipEntries(manifest) { + const render = manifest?.render ?? {}; + if (!flipsOn(render)) return []; + const errors = []; + const at = new Map((manifest.timeline ?? []).map((e, i) => [e?.id, { e, i }])); + const used = new Map(); + render.chrome.flips.pairs.forEach((p, i) => { + const w = `render.chrome.flips.pairs[${i}] (${p?.id ?? "?"})`; + const idx = {}; + for (const side of ["then", "now"]) { + const id = p?.[side]?.entry; + const hit = at.get(id); + if (!hit) { errors.push(`${w}.${side}.entry ${id} is not in the timeline`); continue; } + if (hit.e.type === "teaser") errors.push(`${w}.${side}.entry ${id} is a teaser, not a clip`); + if (used.has(id)) errors.push(`${w}.${side}.entry ${id} is already in pair ${used.get(id)}`); + used.set(id, p.id); + idx[side] = hit.i; + } + if (idx.then !== undefined && idx.now !== undefined && idx.then >= idx.now) errors.push(`${w}: its THEN entry must come before its NOW entry`); + }); + return errors; +} + +/** The years between two `when`s, when both carry one (`2019`, `May 2019`): else null. */ +export function yearsBetween(a, b) { + const y = (s) => { + const m = /(19|20)\d\d/.exec(String(s ?? "")); + return m ? Number(m[0]) : null; + }; + const ya = y(a), yb = y(b); + return ya != null && yb != null && yb > ya ? yb - ya : null; +} + +/** + * The panel's schedule, in the cut's clock: each pair with its two sides' + * spans (`from`: the clip's start; `to`: the next clip's start, or the cut's + * end) and the years between; `asides` as the thread rail's (a popup post's + * move to the end of its clip). + * + * @returns {{ pairs: Array<{ id: string, topic: string, index: number, of: number, years: number|null, + * then: { segment: string, from: number, to: number, when: string, words: string }, + * now: { segment: string, from: number, to: number, when: string, words: string } }>, + * asides: Array<{ from: number, to: number }> }} + */ +export function flipSchedule({ segments, total, render, moves = [] }) { + const R = (v) => Math.round(v * 1000) / 1000; + const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total); + const idx = new Map(segments.map((s, i) => [s.id, i])); + const list = flipsOn(render) ? render.chrome.flips.pairs : []; + const side = (s) => { + const i = idx.get(s.entry); + return i === undefined ? null : { segment: s.entry, from: R(segments[i].start), to: R(endOf(i)), when: s.when, words: s.words }; + }; + const pairs = list + .map((p) => ({ id: p.id, topic: p.topic, then: side(p.then), now: side(p.now), years: yearsBetween(p.then.when, p.now.when) })) + .filter((p) => p.then && p.now) + .sort((a, b) => a.then.from - b.then.from) + .map((p, i, all) => ({ ...p, index: i + 1, of: all.length })); + const asides = moves.map((m) => { + const i = idx.get(m.segment); + return { from: R(m.at), to: R(i === undefined ? total : endOf(i)) }; + }); + return { pairs, asides }; +} + +/** + * Everything the panel's timeline does, as data. Pair j is `p<j>` (autoAlpha, + * y); its THEN card `t<j>` (opacity), NOW card `n<j>` (autoAlpha, scale, x) + * with its flash `nf<j>`, the gap `g<j>` (autoAlpha) and its count `gc<j>` + * (an odometer strip of 0..years, rolled by yPercent -- a transform, so a + * seek from anywhere lands on the same digit); the whole panel is `panel`. + * + * @returns {{ init: Record<string, object>, cues: Array<object> }} + */ +export function flipCues(sched) { + const m = FLIP_MOTION; + const init = { panel: { autoAlpha: 1, x: 0 } }; + const ev = []; + const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why }); + sched.pairs.forEach((p, j) => { + init[`p${j}`] = { autoAlpha: 0, y: 60 }; + init[`t${j}`] = { opacity: 1 }; + init[`n${j}`] = { autoAlpha: 0, scale: m.slamScale, x: 40 }; + init[`nf${j}`] = { opacity: 0 }; + init[`g${j}`] = { autoAlpha: 0 }; + init[`gc${j}`] = { yPercent: 0 }; + add(`p${j}`, p.then.from, m.enter, { autoAlpha: 1, y: 0 }, "power3.out", `${p.id} in`); + add(`n${j}`, p.now.from, m.slam, { autoAlpha: 1, scale: 1, x: 0 }, "power4.in", `${p.id} now`); + add(`nf${j}`, p.now.from + m.slam, m.flashUp, { opacity: 1 }, "none", `${p.id} flash`); + add(`nf${j}`, p.now.from + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `${p.id} flash`); + add(`t${j}`, p.now.from, m.dim, { opacity: THEN_DIM }, "power2.out", `${p.id} then dims`); + if (p.years != null) { + add(`g${j}`, p.now.from, m.slam, { autoAlpha: 1 }, "power2.out", `${p.id} gap`); + add(`gc${j}`, p.now.from + 0.05, m.count, { yPercent: -100 * (p.years / (p.years + 1)) }, "power2.out", `${p.id} count`); + } + add(`p${j}`, p.now.to - m.leave, m.leave, { autoAlpha: 0, y: -50 }, "power2.in", `${p.id} out`); + }); + for (const a of sched.asides) { + add("panel", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post"); + add("panel", a.to, m.aside, { autoAlpha: 1, x: 0 }, "power2.out", "back after a post"); + } + return { init, cues: planCues(init, ev) }; +} diff --git a/umtool/report-to-video/flips.test.mjs b/umtool/report-to-video/flips.test.mjs @@ -0,0 +1,110 @@ +// Tests for the flips panel: its settings and validation (flips.mjs, through +// validateChrome), the pairs against the timeline, the schedule, the cues and +// the page (chrome-flips.mjs). +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { flipCues, flipSchedule, flipsOn, FLIPS_LIMITS, THEN_DIM, validateFlipEntries, validateFlips, yearsBetween } from "./flips.mjs"; +import { deckGeometry, deckSchedule, railGap, validateChrome } from "./deck.mjs"; +import { flipsHtml } from "./chrome-flips.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#e5534b", amber: "#ffc860" }; +const PAIRS = [ + { id: "vax", topic: "Vaccines", then: { entry: "a", when: "2019", words: "I trust doctors" }, now: { entry: "b", when: "2025", words: "Never again" } }, + { id: "dw", topic: "The Daily Wire", then: { entry: "c", when: "May 2021", words: "Best place" }, now: { entry: "d", when: "Ep 300", words: "Worst place" } }, +]; +const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, flips: { pairs: PAIRS } }; +const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.12, palette: PALETTE, chrome: CHROME }; +const TIMELINE = ["a", "b", "c", "d"].map((id) => ({ id, type: "clip" })); + +test("validation: the pairs' shape, and not beside the thread rail", () => { + assert.equal(flipsOn(RENDER), true); + assert.deepEqual(validateFlips({ pairs: PAIRS }), []); + assert.deepEqual(validateChrome(CHROME, RENDER), []); + assert.match(validateFlips({ pairs: [] }).join(";"), /1 to 24 pairs/); + const bad = validateFlips({ + pairs: [ + { id: "x", topic: "t", then: { entry: "a", when: "2019", words: "w".repeat(FLIPS_LIMITS.words + 1) }, now: { entry: "b", when: "x", words: "y" }, extra: 1 }, + { id: "x", topic: "", then: null, now: { entry: "", when: "", words: "y" } }, + ], + }).join(";"); + assert.match(bad, /pairs\[0\]\.extra is not a pair key/); + assert.match(bad, /then\.words must be one line/); + assert.match(bad, /x is listed twice/); + assert.match(bad, /pairs\[1\]\.topic must be/); + assert.match(bad, /pairs\[1\]\.then must be an object/); + assert.match(bad, /now\.entry must name a timeline entry/); + const both = { ...CHROME, threads: { list: [{ id: "t", label: "T" }] } }; + assert.match(validateChrome(both, RENDER).join(";"), /share the left panel/); +}); + +test("the pairs against the timeline: entries exist, THEN first, one pair each", () => { + assert.deepEqual(validateFlipEntries({ render: RENDER, timeline: TIMELINE }), []); + const errs = validateFlipEntries({ render: RENDER, timeline: [{ id: "b", type: "clip" }, { id: "a", type: "teaser" }] }).join(";"); + assert.match(errs, /vax\)?: its THEN entry must come before its NOW entry/); + assert.match(errs, /then\.entry a is a teaser/); + assert.match(errs, /then\.entry c is not in the timeline/); + const twice = { ...RENDER, chrome: { ...CHROME, flips: { pairs: [PAIRS[0], { ...PAIRS[1], then: { ...PAIRS[1].then, entry: "b" } }] } } }; + assert.match(validateFlipEntries({ render: twice, timeline: TIMELINE }).join(";"), /b is already in pair vax/); +}); + +test("the footage moves right for the panel, as for the rail", () => { + const g = deckGeometry(RENDER).footage; + assert.equal(g.x, 1920 - g.width - railGap(RENDER)); +}); + +test("years between two whens, when both carry a year", () => { + assert.equal(yearsBetween("2019", "2025"), 6); + assert.equal(yearsBetween("May 2021", "October 2026"), 5); + assert.equal(yearsBetween("Ep 12", "2025"), null); + assert.equal(yearsBetween("2025", "2019"), null); +}); + +const SEGS = [ + { id: "a", start: 0, duration: 6 }, + { id: "b", start: 5.88, duration: 5 }, + { id: "c", start: 10.76, duration: 7 }, + { id: "d", start: 17.64, duration: 4 }, +]; + +test("the schedule: each pair's spans, its years, its place", () => { + const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER, moves: [{ segment: "c", at: 11 }] }); + assert.deepEqual(s.pairs.map((p) => [p.id, p.index, p.of, p.years]), [["vax", 1, 2, 6], ["dw", 2, 2, null]]); + assert.deepEqual(s.pairs[0].then, { segment: "a", from: 0, to: 5.88, when: "2019", words: "I trust doctors" }); + assert.deepEqual([s.pairs[1].now.from, s.pairs[1].now.to], [17.64, 21.64]); + assert.deepEqual(s.asides, [{ from: 11, to: 17.64 }]); +}); + +test("the cues: a pair rises with THEN, NOW slams and THEN dims, the count rolls to its year, the pair leaves", () => { + const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER }); + const { init, cues } = flipCues(s); + for (const c of cues) for (const k of Object.keys(c.to)) assert.notEqual(c.from[k], undefined, `${c.k} ${k} has a from`); + assert.equal(cues.find((c) => c.k === "p0").at, 0); + const slam = cues.find((c) => c.k === "n0"); + assert.equal(slam.at, 5.88); + assert.deepEqual(slam.to, { autoAlpha: 1, scale: 1, x: 0 }); + assert.equal(cues.find((c) => c.k === "t0").to.opacity, THEN_DIM); + const count = cues.find((c) => c.k === "gc0"); + assert.ok(Math.abs(count.to.yPercent - -100 * (6 / 7)) < 1e-9); + assert.equal(cues.find((c) => c.k === "gc1"), undefined, "no years, no count"); + assert.deepEqual(init.gc0, { yPercent: 0 }); + const leaves = cues.filter((c) => c.k === "p0" && c.to.autoAlpha === 0); + assert.equal(leaves.length, 1); + assert.ok(leaves[0].at + leaves[0].dur <= 10.76 + 1e-9, "gone by the next pair"); +}); + +test("the page: one pair block per pair, both sides' words, an odometer of the years", () => { + const entries = SEGS.map((s) => ({ id: s.id, type: "clip" })); + const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.12, render: RENDER }); + assert.ok(schedule.flips, "the deck's schedule carries the pairs"); + const html = flipsHtml(schedule, RENDER, { fonts: { regular: "r.ttf", bold: "b.ttf" } }); + assert.equal((html.match(/class="pair"/g) ?? []).length, 2); + assert.match(html, /“I trust doctors”/); + assert.match(html, /“Never again”/); + assert.equal((html.match(/class="digit"/g) ?? []).length, 7); + assert.match(html, /years later/); + assert.match(html, /01 \/ 02/); + assert.throws(() => flipsHtml({ ...schedule, flips: undefined }, RENDER), /no pairs/); +});