AI Video Playbook

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 renderFonts it can see
Your computerWhatever is installed. HyperFrames local renders also copy installed fonts into the build
npx hyperframes render --dockerA pinned font set, Chromium version and FFmpeg encoder
HyperFrames distributed or cloud (Lambda) rendersNo installed fonts are captured, and fetching Google fonts at build time fails closed
HyperFrames Cloud Run imageLiberation, Noto (core, extra, UI, CJK and color emoji), FreeFont and DejaVu
Remotion Lambda, defaultA CJK font layer (14.07 MiB) and Google's emoji
Remotion Lambda with --runtime-preference=apple-emojisApple emoji, but this replaces both the Google emoji and the CJK layers, so ship CJK fonts yourself
Puppeteer's example Docker imageIPA 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:

Terminal
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_face lint warning and fails closed in distributed or cloud renders. Put an @font-face in the composition file that points at a font file in the project.
  • Build the timeline after document.fonts.ready, and register window.__timelines[id] only after the build finishes (lint gsap_timeline_registered_before_async_build).
  • Use font-display: block so 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 flagging var() and system-ui stacks, but literal names are still the safest.
fonts.css
/* 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; }
Wait for the faces the real copy needs
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.ready resolves 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 by unicode-range actually 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() and fillTextBox() in @remotion/layout-utils. Load the font first. validateFontIsLoaded measures 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, letterSpacing and fontVariantNumeric. Avoid padding and borders on measured words, and use the same white-space.
  • fitTextOnNLines() (v4.0.313) returns fontSize and the lines array, with maxFontSize at 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 the pretext measurer (prepare, layout, measureNaturalWidth, measureLineStats).
fitTitle.ts
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.Segmenter and granularity: "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.Segmenter with granularity: "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.
Word chunks for any language
const seg = new Intl.Segmenter("th", { granularity: "word" });
const words = [...seg.segment("สวัสดีครับยินดีต้อนรับ")]
  .filter((s) => s.isWordLike)
  .map((s) => s.segment);
One timeline for both directions
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.

Node
"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
// 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.");
Terminal
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". familyName is 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.yaml
# 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.

Prompt
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