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:
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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+
+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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+
+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));
+});