Os vídeos verticais do Beneverso saem de
_Lab_Video/desde 18/08/2026, em stack web: HTML/CSS animado → frames capturados no Chromium → MP4 no ffmpeg. O After Effects está instalado e foi testado lado a lado — e perdeu.
Por que web e não After Effects
O laboratório nasceu como comparativo. Do LEIA_ME.md, literal:
Objetivo: rodar o mesmo roteiro em duas stacks e comparar resultado + velocidade, antes de casar com uma delas.
web/— stack HTML/CSS animado → frames (Playwright) → MP4 (ffmpeg)ae/— stack After Effects (ExtendScript.jsx+aerender)roteiro/ROTEIRO_MODELO.json— formato do roteiro. O "_como_preencher" explica os campos.assets/— fotos, recortes, texturas e áudio do testesaida/— MP4 final das duas stacks, pra comparar lado a lado
O comparativo terminou e a escolhida é a web. Dois motivos, nesta ordem: o Felipe não
quer aprender o AE, e no AE se renderiza às cegas — no navegador dá para ver a peça e
ajustar em segundos. O ae/ continua no disco (plenario.jsx, plenario_AE.aep) como
registro, e os dois resultados estão em saida/ — WEB_plenario.mp4, AE_plenario.mp4
e o COMPARA_web_vs_ae.mp4 que os põe lado a lado.
O timing: a narração manda, ninguém cronometra
Do LEIA_ME.md, e é a regra que mais economiza trabalho:
A narração define a duração. Gero o áudio primeiro (ElevenLabs), meço cada trecho, e a animação se encaixa nele. Ninguém cronometra nada na mão.
Na prática: a duração de cada beat sai de assets/audio/beats.json, que é medido do
áudio já gerado. O roteiro não traz tempo digitado — traz o texto falado, e o tempo é
consequência dele. Trocar uma frase da narração re-mede tudo sozinho.
O pipeline, arquivo por arquivo
| Passo | O que roda | O que sai |
|---|---|---|
| 1. Roteiro | roteiro/gerar_roteiro_v*.py | roteiro/ROTEIRO_VISUAL_v2.json |
| 2. Linha de montagem | web/montar_v2.py | web/peca_v2.html (tudo inline) |
| 3. Render | web/turbo/render_turbo.py | os PNG + o MP4 mudo |
| 4. Mixagem | web/mixar_v2.py | o MP4 final, em saida/ |
montar_v2.py — do cabeçalho do script:
Linha de montagem da peca v2. Junta: motor.js + mod_efeitos/efeitos.js + fontes_novas/fontes.css
- ROTEIRO_VISUAL_v2.json + beats.json -> peca_v2.html (tudo inline). Tudo inline porque a captura roda em file:// e fetch() nao funciona la.
render_turbo.py — divide a faixa de frames entre até 8 processos. Oito é
teto, não número: o script faz n_work = max(1, min(8, (cpu_count() or 4) - 2)), então
numa máquina de 6 núcleos são 4 workers. Cada worker abre a própria página no Playwright,
chama window.render(t) só nos frames dele e salva com o índice global; no fim o
ffmpeg junta tudo na ordem.
📌 O ganho de "~10 min para ~25 s" é medição RELATADA pelo autor, sem registro no disco. Não há log, tabela nem saída de benchmark no
_Lab_Video/que sustente o par de números — trate como ordem de grandeza, não como dado conferido. Quem quiser o número de verdade roda oweb/turbo/bench_serial.pye anota.
mixar_v2.py — junta vídeo mudo + narração + trilha + os SFX marcados em
assets/audio/novos/plano_som.json (cada evento traz arquivo, tempo_seg e volume).
A invariante que sustenta tudo
window.render(t) é função pura do tempo. Sem Date.now, sem Math.random em
runtime, sem CSS animation. Do contrato escrito em web/mod_efeitos/efeitos.js:
DETERMINISMO (requisito duro)
- zero Date.now / performance.now
- zero Math.random em tempo de execucao
- zero CSS animation / transition
- toda variacao vem do PRNG por seed abaixo (hash puro, sem estado)
Mesmo dt + mesmo o.seed ==> exatamente o mesmo retorno, sempre.
E o porquê, do cabeçalho do render_turbo.py:
Isso so e seguro porque window.render(t) da peca.html e deterministico e sem estado acumulado: o unico "estado" do DOM (innerHTML da datilografia e o clip-path da fita) e reescrito por inteiro a cada chamada, em funcao apenas de t. Logo qualquer worker pode pular direto para qualquer t.
⚠️ Quem quebrar a pureza quebra o render paralelo, e o sintoma não é um erro: é frame divergindo de frame. A variação "aleatória" (o torto de cada foto, o jitter do papel) vem toda de
rnd(seed), um hash puro — não deMath.random.
Três pegadinhas que já custaram um round de revisão
1. Fonte em página capturada pelo Playwright. O Chromium registra as @font-face
mas não as carrega até serem exigidas, e o screenshot não espera. Sem
await Promise.all([...document.fonts].map(f => f.load()))
antes de marcar a página como pronta, a peça inteira sai em Arial — sem erro nenhum.
2. Reescrita de url(...) no CSS. Capture a aspa DENTRO do grupo, antes do lookahead:
url\(\s*['"]?(?!data:) casa antes da aspa e corrompe url("data:font/ttf....
Mais simples ainda: pular o regex quando o CSS já contém base64,.
3. Variable fonts precisam de peso explícito. Big Shoulders e Archivo VF exigem
font-weight: 900; em 400 saem finas e a peça perde o soco. Alfa Slab One, Bungee e
Rubik Distressed são peso fixo 400 — nelas não se mexe.
Fotos de candidatas (TSE)
- ZIP oficial por UF:
cdn.tse.jus.br/estatistica/sead/eleicoes/eleicoes2026/fotos/foto_cand2026_<UF>_div.zip - Nome de arquivo dentro do ZIP:
F<UF><SQ_CANDIDATO>_div.jpg - Índice para casar nome × SQ_CANDIDATO:
consulta_cand/consulta_cand_2026.zip— CSV em latin-1, separador; - Resolução: 161×225 px. Serve para mosaico e grade de rostos; não serve para close.
O roteiro animado — o formato canônico
O formato é o roteiro/ROTEIRO_MODELO.json. Ele tem um meta (título, projeto, formato,
resolução, fps, descrição de voz e prompt de trilha), uma lista de beats e um bloco
_como_preencher que é a documentação do formato. Transcrito:
| Campo | O que é |
|---|---|
narracao | O texto falado. A duração de cada beat sai daqui — o áudio é gerado primeiro e a animação se encaixa nele. Não precisa cronometrar nada. |
direcao_voz | Opcional. Tags do eleven_v3 inline no próprio texto: [sussurrando] [rindo] [pausa] [emocionada]. |
elementos | O que aparece na tela nesse beat. Ordem = ordem de empilhamento (o último fica por cima). |
Tipos de elemento: foto · recorte · texto · fita · carimbo · doodle ·
textura_fundo.
As 8 entradas do MODELO, como estão escritas nele:
| Entrada | Descrição no arquivo |
|---|---|
cai_e_quica | o elemento cai de cima, bate e assenta torto |
desliza_de_baixo | sobe deslizando, como quem empurra a foto na página |
vira_pagina | giro 3D, folha virando |
datilografa | texto surge letra a letra (Courier) |
rasga | o papel se rasga revelando o que está atrás |
cola_fita | o elemento entra e uma fita crepe cai prendendo ele |
xerox | aparece com flash de fotocopiadora, estourado depois assenta |
carimba | baixa com impacto e leve tremida, tipo carimbo |
O que o motor entende de verdade hoje
⚠️ Sete dos oito nomes acima são do modelo, não do motor. A exceção é
carimba, que existe de verdade — está emENTRADAS_BASE, nomotor.js:61. Os outros sete não existem em lugar nenhum. Emotor.js:69resolve a entrada assim:const entradaDe = nome => (window.EFEITOS && window.EFEITOS[nome]) || ENTRADAS_BASE[nome] || ENTRADAS_BASE.aparecer;Escrever
cai_e_quicanum roteiro não dá erro — dá um fade sem graça, porque cai no fallbackaparecer. É a mesma classe de defeito da fonte que vira Arial: sai errado e sai calado.
O que está implementado em web/mod_efeitos/efeitos.js, com a descrição do próprio arquivo:
| Entrada | O que faz |
|---|---|
papel_abre | desdobra na horizontal, escala X 0 → 1 com overshoot |
desdobra_cima | a mesma ideia no eixo vertical (papel dobrado ao meio) |
quica | cai e quica 3 vezes com amortecimento; squash no toque, stretch no ar |
gira_entra | entra girando de −25°, com overshoot, e assenta |
estala | 1 passo seco, zero interpolação: passo 0 é pose "errada", passo 1 é a final |
desliza_lado | entra pela lateral com arrasto e uma freada seca no fim |
cresce_pop | 0 → 1,12 → 1. Carimbo, mas leve |
rasga_revela | clip-path com borda irregular de papel rasgado, com empurrão lateral |
balanca_entra | entra pendurado pelo topo e balança até parar (pêndulo amortecido) |
bate_gruda | a mão bate o papel na mesa: chega grande, achata e assenta |
sobe_encaixa | sobe de baixo do quadro e encaixa com overshoot curto |
pisca_montagem | poses discretas, tipo xerox de montagem — cada passo é um quadro |
varre_fita | a fita crepe sendo passada, com a ponta levantada, e um tapa no fim |
poe_mao | chega grande, como se a mão pusesse o recorte na frente da câmera |
desamassa | o papel entra amassado e se abre, com tremor que decai |
voa_de_longe | entra de fora da tela, girando, e pousa |
Mais os embutidos de motor.js como fallback: cair, aparecer, cresce_pop e
carimba. Campos que qualquer entrada lê do elemento (todos opcionais): seed (semente
determinística), dir, dur, altura e passos (passos por segundo do stop-motion).
O terceiro vocabulário: o que o validador aceita
Não são duas listas, são três, e nenhuma bate com a outra. O roteiro/_validar.py tem
a própria, e ela é mais curta que a do motor:
VOCAB = {"papel_abre", "quica", "gira_entra", "estala", "desliza_lado", "cresce_pop",
"rasga_revela", "balanca_entra", "cair", "datilografa", "cascata", "nenhuma"}
| Lista | Onde | Quantos nomes |
|---|---|---|
| MODELO | roteiro/ROTEIRO_MODELO.json | 8 |
| motor | web/mod_efeitos/efeitos.js + os embutidos do motor.js | 16 + 4 |
| validador | roteiro/_validar.py (VOCAB) | 12 |
⚠️ Oito nomes que o motor implementa seriam REPROVADOS pelo validador:
desdobra_cima,bate_gruda,sobe_encaixa,pisca_montagem,varre_fita,poe_mao,desamassaevoa_de_longe. Eles rodam — oVOCABé que ficou para trás dogerar_roteiro_v3.pye não os conhece. (Hoje isso é teórico: o validador estoura antes de chegar lá — ver a ressalva no topo desta página.)
E datilografa é o caso contrário, o mais traiçoeiro dos três: está no MODELO e no
VOCAB — ou seja, passa por escrito nos dois lugares que um roteirista consulta — e não é
implementado em lugar nenhum. Como entrada, cai no mesmo fallback mudo de cai_e_quica. A
datilografia da peça existe, mas ela é o motor reescrevendo o innerHTML em função de
t a cada frame, não uma entrada chamada datilografa.
Quem for escrever um roteiro animado novo: parta da tabela do motor — é a única das três que descreve o que vai acontecer na tela. Se for rodar o
_validar.pydepois, saiba que ele reprova oito daqueles nomes e aprova um que não existe. E se precisar de um gesto que não está emefeitos.js, escreva o efeito lá obedecendo ao contrato — não invente nome no JSON esperando que o motor adivinhe.
As regras de composição, e o script que deveria checá-las
roteiro/_validar.py foi escrito para rodar em 1s e sair com código ≠ 0 se alguma regra
quebrasse. Hoje ele não chega a validar: estoura KeyError: 'id' na linha 31 contra o
JSON que está no disco — ficou para trás do gerar_roteiro_v3.py. Vale como especificação
escrita das regras da casa, não como rede viva; até alguém consertar, isto se confere à mão.
O que ele cobra:
terço superior nunca vazio (amostrado a cada 0,05s) · no máximo 2 blocos de texto
simultâneos · texto nunca ultrapassa x=1000 · nada de camada superior cobre texto vivo ·
3 a 6 elementos por beat · a ordem de camadas (0 fundo → 10 foto/mapa → 20 recorte →
30 texto → 40 fita → 50 carimbo/doodle).
As larguras de texto não são estimadas: _medir.py renderiza cada string no Chromium
com a TTF do projeto e grava o getBoundingClientRect() em _medidas.json. Trocar a
fonte invalida as medidas — quem trocar roda o _medir.py de novo.
Áudio
Do LEIA_ME.md:
Narração e trilha saem por MCP (ElevenLabs / ElevenLabs Music / Lyria). Gerações gastam crédito — simulo o custo antes de rodar.
A procedência dos ativos externos da peça está em Acervo de imagens e direitos.
Original completo
_Lab_Video/, na raiz de Documents/Bené/: o LEIA_ME.md, o CREDITOS.md, o
roteiro/ROTEIRO_MODELO.json (formato) e o roteiro/MUDANCAS_v2.md (o diário beat a beat
da peça "Senado Amigo do Povo", com o antes/depois de cada decisão). O código vive em
web/ — motor.js, mod_efeitos/efeitos.js, montar_v2.py, turbo/render_turbo.py e
mixar_v2.py. As ferramentas de imagem estática são outras, e estão em
Ferramentas que rodam só na máquina.