Build Twitch highlight reels with Python and FFmpeg
Put your opening moment, best play, and closing reaction in the order you want, then let Python and FFmpeg turn those local clips into one MP4. This walkthrough produces a 1280×720 highlight reel with black padding around portrait footage and a continuous stereo audio track, including silence for clips that have no audio.
Choose your source clips
Start with MP4 clips you own or have permission to use, already saved on disk. Acquisition is a separate step: this program does not fetch clips from Twitch or need an API token. Choose the moments and their order yourself; a list of popular clips cannot make those editorial decisions.
The example accepts complete SDR clips with one 8-bit H.264 yuv420p video stream, square pixels,
no rotation metadata, and either no audio or one mono/stereo AAC stream. Tracks must start at
zero. Different resolutions, portrait/landscape shapes, frame rates, and audio sample rates are
allowed. Export other formats to these settings first. HDR, multiple audio tracks, and offset
soundtracks need a separate editing pass.
Unlike joining matching exports without re-encoding, this task normalizes mismatched clips before joining them. It re-encodes the picture, so keep your originals. The output uses 30 frames per second; each clip’s duration rounds to the nearest frame. Audio is resampled to 48 kHz stereo, padded with silence if short, and trimmed at that clip’s video boundary if long. This preserves the selected soundtrack, but does not equalize loudness.
Check Python and FFmpeg
Use Linux with Bash, a maintained Python 3 installation, and
FFmpeg with ffprobe. FFmpeg is a separate installation, not a
Python package. Its build needs the libx264 and aac encoders and the filters used below.
There are no pip dependencies. The workflow was tested with Python 3.14.8 and 3.15.2 and FFmpeg/ffprobe
9.0.2; for a new Python installation, use the current patched 3.15 release.
Paste this into Bash to check each executable before saving the program:
(
set -eu
command -v python3
command -v ffmpeg
command -v ffprobe
python3 --version
ffmpeg -version
ffprobe -version
)
Use a writable local output directory on a filesystem that supports hard links. Keep enough free space for the encoded reel. Run one compilation at a time, and leave the inputs unchanged until it finishes. Each subprocess has a 10-minute timeout, so this example is intended for short highlight reels rather than full stream archives.
Save the complete Python program
Save this as highlight_reel.py in your working directory. Python passes each filename as a separate
argument to subprocess.run, so spaces and
apostrophes in clip names do not become shell commands. Paths must be valid UTF-8.
import argparse
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
def run(argv, stage):
result = subprocess.run(
argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=600,
)
if result.returncode != 0 or result.stderr.strip():
raise RuntimeError(f"{stage}: {result.stderr.strip() or 'command failed'}")
return result.stdout
def inspect(path):
info = json.loads(run([
"ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json",
str(path),
], f"Inspecting {str(path)!r}"))
streams = info["streams"]
videos = [s for s in streams if s["codec_type"] == "video"]
audios = [s for s in streams if s["codec_type"] == "audio"]
if len(videos) != 1 or len(audios) > 1 or len(streams) != 1 + len(audios):
raise ValueError(f"{path!s}: expected one video and at most one audio stream")
video = videos[0]
if (video["codec_name"] != "h264" or video.get("pix_fmt") != "yuv420p"
or video.get("sample_aspect_ratio") != "1:1"
or any("rotation" in item for item in video.get("side_data_list", []))
or "rotate" in video.get("tags", {})
or video.get("color_transfer") in ("smpte2084", "arib-std-b67")
or video.get("color_primaries") == "bt2020"):
raise ValueError(f"{path!s}: use SDR H.264, yuv420p, square pixels, no rotation")
if audios and (audios[0]["codec_name"] != "aac" or audios[0]["channels"] not in (1, 2)):
raise ValueError(f"{path!s}: use mono or stereo AAC audio")
if any(abs(float(s.get("start_time", "nan"))) > 0.001
or not math.isfinite(float(s.get("start_time", "nan"))) for s in streams):
raise ValueError(f"{path!s}: tracks must start at zero")
duration = float(video.get("duration", "nan"))
if not math.isfinite(duration) or duration < 1 / 30:
raise ValueError(f"{path!s}: video needs a finite duration of at least one frame")
return round(duration * 30) / 30, bool(audios)
def main():
parser = argparse.ArgumentParser(description="Compile local clips in argument order")
parser.add_argument("--output", required=True)
parser.add_argument("clips", nargs="+")
args = parser.parse_args()
for tool in ("ffmpeg", "ffprobe"):
if shutil.which(tool) is None:
raise ValueError(f"Install {tool} and put it on PATH")
for value in [args.output, *args.clips]:
value.encode("utf-8")
requested = Path(args.output).absolute()
output = requested.parent.resolve(strict=True) / requested.name
if output.suffix.lower() != ".mp4":
raise ValueError("Output must end in .mp4")
if os.path.lexists(output):
raise FileExistsError(f"Destination already exists: {output}")
inputs = []
filters = []
for index, name in enumerate(args.clips):
path = Path(name).resolve(strict=True)
if not path.is_file() or path.suffix.lower() != ".mp4":
raise ValueError(f"{name!r}: expected a local MP4 file")
duration, has_audio = inspect(path)
inputs.extend(["-i", str(path)])
filters.append(
f"[{index}:v:0]setpts=PTS-STARTPTS,"
"scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2,"
"pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=30,"
f"tpad=stop_mode=clone:stop_duration=1,trim=duration={duration}[v{index}]"
)
audio = (f"[{index}:a:0]aresample=48000,"
"aformat=sample_fmts=fltp:channel_layouts=stereo,asetpts=PTS-STARTPTS,apad"
if has_audio else "anullsrc=r=48000:cl=stereo")
filters.append(f"{audio},atrim=duration={duration}[a{index}]")
labels = "".join(f"[v{i}][a{i}]" for i in range(len(args.clips)))
filters.append(f"{labels}concat=n={len(args.clips)}:v=1:a=1[v][a]")
with tempfile.TemporaryDirectory(prefix=".twitch-reel-", dir=output.parent) as work:
staged = Path(work) / "reel.mp4"
print(f"Encoding {len(args.clips)} clips…", flush=True)
run([
"ffmpeg", "-nostdin", "-hide_banner", "-v", "error", "-xerror", "-n",
*inputs, "-filter_complex_threads", "1", "-filter_complex", ";".join(filters),
"-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "medium",
"-crf", "22", "-pix_fmt", "yuv420p", "-threads", "2",
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(staged),
], f"Encoding clips {list(enumerate(args.clips))}")
run([
"ffmpeg", "-nostdin", "-v", "error", "-xerror", "-i", str(staged),
"-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-",
], "Checking encoded reel")
os.chmod(staged, 0o600)
os.link(staged, output)
print(f"Created {output}")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error:
print(f"Cannot create reel: {error}", file=sys.stderr)
sys.exit(1)
FFmpeg’s scale filter fits the entire picture inside the canvas, and padding centers it on black. Portrait clips receive side bars. A small rounding difference is possible because encoded widths and heights must be even. The concat filter then joins the normalized video/audio pairs in the order Python supplies them.
Run your selected clips in order
Place your clips in a clips directory beside the program, or substitute their actual paths in
this command. The existing current directory receives the output. Use -- before the input list
so even a filename beginning with a hyphen is treated as a clip:
python3 highlight_reel.py --output highlights.mp4 -- "clips/opening.mp4" "clips/best play.mp4" "clips/reaction.mp4"
A successful run prints the final path and exits zero. The result contains H.264 video and AAC audio; CRF 22 selects x264’s quality-based mode, not a target file size. There are straight cuts between clips. The program never shuffles them or skips a failed input.
Review the reel before sharing
Open highlights.mp4 in your video player. Check the opening moment, both sides of every cut,
portrait edges, and the final reaction. Listen through each clip: a silent source should occupy
its own time on the timeline rather than removing that section or shifting the next soundtrack.
Transitions, music, and titles are separate edits; they are not added by this program.
If a run fails, read the diagnostic and check the named input. An encoding-stage diagnostic can include FFmpeg’s input index, counted from zero in your argument list. Unsupported stream settings require a new export; decode errors require a fresh intact source. FFmpeg can recover some damaged media without reporting an error, so a successful decode does not prove that the source contains every intended frame. Watch and listen before publishing.
The program encodes and fully decodes a temporary MP4 before linking it to the requested name.
Ordinary failures return nonzero and remove the temporary directory without publishing a partial
highlights.mp4. An existing destination is preserved: choose a new output name for a retry.
The finished file is owner-readable and owner-writable. A forced kill or power loss can leave a
hidden .twitch-reel-… directory; after confirming that the job has stopped, remove only that job’s
leftover directory. Keep the original clips until you have reviewed the reel.
