EngineeringRendering

Wir haben einen Headless-Browser durch einen Python-Renderer ersetzt

Ridvay · 4. August 2026 · 10 min read

Wir haben einen Headless-Browser durch einen Python-Renderer ersetzt

Jedes Poster und jedes Video, das Ridvay exportiert, wurde früher von einem Webbrowser gerendert. Nicht im übertragenen Sinne — ein echtes Chromium, in einem Container gestartet, das unseren Editor lädt, auf Schriften wartet und dann einen Screenshot macht.

Das klingt absurd, bis man darüber nachdenkt, warum das überhaupt jemand tut. Dann klingt es naheliegend. Und sobald man es misst, sieht es teuer aus.

So sind wir von dort zu einem Renderer gekommen, der dasselbe Poster in 70 Millisekunden zeichnet — und das hat es gebraucht, ihm zu vertrauen.

Warum ein Browser anfangs sinnvoll war

Unser Editor ist eine React-App. Ein Design ist ein JSON-Dokument — Seiten, Elemente, Positionen, Schriften, Farben, Animationsschritte — und der Editor macht daraus DOM und CSS. Wenn man eine Überschrift verschiebt, sieht man der Layout-Engine des Browsers bei der Arbeit zu.

Klickt jemand also auf Exportieren, ist der sicherste denkbare Renderer derjenige, der ohnehin schon mit dem übereinstimmt, was diese Person vor sich hat: der Editor selbst. Wir laden die App headless mit einem ?render=1-Flag, übergeben ihr das Design, warten, bis Schriften und Bilder geladen sind, und fotografieren die Bühne ab.

Das ist konstruktionsbedingt korrekt. Es gibt keine zweite Implementierung, die auseinanderdriften könnte, weil es schlicht keine zweite Implementierung gibt. Text bricht so um, wie er im Editor umbricht, weil derselbe Code den Umbruch macht.

Der Haken ist alles, was ein Browser sonst noch mitschleppt. Prozessstart. Ein DOM. Eine CSS-Kaskade. Eine Layout-Engine, die mit Floats, Flexbox, Grid und Schreibrichtungen umgehen kann. Ein Compositor. Für ein Design, das in der überwältigenden Mehrheit der Fälle aus sechs Rechtecken und etwas Text besteht.

Die Beobachtung

Wir haben uns angesehen, was unsere Designs tatsächlich enthalten. Ein typisches Poster besteht aus einem Hintergrund (einfarbig oder ein linearer Verlauf), ein paar Textblöcken, zwei, drei Formen und ein bis zwei Bildern. Absolute Positionen. Keine Floats. Keine Flexbox. Kein verschachteltes Layout.

Dafür braucht man keinen Browser. Man braucht eine 2D-Zeichenbibliothek und eine ehrliche Textvermessung.

Also haben wir eine geschrieben. Sie heißt Rasterizer, ist ein kleiner Python-Dienst und sitzt vor dem Browser-Dienst, statt ihn zu ersetzen.

Die Regel, die das Ganze sicher macht

Das ist der Teil, der mehr zählt als jeder Benchmark — deshalb steht er vor den Benchmarks.

Ein schneller Renderer, der meistens richtig liegt, ist schlimmer als gar kein schneller Renderer. Wenn unser Rasterizer ein Poster auch nur leicht anders zeichnet als der Browser, merkt das niemand. Beide Rendervorgänge „gelingen“. Man bekommt eine Datei. Nur eben die falsche.

Deshalb läuft im Rasterizer eine strikte Whitelist, bevor überhaupt etwas gezeichnet wird — und entscheidend: Sie erlaubt einzelne Schlüssel, nicht bloß Elementtypen. Trägt ein Design eine Eigenschaft, die der Rasterizer nicht implementiert, ist dieses Design nicht seine Sache. Es geht Byte für Byte an Chromium, und der Aufrufer merkt keinen Unterschied.

Der Fehlerfall, dem diese Regel vorbeugt, sieht so aus. Angenommen, ein Element trägt "rotation": 45. Ein Renderer, der nach Typ whitelistet, denkt sich „das ist eine Form, Formen kann ich“ — und zeichnet sie ungedreht. Der Browser zeichnet sie gedreht. Zwei erfolgreiche Renderings, eines davon still und leise falsch, und nirgends im System ein Fehler.

Ein unbekannter Schlüssel führt deshalb automatisch zur Ablehnung, und der Schlüssel steht namentlich in der Logzeile. Diese Logzeilen sind unsere Feature-Warteschlange: Sie zeigen genau, was echter Traffic verlangt und wir noch nicht unterstützen.

Wie aus einem Design ein PNG wird

Der Weg zum Standbild ist kurz:

  1. Klassifizieren. Jede Seite, jedes Element und jede Textzeile wird gegen die Whitelist geprüft. Jeder unbekannte Schlüssel, jede nicht unterstützte Schrift, jedes nicht unterstützte Schriftsystem und jeder Wert außerhalb des zulässigen Bereichs erzeugt einen Grund im Klartext. Ein einziger Grund genügt, und die Anfrage wird an Chromium weitergereicht.
  2. Hintergrund zeichnen. Eine einfarbige Füllung oder ein linearer Verlauf im CSS-Stil.
  3. Jedes Element auf eine eigene Ebene zeichnen. Eine RGBA-Ebene pro Element, in z-Reihenfolge zusammengesetzt. Die eigene Ebene pro Element ist wichtig: Dadurch mischt sich die Deckkraft eines Elements mit dem, was darunter liegt, statt sich hineinzumultiplizieren.
  4. Kodieren und hochladen. Standardmäßig PNG, auf Wunsch JPEG, direkt in den Speicher. Die Antwort hat dieselbe {imageUrl}-Form, die auch der Browser-Dienst liefert.

Text ist der Teil, der Sorgfalt verlangt. Unser Format kennt kein automatisches Einpassen — es rendert genau die Schriftgröße, die im Design steht. Also lädt der Rasterizer dieselbe TTF wie der Browser, misst damit und bricht so um, wie CSS pre-wrap mit break-word umbricht. Er bildet außerdem das Half-Leading von CSS nach, damit die Glyphen mittig in ihrer Zeilenbox sitzen und nicht oben anschlagen.

Ein Detail hat uns dabei echte Zeit gekostet: Variable Fonts brauchen jede Achse, und zwar in der Reihenfolge, die die Schrift selbst deklariert. Bei Inter sind die Achsen [opsz, wght]. Übergibt man nur einen Wert, setzt man die optische Größe und rührt das Gewicht nie an — was Fließtext um rund 10 % zu klein vermessen hat. Genug, um eine Textzeile im Browser in eine zweite Zeile zu schieben, im Rasterizer aber nicht.

Der Verlauf, der 65 % eines Renderings war

Das erste Profiling förderte etwas ebenso Peinliches wie Erfreuliches zutage: Mehr als die Hälfte der Renderzeit ging für den Verlaufshintergrund drauf, Pixel für Pixel in einer Python-Schleife gezeichnet.

Ein linearer Verlauf ist per Definition eindimensional. Die Farbe hängt an einer einzigen projizierten Koordinate, es gibt also nie mehr als 256 verschiedene Werte, die sich zu berechnen lohnen. Die Rampe einmal in eine Lookup-Tabelle samplen — und NumPy sie über die Fläche projizieren lassen.

Verlauf 1080×1350 Zeit
Pixelweise Python-Schleife 1.237 ms
LUT mit 256 Einträgen + NumPy 10,2 ms

Faktor 122 bei einer einzigen Funktion — die Änderung, die den ganzen Ansatz erst tragfähig gemacht hat.

Wie aus einem Design ein MP4 wird

Beim Video wird die Architektur interessant, denn in Animation ist ein Browser wirklich gut, und wir mussten genau aufpassen, wie wir ihn nachbilden.

Unser Animationsmodell ist bewusst klein: Jedes Element bekommt optional einen Auftritt, einen Abgang und eine szenenlange Schleife, jede Seite einen Übergang zur nächsten. Der Browser rendert Video, indem er diese Animations-Engine buchstäblich laufen lässt und jeden Frame abfotografiert.

Um das zu treffen, ist der Rasterizer eine zeilengetreue Portierung des Motion-Codes aus dem Editor — die Segmentierung der Timeline, die Preset-Definitionen und der Solver für Cubic-Bézier-Easing samt derselben 24 Bisektionsschritte, damit beide Engines beim exakt gleichen Easing-Wert landen und nicht bloß bei einem ähnlichen.

Der Performance-Kniff ist das Sprite-Modell. Ein naiver Frame-Renderer zeichnet in jedem Frame jedes Element neu. Aber eine Auftrittsanimation ändert nicht, was ein Element ist — sie ändert Deckkraft, Position, Skalierung und Beschnitt. Genau das macht ein Browser auch: Er layoutet und rastert eine Ebene einmal und legt dann pro Frame im Compositor eine Transformation darauf.

Der Rasterizer zeichnet also jedes Element einmal in seiner natürlichen Größe, hält diese Bitmap im Cache und wendet pro Frame die interpolierte Transformation auf das gecachte Sprite an. Eine Timeline mit 150 Frames kostet damit Sekunden statt Minuten.

Die Frames wandern anschließend als rohes RGB über eine Pipe in ffmpeg — libx264, Preset medium, CRF 20, yuv420p, +faststart. Gepuffert wird nichts außer dem Fenster des Encoders, und es entstehen zu keinem Zeitpunkt Zwischendateien im PNG-Format.

Die Zahlen

Zuerst das Zeichnen. Ein Poster in 1080×1350 mit Verlaufshintergrund, vier Textelementen und zwei Formen, lokal auf einem Apple M4 gemessen:

Schritt 1× (1080×1350) 2× (2160×2700, Standard)
Zeichnen 26,8 ms 70,2 ms
PNG-Kodierung 68,8 ms 205,2 ms
Ausgabegröße 132 KB 306 KB

Frame-Erzeugung fürs Video, gleiche Maschine: 3,2 ms pro Frame, also rund 315 Frames pro Sekunde, nach einem einmaligen Aufbau des Sprite-Caches von 24 ms.

Und dann der Vergleich, auf den es wirklich ankommt — dasselbe Design an beide Renderer im Cluster, Ende zu Ende, inklusive Upload:

Clip Rasterizer Chromium Faktor
1080×1350, 113 Frames 6,0 s 24,0 s 4,0×
720×900, 77 Frames 3,0 s 18,0 s 6,0×

Identische Hardware, dasselbe eingereichte Design, Video Frame für Frame deckungsgleich.

Der Flaschenhals ist gewandert

Dieses Ergebnis hat uns am meisten überrascht. Bei unseren Standardeinstellungen für den Export — Pixelverhältnis 2, PNG — dauert das Zeichnen des Posters 70 ms und das Komprimieren 205 ms. Die PNG-Kodierung macht inzwischen drei Viertel der Arbeit aus.

Dasselbe Bild als JPEG in Qualität 82 kostet 12,9 ms statt 205 ms und ergibt eine Datei von 185 KB statt 306 KB. Ende zu Ende sind das 83 ms gegenüber 275 ms.

Wir haben unsere Optimierungsarbeit ins Zeichnen gesteckt — und das Zeichnen hat aufgehört, das Problem zu sein. Daran lohnt es sich zu erinnern, wenn man das nächste Mal ganz genau zu wissen glaubt, wo die Zeit hingeht.

Woher wir wissen, dass es wirklich stimmt

Ein Renderer, der Parität behauptet, muss sie beweisen. Also haben wir ein Harness gebaut, das dasselbe Design an beide Dienste schickt und die Ergebnisse vergleicht.

Renderings Pixel für Pixel zu vergleichen, bringt nichts — das Antialiasing unterscheidet sich, und der Vergleich würde bei jedem Lauf grundlos fehlschlagen. Stattdessen vergleicht das Harness Dinge, die über zwei verschiedene Rasterizer hinweg stabil bleiben: die mittlere Hintergrundfarbe an den Rändern, den Anteil gezeichneter Pixel innerhalb der Box jedes Elements und eine 32×32-Graustufenreduktion des gesamten Frames. Grob genug, dass Antialiasing keine Rolle spielt, präzise genug, dass ein fehlendes Element, eine ersatzweise geladene Schrift oder ein verschobener Block sofort auffällt.

Aktueller Stand: 13 von 13 Elementen bei Standbildern innerhalb der Toleranz und 113 von 113 Frames beim Video deckungsgleich. Im jüngsten Lauf maß ein animiertes Element im Rasterizer eine Farbdeckung von 0,238 gegenüber 0,240 in Chromium — ein relativer Unterschied von 1 %.

Die Regel, an die wir uns halten: Kein Feature kommt auf die Whitelist ohne eine Paritäts-Fixture, die genau diesen Fall als übereinstimmend nachweist. Die Whitelist ohne diesen Nachweis aufzuweichen — so zerstört ein Dienst wie dieser genau das Vertrauen, das er sich gerade erarbeitet hat.

Was weiterhin an den Browser geht

Einiges, und zwar mit Absicht.

Schriftsysteme, die von rechts nach links laufen, gehen an Chromium, weil unser Pillow-Build kein Raqm enthält und Arabisch von links nach rechts in isolierten, unverbundenen Formen setzen würde. Das ist nicht „ein bisschen daneben“ — das ist ein anderer Text, der trotzdem sauber gerendert aussieht, und damit der denkbar schlimmste Fehlerfall. Animations-Presets, die einzelne Buchstaben ansprechen, gehen ebenfalls an den Browser, weil sie eine Engine auf Zeichenebene brauchen, die wir nicht gebaut haben. Dazu kommen Morph-Übergänge zwischen Seiten mit einander zugeordneten Elementen sowie jede Schrift, die wir nicht ausliefern.

Im Produktionsverkehr der letzten Zeit hat der schnelle Pfad rund 83 % der Render-Anfragen übernommen. Der größte Teil des Rests war unsere eigene Paritäts-Fixture, die absichtlich einen Fallback auslöst; echte Ablehnungen lagen unter 6 %. Als wir einer davon nachgegangen sind, stellte sich als Ursache ein Design heraus, das durationMs mitführte, wo unser Format duration vorsieht.

Das war interessant, denn der Loader des Editors schreibt genau dieses Feld stillschweigend um, bevor Chromium überhaupt etwas zeichnet: Unbekannte Schlüssel innerhalb eines Animationsschritts werden verworfen, und die Dauer fällt auf einen Standardwert zurück. Der Browser hat also gar keine Animation von 900 ms gerendert, sondern eine von 600 ms. Dieses Design abzulehnen, war nie die sichere Entscheidung — nur die langsame. Wir führen jetzt vor dem Klassifizieren dieselbe Normalisierung aus wie der Editor, und diese Klasse von Ablehnungen ist verschwunden.

Der Teil, der zum Nachbauen taugt

Wenn man eine einzige Sache hieraus mitnimmt, dann vermutlich nicht die Lookup-Tabelle für Verläufe.

Sondern die Form des Systems: ein schneller Pfad, der unvollständig sein darf, vor einem langsamen Pfad, der immer korrekt ist — mit der harten Regel, dass alles Unbekannte durchfällt. Wir mussten den Rasterizer nie dazu bringen, alles zu können. Wir mussten ihn nur ehrlich darüber machen, was er nicht kann.

Genau das hat ihn überhaupt erst auslieferbar gemacht. Ein Fehler im schnellen Pfad macht ein Rendering langsam. Falsch machen kann er es nicht.


Ridvay Engineering — Messungen aus dem Paritäts-Harness und einem lokalen Profiling, August 2026. Der hier beschriebene Renderer zeichnet jedes Design, das in Ridvay Studio entsteht — auch die Cover und Videos in diesem 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