#!/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 `
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}`);
}