name: deck-to-video description: Use ao transformar um conjunto de slides mais narração por slide em um vídeo mp4 — um vídeo de apresentação que se reproduz sozinho, "slides para vídeo", "apresentação para mp4", um vídeo compartilhável de uma apresentação narrada (WhatsApp/Slack/Drive), ou quando um vídeo de slide precisar de uma barra de progresso / contagem regressiva / contador de slides. Use também quando um vídeo de slide precisa destacar, iluminar ou focar o elemento sendo falado em sincronia com a voz, ou quando a narração e a animação na tela precisam ser sincronizadas. Use também quando uma barra de progresso desenhada pelo ffmpeg aparece cheia/estática desde o primeiro quadro, ou o ffmpeg retorna o erro "No such filter: 'drawtext'". Use também ao queimar ou embutir legendas em um vídeo de apresentação, quando as legendas não aparecem no player inline do Slack ou Teams, quando o VLC mostra dois conjuntos de legendas ao mesmo tempo, ou quando o ffmpeg informa "No option name near" em um filtro de legendas. Use também quando a multiplexação final do ffmpeg corta o final do vídeo, ou o ffmpeg rejeita -filter_complex_script.
Deck para Vídeo (slides + narração → mp4 que se reproduz sozinho)
Visão Geral
Transforme N imagens de slides (ou um PDF) + N clipes de áudio por slide em um único mp4: cada slide é exibido exatamente pela duração da sua narração mais uma pequena pausa, e então corta para o próximo. Overlays: uma barra de progresso que se preenche + contagem regressiva de segundos restantes (canto inferior direito) e um contador de slides "N / M" (canto superior direito).
O comando principal (o script está no diretório scripts/ deste skill — chame-o pelo caminho completo):
python3 <caminho-deste-skill>/scripts/build_video.py SLIDES AUDIO_DIR out.mp4
# SLIDES = slides.pdf (rasterizado a 200dpi) OU um diretório com imagens por slide
# (ordenação numérica: s2 antes de s10; nomes com zeros à esquerda ainda recomendados)
# AUDIO_DIR = slide01.mp3, slide02.mp3, … — a contagem DEVE ser igual ao número de slides
# opções: --size 1920x1080 · --pad 0.7 · --no-bar · --no-counter · --bar-color HEX · --crf 20
# --highlights hl.json · --anim-fps 30 (veja "Destacando elementos" abaixo;
# scripts/make_highlights.py constrói o hl.json)
Em caso de sucesso, ele imprime uma linha por slide — a duração de cada slide já inclui o pad — e termina com duration matches segment sum (caso contrário, sai com código de erro).
Preparando as entradas corretamente
- Slides: se começar de um pptx, rasterize a partir do PowerPoint real, não do LibreOffice. Exporte o pptx para PDF usando o PowerPoint original (o skill office-render faz isso no macOS), então entregue o PDF ao script — o LibreOffice substitui fontes e reorganiza layouts, então o vídeo não corresponderia ao que o autor da apresentação vê. 200 dpi mantém o texto denso dos slides nítido em 1080p. (Já tem imagens por slide? Use-as diretamente — pule esta etapa.)
- Áudio: um clipe por slide, nomeado
slideNN.mp3. Se os clipes vieram dogenerate_tts.pydo elevenlabs-tts (que o narrating-pptx usa), eles já estão neste formato exato — reutilize-o para que o pptx e o vídeo soem idênticos. - Se a velocidade da narração foi ajustada (ex.:
ffmpeg -filter:a atempo=1.1), construa o vídeo a partir dos arquivos ajustados.
Por que os overlays são gerados com PIL, não desenhados pelo ffmpeg (pago com sangue)
drawboxcom uma expressão de largura baseada emtrenderiza uma barra CHEIA desde o quadro 0 em várias compilações do ffmpeg — a expressão é avaliada uma vez na inicialização, não por quadro. A falha é silenciosa: o vídeo codifica perfeitamente e a barra simplesmente nunca se move. Expressõesdrawtextatualizam por quadro nas mesmas compilações, o que torna a barra quebrada fácil de passar despercebida ao lado de uma contagem regressiva funcional.- Os bottles do ffmpeg do Homebrew (2026+) são compilados sem libfreetype —
No such filter: 'drawtext'— e as versões keg (ffmpeg@7,ffmpeg@8) são iguais. Não perca um ciclo de instalação descobrindo isso; se uma compilação completa for necessária para qualquer outra coisa,uv tool install static-ffmpegfornece uma (o script faz fallback automaticamente para ela).
Então o script gera frames com PIL (preenchimento da barra, contagem regressiva, contador) e alimenta o ffmpeg com uma sequência de imagens codificada a -r 30. Determinístico, com controle de fonte e mais barato de codificar do que filtrar um stream de 30 fps. Um frame por segundo é o padrão e está correto enquanto nada no slide se move — atualizações com granularidade de segundo são naturais para uma contagem regressiva. Slides com --highlights são gerados a 30 fps, pelo motivo da próxima seção.
Escolhas de codificação que o script já faz (não as desfaça)
| Escolha | Porquê |
|---|---|
apad=pad_dur=PAD + -t seg_dur | preenchimento exato no final; -shortest ingênuo corta a pausa após a fala |
yuv420p + dimensões pares | qualquer outra coisa não reproduz no QuickTime/WhatsApp/navegadores |
-tune stillimage, CRF 20 | slides estáticos comprimem bem sem cintilação de texto |
concat demuxer + -c copy | junção sem perdas de segmentos codificados identicamente |
-movflags +faststart | o vídeo é transmitido antes de terminar o download |
Valide — assuma que o overlay está quebrado até que os quadros provem o contrário
O script verifica se a duração final ≈ soma dos segmentos. O overlay precisa de olhos:
ffmpeg -y -ss T1 -i out.mp4 -frames:v 1 a.jpg # no início de algum slide
ffmpeg -y -ss T2 -i out.mp4 -frames:v 1 b.jpg # no final do mesmo slide
Escolha T1/T2 a partir das durações por slide impressas pelo script (some-as cumulativamente para os limites dos slides; lembre-se de que elas incluem o pad). Leia ambas as imagens. A barra deve estar visivelmente mais cheia em b.jpg e a contagem regressiva menor — e deve resetar em cada novo slide. Uma barra com aparência cheia em um timestamp inicial = o bug da barra estática — esta verificação exata foi o que o detectou originalmente. Você não pode verificar o áudio sem uma interface visual — diga isso; nunca afirme que ouviu algo.
Destacando elementos em sincronia com a narração
Narração que diz "à esquerda… no meio… à direita" soa como uma faixa de audiodescrição (veja apresentações-autoexecutáveis). A correção é remover o ponteiro verbal e iluminar o elemento — mas apenas se o timing vier do áudio.
O áudio é o mestre; a animação o segue. Nunca escreva narração para atingir uma marca, e nunca estime quando uma frase é dita. Gere o clipe normalmente, então use o alinhamento forçado do ElevenLabs (POST /v1/forced-alignment, áudio + transcrição → start/end por caractere; execute-o com o scripts/align_narration.py do elevenlabs-tts) para ler quando cada coisa foi realmente dita, e derive as janelas a partir disso. Regravar um clipe depois e realinhar corrige automaticamente.
- Âncore nas posições no script, não em palavras-chave. Uma palavra-chave solitária quebra no momento em que aparece duas vezes ("tags" aparece quatro vezes em um slide). Marque onde a narração se volta para cada elemento com uma frase curta copiada do script, na ordem falada, e busque cada uma apenas após a correspondência anterior, que é o que o
make_highlights.pyfaz; uma palavra repetida anteriormente no script não pode capturá-la. (Manter deslocamentos de segmento por elemento no script unido também funciona.) - Remova as tags de áudio antes de alinhar — todas elas.
[confiante]é interpretado, não falado; envie-o ao alinhador e ele procurará a palavra no áudio e arrastará todos os deslocamentos posteriores com ela. As tags do Eleven v4 são de forma livre ([Pausa, humor seco]), então remova qualquer coisa entre colchetes, não apenas letras e espaços (o alinhador do elevenlabs-tts faz isso). Calcule os deslocamentos contra a mesma string limpa. - Verifique se a contagem de caracteres retornada é igual ao comprimento do texto e observe
loss(0.04-0.10 em uma apresentação saudável de 17 clipes em inglês, 0.59-0.77 em uma apresentação saudável de 26 clipes em hebraico; um valor claramente discrepante dentro da apresentação é o sinal, não um limite absoluto). Uma incompatibilidade significa que todas as janelas naquele slide estão erradas.
Construa o JSON --highlights com scripts/make_highlights.py a partir de três entradas: marcadores (por slide, cada região com a frase onde a narração se volta para ela), caixas (por slide, região → [x, y, w, h]) e os arquivos alignment/:
python3 <caminho-deste-skill>/scripts/make_highlights.py markers.json boxes.json narration/alignment/ hl.json \
--size 1920x1080 [--inches 13.333] # --inches: caixas estão em polegadas do slide, página com esta largura
# markers.json: {"09": [["chart", "Último trimestre"], ["callout", "O verdadeiro problema"], ["_end", "Agora, para"]],
# "12": {"marks": [["a", "Primeiro"], ["b", "Segundo"]], "dim": "black", "colour": "#E07A5F"}}
A janela de uma região vai da sua frase até o próximo marcador (_end fecha uma sem abrir outra; caso contrário, a última vai até o final da fala), começando 0,12 s antes. Janelas abaixo de 1,4 s são ignoradas com um aviso, já que um fade de 0,5 s para entrar e sair não deixa nada para ver. Ele para, sem escrever nada, quando uma frase não está no script, uma região não tem caixa, uma caixa sai do quadro ou um arquivo de alinhamento não corresponde ao seu script. Ele avisa quando uma caixa está a menos de 19 px da borda, onde o brilho é cortado.
O JSON resultante, indexado pelo número do slide com zero à esquerda, caixas em pixels de saída:
{"09": [{"card": "chart", "box": [87, 468, 549, 374], "colour": "#B45309",
"dim": "white", "start": 13.6, "end": 22.9}]}
Pegue box do próprio código de layout da apresentação (polegadas → pixels), nunca inspecionando a renderização visualmente — a geometria de um card já é exata no construtor que o desenhou. dim é white em slides claros, black em escuros; verifique a cor do brilho também em um slide escuro (um brilho ameixa escuro desapareceu em um slide de encerramento quase preto). Uma região pode ser destacada mais de uma vez em um slide. Slides ausentes do arquivo são gerados a 1 fps como de costume.
Legendas vêm de graça uma vez alinhadas. Os mesmos timings de palavras produzem um arquivo .srt sem uma segunda passagem de transcrição:
python3 <caminho-deste-skill>/scripts/make_srt.py narration/alignment/ narration/audio/ out.srt --pad 0.7
--pad deve corresponder ao pad com que o vídeo foi construído, ou as pistas se desviam ainda mais a cada slide.
Três maneiras de enviá-las, e o destino decide qual.
| Forma | Como | O que o espectador obtém |
|---|---|---|
.srt lateral | envie o arquivo junto com o mp4 | legendas apenas se abrirem ambos em um player real |
| Faixa soft | -c copy -c:s mov_text -disposition:s:0 default — multiplexação sem perdas, ~12 KB, segundos | dependente do player; precisa de um controle de CC existente |
| Queimadas | recodifique através do libass | sempre visíveis, em qualquer lugar, não podem ser desativadas |
Padrão: o arquivo lateral ou a faixa soft — ambos são reversíveis e nenhum toca na imagem. Queime apenas quando o vídeo for reproduzido em algum lugar sem controle de legenda. O player inline de um tópico do Slack ou Teams é o caso que força isso: um espectador que apenas aperta play ali não obtém nada das outras duas formas.
Queimando as legendas
ffmpeg -i deck.srt /tmp/subs.ass # converta PRIMEIRO; não passe force_style
# …edite a linha Style: em /tmp/subs.ass (veja abaixo), então:
<static-ffmpeg> -i master.mp4 -vf "ass=/tmp/subs.ass" \
-c:v libx264 -crf 20 -preset medium -tune stillimage -pix_fmt yuv420p \
-c:a copy -sn -movflags +faststart out.mp4
Quatro coisas que custam um ciclo cada:
- O ffmpeg do Homebrew não tem libass — a mesma lacuna do
drawtext/libfreetype, e o sinal é enganoso. Você obtémNo option name near '<filename>', um erro de análise do filtergraph em vez deNo such filter, então parece um problema de citação e faz você reescrever escapes que já estavam corretos. Verifique comffmpeg -filters | grep -E ' (ass|subtitles) 'e use a compilação static-ffmpeg. - Estilize o arquivo
.ass; nunca coloqueforce_stylena string do filtro. Suas vírgulas são lidas como separadores de filtro, e escapar com barras invertidas é frágil o suficiente para falhar duas vezes antes de funcionar. - Mapeie os campos de estilo ASS pelo cabeçalho
Format:, nunca contando posições. Um erro de um escreve Alignment=0 (inválido) e deixa MarginV não definido, e o libass renderiza sem reclamar. - Queime a partir do mestre pré-queima e mantenha esse mestre. Reestilizar a partir de um arquivo já queimado empilha uma segunda geração e grava o texto antigo permanentemente.
Posicionamento — faixa ou sobreposição. A apresentação decide, não o gosto.
| Como | Custo | |
|---|---|---|
| Faixa abaixo do slide | pad=1920:1200:0:0:black então ass=, MarginV dentro da faixa | nada no slide é coberto; a saída deixa de ser 16:9 |
| Sobreposição (o visual familiar do VLC) | ass= sozinho; centralizado na parte inferior, negrito ~48 px em 1080p, contorno preto pesado, MarginV ~40 | clássico e autocontido, mas cobrirá o que a apresentação colocar em sua faixa inferior |
Sobreposição é o que a maioria das pessoas quer dizer com "legendas". Em uma apresentação com muito texto, extraia um quadro e olhe antes de confirmar: ela cai sobre chamadas de rodapé, legendas e a barra de progresso.
1 fps é um limite de sincronia, não apenas de suavidade. A renderização padrão é um quadro por segundo, então um destaque só pode mudar em um segundo inteiro — até um segundo inteiro de distância da palavra que aponta, o que parece quebrado. Slides animados precisam de ~30 fps.
30 fps é barato se você armazenar frames em cache. Um slide de 60 s tem 1.800 quadros, mas quase todos são pixel-idênticos — o brilho só se move durante os fades. Chaveie cada quadro pelo seu estado visual e crie links físicos para os duplicados:
key = (active_element, round(fade_alpha, 2), int(t)) # int(t) = o passo da barra
if key in cache: os.link(cache[key], path); continue
120-200 composições reais por slide em vez de ~1.800, e sem disco extra. Mas quantize a barra de progresso para segundos inteiros primeiro — uma barra que desliza continuamente torna cada quadro único e destrói o cache completamente. (Em 1 fps já se comportava assim, então nada se perde.)
Inverta o dim em slides escuros. Lavar elementos inativos com branco translúcido os empurra para trás em um slide claro e os clareia em um escuro. Carregue a cor dim por slide, não por apresentação.
Não anime o que é dito rápido demais. Meça o intervalo falado antes de confirmar: quatro itens de lista ditos em ~1,2 s cada deixam ~0,2 s de brilho após um fade de 0,5 s para entrar e sair, e causam estroboscópio. Dê a cada item sua própria batida na narração — geralmente uma melhoria, já que listas ditas em um fôlego são a coisa mais densa em qualquer apresentação — ou deixe o slide estático.
Erros comuns
| Erro | Consequência | Correção |
|---|---|---|
animação drawbox do ffmpeg com base em t | barra cheia desde o quadro 0 | use o script deste skill (overlays com PIL) |
| usar o ffmpeg do Homebrew sem libfreetype/libass | erro No such filter: 'drawtext' ou erro No option name near | use static-ffmpeg via uv tool install static-ffmpeg |
| pular a validação visual dos overlays | barra quebrada ou contagem incorreta passam despercebidas | extraia quadros no início e final do mesmo slide com ffmpeg -ss |
não remover tags de áudio ([confiante]) antes do alinhamento | deslocamentos de caractere incorretos para todas as palavras seguintes | limpe qualquer coisa entre colchetes antes de alinhar |
usar force_style na string do filtro ass | falha de análise devido a vírgulas | edite o arquivo .ass diretamente |
| posicionamento de legendas por tentativa e erro em uma apresentação densa | legendas cobrem conteúdo importante | extraia um quadro e inspecione antes de codificar |
| animar destaques em janelas muito curtas (<1,4 s) | efeito estroboscópico; nada é visível | meça o intervalo falado; se muito curto, deixe o slide estático |
| não usar cache de frames para animações de destaque a 30 fps | alto uso de CPU e disco | chaveie por estado e crie links físicos para quadros duplicados |
Controle de versão implícito
build_video.pylêscripts/version.py(não mostrado) e imprime a versão na saída padrão.- A saída de áudio e os arquivos de alinhamento são versionados pela ferramenta que os gerou (elevenlabs-tts).
- Os JSONs de marcadores e caixas são textos pequenos; considere versioná-los junto com o pptx de origem se o projeto for colaborativo.