Archilyzer · Source

archilyzer

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

commit 62cb88b946f1fa076ca6b5e60fe17a0a16c34468
parent 8bea197de80e88b42f7df539f725166152788502
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 12:52:22 -0400

Merge brand/media — brand slice S4: Archilyzer Media — the lockup as glyph outlines (brandMedia), the YouTube channel assets CLI, umtool report-to-video's opt-in render.brand preset with vendored OFL fonts (non-opted manifests byte-identical)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/bin/brand-media.test.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/brand-media.ts | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/gen-media-glyphs.py | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/brandIcons.ts | 24++++++++++++++++--------
Acommon/lib/brandMedia.test.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandMedia.ts | 479+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandMediaGlyphs.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 3+++
Mplans/brand-and-themes.md | 206++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mplans/release-10.md | 2++
Mumtool/bin/umtool.mjs | 7+++++--
Mumtool/components/NewProjectMenu.tsx | 24+++++++++++++++++++++++-
Mumtool/e2e/projects.spec.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/kinds.mjs | 5++++-
Mumtool/lib/projects/scaffold.mjs | 21++++++++++++++++-----
Mumtool/lib/projects/scaffold.ts | 2++
Mumtool/lib/tools.mjs | 2+-
Mumtool/report-to-video/README.md | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand-cards.mjs | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand-ids.mjs | 9+++++++++
Aumtool/report-to-video/brand.mjs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brand.test.mjs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/brands/archilyzer-media.json | 23+++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Aumtool/report-to-video/fonts/Archivo[wdth,wght].ttf | 0
Aumtool/report-to-video/fonts/IBMPlexMono-Regular.ttf | 0
Aumtool/report-to-video/fonts/IBMPlexSans[wdth,wght].ttf | 0
Aumtool/report-to-video/fonts/OFL.txt | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/fonts/fonts.conf | 18++++++++++++++++++
Aumtool/report-to-video/fonts/install-user-fonts.sh | 14++++++++++++++
Mumtool/report-to-video/package.json | 2++
Mumtool/report-to-video/render-cards.mjs | 67+++++++++++++++++++++++++++++++++++++++++++++----------------------
32 files changed, 2856 insertions(+), 75 deletions(-)

diff --git a/common/bin/brand-media.test.ts b/common/bin/brand-media.test.ts @@ -0,0 +1,88 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { mediaBannerSvg, mediaLockupGeometry, mediaMarkSvg, MEDIA_BANNER, MEDIA_VIDEO_KIT_PX } from "../lib/brandMedia"; +import { BANNER_MAX_BYTES, MEDIA_ASSETS, VIDEO_KIT_PATH, videoKitJson, writeMediaAssets } from "./brand-media"; + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function ihdr(png: Buffer): { width: number; height: number; colourType: number } { + assert.deepEqual([...png.subarray(0, 8)], PNG_MAGIC, "PNG magic"); + assert.equal(png.toString("latin1", 12, 16), "IHDR"); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20), colourType: png[25] }; +} + +test("the asset list: YouTube's four sizes, each from its own SVG", () => { + assert.deepEqual( + MEDIA_ASSETS.map((a) => [a.png, a.svg, a.width, a.height]), + [ + ["avatar-800.png", "avatar.svg", 800, 800], + ["watermark-150.png", "watermark.svg", 150, 150], + ["banner-2560x1440.png", "banner.svg", 2560, 1440], + ["banner-safe-1546x423.png", "banner-safe.svg", 1546, 423], + ], + ); + const by = Object.fromEntries(MEDIA_ASSETS.map((a) => [a.png, a.render()])); + assert.equal(by["avatar-800.png"], mediaMarkSvg("maskable")); + assert.equal(by["watermark-150.png"], mediaMarkSvg("any")); + assert.equal(by["banner-2560x1440.png"], mediaBannerSvg()); + assert.equal(by["banner-safe-1546x423.png"], mediaBannerSvg({ view: MEDIA_BANNER.safe })); +}); + +test("writeMediaAssets: every PNG at its IHDR size, the banner under 6 MB, sources and INDEX.html beside them", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "brand-media-")); + try { + const rows = await writeMediaAssets(dir); + const names = (await readdir(dir)).sort(); + assert.deepEqual(names, [ + "INDEX.html", + "avatar-800.png", + "avatar.svg", + "banner-2560x1440.png", + "banner-safe-1546x423.png", + "banner-safe.svg", + "banner.svg", + "lockup.svg", + "watermark-150.png", + "watermark.svg", + ]); + for (const r of rows) { + const png = await readFile(path.join(dir, r.png)); + const h = ihdr(png); + assert.deepEqual([h.width, h.height], [r.width, r.height], r.png); + assert.equal(png.byteLength, r.bytes); + assert.equal(await readFile(path.join(dir, r.svg), "utf8"), r.render()); + } + const banner = await readFile(path.join(dir, "banner-2560x1440.png")); + assert.ok(banner.byteLength <= BANNER_MAX_BYTES, `${banner.byteLength} B`); + const html = await readFile(path.join(dir, "INDEX.html"), "utf8"); + for (const r of rows) assert.ok(html.includes(`src="${r.png}"`), r.png); + // Studio's current page: Customization -> Profile (Branding was folded into it). + for (const step of ["Customization", "<b>Profile</b>", "Picture", "Banner image", "Video watermark", "<b>Change</b>"]) { + assert.ok(html.includes(step), step); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// umtool's report-to-video preset cannot import TypeScript, so it reads this +// JSON. Stale means the preset draws a lockup the channel no longer uses. +test("umtool/report-to-video/brands/archilyzer-media.json is what --video-kit writes", () => { + assert.equal(readFileSync(VIDEO_KIT_PATH, "utf8"), videoKitJson()); + const kit = JSON.parse(videoKitJson()); + const g = mediaLockupGeometry(MEDIA_VIDEO_KIT_PX); + assert.equal(kit.lockup.px, MEDIA_VIDEO_KIT_PX); + assert.ok(Math.abs(kit.lockup.blockHeight - g.blockHeight) < 1e-3); + assert.deepEqual(kit.palette, { + bg: "#151b20", + fg: "#e7edf1", + muted: "#8496a2", + accent: "#5fa8a0", + amber: "#e3b15c", + dim: "#3f4c56", + }); +}); diff --git a/common/bin/brand-media.ts b/common/bin/brand-media.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env tsx +// The Archilyzer Media YouTube channel's assets, rendered from lib/brandMedia.ts. +// +// pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts [--out <dir>] +// pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts --video-kit +// +// Without --video-kit it writes, into --out (default ~/reports/archilyzer-media/brand/), +// each PNG beside the SVG it was rasterised from: +// +// avatar-800.png the channel picture: mark M2, maskable (YouTube crops a circle) +// watermark-150.png the Studio video watermark: mark M2, `any` (transparent corners) +// banner-2560x1440.png the channel banner, the approved canvas design +// banner-safe-1546x423.png the part of the banner every device shows +// lockup.svg the lockup on its own (no PNG: it is a source) +// INDEX.html the operator's handoff: previews, sizes, the Studio upload steps +// +// PNGs go through lib/brandIcons.ts renderSvgPng (next/og: satori + resvg), the same +// path the site icons take, so nothing here needs a system rasteriser or a font. +// +// --video-kit instead writes umtool/report-to-video/brands/archilyzer-media.json, the +// palette, mark and lockup the report-to-video preset draws with (its scripts are plain +// .mjs and cannot import this TypeScript). brandMedia.test.ts fails when it is stale. + +import { mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderSvgPng } from "../lib/brandIcons"; +import { + MEDIA_BANNER, + MEDIA_PALETTE, + mediaBannerSvg, + mediaLockupGeometry, + mediaLockupSvg, + mediaMarkSvg, + mediaVideoKit, +} from "../lib/brandMedia"; +import { PROJECT_URL } from "../lib/project"; +import { runIfEntryPoint } from "./_cli"; +import { parseFlags } from "./_parseFlags"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const VIDEO_KIT_PATH = path.join(REPO, "umtool/report-to-video/brands/archilyzer-media.json"); +export const DEFAULT_OUT = path.join(os.homedir(), "reports/archilyzer-media/brand"); + +// The kit as the file holds it: two-space JSON and a trailing newline. +export function videoKitJson(): string { + return JSON.stringify(mediaVideoKit(), null, 2) + "\n"; +} + +export type MediaAsset = { + png: string; + svg: string; + width: number; + height: number; + title: string; + studio: string; + note: string; + render: () => string; +}; + +const S = MEDIA_BANNER.safe; + +export const MEDIA_ASSETS: ReadonlyArray<MediaAsset> = [ + { + png: "avatar-800.png", + svg: "avatar.svg", + width: 800, + height: 800, + title: "Channel picture", + studio: "Picture", + note: "Mark M2 · Signal, maskable: the lines sit inside the circle YouTube crops to.", + render: () => mediaMarkSvg("maskable"), + }, + { + png: "watermark-150.png", + svg: "watermark.svg", + width: 150, + height: 150, + title: "Video watermark", + studio: "Video watermark", + note: "Mark M2 · Signal, rounded square; the corners are transparent.", + render: () => mediaMarkSvg("any"), + }, + { + png: "banner-2560x1440.png", + svg: "banner.svg", + width: MEDIA_BANNER.width, + height: MEDIA_BANNER.height, + title: "Banner image", + studio: "Banner image", + note: "The whole banner: a TV shows all of it, a desktop the full-width strip through the middle.", + render: () => mediaBannerSvg(), + }, + { + png: "banner-safe-1546x423.png", + svg: "banner-safe.svg", + width: S.width, + height: S.height, + title: "Banner, safe area", + studio: "(not uploaded)", + note: "What every device shows, phones included. A preview of the crop, not a separate upload.", + render: () => mediaBannerSvg({ view: S }), + }, +]; + +// The lockup at the head of INDEX.html (and lockup.svg), wordmark px. +const INDEX_LOCKUP_PX = 64; + +// YouTube's own limits, checked before anything is written. +export const BANNER_MAX_BYTES = 6 * 1024 * 1024; + +function esc(s: string): string { + return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); +} + +function kb(n: number): string { + return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)} MB` : `${(n / 1024).toFixed(1)} KB`; +} + +export function indexHtml(rows: ReadonlyArray<MediaAsset & { bytes: number }>, generatedAt: string): string { + const P = MEDIA_PALETTE; + const bannerBytes = rows.find((r) => r.png.startsWith("banner-2560"))?.bytes ?? 0; + const lockup = mediaLockupGeometry(INDEX_LOCKUP_PX); + const card = (r: MediaAsset & { bytes: number }) => { + const round = r.png.startsWith("avatar"); + return `<figure class="asset${r.width > 1000 ? " wide" : ""}"> + <div class="stage${round ? " round" : ""}"><img src="${esc(r.png)}" alt="${esc(r.title)}" width="${r.width}" height="${r.height}"></div> + <figcaption> + <strong>${esc(r.title)}</strong> + <span class="meta">${esc(r.png)} · ${r.width} × ${r.height} · ${kb(r.bytes)} · source <a href="${esc(r.svg)}">${esc(r.svg)}</a></span> + <span>${esc(r.note)}</span> + </figcaption> +</figure>`; + }; + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Archilyzer Media assets</title> +<style> +:root { --bg: ${P.ground}; --fg: ${P.fg}; --muted: ${P.muted}; --line: #2a343c; --accent: ${P.lit}; --amber: ${P.amber}; } +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 "IBM Plex Sans", system-ui, sans-serif; } +main { max-width: 1120px; margin: 0 auto; padding: 40px 16px 64px; } +h1 { font: 700 28px/1.2 Archivo, system-ui, sans-serif; font-stretch: 118%; margin: 24px 0 4px; } +h2 { font-size: 18px; margin: 40px 0 12px; } +p, li { color: var(--muted); max-width: 70ch; } +strong, b { color: var(--fg); } +a { color: var(--accent); } +code { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 0.9em; color: var(--fg); } +.lockup { display: block; max-width: 100%; height: auto; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 24px; } +.asset { margin: 0; display: flex; flex-direction: column; gap: 10px; } +.asset.wide { grid-column: 1 / -1; } +.stage { background: #0f0f0f; border: 1px solid var(--line); border-radius: 10px; padding: 16px; display: flex; align-items: center; justify-content: center; } +.stage img { display: block; max-width: 100%; height: auto; } +.stage.round img { border-radius: 50%; max-width: 200px; } +figcaption { display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 14px; } +.meta { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12px; overflow-wrap: anywhere; } +ol li { margin: 6px 0; } +</style> +</head> +<body> +<main> +<img class="lockup" src="lockup.svg" alt="Archilyzer Media" width="${Math.round(lockup.blockWidth)}" height="${Math.round(lockup.blockHeight + lockup.inkTop)}"> +<h1>Archilyzer Media: channel assets</h1> +<p>Generated ${esc(generatedAt)} by <code>common/bin/brand-media.ts</code> from <code>common/lib/brandMedia.ts</code> +(mark M2 · Signal, lockup "flush · caps matched"). Re-run it to regenerate; never edit these files by hand.</p> + +<h2>Upload in YouTube Studio</h2> +<ol> +<li>Open <a href="https://studio.youtube.com">YouTube Studio</a> signed in as the channel, then <b>Customization</b> → <b>Profile</b> (formerly <i>Branding</i>). The picture, the banner and the watermark are all on that page (<a href="https://support.google.com/youtube/answer/10456525">YouTube Help: Manage your channel branding</a>).</li> +<li><b>Picture</b> → <b>Change</b> (<b>Upload</b> if the channel has none yet) → <code>avatar-800.png</code>. Keep the crop at the whole square; YouTube shows it as a circle.</li> +<li><b>Banner image</b> → <b>Change</b> (or <b>Upload</b>) → <code>banner-2560x1440.png</code> (${kb(bannerBytes)}; YouTube takes up to 6 MB). Leave the crop at the default: the device preview should show the lockup centred, as in the safe-area picture below.</li> +<li><b>Video watermark</b> → <b>Change</b> (or <b>Upload</b>) → <code>watermark-150.png</code>. Display time: <i>Entire video</i>.</li> +<li><b>Publish</b> (top right). Changes can take a few minutes to show on the channel page.</li> +</ol> + +<h2>Assets</h2> +<div class="grid"> +${rows.map(card).join("\n")} +</div> + +<h2>In the videos</h2> +<p>The report-to-video preset <code>render.brand: "archilyzer-media"</code> in a umtool manifest draws the title card, +the mark in the clip header, the end card (right half clear for YouTube's end-screen elements) and the thumbnail +in the same palette. See <code>umtool/report-to-video/README.md</code>, "The Archilyzer Media preset". The site is +<a href="${esc(PROJECT_URL)}">${esc(PROJECT_URL.replace(/^https?:\/\//, ""))}</a>.</p> +</main> +</body> +</html> +`; +} + +export async function writeMediaAssets(outDir: string): Promise<Array<MediaAsset & { bytes: number; path: string }>> { + await mkdir(outDir, { recursive: true }); + const rows: Array<MediaAsset & { bytes: number; path: string }> = []; + for (const a of MEDIA_ASSETS) { + const svg = a.render(); + const png = await renderSvgPng(svg, { width: a.width, height: a.height }); + if (a.png.startsWith("banner-2560") && png.byteLength > BANNER_MAX_BYTES) { + throw new Error(`${a.png} is ${png.byteLength} bytes; YouTube's banner limit is ${BANNER_MAX_BYTES}`); + } + await writeFile(path.join(outDir, a.svg), svg, "utf8"); + await writeFile(path.join(outDir, a.png), png); + rows.push({ ...a, bytes: png.byteLength, path: path.join(outDir, a.png) }); + } + await writeFile(path.join(outDir, "lockup.svg"), mediaLockupSvg({ px: INDEX_LOCKUP_PX }), "utf8"); + const stamp = `${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC`; + await writeFile(path.join(outDir, "INDEX.html"), indexHtml(rows, stamp), "utf8"); + return rows; +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise<number> { + const flags = parseFlags(argv); + if (flags.help) { + console.log("usage: brand-media.ts [--out <dir>] | --video-kit"); + return 0; + } + if (flags["video-kit"]) { + await mkdir(path.dirname(VIDEO_KIT_PATH), { recursive: true }); + await writeFile(VIDEO_KIT_PATH, videoKitJson(), "utf8"); + console.log(`wrote ${path.relative(REPO, VIDEO_KIT_PATH)}`); + return 0; + } + const out = path.resolve(flags.out && flags.out !== "true" ? flags.out.replace(/^~(?=\/)/, os.homedir()) : DEFAULT_OUT); + const rows = await writeMediaAssets(out); + for (const r of rows) console.log(`${r.path} ${r.width}x${r.height} ${r.bytes} B`); + console.log(`${path.join(out, "INDEX.html")}`); + return 0; +} + +runIfEntryPoint(import.meta.url, main); diff --git a/common/bin/gen-media-glyphs.py b/common/bin/gen-media-glyphs.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Generate common/lib/brandMediaGlyphs.ts: the Archilyzer Media lockup's letters as outlines. + +The lockup (common/lib/brandMedia.ts mediaLockupSvg) is the `Archi|lyzer` wordmark over +`MEDIA`, all Archivo at wdth 118. It is drawn as PATHS, not <text>, so anything that +rasterises it -- rsvg-convert, satori's <img>, ImageMagick -- needs no font installed and +cannot substitute one. This script is where those paths come from. + +For each run (`Archi` at wght 720, `lyzer` at 380, `MEDIA` at 700) it pins the variable +font to that instance with the fontTools instancer and records, per glyph, the advance and +the outline, plus the GPOS pair kerning between neighbours inside the run. (A browser does +not kern ACROSS two spans of different weight -- they are two shaping runs -- so there is +no kerning between `Archi` and `lyzer`, and none is recorded.) + +Coordinates are font units (the em is `unitsPerEm`), with y pointing DOWN and the baseline +at 0, which is what an SVG `translate(x baseline) scale(px / unitsPerEm)` wants. + +The font is the copy vendored for the umtool report-to-video preset, +umtool/report-to-video/fonts/Archivo[wdth,wght].ttf, so the outlines and the Pango-rendered +titles beside them come from the same bytes. `--font` takes another path; `--download` +fetches google/fonts' copy instead (into a temp dir, not the repo). + + python3 common/bin/gen-media-glyphs.py # rewrite common/lib/brandMediaGlyphs.ts + python3 common/bin/gen-media-glyphs.py --stdout # print it (the parity test diffs this) + +Needs fontTools (>= 4.40, for the instancer's partial-instance API). +""" +import argparse +import hashlib +import os +import sys +import tempfile +import urllib.request + +import fontTools +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.pens.transformPen import TransformPen +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.normpath(os.path.join(HERE, "..", "..")) +VENDORED = os.path.join(REPO, "umtool", "report-to-video", "fonts", "Archivo[wdth,wght].ttf") +OUT = os.path.join(REPO, "common", "lib", "brandMediaGlyphs.ts") +FONT_URL = "https://raw.githubusercontent.com/google/fonts/main/ofl/archivo/Archivo%5Bwdth%2Cwght%5D.ttf" + +WDTH = 118 +# key, text, weight -- the three runs of plans/brand-and-themes.md "S4": the wordmark's +# lead and suffix, and MEDIA. +RUNS = [ + ("archi", "Archi", 720), + ("lyzer", "lyzer", 380), + ("media", "MEDIA", 700), +] + + +def num(v): + """Font units to one decimal, without a trailing .0 -- stable text for the diff.""" + r = round(float(v), 1) + if r == int(r): + return str(int(r)) + return f"{r:.1f}" + + +def glyph_path(glyphset, name): + """The outline as SVG path data, y flipped so the baseline is 0 and down is +y.""" + pen = SVGPathPen(glyphset, ntos=num) + glyphset[name].draw(TransformPen(pen, (1, 0, 0, -1, 0, 0))) + return pen.getCommands() + + +def _value_x(v): + return (getattr(v, "XAdvance", 0) or 0) if v is not None else 0 + + +def _pair_subtables(font): + """Every PairPos subtable of the GPOS 'kern' feature, in lookup order, unwrapped + from Extension (type 9) lookups.""" + if "GPOS" not in font: + return [] + gpos = font["GPOS"].table + if not gpos.FeatureList or not gpos.LookupList: + return [] + indices = set() + for rec in gpos.FeatureList.FeatureRecord: + if rec.FeatureTag == "kern": + indices.update(rec.Feature.LookupListIndex) + subs = [] + for li in sorted(indices): + lookup = gpos.LookupList.Lookup[li] + for sub in lookup.SubTable: + kind = lookup.LookupType + if kind == 9: + kind = sub.ExtensionLookupType + sub = sub.ExtSubTable + if kind == 2: + subs.append(sub) + return subs + + +def pair_kerning(font, left, right): + """The XAdvance adjustment GPOS 'kern' applies to `left` followed by `right`. + + Resolved the way a shaper resolves one pair: the first subtable that covers + `left` and answers for `right` wins. A format-1 subtable with no record for the + pair passes; a format-2 (class) subtable that covers `left` always answers, + zero included. + """ + for sub in _pair_subtables(font): + cov = sub.Coverage.glyphs + if left not in cov: + continue + if sub.Format == 1: + for rec in sub.PairSet[cov.index(left)].PairValueRecord: + if rec.SecondGlyph == right: + return _value_x(rec.Value1) + elif sub.Format == 2: + c1 = sub.ClassDef1.classDefs.get(left, 0) + c2 = sub.ClassDef2.classDefs.get(right, 0) + return _value_x(sub.Class1Record[c1].Class2Record[c2].Value1) + return 0 + + +def run_data(varfont_path, text, wght): + font = TTFont(varfont_path) + inst = instancer.instantiateVariableFont(font, {"wght": wght, "wdth": WDTH}) + cmap = inst.getBestCmap() + hmtx = inst["hmtx"] + glyphset = inst.getGlyphSet() + glyphs = [] + for ch in text: + name = cmap[ord(ch)] + g = inst["glyf"][name] + g.recalcBounds(inst["glyf"]) # the instancer moves points; the stored bbox is the default master's + # Ink extent: `left` / `right` from the glyph origin, and y down, so `top` is + # how far it rises above the baseline (negative) and `bottom` how far it + # descends (positive for a descender). + top, bottom = (-g.yMax, -g.yMin) if g.numberOfContours else (0, 0) + left, right = (g.xMin, g.xMax) if g.numberOfContours else (0, 0) + glyphs.append({"char": ch, "name": name, "advance": hmtx[name][0], "left": left, "right": right, + "top": top, "bottom": bottom, "d": glyph_path(glyphset, name)}) + kerning = [pair_kerning(inst, a["name"], b["name"]) for a, b in zip(glyphs, glyphs[1:])] + os2 = inst["OS/2"] + return { + "glyphs": glyphs, + "kerning": kerning, + "capHeight": os2.sCapHeight, + "upem": inst["head"].unitsPerEm, + } + + +def render_ts(font_path): + sha = hashlib.sha256(open(font_path, "rb").read()).hexdigest() + runs = {key: run_data(font_path, text, wght) for key, text, wght in RUNS} + upem = {r["upem"] for r in runs.values()} + caps = {r["capHeight"] for r in runs.values()} + assert len(upem) == 1, f"unitsPerEm differs across instances: {upem}" + assert len(caps) == 1, f"cap height differs across weights: {caps}" + out = [ + "// GENERATED by common/bin/gen-media-glyphs.py -- DO NOT EDIT. Re-run it instead:", + "// python3 common/bin/gen-media-glyphs.py", + "// brandMedia.test.ts diffs this file against a fresh run -- under the fontTools version", + "// recorded below; another version may round the instancer differently, so the test skips.", + "//", + "// The Archilyzer Media lockup's letters as outlines: Archivo[wdth,wght].ttf pinned to", + f"// wdth {WDTH}, one instance per run. Font units, y down, baseline at 0; `kerning[i]` is", + "// the GPOS pair adjustment between glyph i and glyph i + 1 of the same run.", + "", + "// `left` / `right` / `top` / `bottom`: the glyph's ink box from its origin, y down", + "// (`top` < 0 is above the baseline).", + "export type MediaGlyph = {", + " char: string;", + " advance: number;", + " left: number;", + " right: number;", + " top: number;", + " bottom: number;", + " d: string;", + "};", + "export type MediaRun = {", + " text: string;", + " wght: number;", + " glyphs: ReadonlyArray<MediaGlyph>;", + " kerning: ReadonlyArray<number>;", + "};", + "", + "export const MEDIA_GLYPH_SOURCE = {", + ' font: "Archivo[wdth,wght].ttf",', + f' sha256: "{sha}",', + f" wdth: {WDTH},", + f' fontTools: "{fontTools.version}",', + "} as const;", + "", + f"export const MEDIA_UNITS_PER_EM = {upem.pop()};", + f"export const MEDIA_CAP_HEIGHT = {caps.pop()};", + "", + "export const MEDIA_RUNS = {", + ] + for key, text, wght in RUNS: + r = runs[key] + out.append(f" {key}: {{") + out.append(f' text: "{text}",') + out.append(f" wght: {wght},") + out.append(" glyphs: [") + for g in r["glyphs"]: + out.append( + f' {{ char: "{g["char"]}", advance: {g["advance"]}, left: {g["left"]}, right: {g["right"]}, ' + f'top: {g["top"]}, bottom: {g["bottom"]},' + ) + out.append(f' d: "{g["d"]}" }},') + out.append(" ],") + out.append(f" kerning: [{', '.join(str(k) for k in r['kerning'])}],") + out.append(" },") + out.append("} as const satisfies Record<string, MediaRun>;") + out.append("") + return "\n".join(out) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--font", help="Archivo[wdth,wght].ttf to read (default: the umtool vendored copy)") + ap.add_argument("--download", action="store_true", help="fetch google/fonts' copy into a temp dir") + ap.add_argument("--stdout", action="store_true", help="print instead of writing " + os.path.relpath(OUT, REPO)) + args = ap.parse_args() + + font = args.font or VENDORED + if args.download: + tmp = tempfile.mkdtemp(prefix="archivo-") + font = os.path.join(tmp, "Archivo[wdth,wght].ttf") + urllib.request.urlretrieve(FONT_URL, font) + if not os.path.exists(font): + sys.exit(f"no font at {font} -- pass --font <path> or --download") + + ts = render_ts(font) + if args.stdout: + sys.stdout.write(ts) + else: + with open(OUT, "w", encoding="utf-8") as f: + f.write(ts) + print(f"wrote {os.path.relpath(OUT, REPO)}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts @@ -26,19 +26,27 @@ export function svgDataUri(svg: string): string { return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; } -// The mark as a PNG of `size` × `size`. satori lays out one <img> of the SVG; -// resvg rasterises it. +// Any SVG document as a PNG of `width` × `height`. satori lays out one <img> +// of the SVG; resvg rasterises it. +export async function renderSvgPng( + svg: string, + opts: { width: number; height: number }, +): Promise<Uint8Array> { + const { width, height } = opts; + const res = new ImageResponse( + createElement("img", { src: svgDataUri(svg), width, height, alt: "" }), + { width, height }, + ); + return new Uint8Array(await res.arrayBuffer()); +} + +// The mark as a PNG of `size` × `size`. export async function renderIconPng( palette: IconPalette, opts: { variant?: MarkVariant; size: number }, ): Promise<Uint8Array> { const { size } = opts; - const src = svgDataUri(markSvg(palette, { variant: opts.variant ?? "any" })); - const res = new ImageResponse( - createElement("img", { src, width: size, height: size, alt: "" }), - { width: size, height: size }, - ); - return new Uint8Array(await res.arrayBuffer()); + return renderSvgPng(markSvg(palette, { variant: opts.variant ?? "any" }), { width: size, height: size }); } // A PNG-payload ICO: the 6-byte ICONDIR, one 16-byte ICONDIRENTRY per image, diff --git a/common/lib/brandMedia.test.ts b/common/lib/brandMedia.test.ts @@ -0,0 +1,172 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ACCENTS, ICON_PALETTES, markSvg } from "./brand"; +import { + MEDIA_BANNER, + MEDIA_MARK_PALETTE, + MEDIA_PALETTE, + MEDIA_RATIO, + PyRandom, + mediaBannerField, + mediaBannerLayout, + mediaBannerSvg, + mediaLockupGeometry, + mediaLockupSvg, + mediaMarkSvg, + mediaWordAdvanceEm, + mediaWordmarkAdvanceEm, +} from "./brandMedia"; +import { MEDIA_CAP_HEIGHT, MEDIA_GLYPH_SOURCE, MEDIA_RUNS } from "./brandMediaGlyphs"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("palette: mark M2 is the parent mark's slate, lit in Signal", () => { + assert.deepEqual(MEDIA_MARK_PALETTE, { + ground: ICON_PALETTES.archilyzer.ground, + dim: ICON_PALETTES.archilyzer.dim, + lit: ACCENTS.signal.onDark, + }); + assert.deepEqual(MEDIA_PALETTE, { + ground: "#151b20", + dim: "#3f4c56", + lit: "#5fa8a0", + fg: "#e7edf1", + muted: "#8496a2", + media: "#5fa8a0", + amber: "#e3b15c", + }); + assert.equal(MEDIA_RATIO, 1.035); + // The mark is markSvg's, not a second drawing. + assert.equal(mediaMarkSvg("maskable"), markSvg(MEDIA_MARK_PALETTE, { variant: "maskable" })); + assert.equal(mediaMarkSvg(), markSvg(MEDIA_MARK_PALETTE)); +}); + +test("glyphs: the measured widths of plans/brand-and-themes.md S4 (5.334 em, 3.953 em, caps 0.686 em)", () => { + assert.equal(MEDIA_GLYPH_SOURCE.wdth, 118); + assert.deepEqual( + [MEDIA_RUNS.archi, MEDIA_RUNS.lyzer, MEDIA_RUNS.media].map((r) => [r.text, r.wght]), + [["Archi", 720], ["lyzer", 380], ["MEDIA", 700]], + ); + assert.equal(Number(mediaWordmarkAdvanceEm().toFixed(3)), 5.334); + assert.equal(Number(mediaWordAdvanceEm().toFixed(3)), 3.953); + assert.equal(MEDIA_CAP_HEIGHT, 686); +}); + +test("lockup: MEDIA spans the wordmark's advance (within 0.5 %) with 0.30 em between its letters", () => { + for (const px of [18, 28, 64, 124]) { + const g = mediaLockupGeometry(px); + assert.ok( + Math.abs(g.mediaAdvance - g.wordmarkAdvance) / g.wordmarkAdvance < 0.005, + `px ${px}: MEDIA ${g.mediaAdvance} vs wordmark ${g.wordmarkAdvance}`, + ); + assert.ok(Math.abs(g.mediaAdvance - g.textWidth) < 1e-6); + assert.ok(Math.abs(g.mediaSpacing / g.mediaPx - 0.3) < 0.005, `spacing ${g.mediaSpacing / g.mediaPx} em`); + assert.ok(Math.abs(g.mediaPx - px * MEDIA_RATIO) < 1e-9); + // The mark is as tall as the block, which is about 1.70 x the wordmark. + assert.equal(g.markSize, g.blockHeight); + assert.ok(Math.abs(g.blockHeight / px - 1.7) < 0.01, `block ${g.blockHeight / px} x`); + // The ink of both lines starts at the text origin and ends within a hair of + // the shared advance: flush left and right. + const lines = [g.glyphs.filter((p) => p.tone !== "media"), g.glyphs.filter((p) => p.tone === "media")]; + for (const line of lines) { + const first = line[0]; + const last = line[line.length - 1]; + assert.ok(first.glyph.left * first.scale < 0.1 * px, "left bearing"); + const right = last.x + last.glyph.right * last.scale; + assert.ok(Math.abs(g.textWidth - right) < 0.08 * px, `right edge ${right} vs ${g.textWidth}`); + } + } +}); + +test("lockup on the banner: the approved numbers, inside the safe area", () => { + const { geometry: g, block, ink } = mediaBannerLayout(); + assert.equal(MEDIA_BANNER.wordmarkPx, 124); + assert.equal(Math.round(g.mediaPx), 128); + assert.equal(Math.round(g.textWidth), 661); + assert.equal(Math.round(g.markSize), 210); + const S = MEDIA_BANNER.safe; + for (const box of [block, ink]) { + assert.ok(box.x >= S.x && box.y >= S.y, `${JSON.stringify(box)} starts inside the safe area`); + assert.ok(box.x + box.width <= S.x + S.width, "ends inside horizontally"); + assert.ok(box.y + box.height <= S.y + S.height, "ends inside vertically"); + } + // Centred on it. + assert.ok(Math.abs(block.x + block.width / 2 - (S.x + S.width / 2)) < 1e-6); + assert.ok(Math.abs(block.y + block.height / 2 - (S.y + S.height / 2)) < 1e-6); + // And the field keeps out of the gutter round it. + for (const [x, y, w] of mediaBannerField()) { + const inBandY = y + MEDIA_BANNER.line.height > S.y - MEDIA_BANNER.gutter.y && y < S.y + S.height + MEDIA_BANNER.gutter.y; + if (!inBandY) continue; + const clear = x + w <= S.x - MEDIA_BANNER.gutter.x || x >= S.x + S.width + MEDIA_BANNER.gutter.x; + assert.ok(clear, `line ${x},${y} +${w} crosses the gutter`); + } +}); + +test("PyRandom is CPython's random.Random: the canvas field, line for line", () => { + // python3 -c "import random; r = random.Random(11); print([r.getrandbits(32) for _ in range(5)])" + const r = new PyRandom(11); + assert.deepEqual( + Array.from({ length: 5 }, () => r.genrandUint32()), + [1942955373, 3718334796, 2404204071, 3680198571, 3969454221], + ); + // r = random.Random(11); [r.choice([0, 0, 0, 96, 192]) for _ in range(8)] + const c = new PyRandom(11); + assert.deepEqual( + Array.from({ length: 8 }, () => c.choice([0, 0, 0, 96, 192])), + [96, 192, 96, 96, 192, 192, 0, 0], + ); + // The canvas generator's banner() field (gen_media.py), 87 lines. + const field = mediaBannerField(); + assert.equal(field.length, 87); + assert.deepEqual(field.slice(0, 3), [[160, 40, 300], [488, 40, 380], [896, 40, 380]]); + assert.deepEqual(field.slice(-3), [[1658, 1384, 190], [1876, 1384, 300], [2204, 1384, 172]]); +}); + +test("SVGs: well-formed roots, colours validated, the safe crop is a viewBox", () => { + const lockup = mediaLockupSvg({ px: 28 }); + assert.match(lockup, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" width="[\d.]+" height="[\d.]+" viewBox="0 0 [\d.]+ [\d.]+">/); + assert.ok(lockup.includes(`fill="${MEDIA_PALETTE.fg}"`) && lockup.includes(`fill="${MEDIA_PALETTE.muted}"`)); + assert.equal((lockup.match(/<path /g) ?? []).length, 15); + assert.doesNotMatch(lockup, /<text/); + assert.throws(() => mediaLockupSvg({ px: 28, palette: { ...MEDIA_PALETTE, fg: "red" } }), /palette\.fg/); + assert.throws(() => mediaLockupGeometry(0), /px must be > 0/); + + const banner = mediaBannerSvg(); + assert.match(banner, /^<svg [^>]*width="2560" height="1440" viewBox="0 0 2560 1440">/); + assert.equal((banner.match(/<rect x="\d+" y="\d+" width="\d+" height="44" rx="22"\/>/g) ?? []).length, 87); + const safe = mediaBannerSvg({ view: MEDIA_BANNER.safe }); + assert.match(safe, /^<svg [^>]*width="1546" height="423" viewBox="507 508.5 1546 423">/); + assert.equal(safe.slice(safe.indexOf(">")), banner.slice(banner.indexOf(">"))); +}); + +// The glyph file is generated; a hand edit or a font swap without a re-run is a +// failure. Needs python3 + fontTools, which a test machine may not have -- and +// the SAME fontTools the file records: another version can round the +// instancer's outlines differently, which is not a stale file. Both skips say +// why in the test output. +test("brandMediaGlyphs.ts is exactly what gen-media-glyphs.py writes", (t) => { + const probe = spawnSync( + "python3", + ["-c", "import fontTools, fontTools.varLib.instancer; print(fontTools.version)"], + { encoding: "utf8" }, + ); + if (probe.status !== 0) { + t.skip("python3 with fontTools is not available"); + return; + } + const installed = probe.stdout.trim(); + if (installed !== MEDIA_GLYPH_SOURCE.fontTools) { + t.skip(`fontTools ${installed} is installed; brandMediaGlyphs.ts was generated with ${MEDIA_GLYPH_SOURCE.fontTools}`); + return; + } + const fresh = execFileSync("python3", [path.join(REPO, "common/bin/gen-media-glyphs.py"), "--stdout"], { + encoding: "utf8", + maxBuffer: 1 << 24, + }); + const committed = readFileSync(path.join(REPO, "common/lib/brandMediaGlyphs.ts"), "utf8"); + assert.equal(committed, fresh); +}); diff --git a/common/lib/brandMedia.ts b/common/lib/brandMedia.ts @@ -0,0 +1,479 @@ +// ARCHILYZER MEDIA — the YouTube channel's brand, as data and pure SVG. +// +// plans/brand-and-themes.md "S4 — Archilyzer Media" is the source of every +// value here, and the approved canvas (row "Archilyzer Media · the YouTube +// channel") is what each drawing reproduces: +// +// - the mark is M2 "Signal": the parent mark's geometry (lib/brand.ts MARK, +// drawn by markSvg — never redrawn here), lit in Signal; +// - the lockup is "flush · caps matched": the `Archi|lyzer` wordmark over +// `MEDIA`, both Archivo at wdth 118, MEDIA at MEDIA_RATIO × the wordmark's +// size and letter-spaced to exactly the wordmark's width, the mark as tall +// as the text block beside it; +// - the banner is a field of dim transcript lines wrapping round a clear +// gutter about YouTube's safe area, the lockup centred in it, and one lit +// found line in the desktop strip to its right. +// +// The letters are OUTLINES from lib/brandMediaGlyphs.ts (generated by +// common/bin/gen-media-glyphs.py from the Archivo the umtool preset vendors), +// so rendering any of this needs no font: rsvg-convert, satori's <img> and +// ImageMagick all draw the same shapes. +// +// PURE: no I/O, no framework imports. The PNG side is common/bin/brand-media.ts. + +import { ACCENTS, MARK_VIEWBOX, markSvg, type IconPalette, type MarkVariant } from "./brand"; +import { + MEDIA_CAP_HEIGHT, + MEDIA_RUNS, + MEDIA_UNITS_PER_EM, + type MediaGlyph, + type MediaRun, +} from "./brandMediaGlyphs"; + +// ── Palette ───────────────────────────────────────────────────────────────── + +// The slate of the parent mark (lib/brand.ts ICON_PALETTES.archilyzer), lit in +// Signal. `media` is MEDIA's ink; `amber` is the annotation colour the video +// preset carries for kickers and markers. +export type MediaPalette = { + ground: string; + dim: string; + lit: string; + fg: string; + muted: string; + media: string; +}; + +export const MEDIA_PALETTE = { + ground: "#151b20", + dim: "#3f4c56", + lit: ACCENTS.signal.onDark, + fg: "#e7edf1", + muted: "#8496a2", + media: ACCENTS.signal.onDark, + amber: "#e3b15c", +} as const satisfies MediaPalette & { amber: string }; + +// Mark M2 · Signal. +export const MEDIA_MARK_PALETTE: IconPalette = { + ground: MEDIA_PALETTE.ground, + dim: MEDIA_PALETTE.dim, + lit: MEDIA_PALETTE.lit, +}; + +// The dim lines of the banner's field: a step above the ground, below the +// mark's own dim, so the field reads as texture and the mark as the object. +export const MEDIA_FIELD_LINE = "#1e262d"; + +// ── The lockup's proportions ──────────────────────────────────────────────── + +// MEDIA's size over the wordmark's. At this ratio MEDIA, spread to the +// wordmark's width, has 0.30 em between its letters. +export const MEDIA_RATIO = 1.035; +// The wordmark's tracking, as the sites set it (common/components/Wordmark.tsx). +// Its measured width — the advances of `Archi` at 720 and `lyzer` at 380, less +// this per letter — is the width both lines share: 5.334 em. +export const MEDIA_WORDMARK_TRACKING_EM = -0.01; +// From the wordmark's baseline to MEDIA's cap top, in wordmark em. +export const MEDIA_LINE_GAP_EM = 0.3; +// From the mark's right edge to the text, in wordmark em. +export const MEDIA_MARK_GAP_EM = 0.46; + +const EM = MEDIA_UNITS_PER_EM; +const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i; + +const sum = (xs: ReadonlyArray<number>): number => xs.reduce((a, b) => a + b, 0); +const advances = (r: MediaRun): number => sum(r.glyphs.map((g) => g.advance)); + +// The wordmark's shared width in em: the spec's measurement, from the glyphs. +export function mediaWordmarkAdvanceEm(): number { + const letters = MEDIA_RUNS.archi.glyphs.length + MEDIA_RUNS.lyzer.glyphs.length; + return (advances(MEDIA_RUNS.archi) + advances(MEDIA_RUNS.lyzer)) / EM + MEDIA_WORDMARK_TRACKING_EM * letters; +} + +// MEDIA's natural advance (no spacing) in em of its own size. +export function mediaWordAdvanceEm(): number { + return (advances(MEDIA_RUNS.media) + sum(MEDIA_RUNS.media.kerning)) / EM; +} + +export type PlacedGlyph = { glyph: MediaGlyph; x: number; baseline: number; scale: number; tone: "fg" | "muted" | "media" }; + +export type MediaLockupGeometry = { + px: number; + // The block: wordmark cap top (y 0) to MEDIA's baseline, mark to text end. + blockWidth: number; + blockHeight: number; + markSize: number; + textX: number; + textWidth: number; + wordmarkBaseline: number; + mediaPx: number; + mediaBaseline: number; + // MEDIA's letter gap, px. + mediaSpacing: number; + // The ink's overshoot above the block (the ascenders of h, i and l rise + // past the cap line), px; the SVG adds it on top so nothing is clipped. + inkTop: number; + glyphs: ReadonlyArray<PlacedGlyph>; + // Where each line's advance ends, px from the text origin. + wordmarkAdvance: number; + mediaAdvance: number; +}; + +// Place the lockup at a wordmark size of `px`. Block coordinates: the mark's +// top-left is (0, 0), which is also the wordmark's cap line. +export function mediaLockupGeometry(px: number): MediaLockupGeometry { + if (!(px > 0) || !Number.isFinite(px)) throw new Error(`mediaLockupGeometry: px must be > 0, got ${px}`); + const s = px / EM; + const mediaPx = px * MEDIA_RATIO; + const sm = mediaPx / EM; + const cap = MEDIA_CAP_HEIGHT / EM; + + const wordmarkBaseline = cap * px; + const mediaBaseline = wordmarkBaseline + MEDIA_LINE_GAP_EM * px + cap * mediaPx; + const blockHeight = mediaBaseline; + const markSize = blockHeight; + const textX = markSize + MEDIA_MARK_GAP_EM * px; + const width = mediaWordmarkAdvanceEm() * px; + + const glyphs: PlacedGlyph[] = []; + + // The wordmark: kerned inside each run (a browser kerns inside a span, not + // across two spans of different weight), then one uniform adjustment per + // gap brings the whole line to the measured width. That is the canvas's + // `textLength` + `lengthAdjust="spacing"`. + const word: Array<{ glyph: MediaGlyph; tone: "fg" | "muted"; kernAfter: number }> = []; + for (const [run, tone] of [ + [MEDIA_RUNS.archi, "fg"], + [MEDIA_RUNS.lyzer, "muted"], + ] as const) { + run.glyphs.forEach((glyph, i) => word.push({ glyph, tone, kernAfter: run.kerning[i] ?? 0 })); + } + const natural = sum(word.map((w, i) => w.glyph.advance + (i < word.length - 1 ? w.kernAfter : 0))) * s; + const perGap = (width - natural) / (word.length - 1); + let x = 0; + word.forEach((w, i) => { + glyphs.push({ glyph: w.glyph, x, baseline: wordmarkBaseline, scale: s, tone: w.tone }); + x += w.glyph.advance * s + (i < word.length - 1 ? w.kernAfter * s + perGap : 0); + }); + const wordmarkAdvance = x; + + // MEDIA: its own advances (and kerning, if the font has any for it) at + // MEDIA_RATIO × px, the rest of the width shared equally between its gaps. + const media = MEDIA_RUNS.media; + const mediaSpacing = (width - mediaWordAdvanceEm() * mediaPx) / (media.glyphs.length - 1); + x = 0; + media.glyphs.forEach((glyph, i) => { + glyphs.push({ glyph, x, baseline: mediaBaseline, scale: sm, tone: "media" }); + x += glyph.advance * sm + (i < media.glyphs.length - 1 ? (media.kerning[i] ?? 0) * sm + mediaSpacing : 0); + }); + const mediaAdvance = x; + + const inkTop = Math.max(0, -Math.min(...glyphs.map((g) => g.baseline + g.glyph.top * g.scale))); + + return { + px, + blockWidth: textX + width, + blockHeight, + markSize, + textX, + textWidth: width, + wordmarkBaseline, + mediaPx, + mediaBaseline, + mediaSpacing, + inkTop, + glyphs, + wordmarkAdvance, + mediaAdvance, + }; +} + +const fmt = (n: number): string => String(Number(n.toFixed(3))); + +function checkPalette(fn: string, palette: Record<string, string>): void { + for (const [slot, colour] of Object.entries(palette)) { + if (!HEX_COLOR_RE.test(colour)) { + throw new Error(`${fn}: palette.${slot} must be #rrggbb, got ${JSON.stringify(colour)}`); + } + } +} + +// markSvg's document, re-rooted as a nested <svg> at (x, y) and `size` square. +function nestedMark(palette: IconPalette, x: number, y: number, size: number, variant: MarkVariant = "any"): string { + const doc = markSvg(palette, { variant }); + const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}">`; + if (!doc.startsWith(open)) throw new Error("nestedMark: markSvg's root changed shape"); + return ( + `<svg x="${fmt(x)}" y="${fmt(y)}" width="${fmt(size)}" height="${fmt(size)}" viewBox="0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}">` + + doc.slice(open.length) + ); +} + +// The lockup's shapes, positioned with its block's top-left at (x, y). +function lockupBody(g: MediaLockupGeometry, palette: MediaPalette, x: number, y: number): string { + const mark = nestedMark({ ground: palette.ground, dim: palette.dim, lit: palette.lit }, x, y, g.markSize); + const byTone = new Map<string, string[]>(); + for (const p of g.glyphs) { + const fill = palette[p.tone].toLowerCase(); + const list = byTone.get(fill) ?? []; + list.push( + `<path transform="translate(${fmt(x + g.textX + p.x)} ${fmt(y + p.baseline)}) scale(${fmt(p.scale)})" d="${p.glyph.d}"/>`, + ); + byTone.set(fill, list); + } + const text = [...byTone].map(([fill, paths]) => `<g fill="${fill}">${paths.join("")}</g>`).join(""); + return mark + text; +} + +// The lockup as a standalone SVG document at a wordmark size of `px`. Its +// box is the block plus the ascenders' overshoot on top; `mediaLockupGeometry` +// says where the block sits in it (y = inkTop). +export function mediaLockupSvg({ px, palette = MEDIA_PALETTE }: { px: number; palette?: MediaPalette }): string { + checkPalette("mediaLockupSvg", palette); + const g = mediaLockupGeometry(px); + const w = fmt(g.blockWidth); + const h = fmt(g.blockHeight + g.inkTop); + return ( + `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` + + lockupBody(g, palette, 0, g.inkTop) + + `</svg>` + ); +} + +// ── The video kit ─────────────────────────────────────────────────────────── + +// What umtool's report-to-video preset needs from here, as JSON: its scripts +// are plain `node` .mjs and cannot import TypeScript, so common/bin/ +// brand-media.ts --video-kit writes this to +// umtool/report-to-video/brands/archilyzer-media.json and brandMedia.test.ts +// holds the committed file to it. The lockup is drawn at `px` and scaled by +// the renderer; `blockTop` is where its design box starts inside the SVG. +export const MEDIA_VIDEO_KIT_PX = 100; + +export function mediaVideoKit() { + const g = mediaLockupGeometry(MEDIA_VIDEO_KIT_PX); + return { + $comment: + "GENERATED by common/bin/brand-media.ts --video-kit from common/lib/brandMedia.ts -- do not edit; re-run it.", + id: "archilyzer-media", + palette: { + bg: MEDIA_PALETTE.ground, + fg: MEDIA_PALETTE.fg, + muted: MEDIA_PALETTE.muted, + accent: MEDIA_PALETTE.lit, + amber: MEDIA_PALETTE.amber, + dim: MEDIA_PALETTE.dim, + }, + mark: { any: mediaMarkSvg("any") }, + lockup: { + px: MEDIA_VIDEO_KIT_PX, + width: Number(fmt(g.blockWidth)), + height: Number(fmt(g.blockHeight + g.inkTop)), + blockTop: Number(fmt(g.inkTop)), + blockHeight: Number(fmt(g.blockHeight)), + svg: mediaLockupSvg({ px: MEDIA_VIDEO_KIT_PX }), + }, + }; +} + +// ── The banner ────────────────────────────────────────────────────────────── + +// YouTube's channel banner: 2560 × 1440, of which every device shows the +// centred 1546 × 423 (the "safe area"); a desktop shows the full-width strip +// through it, a TV the whole image. +export const MEDIA_BANNER = { + width: 2560, + height: 1440, + safe: { x: 507, y: 508.5, width: 1546, height: 423 }, + // The lockup's wordmark size on the banner: MEDIA comes out at 128 px, the + // text at 661 px wide, the mark 210 px — the approved canvas's numbers. + wordmarkPx: 124, + // The field's lines stay out of a gutter this wide round the safe area. + gutter: { x: 48, y: 36 }, + line: { height: 44, pitch: 84, first: 40, last: 1400, left: 64, right: 2496, gap: 28, minWidth: 60 }, + // The one lit line: a play head and a bar, right of the safe area. + found: { y: 712, head: [[2150, 710], [2210, 734], [2150, 758]] as ReadonlyArray<readonly [number, number]>, x: 2226, width: 236 }, + seed: 11, +} as const; + +// CPython's `random.Random(seed).choice`, exactly: MT19937 seeded by +// init_by_array, and `_randbelow` by rejection over `getrandbits(k)`. The +// canvas generator drew the field with Random(11); porting the generator +// rather than transcribing its output keeps the banner a function, not a +// table, and the test pins the two against each other. +export class PyRandom { + private readonly mt = new Uint32Array(624); + private mti = 625; + + constructor(seed: number) { + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) { + throw new Error(`PyRandom: seed must be a 32-bit non-negative integer, got ${seed}`); + } + this.initByArray([seed]); + } + + private initGenrand(s: number): void { + const mt = this.mt; + mt[0] = s >>> 0; + for (let i = 1; i < 624; i++) { + mt[i] = (Math.imul(1812433253, mt[i - 1] ^ (mt[i - 1] >>> 30)) + i) >>> 0; + } + this.mti = 624; + } + + private initByArray(key: ReadonlyArray<number>): void { + const mt = this.mt; + this.initGenrand(19650218); + let i = 1; + let j = 0; + for (let k = Math.max(624, key.length); k > 0; k--) { + mt[i] = ((mt[i] ^ Math.imul(mt[i - 1] ^ (mt[i - 1] >>> 30), 1664525)) + key[j] + j) >>> 0; + i++; + j++; + if (i >= 624) { + mt[0] = mt[623]; + i = 1; + } + if (j >= key.length) j = 0; + } + for (let k = 623; k > 0; k--) { + mt[i] = ((mt[i] ^ Math.imul(mt[i - 1] ^ (mt[i - 1] >>> 30), 1566083941)) - i) >>> 0; + i++; + if (i >= 624) { + mt[0] = mt[623]; + i = 1; + } + } + mt[0] = 0x80000000; + } + + genrandUint32(): number { + const mt = this.mt; + if (this.mti >= 624) { + for (let kk = 0; kk < 624; kk++) { + const y = (mt[kk] & 0x80000000) | (mt[(kk + 1) % 624] & 0x7fffffff); + mt[kk] = mt[(kk + 397) % 624] ^ (y >>> 1) ^ (y & 1 ? 0x9908b0df : 0); + } + this.mti = 0; + } + let y = mt[this.mti++]; + y ^= y >>> 11; + y ^= (y << 7) & 0x9d2c5680; + y ^= (y << 15) & 0xefc60000; + y ^= y >>> 18; + return y >>> 0; + } + + getrandbits(k: number): number { + if (!Number.isInteger(k) || k < 0 || k > 32) throw new Error(`getrandbits: k must be 0..32, got ${k}`); + return k === 0 ? 0 : this.genrandUint32() >>> (32 - k); + } + + randbelow(n: number): number { + const k = Math.floor(Math.log2(n)) + 1; // n.bit_length() + let r = this.getrandbits(k); + while (r >= n) r = this.getrandbits(k); + return r; + } + + choice<T>(seq: ReadonlyArray<T>): T { + return seq[this.randbelow(seq.length)]; + } +} + +// The field of dim lines: [x, y, width] per line, in the canvas generator's +// order. +export function mediaBannerField(): Array<[number, number, number]> { + const B = MEDIA_BANNER; + const rnd = new PyRandom(B.seed); + const zx0 = B.safe.x - B.gutter.x; + const zy0 = B.safe.y - B.gutter.y; + const zx1 = B.safe.x + B.safe.width + B.gutter.x; + const zy1 = B.safe.y + B.safe.height + B.gutter.y; + const L = B.line; + const out: Array<[number, number, number]> = []; + for (let y: number = L.first; y < L.last; y += L.pitch) { + const inBand = y + L.height > zy0 && y < zy1; + let x = L.left + rnd.choice([0, 0, 0, 96, 192]); + let end = L.right - rnd.choice([0, 120, 360, 640]); + // The found line's row: the field stops short of the gutter, and the lit + // line is drawn in the desktop strip instead. + if (y === B.found.y) end = Math.min(end, zx0); + while (x < end) { + let bw = Math.min(rnd.choice([140, 190, 240, 300, 380, 460]), end - x); + if (inBand && x < zx1 && x + bw > zx0) { + bw = zx0 - x; + if (bw >= L.minWidth) out.push([x, y, bw]); + x = zx1; + continue; + } + if (bw >= L.minWidth) out.push([x, y, bw]); + x += bw + L.gap; + } + } + return out; +} + +// Where the lockup lands on the banner: centred on the safe area. `block` is +// the lockup's design box (mark top to MEDIA baseline); `ink` adds the +// ascenders. +export function mediaBannerLayout(): { + geometry: MediaLockupGeometry; + x: number; + y: number; + block: { x: number; y: number; width: number; height: number }; + ink: { x: number; y: number; width: number; height: number }; +} { + const B = MEDIA_BANNER; + const g = mediaLockupGeometry(B.wordmarkPx); + const cx = B.safe.x + B.safe.width / 2; + const cy = B.safe.y + B.safe.height / 2; + const x = cx - g.blockWidth / 2; + const y = cy - g.blockHeight / 2; + return { + geometry: g, + x, + y, + block: { x, y, width: g.blockWidth, height: g.blockHeight }, + ink: { x, y: y - g.inkTop, width: g.blockWidth, height: g.blockHeight + g.inkTop }, + }; +} + +// The banner, 2560 × 1440. `view` crops it (the safe-area PNG is the same +// drawing with a viewBox of the safe rectangle). +export function mediaBannerSvg( + opts: { palette?: MediaPalette; view?: { x: number; y: number; width: number; height: number } } = {}, +): string { + const palette = opts.palette ?? MEDIA_PALETTE; + checkPalette("mediaBannerSvg", palette); + const B = MEDIA_BANNER; + const view = opts.view ?? { x: 0, y: 0, width: B.width, height: B.height }; + const L = B.line; + const lines = mediaBannerField() + .map(([x, y, w]) => `<rect x="${x}" y="${y}" width="${w}" height="${L.height}" rx="${L.height / 2}"/>`) + .join(""); + const lit = palette.lit.toLowerCase(); + const found = + `<polygon points="${B.found.head.map(([x, y]) => `${x},${y}`).join(" ")}" fill="${lit}"/>` + + `<rect x="${B.found.x}" y="${B.found.y}" width="${B.found.width}" height="${L.height}" rx="${L.height / 2}" fill="${lit}"/>`; + const { geometry, x, y } = mediaBannerLayout(); + return ( + `<svg xmlns="http://www.w3.org/2000/svg" width="${view.width}" height="${view.height}" ` + + `viewBox="${view.x} ${view.y} ${view.width} ${view.height}">` + + `<rect width="${B.width}" height="${B.height}" fill="${palette.ground.toLowerCase()}"/>` + + `<g fill="${MEDIA_FIELD_LINE}">${lines}</g>` + + found + + lockupBody(geometry, palette, x, y) + + `</svg>` + ); +} + +// ── The mark on its own ───────────────────────────────────────────────────── + +// Mark M2 as a standalone SVG: `maskable` is the avatar (YouTube crops it to +// a circle, and the 0.8 scale keeps the lines inside it), `any` the Studio +// watermark (rounded square, transparent corners). +export function mediaMarkSvg(variant: MarkVariant = "any"): string { + return markSvg(MEDIA_MARK_PALETTE, { variant }); +} diff --git a/common/lib/brandMediaGlyphs.ts b/common/lib/brandMediaGlyphs.ts @@ -0,0 +1,90 @@ +// GENERATED by common/bin/gen-media-glyphs.py -- DO NOT EDIT. Re-run it instead: +// python3 common/bin/gen-media-glyphs.py +// brandMedia.test.ts diffs this file against a fresh run -- under the fontTools version +// recorded below; another version may round the instancer differently, so the test skips. +// +// The Archilyzer Media lockup's letters as outlines: Archivo[wdth,wght].ttf pinned to +// wdth 118, one instance per run. Font units, y down, baseline at 0; `kerning[i]` is +// the GPOS pair adjustment between glyph i and glyph i + 1 of the same run. + +// `left` / `right` / `top` / `bottom`: the glyph's ink box from its origin, y down +// (`top` < 0 is above the baseline). +export type MediaGlyph = { + char: string; + advance: number; + left: number; + right: number; + top: number; + bottom: number; + d: string; +}; +export type MediaRun = { + text: string; + wght: number; + glyphs: ReadonlyArray<MediaGlyph>; + kerning: ReadonlyArray<number>; +}; + +export const MEDIA_GLYPH_SOURCE = { + font: "Archivo[wdth,wght].ttf", + sha256: "0e094a7d3c7c4c25cf1310c4b30014f1dae9332220b1c2c88f4fa996f0b05053", + wdth: 118, + fontTools: "4.65.0", +} as const; + +export const MEDIA_UNITS_PER_EM = 1000; +export const MEDIA_CAP_HEIGHT = 686; + +export const MEDIA_RUNS = { + archi: { + text: "Archi", + wght: 720, + glyphs: [ + { char: "A", advance: 867, left: 16, right: 851, top: -687, bottom: 0, + d: "M15.8 0 334.8 -687.1H532.2L851.4 0H664.3L609.1 -124.2H248.1L193.1 0ZM306.3 -256.8H550.8L483.3 -413.3Q479 -423.5 471.5 -441.8Q464.1 -460.2 456.4 -480.5Q448.6 -500.8 442.1 -518Q435.5 -535.3 432.6 -542.8H425.4Q418 -523.5 408.2 -498.6Q398.4 -473.6 389.3 -450.4Q380.2 -427.2 373.8 -412.8Z" }, + { char: "r", advance: 439, left: 67, right: 428, top: -539, bottom: 0, + d: "M66.7 0V-527.1H194.4L205.7 -438.5H213.6Q226.2 -467.5 247.1 -490.4Q268 -513.2 297.3 -526.3Q326.6 -539.4 362.6 -539.4Q381.2 -539.4 397.9 -536.5Q414.7 -533.7 427.6 -528.8V-396.2H358.5Q322.8 -396.2 297.3 -384.9Q271.8 -373.6 255.6 -353.5Q239.4 -333.3 231.6 -307.2Q223.8 -281.2 223.8 -251.1V0Z" }, + { char: "c", advance: 702, left: 45, right: 657, top: -539, bottom: 12, + d: "M359.3 12Q260.8 12 190.3 -18.3Q119.7 -48.6 82.2 -109.9Q44.6 -171.2 44.6 -263.8Q44.6 -356.7 82.3 -417.7Q119.9 -478.8 190.5 -509Q261 -539.2 359.3 -539.2Q422.3 -539.2 476.3 -525.7Q530.4 -512.2 571.1 -484.8Q611.8 -457.3 634.2 -416.5Q656.5 -375.7 656.5 -321.2H498.4Q498.4 -354.6 480.2 -377.6Q462 -400.6 430.6 -412.6Q399.2 -424.6 358.9 -424.6Q309 -424.6 274.9 -406.9Q240.9 -389.3 223.6 -355.8Q206.4 -322.4 206.4 -274.4V-252.9Q206.4 -205.6 224 -171.9Q241.7 -138.1 276.9 -120.3Q312.2 -102.5 364.6 -102.5Q404.4 -102.5 435.9 -115.3Q467.4 -128.1 486 -151.7Q504.6 -175.3 504.6 -206.7H656.5Q656.5 -151.9 633.9 -110.7Q611.3 -69.5 571 -42.3Q530.8 -15.1 476.5 -1.6Q422.3 12 359.3 12Z" }, + { char: "h", advance: 700, left: 67, right: 637, top: -724, bottom: 0, + d: "M66.7 0V-724.1H223.8V-460.6H231Q255.7 -488.9 287 -506Q318.3 -523.2 353.5 -531.1Q388.8 -539.1 424.4 -539.1Q493.7 -539.1 540.9 -516.5Q588.1 -494 612.4 -447.8Q636.7 -401.7 636.7 -330.2V0H479.4V-306Q479.4 -334.8 471.6 -355.5Q463.8 -376.2 449.2 -389.2Q434.6 -402.2 413.6 -408.2Q392.5 -414.3 366 -414.3Q327.3 -414.3 295.1 -397.9Q262.8 -381.6 243.3 -352.7Q223.8 -323.9 223.8 -285.8V0Z" }, + { char: "i", advance: 290, left: 67, right: 224, top: -724, bottom: 0, + d: "M66.7 -605.3V-724.1H223.8V-605.3ZM66.7 0V-527.1H223.8V0Z" }, + ], + kerning: [0, -14, 0, 0], + }, + lyzer: { + text: "lyzer", + wght: 380, + glyphs: [ + { char: "l", advance: 241, left: 75, right: 166, top: -723, bottom: 0, + d: "M74.8 0V-723.4H166.4V0Z" }, + { char: "y", advance: 576, left: 9, right: 566, top: -526, bottom: 188, + d: "M122.5 187.7Q100.1 187.7 81 184.7Q62 181.6 46.7 177.4V113.2H99.5Q142.7 113.2 170.5 102.3Q198.3 91.3 217.2 66.6Q236.1 41.9 253.5 0L9.2 -526.4H109.3L232.3 -251.9Q241.3 -233.3 253.1 -204.8Q264.8 -176.3 277.5 -146.4Q290.1 -116.5 299.5 -93.6H306.1Q309.9 -105 316.8 -124.9Q323.7 -144.8 332.5 -168.7Q341.4 -192.5 350.4 -215.9Q359.4 -239.2 366.4 -257.4L470.9 -526.4H566.4L357.1 -15.1Q336.4 34.3 315.4 72.3Q294.5 110.2 268.7 136Q242.9 161.8 207.4 174.8Q171.9 187.7 122.5 187.7Z" }, + { char: "z", advance: 574, left: 34, right: 540, top: -526, bottom: 0, + d: "M33.7 0V-48.6L384.7 -451.4H59.2V-526.4H525.6V-480L172.2 -74.9H540.3V0Z" }, + { char: "e", advance: 665, left: 52, right: 613, top: -538, bottom: 12, + d: "M338 12Q247.2 12 183.4 -17.7Q119.6 -47.4 86 -108.5Q52.5 -169.7 52.5 -263Q52.5 -355.5 85.5 -416.5Q118.5 -477.6 182.2 -508Q245.9 -538.4 337.4 -538.4Q430.4 -538.4 491.6 -507.4Q552.7 -476.5 582.7 -420.1Q612.7 -363.7 612.7 -286.4V-239H148.9Q150.3 -175.2 174.5 -136.1Q198.8 -97 241.9 -79.7Q285 -62.4 342.2 -62.4Q381.1 -62.4 412.4 -72.1Q443.6 -81.9 466.4 -98Q489.2 -114.1 502.3 -135Q515.4 -155.9 516.9 -178.3H608Q607.5 -142.1 591.1 -108Q574.6 -73.9 541.6 -46.9Q508.6 -20 457.7 -4Q406.9 12 338 12ZM149.2 -306.6H516.5Q516.5 -351.4 501.9 -381.4Q487.3 -411.5 461.9 -429.7Q436.6 -447.9 403.9 -455.9Q371.3 -463.8 334.8 -463.8Q282.4 -463.8 241.8 -447.3Q201.2 -430.7 177.4 -395.8Q153.5 -360.9 149.2 -306.6Z" }, + { char: "r", advance: 380, left: 75, right: 363, top: -538, bottom: 0, + d: "M74.8 0V-526.4H148.3L155.8 -431.1H162.6Q169.9 -454.8 186.9 -479.5Q204 -504.1 233.4 -521.3Q262.8 -538.4 304.8 -538.4Q320.9 -538.4 336.3 -536.2Q351.8 -534.1 362.5 -530.5V-449.3H316Q273.7 -449.3 245 -433.8Q216.3 -418.2 199 -392.2Q181.8 -366.2 174.1 -335.4Q166.4 -304.7 166.4 -274.1V0Z" }, + ], + kerning: [0, 0, -20, 0], + }, + media: { + text: "MEDIA", + wght: 700, + glyphs: [ + { char: "M", advance: 1055, left: 87, right: 968, top: -687, bottom: 0, + d: "M87.4 0V-687H344.8L474 -350.2Q480.8 -332.7 490.3 -305.1Q499.8 -277.4 509.7 -248.2Q519.7 -218.9 527.2 -195.6H534.5Q541.1 -216.2 550.3 -243.8Q559.6 -271.5 569.4 -299.8Q579.2 -328.2 586.8 -349.3L716.1 -687H967.7V0H802V-374.7Q802 -400.8 802.6 -430Q803.3 -459.1 804.3 -484.8Q805.4 -510.4 806 -525H798Q793.4 -509.8 785.4 -484.9Q777.3 -460.1 768.9 -434.4Q760.6 -408.6 753.5 -389.6L601.1 0H447.1L294 -389.4Q285.6 -411.5 277.1 -436.6Q268.6 -461.8 261.8 -485.3Q254.9 -508.9 249.7 -524.9H241.7Q242.7 -508.2 243.4 -482.6Q244 -456.9 244.6 -428.9Q245.2 -400.8 245.2 -374.7V0Z" }, + { char: "E", advance: 814, left: 88, right: 755, top: -687, bottom: 0, + d: "M87.6 0V-687H747.7V-554.3H254V-414.7H690.5V-283.9H254V-132.7H755.2V0Z" }, + { char: "D", advance: 879, left: 87, right: 822, top: -687, bottom: 0, + d: "M87.4 0V-687H434.6Q555.6 -687 642.3 -648.1Q728.9 -609.2 775.4 -532.9Q821.8 -456.6 821.8 -343.2Q821.8 -230.7 775.4 -154.2Q728.9 -77.8 642.3 -38.9Q555.6 0 434.6 0ZM253.6 -132.7H427.5Q477.7 -132.7 518.6 -145.6Q559.5 -158.5 589.1 -183.4Q618.6 -208.3 634.5 -245.4Q650.5 -282.5 650.5 -331.1V-356Q650.5 -404.8 634.5 -441.8Q618.6 -478.9 589.1 -503.8Q559.5 -528.7 518.6 -541.5Q477.7 -554.3 427.5 -554.3H253.6Z" }, + { char: "I", advance: 341, left: 87, right: 254, top: -687, bottom: 0, + d: "M87.4 0V-687H253.6V0Z" }, + { char: "A", advance: 864, left: 16, right: 847, top: -687, bottom: 0, + d: "M16.5 0 336.2 -687H527.3L847.3 0H666.5L609.7 -127.1H244.1L187.4 0ZM301.5 -256.6H552.2L482.5 -416.9Q478.1 -427.3 470.4 -445.9Q462.8 -464.6 454.9 -485.1Q446.9 -505.7 440.2 -522.9Q433.5 -540.2 430.8 -547.1H423.7Q416.1 -527.7 406.1 -502.5Q396.1 -477.3 386.8 -454Q377.6 -430.6 371.2 -416.3Z" }, + ], + kerning: [0, 0, 0, 0], + }, +} as const satisfies Record<string, MediaRun>; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **umtool's report videos can wear the Archilyzer Media brand, and the channel's YouTube picture, watermark and banner are generated.** A report video whose manifest says `"render": { "brand": "archilyzer-media" }` (umtool's **new project** form now has a brand choice, and `umtool new` takes `--brand archilyzer-media`) is drawn in the channel's slate-and-teal palette and its three faces, with a title card that carries the Archilyzer Media lockup and the found line, the mark at the head of every clip's citation line, a 20-second end card whose right half is left empty for YouTube's end-screen videos (`render.endCard` changes its length or, with `false`, drops it), and a 1280 × 720 thumbnail from a clip still and a headline (`thumbnail` in the manifest; `build-video.mjs --thumbnail` makes it alone). A manifest without the key renders exactly as before, byte for byte. `pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts` writes the channel picture, the video watermark, the banner and a preview of its phone crop to `~/reports/archilyzer-media/brand/`, with an `INDEX.html` that walks through the YouTube Studio upload. The fonts (Archivo, IBM Plex Sans, IBM Plex Mono) ship in `umtool/report-to-video/fonts/` under the SIL Open Font License; nothing is installed system-wide. + ## [0.9.0] - 2026-09-26 - **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, even with JavaScript off, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Success, warning and other status text reads at 4.5:1 on its own tinted fill on every ground; on Light, success and warning are a shade deeper than before for it. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage. - **Every site, the hub, the homepage and the editor wear the new Found-line mark, and a site's header splits its wordmark.** The mark is four transcript lines on a rounded square, the second lit and carrying a play head. The favicon, app icons and touch icon are no longer committed files: each build draws them from the mark and writes `/icons/icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`, `maskable-512.png`, `apple-touch-icon.png` and `/favicon.ico` (16, 32 and 48 px). A site's icons are an ink tile lit with its own accent (Signal when it sets none; a custom colour is lightened until it reads on the tile). The hub, the homepage and the editor use the parent mark, bone on slate. The site header shows the mark and the header title split at the site's **Wordmark lead**, the lead heavy and the rest light (Jer|alyzer); with no lead the whole title is heavy. The header mark's lit line follows the reader's accent; the icons keep the site's. The footer's "Built with Archilyzer" has the small parent mark in front of it, outside the link. The homepage header shows the parent mark and "Archi|lyzer", no longer in spaced capitals. An installed app's title bar is the dark ground (`#0c0a08`) and its splash the icon's tile, where a site's used to be the old default blue. The service workers fetch icons fresh whenever the reader is online and keep a copy for offline, so an installed app picks up the new icons, and any later accent change, on its next online visit. Their shell cache is renamed to `shell-v2`: readers' offline channel downloads are kept, but the old cached pages and scripts go, so an installed app opens offline again only after one more online visit. The editor's sidebar shows the parent mark beside the admin title, and the editor now has a favicon. Needs a rebuild and deploy of every site, the hub and the homepage. diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md @@ -4,6 +4,8 @@ Status: SHIPPED to main 2026-09-25 (S0 `7c9e3bdf`, S1 `575ae1d4`, S2 `b9772e53`, `plans:` records), not rolled out. Worked on `brand/found-line` (worktree `../brand-found-line`, pnpm wt block #1), with S1 and S2 as `brand/mark` and `brand/themes` off S0's tip. The release is [`release-10.md`](release-10.md); the operator's runbook is `~/reports/release-10/RUNBOOK.html`. +S4 (Archilyzer Media, the YouTube channel) was built after the release-10 cut on `brand/media` +(worktree `../brand-media`, block #3) and is not part of the site rollout; see "Slice S4, as shipped". Design canvas: https://claude.ai/artifact/UsUxwgRkP5a3m4jZXucAvG (boards *Family*, *In context*, *Themes · base × accent*, and the rejected directions A–C). Read it with Artifact @@ -1307,8 +1309,8 @@ S4 ships after release 10's cut and is **not part of the site rollout**: it touc - `banner-safe-1546x423.png`, the part every device shows. It also writes `INDEX.html` (operator rule: handoffs are HTML), with a preview of each asset, - its size, and the YouTube Studio upload steps: Customization → Branding → Picture / Banner - image / Video watermark. + its size, and the YouTube Studio upload steps: Customization → Profile (formerly Branding) → + Picture / Banner image / Video watermark → Change (or Upload). 3. **The umtool report-to-video brand preset: opt-in, and nothing else moves.** - **How it's enabled:** a manifest opts in (e.g. `render.brand: "archilyzer-media"`). The preset sets `render.palette` (bg `#151b20`, fg `#e7edf1`, muted `#8496a2`, accent `#5fa8a0`, amber @@ -1347,3 +1349,203 @@ S4 ships after release 10's cut and is **not part of the site rollout**: it touc fixture manifest. Look at each one. - umtool e2e if umtool's code is touched (`SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e`, queued). + +### Slice S4, as shipped — Archilyzer Media (2026-09-26) + +Branch `brand/media` off `main` at `4e5630aa` (worktree `../brand-media`, port block #3). S4 gives the +operator's YouTube channel its assets and gives umtool's report videos an opt-in brand: a pure +`common/lib/brandMedia.ts` (the lockup with its letters as outlines, the banner, mark M2), a CLI that +renders the channel's four PNGs and an `INDEX.html` handoff, and a report-to-video preset behind one +manifest key. **A manifest without the key renders byte-identical** (334 files compared, below). No +site, export, homepage or editor app file changed; `common/lib/brandIcons.ts` gained +`renderSvgPng`, which `renderIconPng` now calls (the nine icon PNGs checked, byte-identical). + +**The lockup, measured rather than drawn.** +- `common/bin/gen-media-glyphs.py` (fontTools instancer; reads the Archivo umtool vendors, or + `--download`) writes `common/lib/brandMediaGlyphs.ts` (do-not-edit header, font sha256): per glyph + the advance, the ink box and the outline (y down, baseline 0), for `Archi` 720, `lyzer` 380 and + `MEDIA` 700 at wdth 118, plus GPOS pair kerning inside each run (`rc` -14, `ze` -20; none in MEDIA). + The instanced cap height is 686 for all three weights. +- The spec's 5.334 em is Σ advances − 0.01 em × 10 letters **without** kerning; a browser kerns, which + gives 5.300. `mediaLockupGeometry` keeps the spec's width (the canvas's `textLength`) and places the + kerned glyphs with one uniform adjustment per gap (−7.33 units), exactly what + `lengthAdjust="spacing"` did on the canvas. MEDIA at 1.035× then gets **0.3002 em** between letters, + matching "0.30 em". +- Block = cap line to MEDIA's baseline = (0.686 + 0.30 + 0.686 × 1.035) px = 1.696 px; the mark is that + tall, 0.46 px from the text. At the banner's 124 px: MEDIA 128.34, text 661.4, mark 210.3, block + centred on the safe area at (815.6, 614.8) — the canvas had (817, 615) from hand-rounded numbers. + `h`, `i`, `l` rise 0.038 em above the cap line; the SVG adds that on top (`inkTop`) so nothing clips. +- The banner's field is a **port** of the canvas generator, not a transcription: `PyRandom` is + CPython's MT19937 + `init_by_array` + `_randbelow`, and the test pins its first uint32s, the first + `choice`s and the 87 lines against the Python output of `gen_media.py`'s `banner()`. + +**`common/lib/brandMedia.ts`** (pure; imports `brand.ts` and the glyphs): `MEDIA_PALETTE` +(ground `#151b20`, dim `#3f4c56`, lit/media Signal `#5fa8a0`, fg `#e7edf1`, muted `#8496a2`, amber +`#e3b15c`), `MEDIA_MARK_PALETTE`, `MEDIA_RATIO = 1.035`, `mediaLockupGeometry(px)`, +`mediaLockupSvg({px, palette})` (reuses `markSvg`, re-rooted as a nested `<svg>`), `MEDIA_BANNER`, +`mediaBannerField`, `mediaBannerLayout`, `mediaBannerSvg({view?})`, `mediaMarkSvg(variant)`, and +`mediaVideoKit()` — the JSON the umtool preset reads, since its scripts are plain `node`. + +**`common/bin/brand-media.ts`** (`--out`, default `~/reports/archilyzer-media/brand/`; `--video-kit` +rewrites `umtool/report-to-video/brands/archilyzer-media.json`). Rendered through `renderSvgPng` +(next/og) under tsx with no trouble, so rsvg-convert was not needed. Run for real: + +| file | IHDR | bytes | +|---|---|---| +| `avatar-800.png` (maskable) | 800 × 800 | 19,730 | +| `watermark-150.png` (`any`, transparent corners) | 150 × 150 | 2,753 | +| `banner-2560x1440.png` | 2560 × 1440 | 117,254 (limit 6 MB) | +| `banner-safe-1546x423.png` | 1546 × 423 | 33,534 | + +plus `avatar.svg`, `watermark.svg`, `banner.svg`, `banner-safe.svg` (the same drawing with viewBox +`507 508.5 1546 423`), `lockup.svg` and `INDEX.html` (previews, sizes, Customization → Profile +(formerly Branding) → Picture / Banner image / Video watermark → Change, then Publish). All four PNGs +and the page were looked at. + +**The umtool preset** (`render.brand: "archilyzer-media"`; README "The Archilyzer Media preset"). +- `brand.mjs`: `resolveBrandRender` (owns `palette` and `fontRegular` = vendored Plex Mono; resolves + `render.endCard` — default 20 s, a number, `{seconds, url}` or `false`), `brandManifest` (appends + `{type:"card", id:"end", style:"end", hideRail:true, chapter:"End"}` unless the timeline has its own + end card; an unrelated `end` id is an error), `brandFaces`, `childOpts` (FONTCONFIG_FILE on the + preset's own `magick`/`rsvg-convert` children), `brandHeaderGeometry`. Every one returns its input + object itself when there is no brand. `brand-ids.mjs` holds the ids alone, because umtool's + registry is read by client components and must not pull `node:fs`. +- The hook is **the end of `selectVariant`**, the one door the build, verify-build, compose-chrome and + umtool's export all use — so all of them agree the end card exists. +- `brand-cards.mjs`: the title card (lockup top-left at the board's 34 × 2.424; the title in + `Archivo @wght=720,wdth=118` at 97 px, line height 1.08, from y 412; `sub` in Plex Sans; the found + line; a Plex Mono `meta ?? foot` line), the end card (lockup at 68 px + `archilyzer.pages.dev`, + centred with room for the subscribe element; a guard throws if the column reaches the right half), + the header mark PNG, and `renderThumbnail` (still cover-cropped into the left 711 px, the headline + in Archivo 800 from 78 px shrinking by 8 % until it clears the duration badge and no word overflows, + refused below 44 px; the mark at 29,29). +- `render-cards.mjs`: `renderCard` routes title/end to the preset when branded; `span` takes a Pango + `face` (only ever passed when branded), so chapter / bullets / sources / timeline / footer labels + take the preset's faces with their layouts unchanged. +- `build-video.mjs`: `headerFilters` (the unbranded tick + drawtext, verbatim; branded: two drawtexts + in Plex Mono 20 px, the channel drawn again in the foreground over the line's head — same glyphs, + same origin, no measuring); the mark (34 px in the 56 px header, at x 90 where the tick was) is the + LAST ffmpeg input, so every unbranded input index is unchanged; `--thumbnail` and a thumbnail after a + full branded build when `manifest.thumbnail` is set (`{headline, clip?, at?, still?}`; the frame + comes from the cached window, never the network; `.jpg` too if the PNG passes 2 MB). +- **Surface:** umtool has no render settings UI; the new-project form is where a report video's + render block is born, so the brand is a select there (declared in the registry as a scaffold field + with `brands`, the menu renders it from data) and `umtool new --brand archilyzer-media`. No brand + writes the skeleton it always did. +- **Not branded, on purpose:** the rail, `ledger`, `scroll` and `chart` SVG text stays Fira Sans — + `fit()` truncates against Fira's measured 0.50 em average, and swapping the face without + re-measuring would overrun columns. The palette does apply. + +**Fonts** (`umtool/report-to-video/fonts/`, committed unmodified from google/fonts; Plex carries the +Reserved Font Name "Plex", so it may not ship subset or modified): + +| file | bytes | source | +|---|---|---| +| `Archivo[wdth,wght].ttf` | 658,596 | `ofl/archivo` @ `95f4904f` | +| `IBMPlexSans[wdth,wght].ttf` | 537,244 | `ofl/ibmplexsans` @ `0b58fb37` | +| `IBMPlexMono-Regular.ttf` (static, for drawtext) | 135,580 | `ofl/ibmplexmono` @ `0b58fb37` | +| `OFL.txt` (both upstream OFL files, verbatim) | 8,846 | | + +1.33 MB of fonts in all. No italics, no other Plex Mono weights: nothing draws them. `fonts.conf` +adds the directory and includes the system config (fallback for emoji / CJK, and its cache dir); +`install-user-fonts.sh` is the no-sudo fallback. + +**The byte-identity proof** (`$T/s4-identity.mjs`, `$T/s4-idiff.mjs`). Before any report-to-video +change, the harness rendered, into `$T/s4-before/`: every card, footer, rail, ledger, scroll and chart +asset of five real manifests (`elfpire-eva-lawyer`, `quartering-christian`, +`quartering-employee-count`, `ferret-rescue`, `destinys-child`; both variants; 70 render calls; the +manifests read, never written) plus each resolved `render` block, and a full offline build of a +fixture (local corpus, cached windows made with `ffmpeg -f lavfi`: title / chapter / sources cards, +two clips with header, footer, marker and QR, an image entry, xfade, chapters) — **334 files**. The +same harness after the preset, on the final tip `782eb48e`: **334 files, 201 raw byte-identical, 133 +identical but for ImageMagick's three `tEXt date:*` chunks, 0 different, none missing or extra.** +- ImageMagick stamps the wall clock into every PNG and ignores `SOURCE_DATE_EPOCH`, so the control — + the UNCHANGED code run twice — already differs in exactly those 133 files in exactly those chunks + (`$T/s4-before` vs `$T/s4-before2`: 201 / 133 / 0). The diff drops only `tEXt` chunks whose key + starts `date:` and `tIME`; IHDR, IDAT and every other byte are compared. +- The seven mp4s (six segments and the cut) are raw byte-identical: x264 is deterministic here. + +**Samples** (the branded fixture, `$T/s4-fixture/brand`, built offline): `$T/s4-sample-title.png`, +`s4-sample-clip-header.png` (+ `-zoom`), `s4-sample-image-header.png`, `s4-sample-end.png`, +`s4-sample-thumbnail.png`, `s4-sample-chapter.png`, `s4-sample-sources.png`. Each was looked at +against the canvas's *in the video* board. The first header had the mark 5 px low and small beside the +text; it is now 0.6 of the header, the capitals centred on it (drawtext `y` is the top of the tallest +glyph, so it is set from Plex Mono's 0.74 em ascender and 0.698 em cap height). + +**Tests.** +- `common/lib/brandMedia.test.ts` (7): the palette and mark M2 = `markSvg`; the measured widths + (5.334 / 3.953 / 686); **MEDIA's advance = the wordmark's within 0.5 %** at 18/28/64/124 px, 0.30 em + spacing, block ≈ 1.70×, both lines flush; **the lockup's block and ink boxes inside the banner's safe + area**, centred, and every field line clear of the gutter; `PyRandom` = CPython; SVG shape; **the + glyph file = a fresh `gen-media-glyphs.py --stdout`** (skips without python3 + fontTools). +- `common/bin/brand-media.test.ts` (3): the asset list; `writeMediaAssets` into a temp dir — every + IHDR, the banner ≤ 6 MB, sources and INDEX; the committed video kit = `--video-kit`'s output. +- `umtool/report-to-video/brand.test.mjs` (10): no brand → the same objects back from every hook, + `selectVariant`'s pre-preset shape, the unbranded header strings pinned literally; the preset's + render, `endCardConfig`, the end card rules, the branded header, the fonts and OFL, the kit; a guarded + render (title and end at 1920 × 1080, the end card's right half one colour, the thumbnail 1280 × 720 + with the badge corner one colour). +- `umtool/e2e/projects.spec.ts` +1: `umtool new --brand` writes `render.brand` (and still the palette), + no brand writes none, a bad brand exits non-zero; the form's brand select defaults to "no brand", + offers "Archilyzer Media", and creating with it writes `render.brand`. + +| sha | what | +|---|---| +| `fe053e74` | umtool: vendor Archivo, IBM Plex Sans, IBM Plex Mono Regular + `OFL.txt`, `fonts.conf`, `install-user-fonts.sh` | +| `598fe502` | common: `brandMedia.ts`, `brandMediaGlyphs.ts` (generated), `bin/gen-media-glyphs.py`, tests | +| `6d27814f` | common: `bin/brand-media.ts` (+ test), `renderSvgPng` in `brandIcons.ts`, the video kit JSON | +| `a91d6527` | umtool: the report-to-video preset (`brand.mjs`, `brand-ids.mjs`, `brand-cards.mjs`, render-cards / build-video hooks, README, tests) | +| `c2483ebf` | umtool: the brand choice in the new-project form and `umtool new --brand`, e2e +1 | +| `782eb48e` | common: INDEX.html's timestamp says UTC | + +**Gates.** +- tsc (`pnpm -r … tsc --noEmit`): clean before the common commits (59 s) and on the umtool tree (88 s); + common's own tsc before the two later common commits. +- common **1,923/1,923** (1,913 + 10); editor unit **79/79**; `test:scripts` **172 + 1 skip** (162 + + 10); mcp **219/219**. +- Builds: `pnpm --filter editor exec next build` ok (69 s); `pnpm --filter umtool run build` ok (22 s). + The umtool bundle keeps `brand.mjs`'s `import.meta.url` as the source path + (`file://${P("umtool/report-to-video/brand.mjs")}`), so the kit and fonts resolve in the server too. +- umtool e2e, full suite (`SONG_DIR=~/reports/quartering-uh-song/data node scripts/worktree.mjs run -- + pnpm --filter umtool run e2e`, from the worktree): **175 passed, 2 failed, 45 skipped**, 4.5 min + after 17.5 min in the queue. The new `projects.spec.ts:506` passed. The two failures are + `mix.spec.ts:166` and `:201`, the pair FACTS.md ("The umtool suite skips instead of going red…") + records as failing in every full run on main (order-dependent clip windows in the fixture); the 45 + skips are the song-data capabilities this machine lacks. + Alone, `mix.spec.ts`: **12 passed**, 24 s (after 1.5 min in the queue), `:110`, `:166` and `:201` + included.- The identity diff: 334 files, 0 different (above). +- Numbers tools: none. + +**Found and left.** +- `render-cards.mjs` sets `-define pango:width=…`, which this ImageMagick (7.1.2) ignores; the wrap + comes from `-size`. Harmless, and untouched (it is in the unbranded path). +- ImageMagick's pango coder lays out at 96 dpi: `-density 72` makes 1 pt = 1 px but wraps at ¾ of the + `-size` width. The preset sizes in px through `pt(px) = 0.75 px`. +- `render.fontBold` is left as the manifest has it under the preset: only compose-chrome's HyperFrames + band reads it, and no Plex Mono bold is vendored. +- umtool's project page lists the raw timeline, so the appended end card is built and chaptered but + not listed there. + +**Review fixes** (verdict SHIP AFTER FIXES, `$T/s4-review.md`; the three rulings — automatic end card, +rail/ledger/chart on Fira, no bold Plex Mono yet — stand). +- `387a8233`, should-fix + nit 4: + - INDEX.html's Studio steps now say **Customization → Profile (formerly Branding)**, then the + asset's **Change** (Upload if it has none), linking YouTube Help "Manage your channel branding" + (answer 10456525): Branding was folded into Profile. The test pins the new words; this plan's + two mentions are corrected. + - `brandMediaGlyphs.ts` records the fontTools it was generated with (`4.65.0`); the parity test + skips, with the reason in the output, when another version is installed (checked with a fake + `python3` on PATH: `# SKIP fontTools 0.0-fake is installed; … generated with 4.65.0`). +- `fde57d46`, nits 2, 3, 5: + - `endCardConfig(null)` is off, like `false`, so resolving a render block twice keeps an + ended-off card off (test +1); + - the header mark is `_mark-<size>-<sha256(kit svg)[:12]>.png`, so a regenerated kit is never + answered by a stale PNG (asserted in the guarded render test); + - `umtool doctor` lists `rsvg-convert` as needed by the brand preset too. +- Nit 6: `release-10.md` says S4 *merges* after the cut. +- Gates: tsc clean (49 s); common **1,923/1,923**, editor unit **79/79**, `test:scripts` **173 + 1 + skip**, mcp **219/219**; `pnpm --filter umtool run build` ok. The identity diff re-run on the fixed + tree anyway (the non-brand path is untouched): **334 files, 201 raw + 133 date-chunk-only, 0 + different**. The branded fixture rebuilt (same 3,631,785-byte cut). The real assets were + regenerated: every PNG and SVG byte-identical to the first run, only `INDEX.html` changed. No e2e: + the one umtool app line is a `neededBy` label no spec reads. diff --git a/plans/release-10.md b/plans/release-10.md @@ -222,6 +222,8 @@ safeRevalidate helper. - **L2:** the editor `next build`, and the editor e2e specs for `/jobs`, boot and downloads (the full editor suite runs at integration). Unit tests for the classification (6) and the timeout (7). +**S4 — Archilyzer Media** (the YouTube channel's assets and umtool's opt-in `render.brand` preset) merges after the release-10 cut, from `brand/media`, and is **not part of the site rollout**: it touches only `common/lib` + `common/bin` and umtool; see `brand-and-themes.md`, "Slice S4, as shipped". + ## Rollout Nothing is rolled out. The live :3001 editor still runs `0213f6c8` (the pre-brand build); the five diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -25,7 +25,7 @@ // umtool build <project> [--preset preview|fast|final] [--only ID] [--dry] // umtool index [--rebuild] [--prune] [--since MS] [--json] // umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>] -// [--site-origin URL] [--seed chapters] +// [--site-origin URL] [--seed chapters] [--brand archilyzer-media] // umtool doctor [--json] exit 1 if the report pipeline is missing a tool // umtool snapshot <project> [--label L] copy the manifest into revisions/ // umtool diff <project> <snapshot> what changed since that snapshot @@ -460,6 +460,7 @@ function usage() { " umtool build <project> [--preset preview|fast|final] [--only ID]", " umtool index [--rebuild] [--prune] [--since MS] [--json]", " umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]", + " [--brand archilyzer-media] render.brand: the report-to-video brand preset", " umtool doctor [--json] exit 1 if the report pipeline is missing a tool", " umtool snapshot <project> [--label L] copy the manifest into revisions/", " umtool diff <project> <snapshot> what changed since that snapshot", @@ -735,7 +736,7 @@ async function cmdNew() { die( "usage: umtool new <slug> [--kind report-video] [--title T]\n" + " [--from <sweep-report.md> | <share URL> | <channel>/<videoId>]\n" + - " [--site-origin <url>] [--seed chapters]", + " [--site-origin <url>] [--seed chapters] [--brand archilyzer-media]", ); } const kind = val("--kind") ?? "report-video"; @@ -750,6 +751,7 @@ async function cmdNew() { from: val("--from") ?? null, siteOrigin: val("--site-origin") ?? null, seed: val("--seed") ?? null, + brand: val("--brand") ?? null, }); } catch (e) { die(e?.message ?? String(e)); @@ -763,6 +765,7 @@ async function cmdNew() { } else { console.log(` video.manifest.json an EMPTY timeline — see README.md`); } + if (r.brand) console.log(` render.brand ${r.brand} — palette, faces, title lockup, header mark, end card`); if (r.from?.kind === "report") console.log(` sweep-report.md copied from ${r.from.path}`); console.log(` README.md ${r.seeded ? "the seeded clips" : `${r.citations} citation(s)`} as a checklist`); for (const n of r.notes) console.log(` NOTE: ${n}`); diff --git a/umtool/components/NewProjectMenu.tsx b/umtool/components/NewProjectMenu.tsx @@ -13,12 +13,14 @@ import { useRouter } from "next/navigation"; // a shape to fill in, which is the whole reason it exists: the five songs and // six report videos made by hand each came out slightly different. -type Kind = { id: string; label: string; scaffold?: { fields: string[] } | null }; +type Brand = { id: string; label: string }; +type Kind = { id: string; label: string; scaffold?: { fields: string[]; brands?: Brand[] } | null }; const FIELD_LABEL: Record<string, string> = { from: "from — a sweep-report .md, a viewer share URL, or <channel>/<videoId>", siteOrigin: "site origin — the archive every QR resolves to (e.g. https://jeralyzer.pages.dev)", seed: "seed the timeline from the video's digest chapters (review each)", + brand: "brand — a report-to-video preset: its palette and faces, the title lockup, the header mark, an end card", }; export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; from?: string | null }) { @@ -31,10 +33,12 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; const [src, setSrc] = useState(from ?? ""); const [origin, setOrigin] = useState(""); const [seed, setSeed] = useState(false); + const [brand, setBrand] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const fields = can.find((k) => k.id === kind)?.scaffold?.fields ?? []; + const brands = can.find((k) => k.id === kind)?.scaffold?.brands ?? []; // Seeding only means something for a video ref, and the server refuses it // otherwise -- so the box is offered only when the source looks like one. const looksLikeVideo = /[?&]v=/.test(src) || /^[\w.-]+\/[\w.-]+$/.test(src.trim()); @@ -47,6 +51,7 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; if (fields.includes("from") && src.trim()) body.from = src.trim(); if (fields.includes("siteOrigin") && origin.trim()) body.siteOrigin = origin.trim(); if (fields.includes("seed") && seed && looksLikeVideo) body.seed = "chapters"; + if (fields.includes("brand") && brand) body.brand = brand; const res = await fetch("/api/projects/new", { method: "POST", headers: { "content-type": "application/json" }, @@ -147,6 +152,23 @@ export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; seed from chapters </label> )} + {fields.includes("brand") && brands.length > 0 && ( + <select + value={brand} + onChange={(e) => setBrand(e.target.value)} + aria-label={FIELD_LABEL.brand} + title={FIELD_LABEL.brand} + data-new-brand + className="rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]" + > + <option value="">no brand</option> + {brands.map((b) => ( + <option key={b.id} value={b.id}> + {b.label} + </option> + ))} + </select> + )} <button type="button" disabled={busy || !slug.trim()} diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts @@ -499,3 +499,54 @@ test("a timeline entry of an unknown type renders, rather than crashing the page await expect(page.locator("[data-entry]")).toHaveCount(6); await expect(page.locator("[data-entry=c01]")).toHaveAttribute("data-kind", "clip"); }); + +// The brand preset (report-to-video/brand.mjs, `render.brand`) is offered where +// a report video's render block is born: the new-project form and `umtool new`. +// No brand writes exactly the skeleton it always did. +test("new project offers the brand preset, and only a branded scaffold carries render.brand", async ({ + page, +}) => { + const dir = path.join(FIXTURE, "scaffold-brand"); + rmSync(dir, { recursive: true, force: true }); + const env = { ...cliEnv, REPORTS_DIR: dir }; + const run = (args: string[]) => + execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env }); + type Render = { brand?: string; palette: Record<string, string> }; + const renderOf = (root: string, slug: string) => + (JSON.parse(readFileSync(path.join(root, slug, "video.manifest.json"), "utf8")) as { render: Render }).render; + + const out = JSON.parse(run(["new", "branded", "--brand", "archilyzer-media", "--json"])) as { brand?: string }; + expect(out.brand).toBe("archilyzer-media"); + expect(renderOf(dir, "branded").brand).toBe("archilyzer-media"); + // The palette is still written: dropping `brand` later leaves a cut that renders. + expect(renderOf(dir, "branded").palette.bg).toBeTruthy(); + + const plain = JSON.parse(run(["new", "plain", "--json"])) as Record<string, unknown>; + expect("brand" in plain).toBe(false); + expect("brand" in renderOf(dir, "plain")).toBe(false); + + let code = 0; + try { + run(["new", "acme", "--brand", "acme"]); + } catch (e) { + code = (e as { status: number }).status; + } + expect(code).not.toBe(0); + rmSync(dir, { recursive: true, force: true }); + + // The form: the preset is a choice, "no brand" the default. + const slug = "brand-ui-probe"; + const created = path.join(FIXTURE, slug); + rmSync(created, { recursive: true, force: true }); + await page.goto("/"); + await page.locator("[data-new-project]").click(); + const brand = page.locator("[data-new-brand]"); + await expect(brand).toHaveValue(""); + await expect(brand.locator("option")).toHaveText(["no brand", "Archilyzer Media"]); + await page.locator("[data-new-slug]").fill(slug); + await brand.selectOption("archilyzer-media"); + await page.locator("[data-new-create]").click(); + await page.waitForURL(`**/browse/${slug}`); + expect(renderOf(FIXTURE, slug).brand).toBe("archilyzer-media"); + rmSync(created, { recursive: true, force: true }); +}); diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -14,6 +14,7 @@ // spec that fails if a kind id appears as a string literal anywhere outside // lib/projects/ and components/projects/ -- that is the mechanical form of this // rule, and it is what stops `if (kind === "report-video")` appearing in a page. +import { BRAND_CHOICES } from "umtool-report-to-video/brand-ids"; import { MANIFEST_NAME, REPORT_DECISION_KINDS, reportDecisions, reportSignature, summariseReport } from "./report.mjs"; import { CUT_NAMES, songSignature, summariseSong } from "./song.mjs"; import { sweepSignature, summariseSweep } from "./sweep.mjs"; @@ -77,7 +78,9 @@ export const PROJECT_KINDS = [ views: ["clip", "claim"], // What "new project" asks for, beyond a slug and a title. Declared HERE so // the menu renders fields from data and never branches on a kind id. - scaffold: { fields: ["from", "siteOrigin", "seed"] }, + // `brand` offers report-to-video's presets (render.brand); none is the + // default and writes the manifest it always did. + scaffold: { fields: ["from", "siteOrigin", "seed", "brand"], brands: BRAND_CHOICES }, }, { id: "song", diff --git a/umtool/lib/projects/scaffold.mjs b/umtool/lib/projects/scaffold.mjs @@ -5,6 +5,7 @@ // Plain ESM: `umtool new` runs from a terminal with no server. import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import { BRAND_IDS } from "umtool-report-to-video/brand-ids"; import { GLOBAL_CHANNELS_DIR, MANIFEST_NAME } from "./report.mjs"; export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; @@ -23,8 +24,8 @@ export const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; * (`--seed chapters` is the one exception, and it invents nothing: the windows * it writes are the digest's own chapter boundaries.) */ -export function skeleton(slug, title, provenance) { - return { +export function skeleton(slug, title, provenance, { brand = null } = {}) { + const doc = { schemaVersion: 1, slug, title, @@ -62,6 +63,12 @@ export function skeleton(slug, title, provenance) { timelineNodes: [], timeline: [], }; + // A brand preset (report-to-video/brand.mjs) owns the palette and the faces + // and adds the title lockup, the header mark, the end card and a thumbnail + // step. The key is ADDED, never a different skeleton: the palette above is + // still written, so dropping `brand` later leaves a manifest that renders. + if (brand) doc.render.brand = brand; + return doc; } /** @@ -151,9 +158,9 @@ export async function chaptersAsClips({ channelsDir, channel, video }) { * Write the project. Refuses an existing directory and a slug that will not * route; everything else it cannot know is written EMPTY so `check` blocks. * - * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, channelsDir?: string }} args + * @param {{ root: string, slug: string, title?: string, from?: string | null, siteOrigin?: string | null, seed?: string | null, brand?: string | null, channelsDir?: string }} args */ -export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, channelsDir = GLOBAL_CHANNELS_DIR() }) { +export async function scaffoldReportVideo({ root, slug, title, from = null, siteOrigin = null, seed = null, brand = null, channelsDir = GLOBAL_CHANNELS_DIR() }) { if (!SLUG_RE.test(String(slug ?? ""))) { throw new Error(`"${slug}" will not route — use lower-case letters, digits and dashes`); } @@ -171,6 +178,9 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site } if (seed && seed !== "chapters") throw new Error(`--seed must be "chapters", not "${seed}"`); if (seed && src?.kind !== "video") throw new Error("--seed chapters needs --from to be a video ref"); + if (brand && !BRAND_IDS.includes(brand)) { + throw new Error(`--brand must be one of ${BRAND_IDS.join(", ")}, not "${brand}"`); + } let finalTitle = String(title ?? "").trim() || slug.replace(/-/g, " "); let reportText = null; @@ -194,7 +204,7 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site } if (siteOrigin) provenance.siteOrigin = String(siteOrigin); - const doc = skeleton(slug, finalTitle, provenance); + const doc = skeleton(slug, finalTitle, provenance, { brand }); let seeded = 0; if (seed === "chapters") { @@ -254,5 +264,6 @@ export async function scaffoldReportVideo({ root, slug, title, from = null, site seeded, notes, siteOrigin: provenance.siteOrigin ?? "", + ...(brand ? { brand } : {}), }; } diff --git a/umtool/lib/projects/scaffold.ts b/umtool/lib/projects/scaffold.ts @@ -14,6 +14,7 @@ export type NewProjectBody = { from?: string | null; siteOrigin?: string | null; seed?: string | null; + brand?: string | null; }; /** @@ -39,6 +40,7 @@ export async function scaffoldProject(body: NewProjectBody): Promise<{ href: str from: body.from || null, siteOrigin: body.siteOrigin || null, seed: body.seed || null, + brand: body.brand || null, }); result = { ...r, href: `/browse/${r.id}` }; } catch (e) { diff --git a/umtool/lib/tools.mjs b/umtool/lib/tools.mjs @@ -38,7 +38,7 @@ export const TOOLS = () => [ { id: "yt-dlp", bin: process.env.YTDLP_BIN ?? "yt-dlp", args: ["--version"], neededBy: ["report-video fetch", "check-sources"], required: true }, { id: "qrencode", bin: process.env.QRENCODE_BIN ?? "qrencode", args: ["--version"], neededBy: ["report-video QR"], required: true }, { id: "imagemagick", bin: "magick", args: ["-version"], fallback: "convert", neededBy: ["report-video cards, rail, chrome"], required: false }, - { id: "rsvg-convert", bin: process.env.RSVG_BIN ?? "rsvg-convert", args: ["--version"], neededBy: ["report-video chart"], required: false }, + { id: "rsvg-convert", bin: process.env.RSVG_BIN ?? "rsvg-convert", args: ["--version"], neededBy: ["report-video chart", "brand preset"], required: false }, { id: "python", bin: facedetPython(), args: ["--version"], neededBy: ["faces (facecrop.py)"], required: false }, { id: "facecrop.py", file: facecropPy(), neededBy: ["faces"], required: false }, ]; 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,295 @@ +// 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 { createHash } from "node:crypto"; +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. Rendered once per + * directory per DRAWING: the name carries a hash of the kit's SVG, so a kit + * regenerated by `brand-media.ts --video-kit` is never answered by a stale + * PNG left in out/<variant>/cards. + */ +export async function markPng(render, size, dir) { + const svg = brandKit(render.brand).mark.any; + const key = createHash("sha256").update(svg).digest("hex").slice(0, 12); + const file = path.join(dir, `_mark-${size}-${key}.png`); + if (!(await exists(file))) { + await mkdir(dir, { recursive: true }); + await svgPng(render, svg, path.join(dir, `_mark-${size}-${key}.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,196 @@ +// 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. `null` is + * OFF too: it is what this function returns for `false`, so resolving a render + * block twice leaves an ended-off card off. + * @returns {{ seconds: number, url: string } | null} + */ +export function endCardConfig(endCard) { + if (endCard === false || endCard === null) 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,195 @@ +// 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 { markPng, 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("endCard false stays off when a render block is resolved twice", () => { + assert.equal(endCardConfig(null), null); + const branded = { ...PLAIN_RENDER, brand: "archilyzer-media" }; + const once = resolveBrandRender({ ...branded, endCard: false }); + assert.equal(once.endCard, null); + const twice = resolveBrandRender(once); + assert.equal(twice.endCard, null); + assert.equal(brandManifest(manifest(twice)).timeline.length, 1); + // A configured card resolves to itself. + const on = resolveBrandRender({ ...branded, endCard: { seconds: 12 } }); + assert.deepEqual(resolveBrandRender(on).endCard, on.endCard); +}); + +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"); + // The header mark is cached per DRAWING, not per size: its name carries + // the kit SVG's hash, so a regenerated kit cannot be answered by a stale PNG. + const mark = await markPng(render, 34, dir); + assert.match(path.basename(mark), /^_mark-34-[0-9a-f]{12}\.png$/); + assert.equal(size(mark), "34 34"); + assert.equal(await markPng(render, 34, dir), mark); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/brands/archilyzer-media.json b/umtool/report-to-video/brands/archilyzer-media.json @@ -0,0 +1,23 @@ +{ + "$comment": "GENERATED by common/bin/brand-media.ts --video-kit from common/lib/brandMedia.ts -- do not edit; re-run it.", + "id": "archilyzer-media", + "palette": { + "bg": "#151b20", + "fg": "#e7edf1", + "muted": "#8496a2", + "accent": "#5fa8a0", + "amber": "#e3b15c", + "dim": "#3f4c56" + }, + "mark": { + "any": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\"><rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#151b20\"/><rect x=\"112\" y=\"128\" width=\"288\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/><polygon points=\"112,210 172,234 112,258\" fill=\"#5fa8a0\"/><rect x=\"188\" y=\"212\" width=\"212\" height=\"44\" rx=\"22\" fill=\"#5fa8a0\"/><rect x=\"112\" y=\"296\" width=\"232\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/><rect x=\"112\" y=\"380\" width=\"152\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/></svg>" + }, + "lockup": { + "px": 100, + "width": 749.001, + "height": 173.401, + "blockTop": 3.8, + "blockHeight": 169.601, + "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"749.001\" height=\"173.401\" viewBox=\"0 0 749.001 173.401\"><svg x=\"0\" y=\"3.8\" width=\"169.601\" height=\"169.601\" viewBox=\"0 0 512 512\"><rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#151b20\"/><rect x=\"112\" y=\"128\" width=\"288\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/><polygon points=\"112,210 172,234 112,258\" fill=\"#5fa8a0\"/><rect x=\"188\" y=\"212\" width=\"212\" height=\"44\" rx=\"22\" fill=\"#5fa8a0\"/><rect x=\"112\" y=\"296\" width=\"232\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/><rect x=\"112\" y=\"380\" width=\"152\" height=\"44\" rx=\"22\" fill=\"#3f4c56\"/></svg><g fill=\"#e7edf1\"><path transform=\"translate(215.601 72.4) scale(0.1)\" d=\"M15.8 0 334.8 -687.1H532.2L851.4 0H664.3L609.1 -124.2H248.1L193.1 0ZM306.3 -256.8H550.8L483.3 -413.3Q479 -423.5 471.5 -441.8Q464.1 -460.2 456.4 -480.5Q448.6 -500.8 442.1 -518Q435.5 -535.3 432.6 -542.8H425.4Q418 -523.5 408.2 -498.6Q398.4 -473.6 389.3 -450.4Q380.2 -427.2 373.8 -412.8Z\"/><path transform=\"translate(301.568 72.4) scale(0.1)\" d=\"M66.7 0V-527.1H194.4L205.7 -438.5H213.6Q226.2 -467.5 247.1 -490.4Q268 -513.2 297.3 -526.3Q326.6 -539.4 362.6 -539.4Q381.2 -539.4 397.9 -536.5Q414.7 -533.7 427.6 -528.8V-396.2H358.5Q322.8 -396.2 297.3 -384.9Q271.8 -373.6 255.6 -353.5Q239.4 -333.3 231.6 -307.2Q223.8 -281.2 223.8 -251.1V0Z\"/><path transform=\"translate(343.334 72.4) scale(0.1)\" d=\"M359.3 12Q260.8 12 190.3 -18.3Q119.7 -48.6 82.2 -109.9Q44.6 -171.2 44.6 -263.8Q44.6 -356.7 82.3 -417.7Q119.9 -478.8 190.5 -509Q261 -539.2 359.3 -539.2Q422.3 -539.2 476.3 -525.7Q530.4 -512.2 571.1 -484.8Q611.8 -457.3 634.2 -416.5Q656.5 -375.7 656.5 -321.2H498.4Q498.4 -354.6 480.2 -377.6Q462 -400.6 430.6 -412.6Q399.2 -424.6 358.9 -424.6Q309 -424.6 274.9 -406.9Q240.9 -389.3 223.6 -355.8Q206.4 -322.4 206.4 -274.4V-252.9Q206.4 -205.6 224 -171.9Q241.7 -138.1 276.9 -120.3Q312.2 -102.5 364.6 -102.5Q404.4 -102.5 435.9 -115.3Q467.4 -128.1 486 -151.7Q504.6 -175.3 504.6 -206.7H656.5Q656.5 -151.9 633.9 -110.7Q611.3 -69.5 571 -42.3Q530.8 -15.1 476.5 -1.6Q422.3 12 359.3 12Z\"/><path transform=\"translate(412.801 72.4) scale(0.1)\" d=\"M66.7 0V-724.1H223.8V-460.6H231Q255.7 -488.9 287 -506Q318.3 -523.2 353.5 -531.1Q388.8 -539.1 424.4 -539.1Q493.7 -539.1 540.9 -516.5Q588.1 -494 612.4 -447.8Q636.7 -401.7 636.7 -330.2V0H479.4V-306Q479.4 -334.8 471.6 -355.5Q463.8 -376.2 449.2 -389.2Q434.6 -402.2 413.6 -408.2Q392.5 -414.3 366 -414.3Q327.3 -414.3 295.1 -397.9Q262.8 -381.6 243.3 -352.7Q223.8 -323.9 223.8 -285.8V0Z\"/><path transform=\"translate(482.068 72.4) scale(0.1)\" d=\"M66.7 -605.3V-724.1H223.8V-605.3ZM66.7 0V-527.1H223.8V0Z\"/></g><g fill=\"#8496a2\"><path transform=\"translate(510.334 72.4) scale(0.1)\" d=\"M74.8 0V-723.4H166.4V0Z\"/><path transform=\"translate(533.701 72.4) scale(0.1)\" d=\"M122.5 187.7Q100.1 187.7 81 184.7Q62 181.6 46.7 177.4V113.2H99.5Q142.7 113.2 170.5 102.3Q198.3 91.3 217.2 66.6Q236.1 41.9 253.5 0L9.2 -526.4H109.3L232.3 -251.9Q241.3 -233.3 253.1 -204.8Q264.8 -176.3 277.5 -146.4Q290.1 -116.5 299.5 -93.6H306.1Q309.9 -105 316.8 -124.9Q323.7 -144.8 332.5 -168.7Q341.4 -192.5 350.4 -215.9Q359.4 -239.2 366.4 -257.4L470.9 -526.4H566.4L357.1 -15.1Q336.4 34.3 315.4 72.3Q294.5 110.2 268.7 136Q242.9 161.8 207.4 174.8Q171.9 187.7 122.5 187.7Z\"/><path transform=\"translate(590.568 72.4) scale(0.1)\" d=\"M33.7 0V-48.6L384.7 -451.4H59.2V-526.4H525.6V-480L172.2 -74.9H540.3V0Z\"/><path transform=\"translate(645.234 72.4) scale(0.1)\" d=\"M338 12Q247.2 12 183.4 -17.7Q119.6 -47.4 86 -108.5Q52.5 -169.7 52.5 -263Q52.5 -355.5 85.5 -416.5Q118.5 -477.6 182.2 -508Q245.9 -538.4 337.4 -538.4Q430.4 -538.4 491.6 -507.4Q552.7 -476.5 582.7 -420.1Q612.7 -363.7 612.7 -286.4V-239H148.9Q150.3 -175.2 174.5 -136.1Q198.8 -97 241.9 -79.7Q285 -62.4 342.2 -62.4Q381.1 -62.4 412.4 -72.1Q443.6 -81.9 466.4 -98Q489.2 -114.1 502.3 -135Q515.4 -155.9 516.9 -178.3H608Q607.5 -142.1 591.1 -108Q574.6 -73.9 541.6 -46.9Q508.6 -20 457.7 -4Q406.9 12 338 12ZM149.2 -306.6H516.5Q516.5 -351.4 501.9 -381.4Q487.3 -411.5 461.9 -429.7Q436.6 -447.9 403.9 -455.9Q371.3 -463.8 334.8 -463.8Q282.4 -463.8 241.8 -447.3Q201.2 -430.7 177.4 -395.8Q153.5 -360.9 149.2 -306.6Z\"/><path transform=\"translate(711.001 72.4) scale(0.1)\" d=\"M74.8 0V-526.4H148.3L155.8 -431.1H162.6Q169.9 -454.8 186.9 -479.5Q204 -504.1 233.4 -521.3Q262.8 -538.4 304.8 -538.4Q320.9 -538.4 336.3 -536.2Q351.8 -534.1 362.5 -530.5V-449.3H316Q273.7 -449.3 245 -433.8Q216.3 -418.2 199 -392.2Q181.8 -366.2 174.1 -335.4Q166.4 -304.7 166.4 -274.1V0Z\"/></g><g fill=\"#5fa8a0\"><path transform=\"translate(215.601 173.401) scale(0.103)\" d=\"M87.4 0V-687H344.8L474 -350.2Q480.8 -332.7 490.3 -305.1Q499.8 -277.4 509.7 -248.2Q519.7 -218.9 527.2 -195.6H534.5Q541.1 -216.2 550.3 -243.8Q559.6 -271.5 569.4 -299.8Q579.2 -328.2 586.8 -349.3L716.1 -687H967.7V0H802V-374.7Q802 -400.8 802.6 -430Q803.3 -459.1 804.3 -484.8Q805.4 -510.4 806 -525H798Q793.4 -509.8 785.4 -484.9Q777.3 -460.1 768.9 -434.4Q760.6 -408.6 753.5 -389.6L601.1 0H447.1L294 -389.4Q285.6 -411.5 277.1 -436.6Q268.6 -461.8 261.8 -485.3Q254.9 -508.9 249.7 -524.9H241.7Q242.7 -508.2 243.4 -482.6Q244 -456.9 244.6 -428.9Q245.2 -400.8 245.2 -374.7V0Z\"/><path transform=\"translate(355.86 173.401) scale(0.103)\" d=\"M87.6 0V-687H747.7V-554.3H254V-414.7H690.5V-283.9H254V-132.7H755.2V0Z\"/><path transform=\"translate(471.175 173.401) scale(0.103)\" d=\"M87.4 0V-687H434.6Q555.6 -687 642.3 -648.1Q728.9 -609.2 775.4 -532.9Q821.8 -456.6 821.8 -343.2Q821.8 -230.7 775.4 -154.2Q728.9 -77.8 642.3 -38.9Q555.6 0 434.6 0ZM253.6 -132.7H427.5Q477.7 -132.7 518.6 -145.6Q559.5 -158.5 589.1 -183.4Q618.6 -208.3 634.5 -245.4Q650.5 -282.5 650.5 -331.1V-356Q650.5 -404.8 634.5 -441.8Q618.6 -478.9 589.1 -503.8Q559.5 -528.7 518.6 -541.5Q477.7 -554.3 427.5 -554.3H253.6Z\"/><path transform=\"translate(593.217 173.401) scale(0.103)\" d=\"M87.4 0V-687H253.6V0Z\"/><path transform=\"translate(659.577 173.401) scale(0.103)\" d=\"M16.5 0 336.2 -687H527.3L847.3 0H666.5L609.7 -127.1H244.1L187.4 0ZM301.5 -256.6H552.2L482.5 -416.9Q478.1 -427.3 470.4 -445.9Q462.8 -464.6 454.9 -485.1Q446.9 -505.7 440.2 -522.9Q433.5 -540.2 430.8 -547.1H423.7Q416.1 -527.7 406.1 -502.5Q396.1 -477.3 386.8 -454Q377.6 -430.6 371.2 -416.3Z\"/></g></svg>" + } +} 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/fonts/Archivo[wdth,wght].ttf b/umtool/report-to-video/fonts/Archivo[wdth,wght].ttf Binary files differ. diff --git a/umtool/report-to-video/fonts/IBMPlexMono-Regular.ttf b/umtool/report-to-video/fonts/IBMPlexMono-Regular.ttf Binary files differ. diff --git a/umtool/report-to-video/fonts/IBMPlexSans[wdth,wght].ttf b/umtool/report-to-video/fonts/IBMPlexSans[wdth,wght].ttf Binary files differ. diff --git a/umtool/report-to-video/fonts/OFL.txt b/umtool/report-to-video/fonts/OFL.txt @@ -0,0 +1,188 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/umtool/report-to-video/fonts/fonts.conf b/umtool/report-to-video/fonts/fonts.conf @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"> +<!-- + The Archilyzer Media preset's faces (render.brand "archilyzer-media"). + + report-to-video points FONTCONFIG_FILE here for its OWN magick and rsvg-convert + children when a manifest opts in, and for nothing else: a manifest that does not + opt in renders exactly as before, against the system's fonts. The system + configuration is included after this directory so that anything these three faces + lack (emoji, CJK) still falls back, and its cache dir is reused. + + ffmpeg's drawtext does not read this: it takes a fontfile= path, which is why the + mono face is vendored as a static file. +--> +<fontconfig> + <dir prefix="relative">.</dir> + <include ignore_missing="yes">/etc/fonts/fonts.conf</include> +</fontconfig> diff --git a/umtool/report-to-video/fonts/install-user-fonts.sh b/umtool/report-to-video/fonts/install-user-fonts.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Fallback for a renderer that ignores FONTCONFIG_FILE: install the Archilyzer Media +# preset's faces for THIS user (no sudo), under the XDG fonts dir, and refresh the +# cache. The preset itself never needs this; see fonts.conf. +# +# sh umtool/report-to-video/fonts/install-user-fonts.sh +set -eu +here=$(cd "$(dirname "$0")" && pwd) +dest="${XDG_DATA_HOME:-$HOME/.local/share}/fonts/archilyzer-media" +mkdir -p "$dest" +cp "$here"/*.ttf "$here/OFL.txt" "$dest/" +if command -v fc-cache >/dev/null 2>&1; then fc-cache -f "$dest" >/dev/null; fi +echo "installed into $dest" +fc-list 2>/dev/null | grep -E "Archivo|IBM Plex (Sans|Mono)" | sed 's/^/ /' | head -3 || true 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");