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:
- 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.
- Desenhar o fundo. Um preenchimento sólido ou um gradiente linear no estilo do CSS.
- 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.
- 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.