Sync or Die: Anchor Visuals to Speech with Whisper
A real production bug: proportionally scaling a storyboard to a voiceover made the 'Context Engine' caption appear over the wrong slide. Whisper word-level timestamps are the only fix.
The Bug That Shipped
I was building a 9:16 reel for Acme Intelligence. Six segments, ElevenLabs voiceover, custom captions, clean cut.
The storyboard was written for a 75-second clip. The final ElevenLabs render came back at 65.84 seconds. Standard stuff — TTS never hits the estimated duration exactly. So I did what seemed reasonable: I multiplied every timestamp by 65.84 ÷ 75 ≈ 0.877. A single scalar applied globally. Every segment start, every caption start, scaled down proportionally.
The video exported. It looked fine in the timeline scrubber. I shipped it.
Then came the feedback: "The Context Engine line didn't make sense — it was showing the Acme Labs slide."
The caption "Powered by Context Engine" — a line ElevenLabs actually speaks at roughly 8.3 seconds into the intro — was appearing over the wrong visual. The caption had drifted late. Not randomly — predictably, because the scalar gets applied to the storyboard's estimate, not to the audio. The storyboard had placed that line at 11.06 seconds, and 11.06 × 0.877 = 9.7s. But ElevenLabs said "Context Engine" at 8.3s. The caption was running over a second behind the spoken word in the intro, and the drift only compounded deeper into the clip.
Why the Composer Didn't Catch It
The video composer is a pure renderer. You give it segment start times, durations, and caption events as floats. It renders them exactly as specified. It has no idea what the voiceover actually says or when. It has no mechanism to cross-check the supplied timestamps against the audio. It trusts the caller completely.
This is the right architecture for a renderer. The problem was upstream: the caller passed it guesses instead of measurements.
A render engine that accepts timing_source="proportional_guess" and timing_source="whisper_anchored" with equal trust will produce identical output — one accurate, one wrong, no warning either way.
The Root Cause Is One Assumption
The storyboard was the timing authority. It shouldn't be. The storyboard is written before the voiceover exists. It's an estimate. Once the final voiceover renders, everything changes — duration, pacing, emphasis. The storyboard's timestamps describe a hypothetical version of the video, not the one that actually got recorded.
The mismatch you see in the diagram above is not an edge case. It's the inevitable result of treating a pre-VO document as the post-VO source of truth. Scaling by a single multiplier cannot fix non-uniform drift — it can only shift the timing curve uniformly, and ElevenLabs doesn't produce a uniform curve.
The Fix: Whisper Word-Level Timestamps
The final voiceover is the single source of truth for all timing. It is the only artifact that cannot lie about when words are spoken — because it is the words being spoken.
Transcribe it. One command:
whisper vo.mp3 \
--model base \
--language en \
--word_timestamps True \
--output_format json \
--output_dir ./timing/
The output JSON has this shape:
{
"segments": [
{
"id": 0,
"start": 0.0,
"end": 10.2,
"text": " Welcome to Acme Intelligence.",
"words": [
{ "word": "Welcome", "start": 0.42, "end": 0.88, "probability": 0.97 },
{ "word": "to", "start": 0.88, "end": 1.02, "probability": 0.99 },
{ "word": "Acme", "start": 1.06, "end": 1.64, "probability": 0.96 },
{ "word": "Intelligence.", "start": 1.64, "end": 2.31, "probability": 0.98 }
]
}
]
}
Every word has a start and end. Those are the timestamps you anchor to.
For the actual reel that shipped with the bug, these were the recovered Whisper timestamps:
| Visual segment | Whisper anchor |
|---|---|
| "Acme Labs" slide | 13.3s — when "Acme" is first spoken |
| "employees" reference | 15.1s |
| "org chart" slide | 36.1s |
| "spending limit" line | 54.8s |
| "boss / robot" reveal | 59.7s |
| "Powered by Context Engine" caption | 8.3s — what it should have been all along |
The scaled position of that caption was 9.7s, over a second late. The viewer caught it because captions that lag the speech break the illusion of competent production. Human perception of audio-visual sync is merciless at the 500ms threshold.
Making It a Pipeline Step
The fix becomes permanent when it's structural, not manual. Two additions:
Step 1: Add whisper-anchor to your VO pipeline step
import subprocess, json, pathlib
def whisper_anchor(vo_path: str, output_dir: str = "./timing") -> dict:
"""Transcribe VO with word-level timestamps. Returns parsed JSON."""
pathlib.Path(output_dir).mkdir(exist_ok=True)
subprocess.run([
"whisper", vo_path,
"--model", "base",
"--language", "en",
"--word_timestamps", "True",
"--output_format", "json",
"--output_dir", output_dir,
], check=True)
stem = pathlib.Path(vo_path).stem
with open(f"{output_dir}/{stem}.json") as f:
return json.load(f)
def find_word_start(whisper_json: dict, target: str) -> float:
"""Find the start timestamp for the first occurrence of target word."""
target_clean = target.lower().strip(".,!?")
for seg in whisper_json["segments"]:
for w in seg.get("words", []):
if w["word"].strip().lower().strip(".,!?") == target_clean:
return w["start"]
raise ValueError(f"Word '{target}' not found in transcript")
Step 2: Stamp the timing manifest
segment_manifest = {
"vo_path": "vo_final.mp3",
"vo_duration": 65.84,
"timing_source": "whisper_anchored", # gate key
"anchored_at": "2026-05-31T14:22:00Z",
"segments": [
{
"id": "acme-slide",
"start": find_word_start(whisper_data, "Acme"),
"duration": 4.2,
"visual": "acme_labs_slide.png",
},
# ... rest of segments
]
}
Step 3: Gate at compose time
def validate_manifest(manifest: dict) -> None:
if manifest.get("timing_source") != "whisper_anchored":
raise ValueError(
f"Refused to compose: timing_source='{manifest.get('timing_source')}'. "
"Only whisper_anchored manifests are accepted. "
"Run whisper_anchor() on the FINAL voiceover render and update the manifest."
)
The Rule, Hard
Never proportionally scale a storyboard to a voiceover. Proportional scaling is a guess that happens to work when drift is small enough that viewers don't notice. Viewers notice.
The production rule:
- Write the storyboard as a creative document — segment order, visual descriptions, approximate durations. Treat timestamps as placeholders.
- Generate the final ElevenLabs voiceover.
- Run
whisper vo.mp3 --word_timestamps Trueon the FINAL render. - For each visual segment, anchor
startto the Whisper timestamp of the first word spoken in that segment's content. - For each caption, anchor
starttowords[j].startandendtowords[j].endfor the exact word. - Mark
timing_source="whisper_anchored"in the manifest. - The compose step validates
timing_sourceand hard-fails on anything else.
The compose step trusting caller-supplied floats is fine. The caller supplying those floats must be Whisper, not a spreadsheet formula.
What's Next
The next lesson covers the caption density problem: when word-level captions produce 54 groups for a 66-second reel, the viewer reads instead of watches — and the sparse key-phrase contract that fixes it.