Zurück zum Blog
13. August 2026Sergei Solod13 Min. Lesezeit

Wie ich Animated WebP, GIF und APNG in kleine, browser-sichere H.264-MP4s verwandle

Meine Produktionspipeline rekonstruiert sichtbare Animationsframes und ihr echtes Timing, wählt pro Quelle einen passenden CFR, vermeidet Upscaling, encodiert konservative H.264-Segmente, fügt sie ohne zweiten verlustbehafteten Encode zusammen und validiert die exakte Packet-Timeline vor der Veröffentlichung.

H.264FFmpegMP4Animated WebPGIFAPNGVideokompressionMedia-Pipeline

Ich habe diese Pipeline nicht gebaut, weil ich mit Codecs experimentieren wollte. Der Auslöser war viel praktischer: Animierte Bilder wurden zu einer teuren Art, etwas auszuliefern, das sich in der Praxis bereits wie ein kurzes stummes Video verhielt.

Mein Workload besteht überwiegend aus kurzen animierten WebP-, GIF- und APNG-Dateien: meist nur wenige Sekunden lang, typischerweise mit einigen Dutzend sichtbaren Frames und sehr viel zeitlicher Redundanz. Der Traffic ist stark mobil geprägt, dieselben Assets werden wiederholt ausgeliefert, und die einmalige Encoding-Zeit ist für mich deutlich weniger wichtig als die Bytes, die danach bei jedem Abruf übertragen werden.

Die einfache Formulierung lautet: „Konvertiere die Animation zu MP4.“ Die produktionsreife Version ist wesentlich schwieriger. Ein animiertes Bild ist nicht zwangsläufig eine saubere Folge vollständiger Bilder mit einer einzigen konstanten Framerate. Es kann Teilrechtecke, Disposal-Regeln, Alpha-Blending, unregelmäßige Delays, mehrdeutige Null-Dauern, verschiedene Ausrichtungen und Timing-Metadaten enthalten, die ein allgemeines Media-Probe nur unzureichend zusammenfasst.

Meine Pipeline ist deshalb nicht „FFmpeg auf eine WebP-Datei loslassen“, sondern eine Kette von Invarianten:

animated WebP / GIF / APNG
        ↓
sichtbare Canvas-Zustände dekodieren und rekonstruieren
        ↓
Source-Timeline wiederherstellen
        ↓
pro Source einen passenden CFR wählen
        ↓
Geometrie ohne Upscaling normalisieren
        ↓
ein konservatives H.264-Segment encodieren
        ↓
für jede Quelle unabhängig wiederholen
        ↓
kompatible Segmente per Stream Copy zusammenfügen
        ↓
finale Packet-Timeline normalisieren
        ↓
Probe + Packet-Prüfung + vollständiger Decode
        ↓
atomar veröffentlichen

Der Codec ist nur ein Teil davon. Schwieriger ist es, genau das Verhalten der Animation zu bewahren, während die zugrunde liegende Darstellung vollständig ausgetauscht wird.

Erste Regel: sichtbare Frames extrahieren, nicht gespeicherte Rechtecke

Die gefährlichste Vereinfachung wäre anzunehmen, jeder gespeicherte Animationsframe sei ein vollständiges Bild, das den vorherigen Frame komplett ersetzt.

Das müssen diese Formate nicht so speichern. Ein Animated-WebP-Frame hat Position, Rechteckgröße, Blend-Modus und Disposal-Modus. APNG speichert Offsets, Abmessungen sowie Dispose- und Blend-Operationen. Bei GIF kann die Graphic Control Extension vorgeben, das bestehende Bild stehen zu lassen, die betroffene Fläche auf den Hintergrund zurückzusetzen oder den vorherigen Canvas-Zustand wiederherzustellen.

Ein gespeicherter Frame kann also nur ein kleiner Patch sein, der vom bereits vorhandenen Canvas abhängt. Wenn ich solche Patches direkt als vollständige Videoframes encodiere, erhalte ich nicht dieselbe Animation in kleinerer Form, sondern eine falsche Animation.

Meine Extraktionsgrenze ist deshalb der tatsächlich angezeigte Canvas-Zustand. Für jeden Animationsschritt brauche ich die vollständig komponierten Pixel, die ein korrekter Viewer nach Disposal des vorherigen Frames und Blending des aktuellen Frames anzeigen würde.

Das klingt wie ein Decoder-Detail, ist aber die erste Korrektheitsgarantie der gesamten Pipeline. Sobald ein falsch zusammengesetzter Frame in H.264 eingebrannt wurde, kann keine spätere Encoder-Option ihn reparieren.

Die Spezifikationen beschreiben genau diese Mechanik: WebP definiert Canvas-Assembly aus ANMF-Rechtecken samt Blending und Disposal, die PNG-Spezifikation beschreibt für APNG fcTL mit Offsets, Delays, Disposal und Blend-Operationen, und GIF89a definiert das Disposal-Verhalten in der Graphic Control Extension.

Frame-Timing ist Source-Daten, keine zu erratende FPS-Zahl

Der zweite Fehler war, eine von einem allgemeinen Probe gemeldete FPS-Zahl als Wahrheit über ein animiertes Bild zu behandeln.

Die Formate speichern Timing unterschiedlich:

  • Animated WebP speichert die Dauer jedes Frames in 1-ms-Einheiten.
  • GIF speichert die Verzögerung in Hundertstelsekunden.
  • APNG speichert Zähler und Nenner der Frame-Verzögerung; ist der Nenner null, wird er laut PNG-Spezifikation als 100 behandelt.

Diese per-Frame Delays sind die Timeline. Eine durchschnittliche FPS-Angabe ist nur eine Zusammenfassung und kann irreführend sein.

Ein reales WebP machte das besonders deutlich: 1264×720, 49 sichtbare Frames, Delays von abwechselnd 62 und 63 ms und eine Gesamtdauer von 3,063 Sekunden. Das entspricht praktisch 16 fps, also 62,5 ms pro Frame.

Ein Probe meldete für dieselbe Quelle 25 fps.

Hätte ich diese Zahl übernommen und auf 25 oder 30 fps konvertiert, hätte ich entweder das Originaltiming verändert oder unnötige Duplikate erzeugt. Autoritativ waren die Frame-Dauern der Quelle, nicht die zusammengefasste FPS-Zahl.

Schlechtes Timing wird vor der CFR-Auswahl normalisiert

Manche Timing-Werte eignen sich nicht als wörtliche Playback-Anweisung. Die WebP-Spezifikation weist ausdrücklich darauf hin, dass eine Dauer von 0 und oft auch sehr kleine Werte wie 10 ms oder weniger implementationsabhängig interpretiert werden. GIF erlaubt ebenfalls eine Verzögerung von 0. Bei APNG bedeutet ein Zähler von 0, dass der nächste Frame so schnell wie möglich dargestellt werden soll, wobei Viewer trotzdem eine Untergrenze setzen können.

Eine Produktionspipeline braucht daher eine klare Policy für fehlende, beschädigte oder unplausibel kleine Delays. In meiner Pipeline bleibt das Timing millisekundengenau, die minimale Frame-Dauer beträgt 10 ms, und 100 ms werden nur als Fallback verwendet, wenn kein brauchbares Timing vorhanden ist.

Diese Zahlen sind keine universelle Regel. Entscheidend ist die Reihenfolge: Timing wird vor der Framerate-Quantisierung bereinigt, und valide Source-Dauern werden nicht durch einen bequemen Default ersetzt.

Warum ich nicht alles auf 30 fps zwinge

Nach der Rekonstruktion habe ich sichtbare Frames und ihre Dauern. Jetzt muss die Bild-Timeline in eine Video-Timeline übersetzt werden.

Alles auf 30 fps zu setzen wäre einfach, aber für mein Material verschwenderisch. Wenn die Animation ungefähr 16-mal pro Sekunde ihren sichtbaren Zustand ändert, erzeugt 30 fps überwiegend zusätzliche zeitliche Samples. H.264 kann Wiederholungen gut komprimieren, aber komprimierbare Redundanz bleibt Redundanz.

Ich wähle deshalb pro Source einen CFR aus:

10, 12, 15, 16, 18, 20, 24, 25, 30 fps

Der Selector beginnt mit der echten Timeline und wählt den niedrigsten Kandidaten, der die sichtbaren Änderungen sauber repräsentiert. Falls kein niedrigerer Kandidat passt, bleibt 30 fps der Fallback.

Ich veröffentliche bewusst keinen universellen Timing-Fehlergrenzwert. Dieser Teil muss gegen die jeweilige Content-Klasse validiert werden. Die wichtigere Regel lautet: erst reale Display-Zeitpunkte erfassen, dann den günstigsten CFR wählen, der sie ausreichend genau abbildet.

Beim 62/63-ms-Beispiel passen 16 fps besonders gut, weil ein 16-fps-Frame exakt 62,5 ms dauert.

Jede Quelle bekommt ihren eigenen CFR

Ein finales MP4 kann bei mir aus mehreren unabhängig verarbeiteten Animationen bestehen. Eine passt zu 12 fps, eine andere zu 16, die nächste zu 24 und eine weitere zu 30.

Ich encodiere sie nicht nochmals auf eine globale Framerate um, nur damit das Zusammenfügen einfacher erscheint.

source A → 12 fps CFR H.264 segment
source B → 16 fps CFR H.264 segment
source C → 24 fps CFR H.264 segment
source D → 30 fps CFR H.264 segment

segments → stream-copy concat → ein MP4

Jedes Segment ist für sich CFR. Die fertige Datei ist dadurch praktisch stückweise CFR und über die gesamte Sequenz betrachtet variabel.

Entscheidend ist, dass alle Segmente eine kompatible Stream-Beschreibung und eine gemeinsame Time Base besitzen, die jede erlaubte Kadenz exakt darstellen kann.

Warum die Time Base 90 kHz beträgt

Ich verwende für den Video-Track eine Timescale von 90.000 Hz. Jeder Kandidat meines FPS-Sets teilt 90.000 ohne Rest:

FPSExakte Frame-Dauer bei 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

Ein 24-fps-Frame ist damit nicht ungefähr 41,667 ms lang, sondern exakt 3750 Ticks. Bei 16 fps sind es exakt 5625 Ticks. Segmentgrenzen benötigen keine Bruchteile eines Video-Ticks.

Ich setze sowohl Video-Track- als auch Movie-Timescale auf 90.000. Noch wichtiger ist, dass der Validator die erwartete Tick-Struktur als Invariante behandelt und nicht nur gerundete Dezimaldauern prüft.

Geometrie wird vor dem Encoding normalisiert, niemals hochskaliert

Auch geometrisch sind die Quellen uneinheitlich: Landscape, Portrait, quadratisch oder sehr klein.

Source-KlasseMaximaler Arbeitsbereich
Landscape1280×720
Portrait720×1280
Gemischte Orientierung in einer finalen Sequenzungefähr 960×960 Canvas

Die harte Regel lautet: kein Upscaling. Ist die Quelle kleiner, bleibt sie kleiner. Zusätzliche interpolierte Pixel enthalten keine neue Information und kosten trotzdem Encoding- und Delivery-Budget.

Wenn unterschiedliche Seitenverhältnisse in eine gemeinsame finale Datei müssen, passe ich den Inhalt in einen gemeinsamen Canvas ein und fülle ungenutzte Fläche auf, statt das Bild zu verzerren. In meiner Pipeline ist der Hintergrund schwarz.

Damit ist auch Alpha explizit behandelt. H.264/yuv420p trägt in dieser Pipeline keinen Alpha-Kanal; Transparenz wird bewusst gegen den gewählten Hintergrund geflattet.

Warum H.264 MP4 für diesen Workload meist billiger auszuliefern ist

Der strukturelle Vorteil ist zeitliche Kompression. GIF kann Teilbilder speichern, APNG muss nicht immer den ganzen Canvas neu schreiben, und Animated WebP ist deutlich ausgefeilter als GIF. Es wäre deshalb falsch zu behaupten, dass alle Animationsformate jedes vollständige Bild separat speichern.

H.264 ist jedoch als Videocodec für Inter-Prediction ausgelegt: Referenzbilder, Bewegungsinformation, P- und B-Frames. Das passt hervorragend zu meinen kurzen illustrierten Clips, bei denen große Flächen statisch bleiben und sich zwischen sichtbaren Zuständen nur kleine Bereiche bewegen.

Bei GIF und APNG kann der Unterschied sehr groß sein. Apple empfiehlt für statisches Webvideo H.264-MP4 und rät explizit zu MP4 statt Animated GIF; laut Safari-Dokumentation können GIFs gegenüber modernen Videocodecs bis zu 12-mal mehr Bandbreite und etwa doppelt so viel Energie benötigen.

Diese 12× sind kein Versprechen meiner Pipeline, sondern ein Beispiel aus Apples Dokumentation.

Bei Animated WebP ist die Aussage vorsichtiger: lossy WebP ist bereits komprimiert. H.264 ist dann eine zweite verlustbehaftete Generation. Bei hoher zeitlicher Redundanz kann das Ergebnis trotzdem deutlich kleiner sein, aber die visuelle Qualität muss geprüft werden.

Das H.264-Profil ist absichtlich langweilig

EinstellungWert
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / LevelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOPetwa 5 Sekunden
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datakeine

Main@3.1 ist mein konservativer Single-File-Kompatibilitätsrahmen, nicht die maximale Fähigkeit moderner Geräte. Android verlangt Main-Profile-Decoding seit Android 6.0 und führt 1280×720 bei 30 fps als H.264-HD-Empfehlung. Apple empfiehlt in seiner aktuellen Safari-Dokumentation H.264-MP4 für statische Webdateien.

CPU zahle ich einmal, Bandbreite bei jedem Abruf

-preset veryslow

Die einmalige Encoding-Latenz ist nicht mein Engpass. Deshalb darf x264 sehr viel Arbeit investieren. Der Browser wiederholt diese Suche nicht; Decoder-Komplexität begrenze ich separat über Profil, Level, Referenzen und B-Frame-Struktur.

Auch CRF 28 ist workload-spezifisch: mein Material ist überwiegend illustriert und häufig bereits verlustbehaftet, während die Priorität stark auf Bandbreite liegt. Für Film, starkes Grain oder Archivmaster würde ich die Zahl nicht blind übernehmen.

Jede Animation wird ein unabhängig encodiertes Segment

Zu diesem Zeitpunkt stehen sichtbare Frames, ausgewählter CFR, Geometrie und erwartete Dauer bereits fest. Der zentrale Codec-Teil sieht vereinfacht so aus:

ffmpeg -framerate "$SELECTED_FPS" -i frame-%06d.png \
  -c:v libx264 \
  -preset veryslow \
  -tune animation \
  -crf 28 \
  -profile:v main \
  -level:v 3.1 \
  -pix_fmt yuv420p \
  -tag:v avc1 \
  -refs 4 \
  -bf 5 \
  -g "$GOP_FRAMES" \
  -maxrate:v 4M \
  -bufsize:v 8M \
  -x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
  -color_range tv \
  -color_primaries bt709 \
  -color_trc bt709 \
  -colorspace bt709 \
  -fps_mode passthrough \
  -map_metadata -1 \
  -map_chapters -1 \
  -an -sn -dn \
  -video_track_timescale 90000 \
  -movie_timescale 90000 \
  -t "$EXPECTED_DURATION" \
  segment.mp4

Die eigentliche Pipeline erzeugt die timeline-aware Frame-Sequenz und berechnet Resize/Pad schon vorher. Der GOP wird ungefähr auf fünf Sekunden gesetzt:

gopFrames = selectedFps * 5

Damit ergeben sich 80 bei 16 fps, 120 bei 24 fps und 150 bei 30 fps.

Concat funktioniert nur mit einem strikten Segmentvertrag

FFmpegs Concat-Demuxer verlangt kompatible Streams, einschließlich Codec und Time Base. Außerdem bestimmt die Dauer eines Files den Start des nächsten; falsche Dauerinformationen können daher Timeline-Artefakte verursachen.

Kompatibilität wird deshalb nicht erst beim Concat hergestellt. Jedes akzeptierte Segment teilt Codec-Familie, Profile/Level-Rahmen, Pixel-Format, Stream-Layout, Canvas-Geometrie der finalen Sequenz, Color-Signaling und die 90-kHz-Time-Base. Unerwartete Audio- oder Subtitle-Streams existieren nicht. In diesem Workflow verwende ich außerdem stitchable=1.

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  -an -sn -dn \
  -movflags +faststart \
  final.mp4

-c:v copy ist entscheidend: Die bereits verlustbehafteten Segmente werden nicht nochmals dekodiert und neu komprimiert.

Stream Copy schützt die Bilddaten, aber nicht automatisch meine Tick-Invariante

Bei 90 kHz muss ein 24-fps-Frame exakt so lang sein:

90000 / 24 = 3750 ticks

Mein Validator fand in einem realen zusammengefügten Output trotzdem einmal 3751 Ticks.

Visuell hätte ich das ignorieren können. Stattdessen blieb die Regel streng: Wenn alle erlaubten Frameraten auf einer exakt teilbaren 90-kHz-Skala liegen, ist ein Off-Grid-Paket ein Fehler, den ich verstehen will.

Die Erkenntnis: Stream Copy erhält den komprimierten H.264-Payload, garantiert aber nicht automatisch meine gewünschte exakte Packet-Timeline nach Timestamp-Anpassungen an Segmentgrenzen.

Der finale Concat-Schritt nutzt deshalb FFmpegs setts-Bitstream-Filter, um PTS, DTS und Packet-Dauer auf die erwartete 90-kHz-Struktur zu normalisieren, während -c:v copy erhalten bleibt. Die konkrete Expression wird in meiner Implementierung aus Segment-Timeline und FPS erzeugt und ist deshalb kein sinnvoller Copy-paste-Einzeiler.

Validierung ist Bestandteil des Encoders

Ein FFmpeg-Exitcode 0 reicht mir nicht. Vor der Veröffentlichung prüfe ich Stream-Anzahl, H.264-Profil/Level, Pixel-Format, Dimensionen, Time Base, Frame- und Packet-Zahl, exakte Packet-Dauern, PTS/DTS-Struktur, Gesamt- und Segmentdauer und schließlich den vollständigen Decode.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4

Danach folgt ein kompletter Decode-Pass:

ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Erst danach wird die Datei atomar veröffentlicht.

Welche Fehler die Pipeline verhindern soll

  • Raw-Subframes statt sichtbarer Canvas-Zustände encodieren.
  • Eine erratene FPS-Zahl statt der echten Delays verwenden.
  • Alles unnötig auf 30 fps zwingen.
  • Kleine Quellen hochskalieren.
  • Unterschiedliche Seitenverhältnisse verzerren.
  • Nach dem Concat erneut verlustbehaftet encodieren.
  • -c copy mit korrektem Timing gleichsetzen.
  • Nur Header prüfen und den vollständigen Decode auslassen.

Invarianten sind für mich hier wertvoller als noch mehr Encoder-Flags.

Was ich bewusst nicht erhalte

Alpha wird geflattet. Beliebiges VFR wird pro Quelle durch einen geeigneten CFR angenähert. Sehr hohe Auflösung wird reduziert. Lossy WebP erhält einen weiteren lossy Encode. Audio gehört nicht zu diesem Problem.

Das sind keine versteckten Einschränkungen, sondern Teil des Vertrages.

Wann ich diesen Ansatz nicht verwenden würde

Nicht bei zwingend erhaltener Transparenz, semantisch wichtigen unregelmäßigen Frame-Dauern, Archivmastern oder einem bereits vorhandenen adaptiven Multi-Codec-Videostack. Bei einem ohnehin winzigen, sehr gut optimierten Animated WebP würde ich messen statt pauschal eine Einsparung anzunehmen.

Die praktische Pipeline heute

  1. Animation erkennen und echte Frame-Control-Metadaten lesen.
  2. Vollständige sichtbare Canvas-Zustände mit korrektem Blend/Disposal dekodieren.
  3. Frame-Dauern rekonstruieren und bereinigen.
  4. Millisekundengenaue Source-Timeline erstellen.
  5. Pro Source den kleinsten passenden CFR aus 10/12/15/16/18/20/24/25/30 wählen.
  6. Canvas-Geometrie ohne Upscaling berechnen und bei Bedarf pad/flatten anwenden.
  7. Ein H.264 Main@3.1/yuv420p/avc1-Segment mit veryslow und CRF 28 encodieren.
  8. 90-kHz-Time-Base verwenden.
  9. Jedes Segment gegen den Streamvertrag validieren.
  10. Segmente mit -c:v copy zusammenfügen.
  11. Finale Packet-Timeline auf die exakte Tick-Struktur normalisieren.
  12. Streams und Packets prüfen und die gesamte Datei decodieren.
  13. Nur die validierte Datei veröffentlichen.

Die tiefere Erkenntnis: Die Timeline ist das eigentliche Asset

Animated WebP, GIF oder APNG sind nicht bloß „viele Bilder“. Sie sind eine zeitlich definierte Folge sichtbarer Canvas-Zustände.

Seit ich diese Folge als Source of Truth behandle, ist die Architektur klarer: günstige Framerate ohne erfundene Bewegung, kleinere Geometrie ohne erfundene Pixel, unabhängige Segmente ohne globale FPS und Concat ohne weitere Qualitätsgeneration.

H.264 macht die Dateien klein und MP4 leicht auslieferbar. Beides hilft aber nicht, wenn die Animation bereits beim Extrahieren der Frames oder beim Raten des Timings beschädigt wurde.

Der Codec war der einfache Teil. Die eigentliche Arbeit bestand darin, die Bedeutung der Animation zu bewahren, während ihre Repräsentation vollständig verändert wurde.

Primärdokumentation