EngineeringRendering

Reemplazamos un navegador headless por un renderizador en Python

Ridvay · 4 de agosto de 2026 · 10 min de lectura

Reemplazamos un navegador headless por un renderizador en Python

Hasta hace poco, cada cartel y cada video que Ridvay exportaba los renderizaba un navegador web. No es una metáfora: un Chromium de verdad, arrancado dentro de un contenedor, cargando nuestro editor, esperando a las tipografías y sacando una captura de pantalla.

Suena absurdo hasta que piensas por qué alguien haría algo así. Entonces suena obvio. Y después, cuando lo mides, empieza a parecer caro.

Así fue como pasamos de ahí a un renderizador que dibuja el mismo cartel en 70 milisegundos, y lo que costó llegar a confiar en él.

Por qué al principio tenía sentido usar un navegador

Nuestro editor es una app de React. Un diseño es un documento JSON —páginas, elementos, posiciones, tipografías, colores, pasos de animación— y el editor lo convierte en DOM y CSS. Cuando arrastras un titular por el lienzo, lo que estás viendo es el motor de maquetación del navegador haciendo su trabajo.

Así que cuando alguien pulsa Exportar, el renderizador más seguro posible es el que ya coincide con lo que tiene delante: el propio editor. Cargamos la app en modo headless con el parámetro ?render=1, le pasamos el diseño, esperamos a que las tipografías y las imágenes terminen de asentarse y capturamos el escenario.

Esto es correcto por construcción. No hay una segunda implementación que pueda desincronizarse, porque no hay una segunda implementación. El texto se ajusta igual que en el editor porque es exactamente el mismo código el que lo ajusta.

El inconveniente es todo lo que un navegador se trae consigo. El arranque del proceso. Un DOM. La cascada de CSS. Un motor de maquetación capaz de lidiar con floats, flexbox, grid y modos de escritura. Un compositor. Todo eso para un diseño que, en la inmensa mayoría de los casos, son seis rectángulos y algo de texto.

La observación

Miramos qué contienen de verdad nuestros diseños. Un cartel típico es un fondo (color sólido o degradado lineal), unos cuantos bloques de texto, un par de formas y una o dos imágenes. Posiciones absolutas. Nada de floats. Nada de flexbox. Ninguna maquetación anidada.

Para dibujar eso no hace falta un navegador. Hace falta una librería de dibujo 2D y una medición de texto honesta.

Así que la escribimos. Se llama el rasterizador, es un pequeño servicio en Python y se coloca delante del servicio de navegador en lugar de sustituirlo.

La regla que lo vuelve seguro

Esta es la parte que importa más que cualquier medición, así que va antes que las mediciones.

Un renderizador rápido que casi siempre acierta es peor que no tener ningún renderizador rápido. Si nuestro rasterizador dibuja un cartel apenas distinto de como lo dibujaría el navegador, nadie se entera. Los dos renders «funcionan». El usuario recibe un archivo. Solo que es el archivo equivocado.

Por eso el rasterizador aplica una lista blanca estricta antes de dibujar nada; y, sobre todo, pone en esa lista claves, no solo tipos de elemento. Si un diseño trae una propiedad que el rasterizador no implementa, ese diseño no nos toca dibujarlo a nosotros. Se le entrega a Chromium, byte por byte, y quien hizo la petición nunca nota la diferencia.

Este es el fallo que esa regla existe para evitar. Supongamos que un elemento lleva "rotation": 45. Un renderizador que filtra por tipo ve «esto es una forma, yo sé dibujar formas» y la dibuja sin rotar. El navegador la dibuja rotada. Dos renders exitosos, uno de ellos silenciosamente equivocado, y ningún error en ninguna parte del sistema.

Por lo tanto, una clave no reconocida es un rechazo automático, y el log deja escrito el nombre de esa clave. Esas líneas de log son una cola de funcionalidades: nos dicen exactamente qué está pidiendo el tráfico real que todavía no soportamos.

Cómo un diseño se convierte en PNG

El camino de las imágenes fijas es corto:

  1. Clasificar. Recorrer cada página, cada elemento y cada línea de texto contrastándolos con la lista blanca. Cualquier clave desconocida, tipografía no soportada, sistema de escritura no soportado o valor fuera de rango produce un motivo en texto. Basta un solo motivo para que la petición se derive a Chromium.
  2. Dibujar el fondo. Un relleno sólido o un degradado lineal al estilo CSS.
  3. Dibujar cada elemento en su propia capa. Una capa RGBA por elemento, compuestas en orden z. Que cada elemento tenga su capa importa: hace que la opacidad del elemento se mezcle con lo que hay debajo en vez de multiplicarse sobre ello.
  4. Codificar y subir. PNG por defecto, JPEG si se pide, directo al almacenamiento. La respuesta tiene la misma forma {imageUrl} que devuelve el servicio de navegador.

El texto es la parte que exige cuidado. Nuestro formato no tiene autoajuste —el tamaño de fuente que diga el diseño es el que se renderiza—, así que el rasterizador carga el mismo TTF que carga el navegador, mide con él y corta las líneas igual que CSS pre-wrap con break-word. También reproduce el half-leading de CSS, de modo que los glifos quedan centrados en su caja de línea y no pegados al borde superior.

Un detalle que nos costó tiempo real acertar: a las tipografías variables hay que darles todos los ejes, en el orden en que la propia fuente los declara. Los ejes de Inter son [opsz, wght]. Pasar un único valor fija el tamaño óptico y nunca toca el grosor, lo que medía el texto de párrafo alrededor de un 10% por debajo de lo real: suficiente para que una línea saltara a una segunda en el navegador pero no en el rasterizador.

El degradado que era el 65% de un render

El primer profiling sacó a la luz algo vergonzoso y delicioso a la vez: más de la mitad del tiempo de render era el fondo con degradado, dibujado píxel a píxel dentro de un bucle de Python.

Un degradado lineal es unidimensional por definición. El color depende de una única coordenada proyectada, así que como mucho hay 256 valores distintos que valga la pena calcular. Se muestrea la rampa una sola vez en una tabla de consulta y después se deja que NumPy la proyecte sobre todo el lienzo.

Degradado 1080×1350 Tiempo
Bucle de Python píxel a píxel 1237 ms
LUT de 256 entradas + NumPy 10.2 ms

122× en una sola función: el cambio que hizo viable todo el enfoque.

Cómo un diseño se convierte en MP4

Con el video es donde la arquitectura se pone interesante, porque un navegador es genuinamente bueno animando y tuvimos que ser cuidadosos con la forma de igualarlo.

Nuestro modelo de animación es pequeño a propósito: cada elemento tiene una entrada, una salida y un bucle de la duración de la escena, todos opcionales, y cada página tiene una transición hacia la siguiente. El navegador renderiza video ejecutando literalmente ese motor de animación y capturando cada fotograma.

Para igualarlo, el rasterizador es un port línea por línea del código de movimiento del editor: la segmentación de la línea de tiempo, las definiciones de los presets y el solver de easing con curvas cúbicas de Bézier, incluido el mismo bucle de bisección de 24 iteraciones, para que ambos motores aterricen en el mismo valor interpolado y no simplemente en uno parecido.

El truco de rendimiento es el modelo de sprites. Un renderizador de fotogramas ingenuo redibuja todos los elementos en cada fotograma. Pero una animación de entrada no cambia lo que un elemento es: cambia su opacidad, su posición, su escala y su recorte. Es exactamente lo que hace también un navegador: maqueta y rasteriza una capa una sola vez y después aplica una transformación por fotograma en el compositor.

Así que el rasterizador dibuja cada elemento una vez a su tamaño natural, guarda ese bitmap en caché y en cada fotograma aplica la transformación interpolada al sprite cacheado. Una línea de tiempo de 150 fotogramas cuesta segundos en lugar de minutos.

Los fotogramas se transmiten después a ffmpeg como RGB en crudo a través de una tubería: libx264, preset medium, CRF 20, yuv420p, +faststart. No se almacena en búfer nada más que la propia ventana del codificador, y nunca se escribe ningún archivo PNG intermedio.

Los números

Primero, el dibujado. Un cartel de 1080×1350 con fondo en degradado, cuatro elementos de texto y dos formas, medido en local sobre un Apple M4:

Etapa 1× (1080×1350) 2× (2160×2700, por defecto)
Dibujado 26.8 ms 70.2 ms
Codificación PNG 68.8 ms 205.2 ms
Tamaño de salida 132 KB 306 KB

Generación de fotogramas para video, en la misma máquina: 3.2 ms por fotograma, unos 315 fotogramas por segundo, tras una construcción única de la caché de sprites de 24 ms.

Y luego la comparación que de verdad cuenta: el mismo diseño enviado a los dos renderizadores del clúster, de extremo a extremo, subida incluida:

Clip Rasterizador Chromium Mejora
1080×1350, 113 fotogramas 6.0 s 24.0 s 4.0×
720×900, 77 fotogramas 3.0 s 18.0 s 6.0×

Hardware idéntico, mismo diseño enviado, video que coincide fotograma a fotograma.

El cuello de botella se movió

Este es el resultado que más nos sorprendió. Con nuestros ajustes de exportación por defecto —ratio de píxeles 2, PNG— dibujar el cartel tarda 70 ms y comprimirlo tarda 205 ms. La codificación PNG es ahora tres cuartas partes del trabajo.

Pasar esa misma imagen a JPEG con calidad 82 cuesta 12.9 ms de codificación en lugar de 205 ms, y produce un archivo de 185 KB en vez de uno de 306 KB. De extremo a extremo son 83 ms frente a 275 ms.

Invertimos todo el esfuerzo de optimización en el dibujado, y el dibujado dejó de ser el problema. Vale la pena recordarlo la próxima vez que estés seguro de saber dónde se va el tiempo.

Cómo sabemos que de verdad está bien

Un renderizador que dice tener paridad tiene que demostrarlo, así que construimos un banco de pruebas que envía el mismo diseño a los dos servicios y compara las salidas.

Comparar los renders píxel a píxel no sirve de nada: el antialiasing difiere y fallaría en cada ejecución sin motivo real. En su lugar, el banco compara cosas que se mantienen estables entre dos rasterizadores distintos: el color medio del fondo en los bordes, la fracción de píxeles dibujados dentro de la caja de cada elemento y una reducción del fotograma completo a 32×32 en escala de grises. Lo bastante grueso para que el antialiasing no importe y lo bastante preciso para que un elemento ausente, una tipografía que cae en su sustituta o un bloque desplazado salten a la vista de inmediato.

Estado actual: 13 de 13 elementos dentro de tolerancia en imágenes fijas y 113 de 113 fotogramas coincidentes en video. En nuestra ejecución más reciente, un elemento animado midió 0.238 de cobertura de tinta en el rasterizador frente a 0.240 en Chromium: una diferencia relativa del 1%.

La regla que nos imponemos es que ninguna funcionalidad entra en la lista blanca sin un fixture de paridad que demuestre que ese caso concreto coincide. Aflojar la lista blanca sin esa prueba es justo la forma en que un servicio así destruye la confianza que acaba de ganarse.

Lo que seguimos enviando al navegador

Bastante, y a propósito.

Los sistemas de escritura de derecha a izquierda van a Chromium, porque la compilación de Pillow que distribuimos no incluye Raqm y maquetaría el árabe de izquierda a derecha, con las letras en formas aisladas y sin ligar. Eso no es «un poco impreciso»: es otro texto distinto que aun así parece bien renderizado, que es el peor fallo posible. También van los presets de animación letra a letra, porque necesitan un motor a nivel de carácter que no hemos construido. Y lo mismo con las transiciones de morph entre páginas con elementos emparejados, y con cualquier tipografía que no distribuyamos.

En el tráfico de producción reciente, la vía rápida atendió alrededor del 83% de los envíos de render. La mayor parte del resto era nuestro propio fixture de paridad provocando un fallback a propósito; los rechazos genuinos se quedaron por debajo del 6%. Cuando rastreamos uno, la causa resultó ser un diseño que traía durationMs donde nuestro formato dice duration.

Ese caso fue interesante, porque el propio cargador del editor reescribe ese campo en silencio antes de que Chromium pinte nada: las claves desconocidas dentro de un paso de animación se descartan y la duración vuelve a su valor por defecto. Así que el navegador no estaba renderizando una animación de 900 ms. Estaba renderizando una de 600 ms. Rechazar ese diseño nunca fue la opción segura, solo la lenta. Ahora aplicamos la misma normalización que aplica el editor antes de clasificar, y esa clase de rechazo desapareció.

La parte que vale la pena copiar

Si te llevas una sola cosa de todo esto, probablemente no sea la tabla de consulta del degradado.

Es la forma del sistema: una vía rápida a la que se le permite estar incompleta, delante de una vía lenta que siempre es correcta, con una regla inquebrantable de que todo lo que no se reconozca cae hacia abajo. Nunca tuvimos que conseguir que el rasterizador lo soportara todo. Solo tuvimos que hacerlo honesto sobre lo que no soporta.

Eso es lo que lo hizo enviable desde el primer día. Un bug en la vía rápida hace que un render sea lento. No puede hacer que sea incorrecto.


Ridvay Engineering — mediciones del banco de paridad y de un profiling local, agosto de 2026. El renderizador descrito aquí es el que dibuja todos los diseños creados en Ridvay Studio, incluidas las portadas y los videos de este 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