Crea videos de momentos destacados de Twitch con Python y FFmpeg
Coloca el momento inicial, la mejor jugada y la reacción final en el orden que quieras, y deja que Python y FFmpeg conviertan esos clips locales en un solo MP4. Este tutorial produce un video de momentos destacados de 1280×720 con relleno negro alrededor de las grabaciones verticales y una pista de audio estéreo continua, que incluye silencio para los clips que no tienen audio.
Elige tus clips de origen
Empieza con clips MP4 que te pertenezcan o que tengas permiso para usar, ya guardados en disco. Obtenerlos es un paso aparte: este programa no descarga clips de Twitch ni necesita un token de API. Elige los momentos y su orden por tu cuenta; una lista de clips populares no puede tomar esas decisiones editoriales.
El ejemplo acepta clips SDR completos con un flujo de video H.264 yuv420p de 8 bits, píxeles cuadrados,
sin metadatos de rotación y sin audio o con un único flujo AAC mono o estéreo. Las pistas deben
comenzar en cero. Se permiten distintas resoluciones, orientaciones verticales u horizontales,
tasas de fotogramas y frecuencias de muestreo de audio. Primero exporta los demás formatos con
estos ajustes. El contenido HDR, las múltiples pistas de audio y las bandas sonoras con desfase
requieren una edición aparte.
A diferencia de la unión de exportaciones coincidentes sin volver a codificarlas, esta tarea normaliza los clips con características distintas antes de unirlos. El programa vuelve a codificar la imagen, así que conserva los originales. La salida usa 30 fotogramas por segundo; la duración de cada clip se redondea al fotograma más cercano. El audio se remuestrea a estéreo de 48 kHz, se rellena con silencio si es más corto y se recorta al límite del video de ese clip si es más largo. Esto conserva la banda sonora seleccionada, pero no iguala el volumen percibido.
Comprueba Python y FFmpeg
Usa Linux con Bash, una instalación de Python 3 con soporte vigente y
FFmpeg con ffprobe. FFmpeg se instala por separado; no es un paquete de
Python. Su compilación debe incluir los codificadores libx264 y aac y los filtros que se usan a continuación.
No hay dependencias de pip. El flujo de trabajo se probó con Python 3.14.8 y 3.15.2 y FFmpeg/ffprobe
9.0.2; para una nueva instalación de Python, usa la versión 3.15 actualizada con los últimos parches.
Pega esto en Bash para comprobar cada ejecutable antes de guardar el programa:
(
set -eu
command -v python3
command -v ffmpeg
command -v ffprobe
python3 --version
ffmpeg -version
ffprobe -version
)
Usa un directorio de salida local con permisos de escritura en un sistema de archivos que admita enlaces duros. Mantén suficiente espacio libre para el video codificado. Genera una sola compilación de clips a la vez y deja los archivos de entrada sin cambios hasta que termine. Cada subproceso tiene un tiempo de espera máximo de 10 minutos, por lo que este ejemplo está pensado para videos cortos de momentos destacados en lugar de grabaciones completas de transmisiones.
Guarda el programa completo de Python
Guarda esto como highlight_reel.py en tu directorio de trabajo. Python pasa cada nombre de archivo como un
argumento independiente a subprocess.run, por lo que los espacios y
apóstrofos en los nombres de los clips no se convierten en comandos de shell. Las rutas deben ser
válidas en 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)
El filtro scale de FFmpeg ajusta la imagen completa al lienzo y el relleno la centra sobre un fondo negro. Los clips verticales reciben barras laterales. Puede haber una pequeña diferencia de redondeo porque los anchos y las alturas codificados deben ser pares. El filtro concat une después los pares de video y audio normalizados en el orden en que Python los proporciona.
Procesa los clips seleccionados en orden
Coloca tus clips en un directorio clips junto al programa o sustituye las rutas del comando por
sus rutas reales. El directorio actual, que debe existir, recibe la salida. Usa -- antes de la lista
de entradas para que incluso un nombre de archivo que empiece con un guion se trate como un clip:
python3 highlight_reel.py --output highlights.mp4 -- "clips/opening.mp4" "clips/best play.mp4" "clips/reaction.mp4"
Una ejecución correcta imprime la ruta final y termina con un código de salida cero. El resultado contiene video H.264 y audio AAC; CRF 22 selecciona el modo basado en la calidad de x264, no un tamaño de archivo objetivo. Hay cortes directos entre los clips. El programa nunca cambia su orden ni omite una entrada que falle.
Revisa el video antes de compartirlo
Abre highlights.mp4 en tu reproductor de video. Comprueba el momento inicial, ambos lados de cada corte,
los bordes de las tomas verticales y la reacción final. Escucha cada clip completo: una fuente sin
audio debe ocupar su propio tiempo en la línea de tiempo, sin eliminar esa sección ni desplazar
la banda sonora siguiente. Las transiciones, la música y los títulos son ediciones aparte; este
programa no los añade.
Si una ejecución falla, lee el diagnóstico y comprueba la entrada indicada. Un diagnóstico de la fase de codificación puede incluir el índice de entrada de FFmpeg, que se cuenta desde cero en tu lista de argumentos. Los ajustes de flujo no admitidos requieren una nueva exportación; los errores de decodificación requieren una nueva fuente íntegra. FFmpeg puede recuperar algunos archivos multimedia dañados sin informar de un error, por lo que una decodificación correcta no demuestra que la fuente contenga todos los fotogramas previstos. Mira y escucha el resultado antes de publicarlo.
El programa codifica y decodifica por completo un MP4 temporal antes de crear un enlace a él con el
nombre solicitado. Los fallos habituales devuelven un código de salida distinto de cero y eliminan
el directorio temporal sin publicar un highlights.mp4 parcial. Si el destino ya existe, se conserva:
elige un nombre de salida nuevo para volver a intentarlo. El archivo terminado tiene permisos de
lectura y escritura para su propietario. Una terminación forzada o un corte de energía pueden dejar
un directorio oculto .twitch-reel-…; después de confirmar que la tarea se ha detenido, elimina solo
el directorio que haya dejado esa tarea. Conserva los clips originales hasta que hayas revisado
el video.
