Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/report-to-video/README.md | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand-cards.mjs | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand-ids.mjs | 9+++++++++
Aumtool/report-to-video/brand.mjs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand.test.mjs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mumtool/report-to-video/package.json | 2++
Mumtool/report-to-video/render-cards.mjs | 67+++++++++++++++++++++++++++++++++++++++++++++----------------------
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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&apos;"); +} + +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, "&apos;"); } -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");