Créer des montages de temps forts Twitch avec Python et FFmpeg
Placez votre séquence d’ouverture, votre meilleure action et votre réaction finale dans l’ordre souhaité, puis laissez Python et FFmpeg réunir ces clips locaux dans un seul MP4. Ce guide produit un montage de temps forts en 1280×720 avec des bandes noires autour des images en portrait et une piste audio stéréo continue, avec du silence pour les clips sans audio.
Choisir vos clips sources
Commencez avec des clips MP4 qui vous appartiennent ou que vous avez l’autorisation d’utiliser, déjà enregistrés sur disque. Leur acquisition est une étape distincte : ce programme ne récupère pas de clips sur Twitch et ne nécessite pas de jeton d’API. Choisissez vous-même les moments et leur ordre ; une liste de clips populaires ne peut pas prendre ces décisions éditoriales.
L’exemple accepte des clips SDR complets avec un seul flux vidéo H.264 8 bits yuv420p,
des pixels carrés, aucune métadonnée de rotation et soit aucun son, soit un seul flux AAC mono ou
stéréo. Les pistes doivent commencer à zéro. Les résolutions, orientations portrait ou paysage,
fréquences d’images et fréquences d’échantillonnage audio peuvent varier. Exportez d’abord les
autres formats avec ces réglages. Le HDR, les pistes audio multiples et les bandes-son décalées
nécessitent une passe de montage distincte.
Contrairement à l’assemblage d’exports identiques sans réencodage, ce traitement normalise les clips disparates avant de les assembler. Il réencode l’image ; conservez donc vos originaux. La sortie utilise 30 images par seconde ; la durée de chaque clip est arrondie à l’image la plus proche. L’audio est rééchantillonné en stéréo à 48 kHz, complété par du silence s’il est trop court et coupé à la fin de la vidéo du clip s’il est trop long. Cela préserve la bande-son sélectionnée, mais n’uniformise pas le volume sonore.
Vérifier Python et FFmpeg
Utilisez Linux avec Bash, une installation de Python 3 maintenue et
FFmpeg avec ffprobe. FFmpeg s’installe séparément ; ce n’est pas un
paquet Python. Sa compilation doit inclure les encodeurs libx264 et aac
ainsi que les filtres utilisés ci-dessous. Il n’y a aucune dépendance pip. Ce processus a été testé
avec Python 3.14.8 et 3.15.2 et FFmpeg/ffprobe 9.0.2 ; pour une nouvelle installation de Python,
utilisez la version 3.15 actuelle, à jour des correctifs.
Collez ceci dans Bash pour vérifier chaque exécutable avant d’enregistrer le programme :
(
set -eu
command -v python3
command -v ffmpeg
command -v ffprobe
python3 --version
ffmpeg -version
ffprobe -version
)
Utilisez un répertoire de sortie local accessible en écriture, sur un système de fichiers qui prend en charge les liens physiques. Gardez suffisamment d’espace libre pour le montage encodé. Ne générez qu’un montage à la fois et laissez les fichiers d’entrée inchangés jusqu’à la fin du traitement. Chaque sous-processus est soumis à un délai maximal de 10 minutes ; cet exemple est donc destiné à de courts montages de temps forts plutôt qu’à des archives de diffusions complètes.
Enregistrer le programme Python complet
Enregistrez ce programme sous le nom highlight_reel.py dans votre répertoire de travail.
Python transmet chaque nom de fichier comme argument distinct à
subprocess.run ; les espaces et les apostrophes dans
les noms des clips ne deviennent donc pas des commandes shell. Les chemins doivent être en UTF-8
valide.
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)
Le filtre scale de FFmpeg redimensionne l’image entière pour qu’elle tienne dans le cadre, puis l’ajout de bandes la centre sur un fond noir. Les clips en portrait reçoivent des bandes latérales. Un léger écart d’arrondi est possible, car les largeurs et hauteurs encodées doivent être paires. Le filtre concat assemble ensuite les paires vidéo/audio normalisées dans l’ordre fourni par Python.
Traiter les clips sélectionnés dans l’ordre
Placez vos clips dans un répertoire clips à côté du programme, ou remplacez
les chemins d’exemple de cette commande par les chemins réels de vos clips. La sortie est enregistrée
dans le répertoire courant existant. Utilisez -- avant la liste des entrées pour
que même un nom de fichier commençant par un trait d’union soit traité comme un clip :
python3 highlight_reel.py --output highlights.mp4 -- "clips/opening.mp4" "clips/best play.mp4" "clips/reaction.mp4"
Une exécution réussie affiche le chemin final et se termine avec un code de sortie nul. Le résultat contient de la vidéo H.264 et de l’audio AAC ; CRF 22 sélectionne le mode fondé sur la qualité de x264, et non une taille de fichier cible. Les clips sont reliés par des coupes franches. Le programme ne les mélange jamais et n’ignore aucune entrée en échec.
Vérifier le montage avant de le partager
Ouvrez highlights.mp4 dans votre lecteur vidéo. Vérifiez la séquence d’ouverture,
les deux côtés de chaque coupe, les bords des images en portrait et la réaction finale. Écoutez
chaque clip en entier : une source silencieuse doit occuper sa propre durée sur la chronologie,
sans supprimer cette section ni décaler la bande-son suivante. Les transitions, la musique et les
titres relèvent de modifications distinctes ; ce programme ne les ajoute pas.
Si une exécution échoue, lisez le diagnostic et vérifiez l’entrée indiquée. Un diagnostic de la phase d’encodage peut inclure l’indice d’entrée de FFmpeg, compté à partir de zéro dans votre liste d’arguments. Les réglages de flux non pris en charge nécessitent un nouvel export ; les erreurs de décodage nécessitent une nouvelle source intacte. FFmpeg peut récupérer certains médias endommagés sans signaler d’erreur ; un décodage réussi ne prouve donc pas que la source contient toutes les images prévues. Regardez et écoutez le montage avant de le publier.
Le programme encode puis décode intégralement un MP4 temporaire avant de créer un lien physique
vers le nom demandé. Les échecs ordinaires renvoient un code de sortie non nul et suppriment le
répertoire temporaire sans publier de fichier highlights.mp4 partiel. Une destination
existante est préservée : choisissez un nouveau nom de sortie pour réessayer. Le fichier final
accorde à son propriétaire les droits de lecture et d’écriture. Un arrêt forcé du processus ou une
coupure de courant peut laisser un répertoire caché .twitch-reel-… ; après avoir
confirmé que le traitement s’est arrêté, supprimez uniquement le répertoire résiduel de ce
traitement. Conservez les clips originaux jusqu’à ce que vous ayez vérifié le montage.
