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:

  1. const { chromium } = require("playwright");
  2. const browser = await chromium.launch();
  3. const page = await browser.newPage({ viewport: { width: 1080, height: 1440 }, deviceScaleFactor: 1 });
  4. await page.goto("file:///caminho/posts/slug/post.html", { waitUntil: "networkidle" });
  5. await page.evaluate(() => document.fonts.ready);
  6. 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:

ChecagemComo é feitaO que acontece se falhar
Fontes carregadasPercorre document.fonts e procura status igual a errorO script sai com código 1; nenhum arquivo é gravado
Nada fora da telaPara cada elemento visível, compara getBoundingClientRect com a largura e a altura do documentoAviso no log com os primeiros elementos cortados, para revisão
Arte não mudouHash SHA-256 do HTML mais os arquivos de templateNada 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:

  1. Para cada quadro i de 0 até duração × fps, chama page.evaluate((t) => window.__seek(t), i / fps).
  2. Captura o quadro com page.screenshot({ type: "jpeg", quality: 92 }) em um arquivo numerado, como f00042.jpg.
  3. 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âmetroValorPor quê
-framerate e -r30Instagram aceita de 23 a 60 fps; 30 é o padrão de reels
-c:vlibx264 -preset medium -crf 20H.264 é o codec mais aceito; CRF 20 fica visualmente sem perdas em texto
-pix_fmtyuv420pSem isso, celulares e o próprio Instagram podem recusar o arquivo
-c:a e -b:aaac 96kÁudio AAC é obrigatório; 96 kbps basta para uma trilha chiptune
-movflags+faststartMove 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.