Fonts
Ship font files with the project, wait for them before the first frame, fit text per language and fail the render when a fallback font draws a glyph.
In HyperFrames or Remotion, fonts break for one reason: the machine that renders is not the machine you previewed on. Ship every face as a file in the project, wait for it before measuring or capturing, render finals in a pinned environment, and add a check that fails when any glyph comes from a font you did not ship. The script rules (direction, line height, letter spacing, captions) are on Text and fonts in any language. This page covers the code side.
Why renders drift between machines
HyperFrames' own docs say fonts and Chrome versions differ between computers, so a local render can shift by a pixel from one machine to the next, and its troubleshooting page lists fonts first when a render looks different from the preview. Missing fonts are worse than a pixel: the text falls back to whatever the machine has, which gives tofu boxes or a different typeface.
On our test Mac, fc-match picked Arial for Arabic, Tahoma for Thai and Hiragino Sans for Japanese. A Linux CI runner or a cloud worker picks something else, or nothing.
| Where the frames render | Fonts it can see |
|---|---|
| Your computer | Whatever is installed. HyperFrames local renders also copy installed fonts into the build |
npx hyperframes render --docker | A pinned font set, Chromium version and FFmpeg encoder |
| HyperFrames distributed or cloud (Lambda) renders | No installed fonts are captured, and fetching Google fonts at build time fails closed |
| HyperFrames Cloud Run image | Liberation, Noto (core, extra, UI, CJK and color emoji), FreeFont and DejaVu |
| Remotion Lambda, default | A CJK font layer (14.07 MiB) and Google's emoji |
Remotion Lambda with --runtime-preference=apple-emojis | Apple emoji, but this replaces both the Google emoji and the CJK layers, so ship CJK fonts yourself |
| Puppeteer's example Docker image | IPA Gothic, WenQuanYi Zen Hei, TLWG Thai, KACST and FreeFont, for Chinese, Japanese, Arabic, Hebrew and Thai |
The rule that follows: ship every face you need as a file in the project and reference it with @font-face. Treat installed fonts only as a last resort. Remotion's docs say the same for Lambda: if you just want to add fonts, use web fonts instead of building custom layers. See Setup for Docker renders.
To see what a machine would fall back to, and what a shaper really draws:
fc-list ':lang=th' family | sort -u | head
fc-match 'sans-serif:lang=ar' family
hb-shape /path/to/font.ttf "สวัสดี"Load fonts before the first frame
HyperFrames bundles 18 families that render offline with no warning: Inter, Roboto, Open Sans, Lato, Nunito, Montserrat, Poppins, Outfit, Oswald, League Gothic, Archivo Black, Playfair Display, EB Garamond, Space Mono, IBM Plex Mono, JetBrains Mono, Source Code Pro and Noto Sans JP. Only the listed weights exist. Noto Sans JP is the only bundled family for a non Latin script, so every other script needs your own font file.
- Any other Google font is fetched at build time and embedded, but that raises the
font_family_without_font_facelint warning and fails closed in distributed or cloud renders. Put an@font-facein the composition file that points at a font file in the project. - Build the timeline after
document.fonts.ready, and registerwindow.__timelines[id]only after the build finishes (lintgsap_timeline_registered_before_async_build). - Use
font-display: blockso no frame is drawn with a fallback face. - Not settled: one HyperFrames animation rule (camera cursor tracking) says to build synchronously, because parallel workers can seek before the promise resolves. The safe reading is to measure after fonts are ready, register the timeline last and use
font-display: block. - Write literal family names in each
:lang()rule. The font resolver reads names during static analysis and did not expand CSS variables. Release v0.7.22 stopped flaggingvar()andsystem-uistacks, but literal names are still the safest.
/* Brand display font, Latin only */
@font-face {
font-family: "Brand";
src: url("fonts/Brand-Bold.woff2") format("woff2");
font-weight: 700;
font-display: block;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
}
/* Same family name, second face: Thai glyphs come from a matched partner font */
@font-face {
font-family: "Brand";
src: url("fonts/NotoSansThai-Bold.woff2") format("woff2");
font-weight: 700;
font-display: block;
unicode-range: U+0E01-0E5B, U+200C-200D, U+25CC;
size-adjust: 104%; /* tune so the Thai letters sit well next to the brand face */
}
.title { font-family: "Brand", "Noto Sans", sans-serif; }
:lang(vi) .title { font-family: "Be Vietnam Pro", sans-serif; }
:lang(ja) .title { font-family: "Brand", "Noto Sans JP", sans-serif; }
:lang(ar) .title { font-family: "Noto Kufi Arabic", "Noto Sans Arabic", sans-serif; }async function waitForCopyFonts(copyByLang, fontSpec = "700 64px Brand") {
const sample = Object.values(copyByLang).join("");
await document.fonts.load(fontSpec, sample);
await document.fonts.ready;
}The unicode-range split only works when scripts never mix inside a word. For Vietnamese, Polish or Turkish, the :lang(vi) rule switches the whole language to a font that covers it, so one word is never drawn in two fonts.
Browser APIs worth knowing
document.fonts.readyresolves once the document has finished loading fonts and layout is done.document.fonts.load(font, text)forces faces to load. Pass a sample of the real copy so faces limited byunicode-rangeactually load. It does not check whether each glyph exists.document.fonts.check()is not a "font is loaded" test. It returns true for a font that does not exist or a system font.
Measure and fit text per language
Translations change length a lot: short English labels can grow 2 to 3 times. Fit each language against the same box, set a minimum readable size, and fail the render when a language would need to go below it. A failed render is cheaper than a video with unreadable text.
- Remotion has
measureText(),fitText(),fitTextOnNLines()andfillTextBox()in@remotion/layout-utils. Load the font first.validateFontIsLoadedmeasures again with the fallback font and throws if both results match; it is on by default from Remotion 5.0. - Measurement and markup must share
fontFamily,fontSize,fontWeight,letterSpacingandfontVariantNumeric. Avoid padding and borders on measured words, and use the samewhite-space. fitTextOnNLines()(v4.0.313) returnsfontSizeand thelinesarray, withmaxFontSizeat 2000 by default. Its docs do not say how lines are split, so test it with Thai, Chinese or Japanese before you rely on it.- HyperFrames fits text without reflow with
window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })and thepretextmeasurer (prepare,layout,measureNaturalWidth,measureLineStats).
import { fitText } from "@remotion/layout-utils";
const MIN_PX = 48;
export function fitTitle(text: string, lang: string, box: number) {
const { fontSize } = fitText({ text, withinWidth: box, fontFamily: "Brand",
fontWeight: 700, textTransform: "none", validateFontIsLoaded: true });
if (fontSize < MIN_PX) throw new Error(`Title too long for ${lang}: ${text}`);
return Math.min(fontSize, 160);
}Animate by word or line
Splitting text into one element per letter breaks shaping. CSS Text 3 says shaping must break at inline box boundaries when margin, border or padding is not zero, when vertical-align changes, or at a bidi isolation boundary. Splitters such as GSAP SplitText wrap each character in its own element, usually inline-block, and GSAP's docs warn that splitting removes kerning between characters.
- For Arabic, Hebrew with vowel points, Indic scripts, Thai, Lao, Khmer, Myanmar and Vietnamese with stacked marks: animate by word or line, never by character.
- If you must split smaller, split by grapheme cluster with
Intl.Segmenterandgranularity: "grapheme". Arabic joins still break. - Grapheme rules changed in Unicode 15.1 to keep Indic conjuncts together: the Bangla cluster ক্ষী was two clusters before and is one now. Older runtimes may still split conjuncts.
- Never split text on spaces for Thai, Lao, Khmer, Myanmar, Chinese or Japanese. Use
Intl.Segmenterwithgranularity: "word", or let the browser wrap. - For right to left languages, flip the sign of directional motion from the document direction, but never mirror logos, photos, clocks or the digits inside a number.
const seg = new Intl.Segmenter("th", { granularity: "word" });
const words = [...seg.segment("สวัสดีครับยินดีต้อนรับ")]
.filter((s) => s.isWordLike)
.map((s) => s.segment);const dir = getComputedStyle(document.documentElement).direction; // "ltr" or "rtl"
const s = dir === "rtl" ? -1 : 1;
tl.fromTo(".headline", { x: -80 * s, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6 });
tl.fromTo(".progress", { scaleX: 0, transformOrigin: dir === "rtl" ? "right" : "left" },
{ scaleX: 1, duration: 1.2 });Uppercase with the locale
When captions or data driven titles are uppercased in code, plain toUpperCase() gets several languages wrong. Use toLocaleUpperCase(lang). The results below are from a local Node run.
"istanbul".toUpperCase(); // "ISTANBUL"
"istanbul".toLocaleUpperCase("tr"); // "İSTANBUL"
"άι".toUpperCase(); // "ΆΙ", accent kept, wrong in Greek caps
"άι".toLocaleUpperCase("el"); // "ΑΪ"
"straße".toUpperCase(); // "STRASSE"In CSS, text-transform: uppercase follows the same rules only when lang is set on the element. Set lang on every text node, and skip uppercase styling for scripts without case (Arabic, Hebrew, Chinese, Japanese, Korean, Thai, Devanagari). Show hierarchy with size and weight there.
Fail the render when a fallback font draws a glyph
Chrome's DevTools Protocol has CSS.getPlatformFontsForNode, which reports the fonts actually used to draw each text node: familyName, postScriptName, isCustomFont (true when the font was downloaded or resolved locally) and glyphCount. That turns "no tofu, no ransom note" into a test. This script was written during the research for this guide from the protocol definition. It is not an official HyperFrames or Remotion tool, so try it on your own setup first.
// qa-fonts.mjs <url> <allowed family>... (exit 1 if any glyph came from another font)
import puppeteer from "puppeteer";
const [url, ...allowed] = process.argv.slice(2);
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
const cdp = await page.createCDPSession();
await cdp.send("DOM.enable");
await cdp.send("CSS.enable");
const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
const { nodeIds } = await cdp.send("DOM.querySelectorAll", { nodeId: root.nodeId, selector: "body *" });
const offenders = {};
for (const nodeId of nodeIds) {
const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId });
for (const f of fonts) {
if (!f.isCustomFont || !allowed.includes(f.familyName)) {
offenders[f.familyName] = (offenders[f.familyName] ?? 0) + f.glyphCount;
}
}
}
await browser.close();
if (Object.keys(offenders).length) { console.error("Unexpected fonts:", offenders); process.exit(1); }
console.log("All glyphs drawn with shipped fonts.");npm install puppeteer
node qa-fonts.mjs http://localhost:3000/ "Be Vietnam Pro" "Noto Sans Thai"- List the real family names (for example "Noto Sans Thai"), not the CSS alias such as "Brand".
familyNameis what the platform reports. - Run it once per language against the composition page. HyperFrames serves plain HTML. For Remotion, point it at a page that renders the same components.
- The URL above is a placeholder: use your own preview address.
Plan fonts before writing code
Have the agent fill in a font plan before it writes any composition code: one file per project, such as FONT_PLAN.md, or a fonts: block in the design spec.
# FONT PLAN
project: <name>
render_targets: [local, docker, lambda] # where frames are rendered
locales:
- tag: vi # BCP 47
script: Latn
direction: ltr
word_separator: space # space or none (th, lo, km, my, ja, zh)
has_case: true
cursive: false # true for Arab, Syrc, Mong, Nkoo, ...
stacked_marks: true # vi, th, hi, ...
copy_file: copy/vi.txt # NFC normalized
longest_string: "<paste>"
fonts:
- role: display
family_css: Brand
file: assets/fonts/BeVietnamPro-Bold.woff2
real_family: Be Vietnam Pro # what qa-fonts.mjs will see
weights: [700]
unicode_range: "U+0000-00FF, U+0100-024F, U+0300-0323, U+1EA0-1EF9, U+20AB"
license: OFL-1.1 (OFL.txt committed)
coverage_check: "coverage.py: missing none (date)"
shaping_check: "shaperglot check ... vi: pass (date)"
- role: body
family_css: Body
file: assets/fonts/NotoSans-Regular.woff2
real_family: Noto Sans
license: OFL-1.1
fallback_chain: ["Brand", "Body", "sans-serif"] # generic last, never a named system font
css_rules:
letter_spacing: "0 for cursive and Indic locales; tracking only where the script allows"
line_height_min: 1.25 # raise for stacked marks
text_transform: "uppercase only when has_case and lang is set"
font_synthesis: "none for CJK and Arabic"
dir: "rtl locales set dir on the root, motion mirrored, logos and digits not"
layout:
fit: "fitText per locale, min 48px, fail if below"
line_breaking: "browser wrapping or Intl.Segmenter(locale, {granularity: 'word'})"
split_animation: "by word or line; never per codepoint for complex scripts"
loading:
method: "Remotion @remotion/fonts loadFont, or HyperFrames @font-face in file plus fonts.ready"
font_display: block
captions:
format: ass
fontsdir: assets/fonts
shaping: complex
encoding: -1 # RTL auto base direction (libass)
max_chars_per_line: 42 # per script: see Netflix limits
ai_footage_text_policy: "no text in generated plates; all text in post; diegetic text needs native review"
qa:
automated: "qa-fonts.mjs <url> 'Be Vietnam Pro' 'Noto Sans'"
human: "native reader reviews contact sheet of every text beat"
risks: []Then paste this prompt into Claude Code (Opus 5.5) or any coding agent at the start of the project, together with the template above.
You are adding on-screen text to a video for these locales: <list of BCP 47 tags>.
Before writing composition code, write FONT_PLAN.md using the font plan template I pasted.
Rules:
1. Use only font files that ship in the project (assets/fonts) with a license that allows commercial
video and server rendering (prefer OFL; never Adobe Fonts or Apple system fonts on a render server).
Never name a system font in a font-family stack.
2. For each locale, run a coverage check of the real copy against the font file and a shaping check
(hyperglot or shaperglot). If the display font fails, pick a partner font for that script
(Noto region variant for Han) and match its size with size-adjust. Report results in the plan.
3. Set lang on the root and on mixed-language spans; dir="rtl" for RTL; use logical CSS properties;
mirror directional motion for RTL but never logos, photos or the digits inside a number.
4. letter-spacing 0 for Arabic-script and other cursive scripts and for Indic scripts; no uppercase
transforms on scripts without case; line-height of at least 1.25 (more for Thai and Indic);
animate complex scripts by word or line, never per character; never split text on spaces for
Thai, Lao, Khmer, Myanmar, Chinese or Japanese (use Intl.Segmenter or browser wrapping).
5. Load fonts before measuring or rendering (Remotion loadFont or delayRender; HyperFrames @font-face
in the file, build after document.fonts.ready, register the timeline last, font-display: block).
Fit text per locale with a minimum size and fail loudly instead of shrinking below it.
6. Captions: libass via FFmpeg with fontsdir, shaping=complex, wrap_unicode=1; ASS Encoding -1 for RTL.
7. AI-generated footage must contain no text; add all text in post.
8. Before declaring done, run qa-fonts.mjs for every locale and render a contact sheet of every text
beat for a native reader. Do not claim a locale works if either check was skipped.Check the license before the font reaches a server
Adobe Fonts does not allow installing fonts on a server, and Apple's system fonts are only licensed for interface mock ups. A render on a cloud worker, CI runner or Lambda counts as a server. Use OFL fonts there, or buy the foundry's server license. Details are on Text and fonts in any language.
What creators report on X
- HyperFrames compared with Remotion: "Beautiful, stable font rendering (CJK-friendly out of the box)". This fits Noto Sans JP being in the bundled set. Organic, @ProjectStain, 28 Apr 2026.
- A Japanese lyric video in Remotion got to "60 to 70 points" quickly, then took 50+ rounds with the AI and 3 to 4 hours to fix subtitle drift, lyric mismatches and text colliding with the background. Organic, @tetumemo, 24 Jan 2026.
- A free open source tool sets InDesign style composite fonts in pure CSS, with a separate font, size and baseline for Han, kana, punctuation, Latin and digits. Self promo, @godling_zlm, 13 Oct 2026.
- Anyway.Fonts previews Chinese fonts and checks for missing glyphs. Self promo, @JJYing, 14 Oct 2026.
Sources
- HyperFrames, determinism, HyperFrames, troubleshooting and HyperFrames Cloud Run Dockerfile
- HyperFrames frame worker reference, talking-head-recut skill, release v0.7.22 and release v0.7.27
- Remotion Lambda custom layers, Remotion fonts, @remotion/fonts loadFont, @remotion/google-fonts loadFont, delayRender and Remotion issue 7258
- Remotion layout utils best practices, fitText, measureText and fitTextOnNLines
- Puppeteer troubleshooting
- MDN, FontFaceSet.ready, MDN, FontFaceSet.load, MDN, FontFaceSet.check, MDN, unicode-range and MDN, Intl.Segmenter
- CSS Text Level 3, GSAP SplitText and Unicode UAX 29
- MDN, text-transform
- Chrome DevTools Protocol definition
- Adobe Fonts licensing and Apple fonts