From 274db7a5efabf028d2693caf0738a17287b94fcf Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Fri, 17 Apr 2026 10:15:26 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20address=20PR=20#299=20review=20=E2=80=94?= =?UTF-8?q?=20lint=20correctness,=20docs,=20Gemini=20benchmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lintMultipleRootCompositions: scan filesystem for HTML files with data-composition-id (was filtering results array — always 1 entry) - lintDuplicateAudioTracks: order-independent attribute extraction, dedup by (src,start,duration,trackIndex), Infinity fallback for missing data-duration (matches runtime behavior) - 10 new tests for both lint rules - docs: explicit skill invocation, remove gsap-skills, fix indentation - Gemini: env override (HYPERFRAMES_GEMINI_MODEL), benchmark data in code comment (49 imgs: 3.1-lite ~507ms/img, 2.5-lite ~230ms/img) - cli.mdx: version-agnostic "Gemini vision" reference Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs.json | 2 +- docs/guides/website-to-video.mdx | 12 +- docs/packages/cli.mdx | 2 +- packages/cli/src/capture/contentExtractor.ts | 6 +- packages/cli/src/utils/lintProject.test.ts | 140 +++++++++++++++++++ packages/cli/src/utils/lintProject.ts | 86 ++++++++---- 6 files changed, 212 insertions(+), 36 deletions(-) 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 = ` +
+
+ +`; + 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("deduplicates same audio found in root + sub-composition", () => { + const project = makeProject(validHtmlWithAudio(), { + "scene.html": validHtmlWithAudio("scene"), + }); + writeFileSync(join(project.dir, "song.mp3"), "fake"); + const { results } = lintProject(project); + const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track"); + expect(finding).toBeUndefined(); + }); + + it("detects overlap when data-duration is missing (Infinity fallback)", () => { + 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(); + }); + + it("formats Infinity end times as 'end' without crashing", () => { + 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?.message).toContain("end"); + expect(finding?.message).not.toContain("Infinity"); + }); + + it("finds audio across multiple HTML sources (g-flag regression)", () => { + const project = makeProject(validHtmlWithAudio(), { + "scene.html": ` +
+
+ +`, + }); + writeFileSync(join(project.dir, "song.mp3"), "fake"); + writeFileSync(join(project.dir, "music.wav"), "fake"); + const { results } = lintProject(project); + const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track"); + // song.mp3@0 (from validHtmlWithAudio, no data-duration → Infinity) and music.wav@5-25 overlap + expect(finding).toBeDefined(); + }); +}); + describe("shouldBlockRender", () => { it("default: does not block on errors", () => { expect(shouldBlockRender(false, false, 5, 0)).toBe(false); diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index 133a53dd1..3519baad3 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -53,7 +53,7 @@ export function lintProject(project: ProjectDir): ProjectLintResult { const projectFindings = [ ...lintProjectAudioFiles(project.dir, allHtmlSources), ...lintAudioSrcNotFound(project.dir, allHtmlSources), - ...lintMultipleRootCompositions(results), + ...lintMultipleRootCompositions(project.dir), ...lintDuplicateAudioTracks(allHtmlSources), ]; if (projectFindings.length > 0) { @@ -109,7 +109,7 @@ function lintProjectAudioFiles(projectDir: string, htmlSources: string[]): Hyper fixHint: 'Add an element inside the composition root.', + '" data-start="0" data-duration="__DURATION__" data-track-index="0" data-volume="1"> element inside the composition root. Replace __DURATION__ with the audio length in seconds.', }); } @@ -157,46 +157,76 @@ function lintAudioSrcNotFound(projectDir: string, htmlSources: string[]): Hyperf } /** - * Error if multiple root-level HTML files exist (not in compositions/). - * Catches the double-audio bug where a scaffold and the real index.html - * both register as root compositions. + * Error if multiple root-level HTML files with data-composition-id exist. + * Scans the project directory filesystem (not just what lintProject chose to read) + * to catch stray scaffold files, duplicates, or backup copies. */ -function lintMultipleRootCompositions( - results: Array<{ file: string; result: HyperframeLintResult }>, -): HyperframeLintFinding[] { +function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] { const findings: HyperframeLintFinding[] = []; - const rootFiles = results.map((r) => r.file).filter((f) => !f.startsWith("compositions/")); - - if (rootFiles.length > 1) { - findings.push({ - code: "multiple_root_compositions", - severity: "error", - message: `Multiple root-level HTML files found: ${rootFiles.join(", ")}. The runtime may discover both as composition entry points, causing duplicate audio playback.`, - fixHint: - "A project should have exactly one root index.html. Remove or rename extra root-level HTML files.", - }); + try { + const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html")); + const rootCompositions: string[] = []; + for (const file of rootHtmlFiles) { + const content = readFileSync(join(projectDir, file), "utf-8"); + if (/data-composition-id/i.test(content)) { + rootCompositions.push(file); + } + } + if (rootCompositions.length > 1) { + findings.push({ + code: "multiple_root_compositions", + severity: "error", + message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`, + fixHint: + "A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.", + }); + } + } catch { + /* directory read failed — skip */ } return findings; } /** * Warn if multiple