From 4ae59e5a7dd731bdc8f5f82304bcd91b8b71a6c7 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 16 May 2026 11:48:09 -0700 Subject: [PATCH] fix(snapshot): two sub-composition seek bugs causing black beats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: snapshot fired before all sub-compositions loaded - Old: resolved when keys.length >= 2 (first sub-comp registered) - Fix: wait until all data-composition-src hosts have timelines - Verified: beats 2-4 now visible in huly-promo v4 snapshots Bug 2: sub-comp timelines seeked to global time, not local time - Old: tls[key].seek(globalTime) — with exit animations, global t=29 clamps to GSAP end (t=7), elements at opacity:0 from exit fade - Fix: seek to global_t - host.data_start (correct local time) - Verified: beat-5 (starts at t=26.7) seeked to local t=2.3 at global t=29 — 'Join the Movement' now visible in snapshots Both bugs confirmed via render vs snapshot comparison before fixing. Also fix HeyGen v3 API docs: response is data.audio_url not audio_url directly; word_timestamps uses 'word' key not 'text' — normalize before saving as transcript.json --- packages/cli/src/commands/snapshot.ts | 28 ++++++++++++++----- .../references/step-4-vo.md | 21 ++++++++++++-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 09c5a9362c..0c8972bf4a 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -139,17 +139,20 @@ async function captureSnapshots( }) .catch(() => {}); - // Wait for sub-compositions to be mounted by the runtime - // (they're fetched and injected asynchronously via data-composition-src) + // Wait for ALL sub-compositions to be mounted by the runtime. + // The old check resolved when the first sub-timeline registered, causing + // "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the + // snapshot seeked into its time range. Now we count data-composition-src + // host elements and wait until we have a matching number of sub-timelines. await page .waitForFunction( () => { const tls = (window as any).__timelines; if (!tls) return false; - const keys = Object.keys(tls); - // Wait until at least one sub-composition timeline is registered - // (not counting "main" or empty registrations) - return keys.length >= 2 || keys.some((k) => k !== "main"); + const hosts = document.querySelectorAll("[data-composition-src]").length; + if (hosts === 0) return Object.keys(tls).length >= 1; + const subKeys = Object.keys(tls).filter((k) => k !== "main"); + return subKeys.length >= hosts; }, { timeout: timeoutMs }, ) @@ -293,8 +296,19 @@ async function captureSnapshots( if (tls) { for (const key in tls) { if (tls[key]?.seek) { + // Sub-composition timelines run in local time relative to + // their data-start. Seeking them to global time causes beats + // with exit animations to appear black (global t clamps past + // the exit). Compute local time: global_t - data_start. + const host = document.querySelector( + `[data-composition-id="${key}"]`, + ); + const dataStart = host + ? parseFloat(host.getAttribute("data-start") ?? "0") || 0 + : 0; + const localTime = Math.max(0, t - dataStart); tls[key].pause(); - tls[key].seek(t); + tls[key].seek(localTime); } } } diff --git a/skills/website-to-hyperframes/references/step-4-vo.md b/skills/website-to-hyperframes/references/step-4-vo.md index b0bd80eece..119edd8d3d 100644 --- a/skills/website-to-hyperframes/references/step-4-vo.md +++ b/skills/website-to-hyperframes/references/step-4-vo.md @@ -73,12 +73,18 @@ After the provider is selected, audition at least 2 voices with the first senten -H "x-api-key: $HEYGEN_API_KEY" | python3 -c \ "import json,sys; v=json.load(sys.stdin)['data']; [print(x['voice_id'], x['name'], x['language']) for x in v[:10]]" - # Generate audio — returns audio_url + word_timestamps in one call + # Generate audio — response: { "data": { "audio_url": ..., "word_timestamps": [...] } } curl -s -X POST "https://api.heygen.com/v3/voices/speech" \ -H "x-api-key: $HEYGEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"Your script here","voice_id":"VOICE_ID","speed":1.0}' \ - | python3 -c "import json,sys; r=json.load(sys.stdin); print(r['audio_url']); open('transcript_raw.json','w').write(json.dumps(r.get('word_timestamps',[]),indent=2))" + | python3 -c " + import json,sys + r=json.load(sys.stdin) + d=r['data'] + print(d['audio_url']) + open('transcript_raw.json','w').write(json.dumps(d.get('word_timestamps',[]),indent=2)) + " # Then download the audio curl -sL "AUDIO_URL_FROM_ABOVE" --output narration.mp3 @@ -129,7 +135,16 @@ For ElevenLabs and HeyGen TTS, substitutions are usually unnecessary — they ha ## Transcribe for word-level timestamps -**If you used HeyGen v3 TTS:** word timestamps were already returned in the generate call (`word_timestamps: [{ word, start, end }]`). Save them as `transcript.json` — the format matches what the beat mapping step expects. No separate transcription step needed. +**If you used HeyGen v3 TTS:** word timestamps were returned in the generate call. Normalize the format before saving — HeyGen v3 uses `word` but the pipeline expects `text`: + +```python +import json +raw = json.load(open('transcript_raw.json')) +normalized = [{"text": w["word"], "start": w["start"], "end": w["end"]} for w in raw] +json.dump(normalized, open('transcript.json', 'w'), indent=2) +``` + +No separate transcription step needed. **If you used ElevenLabs or Kokoro:**