commit 72f1ead91254f8ae450860abd4b3314396d8cc84
parent d74ece99182cd36ec5243d0634c5870260a03df4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 02:40:15 -0400
umtool: report-to-video's opt-in brand preset, render.brand "archilyzer-media" — owns the palette and faces (Archivo 118 / Plex Sans / Plex Mono via FONTCONFIG_FILE on its own children), adds the title lockup + found line, the mark leading the clip header, a 20 s end card with the right half clear (appended in selectVariant) and a --thumbnail step; unbranded manifests render byte-identical (334 files, before/after)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 949 insertions(+), 55 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -11,6 +11,7 @@ Two scripts and a manifest:
| `build-video.mjs` | stable | manifest → mp4. Fetches clips, snaps cuts to silence, letterboxes them into the chrome, crossfades. |
| `render-cards.mjs` | stable | draws the timeline footer and marker, plus optional card stills. Imported by `build-video.mjs`. |
| `resolve-windows.mjs` | stable | widens clip windows from cue spans to whole sentences. Run once after authoring a manifest. |
+| `brand.mjs`, `brand-cards.mjs` | stable | the opt-in brand preset (`render.brand`); see [its section](#the-archilyzer-media-preset-renderbrand). |
| `<report>/video.manifest.json` | per report | the edit decision list. **This is the regeneration source of truth**, not the report. |
```
@@ -796,6 +797,78 @@ total's `#EDF0EC` fails the categorical lightness and chroma checks *by design*
it is an aggregate, not a categorical peer, so it is encoded by weight and
consumes no palette slot.
+## The Archilyzer Media preset (`render.brand`)
+
+The channel's own videos wear its brand; a report cut for anybody else does not.
+So the brand is **one opt-in key**, and a manifest without it renders exactly as
+it did — the before/after card diff in `plans/brand-and-themes.md` ("Slice S4, as
+shipped") is the proof, and `brand.test.mjs` pins the unbranded header and
+`selectVariant`'s unbranded result.
+
+```jsonc
+"render": {
+ "brand": "archilyzer-media",
+ "endCard": { "seconds": 20, "url": "archilyzer.pages.dev" }, // optional; false = none
+ ...
+},
+"thumbnail": { "headline": "The county said yes", "clip": "c04", "at": 32990.5 } // optional
+```
+
+`umtool new <slug> --brand archilyzer-media`, or the brand choice in umtool's
+"new project" form, writes the key for you.
+
+What the preset **owns**, whatever the manifest says:
+
+- `render.palette` — the slate of the parent mark lit in Signal
+ (`#151b20` / `#e7edf1` / `#8496a2` / `#5fa8a0`, amber `#e3b15c`);
+- the faces — Archivo at wdth 118 for display, IBM Plex Sans for body, IBM Plex
+ Mono for the header and meta lines; `render.fontRegular` becomes the vendored
+ Plex Mono. (`fontBold` is left alone: only compose-chrome's HyperFrames band
+ reads it.)
+
+What it **adds**:
+
+| | what | where |
+|---|---|---|
+| title card | `style: "title"` becomes the lockup top-left, the title in Archivo 720, the found line, a mono meta line (`meta` or `foot`; `sub` sits above the found line) | `brand-cards.mjs` |
+| clip header | the mark leads the `Channel · title · date @ time` line in place of the accent tick; the channel is drawn in the foreground colour | `headerFilters` in `build-video.mjs` |
+| end card | appended by `selectVariant` (id `end`, `hideRail`), 20 s by default: the lockup and the site on the left, the **right half bare** for YouTube's end-screen videos | `brand.mjs`, `brand-cards.mjs` |
+| thumbnail | 1280 × 720: a still from a cached clip window (or `thumbnail.still`), the headline in Archivo 800, the mark top-left, the duration-badge corner clear. Made after a full build when `thumbnail` is set, or alone with `--thumbnail` | `out/<slug>.thumbnail.png` |
+
+The other card styles (`chapter`, `bullets`, `sources`, `timeline`) keep their
+layouts and take the preset's faces and palette. **Not branded yet:** the rail,
+`ledger`, `scroll` and `chart` still set their SVG text in Fira Sans — their
+truncation (`fit()`) is measured against Fira's average advance, and moving the
+face without re-measuring would overrun columns.
+
+**The drawings are not drawn here.** The mark and the lockup (its letters as
+outlines, so no font is needed to draw it) are `common/lib/brandMedia.ts`'s,
+exported by `common/bin/brand-media.ts --video-kit` into
+`brands/archilyzer-media.json`, because these scripts run under plain `node`.
+A common test fails when that file is stale; re-run the CLI, never edit it.
+
+**Fonts are vendored, not installed.** `fonts/` holds Archivo[wdth,wght], IBM Plex
+Sans[wdth,wght] and IBM Plex Mono Regular — unmodified from google/fonts
+(`ofl/archivo` at `95f4904f`, `ofl/ibmplexsans` / `ofl/ibmplexmono` at
+`0b58fb37`), about 1.3 MB — with `OFL.txt` (Plex carries a Reserved Font Name, so
+it may not be shipped modified, subset included) and a `fonts.conf`. The preset
+sets `FONTCONFIG_FILE` to that conf on **its own** `magick` and `rsvg-convert`
+children only, so Pango sees the variable faces (`Archivo @wght=720,wdth=118`);
+drawtext takes the mono face as a `fontfile=` path, which is why it is a static.
+If a renderer ignores `FONTCONFIG_FILE`, `sh fonts/install-user-fonts.sh` puts
+the faces in `~/.local/share/fonts` with no sudo.
+
+Two measurements the code depends on:
+
+- **ImageMagick's pango coder lays out at 96 dpi** (7.1.2 here), so a Pango `size` of N pt
+ draws N × 4/3 px, and a `-size W` wrap width is honoured in px only at that
+ default density (`-density 72` makes 1 pt = 1 px but wraps at ¾ of W, and
+ `-define pango:width` is ignored). `brand-cards.mjs` sizes in px through `pt()`.
+- **ImageMagick stamps the wall clock into every PNG** (`tEXt date:create`,
+ `date:modify`, `date:timestamp`) and ignores `SOURCE_DATE_EPOCH`, so two runs
+ of the same code never produce the same PNG bytes. A byte-identity check has to
+ drop those three chunks and compare the rest — IHDR and IDAT included.
+
## Things that cost time to find out
**yt-dlp picks VP9 at `height<=720`, and that is a trap.** `--download-sections`
diff --git a/umtool/report-to-video/brand-cards.mjs b/umtool/report-to-video/brand-cards.mjs
@@ -0,0 +1,287 @@
+// brand-cards.mjs — the stills a brand preset adds: the title card, the end card,
+// the mark in the clip header, and the thumbnail.
+//
+// Only a manifest with `render.brand` ever reaches this file (render-cards.mjs
+// and build-video.mjs branch on the brand first); see brand.mjs for the rules.
+//
+// Layouts are the approved canvas's "in the video" board (792 px for 1920), at
+// ×2.424 — or ×1.616 for the 1280 × 720 thumbnail. The lockup and the mark are
+// the kit's SVGs (brands/<id>.json, drawn by common/lib/brandMedia.ts) rasterised
+// with rsvg-convert, which is deterministic about output size; text is Pango
+// through ImageMagick, with FONTCONFIG_FILE pointed at the vendored faces.
+//
+// Pango sizes: ImageMagick's pango coder lays out at 96 dpi, so a `size` of
+// N pt draws N × 4/3 px. `pt(px)` converts, and every size below is in px.
+
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { access, mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { brandFaces, brandKit, childOpts } from "./brand.mjs";
+
+const execFileP = promisify(execFile);
+const RSVG = process.env.RSVG_BIN ?? "rsvg-convert";
+const exists = (p) => access(p).then(() => true, () => false);
+
+const pt = (px) => px * 0.75;
+
+function esc(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function pango(text, { px, color, face, lineHeight }) {
+ const a = [`font_desc="${face}"`, `size="${Math.round(pt(px) * 1024)}"`, `foreground="${color}"`];
+ if (lineHeight) a.push(`line_height="${lineHeight}"`);
+ return `<span ${a.join(" ")}>${esc(text)}</span>`;
+}
+
+/** Pango markup -> a transparent PNG, wrapped at `width` px when given. */
+async function textPng(render, markup, file, width) {
+ const markupPath = file.replace(/\.png$/, ".pango");
+ await writeFile(markupPath, markup, "utf8");
+ const args = ["-background", "none"];
+ if (width) args.push("-size", `${Math.round(width)}x`, "-define", "pango:wrap=word");
+ args.push(`pango:@${markupPath}`, file);
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
+ // %@ is the ink's bounding box: with a fixed wrap width the canvas is always
+ // that wide, and only the ink says whether a word overflowed it.
+ const { stdout } = await execFileP("magick", [file, "-format", "%w %h %@", "info:"]);
+ const [w, h, box] = stdout.trim().split(" ");
+ const m = /^(\d+)x(\d+)\+(-?\d+)\+(-?\d+)$/.exec(box ?? "");
+ const inkRight = m ? Number(m[1]) + Number(m[3]) : Number(w);
+ return { file, width: Number(w), height: Number(h), inkRight };
+}
+
+async function svgPng(render, svg, svgPath, pngPath, zoom) {
+ await writeFile(svgPath, svg, "utf8");
+ await execFileP(RSVG, ["-z", String(zoom), "-o", pngPath, svgPath], childOpts(render, { maxBuffer: 1 << 24 }));
+ return pngPath;
+}
+
+/**
+ * The lockup at a wordmark size of `px`. Returns where its design box (mark top
+ * to MEDIA baseline) sits inside the PNG, so a caller places the BOX, not the
+ * ascenders.
+ */
+export async function lockupPng(render, px, dir) {
+ const kit = brandKit(render.brand);
+ const zoom = px / kit.lockup.px;
+ const file = path.join(dir, `_lockup-${px}.png`);
+ await svgPng(render, kit.lockup.svg, path.join(dir, `_lockup-${px}.svg`), file, zoom);
+ return {
+ file,
+ blockTop: Math.round(kit.lockup.blockTop * zoom),
+ blockHeight: kit.lockup.blockHeight * zoom,
+ width: kit.lockup.width * zoom,
+ };
+}
+
+/** Mark M2 (`any`: rounded square) as a `size` px PNG, once per directory. */
+export async function markPng(render, size, dir) {
+ const file = path.join(dir, `_mark-${size}.png`);
+ if (!(await exists(file))) {
+ await mkdir(dir, { recursive: true });
+ await svgPng(render, brandKit(render.brand).mark.any, path.join(dir, `_mark-${size}.svg`), file, size / 512);
+ }
+ return file;
+}
+
+/**
+ * The found line on its own — the mark's second line, play head and lit bar —
+ * as ImageMagick draw primitives: `w` px long, the bar `bar` px tall, at (x, y).
+ */
+export function foundLineDraw(x, y, w, bar, color) {
+ const k = bar / 44;
+ const h = 48 * k;
+ const f = (n) => n.toFixed(1);
+ const bx = x + 76 * k;
+ const by = y + 2 * k;
+ return [
+ "-fill", color, "-stroke", "none",
+ "-draw", `polygon ${f(x)},${f(y)} ${f(x + 60 * k)},${f(y + 24 * k)} ${f(x)},${f(y + h)}`,
+ "-draw", `roundrectangle ${f(bx)},${f(by)} ${f(x + w - 1)},${f(by + bar - 1)} ${f(bar / 2)},${f(bar / 2)}`,
+ ];
+}
+
+const S = 1920 / 792; // the board's frame to 1080p
+
+/**
+ * `style: "title"` under a brand: the lockup top-left; the title in Archivo 118
+ * 720; the found line under it; a mono meta line. `sub`, when a card has one,
+ * sits between the title and the found line in the body face.
+ */
+async function renderTitle(card, render, outDir, VW) {
+ const pal = render.palette;
+ const faces = brandFaces(render);
+ const { width, height } = render;
+ const dir = path.join(outDir, "cards");
+ const k = width / 1920;
+ const margin = Math.round(40 * S * k);
+ const textW = VW - 2 * margin;
+
+ const lock = await lockupPng(render, Math.round(18 * S * k), dir);
+ const title = await textPng(
+ render,
+ pango(card.heading ?? card.id, { px: 40 * S * k, color: pal.fg, face: faces.display, lineHeight: 1.08 }),
+ path.join(dir, `${card.id}.title.png`),
+ textW,
+ );
+ const gap = Math.round(18 * S * k);
+ let y = Math.round(170 * S * k);
+
+ const args = ["-size", `${width}x${height}`, `xc:${pal.bg}`];
+ args.push(lock.file, "-geometry", `+${margin}+${Math.round(34 * S * k) - lock.blockTop}`, "-composite");
+ args.push(title.file, "-geometry", `+${margin}+${y}`, "-composite");
+ y += title.height + gap;
+ if (card.sub) {
+ const sub = await textPng(
+ render,
+ pango(card.sub, { px: 18 * S * k, color: pal.muted, face: faces.body }),
+ path.join(dir, `${card.id}.sub.png`),
+ textW,
+ );
+ args.push(sub.file, "-geometry", `+${margin}+${y}`, "-composite");
+ y += sub.height + gap;
+ }
+ const bar = 10 * S * k;
+ args.push(...foundLineDraw(margin, y, 210 * S * k, bar, pal.accent));
+ y += Math.round(48 * (bar / 44)) + gap;
+ const metaText = card.meta ?? card.foot;
+ if (metaText) {
+ const meta = await textPng(
+ render,
+ pango(metaText, { px: 14 * S * k, color: pal.muted, face: faces.label }),
+ path.join(dir, `${card.id}.meta.png`),
+ textW,
+ );
+ args.push(meta.file, "-geometry", `+${margin}+${y}`, "-composite");
+ }
+ const out = path.join(dir, `${card.id}.png`);
+ args.push(out);
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
+ return out;
+}
+
+/**
+ * `style: "end"`: the lockup and the site on the left, vertically centred with
+ * room below them for YouTube's subscribe element; the RIGHT HALF IS EMPTY
+ * GROUND, because YouTube draws its end-screen videos there. Nothing is drawn
+ * in the slots themselves -- they are YouTube's.
+ */
+async function renderEnd(card, render, outDir) {
+ const pal = render.palette;
+ const faces = brandFaces(render);
+ const { width, height } = render;
+ const dir = path.join(outDir, "cards");
+ const k = width / 1920;
+ const left = Math.round(48 * S * k);
+ const gap = Math.round(18 * S * k);
+ const subscribe = Math.round(96 * S * k);
+
+ const lock = await lockupPng(render, Math.round(28 * S * k), dir);
+ const url = await textPng(
+ render,
+ pango(card.url ?? render.endCard?.url ?? "", { px: 13 * S * k, color: pal.muted, face: faces.label }),
+ path.join(dir, `${card.id}.url.png`),
+ );
+ if (left + Math.max(lock.width, url.inkRight) > width / 2) {
+ throw new Error(`${card.id}: the end card's left column runs into the right half, which is YouTube's`);
+ }
+ const column = lock.blockHeight + gap + url.height + gap + subscribe;
+ let y = Math.round((height - column) / 2);
+ const args = ["-size", `${width}x${height}`, `xc:${pal.bg}`];
+ args.push(lock.file, "-geometry", `+${left}+${y - lock.blockTop}`, "-composite");
+ y += Math.round(lock.blockHeight) + gap;
+ args.push(url.file, "-geometry", `+${left}+${y}`, "-composite");
+ const out = path.join(dir, `${card.id}.png`);
+ args.push(out);
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
+ return out;
+}
+
+/** The styles a brand draws itself; everything else is render-cards.mjs's, in the brand's faces. */
+export const BRAND_CARD_STYLES = ["title", "end"];
+
+export async function renderBrandCard(card, render, outDir, VW) {
+ await mkdir(path.join(outDir, "cards"), { recursive: true });
+ if (card.style === "title") return renderTitle(card, render, outDir, VW);
+ if (card.style === "end") return renderEnd(card, render, outDir);
+ throw new Error(`renderBrandCard: ${card.style} is not a brand card style`);
+}
+
+// ---- the thumbnail ---------------------------------------------------------
+
+export const THUMB = { width: 1280, height: 720 };
+// YouTube's own limit on a custom thumbnail.
+export const THUMB_MAX_BYTES = 2 * 1024 * 1024;
+
+/**
+ * 1280 × 720: the still on the left 440/792 of the frame, cover-cropped; the
+ * headline in Archivo 118 800 on the ground to its right with the found line
+ * under it; the mark top-left over the still. The bottom-right corner is kept
+ * clear for YouTube's duration badge: the headline shrinks until its block ends
+ * above it, and refuses (rather than overprint the badge) below 44 px.
+ */
+export async function renderThumbnail({ render, still, headline, out, workDir }) {
+ const pal = render.palette;
+ const faces = brandFaces(render);
+ const T = 1280 / 792;
+ const W = THUMB.width;
+ const H = THUMB.height;
+ const split = Math.round(440 * T);
+ const padX = Math.round(32 * T);
+ const padY = Math.round(40 * T);
+ const textW = W - split - 2 * padX;
+ // YouTube's duration badge: ~64 x 26 at 12 px from the corner, on the board.
+ const badgeTop = H - Math.round((12 + 26) * T);
+ const gap = Math.round(20 * T);
+ const bar = 12 * T;
+ const foundH = Math.round(48 * (bar / 44));
+ await mkdir(workDir, { recursive: true });
+
+ const photo = path.join(workDir, "_thumb-still.png");
+ await execFileP("magick", [
+ still, "-resize", `${split}x${H}^`, "-gravity", "center", "-extent", `${split}x${H}`, "+repage", photo,
+ ], { maxBuffer: 1 << 24 });
+
+ let px = 48 * T;
+ let head;
+ for (;;) {
+ head = await textPng(
+ render,
+ pango(headline, { px, color: pal.fg, face: faces.headline, lineHeight: 1.02 }),
+ path.join(workDir, "_thumb-headline.png"),
+ textW,
+ );
+ const block = head.height + gap + foundH;
+ const top = Math.round((H - block) / 2);
+ // A word wider than the column overflows the wrap; a block taller than the
+ // clear space runs into the badge. Either way, smaller.
+ const fits = head.inkRight < textW - 1 && top >= padY && top + block <= badgeTop - gap;
+ if (fits) break;
+ px *= 0.92;
+ if (px < 44) {
+ throw new Error(`thumbnail headline "${headline}" does not fit at 44 px — shorten it`);
+ }
+ }
+ const block = head.height + gap + foundH;
+ const top = Math.round((H - block) / 2);
+ const markSize = Math.round(44 * T);
+ const mark = await markPng(render, markSize, workDir);
+
+ const args = [
+ "-size", `${W}x${H}`, `xc:${pal.bg}`,
+ photo, "-geometry", "+0+0", "-composite",
+ head.file, "-geometry", `+${split + padX}+${top}`, "-composite",
+ ...foundLineDraw(split + padX, top + head.height + gap, 200 * T, bar, pal.accent),
+ mark, "-geometry", `+${Math.round(18 * T)}+${Math.round(18 * T)}`, "-composite",
+ out,
+ ];
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
+ return { out, headlinePx: Math.round(px) };
+}
diff --git a/umtool/report-to-video/brand-ids.mjs b/umtool/report-to-video/brand-ids.mjs
@@ -0,0 +1,9 @@
+// The brand presets report-to-video knows, and their labels -- and nothing else.
+//
+// Its own module, with no imports at all, because umtool's project registry
+// (lib/projects/kinds.mjs) is read by CLIENT components too and must not pull
+// `node:fs` in through brand.mjs. brand.mjs re-exports both.
+export const BRAND_IDS = ["archilyzer-media"];
+
+/** The presets as umtool's "new project" form offers them. */
+export const BRAND_CHOICES = [{ id: "archilyzer-media", label: "Archilyzer Media" }];
diff --git a/umtool/report-to-video/brand.mjs b/umtool/report-to-video/brand.mjs
@@ -0,0 +1,194 @@
+// brand.mjs — opt-in brand presets for a report-to-video manifest.
+//
+// A manifest opts in with ONE key:
+//
+// "render": { "brand": "archilyzer-media", ... }
+//
+// and everything else about it stays the manifest's own. The preset owns two
+// things and adds four:
+//
+// OWNS the palette (render.palette) and the faces: Archivo at wdth 118 for
+// display, IBM Plex Sans for body, IBM Plex Mono for the header and
+// meta lines. A manifest that opts in and also sets `palette` or
+// `fontRegular` gets the preset's -- the brand is the point.
+// ADDS the title card's lockup and found line, the mark leading the clip
+// header, an end card, and a thumbnail step.
+//
+// A MANIFEST THAT DOES NOT OPT IN IS UNTOUCHED, BYTE FOR BYTE. Every function
+// here returns its input as it came when `render.brand` is absent, and the
+// render paths branch on the brand before they build a single argument. The
+// live umtool spawns these scripts from disk, so a drift here would reach the
+// operator's next render of an unrelated cut.
+//
+// The brand's drawings (palette, mark, lockup) are NOT drawn here: they are
+// common/lib/brandMedia.ts's, exported as JSON by
+// `common/bin/brand-media.ts --video-kit` into brands/<id>.json, because these
+// scripts run under plain `node` and cannot import TypeScript. A common test
+// fails when that file is stale.
+//
+// The faces are vendored in ./fonts (OFL). Pango and rsvg-convert find them
+// through FONTCONFIG_FILE=fonts/fonts.conf, set on the preset's own `magick` /
+// `rsvg-convert` children only; ffmpeg's drawtext takes the mono face as a
+// `fontfile=` path.
+
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+
+import { BRAND_CHOICES, BRAND_IDS } from "./brand-ids.mjs";
+
+export { BRAND_CHOICES, BRAND_IDS };
+export const FONTS_DIR = path.join(HERE, "fonts");
+export const FONTCONFIG_FILE = path.join(FONTS_DIR, "fonts.conf");
+export const MONO_FONT_FILE = path.join(FONTS_DIR, "IBMPlexMono-Regular.ttf");
+
+/** The end card's default length: YouTube's end screen runs in the last 5–20 s. */
+export const END_CARD_DEFAULT_SECONDS = 20;
+/** What the end card names, beside the lockup. */
+export const END_CARD_DEFAULT_URL = "archilyzer.pages.dev";
+
+// Pango font descriptions, variations included (Pango >= 1.42 reads `@axis=v`).
+// `display` is the title face; the rest set the existing card styles.
+export const FACES = {
+ "archilyzer-media": {
+ display: "Archivo @wght=720,wdth=118",
+ headline: "Archivo @wght=800,wdth=118",
+ body: "IBM Plex Sans",
+ label: "IBM Plex Mono",
+ },
+};
+
+const kits = new Map();
+
+/** brands/<id>.json: the palette, mark and lockup common/lib/brandMedia.ts draws. */
+export function brandKit(id) {
+ if (!BRAND_IDS.includes(id)) {
+ throw new Error(`render.brand "${id}" is not a preset — one of ${BRAND_IDS.join(", ")}`);
+ }
+ if (!kits.has(id)) {
+ kits.set(id, JSON.parse(readFileSync(path.join(HERE, "brands", `${id}.json`), "utf8")));
+ }
+ return kits.get(id);
+}
+
+/**
+ * `render.endCard` as the preset reads it: `false` turns the card off, a number
+ * or `{ seconds, url }` configures it, absent is the default 20 s.
+ * @returns {{ seconds: number, url: string } | null}
+ */
+export function endCardConfig(endCard) {
+ if (endCard === false) return null;
+ const o = typeof endCard === "number" ? { seconds: endCard } : (endCard ?? {});
+ const seconds = o.seconds ?? END_CARD_DEFAULT_SECONDS;
+ if (!Number.isFinite(seconds) || seconds <= 0) {
+ throw new Error(`render.endCard.seconds must be a positive number, got ${JSON.stringify(o.seconds)}`);
+ }
+ return { seconds, url: o.url ?? END_CARD_DEFAULT_URL };
+}
+
+/**
+ * The render block a manifest builds with. No `brand`: the SAME object, so a
+ * manifest that does not opt in cannot be touched by anything downstream.
+ */
+export function resolveBrandRender(render) {
+ if (!render?.brand) return render;
+ const kit = brandKit(render.brand);
+ return {
+ ...render,
+ palette: { ...kit.palette },
+ // drawtext's face: the header and the image header read `fontRegular`.
+ fontRegular: MONO_FONT_FILE,
+ endCard: endCardConfig(render.endCard),
+ };
+}
+
+/** The id of the end card the preset appends. */
+export const END_CARD_ID = "end";
+
+/**
+ * The manifest as a branded build sees it: the render resolved and, unless the
+ * timeline already ends itself with a `style: "end"` card or `render.endCard`
+ * is `false`, the end card appended. `hideRail`, because the right half of the
+ * frame belongs to YouTube's end-screen elements.
+ *
+ * Called at the end of `selectVariant`, which every reader of a cut goes
+ * through (the build, verify-build, compose-chrome, umtool's export), so all of
+ * them agree the end card is there. No `brand`: the manifest itself.
+ */
+export function brandManifest(manifest) {
+ if (!manifest?.render?.brand) return manifest;
+ const render = resolveBrandRender(manifest.render);
+ const timeline = manifest.timeline ?? [];
+ const hasEnd = timeline.some((e) => e.type === "card" && e.style === "end");
+ if (!render.endCard || hasEnd) return { ...manifest, render };
+ if (timeline.some((e) => e.id === END_CARD_ID)) {
+ throw new Error(
+ `render.brand appends an end card with id "${END_CARD_ID}", and the timeline already has an entry with that id — ` +
+ `rename it, add your own { "type": "card", "style": "end" }, or set render.endCard to false`,
+ );
+ }
+ return {
+ ...manifest,
+ render,
+ timeline: [
+ ...timeline,
+ {
+ type: "card",
+ id: END_CARD_ID,
+ style: "end",
+ seconds: render.endCard.seconds,
+ hideRail: true,
+ chapter: "End",
+ },
+ ],
+ };
+}
+
+/** Pango faces for a card, or null for a manifest that does not opt in. */
+export function brandFaces(render) {
+ return render?.brand ? FACES[render.brand] : null;
+}
+
+/**
+ * Options for a `magick` / `rsvg-convert` child: `opts` itself when there is no
+ * brand (the unbranded call is the call it always was), else the same with
+ * FONTCONFIG_FILE pointed at the vendored faces.
+ */
+export function childOpts(render, opts) {
+ if (!render?.brand) return opts;
+ return { ...opts, env: { ...process.env, FONTCONFIG_FILE } };
+}
+
+// IBM Plex Mono's cap height, and the top of its ascenders (l, h, d), in em.
+const MONO_CAP = 0.698;
+const MONO_ASCENDER = 0.74;
+
+/**
+ * The clip/image header's geometry under a brand: the mark leads the line
+ * where the unbranded header draws its accent tick (x 90), and the text
+ * follows it in the mono face.
+ *
+ * The mark's box is 0.6 of the header: its ground is the header's own colour,
+ * so what reads is the four lines, about 1.4 x the text's cap height -- the
+ * board's proportion. drawtext's `y` is the top of the tallest glyph drawn
+ * (y_align "text", the only mode older ffmpegs have); a citation line always
+ * carries an ascender, so y is set to put the CAPITALS' middle on the
+ * header's.
+ */
+export function brandHeaderGeometry(render) {
+ const HH = render.headerHeight ?? 56;
+ const mark = Math.round(HH * 0.6);
+ const x = 90;
+ const gap = Math.round(HH * 0.25);
+ const fontSize = Math.round(HH * 0.36);
+ return {
+ mark,
+ markX: x,
+ markY: Math.round((HH - mark) / 2),
+ textX: x + mark + gap,
+ fontSize,
+ textY: Math.round(HH / 2 + (MONO_CAP / 2 - MONO_ASCENDER) * fontSize),
+ };
+}
diff --git a/umtool/report-to-video/brand.test.mjs b/umtool/report-to-video/brand.test.mjs
@@ -0,0 +1,176 @@
+// Tests for the opt-in brand preset (brand.mjs) and the hooks it has in the
+// build: the config it resolves, and -- the hard rule -- that a manifest
+// WITHOUT `render.brand` comes through every hook exactly as it went in.
+//
+// The pixels themselves are proven by rendering (a guarded test below, and the
+// before/after card diff recorded in plans/brand-and-themes.md).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+
+import {
+ BRAND_IDS, END_CARD_DEFAULT_SECONDS, END_CARD_DEFAULT_URL, END_CARD_ID, FACES, FONTCONFIG_FILE,
+ FONTS_DIR, MONO_FONT_FILE, brandFaces, brandHeaderGeometry, brandKit, brandManifest, childOpts,
+ endCardConfig, resolveBrandRender,
+} from "./brand.mjs";
+import { headerFilters, selectVariant } from "./build-video.mjs";
+import { renderCard } from "./render-cards.mjs";
+import { renderThumbnail, THUMB } from "./brand-cards.mjs";
+
+const PLAIN_RENDER = {
+ width: 1920, height: 1080, fps: 30, headerHeight: 56,
+ fontRegular: "/usr/share/fonts/TTF/FiraSans-Regular.ttf",
+ palette: { bg: "#12100c", fg: "#f6f1e6", muted: "#a2957f", accent: "#c8752a", amber: "#ffc860" },
+};
+const manifest = (render, timeline = [{ type: "clip", id: "c01" }]) => ({
+ slug: "t", render, timeline, provenance: {},
+});
+
+test("no brand: every hook hands back the very object it was given", () => {
+ assert.equal(resolveBrandRender(PLAIN_RENDER), PLAIN_RENDER);
+ const m = manifest(PLAIN_RENDER);
+ assert.equal(brandManifest(m), m);
+ assert.equal(brandFaces(PLAIN_RENDER), null);
+ const opts = { maxBuffer: 1 };
+ assert.equal(childOpts(PLAIN_RENDER, opts), opts);
+ assert.equal(childOpts(PLAIN_RENDER, undefined), undefined);
+});
+
+test("no brand: selectVariant's result is the pre-preset shape, render untouched, no end card", () => {
+ const m = manifest(PLAIN_RENDER, [
+ { type: "card", id: "t00", style: "title" },
+ { type: "clip", id: "c01" },
+ { type: "card", id: "L1", variant: "full" },
+ ]);
+ const v = selectVariant(m, "sourced");
+ assert.equal(v.render, PLAIN_RENDER);
+ assert.deepEqual(v, { ...m, variant: "sourced", timeline: m.timeline.slice(0, 2), ledger: [] });
+});
+
+test("no brand: the header is the accent tick and one drawtext line, as it always was", () => {
+ assert.deepEqual(headerFilters(PLAIN_RENDER, "/o/c01.attrib.txt", "/o/c01.channel.txt"), [
+ "drawbox=x=90:y=16:w=4:h=24:color=#c8752a:t=fill",
+ "drawtext=textfile='/o/c01.attrib.txt':fontfile='/usr/share/fonts/TTF/FiraSans-Regular.ttf'" +
+ ":fontsize=22:fontcolor=#a2957f:x=118:y=15",
+ ]);
+});
+
+test("the preset owns palette and face, and resolves the end card", () => {
+ const r = resolveBrandRender({ ...PLAIN_RENDER, brand: "archilyzer-media" });
+ assert.deepEqual(r.palette, {
+ bg: "#151b20", fg: "#e7edf1", muted: "#8496a2", accent: "#5fa8a0", amber: "#e3b15c", dim: "#3f4c56",
+ });
+ assert.equal(r.fontRegular, MONO_FONT_FILE);
+ assert.deepEqual(r.endCard, { seconds: END_CARD_DEFAULT_SECONDS, url: END_CARD_DEFAULT_URL });
+ // Everything else is still the manifest's.
+ assert.equal(r.headerHeight, 56);
+ assert.equal(r.fps, 30);
+ assert.throws(() => resolveBrandRender({ ...PLAIN_RENDER, brand: "acme" }), /not a preset/);
+ assert.deepEqual(BRAND_IDS, ["archilyzer-media"]);
+});
+
+test("endCardConfig: default 20 s, a number, an object, or false", () => {
+ assert.deepEqual(endCardConfig(undefined), { seconds: 20, url: "archilyzer.pages.dev" });
+ assert.deepEqual(endCardConfig(12), { seconds: 12, url: "archilyzer.pages.dev" });
+ assert.deepEqual(endCardConfig({ seconds: 8, url: "example.org" }), { seconds: 8, url: "example.org" });
+ assert.equal(endCardConfig(false), null);
+ assert.throws(() => endCardConfig({ seconds: 0 }), /positive/);
+});
+
+test("brandManifest appends one end card, clear of the rail, unless told otherwise", () => {
+ const branded = { ...PLAIN_RENDER, brand: "archilyzer-media" };
+ const m = brandManifest(manifest(branded));
+ assert.deepEqual(m.timeline.at(-1), {
+ type: "card", id: END_CARD_ID, style: "end", seconds: 20, hideRail: true, chapter: "End",
+ });
+ assert.equal(m.timeline.length, 2);
+ // Configured length.
+ assert.equal(brandManifest(manifest({ ...branded, endCard: { seconds: 15 } })).timeline.at(-1).seconds, 15);
+ // Off.
+ assert.equal(brandManifest(manifest({ ...branded, endCard: false })).timeline.length, 1);
+ // The author's own end card is not doubled.
+ const own = [{ type: "clip", id: "c01" }, { type: "card", id: "bye", style: "end", seconds: 10 }];
+ assert.deepEqual(brandManifest(manifest(branded, own)).timeline, own);
+ // An unrelated entry called "end" is a collision, said rather than overwritten.
+ assert.throws(() => brandManifest(manifest(branded, [{ type: "clip", id: "end" }])), /already has an entry/);
+ // Through selectVariant, which every reader of a cut uses.
+ assert.equal(selectVariant(manifest(branded), "full").timeline.at(-1).style, "end");
+});
+
+test("branded header: mono face, no tick, the channel drawn again in the foreground", () => {
+ const r = resolveBrandRender({ ...PLAIN_RENDER, brand: "archilyzer-media" });
+ const g = brandHeaderGeometry(r);
+ assert.deepEqual(g, { mark: 34, markX: 90, markY: 11, textX: 138, fontSize: 20, textY: 20 });
+ const f = headerFilters(r, "/o/a.txt", "/o/c.txt");
+ assert.equal(f.length, 2);
+ assert.ok(f.every((s) => s.startsWith("drawtext=")));
+ assert.match(f[0], /textfile='\/o\/a\.txt'.*fontcolor=#8496a2:x=138:y=20$/);
+ assert.match(f[1], /textfile='\/o\/c\.txt'.*fontcolor=#e7edf1:x=138:y=20$/);
+ assert.ok(f[0].includes(`fontfile='${MONO_FONT_FILE}'`));
+ assert.equal(headerFilters(r, "/o/a.txt").length, 1);
+});
+
+test("the vendored faces: every file the preset names is there, under the OFL", () => {
+ for (const f of ["Archivo[wdth,wght].ttf", "IBMPlexSans[wdth,wght].ttf", "IBMPlexMono-Regular.ttf", "OFL.txt", "fonts.conf"]) {
+ assert.ok(existsSync(path.join(FONTS_DIR, f)), f);
+ }
+ assert.equal(FONTCONFIG_FILE, path.join(FONTS_DIR, "fonts.conf"));
+ const ofl = readFileSync(path.join(FONTS_DIR, "OFL.txt"), "utf8");
+ assert.match(ofl, /The Archivo Project Authors/);
+ assert.match(ofl, /IBM Corp\. with Reserved Font Name "Plex"/);
+ assert.match(readFileSync(FONTCONFIG_FILE, "utf8"), /<dir prefix="relative">\.<\/dir>/);
+ const childEnv = childOpts({ brand: "archilyzer-media" }, { maxBuffer: 1 }).env;
+ assert.equal(childEnv.FONTCONFIG_FILE, FONTCONFIG_FILE);
+ // The faces the cards ask Pango for are the vendored families.
+ assert.match(FACES["archilyzer-media"].display, /^Archivo @wght=720,wdth=118$/);
+});
+
+test("the kit: the lockup and the mark are outlines, not text", () => {
+ const kit = brandKit("archilyzer-media");
+ assert.doesNotMatch(kit.lockup.svg, /<text/);
+ assert.match(kit.mark.any, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="0 0 512 512">/);
+ assert.ok(kit.lockup.blockTop > 0 && kit.lockup.blockHeight > 0);
+});
+
+// Rendering needs ImageMagick with Pango and rsvg-convert; a machine without
+// them skips rather than fails.
+const hasTools =
+ spawnSync("magick", ["-version"], { stdio: "ignore" }).status === 0 &&
+ spawnSync(process.env.RSVG_BIN ?? "rsvg-convert", ["--version"], { stdio: "ignore" }).status === 0;
+
+test("rendered: title and end cards at the frame size, the thumbnail at 1280 x 720", { skip: !hasTools && "magick / rsvg-convert not available" }, async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "rtv-brand-"));
+ try {
+ const render = resolveBrandRender({ ...PLAIN_RENDER, brand: "archilyzer-media" });
+ const size = (f) => execFileSync("magick", ["identify", "-format", "%w %h", f], { encoding: "utf8" });
+ const title = await renderCard(
+ { type: "card", id: "t00", style: "title", heading: "A title", foot: "Channel · 2025" }, render, dir, [],
+ );
+ assert.equal(size(title), "1920 1080");
+ const end = await renderCard({ type: "card", id: "end", style: "end", seconds: 20 }, render, dir, []);
+ assert.equal(size(end), "1920 1080");
+ // The right half of the end card is bare ground: YouTube's end screen.
+ const right = execFileSync(
+ "magick", [end, "-crop", "960x1080+960+0", "+repage", "-format", "%k", "info:"], { encoding: "utf8" },
+ );
+ assert.equal(right, "1");
+ const still = path.join(dir, "still.png");
+ execFileSync("magick", ["-size", "640x360", "xc:#446688", still]);
+ const { out } = await renderThumbnail({
+ render, still, headline: "The county said yes", out: path.join(dir, "thumb.png"), workDir: dir,
+ });
+ assert.equal(size(out), `${THUMB.width} ${THUMB.height}`);
+ // The duration badge's corner is clear: one colour, the ground.
+ const corner = execFileSync(
+ "magick", [out, "-crop", "110x50+1170+670", "+repage", "-format", "%k", "info:"], { encoding: "utf8" },
+ );
+ assert.equal(corner, "1");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -49,12 +49,13 @@
// --no-rail Skip the claim rail even when the manifest configures one
// --rail-only Re-run just the rail over out/<slug>.prerail.mp4
// --preview <s> <d> Rail-only, over a <d>-second window starting at <s>
+// --thumbnail A brand preset's thumbnail (manifest.thumbnail) and stop
//
// Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
-import { mkdir, writeFile, readFile, access, readdir, rename } from "node:fs/promises";
+import { mkdir, writeFile, readFile, access, readdir, rename, stat } from "node:fs/promises";
import path from "node:path";
import {
@@ -72,6 +73,11 @@ import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.
import {
attributionLine, channelName, hms, imageAttributionLine, uploadDateToIso,
} from "./attribution.mjs";
+// An opt-in brand preset (`render.brand`). Every hook below branches on the
+// brand BEFORE building an argument, so a manifest without one renders exactly
+// as it did -- see brand.mjs.
+import { brandHeaderGeometry, brandManifest } from "./brand.mjs";
+import { markPng, renderThumbnail, THUMB_MAX_BYTES } from "./brand-cards.mjs";
const execFileP = promisify(execFile);
@@ -142,7 +148,50 @@ export function selectVariant(manifest, variant = DEFAULT_VARIANT) {
});
const kept = new Set(timeline.map((e) => e.id));
const ledger = (manifest.ledger ?? []).filter((c) => c.entryId && kept.has(c.entryId));
- return { ...manifest, variant, timeline, ledger };
+ // A brand preset resolves here, at the one door every reader of a cut goes
+ // through, so the build, verify-build, compose-chrome and umtool's export
+ // all see the same render block and the same appended end card. No brand:
+ // the object as built above.
+ return brandManifest({ ...manifest, variant, timeline, ledger });
+}
+
+/**
+ * The citation header's filters, for a clip or a still.
+ *
+ * Unbranded: the accent tick and one drawtext line in `render.fontRegular`,
+ * exactly as they always were. Branded: no tick -- the mark leads the line, as
+ * an overlay the caller adds (`brandHeaderGeometry` says where) -- and the line
+ * in the preset's mono face; `channelPath`, when given, is drawn over the
+ * line's head in the foreground colour, the same glyphs at the same origin, so
+ * the channel reads brighter than the rest without measuring any text.
+ */
+export function headerFilters(render, attribPath, channelPath = null) {
+ const pal = render.palette;
+ const HH = render.headerHeight ?? 56;
+ if (!render.brand) {
+ return [
+ `drawbox=x=90:y=${Math.round((HH - 24) / 2)}:w=4:h=24:color=${pal.accent}:t=fill`,
+ [
+ `drawtext=textfile='${attribPath}'`,
+ `fontfile='${render.fontRegular}'`,
+ "fontsize=22",
+ `fontcolor=${pal.muted}`,
+ "x=118",
+ `y=${Math.round((HH - 26) / 2)}`,
+ ].join(":"),
+ ];
+ }
+ const g = brandHeaderGeometry(render);
+ const text = (file, color) =>
+ [
+ `drawtext=textfile='${file}'`,
+ `fontfile='${render.fontRegular}'`,
+ `fontsize=${g.fontSize}`,
+ `fontcolor=${color}`,
+ `x=${g.textX}`,
+ `y=${g.textY}`,
+ ].join(":");
+ return [text(attribPath, pal.muted), ...(channelPath ? [text(channelPath, pal.fg)] : [])];
}
/**
@@ -646,6 +695,10 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
// `channelTitle`, `date` (the stream's own YYYY-MM-DD) and `title` (a display
// title) to override the record's.
await writeFile(attribPath, attributionLine(entry, meta, provenance), "utf8");
+ // A brand draws the line's head -- the channel -- in the foreground colour.
+ const who = render.brand ? channelName(entry, meta, provenance) : "";
+ const channelPath = who ? path.join(outDir, "segments", `${entry.id}.channel.txt`) : null;
+ if (channelPath) await writeFile(channelPath, who, "utf8");
// The picture is the point. Nothing is drawn over it: the video is letterboxed
// between a thin citation header and a thin timeline footer, so the source
@@ -697,19 +750,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
`pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
"setsar=1",
`fps=${render.fps}`,
- ...(hasHeader
- ? [
- `drawbox=x=90:y=${Math.round((HH - 24) / 2)}:w=4:h=24:color=${pal.accent}:t=fill`,
- [
- `drawtext=textfile='${attribPath}'`,
- `fontfile='${render.fontRegular}'`,
- "fontsize=22",
- `fontcolor=${pal.muted}`,
- "x=118",
- `y=${Math.round((HH - 26) / 2)}`,
- ].join(":"),
- ]
- : []),
+ ...(hasHeader ? headerFilters(render, attribPath, channelPath) : []),
].join(",");
// A manifest with a RAIL draws the code in the rail's foot instead, as one
@@ -743,6 +784,14 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
);
}
if (qr) { qrIdx = nextIdx++; inputs.push("-i", qr.png); }
+ // The brand's mark, leading the header: the LAST input, so every index above
+ // is the one an unbranded build uses.
+ const brandMark = hasHeader && render.brand ? brandHeaderGeometry(render) : null;
+ let markIdx;
+ if (brandMark) {
+ markIdx = nextIdx++;
+ inputs.push("-i", await markPng(render, brandMark.mark, path.join(outDir, "cards")));
+ }
const parts = hasFooter
? [
@@ -754,11 +803,16 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes,
]
: [`[0:v]${base}[q]`];
+ let q = "q";
+ if (brandMark) {
+ parts.push(`[q][${markIdx}:v]overlay=x=${brandMark.markX}:y=${brandMark.markY}[qm]`);
+ q = "qm";
+ }
// Sit above the footer when there is one, so the code never straddles the chrome.
parts.push(
qr
- ? `[q][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
- : `[q]null[v]`,
+ ? `[${q}][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
+ : `[${q}]null[v]`,
);
await execFileP(
@@ -989,19 +1043,7 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
`pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
"setsar=1",
`fps=${render.fps}`,
- ...(hasHeader
- ? [
- `drawbox=x=90:y=${Math.round((HH - 24) / 2)}:w=4:h=24:color=${pal.accent}:t=fill`,
- [
- `drawtext=textfile='${attribPath}'`,
- `fontfile='${render.fontRegular}'`,
- "fontsize=22",
- `fontcolor=${pal.muted}`,
- "x=118",
- `y=${Math.round((HH - 26) / 2)}`,
- ].join(":"),
- ]
- : []),
+ ...(hasHeader ? headerFilters(render, attribPath) : []),
].join(",");
// `qrForEntry` already prefers `citeUrl`; the guard is that we never reach it
@@ -1017,10 +1059,18 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
const silenceIdx = resolved.length;
const qrIdx = silenceIdx + 1;
const parts = [...panelParts, `${source}${base}[q]`];
+ // The brand's mark leads the header, as on a clip; its input goes last.
+ const brandMark = hasHeader && render.brand ? brandHeaderGeometry(render) : null;
+ const markIdx = qr ? qrIdx + 1 : qrIdx;
+ let q = "q";
+ if (brandMark) {
+ parts.push(`[q][${markIdx}:v]overlay=x=${brandMark.markX}:y=${brandMark.markY}[qm]`);
+ q = "qm";
+ }
parts.push(
qr
- ? `[q][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
- : `[q]null[v]`,
+ ? `[${q}][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
+ : `[${q}]null[v]`,
);
try {
@@ -1038,6 +1088,7 @@ async function buildImageSegment(entry, render, outDir, chrome, provenance, base
"-f", "lavfi", "-t", secs,
"-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
...(qr ? ["-i", qr.png] : []),
+ ...(brandMark ? ["-i", await markPng(render, brandMark.mark, path.join(outDir, "cards"))] : []),
"-filter_complex", parts.join(";"),
"-map", "[v]", "-map", `${silenceIdx}:a`,
...encodeArgs(render),
@@ -2171,6 +2222,13 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: r.path, failures: [] };
}
+ // The thumbnail alone: a brand preset's still, from a cached window.
+ if (opts.thumbnailOnly) {
+ const r = await buildThumbnail(manifest, dirs, manifestDir);
+ EMIT("done", { out: r.out, failures: [] });
+ return { out: r.out, failures: [] };
+ }
+
// Footer chrome is shared by every clip, so build it once up front.
const hyper = render.chromeEngine === "hyperframes";
const chrome = hyper
@@ -2361,6 +2419,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps);
+ // A branded cut with a `thumbnail` gets one beside it. The cut is already
+ // done, so a thumbnail that cannot be made is said, not thrown.
+ if (render.brand && manifest.thumbnail) {
+ await buildThumbnail(manifest, dirs, manifestDir).catch((err) =>
+ EMIT("note", { message: `thumbnail not made: ${err?.message ?? err}` }),
+ );
+ }
+
const { stdout } = await execFileP(FFPROBE, [
"-v", "error", "-show_entries", "format=duration,size",
"-of", "default=noprint_wrappers=1", final,
@@ -2372,6 +2438,68 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: final, failures };
}
+// ---- the thumbnail (brand presets only) ----------------------------------
+// `manifest.thumbnail`:
+//
+// { "headline": "The county said yes", // required; a few words
+// "clip": "c04", // the clip to take the still from (default: the first)
+// "at": 32990.5, // SOURCE seconds, the clip's own clock (default: its cite)
+// "still": "stills/c04.png" } // OR a picture, relative to the manifest
+//
+// The frame comes out of the CACHED window (clips-raw), never the network: a
+// clip that has not been fetched says so. Written to out/<slug>.thumbnail.png
+// beside the cut, and as a .jpg too when the PNG is over YouTube's 2 MB.
+export function thumbnailPath(outRoot, slug) {
+ return path.join(outRoot, `${slug}.thumbnail.png`);
+}
+
+async function buildThumbnail(manifest, dirs, manifestDir) {
+ const { render } = manifest;
+ if (!render.brand) {
+ throw new Error("the thumbnail step belongs to a brand preset — set render.brand");
+ }
+ const t = manifest.thumbnail;
+ if (!t?.headline) throw new Error("manifest.thumbnail.headline is required for the thumbnail step");
+ const work = path.join(dirs.dir, "cards");
+ await mkdir(work, { recursive: true });
+ let still;
+ if (t.still) {
+ still = path.resolve(manifestDir, t.still);
+ if (!(await exists(still))) throw new Error(`thumbnail.still ${t.still} does not exist`);
+ } else {
+ const clip = t.clip
+ ? manifest.timeline.find((e) => e.id === t.clip)
+ : manifest.timeline.find((e) => e.type === "clip");
+ if (!clip || clip.type !== "clip") {
+ throw new Error(`thumbnail.clip ${t.clip ?? "(first clip)"} is not a clip in this cut`);
+ }
+ const at = Number(t.at ?? clip.cite ?? clip.start);
+ const win = await findContainingWindow(dirs.rawDir, clip.video, at, at);
+ if (!win) {
+ throw new Error(`no cached window of ${clip.video} holds ${at}s — fetch or build ${clip.id} first`);
+ }
+ still = path.join(work, "_thumb-frame.png");
+ await execFileP(FFMPEG, [
+ "-nostdin", "-v", "error", "-y",
+ "-ss", Math.max(0, at - win.from).toFixed(3), "-i", win.path,
+ "-frames:v", "1", still,
+ ]);
+ }
+ const out = thumbnailPath(dirs.root, manifest.slug);
+ const r = await renderThumbnail({ render, still, headline: t.headline, out, workDir: work });
+ const { size } = await stat(out);
+ let jpg = null;
+ if (size > THUMB_MAX_BYTES) {
+ jpg = out.replace(/\.png$/, ".jpg");
+ await execFileP("magick", [out, "-sampling-factor", "4:4:4", "-quality", "92", jpg]);
+ }
+ EMIT("note", {
+ message: `thumbnail -> ${out} (headline ${r.headlinePx}px, ${size} B)` +
+ (jpg ? `; over YouTube's 2 MB, so also ${jpg}` : ""),
+ });
+ return { out, jpg };
+}
+
async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
@@ -2382,7 +2510,8 @@ async function main() {
" [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" +
" [--progress ndjson] [--continue-on-error] [--no-reuse]\n" +
" [--no-rail] [--rail-only] [--preview <start> <dur>]\n" +
- " [--site-origin <url>] [--resolve-site-ids] [--cue-source auto|local|http]",
+ " [--site-origin <url>] [--resolve-site-ids] [--cue-source auto|local|http]\n" +
+ " [--thumbnail] (render.brand only: out/<slug>.thumbnail.png and stop)",
);
process.exit(2);
}
@@ -2411,6 +2540,7 @@ async function main() {
siteOrigin: flag("--site-origin"),
resolveSiteIds: argv.includes("--resolve-site-ids"),
cueSource: flag("--cue-source"),
+ thumbnailOnly: argv.includes("--thumbnail"),
};
const pv = argv.indexOf("--preview");
if (pv >= 0) {
diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json
@@ -16,6 +16,8 @@
},
"exports": {
"./attribution": "./attribution.mjs",
+ "./brand": "./brand.mjs",
+ "./brand-ids": "./brand-ids.mjs",
"./build-video": "./build-video.mjs",
"./check-availability": "./check-availability.mjs",
"./compose-chrome": "./compose-chrome.mjs",
diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs
@@ -31,6 +31,11 @@ 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, childOpts } from "./brand.mjs";
+import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs";
const execFileP = promisify(execFile);
@@ -66,29 +71,39 @@ function esc(s) {
.replace(/'/g, "'");
}
-function span(text, { size, color, weight, family = "Fira Sans" }) {
- const attrs = [`font_family="${family}"`, `size="${Math.round(size * 1024)}"`];
+// `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) attrs.push(`weight="${weight}"`);
+ if (weight && !(face && face.includes("@wght="))) attrs.push(`weight="${weight}"`);
return `<span ${attrs.join(" ")}>${text}</span>`;
}
+// 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) {
+function markupFor(card, pal, faces = null) {
const H = (t, size = 62) =>
- span(esc(t), { size, color: pal.fg, weight: "bold" });
+ 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" });
- const SUB = (t, size = 30) => span(esc(t), { size, color: pal.muted });
+ 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" }),
+ span(esc(card.heading), { size: 82, color: pal.fg, weight: "bold", ...faceOf(faces, "display") }),
"",
- span(esc(card.sub), { size: 38, color: pal.accent }),
+ span(esc(card.sub), { size: 38, color: pal.accent, ...faceOf(faces, "body") }),
"",
"",
SUB(card.foot, 24),
@@ -107,9 +122,9 @@ function markupFor(card, pal) {
case "bullets": {
const items = (card.bullets ?? []).flatMap((b) => [
- `${span("— ", { size: 30, color: pal.accent, weight: "bold" })}${span(
+ `${span("— ", { size: 30, color: pal.accent, weight: "bold", ...faceOf(faces, "body") })}${span(
esc(b),
- { size: 30, color: pal.fg },
+ { size: 30, color: pal.fg, ...faceOf(faces, "body") },
)}`,
"",
]);
@@ -146,6 +161,7 @@ function markupFor(card, pal) {
// 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");
@@ -186,12 +202,12 @@ async function renderTimelineCard(card, render, nodes, outDir) {
// Heading, centred over the whole card.
const headMarkup = [
span(esc((card.kicker ?? nodes[cur].label).toUpperCase()), {
- size: 26, color: pal.amber, weight: "bold",
+ size: 26, color: pal.amber, weight: "bold", ...faceOf(faces, "label"),
}),
"",
- span(esc(card.heading ?? nodes[cur].title), { size: 62, color: pal.fg, weight: "bold" }),
+ 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 }) : null,
+ card.sub ? span(esc(card.sub), { size: 30, color: pal.muted, ...faceOf(faces, "body") }) : null,
]
.filter((l) => l !== null)
.join("\n");
@@ -217,18 +233,19 @@ async function renderTimelineCard(card, render, nodes, outDir) {
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]);
+ 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, { maxBuffer: 1 << 24 });
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
return outPath;
}
@@ -240,6 +257,7 @@ async function renderTimelineCard(card, render, nodes, outDir) {
// 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");
@@ -286,8 +304,8 @@ export async function renderFooterAssets(render, nodes, outDir) {
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 }), "utf8");
- await execFileP("magick", ["-background", "none", `pango:@${pPath}`, pPng]);
+ 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,
@@ -297,7 +315,7 @@ export async function renderFooterAssets(render, nodes, outDir) {
}
}
args.push(footer);
- await execFileP("magick", args, { maxBuffer: 1 << 24 });
+ 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`
@@ -1442,6 +1460,9 @@ export async function renderChartCard(card, render, ledger, outDir) {
}
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);
@@ -1458,7 +1479,7 @@ async function renderPlainCard(card, render, outDir) {
// 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), "utf8");
+ 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
@@ -1490,7 +1511,7 @@ async function renderPlainCard(card, render, outDir) {
outPath,
];
- await execFileP("magick", args, { maxBuffer: 1 << 24 });
+ await execFileP("magick", args, childOpts(render, { maxBuffer: 1 << 24 }));
return outPath;
}
@@ -1506,7 +1527,9 @@ async function main() {
return i >= 0 ? argv[i + 1] : undefined;
};
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
+ // 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");