Criar compilações de destaques da Twitch com Python e FFmpeg
Coloque o momento de abertura, a melhor jogada e a reação final na ordem que quiser e deixe o Python e o FFmpeg transformarem esses clipes locais em um único MP4. Este tutorial produz uma compilação de destaques em 1280×720, com preenchimento preto ao redor das imagens verticais e uma faixa de áudio estéreo contínua, incluindo silêncio nos clipes sem áudio.
Escolher os clipes de origem
Comece com clipes MP4 que sejam seus ou que você tenha permissão para usar, já salvos em disco. A obtenção dos clipes é uma etapa separada: este programa não busca clipes na Twitch nem precisa de um token de API. Escolha os momentos e a ordem por conta própria; uma lista de clipes populares não pode tomar essas decisões editoriais.
O exemplo aceita clipes SDR completos com um fluxo de vídeo H.264 de 8 bits
yuv420p, pixels quadrados, sem metadados de rotação e sem áudio ou com um fluxo
AAC mono/estéreo. As faixas devem começar no zero. São permitidas resoluções, orientações
vertical/horizontal, taxas de quadros e taxas de amostragem de áudio diferentes. Exporte outros
formatos com essas configurações primeiro. HDR, várias faixas de áudio e trilhas sonoras com
início deslocado precisam de uma etapa de edição separada.
Ao contrário de unir arquivos exportados com configurações iguais sem recodificar, este procedimento normaliza clipes com configurações diferentes antes de uni-los. Ele recodifica a imagem, então guarde os originais. A saída usa 30 quadros por segundo; a duração de cada clipe é arredondada para o quadro mais próximo. O áudio é reamostrado para estéreo a 48 kHz, preenchido com silêncio se for curto e cortado no limite do vídeo desse clipe se for longo. Isso preserva a trilha sonora selecionada, mas não nivela a sonoridade.
Verificar o Python e o FFmpeg
Use Linux com Bash, uma instalação do Python 3 com suporte ativo e
o FFmpeg com ffprobe. O FFmpeg é instalado separadamente, não é um
pacote Python. O FFmpeg instalado precisa incluir os codificadores libx264 e
aac e os filtros usados abaixo.
Não há dependências do pip. O fluxo de trabalho foi testado com Python 3.14.8 e 3.15.2 e
FFmpeg/ffprobe 9.0.2; para uma nova instalação do Python, use a versão 3.15 com as correções atuais.
Cole isto no Bash para verificar cada executável antes de salvar o programa:
(
set -eu
command -v python3
command -v ffmpeg
command -v ffprobe
python3 --version
ffmpeg -version
ffprobe -version
)
Use um diretório local de saída com permissão de gravação em um sistema de arquivos que ofereça suporte a links físicos. Mantenha espaço livre suficiente para a compilação de destaques codificada. Gere uma compilação por vez e mantenha os arquivos de entrada inalterados até o término. Cada subprocesso tem um tempo limite de 10 minutos, portanto este exemplo se destina a compilações curtas de destaques, em vez de gravações completas de transmissões.
Salvar o programa Python completo
Salve este código como highlight_reel.py no seu diretório de trabalho. O Python passa
cada nome de arquivo como um argumento separado para
subprocess.run, então espaços e apóstrofos nos nomes
dos clipes não se tornam comandos do shell. Os caminhos devem ser válidos em 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)
O filtro scale do FFmpeg ajusta a imagem inteira à área de saída, e o preenchimento a centraliza sobre um fundo preto. Clipes verticais recebem barras laterais. Pode haver uma pequena diferença de arredondamento, pois as larguras e alturas codificadas devem ser pares. O filtro concat então une os pares de vídeo/áudio normalizados na ordem fornecida pelo Python.
Processar os clipes selecionados na ordem desejada
Coloque os clipes em um diretório clips ao lado do programa ou substitua os
caminhos neste comando pelos caminhos reais dos clipes. O diretório atual existente recebe a saída.
Use -- antes da lista de entradas para que até um nome de arquivo iniciado
por hífen seja tratado como um clipe:
python3 highlight_reel.py --output highlights.mp4 -- "clips/opening.mp4" "clips/best play.mp4" "clips/reaction.mp4"
Uma execução bem-sucedida imprime o caminho final e termina com código de saída zero. O resultado contém vídeo H.264 e áudio AAC; CRF 22 seleciona o modo baseado em qualidade do x264, não um tamanho de arquivo desejado. Há cortes secos entre os clipes. O programa nunca altera aleatoriamente a ordem deles nem ignora uma entrada com falha.
Revisar a compilação antes de compartilhar
Abra highlights.mp4 no seu reprodutor de vídeo. Confira o momento de abertura,
os dois lados de cada corte, as bordas dos clipes verticais e a reação final. Ouça cada clipe inteiro:
uma fonte sem áudio deve ocupar seu próprio intervalo na linha do tempo, sem que esse trecho seja
removido ou que a trilha sonora seguinte seja deslocada. Transições, música e títulos são edições
separadas; este programa não os adiciona.
Se uma execução falhar, leia o diagnóstico e verifique a entrada indicada. Um diagnóstico da etapa de codificação pode incluir o índice de entrada do FFmpeg, contado a partir de zero na sua lista de argumentos. Configurações de fluxo não compatíveis exigem uma nova exportação; erros de decodificação exigem uma nova fonte íntegra. O FFmpeg pode recuperar algumas mídias danificadas sem relatar um erro, portanto uma decodificação bem-sucedida não prova que a fonte contém todos os quadros esperados. Assista e ouça antes de publicar.
O programa codifica e decodifica completamente um MP4 temporário antes de criar um link físico
para ele com o nome solicitado. Falhas comuns retornam um código de saída diferente de zero e
removem o diretório temporário sem publicar um highlights.mp4 parcial.
Um destino existente é preservado: escolha um novo nome de saída para tentar novamente.
O arquivo final tem permissões de leitura e gravação para o proprietário. Um encerramento forçado
ou uma queda de energia pode deixar um diretório oculto .twitch-reel-…; depois de
confirmar que o processamento parou, remova apenas o diretório residual desse processamento.
Guarde os clipes originais até revisar a compilação.
