Uma animação de canvas escrita do jeito comum — requestAnimationFrame, um delta de tempo por quadro e estado que vai se acumulando — funciona muito bem na tela e falha no instante em que você tenta transformá-la em vídeo. A saída é inverter a lógica: em vez de avançar a animação, você consulta a animação. Dado um instante t, o programa desenha o quadro daquele instante e de mais nenhum, sem depender de nada que tenha acontecido antes.
Essa regra é o que permite renderizar quadro a quadro, pular para qualquer ponto e obter sempre o mesmo resultado. Este tutorial mostra como aplicá-la, com o código que gera os reels diários do Instagram do Navistron: a função de seek, o gerador de aleatórios com semente e os erros que só aparecem quando quem controla o navegador é um script. A etapa seguinte — capturar os quadros e juntar em MP4 — está no artigo sobre transformar HTML em imagem e vídeo com Playwright.
Por que requestAnimationFrame não sobrevive à captura
O requestAnimationFrame é um laço amarrado ao relógio da parede e à taxa de atualização da tela, e é justamente o tempo real que atrapalha na hora de renderizar. Capturar um quadro em 1080x1920 leva perto de 200 milissegundos, muito mais do que os 33 de um frame a 30 fps. Se a animação andar pelo relógio, ela dispara na frente; se andar por um delta fixo, passa a depender de ser chamada o número exato de vezes, na ordem exata, e um quadro perdido desloca todo o resto.
Há ainda dois agravantes práticos. Navegadores reduzem ou congelam o requestAnimationFrame quando a aba não está visível, e em modo headless esse comportamento varia de versão para versão. E se você precisar renderizar de novo só o quadro 137 — porque achou um erro de digitação no texto que aparece ali —, não existe um jeito de chegar até ele sem reexecutar os 136 anteriores.
A regra: o quadro é função do tempo, e de mais nada
A animação determinística troca o laço por uma função pura. Toda posição, opacidade, rotação e número na tela precisa ser calculável a partir de t. Na prática isso significa três proibições: nada de Date.now() ou performance.now(), nada de variável que acumula entre chamadas (o clássico x += v) e nada de desenhar por cima do quadro anterior sem limpar.
O preço é real: você abre mão de integrar física quadro a quadro e passa a precisar da forma fechada de cada movimento. Um meteoro que cai e reaparece no topo deixa de somar velocidade a cada frame e vira uma conta com resto de divisão: a distância é o tempo vezes a velocidade mais uma defasagem própria, e a posição vertical é essa distância módulo a altura do ciclo. Dá mais trabalho e resolve o problema de uma vez — parente próximo da geração procedural dos meteoros dentro do jogo.
A API mínima: window.__seek(t)
Toda a interface entre a animação e o renderizador cabe em três linhas no fim do script:
window.__seek = function (t) { t = clamp(t, 0, DUR); desenhaTudo(t); };
window.__duration = DUR;
window.__seek(0);
Do lado de fora, o renderizador abre a página uma vez, lê window.__duration e entra em um laço: para cada índice i de 0 até duração vezes fps, chama window.__seek(i / fps), tira um screenshot e salva o quadro numerado. Em 14 segundos a 30 fps são 420 chamadas, cada uma independente das outras. A última linha, o __seek(0) imediato, garante que a página nunca apareça em branco.
Para conferir o clímax, basta chamar window.__seek(5.2) e tirar um screenshot — sem esperar 5,2 segundos e sem renderizar os 156 quadros anteriores. A conferência visual diária é feita assim: doze chamadas espalhadas pela duração, montadas em uma folha de contato 3x4.
Aleatoriedade que se repete: um LCG com semente
Cenário de fundo com 1.500 estrelas e 72 meteoros de formatos irregulares pede números aleatórios. Só que Math.random() devolve valores diferentes a cada execução, e duas renderizações do mesmo vídeo sairiam com estrelas em lugares diferentes. A solução é um gerador congruente linear, cinco linhas, com semente explícita:
let s = 2709;
const rnd = () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; };
Os dois números mágicos são os do Numerical Recipes e o módulo é 2 elevado a 32. A qualidade estatística não é de criptografia, mas para posição de estrela e raio de meteoro sobra. O detalhe que importa é outro: o sorteio acontece uma vez só, na montagem da cena, e o resultado fica guardado em arrays. Chamar rnd() dentro da função de desenho faz cada seek consumir números diferentes, e a cena passa a mudar conforme a ordem dos quadros — o bug mais confuso dessa família.
Como a semente é explícita, ela vira um parâmetro de arte: nos reels do Navistron a semente é a data do dia (2709 para 27 de setembro), então o campo de estrelas muda todo dia e é idêntico dentro do mesmo dia. O mesmo truque vale para o ruído branco da trilha chiptune gerada por código, pelo mesmo motivo.
Cenas sem máquina de estados
Um vídeo de 14 segundos tem cinco ou seis cenas, e a tentação é escrever uma máquina de estados. Não precisa. Duas funções auxiliares resolvem tudo:
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const seg = (t, inicio, duracao) => clamp((t - inicio) / duracao, 0, 1);
A função seg devolve 0 antes do início, 1 depois do fim e a fração no meio. Com ela, a opacidade de uma camada que entra aos 2,6 segundos e sai aos 7,5 é o produto de duas chamadas: a de entrada e o complemento da de saída. Uma curva de suavização — 1 - Math.pow(1 - p, 3) — deixa o movimento agradável sem biblioteca nenhuma, e nada disso guarda estado.
O contador numérico, que é o elemento de mais impacto no formato, segue a mesma ideia: interpola do zero até o valor final entre dois instantes, trava no instante de impacto e ganha um pulso de escala que decai exponencialmente. No reel de hoje ele é um relógio, contando de 0:00 até 4:57 — a partida mais longa já registrada no Navistron.
| Trecho | Intervalo (s) | O que aparece |
|---|---|---|
| Gancho | 0,0 a 2,7 | Três linhas de título entrando em sequência |
| Contador | 2,6 a 7,5 | Relógio subindo até travar aos 5,2 s |
| Segundo dado | 7,85 a 10,05 | Número de apoio e duas etiquetas |
| Pergunta | 10,1 a 12,0 | Chamada direta para quem assiste |
| Fecho | 12,05 a 14,0 | Endereço do site e arroba |
Os erros que só aparecem na captura
Na tela tudo funciona; no vídeo, não. Esta é a lista dos problemas que aparecem quando o navegador passa a ser dirigido por um script — todos já custaram uma renderização perdida por aqui.
| Erro | Sintoma no vídeo | Correção |
|---|---|---|
| Uso de Date.now() ou performance.now() | Cada renderização sai diferente | Todo valor deriva do t recebido |
| Estado acumulado entre quadros | O quadro depende da ordem das chamadas | Forma fechada da posição em função de t |
| CSS animation ou transition | Elemento anima pelo relógio da parede | Só opacity e transform, atribuídos pelo JS |
| Math.random() dentro do desenho | Estrelas e formas mudam a cada quadro | PRNG com semente, sorteado só na montagem |
| Fonte web ainda carregando | Primeiro quadro com a fonte de fallback | Esperar document.fonts.ready antes do laço |
| Screenshot com deviceScaleFactor maior que 1 | Quadros com o dobro do tamanho esperado | Fixar 1 e definir o viewport no tamanho final |
O caso da fonte é o mais traiçoeiro porque só afeta os primeiros quadros, justamente os que viram capa. Além do document.fonts.ready, vale checar se cada família declarada em @font-face carregou mesmo — aqui o script de conferência falha de propósito quando alguma não carregou.
Como provar que a animação é determinística
Há um teste rápido e um teste honesto. O rápido: renderize o mesmo quadro duas vezes em ordens diferentes — uma chegando em sequência, outra saltando direto — e compare os PNG byte a byte. Se o hash bater, aquele quadro não depende do caminho. O honesto: renderize o vídeo inteiro duas vezes e compare o hash de todos os quadros.
Na rotina diária o teste é mais barato: um script abre o HTML, chama o seek em doze instantes, monta a folha de contato e ainda verifica se algum elemento visível escapou da área de 1080 por 1920. Erro de layout e erro de determinismo aparecem no mesmo lugar.
O que isso permite na prática
Com a animação sendo uma função do tempo, a renderização vira uma tarefa mecânica que roda sozinha: um push no repositório dispara o fluxo, 420 quadros são capturados, o ffmpeg junta com a trilha e o MP4 aparece pronto minutos depois. Corrigir uma palavra em um texto não exige reencenar nada — o mesmo comando devolve um vídeo idêntico ao anterior, exceto pela palavra trocada.
E o conteúdo desses vídeos pode ser gerado a partir de dados reais sem retrabalho, porque o número que entra no contador é só mais um parâmetro. Os dados de hoje, lidos na telemetria pública em 27/09/2026: 528 partidas registradas, 44 pilotos no ranking, tempo médio de 2min01 por partida e uma única partida que chegou a 4min57 — nenhuma passou dos cinco minutos, e só 23 das 528 passaram dos quatro. Cada um desses números vira uma linha de texto e um valor final de contador; o resto do reel continua igual.
Vale conhecer a abordagem tradicional antes: o artigo sobre como criar um jogo 2D em JavaScript mostra o laço clássico, que é o certo para um jogo, onde a entrada do jogador impede qualquer forma fechada. A técnica deste tutorial serve para o caso oposto. E as ideias de otimização de jogos web continuam valendo: o fundo estrelado, que nunca muda, é desenhado uma vez em um canvas separado e nunca mais é tocado.
FAQ
O que é uma animação determinística?
É uma animação em que o quadro exibido depende exclusivamente do instante pedido. Chamar a função com o mesmo t sempre produz a mesma imagem, independentemente de quais quadros foram desenhados antes ou de quanto tempo real passou.
Por que não usar requestAnimationFrame para gerar vídeo?
Porque ele avança pelo relógio real e pela taxa de atualização da tela, enquanto a captura de cada quadro leva bem mais tempo do que a duração de um frame. A animação ficaria adiantada, e quadros perdidos ou a aba em segundo plano mudariam o resultado.
Como gerar números aleatórios que se repetem em JavaScript?
Com um gerador congruente linear de semente explícita: guarde um estado, multiplique por 1664525, some 1013904223 e tire o resto por 2 elevado a 32. Com a mesma semente, a sequência é sempre a mesma — o que Math.random() não garante.
Dá para usar CSS animation nesse esquema?
Não. Animações e transições de CSS correm pelo relógio do navegador e ignoram o instante pedido, então saem dessincronizadas na captura. O caminho é atribuir opacity e transform por JavaScript dentro da própria função de desenho.
Quantos quadros tem um vídeo de 14 segundos?
A 30 quadros por segundo, 420 quadros. É o número de chamadas da função de seek e de screenshots que o renderizador tira antes de entregar os arquivos ao ffmpeg.
Essa técnica serve para jogo?
Para o jogo em si, não: a entrada do jogador é imprevisível e impede escrever a posição como função fechada do tempo. Serve para trailers, aberturas, vinhetas e qualquer peça sem interação que precise virar arquivo de vídeo.
