EngineeringRendering

Substituímos um Navegador Headless por um Renderizador em Python

Ridvay · 4 de agosto de 2026 · 10 min read

Substituímos um Navegador Headless por um Renderizador em Python

Todo pôster e todo vídeo que a Ridvay exporta era renderizado por um navegador web. Não é metáfora — um Chromium de verdade, iniciado dentro de um contêiner, carregando o nosso editor, esperando as fontes e, então, tirando uma captura de tela.

Isso soa absurdo até você parar para pensar no motivo de alguém fazer assim. Aí passa a soar óbvio. E depois, quando você mede, começa a parecer caro.

Veja como saímos dali e chegamos a um renderizador que desenha o mesmo pôster em 70 milissegundos — e o que foi preciso para confiar nele.

Por Que o Navegador Fazia Sentido no Começo

O nosso editor é uma aplicação React. Um design é um documento JSON — páginas, elementos, posições, fontes, cores, passos de animação — e o editor transforma isso em DOM e CSS. Quando você arrasta um título pela tela, está vendo o motor de layout do navegador fazendo o trabalho dele.

Então, quando o usuário clica em Exportar, o renderizador mais seguro possível é justamente aquele que já concorda com o que ele está olhando: o próprio editor. Carregamos a aplicação em modo headless com a flag ?render=1, entregamos o design, esperamos fontes e imagens assentarem e capturamos o palco.

Isso é correto por construção. Não existe uma segunda implementação para sair de sincronia, porque não existe uma segunda implementação. O texto quebra do jeito que quebra no editor porque é o mesmo código fazendo a quebra.

O problema é tudo o que um navegador traz junto na bagagem. Inicialização de processo. Um DOM. Uma cascata de CSS. Um motor de layout capaz de lidar com floats, flexbox, grid e modos de escrita. Um compositor. Tudo isso para um design que, na esmagadora maioria dos casos, são seis retângulos e um pouco de texto.

A Constatação

Fomos olhar o que os nossos designs de fato contêm. Um pôster típico tem um fundo (cor sólida ou gradiente linear), alguns blocos de texto, duas ou três formas e uma ou duas imagens. Posições absolutas. Sem floats. Sem flexbox. Sem layout aninhado.

Você não precisa de um navegador para desenhar isso. Precisa de uma biblioteca de desenho 2D e de uma medição de texto honesta.

Então escrevemos uma. Chamamos de rasterizador, é um pequeno serviço em Python e ele fica na frente do serviço do navegador, em vez de substituí-lo.

A Regra Que Torna Isso Seguro

Esta é a parte que importa mais do que qualquer benchmark, então ela vem antes dos benchmarks.

Um renderizador rápido que quase sempre acerta é pior do que nenhum renderizador rápido. Se o nosso rasterizador desenha um pôster um pouco diferente do que o navegador desenharia, ninguém fica sabendo. As duas renderizações "dão certo". O usuário recebe um arquivo. Só que é o arquivo errado.

Por isso o rasterizador roda uma whitelist estrita antes de desenhar qualquer coisa — e, o que é crucial, ela libera chaves, não apenas tipos de elemento. Se um design carrega uma propriedade que o rasterizador não implementa, esse design não é nosso para desenhar. Ele é entregue ao Chromium, byte a byte, e quem chamou nunca percebe a diferença.

Eis o modo de falha que essa regra existe para evitar. Suponha que um elemento carregue "rotation": 45. Um renderizador que faz whitelist por tipo vê "isto é uma forma, eu sei desenhar formas" e desenha sem rotação. O navegador desenha com rotação. Duas renderizações bem-sucedidas, uma delas silenciosamente errada, e nenhum erro em lugar nenhum do sistema.

Uma chave não reconhecida é, portanto, uma recusa automática, com o nome da chave registrado na linha de log. Essas linhas de log são uma fila de funcionalidades: elas nos dizem exatamente o que o tráfego real está pedindo e nós ainda não suportamos.

Como um Design Vira um PNG

O caminho da imagem estática é curto:

  1. Classificar. Percorrer cada página, elemento e linha de texto comparando com a whitelist. Qualquer chave desconhecida, fonte não suportada, sistema de escrita não suportado ou valor fora de faixa produz uma string de motivo. Qualquer motivo, por menor que seja, faz a requisição ser encaminhada ao Chromium.
  2. Desenhar o fundo. Um preenchimento sólido ou um gradiente linear no estilo do CSS.
  3. Desenhar cada elemento na sua própria camada. Uma camada RGBA por elemento, composta na ordem z. A camada por elemento importa: é ela que faz a opacidade do elemento se misturar com o que está por baixo, em vez de se multiplicar sobre isso.
  4. Codificar e enviar. PNG por padrão, JPEG sob demanda, direto para o armazenamento. A resposta tem o mesmo formato {imageUrl} que o serviço do navegador devolve.

O texto é a parte que exige cuidado. Não existe ajuste automático no nosso formato — o corpo de fonte que o design manda é o que sai renderizado —, então o rasterizador carrega o mesmo TTF que o navegador carrega, mede com ele e quebra as linhas do jeito que o pre-wrap do CSS com break-word quebra. Ele também reproduz o half-leading do CSS, para que os glifos fiquem centralizados na caixa de linha em vez de colados no topo.

Um detalhe que nos custou um bom tempo até acertar: fontes variáveis precisam de todos os eixos informados, na ordem em que a fonte os declara. Os eixos da Inter são [opsz, wght]. Passar um único valor define o tamanho óptico e nunca toca no peso, o que subdimensionava o corpo de texto em cerca de 10% — o bastante para empurrar uma linha de texto para uma segunda linha no navegador, mas não no rasterizador.

O Gradiente Que Era 65% de Uma Renderização

O primeiro profiling revelou algo constrangedor e delicioso: mais da metade do tempo de renderização era o gradiente de fundo, desenhado um pixel por vez dentro de um laço em Python.

Um gradiente linear é unidimensional por definição. A cor depende de uma única coordenada projetada, então só existem 256 valores distintos que valem o cálculo. Basta amostrar a rampa uma vez em uma lookup table e deixar o NumPy projetá-la sobre a tela.

Gradiente 1080×1350 Tempo
Laço em Python, pixel a pixel 1.237 ms
LUT de 256 entradas + NumPy 10,2 ms

122× em uma única função — a mudança que tornou toda a abordagem viável.

Como um Design Vira um MP4

O vídeo é onde a arquitetura fica interessante, porque um navegador é genuinamente bom em animação e tivemos que ter cuidado na hora de igualá-lo.

O nosso modelo de animação é pequeno de propósito: cada elemento tem uma entrada, uma saída e um loop opcional que dura a cena inteira, e cada página tem uma transição para a seguinte. O navegador renderiza vídeo literalmente rodando esse motor de animação e capturando cada quadro.

Para igualar, o rasterizador é um port linha a linha do código de movimento do editor — a segmentação da linha do tempo, as definições dos presets e o solucionador de easing cubic-bezier, incluindo o mesmo laço de bisseção de 24 iterações, para que os dois motores cheguem exatamente ao mesmo valor suavizado, e não apenas a um valor próximo.

O truque de performance é o modelo de sprites. Um renderizador de quadros ingênuo redesenha cada elemento em cada quadro. Mas uma animação de entrada não muda o que o elemento é — ela muda a opacidade, a posição, a escala e o recorte. É exatamente o que o navegador também faz: ele calcula o layout e rasteriza a camada uma vez, e depois aplica uma transformação por quadro no compositor.

Então o rasterizador desenha cada elemento uma única vez no tamanho natural, guarda esse bitmap em cache e, a cada quadro, aplica a transformação interpolada ao sprite em cache. Uma linha do tempo de 150 quadros custa segundos em vez de minutos.

Os quadros então vão em streaming para o ffmpeg como RGB bruto por um pipe — libx264, preset medium, CRF 20, yuv420p, +faststart. Nada fica em buffer além da janela do próprio encoder, e nenhum arquivo PNG intermediário chega a ser escrito.

Os Números

Primeiro, o desenho. Um pôster 1080×1350 com fundo em gradiente, quatro elementos de texto e duas formas, medido localmente em um Apple M4:

Etapa 1× (1080×1350) 2× (2160×2700, padrão)
Desenho 26,8 ms 70,2 ms
Codificação PNG 68,8 ms 205,2 ms
Tamanho de saída 132 KB 306 KB

Geração de quadros para vídeo, na mesma máquina: 3,2 ms por quadro, cerca de 315 quadros por segundo, depois de uma construção única do cache de sprites de 24 ms.

Depois, a comparação que realmente conta — o mesmo design submetido aos dois renderizadores no cluster, de ponta a ponta, incluindo o upload:

Clipe Rasterizador Chromium Ganho
1080×1350, 113 quadros 6,0 s 24,0 s 4,0×
720×900, 77 quadros 3,0 s 18,0 s 6,0×

Hardware idêntico, mesmo design submetido, vídeo batendo quadro a quadro.

O Gargalo Mudou de Lugar

Aqui está o resultado que mais nos surpreendeu. Nas nossas configurações padrão de exportação — pixel ratio 2, PNG — desenhar o pôster leva 70 ms e comprimi-lo leva 205 ms. A codificação PNG hoje é três quartos do trabalho.

Trocar essa mesma imagem para JPEG em qualidade 82 custa 12,9 ms de codificação em vez de 205 ms, e produz um arquivo de 185 KB em vez de 306 KB. De ponta a ponta, são 83 ms contra 275 ms.

Gastamos o nosso esforço de otimização no desenho, e o desenho deixou de ser o problema. Vale lembrar disso na próxima vez que você tiver certeza de saber para onde o tempo está indo.

Como Sabemos Que Está Realmente Certo

Um renderizador que alega paridade precisa provar isso, então construímos um harness que submete o mesmo design aos dois serviços e compara as saídas.

Comparar renderizações pixel a pixel é inútil — o antialiasing difere, e o teste falharia em toda execução sem motivo nenhum. Em vez disso, o harness compara coisas que se mantêm estáveis entre dois rasterizadores diferentes: a cor média do fundo nas bordas, a fração de pixels desenhados dentro da caixa de cada elemento e uma redução do quadro inteiro para 32×32 em tons de cinza. Grosseiro demais para o antialiasing importar, preciso o bastante para que um elemento faltando, uma fonte caindo em fallback ou um bloco deslocado apareçam na hora.

Estado atual: 13 de 13 elementos dentro da tolerância nas imagens estáticas e 113 de 113 quadros batendo no vídeo. Na nossa execução mais recente, um elemento animado mediu 0,238 de cobertura de tinta no rasterizador contra 0,240 no Chromium — uma diferença relativa de 1%.

A regra que seguimos é que nenhuma funcionalidade entra na whitelist sem um fixture de paridade provando que aquele caso específico bate. Afrouxar a whitelist sem essa prova é exatamente como um serviço desse tipo destrói a confiança que acabou de conquistar.

O Que Ainda Mandamos Para o Navegador

Bastante coisa, e de propósito.

Sistemas de escrita da direita para a esquerda vão para o Chromium, porque o build do Pillow que distribuímos não tem Raqm e disporia o árabe da esquerda para a direita, em formas isoladas e sem ligaduras. Isso não é ficar "levemente errado" — é outro texto, e um que ainda por cima parece bem renderizado, que é a pior falha possível. Presets de animação letra a letra também vão, já que precisam de um motor no nível do caractere que ainda não construímos. O mesmo vale para transições de morph entre páginas com elementos pareados e para qualquer fonte que não distribuímos.

No tráfego de produção recente, o caminho rápido deu conta de cerca de 83% dos envios de renderização. A maior parte do restante era o nosso próprio fixture de paridade forçando um fallback de propósito; as recusas genuínas ficaram abaixo de 6%. Quando rastreamos uma delas, a causa acabou sendo um design que carregava durationMs onde o nosso formato diz duration.

Essa foi interessante, porque o próprio carregador do editor reescreve silenciosamente esse campo antes de o Chromium pintar qualquer coisa: chaves desconhecidas dentro de um passo de animação são descartadas e a duração volta para um valor padrão. Ou seja, o navegador não estava renderizando uma animação de 900 ms. Estava renderizando uma de 600 ms. Recusar aquele design nunca foi a escolha segura — foi só a lenta. Agora rodamos a mesma normalização que o editor roda antes de classificar, e essa classe de recusa desapareceu.

A Parte Que Vale Copiar

Se você levar uma coisa só daqui, provavelmente não é a lookup table do gradiente.

É o formato do sistema: um caminho rápido que tem permissão de ser incompleto, na frente de um caminho lento que está sempre certo, com uma regra dura de que tudo o que não for reconhecido cai adiante. Nunca precisamos fazer o rasterizador dar conta de tudo. Só precisamos fazê-lo honesto sobre aquilo de que ele não dá conta.

Foi isso que o tornou entregável logo de cara. Um bug no caminho rápido deixa uma renderização lenta. Ele não consegue deixá-la errada.


Ridvay Engineering — medições do harness de paridade e de um profile local, agosto de 2026. O renderizador descrito aqui é o que desenha todo design feito no Ridvay Studio, incluindo as capas e os vídeos deste blog.

Try Ridvay — the free AI design tool

Describe a poster, social post, flyer or slide and Ridvay generates a complete, editable design in seconds.

Open Ridvay Studio   ← All posts