Nous avons remplacé un navigateur headless par un moteur de rendu Python
Chaque affiche et chaque vidéo exportée par Ridvay était autrefois rendue par un navigateur web. Pas au sens figuré : un vrai Chromium, démarré dans un conteneur, qui chargeait notre éditeur, attendait les polices, puis prenait une capture d'écran.
Cela paraît absurde, jusqu'à ce qu'on se demande pourquoi qui que ce soit ferait une chose pareille. Alors cela paraît évident. Puis, une fois qu'on le mesure, cela commence à coûter cher.
Voici comment nous sommes passés de là à un moteur de rendu qui dessine la même affiche en 70 millisecondes, et ce qu'il a fallu pour lui faire confiance.
Pourquoi un navigateur avait du sens au départ
Notre éditeur est une application React. Un design est un document JSON — pages, éléments, positions, polices, couleurs, étapes d'animation — que l'éditeur transforme en DOM et en CSS. Quand vous déplacez un titre à la souris, vous regardez le moteur de mise en page du navigateur faire son travail.
Alors, quand un utilisateur clique sur Exporter, le moteur de rendu le plus sûr qui soit est celui qui est déjà d'accord avec ce qu'il a sous les yeux : l'éditeur lui-même. Nous chargeons l'application en mode headless avec un paramètre ?render=1, nous lui passons le design, nous attendons que les polices et les images se stabilisent, puis nous capturons la scène.
C'est correct par construction. Aucune seconde implémentation ne risque de diverger, puisqu'il n'y a pas de seconde implémentation. Le texte se coupe comme il se coupe dans l'éditeur, parce que c'est le même code qui s'en charge.
Le hic, c'est tout ce qu'un navigateur embarque au passage. Le démarrage d'un processus. Un DOM. Une cascade CSS. Un moteur de mise en page capable de gérer les flottants, flexbox, grid et les modes d'écriture. Un compositeur. Le tout pour un design qui, dans l'immense majorité des cas, se résume à six rectangles et un peu de texte.
Le constat
Nous avons regardé ce que contiennent réellement nos designs. Une affiche type, c'est un fond (couleur unie ou dégradé linéaire), quelques blocs de texte, deux ou trois formes et une image ou deux. Des positions absolues. Pas de flottants. Pas de flexbox. Aucune mise en page imbriquée.
Nul besoin d'un navigateur pour dessiner cela. Il faut une bibliothèque de dessin 2D et une mesure de texte honnête.
Nous l'avons donc écrite. Elle s'appelle le rasterizer, c'est un petit service Python, et il se place devant le service navigateur plutôt que de le remplacer.
La règle qui rend la chose sûre
C'est le point qui compte plus que n'importe quel benchmark : il passe donc avant les benchmarks.
Un moteur de rendu rapide qui a généralement raison est pire que pas de moteur rapide du tout. Si notre rasterizer dessine une affiche un peu différemment de ce qu'aurait fait le navigateur, personne ne s'en aperçoit. Les deux rendus « réussissent ». L'utilisateur récupère un fichier. Simplement, ce n'est pas le bon.
Le rasterizer applique donc une liste blanche stricte avant de dessiner quoi que ce soit — et, point crucial, cette liste blanche porte sur les clés, pas seulement sur les types d'éléments. Si un design porte une propriété que le rasterizer n'implémente pas, ce n'est pas à nous de le dessiner. Il est transmis à Chromium, octet pour octet, et l'appelant ne voit jamais la différence.
Voici le mode de défaillance que cette règle sert à empêcher. Supposons qu'un élément porte "rotation": 45. Un moteur qui filtre par type se dit « c'est une forme, je sais dessiner les formes » et la dessine sans rotation. Le navigateur, lui, la dessine pivotée. Deux rendus réussis, dont un silencieusement faux, et pas la moindre erreur nulle part dans le système.
Une clé non reconnue entraîne donc un refus automatique, avec le nom de la clé dans la ligne de log. Ces lignes de log forment une file d'attente de fonctionnalités : elles nous disent exactement ce que le trafic réel réclame et que nous ne prenons pas encore en charge.
Comment un design devient un PNG
Le chemin des images fixes est court :
- Classifier. Passer chaque page, chaque élément et chaque ligne de texte au crible de la liste blanche. Toute clé inconnue, police non prise en charge, écriture non prise en charge ou valeur hors plage produit une chaîne de motif. Le moindre motif suffit à faire relayer la requête vers Chromium.
- Dessiner le fond. Un aplat de couleur, ou un dégradé linéaire à la manière de CSS.
- Dessiner chaque élément sur son propre calque. Un calque RGBA par élément, composité dans l'ordre z. Ce calque par élément a son importance : il fait que l'opacité de l'élément se fond avec ce qui se trouve dessous au lieu de se multiplier avec.
- Encoder et téléverser. PNG par défaut, JPEG sur demande, directement vers le stockage. La réponse a la même forme
{imageUrl}que celle renvoyée par le service navigateur.
Le texte est la partie qui demande du soin. Notre format ne prévoit aucun ajustement automatique — la taille de police indiquée par le design est celle qui est rendue —, donc le rasterizer charge le même TTF que le navigateur, mesure avec, et coupe les lignes comme le fait CSS pre-wrap avec break-word. Il reproduit aussi le demi-interligne de CSS, pour que les glyphes soient centrés dans leur boîte de ligne au lieu d'être collés en haut.
Un détail qui nous a coûté un temps fou avant d'être juste : les polices variables exigent que tous leurs axes soient fournis, dans l'ordre où la police les déclare. Les axes d'Inter sont [opsz, wght]. Ne passer qu'une seule valeur règle la taille optique sans jamais toucher à la graisse, ce qui sous-mesurait le texte courant d'environ 10 % — assez pour qu'une ligne de texte bascule sur une deuxième ligne dans le navigateur, mais pas dans le rasterizer.
Le dégradé qui représentait 65 % d'un rendu
Le premier profilage a mis au jour quelque chose d'aussi gênant que réjouissant : plus de la moitié du temps de rendu partait dans le fond en dégradé, dessiné pixel par pixel dans une boucle Python.
Un dégradé linéaire est unidimensionnel par définition. La couleur ne dépend que d'une seule coordonnée projetée : il n'y a donc jamais que 256 valeurs distinctes qui méritent d'être calculées. On échantillonne la rampe une fois dans une table de correspondance, puis on laisse NumPy la projeter sur toute la toile.
| Dégradé 1080×1350 | Temps |
|---|---|
| Boucle Python pixel par pixel | 1 237 ms |
| LUT de 256 entrées + NumPy | 10,2 ms |
122× sur une seule fonction — le changement qui a rendu toute l'approche viable.
Comment un design devient un MP4
C'est avec la vidéo que l'architecture devient intéressante, parce qu'un navigateur est réellement bon en animation et qu'il a fallu être prudents sur la façon de l'égaler.
Notre modèle d'animation est volontairement réduit : chaque élément reçoit une entrée, une sortie et une boucle sur toute la scène, toutes facultatives, et chaque page reçoit une transition vers la suivante. Le navigateur produit la vidéo en exécutant littéralement ce moteur d'animation et en capturant chaque image.
Pour l'égaler, le rasterizer est un portage ligne à ligne du code d'animation de l'éditeur : le découpage de la timeline, la définition des préréglages et le solveur d'accélération en courbe de Bézier cubique, avec la même boucle de bissection à 24 itérations, afin que les deux moteurs aboutissent à la même valeur interpolée, et pas seulement à une valeur voisine.
L'astuce de performance, c'est le modèle de sprites. Un générateur d'images naïf redessine chaque élément à chaque image. Or une animation d'entrée ne change pas ce qu'un élément est : elle change son opacité, sa position, son échelle et son masque. C'est exactement ce que fait un navigateur, lui aussi : il met en page et rastérise un calque une seule fois, puis applique une transformation par image dans le compositeur.
Le rasterizer dessine donc chaque élément une seule fois à sa taille naturelle, met ce bitmap en cache, puis applique à chaque image la transformation interpolée au sprite mis en cache. Une timeline de 150 images coûte des secondes au lieu de minutes.
Les images partent ensuite en flux vers ffmpeg en RGB brut à travers un tube — libx264, preset medium, CRF 20, yuv420p, +faststart. Rien n'est mis en tampon hormis la fenêtre propre à l'encodeur, et aucun fichier PNG intermédiaire n'est jamais écrit.
Les chiffres
D'abord, le dessin. Une affiche 1080×1350 avec un fond en dégradé, quatre éléments de texte et deux formes, mesurée en local sur un Apple M4 :
| Étape | 1× (1080×1350) | 2× (2160×2700, par défaut) |
|---|---|---|
| Dessin | 26,8 ms | 70,2 ms |
| Encodage PNG | 68,8 ms | 205,2 ms |
| Taille du fichier | 132 KB | 306 KB |
Génération des images pour la vidéo, sur la même machine : 3,2 ms par image, soit environ 315 images par seconde, après une construction unique du cache de sprites en 24 ms.
Vient ensuite la comparaison qui compte vraiment : le même design soumis aux deux moteurs de rendu dans le cluster, de bout en bout, téléversement compris :
| Clip | Rasterizer | Chromium | Gain |
|---|---|---|---|
| 1080×1350, 113 images | 6,0 s | 24,0 s | 4,0× |
| 720×900, 77 images | 3,0 s | 18,0 s | 6,0× |
Matériel identique, même design soumis, vidéo conforme image par image.
Le goulot d'étranglement s'est déplacé
Voici le résultat qui nous a le plus surpris. Avec nos réglages d'export par défaut — ratio de pixels 2, PNG —, dessiner l'affiche prend 70 ms et la compresser en prend 205. L'encodage PNG représente désormais les trois quarts du travail.
Basculer cette même image en JPEG à une qualité de 82 coûte 12,9 ms d'encodage au lieu de 205 ms, et produit un fichier de 185 KB au lieu de 306 KB. De bout en bout, cela fait 83 ms contre 275 ms.
Nous avons concentré nos efforts d'optimisation sur le dessin, et le dessin a cessé d'être le problème. Bon à se rappeler la prochaine fois que vous serez certain de savoir où passe le temps.
Comment nous savons que c'est réellement juste
Un moteur de rendu qui revendique la parité doit le prouver : nous avons donc construit un banc d'essai qui soumet le même design aux deux services et compare les résultats.
Comparer les rendus pixel par pixel ne sert à rien : l'anticrénelage diffère, et le test échouerait à chaque exécution sans aucune raison valable. Le banc compare plutôt ce qui reste stable d'un moteur de rastérisation à l'autre : la couleur moyenne du fond le long des bords, la proportion de pixels dessinés à l'intérieur de la boîte de chaque élément, et une réduction en niveaux de gris 32×32 de l'image entière. Trop grossier pour que l'anticrénelage pèse, assez précis pour qu'un élément manquant, une police de substitution ou un bloc décalé saute aux yeux immédiatement.
État actuel : 13 éléments sur 13 dans la tolérance sur les images fixes, et 113 images sur 113 conformes en vidéo. Lors de notre dernière exécution, un élément animé mesurait une couverture d'encre de 0,238 dans le rasterizer contre 0,240 dans Chromium — soit un écart relatif de 1 %.
La règle que nous nous imposons, c'est qu'aucune fonctionnalité n'entre dans la liste blanche sans un cas de test de parité prouvant que ce cas précis correspond. Assouplir la liste blanche sans cette preuve, c'est précisément ainsi qu'un service de ce genre détruit la confiance qu'il vient de gagner.
Ce que nous envoyons encore au navigateur
Beaucoup de choses, et délibérément.
Les écritures de droite à gauche partent chez Chromium, parce que la version de Pillow que nous livrons n'embarque pas Raqm et disposerait l'arabe de gauche à droite, en formes isolées et non liées. Ce n'est pas « légèrement décalé » : c'est un autre texte, qui a pourtant l'air correctement rendu — le pire échec possible. Les préréglages d'animation lettre par lettre partent eux aussi, puisqu'ils réclament un moteur au niveau du caractère que nous n'avons pas construit. Idem pour les transitions par morphing entre pages avec appariement d'éléments, et pour toute police que nous ne livrons pas.
Sur le trafic de production récent, le chemin rapide a traité environ 83 % des demandes de rendu. Le reste venait pour l'essentiel de notre propre cas de test de parité, qui déclenche volontairement un repli ; les refus authentiques restaient sous les 6 %. En traçant l'un d'eux, la cause s'est révélée être un design portant durationMs là où notre format dit duration.
Celui-là était intéressant, parce que le chargeur de l'éditeur réécrit silencieusement ce champ avant même que Chromium ne peigne quoi que ce soit : les clés inconnues à l'intérieur d'une étape d'animation sont supprimées, et la durée retombe sur une valeur par défaut. Le navigateur ne rendait donc pas une animation de 900 ms. Il en rendait une de 600 ms. Refuser ce design n'a jamais été le choix sûr — seulement le choix lent. Nous appliquons désormais la même normalisation que l'éditeur avant de classifier, et cette catégorie de refus a disparu.
Ce qui mérite d'être copié
Si vous ne devez retenir qu'une chose de tout cela, ce n'est probablement pas la table de correspondance des dégradés.
C'est la forme du système : un chemin rapide qui a le droit d'être incomplet, placé devant un chemin lent toujours correct, avec une règle intangible — tout ce qui n'est pas reconnu bascule vers le second. Nous n'avons jamais eu à rendre le rasterizer capable de tout traiter. Nous avons seulement eu à le rendre honnête sur ce qu'il ne traite pas.
C'est ce qui l'a rendu livrable dès le départ. Un bug dans le chemin rapide rend un rendu lent. Il ne peut pas le rendre faux.
Ridvay Engineering — mesures issues du banc de parité et d'un profilage local, août 2026. Le moteur de rendu décrit ici est celui qui dessine chaque design créé dans Ridvay Studio, y compris les couvertures et les vidéos de ce blog.