// 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/.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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function pango(text, { px, color, face, lineHeight }) { const a = [`font_desc="${face}"`, `size="${Math.round(pt(px) * 1024)}"`, `foreground="${color}"`]; if (lineHeight) a.push(`line_height="${lineHeight}"`); return `${esc(text)}`; } /** 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//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) }; }