From fab7c5e07b1cee52d8f4c0cd0e02af131ffe709a Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 24 Jul 2026 19:03:48 +0200 Subject: [PATCH] Add script for fetching web fonts for OG card rendering - Introduced `fetch-fonts.mjs` to download brand web fonts from Google Fonts into `public/fonts/`. - Integrated font fetching into the `still:og` script to ensure rendering dependencies are prepared automatically. - Updated `package.json`, README, and font-related modules to document usage and improve reproducibility. - Ensured rendering fails cleanly if fonts are missing or invalid, maintaining visual consistency. --- media-creator/.gitignore | 4 + media-creator/README.md | 38 ++++-- media-creator/package.json | 3 +- media-creator/scripts/fetch-fonts.mjs | 134 +++++++++++++++++++++ media-creator/src/components/BrandFonts.ts | 7 +- media-creator/src/theme.ts | 4 +- 6 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 media-creator/scripts/fetch-fonts.mjs diff --git a/media-creator/.gitignore b/media-creator/.gitignore index 62a48368..bc6a67c3 100644 --- a/media-creator/.gitignore +++ b/media-creator/.gitignore @@ -17,3 +17,7 @@ npm-debug.log* # Captured marketing stills (regenerate with the capture scripts). public/search/ public/alert/ + +# Brand web faces for the OG card. Build inputs, not ours to redistribute — refetch from Google +# with `npm run fonts` (which `npm run still:og` does for you). +public/fonts/ diff --git a/media-creator/README.md b/media-creator/README.md index 4c7d0127..00973672 100644 --- a/media-creator/README.md +++ b/media-creator/README.md @@ -18,14 +18,25 @@ own dependency tree so the web app and API never pull Remotion (and its bundled ```bash cd media-creator npm install # first time only — also downloads a headless browser for rendering +npm run fonts # brand web faces for the OG card; uncommitted, see Stills below ``` +Two render inputs are not in the repo and are not installed by npm: + +- `public/fonts/` — fetched by `npm run fonts` (and automatically by `npm run still:og`). +- `public/logo.svg` — a copy of `web/public/favicon.svg`, which the root `.gitignore` excludes along + with every other `*.svg`. Copy it by hand: `cp ../web/public/favicon.svg public/logo.svg`. Every + scene that draws the logo needs it. + ## Preview (interactive studio) ```bash npm run studio # opens the Remotion Studio at http://localhost:3000 ``` +> Previewing `OgCard` in the studio needs `npm run fonts` first — the studio does not run it, and +> the render fails loudly rather than falling back to a substitute face. + ## Render to MP4 The same intro renders into two Instagram-ready formats: @@ -137,11 +148,24 @@ cannot promise something the landing page does not say. If the home headline cha #### Fonts This is the one scene that uses the real web faces — Newsreader for the headline, DM Sans for the -eyebrow, Cormorant Garamond for the wordmark — rather than the system stack the videos use. The -woff2 files are vendored in `public/fonts/` (both families are SIL OFL) and loaded by -`src/components/BrandFonts.ts`, which holds the render open until they are ready. Vendoring rather -than fetching from Google keeps the render reproducible offline; a missing network would otherwise -silently produce a card in Georgia. +eyebrow, Cormorant Garamond for the wordmark — rather than the system stack the videos use. + +The woff2 files live in `public/fonts/` and are **not committed** (`.gitignore`), like the hero clips +and `public/logo.svg`. Fetch them with: + +```bash +npm run fonts # no-op when they are already there +npm run fonts -- --force # re-download, e.g. to pick up a new family version +``` + +`npm run still:og` runs this itself, so rendering the card is a single command. `scripts/fetch-fonts.mjs` +resolves the download URLs through the Google Fonts css2 API rather than hardcoding gstatic paths: +those carry a family version (`/newsreader/v26/`) that Google rotates, so a hardcoded list would rot +silently. + +`src/components/BrandFonts.ts` registers the faces and holds the render open until the browser +reports them ready. A missing or corrupt file **cancels the render** instead of falling back to +Georgia — a card in the wrong typeface would ship unnoticed, a red render will not. ## Structure @@ -149,8 +173,8 @@ silently produce a card in Georgia. media-creator/ ├── package.json standalone deps (npm) ├── remotion.config.ts render settings (codec, quality) -├── public/logo.svg Compass logo (copied from web/public/favicon.svg) -├── public/fonts/ vendored web faces, used by the OG card only +├── public/logo.svg Compass logo (copied from web/public/favicon.svg; uncommitted) +├── public/fonts/ brand web faces, OG card only (npm run fonts; uncommitted) └── src/ ├── index.ts registerRoot ├── Root.tsx composition registry diff --git a/media-creator/package.json b/media-creator/package.json index 74eeec81..008a0c6a 100644 --- a/media-creator/package.json +++ b/media-creator/package.json @@ -11,6 +11,7 @@ "capture:search": "node scripts/capture-search.mjs --theme light", "capture:search:dark": "node scripts/capture-search.mjs --theme dark", "capture:vote": "node scripts/capture-vote.mjs", + "fonts": "node scripts/fetch-fonts.mjs", "render": "npm run render:post", "render:alert": "remotion render SearchAlertLight out/compass-search-alert-light.mp4 --crf 30", "render:alert:dark": "remotion render SearchAlertDark out/compass-search-alert-dark.mp4 --crf 30", @@ -23,7 +24,7 @@ "render:tour:post": "remotion render ProfileTourPost out/compass-profile-tour-post.mp4", "still:alert": "remotion still SearchAlertLight out/compass-alert-poster-light.png --frame=0", "still:alert:dark": "remotion still SearchAlertDark out/compass-alert-poster-dark.png --frame=0", - "still:og": "remotion still OgCard out/compass-og-card.jpg --image-format=jpeg --jpeg-quality=92", + "still:og": "npm run fonts && remotion still OgCard out/compass-og-card.jpg --image-format=jpeg --jpeg-quality=92", "still:search": "remotion still SearchDemoLight out/compass-search-poster-light.png --frame=0", "still:search:dark": "remotion still SearchDemoDark out/compass-search-poster-dark.png --frame=0", "studio": "remotion studio", diff --git a/media-creator/scripts/fetch-fonts.mjs b/media-creator/scripts/fetch-fonts.mjs new file mode 100644 index 00000000..9663baf6 --- /dev/null +++ b/media-creator/scripts/fetch-fonts.mjs @@ -0,0 +1,134 @@ +/** + * Downloads the brand web faces into public/fonts/ for the OG card render. + * + * The files are not committed (see .gitignore) — the same call the repo makes for the hero clips + * and for public/logo.svg. They are build *inputs* though, not outputs, so they come from Google + * Fonts rather than from R2: no credentials, and nothing to upload when a face changes. + * + * Resolved through the css2 API rather than hardcoded gstatic URLs on purpose. Those URLs carry a + * family version (`/newsreader/v26/`) that Google rotates, so a hardcoded list rots silently; the + * API always answers with the current one. + * + * npm run fonts # no-op if the files are already there + * npm run fonts -- --force # re-download, e.g. to pick up a new family version + * + * `npm run still:og` runs this first, so rendering the card needs no separate step. Run it by hand + * before `npm run studio` if you want to preview the card there. + * + * Only Newsreader, DM Sans and Cormorant Garamond are fetched, and only their `latin` subset — + * that is all the card sets. The video scenes deliberately use the system stack instead. + */ +import {mkdir, readFile, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const FONT_DIR = join(HERE, '../public/fonts') + +// A desktop UA is what makes the API answer with woff2; older agents get ttf. +const UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36' + +/** + * Filenames are what src/components/BrandFonts.ts loads — keep the two in sync. + * + * Every family here is variable, so one file per style covers the whole weight range and the + * weights in the query only decide which axes Google keeps. + */ +const FONTS = [ + { + file: 'Newsreader-latin.woff2', + family: 'Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;0,6..72,700', + style: 'normal', + }, + { + file: 'Newsreader-Italic-latin.woff2', + family: 'Newsreader:ital,opsz,wght@1,6..72,400', + style: 'italic', + }, + { + file: 'DMSans-latin.woff2', + family: 'DM+Sans:opsz,wght@9..40,400;9..40,500', + style: 'normal', + }, + { + file: 'CormorantGaramond-500-latin.woff2', + family: 'Cormorant+Garamond:wght@500', + style: 'normal', + }, +] + +const force = process.argv.includes('--force') + +const exists = async (path) => { + try { + return (await readFile(path)).byteLength > 0 + } catch { + return false + } +} + +/** + * Pulls the `latin` @font-face of the requested style out of a css2 response. + * + * The API returns one block per subset, each preceded by a `/* subset *\/` comment — that comment + * is the only thing identifying them, so the parse keys off it. + */ +function findLatinSrc(css, style) { + const blocks = [...css.matchAll(/\/\*\s*([\w-]+)\s*\*\/\s*@font-face\s*\{([^}]*)\}/g)] + + for (const [, subset, body] of blocks) { + if (subset !== 'latin') continue + const blockStyle = body.match(/font-style:\s*([\w]+)/)?.[1] ?? 'normal' + if (blockStyle !== style) continue + const url = body.match(/url\((https:\/\/[^)]+\.woff2)\)/)?.[1] + if (url) return url + } + return null +} + +async function fetchFont({file, family, style}) { + const target = join(FONT_DIR, file) + + if (!force && (await exists(target))) { + console.log(`[fonts] ${file} — already present`) + return + } + + const cssUrl = `https://fonts.googleapis.com/css2?family=${family}&display=swap` + const cssRes = await fetch(cssUrl, {headers: {'User-Agent': UA}}) + if (!cssRes.ok) { + throw new Error(`${cssRes.status} ${cssRes.statusText} for ${cssUrl}`) + } + + const src = findLatinSrc(await cssRes.text(), style) + if (!src) { + throw new Error(`No latin ${style} woff2 in the css2 response for ${family}`) + } + + const fontRes = await fetch(src, {headers: {'User-Agent': UA}}) + if (!fontRes.ok) { + throw new Error(`${fontRes.status} ${fontRes.statusText} for ${src}`) + } + + const bytes = Buffer.from(await fontRes.arrayBuffer()) + // Guard against a redirect or an error page landing on disk as a .woff2: the render would fail + // much further away from the cause. + if (bytes.subarray(0, 4).toString('ascii') !== 'wOF2') { + throw new Error(`${src} did not return a woff2 file`) + } + + await writeFile(target, bytes) + console.log(`[fonts] ${file} (${(bytes.byteLength / 1024).toFixed(0)} KB)`) +} + +async function main() { + await mkdir(FONT_DIR, {recursive: true}) + for (const font of FONTS) await fetchFont(font) +} + +main().catch((err) => { + console.error(`[fonts] ${err.message}`) + console.error('[fonts] The OG card cannot render without these — see media-creator/README.md.') + process.exit(1) +}) diff --git a/media-creator/src/components/BrandFonts.ts b/media-creator/src/components/BrandFonts.ts index b0cb0480..9b1902bb 100644 --- a/media-creator/src/components/BrandFonts.ts +++ b/media-creator/src/components/BrandFonts.ts @@ -1,9 +1,10 @@ // Loads the real Compass web faces so a render looks like the site rather than like the local // system-font stack. // -// The woff2 files are vendored under public/fonts (both families are SIL OFL) rather than fetched -// from Google at render time — theme.ts's rule is that a render must be reproducible offline, and a -// missing network would otherwise silently fall back to Georgia mid-render. +// The woff2 files are read from public/fonts, which is uncommitted: `npm run fonts` downloads them +// from Google Fonts, and `npm run still:og` runs that first. Fetching once at setup rather than at +// render time is what keeps theme.ts's rule intact — a render itself touches no network, so it +// cannot silently fall back to Georgia halfway through. // // Importing this module registers the faces and holds the render open until the browser reports // them ready. Only scenes that need them should import it; the video scenes deliberately do not, diff --git a/media-creator/src/theme.ts b/media-creator/src/theme.ts index 8dd27dd6..cfde8e2e 100644 --- a/media-creator/src/theme.ts +++ b/media-creator/src/theme.ts @@ -33,8 +33,8 @@ export const fonts = { serif: 'Georgia, "Times New Roman", serif', // The real web faces (see web/pages/_document.tsx and tailwind.config.js). Only usable in a - // scene that imports ./components/BrandFonts — that module vendors the woff2 files from - // public/fonts and blocks the render until they are ready. Without it these fall back. + // scene that imports ./components/BrandFonts — that module loads the woff2 files from + // public/fonts (`npm run fonts`) and blocks the render until they are ready. heading: '"Newsreader", Georgia, serif', // h1–h6 on the site body: '"DM Sans", "Segoe UI", sans-serif', // body copy wordmark: '"Cormorant Garamond", Georgia, serif', // the `.logo` wordmark