Bitdoze logo

Python Video Intro Editor: Silero VAD + MoviePy on Mac

Build a Python video intro editor with Silero VAD, MoviePy, and FFmpeg. Auto-detect speech in your footage and render a 3D-styled intro montage on Mac.

Dragos

Updated Published 18 min read

Terminal output of a Python video intro editor using Silero VAD to detect speech and render a 3D intro montage on Mac

Cutting an intro by hand means scrubbing through footage, hunting for good moments, and rebuilding the same montage on every upload. This guide shows you how to build a Python video intro editor that does it for you: it analyzes your footage, detects when people are speaking using voice activity detection (VAD), and renders a 3D-styled intro montage.

The stack: Silero VAD finds the speech locally, FFmpeg handles the blur and perspective effects, MoviePy composites the fast cuts, and uv runs the whole thing as a single script with zero manual dependency management. The walkthrough targets a Mac; the script itself is plain Python underneath.

What You'll Learn

  • Detect speech locally with Silero VAD, no API key needed
  • Blur and 3D-warp clips with FFmpeg filters
  • Composite fast cuts with MoviePy: 3x speed, slides, crossfades
  • Run it all with one uv run command thanks to PEP 723 inline dependencies

Prerequisites


  • FFmpeg installed through Homebrew
  • uv installed (one curl command)
  • A source video with people talking in it
  • A few GB of free disk for the one-time PyTorch download

FFmpeg

FFmpeg does the audio extraction, the blur, and the 3D warp. Install it with Homebrew:

bash
brew install ffmpeg

Verify it works:

bash
ffmpeg -version

You should see a version line such as ffmpeg version 7.x. If you get zsh: command not found: ffmpeg, Homebrew did not link it; run brew link ffmpeg or reopen your terminal.

uv

uv manages the Python side, including the interpreter itself. Install it with:

bash
curl -LsSf https://astral.sh/uv/install.sh | sh

Verify:

bash
uv --version

About Python and disk space

The script declares requires-python = ">=3.10", and uv downloads and manages a matching Python on first run, so you do not need to install or configure Python by hand. If you would rather keep a regular interpreter plus venv setup on the machine, follow this guide to install Python on Mac and set up a virtual environment.

Plan for a few gigabytes of free disk: the first run pulls PyTorch and the Silero VAD model weights. Everything after that is cached.

New to uv?

If you’re new to uv, Getting Started with uv: Python Project Setup in 2026 covers project setup and environment basics. For this article you only need one command: uv run.

How the Python Video Intro Editor Works


The script runs a straight pipeline from raw footage to finished montage:

  1. Extracts audio from your video with FFmpeg as 16 kHz mono WAV
  2. Runs Silero VAD over the audio to find where someone is speaking
  3. Picks speech segments longer than 2 seconds and samples six of them
  4. Builds a blurred background plus a 3D perspective-warped foreground per clip with FFmpeg
  5. Composites the layers in MoviePy at 3x speed with slide and crossfade
  6. Concatenates the clips and muxes a silent audio track into intro_only.mp4

With the defaults, that is six clips of 1.5 seconds each: about a 9-second montage with a silent audio track, ready to drop under a music bed.

Pipeline diagram of the Python video intro editor: FFmpeg extracts audio, Silero VAD detects speech, FFmpeg warps clips, MoviePy composites the intro.input.mp4FFmpeg · extract audio to 16 kHz mono WAVSilero VAD · find speech timestampspick speech segments over 2 s · sample 6per clip × 6FFmpegblurred backgroundFFmpeg3D perspective warpMoviePy · 3x speed, slides, crossfadesFFmpeg · mux silent audio trackintro_only.mp4

The intro pipeline: FFmpeg extracts audio, Silero VAD finds the speech, the picker samples six segments, FFmpeg builds the blurred background and 3D-warped foreground per clip, MoviePy composites and concatenates, and a final FFmpeg pass adds the silent audio track.

The Complete Script


Here is the full script. Save it as intro_generator.py; the PEP 723 header at the top tells uv exactly which pinned dependencies to install, so uv run is the only command you need.

python
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "openai-whisper",
#     "torch",
#     "torchaudio<2.6",
#     "soundfile",
#     "numpy<2.0.0",
#     "moviepy==1.0.3",
#     "packaging",
#     "Pillow<10.0.0",
# ]
# ///

import os
import sys
import warnings

# 1. Suppress warnings BEFORE importing moviepy
# This hides the "invalid escape sequence" text
warnings.filterwarnings("ignore")

import random
import subprocess
from pathlib import Path

import torch
import whisper
from moviepy.editor import (
    CompositeVideoClip,
    VideoFileClip,
    concatenate_videoclips,
    vfx,
)

# --- Configuration ---
MIN_SILENCE = 0.5
MIN_SPEECH = 0.25
PADDING = 0.1

# Intro Style Settings
INTRO_CLIP_COUNT = 6  # Number of fast cuts
INTRO_SPEED = 3.0  # Speed multiplier (3x fast)
CLIP_DURATION = 1.5  # Duration of each clip in seconds
OUTPUT_FILENAME = "intro_only.mp4"


def check_ffmpeg():
    try:
        subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
    except:
        print("❌ Error: FFmpeg not found. Run: brew install ffmpeg")
        sys.exit(1)


def extract_audio(video_path, audio_path):
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            video_path,
            "-vn",
            "-acodec",
            "pcm_s16le",
            "-ar",
            "16000",
            "-ac",
            "1",
            "-loglevel",
            "error",
            audio_path,
        ],
        check=True,
    )


def get_good_segments(audio_path):
    """Finds segments where people are actually speaking (Key Moments)."""
    print("🧠 Scanning video for key moments...")

    # Load Silero VAD
    # trust_repo=True fixes the "cache" warning/error
    model, utils = torch.hub.load(
        repo_or_dir="snakers4/silero-vad", model="silero_vad", trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )

    segments = []
    for v in vad_stamps:
        segments.append((v["start"] / 16000, v["end"] / 16000))

    return segments


def apply_blur_background(input_path, output_path):
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            input_path,
            "-vf",
            "boxblur=40:5,eq=brightness=-0.4",
            "-c:v",
            "libx264",
            "-preset",
            "ultrafast",
            "-an",
            "-loglevel",
            "error",
            output_path,
        ],
        check=True,
    )


def apply_3d_warp(input_path, output_path, direction="left"):
    if direction == "left":
        vf = "perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination"
    else:
        vf = "perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination"

    vf += ",pad=w=iw+100:h=ih+100:x=50:y=50:color=black@0"

    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            input_path,
            "-vf",
            vf,
            "-c:v",
            "libx264",
            "-preset",
            "ultrafast",
            "-an",
            "-loglevel",
            "error",
            output_path,
        ],
        check=True,
    )


def generate_intro(video_path, segments):
    print("✨ Rendering 3D Intro (No Text)...")

    long_segments = [s for s in segments if (s[1] - s[0]) > 2.0]

    if len(long_segments) < INTRO_CLIP_COUNT:
        print(f"⚠️ Not enough footage found. Need {INTRO_CLIP_COUNT} distinct moments.")
        if not long_segments:
            return
        picks = random.choices(long_segments, k=INTRO_CLIP_COUNT)
    else:
        picks = sorted(random.sample(long_segments, INTRO_CLIP_COUNT))

    intro_clips = []
    temp_files = []

    for i, (start, end) in enumerate(picks):
        raw_dur = CLIP_DURATION * INTRO_SPEED
        mid = start + (end - start) / 2 - (raw_dur / 2)

        raw_clip = f"temp_raw_{i}.mp4"
        bg_clip = f"temp_bg_{i}.mp4"
        fg_clip = f"temp_fg_{i}.mp4"

        # 1. Extract
        subprocess.run(
            [
                "ffmpeg",
                "-y",
                "-ss",
                str(mid),
                "-t",
                str(raw_dur),
                "-i",
                video_path,
                "-c:v",
                "libx264",
                "-an",
                "-loglevel",
                "error",
                raw_clip,
            ],
            check=True,
        )

        # 2. Process
        apply_blur_background(raw_clip, bg_clip)

        direction = "left" if i % 2 == 0 else "right"
        apply_3d_warp(raw_clip, fg_clip, direction)

        # 3. Composite
        try:
            bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
            fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

            if direction == "left":
                fg = fg.set_position(
                    lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), "center")
                )
            else:
                fg = fg.set_position(
                    lambda t: (int(50 - 50 * (t / CLIP_DURATION)), "center")
                )

            comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)
            if i > 0:
                comp = comp.crossfadein(0.2)

            intro_clips.append(comp)
            temp_files.extend([raw_clip, bg_clip, fg_clip])

        except Exception as e:
            print(f"   ⚠️ Error processing clip {i}: {e}")

    if not intro_clips:
        print("❌ Failed to generate intro clips.")
        return

    # Concatenate
    full_montage = concatenate_videoclips(intro_clips, method="compose")

    print("   💾 Saving video file...")
    full_montage.write_videofile(
        "temp_visual_intro.mp4", fps=24, codec="libx264", logger=None
    )

    # Add silent audio
    print("   🔊 Adding silent audio track...")
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            "temp_visual_intro.mp4",
            "-f",
            "lavfi",
            "-i",
            "anullsrc=channel_layout=mono:sample_rate=44100",
            "-c:v",
            "copy",
            "-c:a",
            "aac",
            "-shortest",
            "-loglevel",
            "error",
            OUTPUT_FILENAME,
        ],
        check=True,
    )

    # Cleanup
    for f in temp_files:
        if os.path.exists(f):
            os.remove(f)
    if os.path.exists("temp_visual_intro.mp4"):
        os.remove("temp_visual_intro.mp4")

    print(f"✅ Success! Intro saved as: {OUTPUT_FILENAME}")


def main():
    if len(sys.argv) < 2:
        print("Usage: uv run intro_generator.py <video.mp4>")
        sys.exit(1)

    input_video = sys.argv[1]
    check_ffmpeg()

    temp_wav = "temp_analysis.wav"

    try:
        extract_audio(input_video, temp_wav)

        good_parts = get_good_segments(temp_wav)

        if not good_parts:
            print("⚠️ No speech detected. Picking random segments...")
            duration = float(
                subprocess.check_output(
                    [
                        "ffprobe",
                        "-v",
                        "error",
                        "-show_entries",
                        "format=duration",
                        "-of",
                        "default=noprint_wrappers=1:nokey=1",
                        input_video,
                    ]
                )
            )
            good_parts = [(t, t + 5) for t in range(0, int(duration), 10)]

        generate_intro(input_video, good_parts)

    finally:
        if os.path.exists(temp_wav):
            os.remove(temp_wav)


if __name__ == "__main__":
    main()

Understanding the Script


The script uses PEP 723 inline metadata to declare its dependencies:

python
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "openai-whisper",
#     "torch",
#     "torchaudio<2.6",
#     "soundfile",
#     "numpy<2.0.0",
#     "moviepy==1.0.3",
#     "packaging",
#     "Pillow<10.0.0",
# ]
# ///

When you run it, uv creates an isolated environment, installs exactly these versions, and touches nothing in your global Python. The pattern is covered in more depth in uv run: Run Python Scripts With Zero Dependency Management.

Configuration Variables

Variable Default Description
MIN_SILENCE 0.5 Minimum silence duration in seconds
MIN_SPEECH 0.25 Minimum speech duration in seconds
INTRO_CLIP_COUNT 6 Number of clips in the intro
INTRO_SPEED 3.0 Speed multiplier for clips
CLIP_DURATION 1.5 Duration of each clip in seconds
OUTPUT_FILENAME “intro_only.mp4” Output file name

Two of these control the intro’s length: total runtime is always INTRO_CLIP_COUNT × CLIP_DURATION, so the defaults produce a 9-second montage.

Voice Activity Detection (VAD)

Speech detection is what makes this an editor instead of a random clip sampler. get_good_segments() loads Silero VAD through torch.hub and asks it for every window where someone is talking:

python
def get_good_segments(audio_path):
    """Finds segments where people are actually speaking (Key Moments)."""
    model, utils = torch.hub.load(
        repo_or_dir="snakers4/silero-vad", model="silero_vad", trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )

    return [(v["start"] / 16000, v["end"] / 16000) for v in vad_stamps]

Silero VAD is the usual pick for voice activity detection in Python: the model is around two megabytes, runs on CPU, and works fully offline after the one-time download. You do not need an API key, and there is no per-minute cost. It returns start and end positions in samples at 16 kHz, which the function converts to seconds. The threshold=0.5 argument is the sensitivity dial: lower it if quiet speech slips through, raise it if background noise produces false hits.

3D Perspective Effects with FFmpeg

The 3D look comes from FFmpeg’s perspective filter, applied to each clip with alternating directions:

python
def apply_3d_warp(input_path, output_path, direction="left"):
    if direction == "left":
        vf = "perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination"
    else:
        vf = "perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination"

sense=destination tells FFmpeg to map the four output corner points onto the source. That squeezes the frame into a trapezoid instead of stretching it: one edge leans in while the opposite edge stays pinned. The pad filter appended after the perspective string adds 50 pixels of transparent margin on every side, so the warped clip can slide across the blurred background later without hard edges.

Video Compositing with MoviePy

MoviePy layers each clip as a blurred background with a warped foreground sliding over it:

python
bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

if direction == "left":
    fg = fg.set_position(
        lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), "center")
    )

comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)

Both layers are sped up 3x with vfx.speedx, the foreground moves 50 pixels toward center over the clip’s 1.5-second duration, and CompositeVideoClip flattens the stack. Each finished clip gets a 0.2-second crossfadein, then concatenate_videoclips stacks all six into the montage.

Why MoviePy is pinned to 1.0.3

MoviePy 2.0 shipped in late 2024 with a breaking rewrite: the moviepy.editor module is gone, set_position became with_position, and the .fx() chaining changed. This script imports straight from moviepy.editor, so any 2.x install fails at import time.

That pin is what keeps the script reproducible. uv reads the header, installs MoviePy 1.0.3 into an environment scoped to this file alone, and nothing else on your machine changes. Migrating to the 2.x API is a reasonable project (the with_position style is cleaner), but it is a rewrite of the compositing code, not a version bump. If the import error shows up anyway, the fix is in the troubleshooting section below.

Running the Script


With uv and FFmpeg installed:

bash
uv run intro_generator.py your_video.mp4

The first run is slower: uv resolves and installs the pinned dependencies (PyTorch is the big one) and torch.hub downloads the Silero VAD model once. After that everything is cached, and later runs only pay for the render.

Because the whole thing is uv plus FFmpeg, the script also travels well: if you want a repeatable environment on a server instead of your Mac, see how to run any Python app in Docker.

Expected Output

text
🧠 Scanning video for key moments...
✨ Rendering 3D Intro (No Text)...
   💾 Saving video file...
   🔊 Adding silent audio track...
✅ Success! Intro saved as: intro_only.mp4

Verify the Output

Three checks before you call it done.

Duration should land close to 9 seconds with the defaults (6 clips × 1.5 s):

bash
ffprobe -v error -show_entries format=duration \
  -of default=noprint_wrappers=1:nokey=1 intro_only.mp4

The audio stream must exist and be aac, which is the silent track the script muxes in:

bash
ffprobe -v error -select_streams a -show_entries stream=codec_name \
  -of default=noprint_wrappers=1:nokey=1 intro_only.mp4

Expected output: aac. If this prints nothing, the silent-audio step failed and editors will complain about a missing track.

Finally, the eyeball check: open intro_only.mp4. You should see six fast cuts alternating left and right 3D warps over a blurred, darkened background, with quick crossfades between clips. When it breaks: a duration far from 9 seconds means some clips errored during processing (scroll the terminal for the ⚠️ lines), and a flat, unwarped picture means the perspective filter string got mangled in an edit.

Customizing the Output


Edit the configuration block at the top of the script to change the style.

Change Number of Clips

python
INTRO_CLIP_COUNT = 8  # More clips

Adjust Speed

python
INTRO_SPEED = 2.0  # Slower
INTRO_SPEED = 4.0  # Faster

Change Clip Duration

python
CLIP_DURATION = 2.0  # Longer clips
CLIP_DURATION = 1.0  # Shorter clips

Modify Blur Intensity

Edit the filter string in apply_blur_background():

python
# Stronger blur
"-vf", "boxblur=60:10,eq=brightness=-0.5"

# Lighter blur
"-vf", "boxblur=20:3,eq=brightness=-0.2"

Style Presets

Three starting points if you do not want to tune four knobs from scratch. Each changes the config block, and the presets that mention blur also touch the filter string in apply_blur_background().

Default (fast)

python
INTRO_CLIP_COUNT = 6
INTRO_SPEED = 3.0
CLIP_DURATION = 1.5
python
# in apply_blur_background()
"boxblur=40:5,eq=brightness=-0.4"

Total: 6 × 1.5 = 9 seconds. The original behavior.

Chill

python
INTRO_CLIP_COUNT = 4
INTRO_SPEED = 2.0
CLIP_DURATION = 2.0
python
# in apply_blur_background()
"boxblur=20:3,eq=brightness=-0.2"

Total: 8 seconds. Slower cuts, cleaner background, easier to follow.

Cinematic

python
INTRO_CLIP_COUNT = 5
INTRO_SPEED = 1.5
CLIP_DURATION = 2.5
python
# in apply_blur_background()
"boxblur=40:5,eq=brightness=-0.5"

Total: 12.5 seconds. Slower motion, darker background, more room for a title overlay.

These are starting points, not calibration data. The montage renders with a silent audio track on purpose, so layer music or a voiceover over it in your editor.

Dependencies Explained


Package Purpose
openai-whisper Speech recognition toolkit; imported at the top but never called. Silero VAD does the detection, so you can drop this line and the import for a leaner install
torch PyTorch for running the VAD model
torchaudio Audio processing with PyTorch; provides the read_audio helper Silero VAD uses
soundfile Audio decoding backend torchaudio can use when loading the WAV
numpy Numerical operations
moviepy Video editing and compositing
Pillow Image processing (required by MoviePy)
packaging Version handling utilities

Version Constraints

The pins in the PEP 723 header are load-bearing, not decoration:

  • moviepy==1.0.3: MoviePy 2.x removed the moviepy.editor module, so the import fails on any 2.x install. See “Why MoviePy is pinned to 1.0.3” above.
  • torchaudio<2.6: avoids torch/torchaudio pairing problems on Apple Silicon. The troubleshooting section below covers slow or failed installs.
  • Pillow<10.0.0: MoviePy 1.0.3 still references Image.ANTIALIAS, which Pillow 10 removed.
  • numpy<2.0.0: keeps the audio stack on the numpy 1.x ABI those torch builds expect.

Troubleshooting


FFmpeg not found error

Install FFmpeg using Homebrew:

bash
brew install ffmpeg

Verify the installation:

bash
ffmpeg -version
Not enough footage found warning

This happens when the video doesn’t have enough speaking segments longer than 2 seconds. The script reuses segments in this case. Try:

  • Using a longer source video
  • Reducing INTRO_CLIP_COUNT
  • Lowering the minimum segment duration in the code
Memory errors with large videos

For large videos, try:

  • Processing shorter clips
  • Reducing the source video resolution
  • Closing other applications
First run is slow

The first run downloads the Silero VAD model and Python dependencies. Subsequent runs are faster because uv and PyTorch cache these files.

ImportError: cannot import name 'editor' from 'moviepy'

You are running MoviePy 2.x. This happens when the script is executed with a different interpreter than uv run (a pip-installed moviepy 2.x shadows the pinned one) or when the PEP 723 header lost its moviepy==1.0.3 pin in an edit.

Fixes:

  • Run the script exactly as documented: uv run intro_generator.py your_video.mp4
  • Confirm the header still contains moviepy==1.0.3
  • If you deliberately migrated to 2.x, update the API: moviepy.editor is gone (import clips from moviepy directly), set_position became with_position, and the .fx(vfx.speedx, ...) syntax changed
torch or torchaudio install fails or is slow on Apple Silicon

Work through these in order:

  • Confirm the torchaudio<2.6 pin is still in the header. An unpinned torchaudio can pull a build that does not match your torch version.
  • Clear the cache and retry: uv cache clean, then rerun the same uv run command. A half-finished download is the most common cause.
  • Check that uv picked a native arm64 Python: uv python list. An x86_64 interpreter running under Rosetta produces slow installs and random wheel failures.
No speech detected on music-only or B-roll footage

Expected behavior, not a bug: Silero VAD listens for voices, so music-only footage produces no timestamps and the script falls back to evenly spaced random segments every 10 seconds.

If you want better picks from quiet or mixed footage:

  • Lower the VAD threshold from 0.5 to about 0.4 in get_good_segments(). More sensitive, but more false positives.
  • Lower the 2.0-second minimum segment filter in generate_intro() so shorter speech bursts qualify.
  • Feed it talking footage. That is what the detector is built for.

FAQ


Does Silero VAD need an API key or internet access?

No. torch.hub downloads the model once on the first run; after that, voice activity detection with Silero VAD runs fully offline on your CPU. You do not need an account or an API key, and there is no per-minute billing. The model is around two megabytes, which is also why startup is fast.

Will this Python video intro editor work on Linux or Windows?

The script is plain Python, FFmpeg, and pip packages, all cross-platform. Only the FFmpeg install command changes: apt install ffmpeg on Debian/Ubuntu, winget install ffmpeg on Windows. Honest caveat: this walkthrough is written on a Mac and I have not re-run it on other systems, so treat Linux and Windows as “should work” until you have run it once yourself.

Why is the rendered intro silent?

By design. The script strips the source audio and muxes a silent AAC track so the montage drops cleanly under music or narration in your editor without fighting the original sound. If you want audio in the file, layer it in post or generate a voiceover first.

How long is the intro?

INTRO_CLIP_COUNT × CLIP_DURATION. The defaults give 6 × 1.5 = 9 seconds. The style presets above produce 8 seconds (Chill) and 12.5 seconds (Cinematic). For a YouTube intro, staying under 10 seconds is a good rule; longer than that and viewers start skipping.

Conclusion


That is the whole pipeline: a Python video intro editor where Silero VAD finds the speaking moments, FFmpeg does the blur and 3D perspective work, MoviePy composites the cuts, and uv pins every dependency to the script file itself. Point it at any raw talking video and you get a nine-second montage you can drop under a music bed.

Ways to take it further: