#!/usr/bin/env node // Build the chrome regions as HyperFrames compositions and render them to // lossless PNG sequences the ffmpeg pass overlays. // // --------------------------------------------------------------------------- // Why the chrome is a browser and the picture is not // --------------------------------------------------------------------------- // HyperFrames pre-extracts source video to JPEG q95 before compositing, which is // unacceptable when the picture IS the cited evidence -- the whole argument of // the cut is that you are watching the man say it. So FOOTAGE NEVER ENTERS // CHROME. Each chrome region is its own small composition over a transparent // background, rendered to RGBA PNG, and composited by the existing ffmpeg pass. // That is ~37% of full-frame pixels rather than 100%. // // --------------------------------------------------------------------------- // Why the sweep is one clip-path and not a dash offset per series // --------------------------------------------------------------------------- // The obvious build animates every path's stroke-dashoffset. It looks right for // the strokes and wrong for everything else: the gap band between the two // totals is a filled polygon with no stroke to offset, so it appears at full // width the moment it fades in and the chart is already showing you an answer // the playhead has not reached. One clip rect over the whole plot makes the // reveal a property of the SWEEP rather than of each mark, and nothing can get // ahead of it. // // --------------------------------------------------------------------------- // Why the playhead is driven by out/schedule.json and not by dates // --------------------------------------------------------------------------- // The band has to move every frame and be in the right place when a rail row // lands. Only the build knows when that is -- it depends on segment durations // and the crossfade -- so the build writes the schedule and this reads it. // Recomputing it here would be a second implementation of segmentOffsets() and // would drift the first time the transition changed. import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import path from "node:path"; import { ledgerTotals, dateKey } from "./ledger-totals.mjs"; import { selectVariant } from "./build-video.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; import { chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, teaserSeconds, transitionOf, validateTeaser, } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; import { feedHtml } from "./chrome-feed.mjs"; import { stampHtml } from "./chrome-stamp.mjs"; import { threadsHtml } from "./chrome-threads.mjs"; import { flipsHtml } from "./chrome-flips.mjs"; const run = promisify(execFile); /** Chromium, for the review still. `CHROME` overrides it. */ const CHROME = process.env.CHROME ?? "/usr/bin/chromium"; const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode"; const MAGICK = process.env.MAGICK_BIN ?? "magick"; const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(//g, ">"); const num = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace(/\.0$/, "")); /** Days since epoch, so the x scale is linear in real time and not in claims. */ const dayOf = (d) => Date.parse(`${dateKey(d)}T00:00:00Z`) / 86400000; // --------------------------------------------------------------------------- // The chart band. // --------------------------------------------------------------------------- const DEFAULT_CHART = { height: 200, yMax: 30, from: "2020-01-01", to: "2026-12-31", statedColor: "#C55F9C", impliedColor: "#EDF0EC", impliedWidth: 4.5, // NOT the palette's amber. #E8A33F sits at ΔE 12.4 from the coffee series' // #D2732F at NORMAL vision -- below the 15 floor, so a flag badge beside a // coffee mark was hard to tell from the coffee mark. #E0E24A adds no new // worst pair on either the CVD or the normal-vision all-pairs check: the two // worst pairs are identical with and without it. flagColor: "#E0E24A", gapFill: "#C55F9C", gapOpacity: 0.1, series: [ { scope: "media", color: "#22AB83", dash: null }, { scope: "coffee", color: "#D2732F", dash: "7 4" }, { scope: "publica", color: "#6A82DC", dash: "2 4" }, ], }; const SCOPE_LABEL = { media: "The Quartering", coffee: "Coffee Brand", publica: "The Publica" }; /** The predicates that get a glyph on the mark. See the note at the call site. */ const BADGED = new Set([ "contradicts_component", "same_day_conflict", "self_negating", "not_his_number", // The same people, staff one month and contractors the next. It earns a // badge for the same reason self_negating does: the mark is drawn at a // height the claim's own words undercut. "status_flip", ]); /** * A step path. The figure he gave HOLDS until he gives another one, so the * segment between two claims is flat and the change is vertical. A smooth line * would draw a fortnight of intermediate headcounts nobody ever claimed. */ function stepPath(points, X, Y) { if (!points.length) return ""; const d = [`M${X(points[0].date).toFixed(1)},${Y(points[0].value).toFixed(1)}`]; for (let i = 1; i < points.length; i += 1) { d.push(`L${X(points[i].date).toFixed(1)},${Y(points[i - 1].value).toFixed(1)}`); d.push(`L${X(points[i].date).toFixed(1)},${Y(points[i].value).toFixed(1)}`); } return d.join(" "); } /** The same walk, but returning the polyline vertices — the gap band needs them. */ function stepVerts(points, X, Y) { const out = []; points.forEach((p, i) => { if (i > 0) out.push([X(p.date), Y(points[i - 1].value)]); out.push([X(p.date), Y(p.value)]); }); return out; } export function chartBandHtml(manifest, totals, schedule, opts = {}) { const fonts = opts.fonts ?? null; const render = manifest.render; const cfg = { ...DEFAULT_CHART, ...(render.chart ?? {}) }; const W = opts.width ?? render.width - (render.rail?.width ?? 0); const H = cfg.height; const pal = render.palette; const DUR = opts.duration ?? schedule.total; const L = 92, R = 1120, T = 26, B = 150; const AXIS = 158; const d0 = dayOf(cfg.from), d1 = dayOf(cfg.to); const X = (date) => L + ((dayOf(date) - d0) / (d1 - d0)) * (R - L); // Headroom. The implied total peaks at exactly cfg.yMax in this corpus, and a // series drawn along the top gridline reads as clipped rather than as a peak. const peak = Math.max( cfg.yMax, ...(totals.series.implied ?? []).map((p) => p.value), ...(totals.series.stated ?? []).map((p) => p.value), ); const yTop = peak > cfg.yMax - 1 ? Math.ceil(peak * 1.1) : cfg.yMax; const Y = (v) => B - (Math.max(0, Math.min(yTop, v)) / yTop) * (B - T); const atOf = new Map(schedule.claims.map((c) => [c.id, c.at])); const steps = totals.steps.filter((s) => atOf.has(s.id)); // ---- the series --------------------------------------------------------- const seriesSvg = cfg.series .map((s) => { const pts = totals.series[s.scope] ?? []; if (!pts.length) return ""; return ( `` ); }) .join(""); const statedPts = totals.series.stated; const impliedPts = totals.series.implied; // ---- the gap between the two totals ------------------------------------- // Only where BOTH are defined: before his first total there is nothing to be // a gap from, and a band anchored to zero would read as a claim of its own. let gapSvg = ""; const firstStated = statedPts[0] ? dayOf(statedPts[0].date) : Infinity; const firstImplied = impliedPts[0] ? dayOf(impliedPts[0].date) : Infinity; const gapFrom = Math.max(firstStated, firstImplied); if (Number.isFinite(gapFrom)) { const up = stepVerts(impliedPts, X, Y).filter((p) => p[0] >= X(cfg.from) && true); const dn = stepVerts(statedPts, X, Y); const clipX = L + ((gapFrom - d0) / (d1 - d0)) * (R - L); const poly = `M${up.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" L")} ` + `L${R},${up.length ? up[up.length - 1][1].toFixed(1) : Y(0)} ` + `L${R},${dn.length ? dn[dn.length - 1][1].toFixed(1) : Y(0)} ` + `L${[...dn].reverse().map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" L")} Z`; gapSvg = `` + `` + ``; // Where the total is BELOW one of its own parts the gap is not a gap, it is // an impossibility — so it is hatched rather than tinted. Same geometry, a // different claim about what it means. const bad = steps.filter((s) => s.flags.some((f) => f.rule === "contradicts_component")); const bands = bad.map((s) => { const x = X(s.date); const next = statedPts.find((p) => dayOf(p.date) > dayOf(s.date)); const x2 = next ? X(next.date) : R; return ``; }); if (bands.length) { gapSvg += `${bands.join("")}` + `` + `` + ``; } gapSvg += ``; } // ---- marks -------------------------------------------------------------- // Two orthogonal encodings, not four shapes. FILL is evidence: filled means a // clip plays behind it, hollow means the claim is counted but not quoted. // BADGE is coherence. A qualitative claim has no y position at all. const colourOf = (s) => s.scope === "all" ? cfg.statedColor : (cfg.series.find((x) => x.scope === s.scope)?.color ?? pal.muted); const marks = []; const ticks = []; const badges = []; for (const s of steps) { const x = X(s.date); const c = colourOf(s); const live = !!s.entryId; if (s.qualitative || s.value == null) { ticks.push( ``, ); continue; } const y = Y(s.value); marks.push( live ? `` : ``, ); // Not every predicate earns a badge. `population_mismatch` already rides // under the stated number as its qualifier ("salaried"), and // `adjudicator` notes are for the inbox, not the screen. Badging all five // put a triangle on half the marks, which is the same as badging none. if (s.flags.some((f) => BADGED.has(f.rule))) { badges.push( ``, ); } } // ---- direct end labels -------------------------------------------------- // Direct end labels, pushed apart. // // Four of the five series end within a couple of people of each other, so // their labels land on top of one another and the band becomes unreadable // exactly where it is making its point. Same fix the closing card already // uses: spread them, then elbow a leader back to the value each belongs to. // Direct labels are the secondary encoding that lets the palette be legible // at all under CVD, so an unreadable stack defeats the point of having them. const wanted = [ { pts: impliedPts, colour: cfg.impliedColor, text: "implied", weight: true }, { pts: statedPts, colour: cfg.statedColor, text: "stated", weight: true }, ...cfg.series.map((sr) => ({ pts: totals.series[sr.scope] ?? [], colour: sr.color, text: SCOPE_LABEL[sr.scope] ?? sr.scope, weight: false, })), ].filter((l) => l.pts.length); const LBLH = 15; const placed = wanted .map((l) => ({ ...l, lineY: Y(l.pts[l.pts.length - 1].value) })) .sort((a, b) => a.lineY - b.lineY) .map((l) => ({ ...l, y: l.lineY })); for (let i = 1; i < placed.length; i += 1) { placed[i].y = Math.max(placed[i].y, placed[i - 1].y + LBLH); } const over = placed.length ? placed[placed.length - 1].y - (B + 4) : 0; if (over > 0) for (const l of placed) l.y -= over; const endLabels = placed .map((l) => { const elbow = Math.abs(l.y - l.lineY) > 1.5 ? `` : ""; return ( elbow + `${esc(l.text)}` ); }) .join(""); // ---- the year axis ------------------------------------------------------ const y0 = Number(cfg.from.slice(0, 4)), y1 = Number(cfg.to.slice(0, 4)); const years = []; for (let y = y0; y <= y1; y += 1) { const x = X(`${y}-01-01`); if (x < L - 1 || x > R + 1) continue; years.push( `` + `${y}`, ); } const gridStep = yTop > 34 ? 10 : yTop > 12 ? 10 : 5; const gridVals = []; for (let v = 0; v <= yTop; v += gridStep) gridVals.push(v); const grid = gridVals .filter((v) => v <= yTop) .map( (v) => `` + `${v}`, ) .join(""); // ---- the sweep ---------------------------------------------------------- // A piecewise-linear map from finished-video seconds to chart x, through the // schedule's own (claim time, claim date) pairs. That is what makes the // playhead track the CURRENT MOMENT rather than crawling at a constant rate: // where the cut lingers, the playhead lingers. const keys = steps.map((s) => ({ t: atOf.get(s.id), x: X(s.date) })).sort((a, b) => a.t - b.t); const sweep = [{ t: 0, x: L }, ...keys, { t: DUR, x: R }]; // ---- the readout -------------------------------------------------------- const RX = 1215; const rollTweens = []; let prevStated = null, prevImplied = null; for (const s of steps) { const t = atOf.get(s.id); if (s.implied !== prevImplied) { rollTweens.push({ t, k: "imp", v: s.implied, d: s.impliedDelta }); prevImplied = s.implied; } if (s.stated !== prevStated) { rollTweens.push({ t, k: "sta", v: s.stated, d: s.statedDelta, pop: s.population }); prevStated = s.stated; } } const flagCues = steps .map((s) => { const f = s.flags.find((x) => BADGED.has(x.rule)); return f ? { t: atOf.get(s.id), text: f.text } : null; }) .filter(Boolean); const data = { sweep, marks: steps.map((s) => ({ id: s.id, t: atOf.get(s.id) })), rollTweens, flagCues, dur: DUR }; return `
${grid} ${years.join("")} ${gapSvg} ${seriesSvg} ${ticks.join("")} ${marks.join("")} ${badges.join("")} ${endLabels}
IMPLIED · OUR SUM
—
+0
STATED
—
+0
GAP
—
our sum of his per-company claims
`; } // --------------------------------------------------------------------------- // QR codes, for the deck. // --------------------------------------------------------------------------- /** * One QR as a PNG of exactly `size` px. * * `-filter point`: any resampling filter blurs the module edges, and a blurred * QR stops scanning. `-strip`: ImageMagick stamps a creation date into every * PNG, and an asset whose bytes change each run is a render cache that never * hits. Fully opaque, with its quiet zone -- the white border is part of the * symbol, not decoration. */ export async function qrPng(url, render, outPath, size) { const q = render.qr ?? {}; const raw = `${outPath}.raw.png`; await run(QRENCODE, ["-o", raw, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3), "-l", q.ecc ?? "M", "--", url]); await run(MAGICK, [raw, "-filter", "point", "-resize", `${size}x${size}!`, "-strip", outPath]); await rm(raw, { force: true }); return outPath; } /** * One PNG per DISTINCT url into `assetsDir`, named in first-seen order. * Returns `Map(url -> "assets/qrNN.png")`. */ export async function qrPngsFor(urls, render, size, assetsDir) { const out = new Map(); for (const url of urls) { if (!url || out.has(url)) continue; const name = `qr${String(out.size).padStart(2, "0")}.png`; await qrPng(url, render, path.join(assetsDir, name), size); out.set(url, `assets/${name}`); } return out; } // --------------------------------------------------------------------------- // Regions. // --------------------------------------------------------------------------- const HF_JSON = JSON.stringify( { $schema: "https://hyperframes.heygen.com/schema/hyperframes.json", paths: { blocks: "compositions", assets: "assets" } }, null, 2, ); /** * The faces a region draws in, copied in beside it under fixed names. * * Chrome will not resolve a bare local() in the render browser, and the failure * is silent: it falls back and every metric shifts. The deck refuses a missing * face outright -- the band, shipped before this rule, keeps its old leniency. */ async function copyFonts(render, assetsDir, names, { strict }) { const fonts = {}; for (const [slot, src, base] of [ ["regular", render.fontRegular, names.regular], ["bold", render.fontBold, names.bold], ]) { if (!src) { if (strict) throw new Error(`the deck needs render.${slot === "regular" ? "fontRegular" : "fontBold"}`); continue; } const name = `${base}${path.extname(src) || ".ttf"}`; try { await copyFile(src, path.join(assetsDir, name)); } catch (e) { if (strict) throw new Error(`the deck's ${slot} face ${src} cannot be copied: ${e.message}`); // The band as it shipped: the url stays, and the browser falls back. } fonts[slot] = `assets/${name}`; } return fonts; } /** * The posts' screenshots (`posts[].shot`, relative to the manifest) -- or, * with `key` "logo", their logos (`posts[].logo`) -- copied * in beside the page as `shotNN` (`logoNN`), one per distinct file. Returns * `{ [postId]: "assets/shotNN.png" }`. A shot that cannot be copied refuses * the region: a card drawn without the picture its post names is not the * card the manifest asks for. */ async function copyShots(posts, manifestDir, assetsDir, key = "shot") { const out = {}; const byFile = new Map(); for (const p of posts) { if (typeof p[key] !== "string" || !p[key]) continue; const file = path.resolve(manifestDir, p[key]); if (!byFile.has(file)) { const name = `${key}${String(byFile.size).padStart(2, "0")}${path.extname(file).toLowerCase()}`; try { await copyFile(file, path.join(assetsDir, name)); } catch (e) { throw new Error(`post ${p.id}: its ${key} ${p[key]} cannot be copied: ${e.message}`); } byFile.set(file, `assets/${name}`); } out[p.id] = byFile.get(file); } return out; } /** * Which HTML each chrome region is, with the assets it needs written into * `projDir/assets`. The chart's branch is the band as it shipped; only where * its GSAP comes from has changed. */ async function regionHtml(region, { manifest, manifestDir, base, projDir, assetsDir, schedule, duration, from, window, teaser, transition }) { if (region === "chart") { const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8")); const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false }); const totals = ledgerTotals(manifest.ledger); return chartBandHtml(manifest, totals, sched, { fonts, ...(duration ? { duration } : {}) }); } if (region === "deck") { const render = manifest.render; const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); const lay = deckLayout(render); const qrSrcs = {}; if (lay.qr) { const byUrl = await qrPngsFor(schedule.segments.map((s) => s.qrUrl), render, lay.qr.size, assetsDir); for (const s of schedule.segments) if (s.qrUrl) qrSrcs[s.id] = byUrl.get(s.qrUrl); } return deckHtml(schedule, render, { fonts, qrSrcs, from, duration }); } if (region === "posts") { // The deck's faces and the deck's QR maker: a card is part of the deck's // family, not a second design. const render = manifest.render; const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); const posts = windowPosts(schedule, window.segment); const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir); const qrSrcs = {}; for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); const shotSrcs = await copyShots(posts, manifestDir, assetsDir); const logoSrcs = await copyShots(posts, manifestDir, assetsDir, "logo"); return postsHtml(schedule, render, window, { fonts, qrSrcs, shotSrcs, logoSrcs }); } if (region === "feed") { // The posts feed: the deck's faces and QR maker, one page for the whole cut. const render = manifest.render; const fonts = await copyFonts(render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); const posts = schedule.posts ?? []; const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir); const qrSrcs = {}; for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); const shotSrcs = await copyShots(posts, manifestDir, assetsDir); return feedHtml(schedule, render, { fonts, qrSrcs, shotSrcs, from, duration }); } if (region === "stamp") { // The fact-check stamps (chrome-stamp.mjs): the deck's faces, no QR. const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); return stampHtml(schedule, manifest.render, { fonts, from, duration }); } if (region === "threads") { // The thread rail (chrome-threads.mjs): the deck's faces, no QR. const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); return threadsHtml(schedule, manifest.render, { fonts, from, duration }); } if (region === "flips") { // The flips panel (chrome-flips.mjs): the deck's faces, no QR. const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); return flipsHtml(schedule, manifest.render, { fonts, from, duration }); } if (region === "teaser") { // A page module reached by a dynamic import, so nothing that imports this // file -- umtool's preview helper, the build -- loads its face's URL // unless a teaser is being composed (docs/quirks.md). const { teaserHtml, TEASER_FONT_FILE, TEASER_FONT_ASSET } = await import("./chrome-teaser.mjs"); await copyFile(TEASER_FONT_FILE, path.join(projDir, TEASER_FONT_ASSET)); return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET, transition }); } throw new Error(`unknown chrome region: ${region}`); } /** The root's declared size and length, read back off the HTML rather than re-derived. */ function compositionSize(html) { const w = /data-width="(\d+)"/.exec(html); const h = /data-height="(\d+)"/.exec(html); const d = /data-composition-id="[^"]*"[^>]*data-duration="([\d.]+)"/.exec(html); return { width: Number(w?.[1] ?? 1920), height: Number(h?.[1] ?? 1080), duration: Number(d?.[1] ?? 0) }; } const fmtSeconds = (v) => String(Math.round(v * 1000) / 1000); async function framesOnDisk(dir) { try { return (await readdir(dir)).filter((f) => /^frame_\d+\.png$/.test(f)).length; } catch { return 0; } } /** Run the renderer, its chatter to stderr (stdout may be a build's NDJSON). */ /** * The renderer's environment: ours without DISPLAY and WAYLAND_DISPLAY. It is * headless and needs no display, and a stale one breaks it: with DISPLAY naming * an X server that has gone (Xwayland killed by the OOM killer, say), ANGLE's * SwiftShader tries to connect to it, fails, and every render dies with * "assertSwiftShader ... vendor=''". */ export function rendererEnv(env = process.env) { const { DISPLAY: _d, WAYLAND_DISPLAY: _w, ...rest } = env; return rest; } function runRenderer(cmd, args) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: rendererEnv() }); let tail = ""; const keep = (b) => { process.stderr.write(b); tail = (tail + b.toString()).slice(-4000); }; child.stdout.on("data", keep); child.stderr.on("data", keep); child.on("error", reject); child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited ${code}\n${tail}`)), ); }); } /** * Compose a chrome region and, optionally, render it or take a still of it. * * Deck (`region: "deck"`): * - project `out//chrome/deck/` (`deck-preview/` when `preview`, which * never renders and so never disturbs a build's project or cache); * - frames `chrome/deck-frames/frame_%06d.png` and `deck-frames/.key`; * - a window (`from` > 0, or a `duration` shorter than the cut) is its own * project and frames, `deck-from[-frames]`, so a preview slice never * overwrites the whole cut's sequence; * - the render is SKIPPED when `.key` equals this compose's `chromeCacheKey` * and the frame count on disk is `frameCount(duration, fps)`. * * Posts (`region: "posts"`), one WINDOW at a time -- a `postWindows` entry * (`window: {segment, from, to}`), or `segment` alone to look it up: * - the window is snapped outward to the frame grid (`snapWindow`); frame 1 is * cut time `window.from` of the result, and it is `frames` long; * - project `chrome/posts-/` (`posts-preview-/` when * `preview`, never rendered), frames `chrome/posts--frames/` and * their `.key`, cached exactly as the deck's are; * - `still` is in CUT seconds. * * Feed (`region: "feed"`, a schedule with `layout: "feed"`): the posts column * for the whole cut, exactly as the deck -- project `chrome/feed/` * (`feed-preview/` when `preview`), frames `chrome/feed-frames/` and their * `.key`, a window `feed-from[-frames]`, cached as the deck's are. * * Stamp (`region: "stamp"`, a schedule whose `factcheck.stamps` stamps a * claim): the fact-check stamps for the whole cut, exactly as the deck -- * project `chrome/stamp/` (`stamp-preview/` when `preview`), frames * `chrome/stamp-frames/` and their `.key`, a window `stamp-from[-frames]`, * cached as the deck's are. * * Threads (`region: "threads"`, a schedule with `threads`): the thread rail * for the whole cut, exactly as the stamps -- project `chrome/threads/`, * frames `chrome/threads-frames/`, windowed and cached alike. * * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame, * `seconds` long, drawn from the entry alone (no schedule): * - project `chrome/teaser-/` (`teaser-preview-/` when `preview`), * frames `chrome/teaser--frames/` and their `.key`, cached as the deck's * are -- the key hashes the page, so changed words are a new render; * - the build encodes the frames into `segments/.mp4` (build-video's * `buildTeaserSegment`); * - `transition` is the cut's crossfade, read only by a teaser that dips (its * lead is the dissolve and the black): the build passes its own, so a * `--no-xfade` build composes the lead it plays; default the manifest's. * * `schedule` (an object) overrides reading `out//schedule.json`. * * @returns {Promise<{ projDir: string, frames: string|null, still: string|null, * cached: boolean, key: string|null, frameCount: number|null, * window?: { segment: string, from: number, to: number, f0: number, frames: number } }>} */ export async function composeChrome({ manifestPath, outDir = null, variant = "sourced", region = "chart", schedule = null, preview = false, doRender = false, fps = null, workers = null, quality = "high", format = "png-sequence", still = null, png = null, from = 0, duration = null, window = null, segment = null, transition = null, }) { // The variant's view, and its own out directory. Handed the whole manifest // the band would draw claims this cut never makes, and the deck would name // clips it does not play. const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant); // Absolute: the still is a file:// URL, and a relative one is no page at all. const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant)); // The project's out/ through ensureOutDir before anything lands under it: a // link to the media root when UMTOOL_MEDIA_DIR is set, and a loud refusal // when that link dangles. await ensureWriteDir(base); from = Number(from ?? 0); // 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 === "flips"; // The regions drawn over the whole cut from its schedule, windowable alike. 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; if (!teaser) throw new Error(`no teaser entry ${segment ?? "(none named)"} in the ${variant} cut`); const errors = validateTeaser(teaser); if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`); } let sched = schedule; if ((wholeCut || region === "posts") && !sched) { const p = path.join(base, "schedule.json"); try { sched = JSON.parse(await readFile(p, "utf8")); } catch (e) { throw new Error(`the deck needs ${p} (the build writes it) or a schedule passed in: ${e.message}`); } if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`); } if (region === "feed" && sched.layout !== "feed") { throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); } if (region === "flips" && !sched.flips?.pairs?.length) { throw new Error("the flips region needs a schedule with flip pairs (render.chrome.flips)"); } if (region === "threads" && !sched.threads?.threads?.length) { throw new Error("the threads region needs a schedule with a thread rail (render.chrome.threads)"); } if (region === "stamp" && !sched.factcheck?.stamps?.length) { throw new Error("the stamp region needs a schedule that stamps a claim (an entry with a `claim`)"); } const D = transition ?? transitionOf(manifest.render); const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const total = teaser ? teaserSeconds(teaser, D, rate) : keyed ? sched.total : null; const windowed = wholeCut && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); const suffix = windowed ? `-from${fmtSeconds(from)}` : ""; // A posts window: the one asked for, or the segment's from the schedule. let win = null; if (region === "posts") { const want = window ?? postWindows(sched).find((w) => w.segment === segment); if (!want) { throw new Error( segment ? `no post rides on ${segment} in this schedule` : "the posts region needs a window (or a segment)", ); } if (!/^[A-Za-z0-9_-]+$/.test(String(want.segment))) throw new Error(`posts: ${want.segment} is not a segment id`); win = snapWindow(want, { fps: rate, total }); } const projName = region === "posts" ? `posts-${preview ? "preview-" : ""}${win.segment}` : region === "teaser" ? `teaser-${preview ? "preview-" : ""}${teaser.id}` : wholeCut && preview ? `${region}-preview` : `${region}${suffix}`; const projDir = path.join(base, "chrome", projName); const assetsDir = path.join(projDir, "assets"); // The deck's assets are rebuilt every time: a QR from a clip that has since // left the cut must not sit in the directory the cache key hashes. if (keyed) await rm(assetsDir, { recursive: true, force: true }); await mkdir(assetsDir, { recursive: true }); await copyFile(GSAP_FILE, path.join(assetsDir, "gsap.min.js")); const html = await regionHtml(region, { manifest, manifestDir: path.dirname(path.resolve(manifestPath)), base, projDir, assetsDir, schedule: sched, duration: duration != null ? Number(duration) : null, from, window: win, teaser, transition: D, }); await writeFile(path.join(projDir, "index.html"), html, "utf8"); await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8"); const size = compositionSize(html); const hf = hyperframesCommand(process.env); let key = null; let frames = null; if (keyed) { frames = frameCount(size.duration, rate); const assets = []; for (const name of (await readdir(assetsDir)).sort()) { assets.push([name, sha256(await readFile(path.join(assetsDir, name)))]); } key = chromeCacheKey({ html, assets, fps: rate, frames, version: hf.version }); } const result = { projDir, frames: null, still: null, cached: false, key, frameCount: frames, ...(win ? { window: win } : {}) }; // The review still: seek and screenshot, no HyperFrames, ~2 s. It is the SAME // seek the renderer performs for every frame, which is why a still that is // right is evidence about the frames and not just about the markup. The // background is transparent, as the frames' is. if (still != null) { const out = path.resolve(png ?? path.join(projDir, `still-${fmtSeconds(Number(still))}.png`)); await mkdir(path.dirname(out), { recursive: true }); await run(CHROME, [ "--headless", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--default-background-color=00000000", `--window-size=${size.width},${size.height}`, "--virtual-time-budget=6000", `--screenshot=${out}`, `file://${path.join(projDir, "index.html")}?still=${Number(still)}`, ], { maxBuffer: 1 << 26, env: rendererEnv() }); return { ...result, still: out }; } if (!doRender || (keyed && preview)) return result; // A sequence is a directory; every other format is a file. const sequence = format === "png-sequence"; const stem = region === "posts" ? `posts-${win.segment}` : region === "teaser" ? `teaser-${teaser.id}` : `${region}${suffix}`; const target = sequence ? path.join(base, "chrome", `${stem}-frames`) : path.join(base, "chrome", `${stem}.${format}`); const keyFile = path.join(target, ".key"); if (keyed && sequence) { const onDisk = await readFile(keyFile, "utf8").then((s) => s.trim(), () => null); if (onDisk === key && (await framesOnDisk(target)) === frames) { return { ...result, frames: target, cached: true }; } // Stale frames past the new count would be overlaid as the tail of the cut. await rm(target, { recursive: true, force: true }); } const args = [ ...hf.args, "render", "--format", format, "--quality", quality, "--fps", String(rate), ...(workers != null ? ["-w", String(workers)] : []), // The deck is flat colour and text: software GL is deterministic and the // GPU probe is a second per worker for nothing. ...(keyed ? ["--no-browser-gpu"] : []), "--output", target, projDir, ]; await runRenderer(hf.cmd, args); if (keyed && sequence) { const got = await framesOnDisk(target); if (got !== frames) { throw new Error(`the ${region} render wrote ${got} frames to ${target}, expected ${frames} (${size.duration}s at ${rate} fps)`); } await writeFile(keyFile, key + "\n", "utf8"); } return { ...result, frames: target }; } if (import.meta.url === `file://${process.argv[1]}`) { const argv = process.argv.slice(2); const flag = (n) => { const i = argv.indexOf(n); return i < 0 ? null : argv[i + 1]; }; const VALUED = new Set([ "--out", "--region", "--duration", "--variant", "--from", "--segment", "--still", "--png", "--workers", "--quality", "--format", "--fps", ]); const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); if (!manifestPath) { console.error( "usage: compose-chrome.mjs [--region chart|deck|feed|stamp|threads|flips|posts|teaser] [--variant sourced|full]\n" + " [--segment ] (posts: the clip whose window to compose; teaser: its entry)\n" + " [--from ] [--duration ] [--out ] [--preview]\n" + " [--still --png ]\n" + " [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]", ); process.exit(2); } const num = (n) => (flag(n) == null ? null : Number(flag(n))); const region = flag("--region") ?? "chart"; const started = Date.now(); const r = await composeChrome({ manifestPath, outDir: flag("--out"), region, variant: flag("--variant") ?? "sourced", preview: argv.includes("--preview"), duration: num("--duration"), from: num("--from") ?? 0, segment: flag("--segment"), 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" || region === "flips" ? 2 : null), quality: flag("--quality") ?? "high", format: flag("--format") ?? "png-sequence", fps: num("--fps"), doRender: argv.includes("--render"), }); const secs = ((Date.now() - started) / 1000).toFixed(1); if (r.still) console.log(`still -> ${r.still} (${secs}s)`); else if (r.frames) console.log(`frames -> ${r.frames}${r.cached ? " (cached)" : ""} (${r.frameCount ?? "?"} frames, ${secs}s)`); else console.log(`project -> ${r.projDir}`); if (r.key) console.log(`key ${r.key}`); }