diff --git a/CLAUDE.md b/CLAUDE.md index 27d20b6b2..d2f2d4eef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,11 +48,13 @@ packages/ ## Development ```bash -pnpm install # Install dependencies -pnpm build # Build all packages -pnpm test # Run tests +bun install # Install dependencies +bun run build # Build all packages +bun run test # Run tests ``` +**This repo uses bun**, not pnpm. Do NOT run `pnpm install` — it creates a `pnpm-lock.yaml` that should not exist. Workspace linking relies on bun's resolution from `"workspaces"` in root `package.json`. + ### Linting & Formatting This project uses **oxlint** and **oxfmt** (not biome, not eslint, not prettier). diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 3eb146e55..dcdab7b73 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -1,3 +1,7 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + import { parseHTML } from "linkedom"; import { EMBEDDED_FONT_DATA } from "./fontData.generated.js"; @@ -204,37 +208,50 @@ function extractRequestedFontFamilies(html: string): Map { return requested; } -function buildFontFaceCss(requestedFamilies: Map): { +function buildFontFaceRule(familyName: string, src: string, weight: string, style: string): string { + return [ + "@font-face {", + ` font-family: "${familyName}";`, + ` src: url("${src}") format("woff2");`, + ` font-style: ${style};`, + ` font-weight: ${weight};`, + " font-display: block;", + "}", + ].join("\n"); +} + +async function buildFontFaceCss(requestedFamilies: Map): Promise<{ css: string; unresolved: string[]; -} { +}> { const rules: string[] = []; const unresolved: string[] = []; for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) { + // Path 1: pre-bundled fonts via FONT_ALIASES const canonicalKey = FONT_ALIASES[normalizedFamily]; - if (!canonicalKey) { - unresolved.push(originalCaseFamily); + if (canonicalKey) { + const canonical = CANONICAL_FONTS[canonicalKey]; + if (!canonical) continue; + for (const face of canonical.faces) { + const style = face.style || "normal"; + const src = fontDataUri(canonical.packageName, face.weight, style); + rules.push(buildFontFaceRule(originalCaseFamily, src, face.weight, style)); + } continue; } - const canonical = CANONICAL_FONTS[canonicalKey]; - if (!canonical) continue; - for (const face of canonical.faces) { - const style = face.style || "normal"; - const src = fontDataUri(canonical.packageName, face.weight, style); - rules.push( - [ - "@font-face {", - ` font-family: "${originalCaseFamily}";`, - ` src: url("${src}") format("woff2");`, - ` font-style: ${style};`, - ` font-weight: ${face.weight};`, - " font-display: block;", - "}", - ].join("\n"), - ); + // Path 2: fetch from Google Fonts (with local cache) + const googleFaces = await fetchGoogleFont(originalCaseFamily); + if (googleFaces.length > 0) { + for (const face of googleFaces) { + rules.push(buildFontFaceRule(originalCaseFamily, face.dataUri, face.weight, face.style)); + } + continue; } + + // Neither path resolved + unresolved.push(originalCaseFamily); } return { @@ -263,7 +280,103 @@ function warnUnresolvedFonts(unresolved: string[]): void { ); } -export function injectDeterministicFontFaces(html: string): string { +// --------------------------------------------------------------------------- +// Google Fonts on-demand fetch + local cache +// --------------------------------------------------------------------------- + +const GOOGLE_FONTS_CACHE_DIR = join(homedir(), ".cache", "hyperframes", "fonts"); + +// Chrome UA triggers woff2 responses from Google Fonts CSS API +const WOFF2_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +function fontSlug(familyName: string): string { + return familyName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function fontCacheDir(slug: string): string { + const dir = join(GOOGLE_FONTS_CACHE_DIR, slug); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +function cachedWoff2Path(slug: string, weight: string, style: string): string { + return join(fontCacheDir(slug), `${weight}-${style}.woff2`); +} + +type GoogleFontFace = { + weight: string; + style: string; + dataUri: string; +}; + +async function fetchGoogleFont(familyName: string): Promise { + const slug = fontSlug(familyName); + const encodedFamily = encodeURIComponent(familyName); + const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700`; + + let cssText: string; + try { + const res = await fetch(url, { + headers: { "User-Agent": WOFF2_USER_AGENT }, + }); + if (!res.ok) { + return []; + } + cssText = await res.text(); + } catch { + return []; + } + + // Parse @font-face blocks from the CSS response + const faceRegex = + /@font-face\s*\{[^}]*font-style:\s*(normal|italic)[^}]*font-weight:\s*(\d+)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)[^}]*\}/gi; + + const faces: GoogleFontFace[] = []; + + for (const match of cssText.matchAll(faceRegex)) { + const style = match[1] || "normal"; + const weight = match[2] || "400"; + const woff2Url = match[3] || ""; + + if (!woff2Url) continue; + + const cachePath = cachedWoff2Path(slug, weight, style); + + // Check cache first + if (!existsSync(cachePath)) { + try { + const fontRes = await fetch(woff2Url); + if (!fontRes.ok) continue; + const buffer = Buffer.from(await fontRes.arrayBuffer()); + writeFileSync(cachePath, buffer); + } catch { + continue; + } + } + + const fontBytes = readFileSync(cachePath); + const dataUri = `data:font/woff2;base64,${fontBytes.toString("base64")}`; + faces.push({ weight, style, dataUri }); + } + + if (faces.length > 0) { + console.log( + `[Compiler] Fetched ${faces.length} font face(s) for "${familyName}" from Google Fonts (cached to ${fontCacheDir(slug)})`, + ); + } + + return faces; +} + +// --------------------------------------------------------------------------- + +export async function injectDeterministicFontFaces(html: string): Promise { const existingFaces = extractExistingFontFaces(html); const requestedFamilies = extractRequestedFontFamilies(html); const pendingFamilies = new Map(); @@ -278,7 +391,7 @@ export function injectDeterministicFontFaces(html: string): string { return html; } - const { css, unresolved } = buildFontFaceCss(pendingFamilies); + const { css, unresolved } = await buildFontFaceCss(pendingFamilies); if (!css) { if (unresolved.length > 0) { warnUnresolvedFonts(unresolved); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index a737d6f23..7336d182e 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -905,7 +905,7 @@ export async function compileForRender( "$1", ); - const coalescedHtml = injectDeterministicFontFaces( + const coalescedHtml = await injectDeterministicFontFaces( coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)), ); diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index cd94954d1..929399613 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -30,31 +30,44 @@ Position every element where it should be at its **most visible moment** — the ### The process 1. **Identify the hero frame** for each scene — the moment when the most elements are simultaneously visible. This is the layout you build. -2. **Write static CSS** for that frame. Every element at its final `top`, `left`, `width`, `height`. Use the browser or `npx hyperframes preview` to visually verify nothing overlaps unintentionally. +2. **Write static CSS** for that frame. The `.scene-content` container MUST fill the full scene using `width: 100%; height: 100%; padding: Npx;` with `display: flex; flex-direction: column; gap: Npx; box-sizing: border-box`. Use padding to push content inward — NEVER `position: absolute; top: Npx` on a content container. Absolute-positioned content containers overflow when content is taller than the remaining space. Reserve `position: absolute` for decoratives only. 3. **Add entrances with `gsap.from()`** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. 4. **Add exits with `gsap.to()`** — animate TO offscreen/invisible FROM the CSS position. ### Example ```css -/* Step 1-2: Layout the end state. This is what the viewer sees at peak visibility. */ +/* scene-content fills the scene, padding positions content */ +.scene-content { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + padding: 120px 160px; + gap: 24px; + box-sizing: border-box; +} .title { + font-size: 120px; +} +.subtitle { + font-size: 42px; +} +/* Container fills any scene size (1920x1080, 1080x1920, etc). + Padding positions content. Flex + gap handles spacing. */ +``` + +**WRONG — hardcoded dimensions and absolute positioning:** + +```css +.scene-content { position: absolute; top: 200px; left: 160px; - opacity: 1; -} -.subtitle { - position: absolute; - top: 320px; - left: 160px; - opacity: 1; -} -.logo { - position: absolute; - bottom: 80px; - right: 80px; - opacity: 1; + width: 1920px; + height: 1080px; + display: flex; /* ... */ } ``` @@ -104,7 +117,9 @@ Layered effects (glow behind text, shadow elements, background patterns) and z-s ## Composition Structure -Every composition is a `