Skip to content

fix(skills): stage a preset's own fonts/ folder in every build-frame.mjs - #3978

Open
miga-heygen wants to merge 1 commit into
mainfrom
fix/skills-stage-preset-fonts
Open

miga-heygen wants to merge 1 commit into
mainfrom
fix/skills-stage-preset-fonts

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

What

Every workflow's build-frame.mjs (faceless-explainer, pr-to-video, product-launch-video) now stages a frame preset's own fonts/ folder into assets/fonts/ and appends one @font-face block to frame.md, covering both font sources (captured brand files + preset files). The filename → face helpers move into a shared scripts/lib/font-faces.mjs, byte-identical across the three skills and consumed by both build-frame.mjs and captions.mjs.

Refs #3583 (the media-use font-resolver half of that issue stays open).

Supersedes #3950 — carries srikarsunchu's change with signed commits so it can merge under the repo's signed-commit ruleset, plus: repo-oxfmt formatting, captions.mjs now imports the shared font-faces library instead of a hand copy, overlapping-family test, isFile guard.

Why

code-editorial ships licensed WOFF2 files and its FRAME.md promises they are staged. Only the pr-to-video copy did so, with the six filenames hard-coded and a hard exit for any other preset shipping a fonts/ folder; the other two copies never read the folder. A frame worker following font_family_without_font_face then found no file behind the preset's families and fell back to a system stack.

How

  • lib/font-faces.mjs (new, ×3 identical): FONT_FORMAT, normFontName, fontExtOf, fontWeightInfo, fontStyleOf, fontFamiliesNamed, rankFontFamilies (longest family key first — the one ranking the brand block, stagePresetFonts and captions.mjs all use), familyOfFaceFile, stageFontFile, stagePresetFonts.
  • stagePresetFonts is directory-driven: each plain file (statSync(...).isFile() in a try/catch, so a subdirectory or dangling symlink named like a font cannot reach copyFileSync and throw EISDIR/ENOENT) is assigned to the longest family the remixed frame.md still names whose key its stem starts with (familyOfFaceFile; a brand renamed to "Garamond" does not claim EBGaramond-400.woff2); files matching no named family are reported as skipped ("matching no family frame.md names"); faces the brand already staged are not staged twice (stagedFaces is shared across both sources and keyed on family + numeric weight + style, so Inter-Regular.ttf from the capture and Inter-400.woff2 from the preset are one face — first source wins); staged bytes are refreshed from the source only for the preset's fonts/ folder (stageFontFile({ refresh: true }); a skills update may ship re-subsetted files), skipping only a self-copy, while a capture file never overwrites a file already at its staged path, so a hand-placed face in assets/fonts/ keeps winning over the download; OFL-*/LICENSE* texts travel with any staged face.
  • build-frame.mjs: the brand block calls stageFontFile; the preset block calls stagePresetFonts; one ## Font loading (auto-generated) section lists both sources. The brand scan's "single family owns every file" shortcut now applies only to capture/assets/fonts (hash-named downloads); files already in assets/fonts/ follow the staged-name contract and are matched by family-key prefix, because a previous run may have put the preset's faces there and a rerun after brand tokens arrive must not relabel EB Garamond's bytes as the brand font. pr-to-video drops its hard-coded six-file list and the die() on a missing file — a preset folder is now read as-is, so a missing file simply is not staged (this is the intended change: the hard exit fired for any other preset shipping a fonts/ folder).
  • captions.mjs (×3 identical): brandFontFaces imports weight/style/format/key helpers from ./lib/font-faces.mjs; the private weightOf/styleOf/fmtOf/norm copies are deleted. Behaviour unchanged (the old fmtOf "opentype" fallback was unreachable behind the extension gate).
  • code-editorial/FRAME.md notes that families the brand remix replaced are skipped.
  • Formatted with the repo-pinned oxfmt (bunx oxfmt --check passes on all changed .mjs); skills-manifest.json regenerated.

Tests

  • build-frame-fonts.test.mjs (new, ×3; tmp dirs removed in test.after): staging by family/weight/style from a fake preset; remix-replaced family skipped; brand-staged face not re-staged from the preset; preset without fonts/ stages nothing; shipped code-editorial stages six faces + three OFL texts; fontFamiliesNamed, fontWeightInfo, stageFontFile units; overlapping families "Inter" + "Inter Tight" → InterTight-700.woff2 lands on "Inter Tight", Inter-400.woff2 on "Inter" (fails with the length sort reversed); directory entry / dangling symlink named like a font is neither staged nor a crash (fails with the isFile guard removed, EISDIR; fails with the try/catch removed, ENOENT); word-vs-numeric dedup across sources (OtherMono-Bold.woff2 captured + OtherMono-700.woff2 preset → one file, one rule; fails with the dedup keyed on the weight token); stale bytes refreshed by stageFontFile with refresh: true and left alone without it (fails with the flag ignored either way); capture keeps a staged file end-to-end (brand-grotesk-400.woff2 captured vs a pre-existing BrandGrotesk-400.woff2; fails with the capture source refreshing); preset refresh end-to-end on a rerun after the staged bytes went stale (fails with stagePresetFonts not passing the flag); hash-named capture file 19cfc7226ec3afaa-s.woff2 staged as the single brand family and two brand families told apart by substring on CDN-style names (both fail with the capture rule replaced by the staged-name prefix rule); rerun after brand tokens does not relabel staged preset faces (brand "Sans" vs staged AcmeSans-*; fails with the shortcut applied to assets/fonts or with substring matching); substring family ("Tight" owns neither Inter-400 nor InterTight-700); font-faces.mjs byte-identity across the three skills.
  • captions.test.mjs (×3): style-token check now targets lib/font-faces.mjs, restores the negative "must not assert font-style:normal" check, and asserts captions.mjs imports its helpers from the library (replacing the import with a stub fails the round-trip tests).
  • env -u NODE_ENV node --test "skills/**/*.test.mjs": 684/687 pass; the single failure (media-use/scripts/resolve.test.mjs) is a missing @hyperframes/parsers/dist build in a fresh clone and fails identically on main.
  • bunx tsx scripts/lint-skills.ts clean; bunx oxlint 0 warnings on the 15 changed .mjs; bun packages/cli/scripts/gen-skills-manifest.ts --check in sync.

— Miga

Comment thread skills/faceless-explainer/scripts/build-frame-fonts.test.mjs Fixed
Comment thread skills/pr-to-video/scripts/build-frame-fonts.test.mjs Fixed
Comment thread skills/product-launch-video/scripts/build-frame-fonts.test.mjs Fixed
@miga-heygen
miga-heygen force-pushed the fix/skills-stage-preset-fonts branch from 88ceace to 186f713 Compare September 16, 2026 03:45
The code-editorial preset ships licensed WOFF2 files and its FRAME.md
promises build-frame.mjs stages them into assets/fonts/. Only the
pr-to-video copy did, with the six code-editorial filenames hard-coded
and a hard exit for any other preset that ships a fonts/ folder; the
product-launch-video and faceless-explainer copies never read the folder
at all, so a frame worker following the font_family_without_font_face
rule found no file behind the preset's families and fell back to a
system stack.

Make preset font staging directory-driven and shared by all three
workflows: each font file is assigned to the longest family the remixed
frame.md still names, weight and style are read from the filename under
the same naming contract captions.mjs parses, faces the brand remix
replaced are skipped, faces the captured brand already staged are not
staged twice, and OFL/LICENSE texts travel with any staged face. Both
sources now emit one @font-face block in frame.md.

The filename → face helpers move out of the brand-font block into a new
lib/font-faces.mjs (byte-identical across the three skills, guarded by a
test) so the two staging paths cannot drift.

Review corrections folded in: the three build-frame.mjs copies are
formatted with the repo-pinned oxfmt; captions.mjs imports the
weight/style/format/family-key helpers from lib/font-faces.mjs instead
of carrying a hand copy (the longest-family-first ranking all three
sites rely on is one exported rankFontFamilies), and its test checks
the import plus the negative "never assert font-style:normal" guard
against the library; an overlapping-family test ("Inter" + "Inter
Tight") pins the longest-family assignment; the skipped-file message
reads "matching no family frame.md names" since a filename may never
have named one.

Robustness corrections: stagePresetFonts only considers plain files
(statSync().isFile() in a try/catch), so a subdirectory or dangling
symlink named like a font neither crashes copyFileSync with EISDIR nor
stat with ENOENT; dedup is keyed on the face (family, numeric weight,
style) rather than the staged filename, so a word-named brand file and
a numeric-named preset file for the same face yield one @font-face rule
with the brand's bytes; a preset face's staged bytes are refreshed from
the preset on every run (a skills update may ship re-subsetted files),
skipping only a self-copy, while a capture file never overwrites a file
already at its staged path, so a face hand-placed in assets/fonts/ keeps
winning over the download as it did before; and files that
follow the staged-name contract (assets/fonts/ and a preset's fonts/)
are matched by family-key prefix on the stem while only loosely named
capture files get the single-family shortcut and substring match, so a
rerun after brand tokens arrive cannot relabel preset faces already in
assets/fonts/ as the brand font — not even a brand named "Garamond"
against EBGaramond-*.woff2.

Refs #3583

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@miga-heygen
miga-heygen force-pushed the fix/skills-stage-preset-fonts branch from 186f713 to 9f30a6d Compare September 16, 2026 04:03
Comment thread skills/faceless-explainer/scripts/build-frame-fonts.test.mjs Dismissed
Comment thread skills/pr-to-video/scripts/build-frame-fonts.test.mjs Dismissed
Comment thread skills/product-launch-video/scripts/build-frame-fonts.test.mjs Dismissed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants