Shortificator - Meu gerador de vídeos verticais

| 7 minutes read

Como eu fiz uma fábrica de Shorts que roda 100% na minha máquina

Todo mundo que mexe com vídeo no YouTube já ouviu falar de Opus Clip, Klap, SubMagic e a turma. Você joga um vídeo longo, a ferramenta corta os melhores momentos, coloca legenda animada e te devolve uns Shorts prontos. Funciona. O problema é o de sempre: é SaaS, você paga por mês, e seu vídeo sobe pro servidor de alguém.

Eu queria a mesma coisa rodando na minha RTX 5070, sem mandar nada pra lugar nenhum e sem pagar API. Daí saiu o shortificator.

Esse post é sobre como ele funciona por dentro — as decisões técnicas, o que deu errado no caminho e por que algumas coisas estão do jeito que estão.

A ideia em uma frase

Pega um vídeo longo, transcreve, deixa um LLM local escolher os melhores cortes, recorta pra vertical seguindo o rosto, queima legenda e renderiza. Tudo offline.

O pipeline são quatro passos:

input.mp4
   ├─ 1. Transcreve       faster-whisper (large-v3, CUDA, timestamp por palavra)
   ├─ 2. Analisa cortes   Ollama → JSON (start, end, hook, score)
   ├─ 3. Reframe + legenda YuNet (crop no rosto) + OpenCV
   └─ 4. Renderiza        FFmpeg (CRF 18, AAC 192k) → output/*_short_NN.mp4

Nenhuma dessas peças é exótica. A graça está em como elas se encaixam — e nos detalhes chatos que ninguém conta.

Passo 1: transcrição com word-level timestamps

Uso faster-whisper com o large-v3 na GPU. Não é só pra ter o texto — eu preciso do timestamp de cada palavra. Sem isso, esquece legenda no estilo CapCut, onde a palavra atual fica destacada enquanto a pessoa fala. É o word timestamp que sincroniza o highlight com o áudio.

Esse passo é o mais lento e o que eu menos quero repetir. Por isso o transcript é salvo em JSON e dá pra reusar com --transcript, pulando o Whisper inteiro nas próximas rodadas. Quando você tá iterando em prompt ou em estilo de legenda, isso é a diferença entre esperar 5 segundos ou 5 minutos a cada teste.

Passo 2: deixar um LLM local escolher os cortes (a parte chata)

Aqui mora a maior dor de cabeça do projeto.

A ideia: mandar o transcript pro Ollama e pedir os melhores momentos em JSON (start, end, hook, reason, score). Parece trivial. Não é, quando o modelo é pequeno.

Três coisas que aprendi na marra:

1. Sem structured output, o modelo inventa. Pedindo “responde em JSON” sem schema, o qwen2.5:7b me devolvia um JSON lindo com video_title, key_points e um candidates vazio. Ele inventava a própria estrutura. A solução foi usar os structured outputs do Ollama: gero um JSON Schema na mão e passo em format=. Aí o modelo é obrigado a preencher os campos que eu quero. Com schema, até modelo de 7B se comporta.

2. Modelo pequeno amontoa tudo no começo. Se eu mandasse o vídeo inteiro e pedisse 5 cortes, ele jogava os 5 nos primeiros minutos e ignorava o resto. A correção foi fatiar o vídeo em N janelas temporais (--max-shorts janelas) e fazer uma chamada por janela, pedindo 2 candidatos em cada. Cada chamada só vê o trecho dela. No fim eu junto tudo, ordeno por score, removo sobreposição e corto na quantidade pedida. Bônus: cada chamada fica mais barata porque vê menos texto, e a quantidade de cortes para de variar entre execuções.

3. O modelo ignora os limites de duração. Eu peço “entre 30 e 60 segundos” e ele me devolve um corte de 12s e outro de 2 minutos numa boa. Em vez de descartar e perder o candidato, eu ajusto: fit_clip_window expande os curtos e apara os longos em torno do centro do corte, com clamp nas bordas do vídeo. Resultado: qualquer modelo, por pior que respeite instrução, gera candidato válido.

Pra qualidade editorial uso mistral-small. É mais lento, mas o resultado também vai pro disco (--candidates), então só pago esse custo uma vez. Quando é só pra iterar, qwen2.5:7b resolve.

Passo 3: recorte vertical seguindo o rosto

Vídeo horizontal pra 9:16 sem perder o que importa é mais difícil do que parece. Crop central burro corta a cara da pessoa pela metade na hora que ela anda pro lado.

Pra detectar rosto eu usava YOLOv11l-face. Funcionava muito bem, mas é AGPL e arrastava o torch inteiro junto. Pra um projeto que eu quero open source e leve de instalar, isso é veneno duplo: licença contaminante e mais uns gigas de dependência. Troquei pelo YuNet (cv2.FaceDetectorYN), que já vem no OpenCV, é Apache-2.0, roda em CPU e o modelo tem 230 KB — baixado automático na primeira execução. Perdi um pouco de robustez em rosto minúsculo (PiP), mas ganhei licença permissiva e instalação sem PyTorch. Pro caso de uso, troca justa.

Detalhes que importam na prática:

  • Rodar o detector em todo frame de um vídeo 4K mata a performance. Então detecto num frame reduzido (até 960px de largura) e só a cada 3 frames, reusando a caixa nos demais.
  • Box do rosto pula de frame em frame e dá tremor. Suavizo numa janela de 15 frames pra câmera acompanhar suave em vez de ficar nervosa.
  • Quando tem mais de um rosto, uso sempre a maior caixa. Isso de quebra descarta os falsos-positivos pequenos que o YuNet às vezes cospe.

Também tem modo gameplay: aí não carrego detector nenhum e faço crop central estável, pra preservar mira, HUD e contexto. Detectar “rosto” no meio de um DayZ não faz sentido.

Passo 4: legenda que não parece legenda automática

Primeiro tropeço: cv2.putText só fala ASCII. Qualquer “ação”, “você”, “está” virava aç?o, voc?, est?. Inaceitável em português. A build local do OpenCV também não tinha cv2.freetype. Solução: renderizar a legenda com Pillow (fonte TrueType, UTF-8 de verdade) e compor por cima do frame via OpenCV.

A parte legal é a legenda dinâmica estilo CapCut. A primeira versão usava uma janela deslizante centrada na palavra atual — o texto andava a cada palavra falada. Ficou cansativo de ler, parecia teleprompter nervoso. Joguei fora e fui pra blocos fixos: particiono as palavras em grupos de tamanho fixo, o bloco fica parado e só o highlight (palavra atual) se move dentro dele. O bloco só troca quando a fala cruza pro próximo grupo. Durante micro-pausas eu seguro o último bloco em vez de apagar, senão pisca. É exatamente o comportamento que você vê nos Shorts que dão certo.

E tudo isso é configurável por flag: fonte, tamanho, cor, cor do highlight, contorno, posição vertical, palavras por bloco. Default reproduz o estilo hardcoded antigo, então quem não quer mexer não precisa.

Renderização

FFmpeg, sempre via subprocess.run, nunca os.system. CRF 18 pra qualidade visual alta, AAC 192k no áudio. O render é frame a frame, então tem uma barra de progresso de uma linha com porcentagem, frames processados, ETA e tempo decorrido — porque ficar olhando pra um terminal mudo por minutos é tortura.

Por que local, e por que isso importa

O algoritmo não é o segredo — qualquer um junta Whisper + LLM + FFmpeg. O valor real está em rodar tudo offline: sem mensalidade, sem subir seu material pro servidor de ninguém, sem depender de API que muda de preço ou some.

A ironia é que a parte difícil de transformar isso em produto não é o código — é a fricção de instalação. CUDA, driver, WSL, Ollama, baixar pesos de vários gigas. Foi por isso que fiz questão de manter o projeto leve: licença MIT, detector de 230 KB, sem torch, dependências via Poetry. Quanto menos monstro entre o git clone e o primeiro Short, melhor.

Onde está

O shortificator é open source (MIT) no GitHub. Se você curte IA local e automação de vídeo, dá uma olhada — e se travar na instalação, me conta, porque é exatamente esse atrito que eu quero matar.

Eu também postei um vídeo no meu canal do Youtube falando sobre o projeto.