diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts
index e50518b69..ff76579ba 100644
--- a/packages/core/src/lint/hyperframeLinter.ts
+++ b/packages/core/src/lint/hyperframeLinter.ts
@@ -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(
diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts
index ce1e0087f..69fe23ed3 100644
--- a/packages/core/src/lint/rules/composition.test.ts
+++ b/packages/core/src/lint/rules/composition.test.ts
@@ -772,4 +772,50 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});
+
+ describe("invalid_capture_path", () => {
+ it("errors when an
src uses ../capture/", () => {
+ const 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 = `
+
+
+ `;
+ 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 = `
+
+

+
+
+ `;
+ const result = lintHyperframeHtml(html, {
+ filePath: "/project/compositions/scene.html",
+ });
+ const finding = result.findings.find((f) => f.code === "invalid_capture_path");
+ expect(finding).toBeUndefined();
+ });
+ });
});
diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index e9392ad4b..b4b9c94db 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -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 [];
diff --git a/packages/core/src/lint/rules/fonts.test.ts b/packages/core/src/lint/rules/fonts.test.ts
new file mode 100644
index 000000000..af35df87f
--- /dev/null
+++ b/packages/core/src/lint/rules/fonts.test.ts
@@ -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 = `
+
+
`;
+ const findings = findByCode(html, "google_fonts_import");
+ expect(findings).toHaveLength(1);
+ expect(findings[0]!.severity).toBe("warning");
+ });
+
+ it("flags to fonts.googleapis.com", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "google_fonts_import");
+ expect(findings).toHaveLength(1);
+ });
+
+ it("does not flag local @font-face usage", () => {
+ const html = `
+
+
`;
+ 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 = `
+
+
`;
+ 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 = `
+
+
`;
+ const findings = findByCode(html, "font_family_without_font_face");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("does not flag generic font families", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "font_family_without_font_face");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("reports multiple missing families in one finding", () => {
+ const html = `
+
+
`;
+ 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 = `
+
+
`;
+ 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 = `
+
+
`;
+ 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 = `
+
+
`;
+ const findings = findByCode(html, "font_family_without_font_face");
+ expect(findings).toHaveLength(0);
+ });
+
+ it("ignores font-family inside @font-face blocks", () => {
+ const html = `
+
+
`;
+ const findings = findByCode(html, "font_family_without_font_face");
+ expect(findings).toHaveLength(0);
+ });
+ });
+});
diff --git a/packages/core/src/lint/rules/fonts.ts b/packages/core/src/lint/rules/fonts.ts
new file mode 100644
index 000000000..05a5e0db2
--- /dev/null
+++ b/packages/core/src/lint/rules/fonts.ts
@@ -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 {
+ const families = new Set();
+ 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();
+ 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 = /]*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 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;
+ },
+];