Objective
What you're building
Given a voiceover MP3 and its script, produce a complete MP4: relevant B-roll clips trimmed and stitched to exactly match the audio's length, with animated word-by-word captions burned in.
Fetching relevant B-roll from pexels
import requests
def search_pexels_videos(keyword: str, per_page: int = 5) -> list[dict]:
response = requests.get(
"https://api.pexels.com/videos/search",
headers={"Authorization": os.environ["PEXELS_API_KEY"]},
params={"query": keyword, "per_page": per_page, "orientation": "portrait"},
)
response.raise_for_status()
videos = response.json()["videos"]
return [{"id": v["id"], "url": v["video_files"][0]["link"], "duration": v["duration"]} for v in videos]
orientation: "portrait" matters if you're targeting TikTok/Shorts/Reels rather than landscape YouTube -- decide this once for your channel, since mixing orientations mid-pipeline creates real assembly headaches later. Keywords should come from the script's actual content (extract 2-3 concrete nouns per script section), not the raw topic title, which is often too abstract to search stock footage for directly.
The duration math the SPEC's own hotspot warns about
This is the #1 failure mode in this entire course, per the SPEC's own hotspot list
Students consistently end up with videos that either end early (black screen for the last 10 seconds) or cut off mid-sentence, because they didn't do the actual arithmetic: total B-roll duration must equal or exceed the voiceover's duration, with clips trimmed (not stretched) to fit. If your fetched clips total 45 seconds and your voiceover is 75 seconds, you need to loop or fetch more clips -- there is no shortcut around doing this math explicitly.
from moviepy import VideoFileClip, concatenate_videoclips, AudioFileClip
def assemble_broll(clip_paths: list[str], target_duration: float) -> "VideoFileClip":
clips = [VideoFileClip(p) for p in clip_paths]
total_available = sum(c.duration for c in clips)
if total_available < target_duration:
# Loop clips until we have enough footage -- never stretch/slow clips to fit.
multiplier = int(target_duration // total_available) + 1
clips = clips * multiplier
concatenated = concatenate_videoclips(clips, method="compose")
return concatenated.subclipped(0, target_duration)
Note the current MoviePy 2.x API here: no moviepy.editor import (that namespace was removed), and .subclipped() (not the pre-2.0 .subclip()). MoviePy had a real, significant API change between 1.x and 2.x -- set_* methods became with_* methods across the board. If a tutorial or snippet you find elsewhere uses .set_duration() or imports from moviepy.editor, it's describing the older version; verify against current docs before trusting it, exactly the caution this whole curriculum keeps repeating for fast-moving libraries.
Captions: word-level timing from whisper
Word-by-word "viral style" captions need word-level timestamps, not just a subtitle file
A standard SRT subtitle file times whole sentences, not individual words -- Whisper's word_timestamps=True option gives you per-word start/end times, which is what lets you build the animated, one-word-highlighted-at-a-time caption style common on short-form video. This is meaningfully more data to work with than a basic transcript, and it's the specific reason this course uses local Whisper rather than a simpler transcription API.
Your assembled video's B-roll runs exactly the voiceover's duration, but captions appear roughly 1-2 seconds ahead of the words being spoken. What's the most likely cause?
A consistent, uniform offset (not random drift) points to a systematic timing mismatch -- most likely the captions were generated against one version of the audio file (e.g., pre-normalization from Lesson 4) while the video's actual audio track is a slightly different file, or an intro/silence padding was added after caption timing was computed. Trace exactly which audio file Whisper transcribed versus which one ended up in the final video.
What you're building today
video_assembler.py: Pexels B-roll fetching, the duration-matching assembly logic (loop, never stretch), Whisper word-level caption generation, and a composite function overlaying animated captions onto the B-roll+audio — tested on one real script end to end, with the final video's duration verified to exactly match the voiceover.