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.
This commit is contained in:
MartinBraquet
2026-07-24 19:03:48 +02:00
parent de99d2decc
commit fab7c5e07b
6 changed files with 177 additions and 13 deletions

View File

@@ -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/

View File

@@ -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

View File

@@ -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",

View File

@@ -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)
})

View File

@@ -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,

View File

@@ -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', // h1h6 on the site
body: '"DM Sans", "Segoe UI", sans-serif', // body copy
wordmark: '"Cormorant Garamond", Georgia, serif', // the `.logo` wordmark