mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(lint): font loading + invalid capture path composition rules
Two new composition lint rules catching failure modes that recurred across the 11-round website-to-video eval. Both ship with vitest coverage; total lint suite goes from 148 to 151 tests. **`fonts.ts` (new) — two warnings** - `google_fonts_import`: composition loads fonts from `fonts.googleapis.com` via `<link>` or `@import url(...)`. External font requests fail in sandboxed/offline renders and add latency. Fix hint points to root-relative `capture/assets/fonts/...woff2` with a local `@font-face` declaration. - `font_family_without_font_face`: CSS uses a font-family that isn't declared with `@font-face` and isn't in the auto-bundled font set (Inter, JetBrains Mono, etc.). Text would silently fall back to system-ui — the visual fidelity loss the eval kept hitting. Fix hint points to the captured woff2 files. **`composition.ts` invalid_capture_path (new) — one error** Sub-compositions live in `compositions/` but get served with the project root as their base URL. `<img src="../capture/...">` works on disk but 404s in Studio and renders. Errors with a fix hint saying replace `../capture/` with root-relative `capture/`. Three vitest cases: `<img>` triggers, multi-occurrence url()s are counted, root-relative paths stay clean. Registry source files and installed blocks are exempted. **Wiring** `hyperframeLinter.ts` runs the new fonts rules alongside the existing rule set; the composition rule was added inline so it picks up automatically.
This commit is contained in:
@@ -8,6 +8,7 @@ import { captionRules } from "./rules/captions";
|
||||
import { compositionRules } from "./rules/composition";
|
||||
import { adapterRules } from "./rules/adapters";
|
||||
import { textureRules } from "./rules/textures";
|
||||
import { fontRules } from "./rules/fonts";
|
||||
|
||||
const ALL_RULES = [
|
||||
...coreRules,
|
||||
@@ -17,6 +18,7 @@ const ALL_RULES = [
|
||||
...compositionRules,
|
||||
...adapterRules,
|
||||
...textureRules,
|
||||
...fontRules,
|
||||
];
|
||||
|
||||
export function lintHyperframeHtml(
|
||||
|
||||
@@ -772,4 +772,50 @@ describe("composition rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid_capture_path", () => {
|
||||
it("errors when an <img> src uses ../capture/", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="../capture/assets/logo.svg" alt="logo">
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("errors when a CSS url() uses ../capture/ (counts all occurrences)", () => {
|
||||
const html = `<html><body>
|
||||
<style>
|
||||
@font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
|
||||
.hero { background-image: url('../capture/assets/hero.png'); }
|
||||
</style>
|
||||
<div data-composition-id="x"></div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("2 asset path(s)");
|
||||
});
|
||||
|
||||
it("does not flag root-relative capture/ paths", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="capture/assets/logo.svg" alt="logo">
|
||||
</div>
|
||||
<style>.hero { background-image: url('capture/assets/hero.png'); }</style>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,26 @@ function isCompositionRootOrMount(rawTag: string): boolean {
|
||||
}
|
||||
|
||||
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
|
||||
// Sub-compositions live in compositions/ but are served relative to the project
|
||||
// root, so all asset paths must be root-relative ("capture/...").
|
||||
// Using "../capture/..." works on disk but breaks in Studio and renders.
|
||||
({ rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
// Only flag in sub-compositions and root compositions — not in registry blocks
|
||||
const matches = rawSource.match(/\.\.\/capture\//g);
|
||||
if (!matches || matches.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
code: "invalid_capture_path",
|
||||
severity: "error",
|
||||
message: `Found ${matches.length} asset path(s) using ../capture/ — will 404 in Studio and renders.`,
|
||||
fixHint:
|
||||
'Replace all "../capture/" with "capture/" throughout this file. Compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// composition_file_too_large
|
||||
({ rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
function findByCode(html: string, code: string, isSubComposition = true) {
|
||||
const result = lintHyperframeHtml(html, { isSubComposition });
|
||||
return result.findings.filter((f) => f.code === code);
|
||||
}
|
||||
|
||||
describe("font rules", () => {
|
||||
describe("google_fonts_import", () => {
|
||||
it("flags @import url with fonts.googleapis.com", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("flags <link> to fonts.googleapis.com", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
|
||||
</div>`;
|
||||
const findings = findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not flag local @font-face usage", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("font_family_without_font_face", () => {
|
||||
it("flags font-family used without @font-face", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("gt walsheim");
|
||||
});
|
||||
|
||||
it("does not flag when @font-face is declared", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
|
||||
body { font-family: 'GT Walsheim', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag generic font families", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: monospace; }</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports multiple missing families in one finding", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
h1 { font-family: 'Aeonik', sans-serif; }
|
||||
code { font-family: 'Feature Deck', monospace; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("aeonik");
|
||||
expect(findings[0]!.message).toContain("feature deck");
|
||||
});
|
||||
|
||||
it("does not flag fonts the producer has pre-bundled", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
code { font-family: 'JetBrains Mono', monospace; }
|
||||
h1 { font-family: 'Roboto', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still flags Google-Fonts-only fonts not pre-bundled", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Geist', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("geist");
|
||||
});
|
||||
|
||||
it("is case-insensitive when matching @font-face to font-family", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
|
||||
body { font-family: 'inter', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores font-family inside @font-face blocks", () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
|
||||
const GENERIC_FAMILIES = new Set([
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-serif",
|
||||
"ui-sans-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"math",
|
||||
"emoji",
|
||||
"fangsong",
|
||||
"inherit",
|
||||
"initial",
|
||||
"unset",
|
||||
"revert",
|
||||
]);
|
||||
|
||||
// Fonts pre-bundled as data URIs in the producer (deterministicFonts.ts FONT_ALIASES).
|
||||
// These render correctly without @font-face — the producer injects them automatically.
|
||||
// Must match the keys in packages/producer/src/services/deterministicFonts.ts exactly.
|
||||
const PRODUCER_BUNDLED_FONTS = new Set([
|
||||
"inter",
|
||||
"helvetica neue",
|
||||
"helvetica",
|
||||
"arial",
|
||||
"helvetica bold",
|
||||
"montserrat",
|
||||
"futura",
|
||||
"din alternate",
|
||||
"arial black",
|
||||
"outfit",
|
||||
"nunito",
|
||||
"oswald",
|
||||
"bebas neue",
|
||||
"league gothic",
|
||||
"archivo black",
|
||||
"space mono",
|
||||
"ibm plex mono",
|
||||
"jetbrains mono",
|
||||
"courier new",
|
||||
"courier",
|
||||
"eb garamond",
|
||||
"garamond",
|
||||
"playfair display",
|
||||
"source code pro",
|
||||
"noto sans jp",
|
||||
"noto sans japanese",
|
||||
"roboto",
|
||||
"open sans",
|
||||
"lato",
|
||||
"poppins",
|
||||
"segoe ui",
|
||||
]);
|
||||
|
||||
function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string> {
|
||||
const families = new Set<string>();
|
||||
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
|
||||
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
|
||||
for (const style of styles) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = fontFaceRe.exec(style.content)) !== null) {
|
||||
const familyMatch = match[0].match(familyRe);
|
||||
if (familyMatch?.[2]) {
|
||||
families.add(familyMatch[2].trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
|
||||
const used: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
|
||||
for (const style of styles) {
|
||||
const withoutFontFace = style.content.replace(/@font-face\s*\{[^}]*\}/gi, "");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = propRe.exec(withoutFontFace)) !== null) {
|
||||
const stack = match[1]!;
|
||||
for (const part of stack.split(",")) {
|
||||
const name = part
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
used.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// google_fonts_import
|
||||
({ styles, source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
|
||||
const googleFontsInImport = styles.some((s) =>
|
||||
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
|
||||
);
|
||||
|
||||
if (googleFontsInLink || googleFontsInImport) {
|
||||
findings.push({
|
||||
code: "google_fonts_import",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Composition loads fonts from fonts.googleapis.com. External font requests " +
|
||||
"fail in sandboxed/offline renders and add latency. Use local @font-face " +
|
||||
"declarations with captured .woff2 files instead.",
|
||||
fixHint:
|
||||
"Replace the Google Fonts <link> or @import with @font-face { font-family: '...'; " +
|
||||
"src: url('capture/assets/fonts/Font.woff2'); } pointing to captured font files.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// font_family_without_font_face
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const declared = extractFontFaceFamilies(styles);
|
||||
const used = extractUsedFontFamilies(styles);
|
||||
|
||||
const undeclared = used.filter(
|
||||
(name) => !declared.has(name) && !PRODUCER_BUNDLED_FONTS.has(name),
|
||||
);
|
||||
if (undeclared.length === 0) return findings;
|
||||
|
||||
findings.push({
|
||||
code: "font_family_without_font_face",
|
||||
severity: "warning",
|
||||
message:
|
||||
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
|
||||
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
|
||||
"Text will fall back to a generic font, producing incorrect typography in the video.",
|
||||
fixHint:
|
||||
"Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " +
|
||||
"for each font family, pointing to the captured .woff2 files.",
|
||||
});
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user