mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
fix: double-audio bug + lint rules + docs guide + capture improvements
Double-audio bug fix: - scaffolding.ts: stop writing index.html in captures/ (root cause — runtime discovered scaffold + real index.html as two compositions) - New lint rule: multiple_root_compositions — errors if >1 root HTML - New lint rule: duplicate_audio_track — warns on overlapping audio Capture improvements (from testing 30+ websites): - Catalog runs BEFORE extractHtml (which mutates DOM — converts img src to data URLs). HeyKuba: 2 images → 78. - networkidle2 instead of networkidle0 (unblocks SPAs with WebSockets) - Lazy-load image wait, CSS background-image cataloging - SVG naming from class/id/parent (not just aria-label) - Gemini batch 5→20, pause 12s→2s, maxOutputTokens 300→500 - Asset descriptions sorted: captioned first Docs: - New guide: guides/website-to-video.mdx (full tutorial) - CLI docs: added capture and snapshot commands - docs.json: website-to-video in Guides nav C
This commit is contained in:
@@ -61,51 +61,13 @@ export async function generateProjectScaffold(
|
||||
progress: (stage: string, detail?: string) => void,
|
||||
warnings: string[],
|
||||
): Promise<void> {
|
||||
// Ensure capture output is a valid HyperFrames project (index.html + meta.json)
|
||||
const indexPath = join(outputDir, "index.html");
|
||||
// Capture output is a DATA folder, not a video project.
|
||||
// The agent builds index.html + compositions/ during step 6.
|
||||
// We only write meta.json (project metadata) — NOT index.html.
|
||||
// Writing index.html here caused a double-audio bug: the runtime
|
||||
// discovered both the scaffold and the agent's real index.html as
|
||||
// valid compositions, playing two audio tracks offset in time.
|
||||
const metaPath = join(outputDir, "meta.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
writeFileSync(
|
||||
indexPath,
|
||||
`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Root composition wrapper — AGENT: update data-duration to match total video length -->
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="28">
|
||||
|
||||
<!-- SCENE SLOTS — AGENT: adjust count, durations, and IDs to match your scene plan -->
|
||||
<div id="scene-1" data-composition-src="compositions/scene-1.html" data-start="0" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
|
||||
<div id="scene-2" data-composition-src="compositions/scene-2.html" data-start="7" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
|
||||
<div id="scene-3" data-composition-src="compositions/scene-3.html" data-start="14" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
|
||||
<div id="scene-4" data-composition-src="compositions/scene-4.html" data-start="21" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
|
||||
|
||||
<!-- NARRATION — AGENT: update src after generating TTS -->
|
||||
<audio id="narration" data-start="0" data-duration="28" data-track-index="0" data-volume="1" src="narration.wav"></audio>
|
||||
|
||||
<!-- CAPTIONS (optional — only add if user requests captions/subtitles) -->
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
if (!existsSync(metaPath)) {
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, "");
|
||||
writeFileSync(
|
||||
|
||||
@@ -53,6 +53,8 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
const projectFindings = [
|
||||
...lintProjectAudioFiles(project.dir, allHtmlSources),
|
||||
...lintAudioSrcNotFound(project.dir, allHtmlSources),
|
||||
...lintMultipleRootCompositions(results),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
// Append project-level findings to the root index.html result
|
||||
@@ -154,6 +156,68 @@ function lintAudioSrcNotFound(projectDir: string, htmlSources: string[]): Hyperf
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function lintMultipleRootCompositions(
|
||||
results: Array<{ file: string; result: HyperframeLintResult }>,
|
||||
): 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.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn if multiple <audio> elements on the same data-track-index overlap in time.
|
||||
* This causes layered audio playback.
|
||||
*/
|
||||
function lintDuplicateAudioTracks(htmlSources: string[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const audioRe =
|
||||
/<audio\b[^>]*\bdata-track-index\s*=\s*["'](\d+)["'][^>]*\bdata-start\s*=\s*["']([^"']+)["'][^>]*\bdata-duration\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
||||
for (const html of htmlSources) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioRe.exec(html)) !== null) {
|
||||
const trackIndex = parseInt(match[1]!, 10);
|
||||
const start = parseFloat(match[2]!);
|
||||
const duration = parseFloat(match[3]!);
|
||||
const srcMatch = match[0].match(/\bsrc\s*=\s*["']([^"']+)["']/);
|
||||
tracks.push({ trackIndex, start, end: start + duration, src: srcMatch?.[1] ?? "unknown" });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
for (let j = i + 1; j < tracks.length; j++) {
|
||||
const a = tracks[i]!;
|
||||
const b = tracks[j]!;
|
||||
if (a.trackIndex !== b.trackIndex) continue;
|
||||
if (a.start < b.end && b.start < a.end) {
|
||||
findings.push({
|
||||
code: "duplicate_audio_track",
|
||||
severity: "warning",
|
||||
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${a.end.toFixed(1)}s, ${b.src} at ${b.start}-${b.end.toFixed(1)}s). This causes layered audio playback.`,
|
||||
fixHint: "Use non-overlapping time windows or different track indices.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a render should be blocked based on lint results and strict mode.
|
||||
* --strict blocks on errors; --strict-all blocks on errors or warnings.
|
||||
|
||||
Reference in New Issue
Block a user