Twitch-Highlight-Reels mit Python und FFmpeg erstellen
Ordnen Sie Ihren Auftakt, Ihren besten Spielzug und Ihre abschließende Reaktion nach Wunsch an. Python und FFmpeg machen aus diesen lokalen Clips eine MP4-Datei. Diese Anleitung erstellt ein Highlight-Reel mit 1280×720, schwarzen Rändern um Hochformat-Aufnahmen und einer durchgehenden Stereo-Audiospur, einschließlich Stille für Clips ohne Ton.
Quellclips auswählen
Beginnen Sie mit MP4-Clips, die Ihnen gehören oder die Sie verwenden dürfen und die bereits lokal auf der Festplatte gespeichert sind. Die Beschaffung erfolgt separat: Dieses Programm ruft keine Clips von Twitch ab und benötigt kein API-Token. Wählen Sie die Momente und ihre Reihenfolge selbst; eine Liste beliebter Clips kann diese redaktionellen Entscheidungen nicht treffen.
Das Beispiel akzeptiert vollständige SDR-Clips mit einem 8-Bit-H.264-Videostream im Format
yuv420p, quadratischen Pixeln, ohne Rotationsmetadaten und entweder ohne Ton
oder mit einer Mono-/Stereo-AAC-Spur. Die Spuren müssen bei null beginnen. Unterschiedliche
Auflösungen, Hoch- und Querformate, Bildraten und Audio-Abtastraten sind erlaubt. Exportieren Sie
andere Formate zunächst mit diesen Einstellungen. HDR, mehrere Audiospuren und Tonspuren mit
Zeitversatz benötigen einen separaten Bearbeitungsschritt.
Anders als beim Zusammenfügen passender Exporte ohne erneutes Encoding werden hier uneinheitliche Clips vor dem Zusammenfügen vereinheitlicht. Das Bild wird dabei erneut codiert. Bewahren Sie deshalb Ihre Originale auf. Die Ausgabe verwendet 30 Bilder pro Sekunde; die Dauer jedes Clips wird auf das nächste ganze Bild gerundet. Audio wird auf 48 kHz Stereo neu abgetastet, bei zu kurzer Dauer mit Stille aufgefüllt und bei zu langer Dauer am Videoende des jeweiligen Clips abgeschnitten. So bleibt die gewählte Tonspur erhalten, die Lautheit wird jedoch nicht angeglichen.
Python und FFmpeg prüfen
Verwenden Sie Linux mit Bash, eine gepflegte Python-3-Installation
und FFmpeg mit ffprobe. FFmpeg wird separat installiert und ist kein
Python-Paket. Der Build muss die Encoder libx264 und
aac sowie die unten verwendeten Filter enthalten. Es gibt keine
pip-Abhängigkeiten. Der Workflow wurde mit Python 3.14.8 und 3.15.2 sowie FFmpeg/ffprobe 9.0.2
getestet. Verwenden Sie für eine neue Python-Installation die aktuelle gepatchte Version der
Release-Reihe 3.15.
Fügen Sie Folgendes in Bash ein, um jede ausführbare Datei zu prüfen, bevor Sie das Programm speichern:
(
set -eu
command -v python3
command -v ffmpeg
command -v ffprobe
python3 --version
ffmpeg -version
ffprobe -version
)
Verwenden Sie ein beschreibbares lokales Ausgabeverzeichnis auf einem Dateisystem, das Hardlinks unterstützt. Halten Sie ausreichend freien Speicherplatz für das codierte Reel bereit. Erstellen Sie jeweils nur eine Zusammenstellung und lassen Sie die Eingabedateien bis zum Abschluss unverändert. Jeder Unterprozess hat ein Zeitlimit von 10 Minuten. Dieses Beispiel ist daher für kurze Highlight-Reels gedacht, nicht für vollständige Stream-Archive.
Das vollständige Python-Programm speichern
Speichern Sie dies als highlight_reel.py in Ihrem Arbeitsverzeichnis. Python übergibt
jeden Dateinamen als separates Argument an
subprocess.run. Leerzeichen und Apostrophe in
Clipnamen werden daher nicht zu Shell-Befehlen. Pfade müssen gültiges UTF-8 verwenden.
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)
Der Skalierungsfilter von FFmpeg passt das gesamte Bild in die Ausgabefläche ein; schwarze Ränder zentrieren es. Hochformat-Clips erhalten seitliche Balken. Eine geringfügige Rundungsabweichung ist möglich, da die codierten Breiten und Höhen gerade Zahlen sein müssen. Der Verkettungsfilter fügt die vereinheitlichten Video-/Audiopaare anschließend in der Reihenfolge zusammen, in der Python sie übergibt.
Ausgewählte Clips in der gewünschten Reihenfolge verarbeiten
Legen Sie Ihre Clips in einem Verzeichnis namens clips neben dem Programm
ab oder setzen Sie deren tatsächliche Pfade in diesen Befehl ein. Die Ausgabe wird im vorhandenen
aktuellen Verzeichnis gespeichert. Verwenden Sie -- vor der Eingabeliste,
damit auch ein Dateiname, der mit einem Bindestrich beginnt, als Clip behandelt wird:
python3 highlight_reel.py --output highlights.mp4 -- "clips/opening.mp4" "clips/best play.mp4" "clips/reaction.mp4"
Ein erfolgreicher Durchlauf gibt den endgültigen Pfad aus und endet mit dem Exit-Code null. Das Ergebnis enthält H.264-Video und AAC-Audio. CRF 22 wählt den qualitätsbasierten Modus von x264, keine Zieldateigröße. Zwischen den Clips gibt es harte Schnitte. Das Programm ändert niemals ihre Reihenfolge und überspringt keine fehlgeschlagene Eingabe.
Das Reel vor dem Teilen prüfen
Öffnen Sie highlights.mp4 in Ihrem Videoplayer. Prüfen Sie den Auftakt, beide Seiten
jedes Schnitts, die Ränder der Hochformat-Aufnahmen und die abschließende Reaktion. Hören Sie jeden
Clip vollständig an: Eine stumme Quelle sollte ihre eigene Zeit auf der Zeitleiste belegen, statt
diesen Abschnitt zu entfernen oder die nächste Tonspur zu verschieben. Übergänge, Musik und Titel
sind separate Bearbeitungen; dieses Programm fügt sie nicht hinzu.
Wenn ein Durchlauf fehlschlägt, lesen Sie die Diagnose und prüfen Sie die genannte Eingabedatei. Eine Diagnose aus der Encoding-Phase kann den Eingabeindex von FFmpeg enthalten, der in Ihrer Argumentliste bei null beginnt. Nicht unterstützte Stream-Einstellungen erfordern einen neuen Export; Fehler beim Decodieren erfordern eine neue, intakte Quelle. FFmpeg kann manche beschädigten Medien wiederherstellen, ohne einen Fehler zu melden. Erfolgreiches Decodieren beweist daher nicht, dass die Quelle jedes vorgesehene Bild enthält. Sehen und hören Sie sich das Ergebnis vor der Veröffentlichung an.
Das Programm codiert eine temporäre MP4-Datei und decodiert sie vollständig, bevor es sie mit dem
gewünschten Namen verknüpft. Bei regulären Fehlern endet es mit einem Exit-Code ungleich null und
entfernt das temporäre Verzeichnis, ohne unter highlights.mp4 eine unvollständige
Datei bereitzustellen. Ein vorhandenes Ziel bleibt erhalten: Wählen Sie für einen erneuten Versuch
einen neuen Ausgabenamen. Die fertige Datei ist für den Eigentümer lesbar und beschreibbar.
Erzwungenes Beenden oder ein Stromausfall kann ein verstecktes Verzeichnis namens
.twitch-reel-… zurücklassen. Nachdem Sie überprüft haben, dass der Vorgang beendet
ist, entfernen Sie nur dessen verbliebenes Verzeichnis. Bewahren Sie die Originalclips auf, bis Sie
das Reel geprüft haben.
