Todo post do Instagram do Navistron nasce como uma página HTML: um arquivo com fundo estrelado, tipografia própria e os números da telemetria pública do dia. Um script abre essa página em um navegador sem interface, tira uma captura em 1080×1440 e, no caso dos reels, grava 390 quadros e monta um MP4. Nada disso passa por editor de imagem ou de vídeo. Este tutorial mostra como transformar HTML em imagem e em vídeo com Playwright, ffmpeg e GitHub Actions, com o código e os erros reais do pipeline que publica as artes do jogo desde 8 de setembro de 2026.
Por que gerar a arte em HTML
Uma arte em HTML é texto: dá para versionar no Git, revisar em um diff, gerar a partir de dados e renderizar de forma idêntica em qualquer máquina. No Navistron, o post do dia depende de números que mudam a cada rodada (em 13 de setembro de 2026 eram 491 partidas e 38 pilotos), então um template com CSS e um pouco de JavaScript é mais barato de manter do que um arquivo de design. Três decisões tornam esse caminho confiável:
- Tamanho fixo. O elemento html e o body têm largura e altura exatas da arte (1080×1440 para o feed, 1080×1920 para reels), com overflow escondido. O navegador vira uma tela de pintura, não uma página que rola.
- Fontes locais. Os arquivos woff2 ficam no repositório e entram por @font-face. Fonte carregada pela rede pode falhar no servidor de build, e uma arte com fonte substituta é um post errado.
- Sem aleatoriedade real. O fundo de estrelas usa um gerador pseudoaleatório com semente fixa. Renderizar duas vezes produz o mesmo pixel, o que permite comparar versões e detectar regressões.
Passo 1: a página que vira imagem
O arquivo de cada post declara o tamanho em uma meta tag e carrega o CSS compartilhado do projeto. O essencial do CSS cabe em três regras:
html, body { width: 1080px; height: 1440px; overflow: hidden; margin: 0; }@font-face { font-family: "BigShoulders"; src: url("../assets/fonts/BigShoulders.woff2") format("woff2"); font-weight: 100 900; font-display: block; }canvas { position: absolute; inset: 0; }
Repare no caminho da fonte: ele é relativo ao arquivo CSS, não à página. Esse detalhe custou a primeira renderização do projeto, que saiu com a fonte reserva do sistema porque o CSS estava em uma pasta e o HTML em outra. O valor font-display block também importa: sem ele o navegador pode pintar o texto com a fonte reserva por um instante, e é exatamente esse instante que a captura pode pegar.
Passo 2: a captura com Playwright
Playwright é uma biblioteca de automação de navegadores mantida pela Microsoft, com pacotes para Node e Python. Para transformar HTML em PNG, o script abre um Chromium sem interface, cria uma página com a viewport do tamanho da arte, navega até o arquivo local e captura a tela. Em Node, o núcleo é este:
const { chromium } = require("playwright");const browser = await chromium.launch();const page = await browser.newPage({ viewport: { width: 1080, height: 1440 }, deviceScaleFactor: 1 });await page.goto("file:///caminho/posts/slug/post.html", { waitUntil: "networkidle" });await page.evaluate(() => document.fonts.ready);await page.screenshot({ path: "post.png", fullPage: false });
Dois pontos merecem atenção. O primeiro é deviceScaleFactor igual a 1: com o valor padrão de telas retina, uma viewport de 1080 pixels viraria um PNG de 2160 pixels. O segundo é a espera por document.fonts.ready, que resolve quando todas as fontes declaradas terminaram de carregar ou falharam. O Navistron ainda espera 400 milissegundos depois disso, por causa de canvas que desenha no primeiro quadro de animação.
Passo 3: checagens automáticas antes de publicar
Uma captura pode sair "bonita" e ainda assim errada. O script de renderização do Navistron faz duas verificações dentro da página e se recusa a publicar quando uma delas falha:
| Checagem | Como é feita | O que acontece se falhar |
|---|---|---|
| Fontes carregadas | Percorre document.fonts e procura status igual a error | O script sai com código 1; nenhum arquivo é gravado |
| Nada fora da tela | Para cada elemento visível, compara getBoundingClientRect com a largura e a altura do documento | Aviso no log com os primeiros elementos cortados, para revisão |
| Arte não mudou | Hash SHA-256 do HTML mais os arquivos de template | Nada a fazer: a captura anterior continua válida |
A terceira linha economiza minutos de máquina a cada push: o hash de cada arte fica gravado em um arquivo JSON ao lado do PNG, e só o que mudou é renderizado de novo. Uma pré-visualização local, em Python, gera uma folha de contato com 12 quadros de cada reel para avaliar o gancho e as zonas seguras do Instagram antes de enviar ao repositório.
Passo 4: de HTML para vídeo com ffmpeg
Um reel é a mesma ideia repetida 30 vezes por segundo. Em vez de animações CSS, cujo tempo o script não controla, a página expõe uma função window.__seek(t) que posiciona todos os elementos e redesenha o canvas para o instante t, em segundos. O script então percorre os quadros:
- Para cada quadro i de 0 até duração × fps, chama
page.evaluate((t) => window.__seek(t), i / fps). - Captura o quadro com
page.screenshot({ type: "jpeg", quality: 92 })em um arquivo numerado, como f00042.jpg. - Gera a trilha sonora em WAV com um script próprio (um sintetizador chiptune em Node, sem dependências) e chama o ffmpeg.
A escolha do JPEG para os quadros não é estética, é de tempo: no mesmo ambiente, um quadro PNG de 1080×1920 levava cerca de 660 milissegundos para ser capturado e um JPEG de qualidade 92, cerca de 110. Em um reel de 13 segundos, são 390 quadros; a diferença é de mais de três minutos por vídeo. A compressão do JPEG desaparece na codificação final, que já perde mais informação do que ela. Os parâmetros do ffmpeg que funcionam para o Instagram:
| Parâmetro | Valor | Por quê |
|---|---|---|
| -framerate e -r | 30 | Instagram aceita de 23 a 60 fps; 30 é o padrão de reels |
| -c:v | libx264 -preset medium -crf 20 | H.264 é o codec mais aceito; CRF 20 fica visualmente sem perdas em texto |
| -pix_fmt | yuv420p | Sem isso, celulares e o próprio Instagram podem recusar o arquivo |
| -c:a e -b:a | aac 96k | Áudio AAC é obrigatório; 96 kbps basta para uma trilha chiptune |
| -movflags | +faststart | Move o índice para o início do arquivo, o que permite reprodução antes do download completo |
| -shortest | (sem valor) | Corta áudio ou vídeo pelo mais curto dos dois, evitando segundos de tela preta |
O mesmo script captura também a capa do reel em PNG, no instante em que o gancho está totalmente visível, e a API de publicação recebe esse instante em milissegundos como thumbnailOffset.
Passo 5: renderizar no GitHub Actions e publicar pela URL pública
A renderização não roda na máquina de ninguém: roda em um workflow do GitHub Actions disparado quando um arquivo post.html é enviado para a branch principal. O runner Ubuntu instala o Node 20, o pacote playwright com o Chromium (o comando npx playwright install --with-deps chromium cuida das bibliotecas do sistema), baixa as fontes com npm pack dos pacotes fontsource e instala o ffmpeg com apt. Depois de renderizar, o próprio workflow faz commit dos PNG e MP4 de volta ao repositório, com a marca [skip ci] na mensagem para não disparar a si mesmo.
Como o repositório é público, cada arquivo ganha uma URL estável no raw.githubusercontent.com. É essa URL que vai para a API do agendador de posts, que baixa o arquivo e o envia ao Instagram. O ciclo completo, do push ao vídeo pronto, leva de 4 a 7 minutos para um reel e cerca de 1 minuto para uma imagem. O rastro de cada post fica no repositório: HTML, legenda, arquivo final e um registro com as métricas de alcance, que alimentam o texto do dia seguinte.
Erros que já custaram um dia de post
- Commit rejeitado por arquivo modificado. O npm install alterou o package.json no runner e o git pull --rebase falhou. A solução foi um arquivo .npmrc com save=false e package-lock=false no repositório.
- ffmpeg ausente. O runner ubuntu-24.04 não vem com ffmpeg. O script de preparação passou a instalar pelo apt e, se não conseguir, a usar o binário estático do pacote ffmpeg-static via npm.
- Fonte reserva na arte. Caminho relativo errado no @font-face. Desde então a renderização falha de propósito quando qualquer fonte tem status error.
- Quadros lentos. PNG por quadro multiplicava por seis o tempo do reel. JPEG de qualidade 92 resolveu sem diferença visível no MP4.
- Dados velhos. As páginas de telemetria usam cache por URL; um parâmetro extra na consulta força a leitura nova antes de colocar um número na arte.
Onde isso aparece no Navistron
O resultado do pipeline está no Instagram @navistron: reels com contador animado do recorde, carrosséis com o pódio do ranking e imagens com os totais da semana, todos gerados a partir das mesmas páginas de telemetria que qualquer jogador pode abrir. A base técnica é a mesma do jogo: um canvas 2D desenhado com JavaScript puro, como descrito em como criar um jogo 2D em JavaScript e em como otimizar jogos web. A telemetria que abastece as artes é explicada em como a telemetria do Navistron funciona, e o deploy do site, que publica também este blog, em como o deploy na Vercel funciona.
Se você mantém um jogo, um produto ou um perfil que precisa de artes com dados frescos todo dia, o caminho HTML, Playwright e Actions custa zero em licenças e roda sozinho. E se quiser ver o que os números dizem hoje, jogue uma partida: ela entra na telemetria em segundos e pode virar o post de amanhã.
FAQ
Playwright ou Puppeteer para transformar HTML em imagem?
Os dois fazem captura de tela de páginas com qualidade idêntica, porque usam o mesmo Chromium. Playwright tem instalação mais simples das dependências do sistema no Linux (o comando install com a opção with-deps) e API em Node e Python; foi o escolhido pelo Navistron por isso.
Preciso de um servidor para rodar isso?
Não. O pipeline do Navistron roda inteiro dentro do GitHub Actions, no plano gratuito de repositórios públicos, e publica os arquivos pela URL do raw.githubusercontent.com. Localmente, basta Node e o pacote playwright para pré-visualizar.
Quanto tempo leva para gerar um reel de 13 segundos?
Cerca de 4 a 7 minutos no runner do GitHub, contando a instalação do Chromium, a captura de 390 quadros em JPEG, a síntese do áudio e a codificação em H.264. Uma imagem única leva cerca de 1 minuto.
Posso usar fontes do Google em vez de arquivos locais?
Pode, mas não é recomendado para renderização automática: a captura depende da rede e uma falha silenciosa produz a arte com fonte reserva. Baixar o woff2 uma vez (os pacotes fontsource fazem isso via npm) e declarar por @font-face elimina a variável.
Por que capturar os quadros em JPEG e não em PNG?
Porque a captura em PNG é cerca de seis vezes mais lenta e o resultado final é um MP4 em H.264, que já descarta mais detalhe do que um JPEG de qualidade 92. Para a imagem final do feed, que não passa por codificação de vídeo, o PNG continua sendo o formato usado.
