mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
Capture pipeline work that came out of the 11-round website-to-video
eval branch. The wins that actually moved quality were the artifacts
agents read (contact sheets, design-styles) and the snapshot tool
visual-verification fixes; the rest are smaller follow-ons.
**Contact sheets (`contactSheet.ts`, new)**
- Replaces the embedded one-image-per-asset listing with paginated
labeled grids (3-col screenshots / 4-col raster / 5-col SVG). Each
page contains 9–15 cells with filename labels baked in via SVG
text overlay (`escapeXml` covers `&<>"'`).
- `fit: "contain"` keeps every asset visible at its real aspect
ratio; the old `fit: "cover"` cropped to the first image's box.
- Returns `string[]` (page paths) — single-page captures get one
file, multi-page produce `contact-sheet-1.jpg`, `contact-sheet-2.jpg`,
etc.
- `createSvgContactSheet` scans both `assets/svgs/` (inline-extracted
SVGs) and `assets/` root (external SVGs from `<img src="*.svg">`)
and de-dupes by filename. Sites with all-external SVGs (huly.io)
now get coverage they previously didn't.
**Design styles extractor (`designStyleExtractor.ts`, new)**
- Walks the live DOM and reads computed styles to produce
`extracted/design-styles.json`: typography hierarchy (every text
role with exact font-size / weight / line-height / letter-spacing),
button variants (background / padding / radius / shadow), card /
container / nav styles, spacing scale with base unit, border-radius
scale, box-shadow values with usage counts.
- Primary data source for DESIGN.md authoring at Step 1. Replaces
the prior "guess from screenshots" workflow.
**Snapshot tool (`snapshot.ts`)**
- HyperShader pre-rendering used to swallow the entire snapshot
capture window (every frame after the first showed the loading
overlay or final-opacity-zero exit fades). Wait signal is now
`window.__hf.shaderTransitions[].ready` (set after both warm and
cold cache paths complete); local-time seek for sub-comps means
exit fades read at their own t=0..duration, not global time.
- Gemini vision per-frame analysis runs by default (`descriptions.md`
next to the contact sheet). `--describe "custom Q"` overrides the
prompt; `--describe false` opts out.
- 3-column contact sheet generation for snapshot frames so reviewers
see all beats at a glance.
**Screenshot capture (`screenshotCapture.ts`)**
- Replaces `querySelectorAll('*') + getComputedStyle` overlay scan
with a TreeWalker that early-exits on cheap rect checks before
reaching the expensive style read. Caps at 5000 elements per page.
- Cookie/consent dismissal selectors are scoped under cookie /
consent / gdpr ancestors so we don't click "Accept invitation" or
similar unrelated buttons.
**Agent prompt (`agentPromptGenerator.ts`)**
- Auto-discovers contact-sheet page count (matches base name plus
paginated `-NNN` variants only, with regex escaping on the base
name and numeric sort for 10+ pages).
- `inferColorRole`: classifies extracted hex colors as bg-dark /
bg-light / accent / surface / neutral via luminance + saturation,
so the agent prompt shows `#533AFD (accent)` instead of bare hex.
- `design-styles.json` row is gated on `existsSync` — the upstream
write is wrapped in try/catch and may skip on failure, so the
prompt only points to files actually on disk.
**Other CLI ergonomics**
- `cli.ts`: auto-load `.env` from CWD on startup so subcommands like
`snapshot` don't need explicit `export GEMINI_API_KEY=…`. Handles
`export FOO=bar`, quoted values, inline `# comments`.
- `commands/transcribe.ts`: default output dir is the input file's
directory, not CWD. Stops the "wrote transcript.json somewhere
unexpected" footgun.
- `assetDownloader.ts`: improved asset naming uses catalog context;
de-duplicates inline SVG filenames.
- `contentExtractor.ts`: captions SVGs via Gemini (code-as-text) and
integrates them into asset descriptions.
- `tokenExtractor.ts` + `types.ts`: SVG bounding box dimensions and
new DesignStyles schema added.
212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
/**
|
|
* Generate AGENTS.md and CLAUDE.md for captured website projects.
|
|
*
|
|
* Writes the same content to both filenames so any AI agent auto-discovers it:
|
|
* - AGENTS.md — universal convention (Cursor, Codex, Gemini CLI, Windsurf, Aider, Jules)
|
|
* - CLAUDE.md — Claude Code convention
|
|
*
|
|
* This file generates a DATA INVENTORY that tells the AI agent what files
|
|
* exist and what they contain. The actual workflow lives in the
|
|
* website-to-hyperframes skill — this file points agents there.
|
|
*/
|
|
|
|
import { writeFileSync, readdirSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { DesignTokens } from "./types.js";
|
|
import type { AnimationCatalog } from "./animationCataloger.js";
|
|
import type { CatalogedAsset } from "./assetCataloger.js";
|
|
|
|
/**
|
|
* Infer a human-readable role hint from a hex color based on luminance and saturation.
|
|
* Not a substitute for DESIGN.md — just helps orient agents scanning the brand summary.
|
|
*/
|
|
function inferColorRole(hex: string): string {
|
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return "color";
|
|
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
const saturation = max === 0 ? 0 : (max - min) / max;
|
|
|
|
if (luminance < 0.04) return "bg-dark";
|
|
if (luminance > 0.9) return "bg-light";
|
|
if (saturation > 0.4 && luminance > 0.05 && luminance < 0.7) return "accent";
|
|
if (luminance < 0.2) return "surface-dark";
|
|
if (luminance > 0.7) return "surface-light";
|
|
return "neutral";
|
|
}
|
|
|
|
export function generateAgentPrompt(
|
|
outputDir: string,
|
|
url: string,
|
|
tokens: DesignTokens,
|
|
_animations: AnimationCatalog | undefined, // reserved for future animation summary
|
|
hasScreenshot: boolean,
|
|
hasLottie?: boolean,
|
|
hasShaders?: boolean,
|
|
_catalogedAssets?: CatalogedAsset[], // reserved for future asset inventory
|
|
_detectedLibraries?: string[],
|
|
): void {
|
|
const prompt = buildPrompt(outputDir, url, tokens, hasScreenshot, hasLottie, hasShaders);
|
|
writeFileSync(join(outputDir, "AGENTS.md"), prompt, "utf-8");
|
|
writeFileSync(join(outputDir, "CLAUDE.md"), prompt, "utf-8");
|
|
writeFileSync(join(outputDir, ".cursorrules"), prompt, "utf-8");
|
|
}
|
|
|
|
function buildPrompt(
|
|
outputDir: string,
|
|
url: string,
|
|
tokens: DesignTokens,
|
|
hasScreenshot: boolean,
|
|
hasLottie?: boolean,
|
|
hasShaders?: boolean,
|
|
): string {
|
|
const title = tokens.title || new URL(url).hostname.replace(/^www\./, "");
|
|
|
|
const colorSummary = tokens.colors
|
|
.slice(0, 10)
|
|
.map((hex) => `${hex} (${inferColorRole(hex)})`)
|
|
.join(", ");
|
|
const fontSummary =
|
|
tokens.fonts
|
|
.map(
|
|
(f) =>
|
|
f.family +
|
|
(f.variable && f.weightRange
|
|
? ` (${f.weightRange[0]}-${f.weightRange[1]} variable)`
|
|
: f.weights.length > 0
|
|
? ` (${f.weights.join(",")})`
|
|
: ""),
|
|
)
|
|
.join(", ") || "none detected";
|
|
|
|
// Build the data inventory table rows
|
|
// Helper: find all contact sheet pages for a given base name. Matches the
|
|
// exact base file plus paginated variants only (e.g. `contact-sheet.jpg`,
|
|
// `contact-sheet-2.jpg`, `contact-sheet-3.jpg`). The "-NNN" suffix is digits
|
|
// only, so unrelated files that happen to share the prefix (notably the
|
|
// `contact-sheet-svgs.jpg` SVG fallback sheet in assets/) don't get mixed in.
|
|
function contactSheetRows(dir: string, baseFile: string, label: string): string[] {
|
|
const fullDir = join(outputDir, dir);
|
|
if (!existsSync(fullDir)) return [];
|
|
const baseName = baseFile.replace(/\.jpg$/, "");
|
|
// Escape regex metacharacters in baseName so future callers can pass
|
|
// filenames containing `.`, `+`, `(`, etc. without the regex breaking.
|
|
const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const paginatedRe = new RegExp(`^${escapedBase}(?:-(\\d+))?\\.jpg$`);
|
|
// Sort by the numeric page suffix so `contact-sheet-10.jpg` lands after
|
|
// `contact-sheet-2.jpg`, not before (default string sort orders them
|
|
// lexicographically and breaks at 10+ pages). Unpaginated `contact-sheet.jpg`
|
|
// gets page 0 so it sorts first if it co-exists with paginated files.
|
|
const all = readdirSync(fullDir)
|
|
.filter((f) => paginatedRe.test(f))
|
|
.map((f) => ({ name: f, page: parseInt(f.match(paginatedRe)?.[1] ?? "0", 10) }))
|
|
.sort((a, b) => a.page - b.page)
|
|
.map((entry) => entry.name);
|
|
if (all.length === 0) return [];
|
|
if (all.length === 1) {
|
|
return [`| \`${dir}/${all[0]}\` | ${label} |`];
|
|
}
|
|
return all.map((f, i) => `| \`${dir}/${f}\` | ${label} — page ${i + 1} of ${all.length} |`);
|
|
}
|
|
|
|
const tableRows: string[] = [];
|
|
if (hasScreenshot) {
|
|
const screenshotRows = contactSheetRows(
|
|
"screenshots",
|
|
"contact-sheet.jpg",
|
|
"**View this first.** All scroll screenshots in labeled grid — see the entire page at a glance",
|
|
);
|
|
if (screenshotRows.length > 0) {
|
|
tableRows.push(...screenshotRows);
|
|
} else {
|
|
tableRows.push(
|
|
"| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in one labeled grid. |",
|
|
);
|
|
}
|
|
tableRows.push(
|
|
"| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |",
|
|
);
|
|
}
|
|
tableRows.push(
|
|
`| \`extracted/tokens.json\` | Design tokens: ${tokens.colors.length} colors, ${tokens.fonts.length} fonts, ${tokens.headings?.length ?? 0} headings, ${tokens.ctas?.length ?? 0} CTAs |`,
|
|
);
|
|
// design-styles.json is written from a try/catch in capture/index.ts and
|
|
// gets skipped when the live-DOM style extraction fails. Only list it in the
|
|
// agent prompt when it actually exists, so the agent isn't pointed at a 404.
|
|
if (existsSync(join(outputDir, "extracted", "design-styles.json"))) {
|
|
tableRows.push(
|
|
"| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |",
|
|
);
|
|
}
|
|
tableRows.push(
|
|
"| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |",
|
|
);
|
|
tableRows.push(
|
|
"| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |",
|
|
);
|
|
if (hasLottie) {
|
|
tableRows.push(
|
|
"| `extracted/lottie-manifest.json` | Lottie animations with previews at `assets/lottie/previews/`. |",
|
|
);
|
|
}
|
|
if (hasShaders) {
|
|
tableRows.push("| `extracted/shaders.json` | WebGL shader source (GLSL). |");
|
|
}
|
|
|
|
// Asset contact sheets — dynamically list all pages
|
|
const assetSheetRows = contactSheetRows(
|
|
"assets",
|
|
"contact-sheet.jpg",
|
|
"Downloaded images in labeled grid — view before opening individual files",
|
|
);
|
|
if (assetSheetRows.length > 0) {
|
|
tableRows.push(...assetSheetRows);
|
|
} else {
|
|
tableRows.push("| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |");
|
|
}
|
|
|
|
// SVG contact sheets — check both assets/svgs/ and assets/ root fallback
|
|
const svgSubdirRows = contactSheetRows(
|
|
"assets/svgs",
|
|
"contact-sheet.jpg",
|
|
"SVGs rendered as thumbnails in labeled grid",
|
|
);
|
|
const svgRootRows = contactSheetRows(
|
|
"assets",
|
|
"contact-sheet-svgs.jpg",
|
|
"SVGs rendered as thumbnails in labeled grid",
|
|
);
|
|
const svgRows = svgSubdirRows.length > 0 ? svgSubdirRows : svgRootRows;
|
|
if (svgRows.length > 0) {
|
|
tableRows.push(...svgRows);
|
|
}
|
|
|
|
tableRows.push("| `assets/` | Individual downloaded images, SVGs, and font files. |");
|
|
|
|
// Brand summary — just the essentials
|
|
const brandLines: string[] = [];
|
|
brandLines.push(`- **Colors**: ${colorSummary || "see tokens.json"}`);
|
|
brandLines.push(`- **Fonts**: ${fontSummary}`);
|
|
|
|
return `# ${title}
|
|
|
|
Source: ${url}
|
|
|
|
To create a video from this capture, use the \`website-to-hyperframes\` skill.
|
|
|
|
## What's in This Capture
|
|
|
|
| File | Contents |
|
|
|------|----------|
|
|
${tableRows.join("\n")}
|
|
|
|
## Brand Summary
|
|
|
|
${brandLines.join("\n")}
|
|
`;
|
|
}
|