Archilyzer · Source

archilyzer

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

commit 8162aedae1bdd2d3936491779d4416403da36777
parent 69edae233f4744e52d7391eb8fdd0c3d8b40fd7c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 23:55:30 -0400

Merge umtool/report-video-threads (thread rail, flips panel, panning screenshots, the date always shows)

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

Diffstat:
Mumtool/report-to-video/README.md | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/build-video.mjs | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/chrome-deck.mjs | 10++++++++--
Aumtool/report-to-video/chrome-flips.mjs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/chrome-posts.mjs | 40+++++++++++++++++++++++++++++++++-------
Mumtool/report-to-video/chrome-posts.test.mjs | 40++++++++++++++++++++++++++++++++++++----
Aumtool/report-to-video/chrome-threads.mjs | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 31+++++++++++++++++++++++++++----
Mumtool/report-to-video/deck.mjs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++---
Aumtool/report-to-video/flips.mjs | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/flips.test.mjs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/threads.mjs | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/threads.test.mjs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 1443 insertions(+), 26 deletions(-)

diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -411,7 +411,9 @@ characters, trimmed; an empty value deletes the key, the way the attribution fields do. Without it the deck's title is empty (a card falls back to `heading`) and the subtitle is auto-built: a clip's channel · title · date (the channel only when the cut spans more than one), an image's `title · date`, a -card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else +card's `sub`. A line too long for its column gives way in its middle parts +(the title ends in "…"): its first part and its last (the date) always show +whole. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else the site link at the clip's start); an image draws one only with an explicit `citeUrl`; a card never does. @@ -481,8 +483,10 @@ footage, so it rides on a clip. its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to `maxLines` with an ellipsis — and a QR of the post's page on the archive (below), or of its own `url`. A post with a `shot` draws the screenshot in - place of the words, as wide as the card's text and no taller than a full card - of words, or than `shotMaxHeight` px when that is set. + place of the words, always as wide as the card's text (so it reads), in a + viewport no taller than a full card of words, or than `shotMaxHeight` px when + that is set; a taller screenshot holds on its top for 1.2 s once the card has + landed, pans to its bottom, and holds there 1.2 s before the card leaves. - **Marks.** A post's own `accent`, `logo` and `flag` set one kind of card apart from another at a glance — a source document's sentence beside a platform post, say: the rail and rim in the accent, the logo above the QR in the top corner, @@ -597,6 +601,62 @@ a verdict named without a colour keeps the default's), the stamp's seconds and corner, and whether and where the tally is drawn; `validateChrome()` refuses an unknown key there as everywhere in the block. +### `thread` and `render.chrome.threads` — the thread rail + +A cut whose clips make a few lines of argument can draw them as a rail of cards +down the frame's left side. Each clip names its thread; the list names the +threads in the rail's order, each with an optional outcome. + +```jsonc +"render": { "chrome": { …, "threads": { "list": [ + { "id": "bet", "label": "The bet: her career", // ≤ 32 characters, one line + "outcome": { "verdict": "CONTRADICTED", "label": "Walked back" } }, // label optional (≤ 24): else the verdict's + { "id": "aside", "label": "An aside" } ] } } } // no outcome: never stamped +{ "type": "clip", "id": "c07", …, "thread": "bet" } +``` + +- **The layout.** With the rail on, the footage box moves to the frame's right + edge (24 px in) and the rail takes the left, as tall as the footage: a card + per thread (1–8), each its number, a dot per clip and its label. +- **The motion.** The card of the thread on screen is lit and a string draws + from it to the picture; a clip's dot fills as the clip comes in; when the + thread's last clip ends (1.8 s before it hands over), its outcome is stamped + on its card in the verdict's colour. Before its thread plays a card is dim, + after it rests quieter, so by the last clip the rail is the whole argument. + The rail steps aside while a popup post has moved the footage over it. +- **Checks.** `validateThreads()` (via `validateChrome()`) refuses a bad list + and the feed layout beside it; `validateThreadEntries()` refuses an entry + naming an unlisted thread, a teaser in a thread and a listed thread with no + clip. `schedule.json` gains `threads: { threads, runs, asides }` only when + the rail is on. `chrome-threads.mjs` is the page; `compose-chrome.mjs + --region threads` renders it; the build lays it last, like the stamps. + +### `render.chrome.flips` — THEN and NOW, back to back + +A cut built of pairs: a line from THEN and the opposite line from NOW. A panel +down the frame's left (the rail's region: a cut has the rail or the panel) +holds one pair while it plays. + +```jsonc +"render": { "transition": 0.12, "chrome": { …, "deck": { "footageScale": 0.78 }, "flips": { "pairs": [ + { "id": "vax", "topic": "Vaccines", // ≤ 32 characters + "then": { "entry": "c01", "when": "2019", "words": "…" }, // when ≤ 18, words ≤ 110: verbatim + "now": { "entry": "c02", "when": "2025", "words": "…" } } ] } } } +``` + +- **The motion.** The pair rises in with its THEN clip: its place (`03 / 11`), + the topic, the THEN card (tag, when, words) lit. As the NOW clip starts its + card slams in under it with a flash, the THEN card dims, and when both + `when`s carry a year the years between roll up on an odometer + ("+7 years later"). The pair lifts away as its NOW clip ends. +- **Checks.** `validateFlips()` (via `validateChrome()`) refuses a bad pair + and the rail beside it; `validateFlipEntries()` refuses a side naming no + timeline entry, a teaser, a THEN after its NOW and an entry in two pairs. + `schedule.json` gains `flips: { pairs, asides }`. `chrome-flips.mjs` is the + page; `compose-chrome.mjs --region flips` renders it. +- **Punch.** The panel is built for short clips — the line and nothing else — + and near-hard cuts (`transition` 0.12); a smaller `footageScale` gives it room. + ### The `image` entry type A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -109,10 +109,12 @@ import { ensureWriteDir } from "../lib/report/storage.mjs"; import { assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE, dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, stampGeometry, - teaserHits, teaserSeconds, teaserTitle, + teaserHits, teaserSeconds, teaserTitle, threadsGeometry, validateCutEdits, validatePosts, validateTeasers, } 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"; @@ -2298,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"; + 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"] : []), @@ -2363,6 +2365,12 @@ export function feedRegion(render, frames) { return { name: "feed", frames, ...feedGeometry(render).column }; } +/** The thread rail as an overlay region: its frames at threadsGeometry's box. */ +export function threadsRegion(render, frames, name = "threads") { + const { cards: _c, ...box } = threadsGeometry(render); + return { name, frames, ...box }; +} + /** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */ export function stampRegion(render, frames, schedule) { return { name: "stamp", frames, ...stampGeometry(render, { feed: schedule.layout === "feed" }) }; @@ -3584,6 +3592,47 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro }); regions.push(stampRegion(render, st.frames, schedule)); } + + // The thread rail (a schedule with `threads`): one sequence for the whole + // cut -- or the deck's window -- beside the footage, laid like the deck's. + if (schedule.threads?.threads?.length) { + EMIT("chrome", { phase: "compose", region: "threads", ...(duration != null ? { from, duration } : {}) }); + const t1 = Date.now(); + const th = await composeChrome({ + manifestPath, outDir, variant, region: "threads", doRender: true, + fps: render.fps, workers: 2, quality: "high", format: "png-sequence", + ...(duration != null ? { from, duration } : {}), + }); + if (th.frameCount !== want) { + throw new Error(`the thread rail's sequence is ${th.frameCount} frames but the deck's is ${want}`); + } + EMIT("chrome", { + phase: th.cached ? "cached" : "render", region: "threads", + frames: th.frameCount, key: th.key, dir: th.frames, + seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), + }); + 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]" }; } @@ -3882,7 +3931,10 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE // manifest, deck or not: both are made where the cut is joined. { - const errors = [...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole)]; + const errors = [ + ...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole), ...validateThreadEntries(whole), + ...validateFlipEntries(whole), + ]; if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); } const deck = deckOn(render); diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs @@ -485,9 +485,15 @@ export function deckHtml(schedule, render, opts = {}) { text-overflow: ellipsis; font-family: 'DeckSansBold', sans-serif; font-size: ${t.titleSize}px; line-height: ${t.titleBox}px; letter-spacing: -0.012em; color: ${pal.fg}; } - .deck-sub { display: block; max-width: ${t.width}px; white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; font-size: ${t.subtitleSize}px; line-height: ${t.subtitleBox}px; + /* The source line is a row of parts: when it runs long, the middle parts + (the title) give way and the first (who) and last (the date) always + show whole; a line of one or two parts shrinks its first. */ + .deck-sub { display: flex; max-width: ${t.width}px; white-space: nowrap; overflow: hidden; + font-size: ${t.subtitleSize}px; line-height: ${t.subtitleBox}px; letter-spacing: 0.005em; color: ${pal.muted}; } + .deck-sub .part, .deck-sub .sep { flex: none; } + .deck-sub .part:not(:first-child):not(:last-child), .deck-sub .part:first-child:nth-last-child(-n+3) { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .deck-sub .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; } .blade { position: absolute; right: 0; top: ${Math.round(t.titleBox * 0.14)}px; width: 4px; height: ${Math.round(t.titleBox * 0.72)}px; border-radius: 2px; background: ${pal.accent}; 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/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -93,12 +93,19 @@ export function embedFn(name, fn) { * every card still up leaves over its `out` (a front-loaded fade and a slight * shrink). * + * `pans` are, per card, how many px its screenshot runs past its viewport (0: + * it fits). A tall screenshot is drawn at the card's full width -- readable -- + * in a viewport no taller than the column allows, and pans up through the + * rest (`s<j>`, its y): it holds `panHold` s on its top once the card has + * landed, glides to its bottom, and holds there `panHold` s before it leaves. + * With too little time for the holds, the glide takes all of it. + * * @returns {{ tops: number[], init: Record<string, object>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ export function postsCues({ posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624, - glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, + glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, pans = [], panHold = 1.2, }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; @@ -112,6 +119,7 @@ export function postsCues({ for (let j = 0; j < posts.length; j += 1) { init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 }; init[`g${j}`] = { opacity: 0 }; + if (pans[j] > 0) init[`s${j}`] = { y: 0 }; } const ev = []; @@ -135,6 +143,14 @@ export function postsCues({ // The flare, as the card lands; then it settles to a quiet rim. add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`); add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`); + if (pans[j] > 0) { + const landed = t + enter; + const leave = p.out[0]; + let a = landed + panHold; + let b = leave - panHold; + if (b - a < 1) { a = landed; b = Math.max(landed + MIN, leave); } + add(`s${j}`, a, b - a, { y: -pans[j] }, "sine.inOut", `pan ${p.id}`); + } visible.push(j); } for (const j of visible) { @@ -252,7 +268,7 @@ export function postsHtml(schedule, render, window, opts = {}) { return ( `<article class="post${shot ? " has-shot" : ""}${logo ? " has-logo" : ""}" data-post="${esc(p.id)}" data-k="c${j}"${style}>` + (shot - ? `<div class="body shot-body">${flag}<img class="shot" src="${esc(shot)}" alt=""></div>` + ? `<div class="body shot-body">${flag}<div class="shot-view"><img class="shot" data-k="s${j}" src="${esc(shot)}" alt=""></div></div>` : `<div class="body">${flag}` + `<div class="meta">` + (platform ? `<span class="platform">${esc(platform)}</span>` : "") + @@ -355,11 +371,14 @@ export function postsHtml(schedule, render, window, opts = {}) { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; } .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; } .para.gone { display: none; } - /* A post's screenshot in place of its words: as wide as the words would - be, no taller than a full card of them -- or than \`shotMaxHeight\`. */ + /* A post's screenshot in place of its words: always as wide as the words + would be, so it reads; its viewport is no taller than a full card of + them -- or than \`shotMaxHeight\` -- and a taller one pans up through + it (postsCues \`pans\`). */ .shot-body { padding: ${pad - 6}px; } - .shot { display: block; width: 100%; height: auto; max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; - object-fit: contain; object-position: left top; border-radius: 6px; } + .shot-view { position: relative; overflow: hidden; border-radius: 6px; + max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; } + .shot { display: block; width: 100%; height: auto; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; @@ -428,7 +447,14 @@ export function postsHtml(schedule, render, window, opts = {}) { const cards = P.ids.map((_, j) => byK["c" + j]); cards.forEach(clampText); const heights = cards.map((c) => c.offsetHeight); - const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap, + // How far each screenshot runs past its viewport: that much to pan. + const pans = cards.map((c) => { + const view = c.querySelector(".shot-view"); + const img = view && view.querySelector("img.shot"); + return img ? Math.max(0, Math.round(img.offsetHeight - view.clientHeight)) : 0; + }); + document.documentElement.dataset.pans = pans.join(","); + const plan = postsCues({ posts: P.posts, heights, pans, column: P.column, gap: P.gap, enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt, glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest }); cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; }); diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -467,7 +467,7 @@ test("a post with a screenshot draws it in place of its text card, its QR cell k const open = html.indexOf('data-post="a"'); const card = html.slice(open, html.indexOf("<article", open + 1)); assert.match(html, /<article class="post has-shot" data-post="a"/); - assert.ok(card.includes('<img class="shot" src="assets/shot00.png" alt="">')); + assert.match(card, /<div class="shot-view"><img class="shot" data-k="s\d+" src="assets\/shot00\.png" alt=""><\/div>/); assert.doesNotMatch(card, /class="para"|class="meta"/, "no words beside the picture"); assert.ok(card.includes('<img src="assets/qr00.png"'), "the QR stays"); // The post without one is the text card it always was. @@ -495,7 +495,7 @@ test("a post's accent, logo and flag mark its card; a post without them is drawn const card = html.slice(open - 60, html.indexOf("<article", open + 1)); assert.match(card, /class="post has-shot has-logo"/); assert.match(card, /style="--acc: #c98fd6; --acc-rim: rgba\(201, ?143, ?214, ?0\.95\)/); - assert.ok(card.includes('<div class="flag">No source in the article</div><img class="shot"'), "the flag heads the picture"); + assert.ok(card.includes('<div class="flag">No source in the article</div><div class="shot-view"><img class="shot"'), "the flag heads the picture"); assert.ok(card.includes('<img class="logo" src="assets/logo00.png" alt=""><img src="assets/qr00.png"'), "logo above the QR"); const b = html.slice(html.indexOf('data-post="b"') - 60); assert.doesNotMatch(b.slice(0, b.indexOf("</article>")), /--acc|class="flag"|class="logo"/); @@ -508,11 +508,43 @@ test("a post's accent, logo and flag mark its card; a post without them is drawn assert.match(bad, /flag must be a short label/); }); -test("shotMaxHeight caps a screenshot in px; unset, a full card of words does", () => { +test("shotMaxHeight caps a screenshot's viewport in px; unset, a full card of words does", () => { const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" })]); const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total }); - const cap = (render) => /\.shot \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1]; + const cap = (render) => /\.shot-view \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1]; const tall = { ...RENDER, chrome: { ...RENDER.chrome, deck: { ...RENDER.chrome.deck, posts: { ...RENDER.chrome.deck?.posts, shotMaxHeight: 820 } } } }; assert.equal(cap(tall), "820"); assert.notEqual(cap(RENDER), "820"); }); + +test("postsCues: a tall screenshot holds on its top, pans to its bottom, and holds before it leaves", () => { + const posts = [{ id: "p0", appear: 10, out: [30, 30.5] }, { id: "p1", appear: 12, out: [30, 30.5] }]; + const plan = postsCues({ posts, heights: [800, 200], pans: [600, 0], column: 2000, enter: 0.5, panHold: 1.2 }); + assert.deepEqual(plan.init.s0, { y: 0 }); + assert.equal(plan.init.s1, undefined, "a screenshot that fits does not pan"); + const pan = plan.cues.filter((c) => c.k === "s0"); + assert.equal(pan.length, 1); + near(pan[0].at, 10 + 0.5 + 1.2, "after the card lands and a hold on its top"); + near(pan[0].at + pan[0].dur, 30 - 1.2, "a hold on its bottom before it leaves"); + assert.deepEqual([pan[0].from, pan[0].to], [{ y: 0 }, { y: -600 }]); + // Too little time for the holds: the glide takes all of it. + const tight = postsCues({ posts: [{ id: "q", appear: 0, out: [2, 2.5] }], heights: [800], pans: [300], column: 2000, enter: 0.5 }); + const g = tight.cues.find((c) => c.k === "s0"); + near(g.at, 0.5, "from the landing"); + near(g.at + g.dur, 2, "to the leave"); +}); + +test("postsHtml: a screenshot sits in a viewport at the card's width, and the page measures its pan", () => { + const html = postsHtml( + { fps: 30, total: 40, segments: [{ id: "c1", start: 0, duration: 40 }], posts: [ + { id: "x1", segment: "c1", slot: 0, appear: 2, out: [30, 30.5], platform: "x", handle: "a", date: "2026-01-01", text: "t", shot: "s.png" }, + ] }, + { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shotMaxHeight: 800 } } } }, + { segment: "c1", from: 0, to: 31 }, + { shotSrcs: { x1: "assets/shot0.png" } }, + ); + assert.match(html, /<div class="shot-view"><img class="shot" data-k="s0" src="assets\/shot0.png"/); + assert.match(html, /\.shot \{ display: block; width: 100%; height: auto; \}/); + assert.doesNotMatch(html, /\.shot \{[^}]*object-fit/, "never shrunk to fit"); + assert.match(html, /const pans = cards\.map/); +}); diff --git a/umtool/report-to-video/chrome-threads.mjs b/umtool/report-to-video/chrome-threads.mjs @@ -0,0 +1,218 @@ +// The THREAD RAIL's composition: one HyperFrames page for a whole cut, the +// frame's left side beside the footage (deck.mjs threadsGeometry), a card per +// thread (threads.mjs). The card of the thread on screen is lit and strung +// across to the picture; its dots fill clip by clip; its outcome is stamped on +// it as its last clip ends. The rail steps aside while a popup post has moved +// the footage over it. +// +// PURE, like chrome-stamp.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 (`region: "threads"`); the build lays the frames over the cut as +// it lays the deck's. +// +// The timeline is threads.mjs `threadCues`, each cue stating its from -- a +// render is a seek per frame, from parallel workers, in any order. +import { pageDuration, threadsGeometry } from "./deck.mjs"; +import { mix, rgba } from "./chrome-deck.mjs"; +import { threadCues } from "./threads.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; + +/** + * The cards' sizes in the rail's height for `n` threads: each card `h` px tall + * (at most `max`), `gap` apart, the stack centred (`top`). + */ +export function railLayout(height, n, { gap = 14, max = 190 } = {}) { + const h = Math.min(max, Math.floor((height - (n - 1) * gap) / n)); + const total = n * h + (n - 1) * gap; + return { h, gap, top: Math.max(0, Math.floor((height - total) / 2)) }; +} + +/** + * The rail's HTML, for the whole cut -- or a window of it (`from`/`duration`, + * as the deck's). `fonts` = `{ regular, bold }` asset-relative paths + * (DeckSans / DeckSansBold); `gsap` the vendored script. `?still=<t>` and the + * preview's `deck:seek` take CUT seconds. + */ +export function threadsHtml(schedule, render, opts = {}) { + const sched = schedule.threads; + if (!sched?.threads?.length) throw new Error("threads: the schedule has no thread rail"); + 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(`threads: nothing to render from ${from}s of a ${total}s cut`); + const windowed = from > 0 || Math.abs(dur - total) > 1e-6; + const { init, cues } = threadCues(sched); + const n = sched.threads.length; + const lay = railLayout(C.height, n); + // Type scales with the card: a rail of eight is denser than a rail of three. + const k = Math.min(1, lay.h / 170); + const idxSize = Math.round(17 * Math.max(0.8, k)); + const labelSize = Math.round(27 * Math.max(0.72, k)); + const labelLine = Math.round(labelSize * 1.12); + const outSize = Math.round(16 * Math.max(0.8, k)); + const dot = Math.round(11 * Math.max(0.8, k)); + const pad = Math.round(16 * Math.max(0.75, k)); + const rail = 5; + + const cardsHtml = sched.threads + .map((t, j) => { + const top = lay.top + j * (lay.h + lay.gap); + const oc = t.outcome?.color ?? pal.accent; + const dots = t.clips.map((_, d) => `<i class="dot" data-k="d${j}-${d}"></i>`).join(""); + return ( + `<div class="card" data-thread="${esc(t.id)}" data-k="c${j}" style="top:${top}px; --oc:${esc(oc)}; ` + + `--og:${rgba(oc, 0.6)}; --ob:${rgba(mix(pal.bg, oc, 0.14), 0.92)}">` + + `<div class="wash" data-k="w${j}"></div>` + + `<div class="head"><span class="idx">${String(j + 1).padStart(2, "0")}</span><span class="dots">${dots}</span></div>` + + `<div class="label">${esc(t.label)}</div>` + + (t.outcome + ? `<div class="outcome" data-k="o${j}"><span class="word">${esc(t.outcome.label)}</span>` + + `<span class="oflash" data-k="f${j}"></span></div>` + : "") + + `</div>` + + `<div class="string" data-k="s${j}" style="top:${top + Math.round(lay.h / 2) - 1}px"></div>` + ); + }) + .join("\n "); + + const data = { + total: r4(total), + window: windowed ? { from: r4(from), dur } : null, + labelSize, + ids: sched.threads.map((t) => t.id), + init, + cues: cues.map(({ why, ...c }) => c), + }; + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + const fps = schedule.fps ?? render.fps ?? 30; + const cardBg = mix(mix(pal.bg, pal.fg, 0.07), pal.accent, 0.05); + const litBg = mix(mix(pal.bg, pal.fg, 0.12), pal.accent, 0.14); + + 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; } + #threads-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + .rail { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + /* A card: its number, a dot per clip, its label; the outcome stamped at + its foot. Ahead of its thread it is dim; lit while it plays; after, + it keeps its outcome at a quieter rest. */ + .card { position: absolute; left: ${C.x}px; width: ${C.width}px; height: ${lay.h}px; opacity: 0; + border-radius: 10px; overflow: hidden; background: ${cardBg}; + border-left: ${rail}px solid ${pal.accent}; + box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; padding: ${pad - 2}px ${pad}px ${pad}px ${pad}px; } + .wash { position: absolute; inset: 0; opacity: 0; pointer-events: none; background: ${litBg}; + box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.9)}, inset 0 0 26px ${rgba(pal.accent, 0.35)}; } + .head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; + height: ${idxSize + 8}px; } + .idx { font-family: 'DeckSansBold', sans-serif; font-size: ${idxSize}px; line-height: ${idxSize + 8}px; + letter-spacing: 0.12em; color: ${pal.accent}; font-variant-numeric: tabular-nums; } + .dots { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: ${Math.round(dot * 0.55)}px; } + .dot { display: block; width: ${dot}px; height: ${dot}px; border-radius: 50%; background: ${pal.fg}; + box-shadow: 0 0 8px ${rgba(pal.fg, 0.45)}; } + .label { position: relative; margin-top: ${Math.round(pad * 0.45)}px; font-family: 'DeckSansBold', sans-serif; + font-size: ${labelSize}px; line-height: ${labelLine}px; letter-spacing: -0.005em; color: ${pal.fg}; + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } + /* The outcome: a small rubber stamp in its verdict's colour. */ + .outcome { position: absolute; left: ${pad}px; bottom: ${pad - 4}px; visibility: hidden; opacity: 0; + max-width: ${C.width - 2 * pad - rail}px; padding: 3px 10px 4px; border-radius: 5px; + background: var(--ob); border: 2px solid var(--oc); transform-origin: 30% 50%; rotate: -4deg; } + .outcome .word { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-family: 'DeckSansBold', sans-serif; font-size: ${outSize}px; line-height: ${outSize + 6}px; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--oc); } + .oflash { position: absolute; inset: -3px; border-radius: 6px; opacity: 0; pointer-events: none; + box-shadow: 0 0 0 2px var(--oc), 0 0 22px 6px var(--og); } + /* The string: from the lit card across to the picture's edge. */ + .string { position: absolute; left: ${C.x + C.width}px; width: ${W - C.x - C.width}px; height: 3px; + border-radius: 2px; transform-origin: 0% 50%; transform: scaleX(0); + background: linear-gradient(90deg, ${pal.accent} 0%, ${rgba(pal.accent, 0.35)} 100%); + box-shadow: 0 0 10px ${rgba(pal.accent, 0.7)}; } + </style> + </head> + <body> + <div id="root" data-composition-id="threads" data-start="0" data-duration="${pageDuration(dur, fps)}" + data-width="${W}" data-height="${H}"> + <div id="threads-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, fps)}" data-track-index="1"> + <div class="rail" data-k="rail"> + ${cardsHtml} + </div> + </div> + </div> + + <script id="threads-data" type="application/json">${json}</script> + <script> + const S = JSON.parse(document.getElementById("threads-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["threads"] = tl; + + const ready = document.fonts.load(S.labelSize + "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: "threads: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 @@ -47,6 +47,8 @@ import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; 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); @@ -692,6 +694,16 @@ async function regionHtml(region, { manifest, manifestDir, base, projDir, assets const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); return stampHtml(schedule, manifest.render, { fonts, from, duration }); } + if (region === "threads") { + // The thread rail (chrome-threads.mjs): the deck's faces, no QR. + 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 @@ -772,6 +784,10 @@ function runRenderer(cmd, args) { * `chrome/stamp-frames/` and their `.key`, a window `stamp-from<s>[-frames]`, * cached as the deck's are. * + * Threads (`region: "threads"`, a schedule with `threads`): the thread rail + * for the whole cut, exactly as the stamps -- project `chrome/threads/`, + * frames `chrome/threads-frames/`, windowed and cached alike. + * * 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`), @@ -809,9 +825,10 @@ 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"; + const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser" || region === "stamp" || + region === "threads" || region === "flips"; // The regions drawn over the whole cut from its schedule, windowable alike. - const wholeCut = region === "deck" || region === "feed" || region === "stamp"; + 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; @@ -832,6 +849,12 @@ 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)"); + } if (region === "stamp" && !sched.factcheck?.stamps?.length) { throw new Error("the stamp region needs a schedule that stamps a claim (an entry with a `claim`)"); } @@ -960,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|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" + @@ -983,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" ? 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 @@ -20,6 +20,11 @@ import { attributionParts, deckSubtitle } from "./attribution.mjs"; 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"; @@ -132,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"], "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"); @@ -144,6 +149,11 @@ export function validateChrome(chrome, render = {}) { errors.push(...validateEndFade(render)); // The fact-check stamps and tally (factcheck.mjs): drawn only by the deck. 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"; @@ -266,6 +276,14 @@ 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 (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(`${which} leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`); + } + } if (g.footage.height > room) { const max = Math.floor((room / g.H) * 1000) / 1000; errors.push( @@ -321,7 +339,9 @@ export const even = (v) => Math.round(v / 2) * 2; * * The footage box keeps the FRAME's aspect, is `footageScale` of its width * (evened), and is centred in the area above the deck. For 1920×1080 at 0.82 - * with a 190 px deck that is 1574×886 at (173, 2). + * with a 190 px deck that is 1574×886 at (173, 2). With the thread rail on + * (threads.mjs) the box moves to the frame's right edge, `railGap` in, and the + * rail takes the left: 1574×886 at (322, 2). * * @returns {{ W:number, H:number, * footage:{x:number,y:number,width:number,height:number}, @@ -334,13 +354,33 @@ export function deckGeometry(render) { const dh = deck.height; const fw = even(W * deck.footageScale); const fh = even((fw * H) / W); + const fx = railOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2); return { W, H, - footage: { x: Math.floor((W - fw) / 2), y: Math.floor((H - dh - fh) / 2), width: fw, height: fh }, + footage: { x: fx, y: Math.floor((H - dh - fh) / 2), width: fw, height: fh }, deck: { x: 0, y: H - dh, width: W, height: dh }, }; } +/** The air between the frame's edges, the thread rail and the footage. */ +export const railGap = (render) => Math.round(24 * ((render?.width ?? 1920) / 1920)); + +/** + * The thread rail's region (threads.mjs, chrome-threads.mjs): the frame's left + * side beside the footage, as tall as the footage box, from the frame's edge + * to the footage's -- so a lit card's string can reach the picture. `cards` + * is where the cards sit inside it (region-local), `railGap` in from its left + * and short of the footage. + */ +export function threadsGeometry(render) { + const { footage } = deckGeometry(render); + const gap = railGap(render); + return { + x: 0, y: footage.y, width: footage.x, height: footage.height, + cards: { x: gap, y: 0, width: footage.x - 2 * gap, height: footage.height }, + }; +} + /** * Where things sit INSIDE the deck region (region-local pixels). The * composition draws from this; umtool's preview frames the same rect. A @@ -607,6 +647,10 @@ export function deckSchedule({ const feed = placed.length > 0 && deck.posts.layout === "feed"; const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : []; const stamps = stampSchedule({ segments: segs.map((s, i) => ({ ...s, claim: claimOf(entries[i]) })), D, total, render }); + 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", @@ -638,6 +682,10 @@ export function deckSchedule({ }), // The fact-check stamps: present only when a claim is stamped. ...(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/); +}); diff --git a/umtool/report-to-video/threads.mjs b/umtool/report-to-video/threads.mjs @@ -0,0 +1,275 @@ +// THREADS: a cut's clips grouped into the few lines of argument they make, +// drawn as a rail of cards down the frame's left side. The card of the thread +// on screen is lit and strung to the footage; a dot fills for each of its +// clips as it plays; when the thread's last clip ends, its outcome is stamped +// on its card. By the last clip the rail is the whole argument at a glance. +// +// render.chrome: "threads": { "list": [ { "id": "bet", "label": "The bet", +// "outcome": { "verdict": "CONTRADICTED" } } ] } +// timeline entry: "thread": "bet" +// +// An outcome names a verdict of the shared vocabulary; its label and colour +// are the cut's (`factcheck.verdicts`), or the outcome's own `label`. A clip +// with no `thread` belongs to none: nothing is lit while it plays. +// +// With threads on, the footage box moves to the frame's right edge and the +// rail takes the left (deck.mjs deckGeometry, threadsGeometry). A popup post +// moves the footage over the rail, so the rail steps aside while one is up. +// +// PURE, like factcheck.mjs: the settings, their validation, the schedule and +// the cues. No fs. deck.mjs imports this file -- never the other way round. +// chrome-threads.mjs draws the rail. +import { resolveFactcheck, VERDICTS } from "./factcheck.mjs"; + +/** The limits: how many threads, a label's and an outcome label's characters. */ +export const THREADS_LIMITS = Object.freeze({ threads: Object.freeze([1, 8]), label: 32, outcome: 24 }); + +/** + * The rail's motion, in seconds: a card lights over `light` and its string + * draws over `string`; a clip's dot fills over `dot`; an outcome slams in over + * `slam` and flashes; the rail steps aside (and back) over `aside`. The cards + * come up one after another at the start, `stagger` apart. + */ +export const THREAD_MOTION = Object.freeze({ + light: 0.45, string: 0.5, dot: 0.3, slam: 0.3, flashUp: 0.06, flashDown: 0.6, aside: 0.4, intro: 0.5, stagger: 0.08, +}); + +/** How long before its thread's last clip ends the outcome lands, at most. */ +export const OUTCOME_LEAD = 1.8; + +/** A thread id: letters, digits and `_ -`. */ +const THREAD_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) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v); + +/** Is the rail on: a `render.chrome.threads` with at least one thread. */ +export function threadsOn(render) { + const t = render?.chrome?.threads; + return isObj(t) && Array.isArray(t.list) && t.list.length > 0; +} + +/** + * The threads with their outcomes resolved: `[{ id, label, outcome: { verdict, + * label, color } | null }]`, in the rail's order (the list's). + */ +export function resolveThreads(render) { + if (!threadsOn(render)) return []; + const verdicts = resolveFactcheck(render).verdicts; + return render.chrome.threads.list.map((t) => { + const o = isObj(t.outcome) ? t.outcome : null; + const v = o ? verdicts[o.verdict] : null; + return { + id: t.id, + label: t.label, + outcome: o && v ? { verdict: o.verdict, label: o.label ?? v.label, color: v.color } : null, + }; + }); +} + +/** + * Every reason `render.chrome.threads` cannot be built, as sentences (empty: + * it can). validateChrome calls this, so umtool's writer and the build refuse + * the same things. + * + * @returns {string[]} + */ +export function validateThreads(t, where = "render.chrome.threads") { + if (t === undefined) return []; + if (!isObj(t)) return [`${where} must be an object`]; + const errors = []; + for (const k of Object.keys(t)) if (k !== "list") errors.push(`${where}.${k} is not a threads setting`); + const [lo, hi] = THREADS_LIMITS.threads; + if (!Array.isArray(t.list) || t.list.length < lo || t.list.length > hi) { + return [...errors, `${where}.list must be ${lo} to ${hi} threads`]; + } + const seen = new Set(); + t.list.forEach((th, i) => { + const w = `${where}.list[${i}]`; + if (!isObj(th)) { errors.push(`${w} must be an object`); return; } + for (const k of Object.keys(th)) if (!["id", "label", "outcome"].includes(k)) errors.push(`${w}.${k} is not a thread key`); + if (typeof th.id !== "string" || !THREAD_ID_RE.test(th.id)) errors.push(`${w}.id must be letters, digits, _ or -`); + else if (seen.has(th.id)) errors.push(`${w}.id ${th.id} is listed twice`); + else seen.add(th.id); + if (!oneLine(th.label) || th.label.length > THREADS_LIMITS.label) { + errors.push(`${w}.label must be one line of at most ${THREADS_LIMITS.label} characters`); + } + if (th.outcome !== undefined) { + const o = th.outcome; + if (!isObj(o)) { errors.push(`${w}.outcome must be an object`); return; } + for (const k of Object.keys(o)) if (!["verdict", "label"].includes(k)) errors.push(`${w}.outcome.${k} is not an outcome key`); + if (!VERDICTS.includes(o.verdict)) errors.push(`${w}.outcome.verdict must be one of ${VERDICTS.join(", ")}`); + if (o.label !== undefined && (!oneLine(o.label) || o.label.length > THREADS_LIMITS.outcome)) { + errors.push(`${w}.outcome.label must be one line of at most ${THREADS_LIMITS.outcome} characters`); + } + } + }); + return errors; +} + +/** The thread an entry belongs to, or null. */ +export function threadOf(entry) { + return typeof entry?.thread === "string" && entry.thread ? entry.thread : null; +} + +/** + * Every `thread` in the timeline, checked against the list: it names a listed + * thread, it is not on a teaser, and every listed thread has a clip. The + * build refuses with these before it fetches. + * + * @returns {string[]} + */ +export function validateThreadEntries(manifest) { + const errors = []; + const render = manifest?.render ?? {}; + const listed = threadsOn(render) ? new Set(render.chrome.threads.list.map((t) => t?.id)) : new Set(); + const used = new Set(); + (manifest?.timeline ?? []).forEach((e, i) => { + if (e?.thread === undefined || e?.thread === null) return; + const where = `timeline[${i}] (${e.id ?? "?"}).thread`; + if (typeof e.thread !== "string" || !e.thread) { errors.push(`${where} must be a thread id`); return; } + if (e.type === "teaser") { errors.push(`${where}: a teaser belongs to no thread`); return; } + if (!listed.has(e.thread)) { errors.push(`${where} ${e.thread} is not in render.chrome.threads.list`); return; } + used.add(e.thread); + }); + for (const id of listed) if (!used.has(id)) errors.push(`render.chrome.threads: thread ${id} has no clip`); + return errors; +} + +/** + * The rail's schedule, in the cut's clock. `segments` are the schedule's, in + * order, each `{ id, start, duration, thread }`; `moves` are the footage's + * moves for popup posts (deck.mjs footageMoves). + * + * - `threads`: each listed thread with its `clips` (`{ segment, at }`: when + * its dot fills -- halfway into the dissolve that brings the clip in) and, + * with an outcome, `closeAt`: when it is stamped -- `OUTCOME_LEAD` before + * its last clip ends, but never in that clip's first half second; + * - `runs`: when each thread is the one on screen (`{ thread, from, to }`: + * consecutive clips of one thread make one run, ending as the next clip + * starts to come in); + * - `asides`: when the rail steps aside (`{ from, to }`: a popup post's move + * to the end of its clip). + * + * @returns {{ threads: Array<{ id: string, label: string, outcome: object|null, + * clips: Array<{ segment: string, at: number }>, closeAt?: number }>, + * runs: Array<{ thread: string, from: number, to: number }>, + * asides: Array<{ from: number, to: number }> }} + */ +export function threadSchedule({ segments, D, total, render, moves = [] }) { + const R = (v) => Math.round(v * 1000) / 1000; + const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total); + const threads = resolveThreads(render).map((t) => ({ ...t, clips: [] })); + const byId = new Map(threads.map((t) => [t.id, t])); + const last = new Map(); + segments.forEach((s, i) => { + const t = s.thread ? byId.get(s.thread) : null; + if (!t) return; + t.clips.push({ segment: s.id, at: R(i === 0 ? s.start : s.start + D / 2) }); + last.set(t.id, i); + }); + for (const t of threads) { + if (!t.outcome || !last.has(t.id)) continue; + const i = last.get(t.id); + const s = segments[i]; + t.closeAt = R(Math.max(s.start + 0.5, endOf(i) - D - OUTCOME_LEAD)); + } + const runs = []; + segments.forEach((s, i) => { + if (!s.thread || !byId.has(s.thread)) return; + const prev = runs[runs.length - 1]; + if (prev && prev.thread === s.thread && prev.lastIdx === i - 1) { + prev.to = R(endOf(i)); + prev.lastIdx = i; + } else runs.push({ thread: s.thread, from: R(s.start), to: R(endOf(i)), lastIdx: i }); + }); + const asides = moves.map((m) => { + const i = segments.findIndex((s) => s.id === m.segment); + return { from: R(m.at), to: R(i < 0 ? total : endOf(i)) }; + }); + return { threads, runs: runs.map(({ lastIdx, ...r }) => r), asides }; +} + +/** + * Order a page's cue events, clamp each so it never starts before the last on + * its own element ends, and state every from (the last `to` on its element, + * or its `init`): a render is a seek per frame, in any order, so a cue must + * never depend on what played before it. The same bookkeeping as the + * stamps' and the posts'. + */ +export function planCues(init, events, instant = 0.001) { + const r4 = (v) => Math.round(v * 10000) / 10000; + const ev = events.map((e, n) => ({ ...e, at: r4(e.at), dur: r4(Math.max(instant, e.dur)), n })); + ev.sort((a, b) => a.at - b.at || a.n - b.n); + const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); + const freeAt = new Map(); + const cues = []; + for (const e of ev) { + let { at, dur } = e; + const free = freeAt.get(e.k) ?? 0; + if (at < free) { + const end = at + dur; + at = r4(free); + dur = r4(Math.max(instant, end - at)); + } + const cur = state[e.k] ?? (state[e.k] = {}); + const from = {}; + for (const p of Object.keys(e.to)) from[p] = cur[p]; + Object.assign(cur, e.to); + freeAt.set(e.k, r4(at + dur)); + cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); + } + return cues; +} + +/** A card's resting opacity before its thread plays, and after. */ +export const CARD_REST = Object.freeze({ ahead: 0.42, done: 0.8 }); + +/** + * Everything the rail's timeline does, as data. Card j is `c<j>` (opacity), its + * lit wash `w<j>` and string `s<j>` (scaleX from its left), its dots + * `d<j>-<n>`, its outcome `o<j>` (autoAlpha, scale) with its flash `f<j>`; the + * whole rail is `rail` (autoAlpha, x). + * + * @returns {{ init: Record<string, object>, cues: Array<object> }} + */ +export function threadCues(sched) { + const m = THREAD_MOTION; + const init = { rail: { autoAlpha: 1, x: 0 } }; + const ev = []; + const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why }); + const firstRun = new Map(); + for (const r of sched.runs) if (!firstRun.has(r.thread)) firstRun.set(r.thread, r.from); + sched.threads.forEach((t, j) => { + init[`c${j}`] = { opacity: 0 }; + init[`w${j}`] = { opacity: 0 }; + init[`s${j}`] = { scaleX: 0 }; + t.clips.forEach((_, n) => { init[`d${j}-${n}`] = { opacity: 0, scale: 0.4 }; }); + // A card whose thread is already on screen by the end of its intro comes up lit. + const lit = (firstRun.get(t.id) ?? Infinity) <= m.stagger * j + m.intro; + add(`c${j}`, m.stagger * j, m.intro, { opacity: lit ? 1 : CARD_REST.ahead }, "power2.out", `intro ${t.id}`); + t.clips.forEach((c, n) => add(`d${j}-${n}`, c.at, m.dot, { opacity: 1, scale: 1 }, "back.out(2)", `dot ${t.id} ${c.segment}`)); + if (t.outcome && t.closeAt != null) { + init[`o${j}`] = { autoAlpha: 0, scale: 1.6 }; + init[`f${j}`] = { opacity: 0 }; + add(`o${j}`, t.closeAt, m.slam, { autoAlpha: 1, scale: 1 }, "power4.in", `outcome ${t.id}`); + add(`f${j}`, t.closeAt + m.slam, m.flashUp, { opacity: 1 }, "none", `outcome ${t.id} flash`); + add(`f${j}`, t.closeAt + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `outcome ${t.id} flash`); + } + }); + const idx = new Map(sched.threads.map((t, j) => [t.id, j])); + for (const r of sched.runs) { + const j = idx.get(r.thread); + add(`c${j}`, r.from, m.light, { opacity: 1 }, "power2.out", `light ${r.thread}`); + add(`w${j}`, r.from, m.light, { opacity: 1 }, "power2.out", `light ${r.thread}`); + add(`s${j}`, r.from + 0.1, m.string, { scaleX: 1 }, "power3.out", `string ${r.thread}`); + add(`s${j}`, r.to - m.string * 0.6, m.string * 0.6, { scaleX: 0 }, "power2.in", `unstring ${r.thread}`); + add(`w${j}`, r.to, m.light, { opacity: 0 }, "power2.inOut", `dim ${r.thread}`); + add(`c${j}`, r.to, m.light, { opacity: CARD_REST.done }, "power2.inOut", `dim ${r.thread}`); + } + for (const a of sched.asides) { + add("rail", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post"); + add("rail", 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/threads.test.mjs b/umtool/report-to-video/threads.test.mjs @@ -0,0 +1,189 @@ +// Tests for the thread rail: its settings and their validation (threads.mjs, +// through validateChrome), the footage box moving aside for it, its schedule +// (dots, runs, outcomes, asides), its cues, its page (chrome-threads.mjs) and +// its overlay region. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CARD_REST, OUTCOME_LEAD, planCues, resolveThreads, threadCues, threadOf, threadSchedule, threadsOn, THREADS_LIMITS, + validateThreadEntries, validateThreads, +} from "./threads.mjs"; +import { deckGeometry, deckSchedule, railGap, threadsGeometry, validateChrome } from "./deck.mjs"; +import { railLayout, threadsHtml } from "./chrome-threads.mjs"; +import { threadsRegion } from "./build-video.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const LIST = [ + { id: "bet", label: "The bet", outcome: { verdict: "CONTRADICTED" } }, + { id: "proof", label: "Definitive proof", outcome: { verdict: "NOT_FOUND", label: "Still coming" } }, + { id: "aside", label: "An aside" }, +]; +const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, threads: { list: LIST } }; +const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, chrome: CHROME }; +const PLAIN = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; + +test("the rail is on only with a list of threads", () => { + assert.equal(threadsOn(RENDER), true); + assert.equal(threadsOn(PLAIN), false); + assert.equal(threadsOn({ chrome: { threads: { list: [] } } }), false); +}); + +test("an outcome takes its verdict's colour and the cut's label, or its own label", () => { + const t = resolveThreads({ + ...RENDER, chrome: { ...CHROME, factcheck: { verdicts: { CONTRADICTED: { label: "Walked back" } } } }, + }); + assert.equal(t[0].outcome.label, "Walked back"); + assert.match(t[0].outcome.color, /^#[0-9a-f]{6}$/i); + assert.equal(t[1].outcome.label, "Still coming"); + assert.equal(t[2].outcome, null); +}); + +test("validation: the list, ids, labels and outcomes", () => { + assert.deepEqual(validateThreads(undefined), []); + assert.deepEqual(validateThreads({ list: LIST }), []); + assert.match(validateThreads({ list: [] }).join(";"), /1 to 8 threads/); + assert.match(validateThreads({ list: LIST, side: "left" }).join(";"), /side is not a threads setting/); + const bad = validateThreads({ + list: [ + { id: "a b", label: "x" }, + { id: "dup", label: "x" }, + { id: "dup", label: "y".repeat(THREADS_LIMITS.label + 1) }, + { id: "o", label: "z", outcome: { verdict: "MAYBE", label: "two\nlines" } }, + ], + }).join(";"); + assert.match(bad, /list\[0\]\.id must be/); + assert.match(bad, /dup is listed twice/); + assert.match(bad, /list\[2\]\.label must be one line/); + assert.match(bad, /outcome\.verdict must be one of/); + assert.match(bad, /outcome\.label must be one line/); + // validateChrome reads it, and refuses the feed beside it. + assert.deepEqual(validateChrome(CHROME, RENDER), []); + assert.match( + validateChrome({ ...CHROME, deck: { posts: { layout: "feed" } } }, RENDER).join(";"), + /threads needs the popup posts/, + ); + assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.95 } }, RENDER).join(";"), /rail \d+px wide/); +}); + +test("entries: a listed thread, not on a teaser, and every thread used", () => { + const timeline = [ + { id: "a", type: "clip", thread: "bet" }, + { id: "b", type: "clip", thread: "proof" }, + { id: "c", type: "clip", thread: "aside" }, + ]; + assert.deepEqual(validateThreadEntries({ render: RENDER, timeline }), []); + const errs = validateThreadEntries({ + render: RENDER, + timeline: [{ id: "a", type: "clip", thread: "nope" }, { id: "t", type: "teaser", thread: "bet" }], + }).join(";"); + assert.match(errs, /nope is not in render\.chrome\.threads\.list/); + assert.match(errs, /a teaser belongs to no thread/); + assert.match(errs, /thread proof has no clip/); + assert.equal(threadOf({ thread: "bet" }), "bet"); + assert.equal(threadOf({}), null); +}); + +test("the footage moves to the right edge and the rail takes the left", () => { + const plain = deckGeometry(PLAIN).footage; + const g = deckGeometry(RENDER).footage; + assert.equal(g.width, plain.width); + assert.equal(g.x, 1920 - g.width - railGap(RENDER)); + const rail = threadsGeometry(RENDER); + assert.deepEqual([rail.x, rail.y, rail.width, rail.height], [0, g.y, g.x, g.height]); + assert.equal(rail.cards.x, railGap(RENDER)); + assert.equal(rail.cards.x + rail.cards.width + railGap(RENDER), g.x); + const { cards: _c, ...box } = rail; + assert.deepEqual(threadsRegion(RENDER, "/f"), { name: "threads", frames: "/f", ...box }); +}); + +const SEGS = [ + { id: "s0", start: 0, duration: 10, thread: "bet" }, + { id: "s1", start: 9.5, duration: 10, thread: "bet" }, + { id: "s2", start: 19, duration: 10, thread: null }, + { id: "s3", start: 28.5, duration: 10, thread: "proof" }, + { id: "s4", start: 38, duration: 10, thread: "bet" }, + { id: "s5", start: 47.5, duration: 6, thread: "aside" }, +]; + +test("the schedule: dots, runs, outcomes on the last clip, asides for posts", () => { + const moves = [{ segment: "s3", at: 30, segmentAt: 1.5, seconds: 0.6 }]; + const s = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves }); + const bet = s.threads[0]; + assert.deepEqual(bet.clips.map((c) => [c.segment, c.at]), [["s0", 0], ["s1", 9.75], ["s4", 38.25]]); + // Stamped OUTCOME_LEAD (and the dissolve) before its last clip hands over. + assert.equal(bet.closeAt, 47.5 - 0.5 - OUTCOME_LEAD); + // A thread without an outcome is never closed. + assert.equal(s.threads[2].closeAt, undefined); + assert.deepEqual(s.runs, [ + { thread: "bet", from: 0, to: 19 }, + { thread: "proof", from: 28.5, to: 38 }, + { thread: "bet", from: 38, to: 47.5 }, + { thread: "aside", from: 47.5, to: 53.5 }, + ]); + assert.deepEqual(s.asides, [{ from: 30, to: 38 }]); + // A clip too short for the lead is stamped half a second in, never before. + const short = threadSchedule({ + segments: [{ id: "x", start: 0, duration: 1.2, thread: "proof" }], D: 0.5, total: 1.2, render: RENDER, + }); + assert.equal(short.threads[1].closeAt, 0.5); +}); + +test("the cues state every from, light and dim each run, and stamp each outcome once", () => { + const sched = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s3", at: 30 }] }); + const { init, cues } = threadCues(sched); + 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`); + } + // Its thread opens the cut, so its card comes up lit; it lights again for its second run. + const lit = cues.filter((c) => c.k === "c0" && c.to.opacity === 1).map((c) => c.at); + assert.equal(lit[0], 0); + assert.equal(lit[lit.length - 1], 38); + assert.equal(cues.find((c) => c.k === "c1").to.opacity, CARD_REST.ahead); + const rests = cues.filter((c) => c.k === "c0" && c.to.opacity === CARD_REST.done); + assert.equal(rests.length, 2); + assert.equal(cues.filter((c) => c.k === "o0" && c.to.autoAlpha === 1).length, 1); + assert.equal(init.o2, undefined, "no outcome, no stamp"); + assert.deepEqual(cues.filter((c) => c.k === "rail").map((c) => c.to.autoAlpha), [0, 1]); + // One element's cues never overlap. + const byK = {}; + for (const c of cues) { + if (byK[c.k] !== undefined) assert.ok(c.at >= byK[c.k] - 1e-9, `${c.k} overlaps at ${c.at}`); + byK[c.k] = c.at + c.dur; + } +}); + +test("planCues clamps a cue behind its element's last and carries the state", () => { + const cues = planCues({ a: { x: 0 } }, [ + { k: "a", at: 0, dur: 2, to: { x: 1 }, ease: "none" }, + { k: "a", at: 1, dur: 2, to: { x: 2 }, ease: "none" }, + ]); + assert.deepEqual(cues.map((c) => [c.at, c.dur, c.from.x, c.to.x]), [[0, 2, 0, 1], [2, 1, 1, 2]]); +}); + +test("the page: a card per thread, a dot per clip, an outcome where one is set", () => { + const segments = SEGS.map(({ thread, ...s }) => s); + const entries = SEGS.map((s) => ({ id: s.id, type: "clip", ...(s.thread ? { thread: s.thread } : {}) })); + const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: RENDER }); + assert.ok(schedule.threads, "the deck's schedule carries the rail"); + assert.equal(segments.length, schedule.segments.length); + const html = threadsHtml(schedule, RENDER, { fonts: { regular: "assets/r.ttf", bold: "assets/b.ttf" } }); + assert.equal((html.match(/class="card"/g) ?? []).length, 3); + assert.equal((html.match(/class="dot"/g) ?? []).length, 5); + assert.equal((html.match(/class="outcome"/g) ?? []).length, 2); + assert.match(html, /data-composition-id="threads"/); + assert.match(html, />Still coming</); + assert.throws(() => threadsHtml({ ...schedule, threads: undefined }, RENDER), /no thread rail/); + // No rail, no `threads` in the schedule: a cut without one writes what it always did. + const plain = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: PLAIN }); + assert.equal("threads" in plain, false); +}); + +test("railLayout: cards share the height, capped, the stack centred", () => { + assert.deepEqual(railLayout(886, 5), { h: 166, gap: 14, top: 0 }); + const three = railLayout(886, 3); + assert.equal(three.h, 190); + assert.equal(three.top, Math.floor((886 - 3 * 190 - 2 * 14) / 2)); +});