Build a Faceless YouTube Content Engine

Use Claude Code Desktop to prompt-build a Python engine that turns any topic into a narrated explainer video.

Introduction

30 Second Summary

Faceless YouTube channels produce explainer videos on everything from quantum physics to personal finance. Behind the scenes, creators spend hours writing scripts, recording voiceovers, sourcing stock footage, and stitching it all together.

In this project, you will build a command-line content engine that turns any topic into a narrated explainer video with stock footage. You control every stage of the pipeline independently, from script generation to final assembly.

What You'll Build

You will run a single command with any topic to get back an AI-generated explainer script you can review and edit. Then you build the voiceover, pull matching stock footage, and assemble a finished MP4 ready for YouTube.

By the end of this project, you'll have:

  • A three-command CLI (generate, build, and assemble) that lets you produce explainer videos for any topic with full editorial control over every stage.
  • AI-generated explainer scripts saved as editable JSON files you can review and rewrite before the engine touches audio or video.
  • A finished MP4 explainer video with natural-sounding voiceover narration synced to stock footage clips, assembled and ready to share.
  • Secret Mission: Add animated text captions that overlay each section's key point on the video with smooth fade-in and fade-out effects.

Do I need any experience for this project?

This project assumes intermediate Python knowledge and a working VS Code setup.

You will also need a Google account and a free Pexels account for API keys.

Before We Start

Before we dive in, let's take a moment to commit to what you're about to build and why. Having a clear picture of the finished product and a test topic in mind will ground every step ahead.

Set Up Your Tools and API Keys

Your content engine needs three things before any stage can run: a Python runtime new enough for the AI library, a set of packages that handle script generation, text-to-speech, and video editing, and two free API keys that connect the pipeline to the outside world.

In this step, you will confirm your Python version, install the packages, grab your keys, and create the two starter files that hold everything together.

In this step, get ready to:
  • Confirm Python 3.10+ is installed and install your pip packages.
  • Get your free Gemini and Pexels API keys.
  • Create your project folder with config.py and engine.py starter files.
Check your Python version and install packages

The google-genai package requires Python 3.10 or higher. You need to confirm your version before installing anything.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:
python3 --version

Windows and python3

On Windows, python3 may not be recognized. Try python --version instead. Throughout this project, substitute python for python3 and pip for pip3 if your system uses those commands.

✔️ I see version 3.10 or higher

You are good to go. Move on to the package install below.

ⓧ I see an older version

Your system Python is below 3.10. macOS often ships with an older Python that shadows newer installs.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer and follow the prompts.
  • After installation, close and reopen Terminal.
  • Run python3 --version again to confirm it now shows 3.10 or higher.

macOS still showing the old version?

macOS may keep the old system Python on the default path. Try running the full path to the new install, such as /usr/local/bin/python3. Alternatively, use a version manager like pyenv to set 3.10+ as your default.

ⓧ Command not found

Python is not installed on your machine yet.

  • Download the latest Python installer from python.org/downloads.
  • Run the installer and follow the prompts.
  • After installation, close and reopen Terminal.
  • Run python3 --version to confirm you see 3.10 or higher.
  • Install the four packages your engine needs by running this command:
pip3 install google-genai edge-tts moviepy requests

You should see download and install output for each package, ending with a success message.

What are these packages?

google-genai connects to the Gemini API so your engine can generate scripts with AI.

edge-tts uses Microsoft Edge's online text-to-speech service to create natural-sounding voiceover audio for free, with no API key required.

moviepy is a Python video editing library that lets you trim, concatenate, and composite video clips programmatically.

requests handles the HTTP calls to download stock footage from Pexels.

Install failing or permissions error?

If you see a permissions error, try adding --user to the end of the install command. If pip3 is not found, make sure your Python 3.10+ install completed successfully in the step above.

help me debug my pip install error

Get your Gemini and Pexels API keys

Your engine calls two external services: Gemini for script generation and Pexels for stock footage. Both offer free API keys.

Start with the Gemini key:

  • Open aistudio.google.com in your browser.
  • Sign in with your Google account.
  • Click Get API key on the landing page.
  • Click Create API key and follow the dialog to create a new key.
  • Copy the key and save it somewhere safe. You will not be able to see it again after you close the dialog.

Is the Gemini API free?

Yes. The Gemini API free tier gives you free input and output tokens. This project stays well within those limits. Keys created in Google AI Studio are restricted to the Gemini API by default, so no extra configuration is needed.

Now get the Pexels key:

  • Open pexels.com/api in your browser.
  • Click Your API Key (or Get Started) to create a free Pexels account if you do not have one.
  • After signing up or logging in, you will receive your API key immediately.
  • Copy the key and save it alongside your Gemini key.

Is the Pexels API free?

Yes. Each Pexels API key includes 200 requests per hour and 20,000 requests per month at no cost. This project uses a handful of requests per video, so you will stay far below those limits.

Create your project folder and starter files

With your tools installed and keys in hand, you need a project folder to hold your engine code and the output it generates.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir content-engine
cd content-engine
  • Confirm the folder was created by running:
ls

The output should be empty because you just created this folder.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the content-engine folder, and click Open.

VS Code now shows an empty content-engine folder in the Explorer sidebar on the left.

  • In VS Code's Explorer sidebar, hover over the CONTENT-ENGINE folder name and click the New File icon.
  • Name the file config.py and press Enter.
  • Paste the following code into config.py:
GEMINI_API_KEY = "your-gemini-api-key-here"
PEXELS_API_KEY = "your-pexels-api-key-here"
  • Replace your-gemini-api-key-here with the Gemini API key you copied earlier.
  • Replace your-pexels-api-key-here with the Pexels API key you copied earlier.
  • Save config.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Why a separate config file?

Keeping your API keys in config.py separates secrets from logic. Your main engine.py imports the keys as variables, so you never hard-code them into the engine itself.

✔️ Awesome, I've got everything!

Great. Make sure you have saved config.py with your actual API keys pasted in.

ⓧ I'd like to double check the full code

Your config.py should look exactly like this, with your own API keys in place of the placeholder text:

GEMINI_API_KEY = "your-gemini-api-key-here"
PEXELS_API_KEY = "your-pexels-api-key-here"
  • Create another new file in the Explorer sidebar by clicking the New File icon again.
  • Name this file engine.py and press Enter.
  • Paste the following imports into engine.py:
import sys
import os
import json
import asyncio
import requests
from google import genai
from google.genai import types
import edge_tts
from moviepy import (
    VideoFileClip,
    AudioFileClip,
    TextClip,
    CompositeVideoClip,
    concatenate_videoclips,
)
from config import GEMINI_API_KEY, PEXELS_API_KEY

print('All imports OK')

What do these imports do?

  • sys, os, json, asyncio are standard Python libraries for command-line args, file paths, JSON parsing, and async support.
  • requests makes HTTP calls to download stock video from Pexels.
  • genai and types are from the google-genai SDK and let you call the Gemini API to generate scripts.
  • edge_tts converts text to speech audio files.
  • The moviepy imports (VideoFileClip, AudioFileClip, etc.) handle loading, compositing, and concatenating video and audio clips.
  • from config import pulls your API keys from the separate config.py file.

Notice MoviePy v2 imports directly from moviepy instead of the old moviepy.editor path. This is a common gotcha if you have seen older tutorials.

  • Save engine.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run this, what do you think will happen? Will all the imports load cleanly, or will something fail?

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Run the file to verify all imports by running this command:
python3 engine.py

You should see All imports OK printed in the terminal. Every package loaded successfully, and your config.py keys were imported.

Seeing a ModuleNotFoundError?

This means one of the packages did not install. Check which module the error names and re-run the pip3 install command for that specific package. Also confirm your terminal is using the same Python version you installed the packages for.

If the error mentions config, make sure config.py is saved in the same folder as engine.py and that you are running the command from inside the content-engine folder.

help me debug my ModuleNotFoundError

✔️ Awesome, I've got everything!

Great. Double check that you have saved both config.py and engine.py.

ⓧ I'd like to double check the full code

Your config.py should look like this (with your own keys):

GEMINI_API_KEY = "your-gemini-api-key-here"
PEXELS_API_KEY = "your-pexels-api-key-here"

Your engine.py should look like this:

import sys
import os
import json
import asyncio
import requests
from google import genai
from google.genai import types
import edge_tts
from moviepy import (
    VideoFileClip,
    AudioFileClip,
    TextClip,
    CompositeVideoClip,
    concatenate_videoclips,
)
from config import GEMINI_API_KEY, PEXELS_API_KEY

print('All imports OK')

Your environment is wired up. Python, packages, API keys, and starter files are all in place. Next up, you will write the generate command that turns a topic into an editable explainer script.

Generate and Edit an Explainer Script

Your tools are installed and your API keys are stored. Now it is time to build the first stage of the content engine: the script generator.

The entire pipeline starts with a structured script. By saving it to a JSON file instead of passing it in memory, you can review the AI's output, tweak the narration, change visual search terms, or rewrite sections before committing to audio and video generation.

In this step, get ready to:
  • Write a function that asks Gemini to generate a structured explainer script.
  • Set up a CLI with three subcommands so you can run the generator from your terminal.
  • Test the generator and edit the output to prove you control the content.
Set up the project constants and helpers

The engine needs to know where to save its output files. A set of constants and helper functions keeps all the file paths in one place so every command uses the same folders.

  • In engine.py, find this line at the bottom of the file:
print('All imports OK')
  • Delete that line and replace it with the following code that defines your output paths and helper functions:
# Directory paths for all output files
OUTPUT_DIR = "output"
AUDIO_DIR = os.path.join(OUTPUT_DIR, "audio")
VIDEO_DIR = os.path.join(OUTPUT_DIR, "video")
SCRIPT_PATH = os.path.join(OUTPUT_DIR, "script.json")

# TTS voice and video resolution settings
VOICE = "en-US-GuyNeural"
TARGET_RESOLUTION = (1920, 1080)


def setup_dirs():
    # Create output directories if they don't exist
    for d in [OUTPUT_DIR, AUDIO_DIR, VIDEO_DIR]:
        os.makedirs(d, exist_ok=True)


def load_script():
    # Read the saved script JSON from disk
    with open(SCRIPT_PATH, "r") as f:
        return json.load(f)


def save_script(script):
    # Write the script JSON to disk with readable formatting
    with open(SCRIPT_PATH, "w") as f:
        json.dump(script, f, indent=2)


def get_sections(script):
    # Build an ordered list of (name, section_data) tuples
    sections = [("intro", script["intro"])]
    for i, point in enumerate(script["key_points"]):
        sections.append((f"point_{i + 1}", point))
    sections.append(("outro", script["outro"]))
    return sections

What does this code do?

The four constants at the top (OUTPUT_DIR, AUDIO_DIR, VIDEO_DIR, SCRIPT_PATH) define a single folder structure every command shares. VOICE and TARGET_RESOLUTION set the text-to-speech voice and video size you will use later.

The four helper functions handle file I/O. setup_dirs() creates the folder tree. load_script() and save_script() read and write the script JSON. get_sections() flattens the script into an ordered list of tuples so every command can loop through sections the same way.

  • Save engine.py.
  • Confirm the helpers work by running this command to create the output folders:
python3 -c "from engine import setup_dirs; setup_dirs(); print('Dirs created')"
  • Verify the folders exist by running:
ls output/

You should see audio and video listed as subdirectories.

Seeing a ModuleNotFoundError?

Make sure you are running the command from inside your project folder (the one containing engine.py and config.py). If you see an import error for google or moviepy, re-run the pip3 install command from Step 1.

help me debug a ModuleNotFoundError when importing engine.py

Write the script generator function

The generator sends a detailed prompt to Gemini asking for a structured JSON script. The prompt tells the model exactly what shape to return so you can parse it programmatically.

  • In engine.py, add the first half of the generate_script function below get_sections. This creates the Gemini client and defines the prompt:
def generate_script(topic):
    # Create a Gemini client with your API key
    client = genai.Client(api_key=GEMINI_API_KEY)
    prompt = f"""Create a YouTube explainer video script about: {topic}

Return a JSON object with this exact structure:
{{
  "title": "A catchy video title",
  "intro": {{
    "narration": "An engaging 2-sentence introduction to hook the viewer.",
    "visual_query": "a search term for stock footage that fits the intro"
  }},
  "key_points": [
    {{
      "narration": "A clear 2-3 sentence explanation of the first key point.",
      "visual_query": "a search term for stock footage matching this point"
    }},
    {{
      "narration": "A clear 2-3 sentence explanation of the second key point.",
      "visual_query": "a search term for stock footage matching this point"
    }},
    {{
      "narration": "A clear 2-3 sentence explanation of the third key point.",
      "visual_query": "a search term for stock footage matching this point"
    }}
  ],
  "outro": {{
    "narration": "A brief 1-2 sentence closing with a call to action.",
    "visual_query": "a search term for stock footage that fits the outro"
  }}
}}

Make the narration conversational and easy to understand. Keep each narration segment under 40 words. The visual_query should be simple, concrete search terms that would return relevant stock video footage."""

What is this prompt doing?

The prompt asks Gemini to return a JSON object with a specific structure: a title, an intro, three key points, and an outro. Each section has a narration field (the spoken text) and a visual_query field (a search term for finding matching stock footage).

By specifying the exact JSON shape in the prompt, you get back data you can parse directly. The double curly braces {{ and }} are Python f-string escapes so the braces appear literally in the prompt.

The prompt defines what to ask for. The next piece sends it to Gemini and parses the response.

  • Add the following code directly after the prompt string you just pasted, still inside the generate_script function (keep the indentation at 4 spaces):
    # Send the prompt to Gemini and force JSON output
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            temperature=0.7,
        ),
    )
    # Parse the JSON string into a Python dictionary
    script = json.loads(response.text)
    return script

What does this code do?

client.models.generate_content() sends your prompt to the gemini-2.5-flash model. Setting response_mime_type to application/json tells Gemini to return valid JSON instead of freeform text.

json.loads(response.text) converts the JSON string into a Python dictionary so you can access fields like script["title"]. The temperature=0.7 adds slight creativity while keeping the output structured.

  • Save engine.py.
  • Confirm the function loads without syntax errors by running:
python3 -c "from engine import generate_script; print('Generator loaded OK')"

You should see Generator loaded OK printed. If you see a SyntaxError, double-check that the API call code is indented at 4 spaces (inside the function, not at the module level).

Seeing a SyntaxError or IndentationError?

The most common cause is misaligned indentation. The response = client.models.generate_content(...) block must sit inside generate_script at 4 spaces of indentation, directly below the closing """ of the prompt string.

help me fix a SyntaxError or IndentationError in my generate_script function

Set up the CLI and run the generator

The generator function exists, but you have no way to call it from the terminal yet. A CLI dispatcher lets you run different pipeline stages as subcommands.

  • In engine.py, add the cmd_generate command function below generate_script:
def cmd_generate(topic):
    # Orchestrate: create dirs, generate script, save, and print summary
    print(f"\n--- Generating script for: {topic} ---\n")
    setup_dirs()
    script = generate_script(topic)
    save_script(script)
    print(f"Title: {script['title']}")
    print(f"Sections: intro + {len(script['key_points'])} points + outro")
    print(f"\nScript saved to: {SCRIPT_PATH}")
    print("Review and edit the script, then run: python3 engine.py build")

What does this code do?

cmd_generate is the orchestrator for the generate stage. It creates the output folders, calls generate_script() to hit the Gemini API, saves the result to output/script.json, and prints a summary so you know the script is ready for review.

  • Below cmd_generate, add the placeholder stubs for the two commands you will build later, plus the CLI dispatcher:
def cmd_build():
    print("Build command coming soon.")


def cmd_assemble():
    print("Assemble command coming soon.")


# CLI dispatcher: route subcommands to handler functions
def main():
    if len(sys.argv) < 2:
        print("Usage:")
        print("  python3 engine.py generate \"your topic\"  - Generate editable script")
        print("  python3 engine.py build                   - Build audio, video, and assemble")
        print("  python3 engine.py assemble                - Reassemble from existing files")
        sys.exit(1)
    command = sys.argv[1]
    if command == "generate":
        if len(sys.argv) < 3:
            print("Usage: python3 engine.py generate \"your topic here\"")
            sys.exit(1)
        topic = sys.argv[2]
        cmd_generate(topic)
    elif command == "build":
        cmd_build()
    elif command == "assemble":
        cmd_assemble()
    else:
        print(f"Unknown command: {command}")
        sys.exit(1)


if __name__ == "__main__":
    main()

What does this code do?

cmd_build and cmd_assemble are placeholder stubs. You will fill them in during later steps.

main() reads the first argument from the terminal (generate, build, or assemble) and routes it to the right handler. The if __name__ == "__main__" guard makes sure main() only runs when you execute the file directly, not when you import it.

  • Save engine.py.

Before you run the generator, what do you think will happen? Will the command print a single sentence or a full structured script with a title and sections?

  • Run the generator with a test topic by running this command:
python3 engine.py generate "how WiFi works"

You should see a title, a section count (intro + 3 points + outro), and a message saying the script was saved to output/script.json. The command takes a few seconds while Gemini generates the script.

Seeing an API key error?

Make sure config.py contains your actual Gemini API key, not the placeholder text. Open config.py and confirm GEMINI_API_KEY is set to the key you copied from Google AI Studio.

help me fix a Gemini API key error when running the generate command

  • Open output/script.json in VS Code by clicking **File**, then **Open File**, and navigating to the output folder inside your project directory.

You should see a JSON object with a title, an intro containing narration and visual_query, three items in key_points (each with their own narration and visual_query), and an outro. This is the file you can edit before the pipeline uses it.

  • Make a small edit to prove the file is yours to control. For example, change the narration in the intro to something you wrote yourself, or swap a visual_query to a different search term.
  • Save output/script.json after your edit.

Why save the script to a file?

If the script only lived in memory, you would have no chance to review or change it before the engine turns it into audio and video. Saving to script.json gives you full editorial control. You can fix awkward phrasing, swap visual queries, or even rewrite entire sections.

When you run the build command later, it reads your edited file. The AI generated the first draft. You own the final version.

✔️ Awesome, I've got everything!

Great. Double check you have saved both engine.py and output/script.json.

ⓧ I'd like to double check the full code

Your engine.py is too long to show in one block, so confirm that each of these sections appears in order. Scroll through your file and check:

  • Imports at the top (lines 1-16), ending with from config import GEMINI_API_KEY, PEXELS_API_KEY.
  • Constants: OUTPUT_DIR, AUDIO_DIR, VIDEO_DIR, SCRIPT_PATH, VOICE, and TARGET_RESOLUTION.
  • Helper functions: setup_dirs(), load_script(), save_script(), and get_sections().
  • generate_script() function with the full prompt and the client.models.generate_content() call.
  • cmd_generate(), cmd_build() (stub), cmd_assemble() (stub), main(), and the if __name__ == "__main__" guard at the very end.

config.py is unchanged from Step 1.

Your content engine can now generate a structured script from any topic and save it as an editable JSON file. Next up, you will convert that script into voiceover audio and pull matching stock footage from the Pexels API.

Convert the Script to Voiceover Audio

Your generate command created a structured script and saved it to output/script.json. You even edited the narration and visual queries to make them your own.

A script alone is just text on a screen. To turn it into a watchable video, you need two things: spoken narration that drives the timing of each section, and matching visual footage to keep the viewer's attention. In this step, you will generate voiceover audio and download stock video clips for every section in your script.

In this step, get ready to:
  • Generate voiceover audio for each script section using edge-tts.
  • Download matching stock footage from Pexels for each section.
  • Wire both into the build command to produce all media assets in one run.
Generate voiceover audio for each section

edge-tts is a Python library that uses Microsoft Edge's online text-to-speech service. It produces natural-sounding narration from plain text, completely free and with no API key required.

  • In engine.py, add the following two functions below the cmd_generate function:
async def generate_audio_segment(text, output_path):
    # Use edge-tts to convert a text string to an MP3 file
    communicate = edge_tts.Communicate(text, VOICE)
    await communicate.save(output_path)
    return output_path


def generate_all_audio(script):
    # Generate an MP3 file for each section in the script
    sections = get_sections(script)
    audio_paths = []
    for name, section in sections:
        path = os.path.join(AUDIO_DIR, f"{name}.mp3")
        asyncio.run(generate_audio_segment(section["narration"], path))
        audio_paths.append(path)
        print(f"  Audio saved: {path}")
    return audio_paths

What does this code do?

generate_audio_segment is an async function because edge-tts communicates with Microsoft's TTS service over the network. The await keyword lets Python handle that network call without blocking.

generate_all_audio loops through every section in your script (intro, three key points, outro) and calls asyncio.run() to execute the async function for each one. Each section gets its own MP3 file named after its position, like intro.mp3 or point_1.mp3.

  • Save engine.py.

These functions are ready but nothing calls them yet. The cmd_build function is still a placeholder stub. Time to replace it.

  • In engine.py, find the cmd_build function. It currently looks like this:
def cmd_build():
    print("Build command coming soon.")
  • Replace the entire cmd_build function with the following code:
def cmd_build():
    print("\n--- Building video from script ---\n")
    setup_dirs()
    # Check that a script exists before building
    if not os.path.exists(SCRIPT_PATH):
        print(f"No script found at {SCRIPT_PATH}.")
        print("Run: python3 engine.py generate \"your topic\"")
        sys.exit(1)
    script = load_script()
    print(f"Title: {script['title']}\n")
    # Generate voiceover audio for every section
    print("Generating voiceover audio...")
    generate_all_audio(script)
    print()

What does the build command do?

cmd_build first checks that output/script.json exists. If the learner forgot to run generate first, the script exits with a helpful message instead of crashing.

When a script is found, it loads the JSON and calls generate_all_audio to produce MP3 files for every section. You will add video downloading to this function in the next substep.

Before you run the build command, how many MP3 files do you think it will create in output/audio/? Think about the sections your script contains.

  • Save engine.py and run the build command:
python3 engine.py build

You should see five "Audio saved" lines printed, one for each section: intro.mp3, point_1.mp3, point_2.mp3, point_3.mp3, and outro.mp3.

  • Confirm the audio files exist by running:
ls output/audio/

You should see intro.mp3, point_1.mp3, point_2.mp3, point_3.mp3, and outro.mp3 listed.

Not seeing any audio files?

  • Make sure you saved engine.py after replacing the cmd_build function.
  • Check that generate_audio_segment and generate_all_audio are placed between cmd_generate and cmd_build in the file. Python reads top to bottom, so functions must be defined before they are called.
  • If you see a network error, edge-tts requires an internet connection to reach Microsoft's TTS service.

Still stuck? Ask for help: Why is my edge-tts audio generation failing in engine.py?

Download stock footage from Pexels

Your script has a visual_query field for every section. Each one is a search term designed to find relevant stock footage. The Pexels API lets you search and download HD video clips for free using those terms.

  • In engine.py, add the following function below generate_all_audio:
def download_pexels_video(query, output_path):
    # Search Pexels for a matching stock video
    headers = {"Authorization": PEXELS_API_KEY}
    params = {"query": query, "per_page": 1, "size": "medium"}
    response = requests.get(
        "https://api.pexels.com/v1/videos/search",
        headers=headers,
        params=params,
    )
    if response.status_code != 200:
        print(f"  Pexels API error: {response.status_code}")
        return None
    data = response.json()
    if not data.get("videos"):
        print(f"  No videos found for: {query}")
        return None
    # Pick the first HD file (720p+) or fall back to any available
    video_files = data["videos"][0]["video_files"]
    hd_files = [f for f in video_files if f.get("height", 0) >= 720]
    chosen = hd_files[0] if hd_files else video_files[0]
    # Download the chosen video file to disk
    video_url = chosen["link"]
    video_response = requests.get(video_url)
    with open(output_path, "wb") as f:
        f.write(video_response.content)
    print(f"  Video saved: {output_path}")
    return output_path

What does this function do?

download_pexels_video sends a GET request to the Pexels Video Search API at https://api.pexels.com/v1/videos/search. Your API key goes in the Authorization header, and the search query comes from your script's visual_query field.

The function filters the results for HD files (720p or higher) and downloads the first match to disk. If no results are found, it prints a message and returns None instead of crashing.

  • Save engine.py.

Now you need a loop function that calls download_pexels_video for every section, and you need to wire both the loop and the audio generation into cmd_build.

  • Add the download_all_videos function below download_pexels_video in engine.py:
def download_all_videos(script):
    # Download a stock clip for each section's visual_query
    sections = get_sections(script)
    video_paths = []
    for name, section in sections:
        path = os.path.join(VIDEO_DIR, f"{name}.mp4")
        result = download_pexels_video(section["visual_query"], path)
        video_paths.append(result)
    return video_paths

What does this code do?

download_all_videos follows the same pattern as generate_all_audio. It loops through every section, reads the visual_query field, and calls download_pexels_video to save each clip as an MP4 named after the section.

  • Now update cmd_build to download footage after generating audio. Find the last line of cmd_build, which is:
    generate_all_audio(script)
    print()
  • Add the following lines directly below print(), still inside cmd_build:
    print("Downloading stock footage...")
    download_all_videos(script)
    print()
    print("Assembly coming in next step...")

Before you run the updated build command, what new folder do you expect to see populated alongside output/audio/?

  • Save engine.py and run the full build by running this command:
python3 engine.py build

You should see the audio generation lines again, followed by "Downloading stock footage..." and five "Video saved" lines. This will take a minute as each clip downloads from Pexels.

  • Confirm both audio and video files exist by running:
ls output/audio/ output/video/

You should see five .mp3 files in output/audio/ and five .mp4 files in output/video/.

  • Play one of the audio files to hear the narration. Open the output/audio/ folder in Finder (macOS) or File Explorer (Windows) and double-click intro.mp3.

You should hear a natural-sounding voice reading the intro narration from your script.

Seeing a Pexels API error?

  • Double-check that your PEXELS_API_KEY in config.py is correct. Copy it fresh from your Pexels account dashboard if you are unsure.
  • If you see "No videos found" for a particular query, the visual_query in your script.json may be too specific. Open the file in VS Code and try a simpler search term.
  • Pexels allows 200 requests per hour. If you have been testing repeatedly, you may have hit the rate limit. Wait a few minutes and try again.

Need more help? Why am I getting errors when downloading Pexels stock footage in my build command?

✔️ Awesome, I've got everything!

Great. Make sure you have saved engine.py and that both output/audio/ and output/video/ contain five files each.

ⓧ I'd like to double check the full code

Your engine.py is now long enough that inlining the whole file would be a wall of code. Instead, scroll through your file and confirm these blocks are present in this order:

  • Imports at the top (unchanged from Step 2).
  • Constants: OUTPUT_DIR, AUDIO_DIR, VIDEO_DIR, SCRIPT_PATH, VOICE, TARGET_RESOLUTION.
  • Helper functions: setup_dirs, load_script, save_script, get_sections.
  • generate_script and cmd_generate (from Step 2).
  • generate_audio_segment and generate_all_audio (added in this step).
  • download_pexels_video and download_all_videos (added in this step).
  • cmd_build calling generate_all_audio then download_all_videos, ending with "Assembly coming in next step...".
  • cmd_assemble (still a stub) and main at the bottom.

config.py is unchanged from Step 1.

Your build command now produces real media. Audio narration and stock footage sit in their own folders, ready for the next stage. Next up, you will review those files and experience firsthand why a stage-based pipeline gives you editorial control.

Pull Stock Footage and Hit the Wall

In the last step, your build command generated voiceover narration and downloaded stock footage from Pexels. You now have ten media files across two folders, ready to become a video.

Those files are isolated. You can play each one individually, but you cannot watch them together as a single narrated explainer. This step is where you review your assets and take your first editorial action.

In this step, get ready to:
  • Verify matching audio and video files exist for all five sections.
  • Manually swap a video clip that does not match its section.
Check your audio and video files
  • List the contents of both output folders by running these commands:
ls output/audio/
ls output/video/
  • In output/audio/, you should see five MP3 files: intro.mp3, point_1.mp3, point_2.mp3, point_3.mp3, and outro.mp3.
  • In output/video/, you should see five matching MP4 files: intro.mp4, point_1.mp4, point_2.mp4, point_3.mp4, and outro.mp4.

Missing files?

If any audio or video files are missing, your build command may not have finished successfully. Return to your terminal and run python3 engine.py build again to regenerate any missing files.

Help me figure out why my build command didn't produce all audio and video files.

  • Open the output/video/ folder in Finder (macOS) or File Explorer (Windows).
  • Double-click point_2.mp4 to play it in your default video player.

Watch the clip and compare it to the section's topic in script.json. Stock footage search is keyword-based, so abstract queries like 'data packets network traffic' often return generic tech footage instead of something specific to the concept.

Why might some clips feel off?

Pexels matches your visual_query field against its video library using keywords, not meaning. An abstract topic like 'network security encryption' might return a clip of a server room instead of a visual metaphor for encryption.

This is expected. It is exactly why the engine saves files to disk for you to review and replace before the final video is assembled.

Swap a video clip

The stage-based architecture saves every file to disk. If a clip does not fit its section, you can replace it without re-running the entire pipeline.

  • Find a replacement video clip. You can download one from pexels.com/videos by searching for a better term, or use any short MP4 file already on your computer.
  • If downloading from Pexels, search for a term that better fits the section (for example, search for 'cybersecurity lock' if point_2.mp4 does not match the encryption topic).
  • Click the download button on a video and choose the HD quality option.
  • Copy the replacement file into output/video/, renaming it to replace the clip you want to swap, by running:
cp [[PEXELS_DOWNLOAD="path to your downloaded .mp4 file"]] output/video/point_2.mp4

What does this command do?

The cp command copies your downloaded file into the output/video/ folder and renames it to point_2.mp4. This overwrites the original Pexels clip with your hand-picked replacement. The rest of the pipeline will use this new file because it reads clips by filename.

  • Confirm the swap by running:
ls output/video/
  • You should still see five MP4 files. The point_2.mp4 is now your manually chosen clip.

Clip not replaced?

  • Make sure the filename matches exactly. The file must be named point_2.mp4 (or whichever section you are replacing) to match the naming convention from your script.
  • If you used drag-and-drop instead of cp, confirm the file landed inside output/video/ and not in a subfolder.
  • On Windows, cp works in PowerShell and Git Bash. If you are using Command Prompt, use copy instead.

Help me replace a video clip in my content engine project.

You now have five audio narrations and five video clips, including the one you just hand-picked. What do you think will happen if you try to combine them into a final video right now?

  • Test the assemble command by running:
python3 engine.py assemble

You see 'Assemble command coming soon.' printed in the terminal. Five audio files, five video clips, and no function to stitch them into a watchable video. That is the wall.

Your media files are staged and ready, including the clip you hand-picked. Next up, you will write the assemble_video function that combines everything into a polished MP4.

Assemble and Reassemble the Final Video

Your audio files and video clips are sitting in separate folders. You even swapped one clip manually in the last step. The problem is there is no way to watch them as a single video.

This step combines everything into a polished MP4. The standalone assemble command lets you re-render anytime after swapping clips or audio, without re-downloading anything.

In this step, get ready to:
  • Write the assemble_video function to combine clips and audio into a final MP4.
  • Wire the assemble and build commands to produce a complete video.
Write the assembly function

The assemble_video function is the heart of the final stage. It reads script.json to know the section order, then pairs each section's audio and video file, adjusts timing, and stitches everything into one video.

  • In engine.py, add the first part of the assemble_video function above the existing cmd_assemble function by pasting this code:
def assemble_video(output_path):
    script = load_script()
    sections = get_sections(script)
    clips = []

    for i, (name, section) in enumerate(sections):
        audio_path = os.path.join(AUDIO_DIR, f"{name}.mp3")
        video_path = os.path.join(VIDEO_DIR, f"{name}.mp4")
        if not os.path.exists(audio_path):
            print(f"  Missing audio: {audio_path}")
            continue

        # Audio duration drives clip length
        audio = AudioFileClip(audio_path)
        duration = audio.duration
        if os.path.exists(video_path):
            clip = VideoFileClip(video_path)
            # Resize to consistent 1080p
            clip = clip.resized(TARGET_RESOLUTION)
            if clip.duration < duration:
                # Loop short clips to fill the narration time
                clip = clip.looped(duration=duration)
            else:
                clip = clip.subclipped(0, duration)
        else:
            clip = TextClip(
                text="No footage available",
                font_size=48,
                color="white",
                bg_color="black",
                size=TARGET_RESOLUTION,
            ).with_duration(duration)
        clip = clip.with_audio(audio)

What does this code do?

This function loops through every section in your script (intro, three key points, outro) and loads the matching audio and video files.

  • audio.duration sets the master timing. Each video clip is trimmed or looped to match the narration length exactly.
  • resized(TARGET_RESOLUTION) scales every clip to 1920x1080 so the final video has a consistent resolution.
  • If a video file is missing, a black placeholder with white text fills the gap so the pipeline does not crash.
  • Still inside the assemble_video function, add this code directly after the clip = clip.with_audio(audio) line, keeping the same indentation inside the for loop:
        if i == 0:
            title = script.get("title", "Explainer Video")
            title_overlay = TextClip(
                text=title,
                font_size=64,
                color="white",
                stroke_color="black",
                stroke_width=2,
                size=(TARGET_RESOLUTION[0] - 100, None),
                method="caption",
            )
            title_overlay = title_overlay.with_position("center").with_duration(
                min(4, duration)
            )
            clip = CompositeVideoClip([clip, title_overlay])

        clips.append(clip)

    # Combine all clips and export
    final = concatenate_videoclips(clips, method="compose")
    final.write_videofile(
        output_path,
        codec="libx264",
        audio_codec="aac",
        fps=24,
    )
    for clip in clips:
        clip.close()
    final.close()
    return output_path

What does this second half do?

The if i == 0 block adds a title card overlay on the intro clip. It creates a TextClip with the script's title, centers it, and composites it over the intro footage for up to 4 seconds.

After the loop, concatenate_videoclips joins all clips into one timeline. write_videofile exports the result using the H.264 codec (libx264) and AAC audio at 24 frames per second.

  • Save engine.py.
  • Confirm the function loads without syntax errors by running:
python3 -c "import engine; print('Module loaded OK')"

You should see Module loaded OK printed with no errors.

Seeing an IndentationError?

  • The second code block must align with the rest of the for loop body. The if i == 0: line should be at the same indentation level as clip = clip.with_audio(audio) above it.
  • Make sure clips.append(clip) is inside the for loop, but the final = concatenate_videoclips(...) line is outside it (one indentation level back).
  • help me fix an IndentationError in my assemble_video function
Wire the assemble and build commands

The function exists but the CLI commands still point at placeholder stubs. You need to replace both cmd_assemble and update the end of cmd_build to call your new function.

  • In engine.py, find the cmd_assemble function and replace it entirely with this code:
def cmd_assemble():
    print("\n--- Reassembling video from existing files ---\n")

    if not os.path.exists(SCRIPT_PATH):
        print(f"No script found at {SCRIPT_PATH}.")
        sys.exit(1)

    # Export to final video path
    output_path = os.path.join(OUTPUT_DIR, "final_video.mp4")
    assemble_video(output_path)
    print(f"\nDone! Video saved to: {output_path}")

What does the updated command do?

The new cmd_assemble checks that a script.json exists (so it knows the section order), calls assemble_video to produce the MP4, and prints the output path. This is the command you run after swapping a clip to re-render without re-downloading anything.

  • Now scroll up to the cmd_build function and find this line at the end:
    print("Assembly coming in next step...")
  • Replace that line with these four lines so a full build includes assembly:
    print("Assembling final video...")
    output_path = os.path.join(OUTPUT_DIR, "final_video.mp4")
    assemble_video(output_path)
    print(f"\nDone! Video saved to: {output_path}")
  • Save engine.py.

Before you run this, think about what the output video should contain. Will the clip you swapped in Step 4 appear in the final video, or will the original Pexels clip show instead?

  • Assemble the final video by running:
python3 engine.py assemble

You should see MoviePy processing each clip with a progress bar, followed by the output path printed to the terminal.

What should I see?

The terminal shows a frame-by-frame progress bar as MoviePy encodes each section. When it finishes, you see a message like Done! Video saved to: output/final_video.mp4. The encoding may take 30 seconds to a few minutes depending on your machine.

  • Open output/final_video.mp4 in any video player on your computer.

You should see stock footage clips playing in sequence with voiceover narration synced to each section. The clip you swapped in Step 4 appears in its section because the assemble command reads whatever files are currently in output/video/.

Video not playing or looks broken?

  • If you see a FileNotFoundError, make sure you ran python3 engine.py build in a previous step so that audio and video files exist in output/audio/ and output/video/.
  • If the video has no audio, confirm that audio_codec="aac" is in your write_videofile call. Without it, MoviePy may default to a codec your player does not support.
  • If a clip appears stretched or cropped, check that clip.resized(TARGET_RESOLUTION) is present in the loop body.
  • help me debug my assembled video

✔️ Awesome, I've got everything!

Great. Double check you have saved engine.py and that output/final_video.mp4 plays with narration and your swapped clip visible.

ⓧ I'd like to double check the full code

Your engine.py is long at this point. Scroll through and confirm these sections are present in order:

  • Imports and constants (OUTPUT_DIR, AUDIO_DIR, VIDEO_DIR, SCRIPT_PATH, VOICE, TARGET_RESOLUTION).
  • Helper functions: setup_dirs, load_script, save_script, get_sections.
  • generate_script and cmd_generate.
  • generate_audio_segment, generate_all_audio, download_pexels_video, download_all_videos.
  • cmd_build ending with assemble_video(output_path) and a Done! print (not the old placeholder).
  • assemble_video function with the clip loading loop, title card overlay, concatenate_videoclips, and write_videofile.
  • cmd_assemble calling assemble_video (not the old stub).
  • main function with generate, build, and assemble subcommands.

config.py remains unchanged with your two API key variables.

Your content engine now has all three stages working independently. Next up, you will take on a Secret Mission to push the video quality even further.

Secret mission

Add Animated Text Captions

Your video has narration, but anyone watching on mute sees only stock footage with no context. Professional faceless channels always overlay text on screen. In this mission, you will add animated captions that fade in and out over each section, making your content accessible and polished.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses only free-tier APIs, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Gemini API key (free tier, no cost unless you upgrade).
  • Pexels API key (free, 200 requests per hour and 20,000 requests per month).
  • Local project files (config.py, engine.py, and the output/ folder with audio, video clips, and the final video).

Keep everything running

No action needed. Choose this if you want to keep generating more videos with your content engine.

  • Your API keys and output/script.json are ready to use. Run the engine with a new topic anytime.
  • Both API keys are on free tiers, so they will not incur charges while idle.

Pause - I'll come back to this later

Free up disk space by removing the generated media files while keeping your project code and API keys intact.

  • Delete the output/ folder to remove all generated audio, video, and the final MP4. On macOS/Linux:
rm -rf output/
  • On Windows:
rmdir /s /q output
  • Your config.py and engine.py files stay in place. When you are ready to build again, run python3 engine.py generate "your topic" to start fresh.

Delete - I don't want to use this again

Remove all project files and revoke your API keys for a complete cleanup.

  • Delete your entire project folder from the Desktop. On macOS/Linux, navigate to your Desktop and run:
cd ~/Desktop
ls
  • Find your project folder name in the output, then delete it. Replace the folder name with the one you created:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, open File Explorer, navigate to your Desktop, right-click your project folder, and select Delete.
  • Revoke your Gemini API key. Go to Google AI Studio, sign in, and navigate to the API Keys page. Find the key you created for this project and delete it.
  • Revoke your Pexels API key. Go to pexels.com, sign in, and navigate to your account settings. Find your API key and delete it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a stage-based content engine that turns any topic into a fully narrated explainer video with stock footage, voiceover audio, and a title card overlay.

You've learned how to:

  • Built a stage-based content engine with three independent CLI commands (generate, build, and assemble) that gives you full editorial control over every step of video creation.
  • Integrated three APIs into a single automated pipeline: the Gemini API for script generation, edge-tts for free voiceover narration, and the Pexels Video API for automatically sourcing matching stock footage.
  • Assembled and reassembled polished MP4 videos using MoviePy, with the freedom to swap any clip and re-render without re-downloading assets.
  • Secret Mission: Completed an optional challenge to push your video production skills further.

Ready to quiz yourself?