// 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 -- the rail, ledger, scroll and // chart SVG text included, measured by its own advances // (svg-faces.mjs) -- and IBM Plex Mono for the header and meta lines. // A manifest that opts in and also sets `palette`, `fontRegular` or // `fontBold` 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/.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(/* turbopackIgnore: true */ fileURLToPath(import.meta.url)); import { BRAND_CHOICES, BRAND_IDS } from "./brand-ids.mjs"; import { IBM_PLEX_SANS } from "./svg-faces.mjs"; export { BRAND_CHOICES, BRAND_IDS }; export const FONTS_DIR = path.join(/* turbopackIgnore: true */ HERE, "fonts"); export const FONTCONFIG_FILE = path.join(/* turbopackIgnore: true */ FONTS_DIR, "fonts.conf"); export const MONO_FONT_FILE = path.join(/* turbopackIgnore: true */ FONTS_DIR, "IBMPlexMono-Regular.ttf"); /** Plex Mono's static Bold: `render.fontBold`, which compose-chrome's HyperFrames band sets its bold in. */ export const MONO_BOLD_FONT_FILE = path.join(/* turbopackIgnore: true */ FONTS_DIR, "IBMPlexMono-Bold.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", }, }; // The face the rail, `ledger`, `scroll` and `chart` SVG text is set in, with // the metric fit() measures it by (svg-faces.mjs). The preset's body face. const SVG_FACES = { "archilyzer-media": IBM_PLEX_SANS, }; const kits = new Map(); /** brands/.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(/* turbopackIgnore: true */ 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, // compose-chrome's HyperFrames band: its bold weight, beside that regular. fontBold: MONO_BOLD_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; } /** * The SVG assets' face (rail, `ledger`, `scroll`, `chart`), or null for a * manifest that does not opt in -- render-cards.mjs then sets them in Fira * Sans, measured by its 0.50 em average, exactly as it always did. */ export function brandSvgFace(render) { return render?.brand ? SVG_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), }; }