fix(snapshot): two sub-composition seek bugs causing black beats

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
This commit is contained in:
ukimsanov
2026-05-16 11:48:09 -07:00
parent a5a405efd1
commit 4ae59e5a7d
2 changed files with 39 additions and 10 deletions
+21 -7
View File
@@ -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<HTMLElement>(
`[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);
}
}
}
@@ -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:**