commit c83389e06967a52149a336d97bd784f72c32bd87
parent 3784a11633f0483716c15b9b5d92bc2d64d9ca25
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:44:31 -0400
report-to-video: a flips panel -- THEN and NOW, back to back
render.chrome.flips: pairs of a THEN entry and a NOW entry, each with its when and a few of its words. A panel down the frame's left (the rail's region; a cut has one or the other) holds the pair while it plays: the topic and its place, the THEN card lit, the NOW card slamming in under it with a flash as the second clip starts, the years between rolling up on an odometer; the pair lifts away as it ends. The build checks the pairs against the timeline (entries exist, THEN first, one pair each).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 542 insertions(+), 12 deletions(-)
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -114,6 +114,7 @@ import {
} from "./deck.mjs";
import { validateClaims } from "./factcheck.mjs";
import { validateThreadEntries } from "./threads.mjs";
+import { validateFlipEntries } from "./flips.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -2299,7 +2300,7 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
// The posts feed (`name: "feed"`) and the fact-check stamps (`name:
// "stamp"`) are whole-cut sequences like the deck's, laid exactly as the
// deck's is.
- const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads";
+ const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads" || r.name === "flips";
const posts = r.name === "posts";
inputs.push(
...(deck || posts ? ["-reinit_filter", "0"] : []),
@@ -2365,9 +2366,9 @@ export function feedRegion(render, frames) {
}
/** The thread rail as an overlay region: its frames at threadsGeometry's box. */
-export function threadsRegion(render, frames) {
+export function threadsRegion(render, frames, name = "threads") {
const { cards: _c, ...box } = threadsGeometry(render);
- return { name: "threads", frames, ...box };
+ return { name, frames, ...box };
}
/** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */
@@ -3612,6 +3613,26 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
});
regions.push(threadsRegion(render, th.frames));
}
+
+ // The flips panel (a schedule with `flips`): the same region as the rail.
+ if (schedule.flips?.pairs?.length) {
+ EMIT("chrome", { phase: "compose", region: "flips", ...(duration != null ? { from, duration } : {}) });
+ const t1 = Date.now();
+ const fl = await composeChrome({
+ manifestPath, outDir, variant, region: "flips", doRender: true,
+ fps: render.fps, workers: 2, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ if (fl.frameCount !== want) {
+ throw new Error(`the flips panel's sequence is ${fl.frameCount} frames but the deck's is ${want}`);
+ }
+ EMIT("chrome", {
+ phase: fl.cached ? "cached" : "render", region: "flips",
+ frames: fl.frameCount, key: fl.key, dir: fl.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ regions.push(threadsRegion(render, fl.frames, "flips"));
+ }
return { regions, outLabel: "[hfout]" };
}
@@ -3912,6 +3933,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
{
const errors = [
...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole), ...validateThreadEntries(whole),
+ ...validateFlipEntries(whole),
];
if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`);
}
diff --git a/umtool/report-to-video/chrome-flips.mjs b/umtool/report-to-video/chrome-flips.mjs
@@ -0,0 +1,190 @@
+// The FLIPS panel's composition: one HyperFrames page for a whole cut, the
+// frame's left side beside the footage (deck.mjs threadsGeometry -- the region
+// the thread rail would take), one pair on it at a time (flips.mjs): its
+// topic, the THEN card, the years between, the NOW card slamming in.
+//
+// PURE, like chrome-threads.mjs: a schedule and a render block in, an HTML
+// string out; compose-chrome.mjs renders it (`region: "flips"`).
+import { pageDuration, threadsGeometry } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+import { flipCues } from "./flips.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .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/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -48,6 +48,7 @@ import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
import { feedHtml } from "./chrome-feed.mjs";
import { stampHtml } from "./chrome-stamp.mjs";
import { threadsHtml } from "./chrome-threads.mjs";
+import { flipsHtml } from "./chrome-flips.mjs";
const run = promisify(execFile);
@@ -698,6 +699,11 @@ async function regionHtml(region, { manifest, manifestDir, base, projDir, assets
const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
return threadsHtml(schedule, manifest.render, { fonts, from, duration });
}
+ if (region === "flips") {
+ // The flips panel (chrome-flips.mjs): the deck's faces, no QR.
+ const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ return flipsHtml(schedule, manifest.render, { fonts, from, duration });
+ }
if (region === "teaser") {
// A page module reached by a dynamic import, so nothing that imports this
// file -- umtool's preview helper, the build -- loads its face's URL
@@ -820,9 +826,9 @@ export async function composeChrome({
// The regions keyed by the render cache: the two drawn from the deck's
// schedule, and a teaser, drawn from its own timeline entry.
const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser" || region === "stamp" ||
- region === "threads";
+ region === "threads" || region === "flips";
// The regions drawn over the whole cut from its schedule, windowable alike.
- const wholeCut = region === "deck" || region === "feed" || region === "stamp" || region === "threads";
+ const wholeCut = region === "deck" || region === "feed" || region === "stamp" || region === "threads" || region === "flips";
let teaser = null;
if (region === "teaser") {
teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null;
@@ -843,6 +849,9 @@ export async function composeChrome({
if (region === "feed" && sched.layout !== "feed") {
throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)");
}
+ if (region === "flips" && !sched.flips?.pairs?.length) {
+ throw new Error("the flips region needs a schedule with flip pairs (render.chrome.flips)");
+ }
if (region === "threads" && !sched.threads?.threads?.length) {
throw new Error("the threads region needs a schedule with a thread rail (render.chrome.threads)");
}
@@ -974,7 +983,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1]));
if (!manifestPath) {
console.error(
- "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|threads|posts|teaser] [--variant sourced|full]\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|threads|flips|posts|teaser] [--variant sourced|full]\n" +
" [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" +
" [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
" [--still <s> --png <path>]\n" +
@@ -997,7 +1006,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
still: num("--still"),
png: flag("--png"),
// The deck renders four-wide by default; the band keeps the renderer's own default.
- workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" || region === "threads" ? 2 : null),
+ workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" || region === "threads" || region === "flips" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -21,6 +21,10 @@ import {
claimOf, originalUrlAt, resolveFactcheck, roundStamps, stampSchedule, validateFactcheck,
} from "./factcheck.mjs";
import { threadOf, threadSchedule, threadsOn, validateThreads } from "./threads.mjs";
+import { flipSchedule, flipsOn, validateFlips } from "./flips.mjs";
+
+/** Is the left panel on: the thread rail (threads.mjs) or the flips panel (flips.mjs) -- one region, one or the other. */
+export const railOn = (render) => threadsOn(render) || flipsOn(render);
/** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */
export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24";
@@ -133,7 +137,7 @@ export function validateChrome(chrome, render = {}) {
const errors = [];
if (chrome === undefined || chrome === null) return errors;
if (!isObj(chrome)) return ["render.chrome must be an object"];
- unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads"], "render.chrome", errors);
+ unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads", "flips"], "render.chrome", errors);
if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"');
if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"');
if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set");
@@ -147,6 +151,9 @@ export function validateChrome(chrome, render = {}) {
errors.push(...validateFactcheck(chrome.factcheck));
// The thread rail (threads.mjs): the deck's layout only, beside the footage.
errors.push(...validateThreads(chrome.threads));
+ // The flips panel (flips.mjs): the same region as the rail, so not both.
+ errors.push(...validateFlips(chrome.flips));
+ if (threadsOn({ chrome }) && flipsOn({ chrome })) errors.push("render.chrome.threads and render.chrome.flips share the left panel: set one");
const d = chrome.deck ?? {};
if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"];
const w = "render.chrome.deck";
@@ -269,11 +276,12 @@ export function validateChrome(chrome, render = {}) {
// sees the posts.
if (d.posts !== undefined && resolveDeck({ chrome }).posts.show) errors.push(...postsFitErrors({ ...render, chrome }));
const room = g.H - g.deck.height;
- if (threadsOn({ chrome })) {
- if (resolveDeck({ chrome }).posts.layout === "feed") errors.push("render.chrome.threads needs the popup posts, not the feed");
+ if (railOn({ chrome })) {
+ const which = threadsOn({ chrome }) ? "render.chrome.threads" : "render.chrome.flips";
+ if (resolveDeck({ chrome }).posts.layout === "feed") errors.push(`${which} needs the popup posts, not the feed`);
const rail = threadsGeometry({ ...render, chrome });
if (rail.cards.width < 220) {
- errors.push(`render.chrome.threads leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`);
+ errors.push(`${which} leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`);
}
}
if (g.footage.height > room) {
@@ -346,7 +354,7 @@ export function deckGeometry(render) {
const dh = deck.height;
const fw = even(W * deck.footageScale);
const fh = even((fw * H) / W);
- const fx = threadsOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2);
+ const fx = railOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2);
return {
W, H,
footage: { x: fx, y: Math.floor((H - dh - fh) / 2), width: fw, height: fh },
@@ -642,6 +650,7 @@ export function deckSchedule({
const threads = threadsOn(render)
? threadSchedule({ segments: segs.map((s, i) => ({ ...s, thread: threadOf(entries[i]) })), D, total, render, moves })
: null;
+ const flips = flipsOn(render) ? flipSchedule({ segments: segs, total, render, moves }) : null;
return {
version: 1,
kind: "deck",
@@ -675,6 +684,8 @@ export function deckSchedule({
...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}),
// The thread rail: present only when it is on.
...(threads ? { threads } : {}),
+ // The flips panel: present only when it is on.
+ ...(flips ? { flips } : {}),
// Present only when there are posts to draw, so a cut without them writes
// the schedule it always did.
...(placed.length ? { posts: roundPosts(placed) } : {}),
diff --git a/umtool/report-to-video/flips.mjs b/umtool/report-to-video/flips.mjs
@@ -0,0 +1,188 @@
+// FLIPS: a cut built of pairs -- a line from THEN and the opposite line from
+// NOW, back to back -- with a panel down the frame's left that holds the pair
+// while it plays: the topic, the THEN card (when, and the words) lit as the
+// first clip plays, then the NOW card slamming in under it as the second
+// starts, the years between them counting up. When the pair ends it lifts
+// away and the next one comes in. Nothing is said for her: the panel's words
+// are hers, verbatim, a few of them.
+//
+// render.chrome: "flips": { "pairs": [ { "id": "vax", "topic": "Vaccines",
+// "then": { "entry": "f-vax-then", "when": "2019", "words": "…" },
+// "now": { "entry": "f-vax-now", "when": "2025", "words": "…" } } ] }
+//
+// With flips on, the footage box moves to the frame's right edge and the panel
+// takes the left, as the thread rail's does (deck.mjs deckGeometry,
+// threadsGeometry); the two are one region and a cut has one or the other.
+//
+// PURE, like threads.mjs. chrome-flips.mjs draws the panel.
+import { planCues } from "./threads.mjs";
+
+/** The limits: pairs, a topic's, a `when`'s and a side's words' characters. */
+export const FLIPS_LIMITS = Object.freeze({ pairs: Object.freeze([1, 24]), topic: 32, when: 18, words: 110 });
+
+/**
+ * The panel's motion, in seconds: a pair rises in over `enter`, its NOW card
+ * slams over `slam` (from `slamScale`) with a flash, the gap counts over
+ * `count`, the THEN card dims over `dim`; a pair lifts away over `leave`.
+ */
+export const FLIP_MOTION = Object.freeze({
+ enter: 0.35, slam: 0.22, slamScale: 1.35, flashUp: 0.05, flashDown: 0.5, count: 0.6, dim: 0.3, leave: 0.3, aside: 0.4,
+});
+
+/** How bright the THEN card rests once NOW has landed. */
+export const THEN_DIM = 0.5;
+
+const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/;
+const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
+const oneLine = (v, max) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v) && v.length <= max;
+
+/** Is the flips panel on: a `render.chrome.flips` with at least one pair. */
+export function flipsOn(render) {
+ const f = render?.chrome?.flips;
+ return isObj(f) && Array.isArray(f.pairs) && f.pairs.length > 0;
+}
+
+/**
+ * Every reason `render.chrome.flips` cannot be built, as sentences. Shape
+ * only; `validateFlipEntries` checks the pairs against the timeline.
+ *
+ * @returns {string[]}
+ */
+export function validateFlips(f, where = "render.chrome.flips") {
+ if (f === undefined) return [];
+ if (!isObj(f)) return [`${where} must be an object`];
+ const errors = [];
+ for (const k of Object.keys(f)) if (k !== "pairs") errors.push(`${where}.${k} is not a flips setting`);
+ const [lo, hi] = FLIPS_LIMITS.pairs;
+ if (!Array.isArray(f.pairs) || f.pairs.length < lo || f.pairs.length > hi) return [...errors, `${where}.pairs must be ${lo} to ${hi} pairs`];
+ const ids = new Set();
+ f.pairs.forEach((p, i) => {
+ const w = `${where}.pairs[${i}]`;
+ if (!isObj(p)) { errors.push(`${w} must be an object`); return; }
+ for (const k of Object.keys(p)) if (!["id", "topic", "then", "now"].includes(k)) errors.push(`${w}.${k} is not a pair key`);
+ if (typeof p.id !== "string" || !ID_RE.test(p.id)) errors.push(`${w}.id must be letters, digits, _ or -`);
+ else if (ids.has(p.id)) errors.push(`${w}.id ${p.id} is listed twice`);
+ else ids.add(p.id);
+ if (!oneLine(p.topic, FLIPS_LIMITS.topic)) errors.push(`${w}.topic must be one line of at most ${FLIPS_LIMITS.topic} characters`);
+ for (const side of ["then", "now"]) {
+ const s = p[side];
+ const ws = `${w}.${side}`;
+ if (!isObj(s)) { errors.push(`${ws} must be an object`); continue; }
+ for (const k of Object.keys(s)) if (!["entry", "when", "words"].includes(k)) errors.push(`${ws}.${k} is not a side key`);
+ if (typeof s.entry !== "string" || !s.entry) errors.push(`${ws}.entry must name a timeline entry`);
+ if (!oneLine(s.when, FLIPS_LIMITS.when)) errors.push(`${ws}.when must be one line of at most ${FLIPS_LIMITS.when} characters`);
+ if (!oneLine(s.words, FLIPS_LIMITS.words)) errors.push(`${ws}.words must be one line of at most ${FLIPS_LIMITS.words} characters`);
+ }
+ });
+ return errors;
+}
+
+/**
+ * The pairs against the timeline: each side names an entry of the cut, a clip
+ * (not a teaser), the THEN side before the NOW side, and no entry in two pairs.
+ *
+ * @returns {string[]}
+ */
+export function validateFlipEntries(manifest) {
+ const render = manifest?.render ?? {};
+ if (!flipsOn(render)) return [];
+ const errors = [];
+ const at = new Map((manifest.timeline ?? []).map((e, i) => [e?.id, { e, i }]));
+ const used = new Map();
+ render.chrome.flips.pairs.forEach((p, i) => {
+ const w = `render.chrome.flips.pairs[${i}] (${p?.id ?? "?"})`;
+ const idx = {};
+ for (const side of ["then", "now"]) {
+ const id = p?.[side]?.entry;
+ const hit = at.get(id);
+ if (!hit) { errors.push(`${w}.${side}.entry ${id} is not in the timeline`); continue; }
+ if (hit.e.type === "teaser") errors.push(`${w}.${side}.entry ${id} is a teaser, not a clip`);
+ if (used.has(id)) errors.push(`${w}.${side}.entry ${id} is already in pair ${used.get(id)}`);
+ used.set(id, p.id);
+ idx[side] = hit.i;
+ }
+ if (idx.then !== undefined && idx.now !== undefined && idx.then >= idx.now) errors.push(`${w}: its THEN entry must come before its NOW entry`);
+ });
+ return errors;
+}
+
+/** The years between two `when`s, when both carry one (`2019`, `May 2019`): else null. */
+export function yearsBetween(a, b) {
+ const y = (s) => {
+ const m = /(19|20)\d\d/.exec(String(s ?? ""));
+ return m ? Number(m[0]) : null;
+ };
+ const ya = y(a), yb = y(b);
+ return ya != null && yb != null && yb > ya ? yb - ya : null;
+}
+
+/**
+ * The panel's schedule, in the cut's clock: each pair with its two sides'
+ * spans (`from`: the clip's start; `to`: the next clip's start, or the cut's
+ * end) and the years between; `asides` as the thread rail's (a popup post's
+ * move to the end of its clip).
+ *
+ * @returns {{ pairs: Array<{ id: string, topic: string, index: number, of: number, years: number|null,
+ * then: { segment: string, from: number, to: number, when: string, words: string },
+ * now: { segment: string, from: number, to: number, when: string, words: string } }>,
+ * asides: Array<{ from: number, to: number }> }}
+ */
+export function flipSchedule({ segments, total, render, moves = [] }) {
+ const R = (v) => Math.round(v * 1000) / 1000;
+ const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total);
+ const idx = new Map(segments.map((s, i) => [s.id, i]));
+ const list = flipsOn(render) ? render.chrome.flips.pairs : [];
+ const side = (s) => {
+ const i = idx.get(s.entry);
+ return i === undefined ? null : { segment: s.entry, from: R(segments[i].start), to: R(endOf(i)), when: s.when, words: s.words };
+ };
+ const pairs = list
+ .map((p) => ({ id: p.id, topic: p.topic, then: side(p.then), now: side(p.now), years: yearsBetween(p.then.when, p.now.when) }))
+ .filter((p) => p.then && p.now)
+ .sort((a, b) => a.then.from - b.then.from)
+ .map((p, i, all) => ({ ...p, index: i + 1, of: all.length }));
+ const asides = moves.map((m) => {
+ const i = idx.get(m.segment);
+ return { from: R(m.at), to: R(i === undefined ? total : endOf(i)) };
+ });
+ return { pairs, asides };
+}
+
+/**
+ * Everything the panel's timeline does, as data. Pair j is `p<j>` (autoAlpha,
+ * y); its THEN card `t<j>` (opacity), NOW card `n<j>` (autoAlpha, scale, x)
+ * with its flash `nf<j>`, the gap `g<j>` (autoAlpha) and its count `gc<j>`
+ * (an odometer strip of 0..years, rolled by yPercent -- a transform, so a
+ * seek from anywhere lands on the same digit); the whole panel is `panel`.
+ *
+ * @returns {{ init: Record<string, object>, cues: Array<object> }}
+ */
+export function flipCues(sched) {
+ const m = FLIP_MOTION;
+ const init = { panel: { autoAlpha: 1, x: 0 } };
+ const ev = [];
+ const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why });
+ sched.pairs.forEach((p, j) => {
+ init[`p${j}`] = { autoAlpha: 0, y: 60 };
+ init[`t${j}`] = { opacity: 1 };
+ init[`n${j}`] = { autoAlpha: 0, scale: m.slamScale, x: 40 };
+ init[`nf${j}`] = { opacity: 0 };
+ init[`g${j}`] = { autoAlpha: 0 };
+ init[`gc${j}`] = { yPercent: 0 };
+ add(`p${j}`, p.then.from, m.enter, { autoAlpha: 1, y: 0 }, "power3.out", `${p.id} in`);
+ add(`n${j}`, p.now.from, m.slam, { autoAlpha: 1, scale: 1, x: 0 }, "power4.in", `${p.id} now`);
+ add(`nf${j}`, p.now.from + m.slam, m.flashUp, { opacity: 1 }, "none", `${p.id} flash`);
+ add(`nf${j}`, p.now.from + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `${p.id} flash`);
+ add(`t${j}`, p.now.from, m.dim, { opacity: THEN_DIM }, "power2.out", `${p.id} then dims`);
+ if (p.years != null) {
+ add(`g${j}`, p.now.from, m.slam, { autoAlpha: 1 }, "power2.out", `${p.id} gap`);
+ add(`gc${j}`, p.now.from + 0.05, m.count, { yPercent: -100 * (p.years / (p.years + 1)) }, "power2.out", `${p.id} count`);
+ }
+ add(`p${j}`, p.now.to - m.leave, m.leave, { autoAlpha: 0, y: -50 }, "power2.in", `${p.id} out`);
+ });
+ for (const a of sched.asides) {
+ add("panel", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post");
+ add("panel", a.to, m.aside, { autoAlpha: 1, x: 0 }, "power2.out", "back after a post");
+ }
+ return { init, cues: planCues(init, ev) };
+}
diff --git a/umtool/report-to-video/flips.test.mjs b/umtool/report-to-video/flips.test.mjs
@@ -0,0 +1,110 @@
+// Tests for the flips panel: its settings and validation (flips.mjs, through
+// validateChrome), the pairs against the timeline, the schedule, the cues and
+// the page (chrome-flips.mjs).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { flipCues, flipSchedule, flipsOn, FLIPS_LIMITS, THEN_DIM, validateFlipEntries, validateFlips, yearsBetween } from "./flips.mjs";
+import { deckGeometry, deckSchedule, railGap, validateChrome } from "./deck.mjs";
+import { flipsHtml } from "./chrome-flips.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#e5534b", amber: "#ffc860" };
+const PAIRS = [
+ { id: "vax", topic: "Vaccines", then: { entry: "a", when: "2019", words: "I trust doctors" }, now: { entry: "b", when: "2025", words: "Never again" } },
+ { id: "dw", topic: "The Daily Wire", then: { entry: "c", when: "May 2021", words: "Best place" }, now: { entry: "d", when: "Ep 300", words: "Worst place" } },
+];
+const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, flips: { pairs: PAIRS } };
+const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.12, palette: PALETTE, chrome: CHROME };
+const TIMELINE = ["a", "b", "c", "d"].map((id) => ({ id, type: "clip" }));
+
+test("validation: the pairs' shape, and not beside the thread rail", () => {
+ assert.equal(flipsOn(RENDER), true);
+ assert.deepEqual(validateFlips({ pairs: PAIRS }), []);
+ assert.deepEqual(validateChrome(CHROME, RENDER), []);
+ assert.match(validateFlips({ pairs: [] }).join(";"), /1 to 24 pairs/);
+ const bad = validateFlips({
+ pairs: [
+ { id: "x", topic: "t", then: { entry: "a", when: "2019", words: "w".repeat(FLIPS_LIMITS.words + 1) }, now: { entry: "b", when: "x", words: "y" }, extra: 1 },
+ { id: "x", topic: "", then: null, now: { entry: "", when: "", words: "y" } },
+ ],
+ }).join(";");
+ assert.match(bad, /pairs\[0\]\.extra is not a pair key/);
+ assert.match(bad, /then\.words must be one line/);
+ assert.match(bad, /x is listed twice/);
+ assert.match(bad, /pairs\[1\]\.topic must be/);
+ assert.match(bad, /pairs\[1\]\.then must be an object/);
+ assert.match(bad, /now\.entry must name a timeline entry/);
+ const both = { ...CHROME, threads: { list: [{ id: "t", label: "T" }] } };
+ assert.match(validateChrome(both, RENDER).join(";"), /share the left panel/);
+});
+
+test("the pairs against the timeline: entries exist, THEN first, one pair each", () => {
+ assert.deepEqual(validateFlipEntries({ render: RENDER, timeline: TIMELINE }), []);
+ const errs = validateFlipEntries({ render: RENDER, timeline: [{ id: "b", type: "clip" }, { id: "a", type: "teaser" }] }).join(";");
+ assert.match(errs, /vax\)?: its THEN entry must come before its NOW entry/);
+ assert.match(errs, /then\.entry a is a teaser/);
+ assert.match(errs, /then\.entry c is not in the timeline/);
+ const twice = { ...RENDER, chrome: { ...CHROME, flips: { pairs: [PAIRS[0], { ...PAIRS[1], then: { ...PAIRS[1].then, entry: "b" } }] } } };
+ assert.match(validateFlipEntries({ render: twice, timeline: TIMELINE }).join(";"), /b is already in pair vax/);
+});
+
+test("the footage moves right for the panel, as for the rail", () => {
+ const g = deckGeometry(RENDER).footage;
+ assert.equal(g.x, 1920 - g.width - railGap(RENDER));
+});
+
+test("years between two whens, when both carry a year", () => {
+ assert.equal(yearsBetween("2019", "2025"), 6);
+ assert.equal(yearsBetween("May 2021", "October 2026"), 5);
+ assert.equal(yearsBetween("Ep 12", "2025"), null);
+ assert.equal(yearsBetween("2025", "2019"), null);
+});
+
+const SEGS = [
+ { id: "a", start: 0, duration: 6 },
+ { id: "b", start: 5.88, duration: 5 },
+ { id: "c", start: 10.76, duration: 7 },
+ { id: "d", start: 17.64, duration: 4 },
+];
+
+test("the schedule: each pair's spans, its years, its place", () => {
+ const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER, moves: [{ segment: "c", at: 11 }] });
+ assert.deepEqual(s.pairs.map((p) => [p.id, p.index, p.of, p.years]), [["vax", 1, 2, 6], ["dw", 2, 2, null]]);
+ assert.deepEqual(s.pairs[0].then, { segment: "a", from: 0, to: 5.88, when: "2019", words: "I trust doctors" });
+ assert.deepEqual([s.pairs[1].now.from, s.pairs[1].now.to], [17.64, 21.64]);
+ assert.deepEqual(s.asides, [{ from: 11, to: 17.64 }]);
+});
+
+test("the cues: a pair rises with THEN, NOW slams and THEN dims, the count rolls to its year, the pair leaves", () => {
+ const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER });
+ const { init, cues } = flipCues(s);
+ for (const c of cues) for (const k of Object.keys(c.to)) assert.notEqual(c.from[k], undefined, `${c.k} ${k} has a from`);
+ assert.equal(cues.find((c) => c.k === "p0").at, 0);
+ const slam = cues.find((c) => c.k === "n0");
+ assert.equal(slam.at, 5.88);
+ assert.deepEqual(slam.to, { autoAlpha: 1, scale: 1, x: 0 });
+ assert.equal(cues.find((c) => c.k === "t0").to.opacity, THEN_DIM);
+ const count = cues.find((c) => c.k === "gc0");
+ assert.ok(Math.abs(count.to.yPercent - -100 * (6 / 7)) < 1e-9);
+ assert.equal(cues.find((c) => c.k === "gc1"), undefined, "no years, no count");
+ assert.deepEqual(init.gc0, { yPercent: 0 });
+ const leaves = cues.filter((c) => c.k === "p0" && c.to.autoAlpha === 0);
+ assert.equal(leaves.length, 1);
+ assert.ok(leaves[0].at + leaves[0].dur <= 10.76 + 1e-9, "gone by the next pair");
+});
+
+test("the page: one pair block per pair, both sides' words, an odometer of the years", () => {
+ const entries = SEGS.map((s) => ({ id: s.id, type: "clip" }));
+ const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.12, render: RENDER });
+ assert.ok(schedule.flips, "the deck's schedule carries the pairs");
+ const html = flipsHtml(schedule, RENDER, { fonts: { regular: "r.ttf", bold: "b.ttf" } });
+ assert.equal((html.match(/class="pair"/g) ?? []).length, 2);
+ assert.match(html, /“I trust doctors”/);
+ assert.match(html, /“Never again”/);
+ assert.equal((html.match(/class="digit"/g) ?? []).length, 7);
+ assert.match(html, /years later/);
+ assert.match(html, /01 \/ 02/);
+ assert.throws(() => flipsHtml({ ...schedule, flips: undefined }, RENDER), /no pairs/);
+});