#!/usr/bin/env node // render-cards.mjs — turn a video manifest's `card` entries into PNG stills. // // One PNG per card, written to /cards/.png at the manifest's render // resolution. Text is laid out by ImageMagick's Pango delegate rather than // ffmpeg's drawtext: Pango wraps, kerns and takes inline markup, so a card is a // single markup string instead of a stack of hand-positioned drawtext filters. // // Card styles (manifest `style` field): // title — the opening card: big heading, subtitle, provenance footer // chapter — an act break: small amber kicker over a large heading // bullets — heading plus a list of caveats // sources — closing attribution // // `status` is RETIRED. It existed to quote a claim whose source had gone, and // the `ledger` entry type does that better: it says the same words, alongside // the arithmetic the claim moves, and it says WHY there is no footage from a // probe rather than from a hand-written kicker that nothing re-checks. // // In the app: not used. On the CLI: // node umtool/report-to-video/render-cards.mjs [--out ] // // Options: // --out Output root (default: the manifest's directory + /out) // --only Render just one card, by manifest id // // Requires: ImageMagick built with Pango (magick -list format | grep PANGO). import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdir, writeFile, readFile } from "node:fs/promises"; import path from "node:path"; import { dateKey, ledgerTotals, rosterLine } from "./ledger-totals.mjs"; // A manifest with `render.brand` draws its title and end cards through the // preset and sets the other card styles in the preset's faces. Without one, // neither module changes a byte of what this file draws -- see brand.mjs. import { brandFaces, brandManifest, brandSvgFace, childOpts } from "./brand.mjs"; import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs"; import { FIRA_SANS, textWidth } from "./svg-faces.mjs"; import { deckOn, resolveDeck } from "./deck.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; const execFileP = promisify(execFile); const RSVG = process.env.RSVG_BIN ?? "rsvg-convert"; const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode"; // Every card is drawn at the manifest's full `width`, but once a rail column is // configured the RIGHT `rail.width` pixels of the frame belong to it. Text, // rules and timeline nodes therefore lay out inside the CONTENT width, while the // canvas stays full-width — the card ground is flat `pal.bg`, which is exactly // what the rail wants behind it. export const railWidth = (render) => render.rail?.width ?? 0; export const contentWidth = (render) => render.width - railWidth(render); /** * How wide a card's content may be. * * `hideRail` cards slide the rail off over their own dissolve, so they get the * WHOLE frame. Everything else lays out inside the content width and leaves the * rail column as ground. */ export const cardWidth = (card, render) => card?.hideRail ? render.width : contentWidth(render); // Pango markup is XML-ish, so anything we interpolate has to be escaped first. // Curly quotes and the ellipsis pass through fine; only these five matter. function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // `face` is a brand preset's Pango font description (brand.mjs FACES), which // replaces the family; it is only ever set for a manifest with `render.brand`. // A description that pins its own weight axis (`@wght=`) is not given a // `weight` on top of it. function span(text, { size, color, weight, family = "Fira Sans", face }) { const attrs = face ? [`font_desc="${face}"`, `size="${Math.round(size * 1024)}"`] : [`font_family="${family}"`, `size="${Math.round(size * 1024)}"`]; if (color) attrs.push(`foreground="${color}"`); if (weight && !(face && face.includes("@wght="))) attrs.push(`weight="${weight}"`); return `${text}`; } // The span options for one role in a brand's faces; {} without a brand, so an // unbranded span is exactly the call it always was. const faceOf = (faces, role) => (faces ? { face: faces[role] } : {}); // Each style returns Pango markup for the whole card body. Blank lines are real // newlines in the markup — Pango honours them, which is how vertical rhythm is // set without positioning each run separately. function markupFor(card, pal, faces = null) { const H = (t, size = 62) => span(esc(t), { size, color: pal.fg, weight: "bold", ...faceOf(faces, "display") }); const KICKER = (t) => span(esc(t.toUpperCase()), { size: 24, color: pal.amber, weight: "bold", ...faceOf(faces, "label") }); const SUB = (t, size = 30) => span(esc(t), { size, color: pal.muted, ...faceOf(faces, "body") }); switch (card.style) { case "title": return [ span(esc(card.heading), { size: 82, color: pal.fg, weight: "bold", ...faceOf(faces, "display") }), "", span(esc(card.sub), { size: 38, color: pal.accent, ...faceOf(faces, "body") }), "", "", SUB(card.foot, 24), ].join("\n"); case "chapter": return [ card.kicker ? KICKER(card.kicker) : null, card.kicker ? "" : null, H(card.heading), card.sub ? "" : null, card.sub ? SUB(card.sub, 32) : null, ] .filter((l) => l !== null) .join("\n"); case "bullets": { const items = (card.bullets ?? []).flatMap((b) => [ `${span("— ", { size: 30, color: pal.accent, weight: "bold", ...faceOf(faces, "body") })}${span( esc(b), { size: 30, color: pal.fg, ...faceOf(faces, "body") }, )}`, "", ]); return [H(card.heading, 52), "", ...items].join("\n"); } case "sources": return [ H(card.heading, 52), "", card.sub ? SUB(card.sub, 32) : null, card.foot ? "" : null, card.foot ? card.foot .split("\n") .map((l) => SUB(l, 24)) .join("\n") : null, ] .filter((l) => l !== null) .join("\n"); default: return H(card.heading ?? card.id); } } // A chapter break that just states a heading is dead air — it stops the video to // say something the next clip is about to say anyway. This draws the whole // project arc instead, with the current step lit and everything before it // filled, so the pause carries information: where we are and how far is left. // // Nodes come from the manifest's top-level `timelineNodes`; the card names its // position with `step` (0-based). async function renderTimelineCard(card, render, nodes, outDir) { const pal = render.palette; const faces = brandFaces(render); const { width, height } = render; const outPath = path.join(outDir, "cards", `${card.id}.png`); const dir = path.join(outDir, "cards"); const VW = cardWidth(card, render); const x0 = 260; const x1 = VW - 260; const axisY = Math.round(height * 0.56); const gap = (x1 - x0) / (nodes.length - 1); const xs = nodes.map((_, i) => Math.round(x0 + i * gap)); const cur = card.step; const args = ["-size", `${width}x${height}`, `xc:${pal.bg}`, "-strokewidth", "4"]; // Track: filled up to the current node, dim beyond it. args.push( "-stroke", pal.muted, "-fill", "none", "-draw", `line ${xs[0]},${axisY} ${xs[xs.length - 1]},${axisY}`, ); if (cur > 0) { args.push("-stroke", pal.accent, "-draw", `line ${xs[0]},${axisY} ${xs[cur]},${axisY}`); } // Nodes: past and present filled, future hollow. The current one is larger and // amber so the eye lands on it without needing a label to say "you are here". nodes.forEach((_, i) => { const r = i === cur ? 19 : 11; const color = i === cur ? pal.amber : i < cur ? pal.accent : pal.bg; args.push( "-stroke", i <= cur ? (i === cur ? pal.amber : pal.accent) : pal.muted, "-fill", color, "-draw", `circle ${xs[i]},${axisY} ${xs[i] + r},${axisY}`, ); }); args.push("-stroke", "none"); // Heading, centred over the whole card. const headMarkup = [ span(esc((card.kicker ?? nodes[cur].label).toUpperCase()), { size: 26, color: pal.amber, weight: "bold", ...faceOf(faces, "label"), }), "", span(esc(card.heading ?? nodes[cur].title), { size: 62, color: pal.fg, weight: "bold", ...faceOf(faces, "display") }), card.sub ? "" : null, card.sub ? span(esc(card.sub), { size: 30, color: pal.muted, ...faceOf(faces, "body") }) : null, ] .filter((l) => l !== null) .join("\n"); // Heading, left-aligned on the same margin the other card styles use. const headPath = path.join(dir, `${card.id}.head.pango`); await writeFile(headPath, headMarkup, "utf8"); args.push( "(", "-size", `${VW - 460}x`, "-background", "none", "-define", `pango:width=${VW - 460}`, `pango:@${headPath}`, ")", "-gravity", "NorthWest", "-geometry", `+${Math.round(VW * 0.09) + 58}+${Math.round(height * 0.19)}`, "-composite", ); // Per-node date labels, centred under their dot. ImageMagick's // `pango:alignment` define does not actually centre the text inside the box, // so measure each rendered label and place it by hand instead of trusting it. for (let i = 0; i < nodes.length; i += 1) { const isCur = i === cur; const labMarkup = span(esc(nodes[i].label), { size: isCur ? 24 : 21, color: isCur ? pal.fg : i < cur ? pal.muted : "#5c5570", weight: isCur ? "bold" : "normal", ...faceOf(faces, "body"), }); const labPath = path.join(dir, `${card.id}.n${i}.pango`); const labPng = path.join(dir, `${card.id}.n${i}.png`); await writeFile(labPath, labMarkup, "utf8"); await execFileP("magick", ["-background", "none", `pango:@${labPath}`, labPng], childOpts(render, undefined)); const { stdout } = await execFileP("magick", ["identify", "-format", "%w", labPng]); const w = Number(stdout.trim()); args.push(labPng, "-geometry", `+${xs[i] - Math.round(w / 2)}+${axisY + 44}`, "-composite"); } args.push(outPath); await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 })); return outPath; } // Footer chrome, drawn once and overlaid on every clip: a track, a dot per // section and its label. The *progress* along it is not baked in here — the fill // bar and the amber marker are drawn by ffmpeg at encode time so they can slide // between sections instead of cutting. See buildClipSegment in build-video.mjs. // // Returns the geometry the encoder needs to place those moving parts. export async function renderFooterAssets(render, nodes, outDir) { const pal = render.palette; const faces = brandFaces(render); const { width } = render; const FH = render.footerHeight ?? 92; const dir = path.join(outDir, "cards"); // A cut whose clips are not a progression through time has nothing for a // timeline to say, and a footer drawn anyway is chrome that has not earned its // place. No nodes (or an explicit zero height) means no footer at all — the // caller letterboxes against the header alone. if (!nodes?.length || FH === 0) { return { footer: null, marker: null, bar: null, trackLen: 0, footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0, }; } // The band itself stays full-frame so it reads as one strip running under the // rail; only the TRACK is pulled in to the content width. const x0 = 200; const x1 = contentWidth(render) - 200; const trackY = 26; const gap = (x1 - x0) / (nodes.length - 1); const xs = nodes.map((_, i) => Math.round(x0 + i * gap)); const footer = path.join(dir, "_footer.png"); const args = [ "-size", `${width}x${FH}`, `xc:${pal.bg}`, "-strokewidth", "3", "-stroke", "#3a3450", "-fill", "none", "-draw", `line ${xs[0]},${trackY} ${xs[xs.length - 1]},${trackY}`, "-stroke", "none", ]; for (const x of xs) { args.push("-fill", "#4a4363", "-draw", `circle ${x},${trackY} ${x + 6},${trackY}`); } // Two lines per node: what happened, then when. Both are measured and placed // by hand — ImageMagick's `pango:alignment` define does not actually centre // text inside its box. for (let i = 0; i < nodes.length; i += 1) { const lines = [ { text: nodes[i].label, size: 18, color: pal.fg, dy: 18 }, { text: nodes[i].date, size: 16, color: pal.muted, dy: 42 }, ]; for (const [k, ln] of lines.entries()) { const pPath = path.join(dir, `_footer.n${i}.l${k}.pango`); const pPng = path.join(dir, `_footer.n${i}.l${k}.png`); await writeFile(pPath, span(esc(ln.text), { size: ln.size, color: ln.color, ...faceOf(faces, "body") }), "utf8"); await execFileP("magick", ["-background", "none", `pango:@${pPath}`, pPng], childOpts(render, undefined)); const { stdout } = await execFileP("magick", ["identify", "-format", "%w", pPng]); args.push( pPng, "-geometry", `+${xs[i] - Math.round(Number(stdout.trim()) / 2)}+${trackY + ln.dy}`, "-composite", ); } } args.push(footer); await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 })); // The fill bar, as a strip to be TRANSLATED under a fixed crop rather than a // drawbox whose width depends on `t`. drawbox has no time variable — its `t` // is the box thickness — so the width expression the encoder used to build // never evaluated and the bar was always full. Left half accent, right half // transparent: sliding the crop window left across it grows the accent run. const trackLen = xs[xs.length - 1] - x0; const bar = path.join(dir, "_bar.png"); await execFileP("magick", [ "-size", `${trackLen * 2}x3`, "xc:none", "-fill", pal.accent, "-stroke", "none", "-draw", `rectangle 0,0 ${trackLen - 1},2`, bar, ]); // The travelling marker. const marker = path.join(dir, "_marker.png"); const r = 11; await execFileP("magick", [ "-size", `${r * 2 + 2}x${r * 2 + 2}`, "xc:none", "-fill", pal.amber, "-stroke", "none", "-draw", `circle ${r + 1},${r + 1} ${r * 2 + 1},${r + 1}`, marker, ]); return { footer, marker, bar, trackLen, footerHeight: FH, trackY, xs, x0, markerRadius: r }; } // =========================================================================== // The claim rail // =========================================================================== // A vertical ledger down the right edge that appends one row per claim as the // video runs, with a live per-company tally beside it. The dates and the numbers // are SPOKEN in the clips and shown only in the header citation line, so a viewer // can hear "nearly ten" three times without ever seeing that the three refer to // three different companies. The rail is what makes that visible. // // Every asset here is a STRIP, not a per-state still: one tall PNG whose window // ffmpeg slides with an animated `crop`. That is the whole trick — swapping // stills can only cut, but a crop can ease, and one input per moving part keeps // the filtergraph small enough that ffmpeg does not deadlock on chained // `-loop 1` inputs. See railFilterChain in build-video.mjs for the ramps. // // Assets are authored as SVG and rasterized with rsvg-convert rather than drawn // with ImageMagick primitives: the strips need right-aligned columns, hairlines // and ~500 individually-placed text runs, and one rsvg call beats four hundred // `magick` invocations. Fonts inside the SVG resolve through fontconfig, so the // family name has to MATCH the Pango cards ("Fira Sans"), not the font FILE that // render.fontRegular points at. // // Under a brand preset the SVG text is set in the preset's body face (IBM Plex // Sans), which rsvg-convert finds among the vendored fonts through the // FONTCONFIG_FILE childOpts gives it. The face travels with its metric -- see // svg-faces.mjs -- because the columns below are fitted against it. /** The face the SVG assets are set in: the brand's, else Fira Sans. */ const svgFaceOf = (render) => brandSvgFace(render) ?? FIRA_SANS; // A tiny SVG text run. Everything is placed absolutely — no flow, no wrapping. function svgText(x, y, text, o = {}) { const a = [ `x="${x}"`, `y="${y}"`, `font-family="${o.family ?? FIRA_SANS.family}"`, `font-size="${o.size ?? 15}"`, `fill="${o.color}"`, ]; if (o.weight) a.push(`font-weight="${o.weight}"`); if (o.anchor) a.push(`text-anchor="${o.anchor}"`); if (o.ls) a.push(`letter-spacing="${o.ls}"`); if (o.opacity != null) a.push(`opacity="${o.opacity}"`); return `${esc(text)}`; } /** svgText in one face: what each asset below draws its text with. */ const svgTextIn = (face) => (x, y, text, o = {}) => svgText(x, y, text, { ...o, family: face.family }); const svgDoc = (w, h, body) => `${body}`; // rsvg-convert is deterministic about output size in a way ImageMagick's RSVG // delegate is not (its -density is ignored for sizing in some builds), so the // pixel dimensions ffmpeg's crop arithmetic depends on are guaranteed here. // // `render` only matters under a brand: childOpts points the child at the // vendored faces. Without one it hands back the options it was given. async function rasterize(svg, svgPath, pngPath, w, h, render = null) { await writeFile(svgPath, svg, "utf8"); await execFileP(RSVG, ["-w", String(w), "-h", String(h), "-o", pngPath, svgPath], childOpts(render, { maxBuffer: 1 << 26, })); return pngPath; } /** * Truncate to a pixel budget, in the face the run is drawn in. An overflowing * row would run under the value column rather than wrap, so the answer has to * be inside the budget, not near it. * * Fira Sans (no brand): its average, ~0.50 em per character, a hair * conservative -- a character count, exactly as it always was. A measured face * (the brand's IBM Plex Sans): the run's own advances, weight and letter-spacing * included; the longest prefix that fits with its ellipsis. See svg-faces.mjs. * * @param {object} [face] FIRA_SANS or a measured face (svg-faces.mjs) * @param {{ weight?: string, ls?: number }} [run] how the run is drawn; only a * measured face reads it */ export function fit(text, size, maxPx, face = FIRA_SANS, run = {}) { const t = String(text ?? ""); if (!face.advances) { const max = Math.max(4, Math.floor(maxPx / (size * face.em))); return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`; } if (textWidth(t, size, face, run) <= maxPx) return t; const room = maxPx - textWidth("…", size, face, run); const chars = [...t]; let used = 0; let end = 0; for (; end < chars.length; end += 1) { const w = textWidth(chars[end], size, face, run); if (used + w > room) break; used += w; } return `${chars.slice(0, end).join("").trimEnd()}…`; } /** * Every pixel measurement the rail needs, derived once so the asset builder and * the filtergraph builder cannot disagree about a single one of them. * * The log window is deliberately sized to a WHOLE number of rows and butted * against the bottom of the rail column: that is what lets the curtain (below) * park exactly one window-height down and end up outside the rail entirely. */ export function railGeometry(render, nClaims) { const rail = render.rail ?? {}; const RW = rail.width ?? 500; const HH = render.headerHeight ?? 56; // reservedFooterHeight, NOT render.footerHeight. The two differ by 100px the // moment the chart band is on (the band takes the footer's ground and 100 // more), and deriving the rail's height from the smaller one ran the column // a hundred pixels past the band's own top edge -- about two rows of the log // window, drawn below the line everything else letterboxes to. const FH = reservedFooterHeight(render); const ROWH = rail.rowHeight ?? 38; const PAD = rail.pad ?? 22; const TALLYROWH = rail.tallyRowHeight ?? 40; const nTracks = (rail.tracks ?? []).length; const RHGT = render.height - HH - FH; // The tally's header belongs to the CHROME, not to the rolling block. Put it // in the block and every roll scrolls a duplicate copy of it up through the // window, which reads as noise rather than as a counter changing. const TALLYHEAD_REL = rail.tallyTop ?? 76; const TALLYTOP_REL = TALLYHEAD_REL + 26; const TALLYH = nTracks * TALLYROWH; // The rolling CELL: the only part of a tally row that ever changes. The // swatch and the company name sit left of it and belong to the chrome, so // that a number moving does not drag its own label up the screen with it. const CELLW = rail.cellWidth ?? 170; const CELLX = RW - PAD - CELLW; // The roster he last enumerated. One line, and the point of it is that it // stands still while the numbers above it move. const ROSTERH = rail.rosterHeight ?? 26; const ROSTERTOP_REL = TALLYTOP_REL + TALLYH + 14; const ROSTERX = PAD + (rail.rosterLabelWidth ?? 62); const ROSTERW = RW - PAD - ROSTERX; // The provenance tile, parked at the foot of the column: one QR per clip, // bordered so it reads as a link rather than as decoration. const QRSIZE = rail.qrSize ?? 132; const TILEW = RW - 2 * PAD; const TILEH = QRSIZE + 22; const TILETOP_REL = RHGT - (rail.qrBottom ?? 12) - TILEH; const LOGTOP_REL = ROSTERTOP_REL + ROSTERH + 34; // The log window is a WHOLE number of rows and stops short of the tile, so // the curtain parks exactly one window-height down and the QR overlay (which // comes after it in the chain) is never painted over. const K = Math.max(1, Math.floor((TILETOP_REL - 12 - LOGTOP_REL) / ROWH)); const LOGH = K * ROWH; return { RW, PAD, ROWH, TALLYROWH, K, LOGH, TALLYH, RHGT, CELLW, CELLX, ROSTERH, ROSTERTOP_REL, ROSTERTOP: HH + ROSTERTOP_REL, ROSTERX, ROSTERW, QRSIZE, TILEW, TILEH, TILETOP_REL, TILETOP: HH + TILETOP_REL, VW: render.width - RW, RX: render.width - RW, RTOP: HH, LOGTOP_REL, LOGTOP: HH + LOGTOP_REL, TALLYTOP_REL, TALLYTOP: HH + TALLYTOP_REL, TALLYHEAD_REL, nClaims, logStripH: Math.max(LOGH, nClaims * ROWH), }; } /** * How much of the frame the chrome band owns at the bottom. * * ONE definition, because three different renderers need it and they were * already disagreeing: the closing chart drew its footnotes into the bottom * 100px and the ledger scroll sized its window to `height - header - 100`, both * of which are wrong the moment the band takes 200. The symptom is a card that * looks finished in isolation and has its last two lines sitting under the * chart in the cut. * * Under the deck a card either has the whole frame (`overCards: "hide"` -- the * deck slides away over it) or leaves the deck's height free at the bottom * (`"show"`). */ export function reservedFooterHeight(render) { if (deckOn(render)) { const deck = resolveDeck(render); return deck.overCards === "show" ? deck.height : 0; } return render.chromeEngine === "hyperframes" ? (render.chart?.height ?? 200) : (render.footerHeight ?? 100); } /** * Every roll each tally cell will perform, as rows of one shared strip. * * --------------------------------------------------------------------------- * Why the strip's LAYOUT carries the direction * --------------------------------------------------------------------------- * The whole block used to slide as one slab: when the coffee company's number * changed, all four rows moved. Text that has not changed must not move, so * each track now gets its own cell and its own y expression. * * A crop window can only walk a strip, and it walks in whichever direction its * y expression takes it. So the DIRECTION of a roll is decided when the rows * are laid out, not when the ramp is written: * * rise rows [old, new] crop walks DOWN the strip, content moves UP * fall rows [new, old] crop walks UP the strip, content moves DOWN * * Between two transitions the crop steps instantly to the next pair's starting * row. That step is invisible because the row it leaves and the row it arrives * at hold IDENTICAL content — which is the reason every pair repeats the value * it starts from rather than sharing a row with its neighbour. * * The delta chip rides along on both rows of the pair, and therefore stays on * screen until the next change. That is deliberate: it reads as "how this * number last moved", and blanking it at the step would make the invisible * reposition visible. * * A repeated identical figure still rolls, upward. He said it again on a new * date, and the `as of` line underneath is what changed. * * @returns {{lanes: Array, rows: number}} one lane per track plus the * roster lane, each with `rows` (the cells to draw) and `steps` (per claim, * `null` or `{a, b}` — the row the roll starts on and the row it ends on). */ export function tallyTracks(ledger, tracks, opts = {}) { const rosterLineOf = opts.rosterLine ?? (() => null); const lanes = tracks.map((tr) => ({ key: tr.key, track: tr, kind: "tally", rows: [{ empty: true, track: tr }], steps: new Array(ledger.length).fill(null), cur: null, })); const byKey = Object.fromEntries(lanes.map((l) => [l.key, l])); const roster = { key: "__roster", kind: "roster", rows: [{ empty: true }], steps: new Array(ledger.length).fill(null), cur: null, }; /** Lay a transition down as a pair of rows and record where it starts/ends. */ const transition = (lane, from, to, rise) => { const p = lane.rows.length; if (rise) { lane.rows.push(from, to); return { a: p, b: p + 1 }; } lane.rows.push(to, from); return { a: p + 1, b: p }; }; ledger.forEach((c, i) => { // ---- the four company cells ---- const lane = byKey[c.scope ?? c.company]; // UTTERED only. The tally says "the latest figure he has given", and a sum // we performed is not one — showing 18 here while a card beside it says he // never said eighteen makes the video contradict itself on screen. if (lane && c.value != null && (!c.valueKind || c.valueKind === "uttered")) { const prev = lane.cur; const next = { track: lane.track, display: c.display ?? String(c.value), value: c.value, date: c.date, population: c.population ?? null, delta: prev ? Number((c.value - prev.value).toFixed(2)) : null, }; const from = prev ? { ...prev, delta: prev.delta } : { empty: true, track: lane.track }; lane.steps[i] = transition(lane, from, next, !prev || c.value >= prev.value); lane.cur = next; } // ---- the roster ---- // Only when the LINE changes. He enumerates the same two editors and one // designer in October and again in December; rolling the line to arrive at // the words it already said would animate the one thing that held still. const line = Array.isArray(c.roles) && c.roles.length ? rosterLineOf(c.roles) : null; if (line && line !== roster.cur?.line) { const next = { line, date: c.date }; const from = roster.cur ? { ...roster.cur } : { empty: true }; roster.steps[i] = transition(roster, from, next, true); roster.cur = next; } }); const all = [...lanes, roster]; return { lanes: all, rows: Math.max(...all.map((l) => l.rows.length)) }; } /** * The colour of the population word under a figure. Never a new hue -- the * chip is `pal.muted` text, so the dataviz gate does not have to be re-run. */ const POP_WORD = { employees: "employees", "full-time": "full time", salaried: "salaried", contractor: "contractors", 1099: "1099", people: "people", }; /** A small solid triangle, because a font may not carry ▲ and tofu is worse. */ function svgTri(x, y, up, color) { const d = up ? `M${x},${y + 7} L${x + 4.5},${y} L${x + 9},${y + 7} z` : `M${x},${y} L${x + 4.5},${y + 7} L${x + 9},${y} z`; return ``; } /** One rolling tally cell, drawn into a CELLW x TALLYROWH box at (x, y). */ function tallyCellSvg(cell, x, y, g, pal, h, face) { const svgText = svgTextIn(face); const right = x + g.CELLW; const out = [``]; if (cell.empty) { out.push( svgText(right, y + 24, "—", { size: 22, color: pal.muted, weight: "bold", anchor: "end", opacity: 0.5 }), svgText(right, y + 37, "not yet stated", { size: 10.5, color: pal.muted, opacity: 0.6, anchor: "end" }), ); return out.join(""); } const colour = cell.track?.color ?? pal.fg; out.push( svgText(right, y + 24, cell.display, { size: 22, color: colour, weight: "bold", anchor: "end" }), ); if (cell.delta != null && cell.delta !== 0) { const up = cell.delta > 0; out.push( svgTri(x, y + 11, up, colour), svgText(x + 14, y + 22, `${up ? "+" : "−"}${Math.abs(cell.delta)}`, { size: 13, color: colour, weight: "bold", }), ); } const chip = cell.population ? ` · ${POP_WORD[cell.population] ?? cell.population}` : ""; out.push( svgText(right, y + 37, `as of ${cell.date}${chip}`, { size: 10.5, color: pal.muted, anchor: "end", opacity: 0.9, }), ); return out.join(""); } /** One roster line, drawn into a ROSTERW x ROSTERH box. */ function rosterCellSvg(cell, x, y, g, pal, face) { const svgText = svgTextIn(face); const out = [``]; out.push( cell.empty ? svgText(x, y + 18, "not yet enumerated", { size: 12.5, color: pal.muted, opacity: 0.55 }) : svgText(x, y + 18, fit(cell.line, 13, g.ROSTERW, face), { size: 13, color: pal.muted }), ); return out.join(""); } // --------------------------------------------------------------------------- // The provenance tile // --------------------------------------------------------------------------- // A compilation asks the viewer to take the edit on trust. The QR is the // antidote: it resolves to this clip's exact start in the archive's own viewer, // so anyone can pull up the surrounding hour and check the cut is fair. // // It used to float over the bottom-right of the PICTURE, which is the one place // in the frame the cut promises never to draw on. In the rail's foot it is a // bordered tile that reads as a link, and it becomes one more strip walked by a // crop -- one tile per segment, stepped instantaneously at the mid-dissolve, // exactly like the other four. // // Two rules learned the hard way: it must be FULLY OPAQUE (a translucent QR // will not scan) and it must keep its quiet zone (the white border is part of // the symbol, not decoration). export function qrUrlFor(entry, provenance) { // A clip from a file beside the manifest (`src`) has no moment on the archive // to link: its own citeUrl, else -- the rail draws a code on every tile -- // the sweep's, as a card's. const momentless = entry.type === "clip" && entry.src != null && !entry.citeUrl; if (entry.type === "clip" && !momentless) { // A mirror's LOCAL slug is not the id the site serves, so an explicit // per-clip citeUrl always wins over the derived one. return ( entry.citeUrl ?? `${provenance.siteOrigin}/?v=${encodeURIComponent( `${entry.channel ?? provenance.channelSlug}/${entry.video}`, )}&t=${Math.floor(entry.cite ?? entry.start)}` ); } // A card is not a moment, so it gets the search rather than a timestamp. // // NOT `provenance.shareLink`. That link carries all 23 channel filters and is // ~1.4k characters — a version-40 symbol, 177 modules inside a 132 px tile, // which is roughly 0.7 px per module and unscannable. `qrLink` is the same // query without the channel list (~200 chars, 63 modules, verified scannable // at this size); the site origin is the fallback. A code nobody can scan is // worse than a short one. return provenance.qrLink ?? provenance.siteOrigin ?? ""; } async function qrTileStrip(entries, provenance, render, g, outDir) { const pal = render.palette; const svgText = svgTextIn(svgFaceOf(render)); const dir = path.join(outDir, "cards"); const qrDir = path.join(outDir, "qr"); const q = render.qr ?? {}; const QR = g.QRSIZE; const TH = g.TILEH; // One PNG per DISTINCT url, then a tile per segment referencing it. const seen = new Map(); const urls = entries.map((e) => qrUrlFor(e, provenance)); for (const url of urls) { if (seen.has(url)) continue; const png = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.png`); await execFileP(QRENCODE, [ "-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", "--", url, ]); // Nearest-neighbour to an exact box: a resampled QR blurs its module edges // and stops scanning, and the geometry has to be known before this runs. const sized = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.${QR}.png`); await execFileP("magick", [png, "-filter", "point", "-resize", `${QR}x${QR}!`, sized]); seen.set(url, sized); } const stripH = entries.length * TH; const body = [``]; const images = []; entries.forEach((e, i) => { const y = i * TH; const isClip = e.type === "clip" && !(e.src != null && !e.citeUrl); body.push( ``, svgText(14, y + 32, "SCAN → JERALYZER", { size: 12.5, color: pal.accent, weight: "bold", ls: 1.3, }), svgText(14, y + 58, isClip ? "this exact moment," : "the sweep this is cut from,", { size: 13.5, color: pal.fg, }), svgText(14, y + 78, isClip ? "in the archive's own viewer" : "every claim, searchable", { size: 13.5, color: pal.fg, }), svgText(14, y + 106, "the archive outlives the platform", { size: 11.5, color: pal.muted, opacity: 0.8, }), ); images.push({ png: seen.get(urls[i]), x: g.TILEW - QR - 11, y: y + 11 }); }); const svgPath = path.join(dir, "_rail_qr.svg"); const basePng = path.join(dir, "_rail_qr.base.png"); await rasterize(svgDoc(g.TILEW, stripH, body.join("")), svgPath, basePng, g.TILEW, stripH, render); // The codes are composited rather than inlined: an in the SVG // would be resampled by rsvg, and a resampled QR does not scan. const out = path.join(dir, "_rail_qr.png"); const args = [basePng]; for (const im of images) args.push(im.png, "-geometry", `+${im.x}+${im.y}`, "-composite"); args.push(out); await execFileP("magick", args, { maxBuffer: 1 << 26 }); return { path: out, tileH: TH, urls }; } /** * Build the rail strips. Returns their paths plus the geometry, so the caller * never re-derives a size the pixels already committed to. * * `entries` and `provenance` are needed for the QR strip only; without them the * tile is skipped and the rail is the four strips it always was. */ export async function renderRailAssets(render, ledger, outDir, entries = null, provenance = null) { const pal = render.palette; const rail = render.rail; const tracks = rail.tracks ?? []; const byKey = Object.fromEntries(tracks.map((t) => [t.key, t])); const g = railGeometry(render, ledger.length); const dir = path.join(outDir, "cards"); const rule = rail.rule ?? "#2A322F"; const P = (n) => path.join(dir, n); const face = svgFaceOf(render); const svgText = svgTextIn(face); // ---- chrome: opaque, full rail column, never gated ------------------- // It runs for the whole video rather than being switched on with enable=, // which removes four expressions and four off-by-one opportunities. // // The tally SWATCH and LABEL live here. They never change, and while they // rode in the rolling block a coffee figure changing dragged "The Quartering" // up the screen with it — text moving for a reason that was not about it. const chromeBody = [ ``, ``, svgText(g.PAD, 32, "THE CLAIM LEDGER", { size: 15, color: pal.amber, weight: "bold", ls: 1.6 }), svgText(g.PAD, 55, "every count he has given, as he gave it", { size: 14, color: pal.muted }), ``, svgText(g.PAD, g.TALLYHEAD_REL + 16, "LATEST FIGURE HE HAS GIVEN", { size: 12, color: pal.muted, weight: "bold", ls: 1.4, }), ...tracks.map((tr, j) => { const ry = g.TALLYTOP_REL + j * g.TALLYROWH; return ( `` + svgText(g.PAD + 20, ry + 22, fit(tr.label, 14, g.CELLX - g.PAD - 24, face), { size: 14, color: pal.fg, }) ); }), ``, svgText(g.PAD, g.ROSTERTOP_REL + 19, "ROSTER", { size: 11, color: pal.muted, weight: "bold", ls: 1.4, }), ``, svgText(g.PAD, g.LOGTOP_REL - 8, "IN THE ORDER STATED", { size: 12, color: pal.muted, weight: "bold", ls: 1.4, }), ].join(""); const chrome = await rasterize( svgDoc(g.RW, g.RHGT, chromeBody), P("_rail_chrome.svg"), P("_rail_chrome.png"), g.RW, g.RHGT, render, ); // ---- log strip: every claim, stacked, no padding --------------------- const valX = g.RW - g.PAD; const textX = g.PAD + 20; const textBudget = valX - textX - 74; const rows = ledger.map((c, i) => { const y = i * g.ROWH; // `scope` is the ADJUDICATED answer and `company` the undocumented guess it // replaced. Falls back so a manifest with no adjudication yet still renders. const tr = byKey[c.scope ?? c.company]; // In `sourced` every row has a clip behind it, so `live` is always true // there; `full` keeps the distinction because a stacked ledger card is a // weaker citation than footage and must not look like one. const live = !!c.entryId && !c.unsourced; const ink = live ? pal.fg : pal.muted; const dotFill = live ? (tr?.color ?? pal.accent) : "none"; return [ ``, ``, svgText(textX, y + 17, c.date, { size: 13.5, color: pal.muted, opacity: live ? 1 : 0.7 }), svgText(valX, y + 19, c.display ?? "—", { size: 18, color: live ? (tr?.color ?? pal.fg) : pal.muted, weight: "bold", anchor: "end", opacity: live ? 1 : 0.65, }), svgText(textX, y + 33, fit(c.label ?? c.quote ?? "", 13, textBudget + 74, face), { size: 13, color: ink, opacity: live ? 1 : 0.6, }), ``, ].join(""); }).join(""); const log = await rasterize( svgDoc(g.RW, g.logStripH, rows), P("_rail_log.svg"), P("_rail_log.png"), g.RW, g.logStripH, render, ); // ---- curtain --------------------------------------------------------- // With ONE log strip and the window parked at the top while the list is still // filling, rows i+1…K-1 would show claims the video has not made yet. This // opaque rectangle rides just below the last revealed row and, once the list // is full, parks exactly one window-height down — outside the window. It is // pal.bg precisely so that parking there is invisible; the QR tile overlays // AFTER it, which is what stops the parked curtain covering the code. const curtain = P("_rail_curtain.png"); await execFileP("magick", ["-size", `${g.RW}x${g.LOGH}`, `xc:${pal.bg}`, curtain]); // ---- highlight ------------------------------------------------------- const hl = await rasterize( svgDoc(g.RW, g.ROWH, [ ``, ``, ].join("")), P("_rail_hl.svg"), P("_rail_hl.png"), g.RW, g.ROWH, render, ); // ---- tally strip: one COLUMN per lane, side by side ------------------- // Four cells and the roster in one PNG: five crops at different x out of one // input, rather than five inputs. Every column is as tall as the tallest, so // a single strip height serves them all. const { lanes, rows: nRows } = tallyTracks(ledger, tracks, { rosterLine }); const cellH = g.TALLYROWH; const laneX = []; let sx = 0; for (const lane of lanes) { const w = lane.kind === "roster" ? g.ROSTERW : g.CELLW; laneX.push({ x: sx, w }); sx += w; } const stripW = sx; const stripH = nRows * cellH; const strip = [``]; lanes.forEach((lane, li) => { const { x } = laneX[li]; lane.rows.forEach((cell, ri) => { strip.push( lane.kind === "roster" ? rosterCellSvg(cell, x, ri * cellH, g, pal, face) : tallyCellSvg(cell, x, ri * cellH, g, pal, cellH, face), ); }); }); const tally = await rasterize( svgDoc(stripW, stripH, strip.join("")), P("_rail_tally.svg"), P("_rail_tally.png"), stripW, stripH, render, ); // ---- the provenance tile --------------------------------------------- const qr = entries && provenance && render.qr !== false ? await qrTileStrip(entries, provenance, render, g, outDir) : null; return { chrome, log, curtain, hl, tally, qr, geom: g, lanes: lanes.map((lane, li) => ({ key: lane.key, kind: lane.kind, steps: lane.steps, x: laneX[li].x, w: laneX[li].w, cellH, })), }; } // =========================================================================== // Stacked ledger cards // =========================================================================== // The `full` cut's answer to the 28 claims the sweep found and no clip covers. // // Dimmed rail rows were the old answer, and they were confusing: a row with no // audio behind it slid past with nothing to say for itself, and 28 of them read // as padding rather than as evidence. So each one gets SCREEN TIME instead -- // its date, its scope, its quote, his figure, and what that figure does to our // running sum. Consecutive unclipped claims share a card and reveal in // sequence, which is why 28 claims cost 14 cards and about 67 seconds. // // The reveal is the rail curtain's device: an opaque `pal.bg` rectangle walking // down the card. Nothing fades, nothing moves; rows simply stop being covered. /** The reveal clock. One definition, because `scheduleClaims` pins to it. */ export const LEDGER_LEAD = 0.9; export const LEDGER_STEP = 1.3; export const LEDGER_TAIL = 2.6; export const ledgerRevealAt = (r) => LEDGER_LEAD + LEDGER_STEP * r; export const ledgerSeconds = (n) => LEDGER_LEAD + LEDGER_STEP * n + LEDGER_TAIL - LEDGER_STEP; /** * Why this claim is a line of text and not footage. * * "The upload is gone" and "we did not cut it" are different sentences, and * saying the first about a live source is the kind of error that makes a whole * compilation untrustworthy. So the words come from a `yt-dlp --simulate` * probe, recorded in out/availability.json with the date it ran. */ export const SOURCE_TAG = { ok: "not clipped", deleted: "source deleted", private: "source private", "members-only": "members only", restricted: "age-restricted", "geo-blocked": "geo-blocked", "no-cues": "no archived transcript", maybe_missing: "source unreachable", }; /** * Greedy wrap to a pixel budget, at most `maxLines`, last line elided. * * Fira Sans (no brand) wraps at a column count from its average advance, as it * always did. A measured face wraps at the line's own width, and a lone word * wider than the budget is fitted too, so no line it returns overruns. */ export function wrapPx(text, size, maxPx, maxLines, face = FIRA_SANS) { let tooLong; if (face.advances) { tooLong = (s) => textWidth(s, size, face) > maxPx; } else { const perChar = size * face.em; const cols = Math.max(8, Math.floor(maxPx / perChar)); tooLong = (s) => s.length > cols; } const words = String(text ?? "").split(/\s+/).filter(Boolean); const lines = []; let line = ""; for (const w of words) { if (line && tooLong(line + " " + w)) { lines.push(line); line = w; if (lines.length === maxLines) break; } else { line = line ? line + " " + w : w; } } if (lines.length < maxLines && line) lines.push(line); if (lines.length === maxLines) { const used = lines.join(" ").split(/\s+/).length; if (used < words.length) lines[maxLines - 1] = fit(lines[maxLines - 1] + " …", size, maxPx, face); } return face.advances ? lines.map((l) => fit(l, size, maxPx, face)) : lines; } /** * One card carrying a run of consecutive unclipped claims. * * Returns the geometry the encoder needs to walk the curtain: where the rows * start and how tall each one is. */ export async function renderLedgerCard(card, render, ledger, outDir, avail = null) { const pal = render.palette; const tracks = render.rail?.tracks ?? []; const byKey = Object.fromEntries(tracks.map((t) => [t.key, t])); const VW = cardWidth(card, render); const H = render.height; const dir = path.join(outDir, "cards"); const rule = render.rail?.rule ?? "#2A322F"; const RESERVED = reservedFooterHeight(render); const face = svgFaceOf(render); const svgText = svgTextIn(face); // The scope pill's width. Fira: 7.6 px a character at 14.5 px, as it always // was. A measured face: the label's own width, the same 11 px either side. const pillW = (tr) => face.advances ? Math.max(120, Math.ceil(textWidth(tr?.label ?? "", 14.5, face)) + 22) : Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22); const ids = card.claims ?? []; const rows = ids.map((id) => ledger.find((c) => c.id === id)).filter(Boolean); if (rows.length !== ids.length) { const missing = ids.filter((id) => !ledger.some((c) => c.id === id)); throw new Error(`ledger card ${card.id} names claims that are not in the ledger: ${missing.join(", ")}`); } // The arithmetic is READ, never recomputed: one implementation of the walk, // or the card and the chart band can disagree about the same sum. const steps = new Map(ledgerTotals(ledger).steps.map((st) => [st.id, st])); const M = 96; const ARITHW = 320; const arithX = VW - M - ARITHW; const quoteW = arithX - 70 - M; const body = [``]; body.push( svgText(M, 80, (card.kicker ?? "found in the sweep, not clipped here").toUpperCase(), { size: 22, color: pal.amber, weight: "bold", ls: 1.4, }), svgText(M, 132, card.heading ?? "What the sweep found and this cut cannot show you", { size: 40, color: pal.fg, weight: "bold", }), svgText(M, 168, card.sub ?? "his own words, and what they do to our running sum", { size: 20, color: pal.muted, }), ``, ); // Rows are a fixed height and the BLOCK is centred in what is left of the // frame. Stretching two rows to fill 640px puts a hand's width of nothing // between them; packing them at the top leaves the same gap in one lump at // the bottom. Centring is the only arrangement that reads as deliberate. const top = 214; const available = H - RESERVED - top - 24; const ROWH = Math.min(180, Math.floor(available / rows.length)); const rowsTop = top + Math.floor((available - ROWH * rows.length) / 2); rows.forEach((c, r) => { const y = rowsTop + r * ROWH; const tr = byKey[c.scope ?? c.company]; const st = steps.get(c.id); const state = avail?.get(c.id) ?? null; const tag = SOURCE_TAG[state] ?? "not clipped"; body.push( svgText(M, y + 32, c.date, { size: 20, color: pal.muted }), // The scope, as a bordered pill in its own colour. Which payroll a number // is about is the whole argument, so it is never left to the ink alone. ``, svgText(M + 159, y + 30, tr?.label ?? c.scope ?? "", { size: 14.5, color: tr?.color ?? pal.muted }), svgText(M + 148 + pillW(tr) + 16, y + 30, tag, { size: 14.5, color: pal.muted, opacity: 0.85, }), svgText(arithX - 70, y + 40, c.display ?? "—", { size: 34, color: tr?.color ?? pal.fg, weight: "bold", anchor: "end", }), ); wrapPx(`“${c.quote ?? c.label ?? ""}”`, 25, quoteW, 2, face).forEach((line, li) => { body.push(svgText(M, y + 76 + li * 33, line, { size: 25, color: pal.fg })); }); // ---- the arithmetic column ---- // Which layer this claim moved, lit; the others held, dimmed. The point is // that the total on the right is OURS and is made of his own figures. body.push( svgText(arithX, y + 24, "OUR RUNNING SUM", { size: 11, color: pal.muted, weight: "bold", ls: 1.3, }), ); const basis = st?.impliedBasis ?? {}; const companies = tracks.filter((t) => t.key !== "all"); // Before any company has given a figure, our sum is not zero — it is // undefined, and four dashes in a column say that far less clearly than // one sentence does. if (!companies.some((t) => basis[t.key])) { body.push( svgText(arithX, y + 52, "no company figure yet,", { size: 14, color: pal.muted }), svgText(arithX, y + 74, "so our sum is not defined", { size: 14, color: pal.muted }), ); if (r < rows.length - 1) { body.push( ``, ); } return; } companies.forEach((t, k) => { const b = basis[t.key]; const moved = (c.scope ?? c.company) === t.key; const ty = y + 48 + k * 24; body.push( svgText(arithX, ty, fit(t.shortLabel ?? t.label, 13, ARITHW - 90, face), { size: 13, color: moved ? t.color : pal.muted, opacity: moved ? 1 : 0.55, }), svgText(arithX + ARITHW, ty, b ? String(b.value) : "—", { size: 17, color: moved ? t.color : pal.muted, weight: "bold", anchor: "end", opacity: moved ? 1 : 0.55, }), ); }); const sy = y + 48 + companies.length * 24; body.push( ``, svgText(arithX, sy + 30, "IMPLIED", { size: 13, color: pal.fg, weight: "bold", ls: 1.2 }), svgText(arithX + ARITHW, sy + 32, st?.implied == null ? "—" : String(st.implied), { size: 22, color: pal.fg, weight: "bold", anchor: "end", }), ); if (st?.impliedDelta) { const up = st.impliedDelta > 0; body.push( svgTri(arithX + 84, sy + 22, up, pal.amber), svgText(arithX + 98, sy + 30, `${up ? "+" : "−"}${Math.abs(st.impliedDelta)}`, { size: 14, color: pal.amber, weight: "bold", }), ); } if (r < rows.length - 1) { body.push( ``, ); } }); const outPath = path.join(dir, `${card.id}.png`); await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H, render); return { path: outPath, width: VW, rowsTop, rowHeight: ROWH, rows: rows.length }; } // =========================================================================== // End sequence: the ledger scroll and the step chart // =========================================================================== /** * The whole ledger as one tall PNG for an animated crop to walk. * * --------------------------------------------------------------------------- * One chronological line, a column per company * --------------------------------------------------------------------------- * It used to group by company: four blocks, each date-sorted inside itself. The * cut plays in ONE chronology, and grouping at the end re-tells it in an order * the viewer has not just watched — and it hides the only thing worth seeing * here, which is that the four payrolls were being described in the same weeks. * * So: one date-ordered list, and the company is read from COLUMN POSITION. That * makes colour the secondary encoding rather than the only one, which is the * same rule the chart already runs under. * * The rail hides for this card (`hideRail`), so it is drawn at the FULL frame * width rather than the content width. * * Returns the CONTENT HEIGHT because the scroll expression is written against * it — crop clamps its own y, so an off-by-a-few degrades into a static last * frame rather than an error, but only if the caller knows the real number. */ export async function renderScrollCard(card, render, ledger, outDir) { const pal = render.palette; const tracks = render.rail?.tracks ?? []; const VW = cardWidth(card, render); const dir = path.join(outDir, "cards"); const rule = render.rail?.rule ?? "#2A322F"; const M = 96; const ROWH = 42; const body = []; const face = svgFaceOf(render); const svgText = svgTextIn(face); // Columns. The value columns are right-aligned on their own gridline, so a // number's horizontal position IS its company even before the colour reads. const COLW = 152; const dateX = M; const colX = tracks.map((_, i) => M + 168 + i * COLW); const popX = M + 168 + tracks.length * COLW + 24; const labelX = popX + 132; const labelW = VW - M - labelX; let y = 66; body.push(svgText(M, y, card.heading ?? "THE COMPLETE LEDGER", { size: 30, color: pal.fg, weight: "bold", ls: 1.5, })); y += 32; body.push(svgText(M, y, card.sub ?? `${ledger.length} dated claims, in the order he made them`, { size: 19, color: pal.muted, })); y += 44; // The column heads, which are the legend. No separate key: a company name // over its own column of figures is the shortest legend there is. body.push(``); body.push(svgText(dateX, y + 26, "DATE", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 })); // No swatch beside the head: the head is already IN the track's colour, and // the column position is the primary encoding either way. A swatch would only // land on top of the words, since a right-anchored run cannot be measured // here to leave room for one. tracks.forEach((tr, i) => { body.push( svgText(colX[i], y + 26, fit(tr.shortLabel ?? tr.label, 13, COLW - 12, face, { weight: "bold", ls: 0.6 }), { size: 13, color: tr.color, weight: "bold", anchor: "end", ls: 0.6, }), ); }); body.push( svgText(popX, y + 26, "AS WHAT", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 }), svgText(labelX, y + 26, "WHAT HE SAID", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 }), ); y += 40; body.push(``); y += 8; const byKey = Object.fromEntries(tracks.map((t, i) => [t.key, i])); const rows = [...ledger].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date))); for (const c of rows) { const live = !!c.entryId && !c.unsourced; const i = byKey[c.scope ?? c.company]; const tr = tracks[i]; body.push( svgText(dateX, y + 26, c.date, { size: 18, color: pal.muted, opacity: live ? 1 : 0.7 }), ); if (tr) { body.push( svgText(colX[i], y + 26, c.display ?? "—", { size: 21, color: live ? tr.color : pal.muted, weight: "bold", anchor: "end", opacity: live ? 1 : 0.6, }), ); } body.push( svgText(popX, y + 26, POP_WORD[c.population] ?? c.population ?? "", { size: 15, color: pal.muted, opacity: live ? 0.9 : 0.6, }), svgText(labelX, y + 26, fit(c.label ?? "", 18, labelW, face), { size: 18, color: live ? pal.fg : pal.muted, opacity: live ? 1 : 0.6, }), ``, ); y += ROWH; } y += 60; const contentHeight = y; const outPath = path.join(dir, `${card.id}.png`); await rasterize( svgDoc(VW, contentHeight, `${body.join("")}`), path.join(dir, `${card.id}.svg`), outPath, VW, contentHeight, render, ); return { path: outPath, contentHeight, width: VW }; } /** * The four-series step chart, over the claims flagged `plotted`. * * COLOUR IS NOT THE ONLY ENCODING here, and that is a hard requirement rather * than a flourish: no four-colour categorical palette clears the data-viz * all-pairs CVD gate (three slots is the documented ceiling), so each series * also carries a distinct dash pattern and a direct end-of-line label. The four * hues themselves are the published artifact's, re-validated against this * video's darker ground (#0F1312) on the adjacent pairlist — the pairlist for * line charts — where all five checks pass. */ export async function renderChartCard(card, render, ledger, outDir) { const pal = render.palette; const tracks = render.rail?.tracks ?? []; const VW = cardWidth(card, render); const H = render.height; const dir = path.join(outDir, "cards"); const rule = render.rail?.rule ?? "#2A322F"; const svgText = svgTextIn(svgFaceOf(render)); // The series come from ledger-totals, not from the legacy `plotted` flag. // `plotted` was set under the OLD reading, in which a sum we performed sat in // the same series as a figure he uttered. Drawing from it now would put 18 and // 20 back on his line, after the whole point of the adjudication was to take // them off it. let totals = null; try { totals = ledgerTotals(ledger); } catch { // An unadjudicated ledger still renders -- as the three company series only, // because the two totals are exactly what it cannot be trusted about. totals = null; } const pts = ledger.filter((c) => c.value != null && (c.scope ?? c.company) !== "all"); const yr = (d) => { const [Y, M2, D2] = d.split("-").map(Number); return Y + (M2 - 1) / 12 + (D2 - 1) / 365; }; const X0 = yr("2020-01-01"), X1 = yr("2026-12-31"); const YMAX = Math.max(21, ...pts.map((p) => p.value), ...(totals?.series.implied ?? []).map((p) => p.value)) + 1; const RESERVED = reservedFooterHeight(render); const box = { l: 150, r: 300, t: 190, b: 130 + RESERVED }; const plotW = VW - box.l - box.r; const plotH = H - box.t - box.b; const px = (v) => box.l + ((v - X0) / (X1 - X0)) * plotW; const py = (v) => H - box.b - (v / YMAX) * plotH; const body = [``]; body.push( svgText(box.l, 78, "WHAT HE SAID, AND WHAT IT ADDS UP TO", { size: 34, color: pal.fg, weight: "bold", ls: 1.5, }), svgText(box.l, 112, "every figure he utters, against the company he was talking about", { size: 20, color: pal.muted, }), svgText(box.l, 146, "the heavy line is ours — his own per-company claims, added up", { size: 18, color: pal.amber, }), ); // grid + axes for (let gv = 0; gv <= YMAX - 1; gv += 5) { body.push( ``, svgText(box.l - 16, py(gv) + 6, String(gv), { size: 17, color: pal.muted, anchor: "end" }), ); } body.push(svgText(box.l - 16, py(YMAX - 1) - 22, "PEOPLE", { size: 13, color: pal.muted, weight: "bold", anchor: "end", ls: 1.2, })); for (let Y = 2020; Y <= 2026; Y += 1) { const x = px(yr(`${Y}-01-01`)); body.push( ``, svgText(x, py(0) + 30, String(Y), { size: 17, color: pal.muted, anchor: "middle" }), ); } body.push(``); // One step path per series, plus a dot per claim and a direct end label. // // FIVE series, not four. The three companies are his, drawn as before. The // fourth is what he says the WHOLE payroll is -- only ever a figure he utters // as one number. The fifth is what his own per-company claims add up to, and // it is ours: a heavy neutral step, because an aggregate is not a categorical // peer of the things it aggregates and must not consume a palette slot. const DASH = ["", "12 6", "3 7", "18 5 4 5"]; const labels = []; const drawn = []; tracks.forEach((tr, ti) => { if (tr.key === "all") return; drawn.push({ tr, dash: DASH[ti % 4], width: 3.5, pts: pts.filter((c) => (c.scope ?? c.company) === tr.key) .slice().sort((a, b) => a.date.localeCompare(b.date)) .map((c) => ({ date: c.date, value: c.value, display: c.display, hedged: c.hedged })), }); }); if (totals) { const allTrack = tracks.find((t) => t.key === "all"); drawn.push({ tr: { key: "stated", color: allTrack?.color ?? pal.accent, label: "stated total" }, dash: "18 5 4 5", width: 3.5, dots: true, pts: totals.series.stated.map((p) => ({ date: p.date, value: p.value, display: String(p.value) })), }); drawn.push({ tr: { key: "implied", color: pal.fg, label: "implied — our sum" }, dash: "", width: 6, dots: false, pts: totals.series.implied.map((p) => ({ date: p.date, value: p.value, display: String(p.value) })), }); } for (const sr of drawn) { const mine = sr.pts; if (!mine.length) continue; // A step, not a line: the figure he gave holds until he gives another one, // so the segment between two claims must be flat and the change vertical. let d = ""; let prevY = null; for (const [i, c] of mine.entries()) { const x = px(yr(c.date)); const yv = py(c.value); d += i === 0 ? `M ${x.toFixed(1)} ${yv.toFixed(1)}` : ` L ${x.toFixed(1)} ${prevY.toFixed(1)} L ${x.toFixed(1)} ${yv.toFixed(1)}`; prevY = yv; } const last = mine[mine.length - 1]; const lastY = py(last.value); d += ` L ${(box.l + plotW).toFixed(1)} ${lastY.toFixed(1)}`; body.push( ``, ); if (sr.dots !== false) { for (const c of mine) { body.push( ``, ); } } labels.push({ tr: sr.tr, last, lineY: lastY, y: lastY }); } // The closing hold annotates the gap it has just finished drawing. if (card.hold && totals && totals.final.stated != null && totals.final.implied != null) { const xR = box.l + plotW; const yS = py(totals.final.stated); const yI = py(totals.final.implied); body.push( ``, ``, svgText(xR - 96, (yI + yS) / 2 - 4, `gap ${Math.round(totals.final.implied - totals.final.stated)}`, { size: 22, color: pal.amber, weight: "bold", }), svgText(xR - 96, (yI + yS) / 2 + 22, "between his last total and our sum", { size: 14, color: pal.muted, }), ); } // Three of the four series end within a couple of people of each other, so // their direct labels land on top of one another. Push them apart and elbow a // leader line back to the value each one actually belongs to — direct labels // are the secondary encoding that lets a four-colour palette be legible at // all, so an unreadable stack would defeat the point of having them. const LBLH = 48; labels.sort((a, b) => a.y - b.y); for (let i = 1; i < labels.length; i += 1) { labels[i].y = Math.max(labels[i].y, labels[i - 1].y + LBLH); } const overshoot = labels.length ? labels[labels.length - 1].y - (H - box.b - 10) : 0; if (overshoot > 0) for (const l of labels) l.y -= overshoot; for (const l of labels) { const lx = box.l + plotW; if (Math.abs(l.y - l.lineY) > 2) { body.push( ``, ); } body.push( svgText(lx + 20, l.y + 2, l.tr.label, { size: 18, color: l.tr.color, weight: "bold" }), svgText(lx + 20, l.y + 22, `last stated ${l.last.display}`, { size: 14, color: pal.muted }), ); } body.push( svgText(box.l, H - RESERVED - 56, "hollow dot = a hedge word (“nearly ten”, “a handful”), not a figure", { size: 16, color: pal.muted, }), svgText(box.l, H - RESERVED - 30, "each series is dashed as well as coloured — the shapes carry the reading on their own; " + "sums and midpoints are ours and are never drawn as his", { size: 16, color: pal.muted, }), ); const outPath = path.join(dir, `${card.id}.png`); await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H, render); return { path: outPath, plotX: box.l, plotY: box.t, plotW, plotH: py(0) - box.t + 2, }; } export async function renderCard(card, render, outDir, nodes) { if (render.brand && BRAND_CARD_STYLES.includes(card.style)) { return renderBrandCard(card, render, outDir, cardWidth(card, render)); } if (card.style === "timeline") { if (!nodes?.length) throw new Error(`card ${card.id} is style:timeline but no timelineNodes given`); return renderTimelineCard(card, render, nodes, outDir); } return renderPlainCard(card, render, outDir); } async function renderPlainCard(card, render, outDir) { const pal = render.palette; const { width, height } = render; const VW = cardWidth(card, render); const textWidth = Math.round(VW * 0.74); const outPath = path.join(outDir, "cards", `${card.id}.png`); // Pango reads its markup from a file to keep it clear of shell/argv quoting. const markupPath = path.join(outDir, "cards", `${card.id}.pango`); await writeFile(markupPath, markupFor(card, pal, brandFaces(render)), "utf8"); // One magick invocation: solid ground, an accent rule down the left margin, // then the Pango block composited over it. The rule is what keeps the cards // recognisably one family across styles. const barX = Math.round(VW * 0.09); const barTop = Math.round(height * 0.28); const barBottom = Math.round(height * 0.72); const args = [ "-size", `${width}x${height}`, `xc:${pal.bg}`, "-fill", pal.accent, "-draw", `rectangle ${barX},${barTop} ${barX + 6},${barBottom}`, "(", // `-size` is still set to the full frame from the canvas above, and the // pango delegate honours it — leaving it alone renders the text into a // 1920x1080 box, which pins the block to the top and wraps at the frame // edge instead of the margin. Reset it to the text column, height auto. "-size", `${textWidth}x`, "-background", "none", "-define", `pango:width=${textWidth}`, "-define", "pango:alignment=left", "-define", "pango:wrap=word", `pango:@${markupPath}`, ")", "-gravity", "West", "-geometry", `+${barX + 58}+0`, "-composite", outPath, ]; await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 })); return outPath; } async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); if (!manifestPath) { console.error("usage: render-cards.mjs [--out ] [--only ]"); process.exit(2); } const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }; // brandManifest resolves a brand preset (and appends its end card); a // manifest without one comes back as it was read. const manifest = brandManifest(JSON.parse(await readFile(manifestPath, "utf8"))); const outDir = flag("--out") ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const only = flag("--only"); await ensureWriteDir(outDir); // a project's out/ may be a link to the media root await mkdir(path.join(outDir, "cards"), { recursive: true }); const cards = manifest.timeline.filter( (e) => e.type === "card" && (!only || e.id === only), ); for (const card of cards) { const p = await renderCard(card, manifest.render, outDir, manifest.timelineNodes); console.log(`card ${card.id} -> ${p}`); } console.log(`${cards.length} card(s) rendered`); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err); process.exit(1); }); }