diff --git a/docs/docs.json b/docs/docs.json
index b2186defd..440ff046a 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -69,7 +69,7 @@
{
"group": "Guides",
"pages": [
-"guides/website-to-video",
+ "guides/website-to-video",
"guides/prompting",
"guides/gsap-animation",
"guides/rendering",
diff --git a/docs/guides/website-to-video.mdx b/docs/guides/website-to-video.mdx
index c64cd450c..10d3aa1c0 100644
--- a/docs/guides/website-to-video.mdx
+++ b/docs/guides/website-to-video.mdx
@@ -18,7 +18,6 @@ Give your AI agent a URL and a creative direction. It captures the site, extract
```bash
npx skills add heygen-com/hyperframes
- npx skills add greensock/gsap-skills
```
Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [Codex CLI](https://github.com/openai/codex).
@@ -27,11 +26,14 @@ Give your AI agent a URL and a creative direction. It captures the site, extract
Open your agent in any directory and describe the video you want:
```
- Create a 25-second product launch video from https://stripe.com.
- Bold, cinematic, financial infrastructure energy.
+ Create a 25-second product launch video from https://example.com. Bold, cinematic, dark theme energy.
```
- The agent discovers the `/website-to-hyperframes` skill and runs the full pipeline automatically — capture, design, script, storyboard, voiceover, build, validate.
+ The agent loads the skill when they see a URL and a video request, and runs the full pipeline — capture, design, script, storyboard, voiceover, build, validate.
+
+
+ Agents also trigger this skill automatically when they see a URL and a video request.
+
```bash
@@ -203,7 +205,7 @@ You don't need to re-run the full pipeline to make changes:
npx skills add heygen-com/hyperframes
```
- The skill triggers automatically when the agent sees a URL and a video request. To invoke explicitly: _"Use the /website-to-hyperframes skill."_
+ Lead your prompt with _"Use the /website-to-hyperframes skill"_ for the most reliable results. Agents also discover it automatically when they see a URL and a video request.
diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 1e36c3238..4af280e57 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -354,7 +354,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
Output is a self-contained directory with a `CLAUDE.md` file that any AI agent can read to understand the captured site. Used by the `/website-to-hyperframes` skill as step 1 of the video production pipeline.
- Set `GEMINI_API_KEY` in a `.env` file for AI-powered image descriptions via Gemini 2.5 Flash vision (~$0.001/image). See the [Website to Video](/guides/website-to-video#enriching-captures-with-gemini-vision) guide for details.
+ Set `GEMINI_API_KEY` in a `.env` file for AI-powered image descriptions via Gemini vision (~$0.001/image). See the [Website to Video](/guides/website-to-video#enriching-captures-with-gemini-vision) guide for details.
diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts
index 1accb3e3d..f376944f3 100644
--- a/packages/cli/src/capture/contentExtractor.ts
+++ b/packages/cli/src/capture/contentExtractor.ts
@@ -174,7 +174,11 @@ export async function captionImagesWithGemini(
// Free tier: 5 RPM → batch 5, 12s pause (~$0 but slow)
// Paid tier: 2000 RPM → batch 20, 1s pause (~$0.001/image, fast)
// We try a larger batch first; if rate-limited, fall back to smaller batches.
- const model = "gemini-3.1-flash-lite-preview";
+ // Default is a preview model — update when GA ships.
+ // Benchmark (49 images, paid tier): 3.1-flash-lite-preview ~507ms/img 131ch avg,
+ // 2.5-flash-lite ~230ms/img 117ch avg. Preview has richer captions but higher variance.
+ // Override: HYPERFRAMES_GEMINI_MODEL=gemini-2.5-flash-lite
+ const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
const BATCH_SIZE = 20;
for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) {
const batch = imageFiles.slice(i, i + BATCH_SIZE);
diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts
index c3e0b6acd..c3773945d 100644
--- a/packages/cli/src/utils/lintProject.test.ts
+++ b/packages/cli/src/utils/lintProject.test.ts
@@ -326,6 +326,146 @@ describe("audio_src_not_found", () => {
});
});
+describe("multiple_root_compositions", () => {
+ it("fires when two HTML files have data-composition-id", () => {
+ const project = makeProject(validHtml());
+ writeFileSync(
+ join(project.dir, "scaffold.html"),
+ '',
+ );
+ const { totalErrors, results } = lintProject(project);
+ const finding = results[0]?.result.findings.find(
+ (f) => f.code === "multiple_root_compositions",
+ );
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.message).toContain("scaffold.html");
+ expect(totalErrors).toBeGreaterThan(0);
+ });
+
+ it("does NOT fire with a single root composition", () => {
+ const project = makeProject(validHtml());
+ const { results } = lintProject(project);
+ const finding = results[0]?.result.findings.find(
+ (f) => f.code === "multiple_root_compositions",
+ );
+ expect(finding).toBeUndefined();
+ });
+
+ it("ignores HTML files without data-composition-id", () => {
+ const project = makeProject(validHtml());
+ writeFileSync(join(project.dir, "readme.html"), "Not a composition");
+ const { results } = lintProject(project);
+ const finding = results[0]?.result.findings.find(
+ (f) => f.code === "multiple_root_compositions",
+ );
+ expect(finding).toBeUndefined();
+ });
+});
+
+describe("duplicate_audio_track", () => {
+ it("detects overlapping audio with attributes in any order", () => {
+ // The original scaffold bug: data-start BEFORE data-track-index
+ const html = `
+
+
+
+`;
+ const project = makeProject(html);
+ const { results } = lintProject(project);
+ const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("warning");
+ });
+
+ it("does NOT fire for non-overlapping audio on the same track", () => {
+ const html = `
+
+
+
+
+
+`;
+ const project = makeProject(html);
+ const { results } = lintProject(project);
+ const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track");
+ expect(finding).toBeUndefined();
+ });
+
+ it("does NOT fire for audio on different tracks", () => {
+ const html = `
+