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.
Updated Published 18 min read

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 runcommand 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:
brew install ffmpegVerify it works:
ffmpeg -versionYou 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:
curl -LsSf https://astral.sh/uv/install.sh | shVerify:
uv --versionAbout 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:
- Extracts audio from your video with FFmpeg as 16 kHz mono WAV
- Runs Silero VAD over the audio to find where someone is speaking
- Picks speech segments longer than 2 seconds and samples six of them
- Builds a blurred background plus a 3D perspective-warped foreground per clip with FFmpeg
- Composites the layers in MoviePy at 3x speed with slide and crossfade
- 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.
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.
# /// 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:
# /// 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:
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:
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:
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:
uv run intro_generator.py your_video.mp4The 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
🧠 Scanning video for key moments...
✨ Rendering 3D Intro (No Text)...
💾 Saving video file...
🔊 Adding silent audio track...
✅ Success! Intro saved as: intro_only.mp4Verify the Output
Three checks before you call it done.
Duration should land close to 9 seconds with the defaults (6 clips × 1.5 s):
ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 intro_only.mp4The audio stream must exist and be aac, which is the silent track the script muxes in:
ffprobe -v error -select_streams a -show_entries stream=codec_name \
-of default=noprint_wrappers=1:nokey=1 intro_only.mp4Expected 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
INTRO_CLIP_COUNT = 8 # More clipsAdjust Speed
INTRO_SPEED = 2.0 # Slower
INTRO_SPEED = 4.0 # FasterChange Clip Duration
CLIP_DURATION = 2.0 # Longer clips
CLIP_DURATION = 1.0 # Shorter clipsModify Blur Intensity
Edit the filter string in apply_blur_background():
# 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)
INTRO_CLIP_COUNT = 6
INTRO_SPEED = 3.0
CLIP_DURATION = 1.5# in apply_blur_background()
"boxblur=40:5,eq=brightness=-0.4"Total: 6 × 1.5 = 9 seconds. The original behavior.
Chill
INTRO_CLIP_COUNT = 4
INTRO_SPEED = 2.0
CLIP_DURATION = 2.0# in apply_blur_background()
"boxblur=20:3,eq=brightness=-0.2"Total: 8 seconds. Slower cuts, cleaner background, easier to follow.
Cinematic
INTRO_CLIP_COUNT = 5
INTRO_SPEED = 1.5
CLIP_DURATION = 2.5# 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 themoviepy.editormodule, 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 referencesImage.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:
brew install ffmpegVerify the installation:
ffmpeg -versionNot 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.editoris gone (import clips frommoviepydirectly),set_positionbecamewith_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.6pin 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 sameuv runcommand. 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
thresholdfrom 0.5 to about 0.4 inget_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:
- The intro renders silent on purpose, so layer music in your editor, generate a voiceover with a Python text-to-speech script, or use a hosted option with voice cloning like Fish Audio for a consistent narrator voice without running models locally.
- The CLI takes one argument, which makes it a good candidate to wrap this script in a desktop UI with NiceGUI: a file picker, a Render button, a progress bar.
- The montage can only look as good as its input. Screen.Studio automates zooms and edits for macOS screen recordings; the full Screen.Studio review covers the long version.
- No footage to cut? AI video generation APIs like Veo and Kling can produce b-roll from a prompt, and Kie.ai puts those models behind a single API.
- When the video is done, host the finished intro and videos with Bunny Stream instead of fighting platform embeds, and pair the intro with thumbnail art from the guide on how to generate AI images locally on Mac.


