Retour au blog
13 août 2026Sergei Solod12 min de lecture

Comment je transforme Animated WebP, GIF et APNG en petits MP4 H.264 fiables dans les navigateurs

Mon pipeline de production reconstruit les frames réellement affichés et leur timing, choisit un CFR par source, évite l'upscale, encode des segments H.264 conservateurs, les concatène sans second encodage lossy et valide la timeline exacte des packets avant publication.

H.264FFmpegMP4Animated WebPGIFAPNGCompression vidéoPipeline média

Je n'ai pas construit ce pipeline pour jouer avec des codecs. Je l'ai construit parce que les images animées devenaient une manière coûteuse de livrer ce qui, dans les faits, ressemblait déjà à une courte vidéo silencieuse.

Mon workload est surtout composé de WebP animés, GIF et APNG de quelques secondes, généralement quelques dizaines d'images affichées et beaucoup de redondance temporelle. Le trafic est majoritairement mobile, les mêmes assets peuvent être servis encore et encore, et le temps d'encodage initial compte beaucoup moins que le volume de données envoyé à chaque lecture.

La version simplifiée du problème est « convertir l'animation en MP4 ». En production, c'est bien plus compliqué. Une image animée n'est pas forcément une suite propre d'images complètes avec un seul FPS. Elle peut contenir des rectangles partiels, des règles de disposal, de l'alpha blending, des délais irréguliers, des durées nulles ambiguës, plusieurs orientations et des métadonnées de timing qu'un outil générique résume mal.

Mon pipeline n'est donc pas « lancer FFmpeg sur un WebP ». C'est une chaîne d'invariants :

animated WebP / GIF / APNG
        ↓
décoder et reconstruire les états réellement affichés du canvas
        ↓
reconstruire la timeline source
        ↓
choisir un CFR par source
        ↓
normaliser la géométrie sans upscale
        ↓
encoder un segment H.264 conservateur
        ↓
répéter indépendamment pour chaque source
        ↓
concaténer les segments compatibles en stream copy
        ↓
normaliser la timeline finale des packets
        ↓
probe + contrôle des packets + décodage complet
        ↓
publication atomique

Le codec n'est qu'une partie du problème. Le travail difficile consiste à préserver ce que l'animation affichait réellement tout en changeant complètement sa représentation.

Première règle : extraire les images affichées, pas les rectangles stockés

La simplification la plus dangereuse consiste à supposer que chaque frame stocké est une image complète qui remplace la précédente.

Ce n'est pas une obligation de ces formats. Un frame WebP animé possède une position, une taille de rectangle, un mode de blend et un mode de disposal. APNG possède des offsets, dimensions et opérations de blend/dispose. GIF peut demander de conserver le contenu existant, de restaurer la zone au fond ou de remettre le canvas dans son état précédent.

Un frame physique peut donc n'être qu'un petit patch dépendant du canvas déjà construit. Si je l'encode comme une image autonome, je n'obtiens pas la même animation en plus petit : j'obtiens une animation fausse.

Ma frontière d'extraction est donc l'état complet du canvas réellement visible. À chaque étape, je veux les pixels déjà composités qu'un viewer correct afficherait après le disposal du frame précédent et le blending du frame courant.

Ce détail de décodage est en réalité la première garantie de correction du pipeline. Une mauvaise composition déjà encodée en H.264 ne peut plus être corrigée ensuite.

Le timing est une donnée source, pas un FPS à deviner

Les formats stockent le temps différemment :

  • Animated WebP : durée par frame en unités de 1 ms.
  • GIF : délai en centièmes de seconde.
  • APNG : numérateur et dénominateur par délai; un dénominateur nul est traité comme 100 par la spécification PNG.

Ces délais constituent la timeline. Une valeur FPS moyenne n'est qu'un résumé.

Un WebP réel m'a obligé à prendre cette distinction au sérieux : 1264×720, 49 frames affichés, des durées alternant entre 62 et 63 ms, pour 3,063 secondes au total. C'est pratiquement une cadence de 16 fps, soit 62,5 ms par frame.

Un probe indiquait pourtant 25 fps.

Si j'avais cru cette valeur, j'aurais modifié le rythme de l'animation ou ajouté des frames répétés inutilement. Les durées source étaient la vérité.

Je normalise les mauvais timings avant de choisir le CFR

WebP précise qu'une durée nulle, et souvent de très petites durées comme 10 ms ou moins, peuvent être interprétées différemment selon l'implémentation. GIF autorise également un délai nul. APNG autorise un numérateur nul pour passer au frame suivant aussi vite que possible.

Ma politique conserve une précision en millisecondes, impose un minimum de 10 ms et n'utilise 100 ms qu'en fallback quand aucun timing exploitable n'est disponible. Ces valeurs ne sont pas universelles. Ce qui compte est de nettoyer le timing avant de le quantifier en framerate et de ne pas écraser les durées valides par un défaut arbitraire.

Pourquoi je ne force pas toutes les animations à 30 fps

Une animation qui change réellement d'état environ 16 fois par seconde n'a pas besoin d'être stockée à 30 fps. H.264 compresse bien les répétitions, mais des samples temporels inutiles restent inutiles.

Je sélectionne donc un CFR propre à chaque source dans cette liste :

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

Le sélecteur part de la timeline source et prend le candidat le plus bas qui représente proprement les changements visibles. 30 fps reste le fallback.

Je ne publie pas un seuil d'erreur magique : cette tolérance dépend du contenu. La règle générale est plus importante : partir des temps d'affichage réels, puis choisir le CFR le moins coûteux qui les représente correctement.

Pour 62/63 ms, 16 fps est naturel car un frame dure exactement 62,5 ms.

Chaque source peut avoir son propre CFR

Un MP4 final peut réunir plusieurs animations traitées indépendamment. Je ne les réencode pas toutes à une fréquence globale uniquement pour faciliter concat.

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 → un MP4

Chaque segment est CFR, tandis que l'ensemble est de fait CFR par morceaux et variable au niveau global. Il faut seulement que les streams soient compatibles et partagent une time base capable de représenter toutes les cadences exactement.

Pourquoi j'utilise une base de temps de 90 kHz

La timescale vidéo est de 90 000 Hz, car chaque FPS autorisé divise exactement 90 000 :

FPSDurée exacte à 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

À 24 fps, un frame vaut exactement 3750 ticks. À 16 fps, 5625. Les limites de segments n'ont pas besoin de fractions de ticks vidéo.

Je définis le video track timescale et le movie timescale à 90 000 et le validator traite cette grille comme un invariant.

Je normalise la géométrie sans jamais agrandir

ClasseEnveloppe maximale
Landscape1280×720
Portrait720×1280
Séquence finale à orientations mixtescanvas d'environ 960×960

La règle absolue est pas d'upscale. Si une source est plus petite, je ne crée pas de pixels interpolés qui n'ajoutent aucune information.

Quand plusieurs ratios doivent partager le même stream final, j'inscris chaque image dans un canvas commun et j'ajoute du padding au lieu de la déformer. Mon fond est noir.

Le canal alpha est donc également une décision explicite : H.264/yuv420p ne le transporte pas dans ce pipeline, la transparence est aplatie sur le fond choisi.

Pourquoi H.264 MP4 est souvent moins cher à livrer

GIF, APNG et WebP animé ont déjà des mécanismes pour éviter de stocker naïvement chaque canvas complet. Mais H.264 est un codec vidéo conçu autour de la prédiction inter-images, des images de référence, P-frames et B-frames.

Cela correspond très bien à mes clips illustrés : de grandes zones restent statiques et seules de petites régions changent entre deux états affichés.

Apple recommande aujourd'hui H.264 MP4 pour les fichiers vidéo web statiques et conseille de remplacer les GIF animés par du MP4; sa documentation Safari indique qu'un GIF peut coûter jusqu'à 12 fois plus de bande passante et environ deux fois plus d'énergie qu'un codec vidéo moderne. Je n'utilise pas ce 12× comme résultat de mon propre benchmark.

Avec un WebP animé lossy, il faut être plus prudent : la conversion H.264 constitue une seconde génération lossy. Le fichier peut être beaucoup plus petit sur un contenu très redondant, mais la qualité doit être vérifiée.

Le profil H.264 est volontairement banal

ParamètreValeur
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 GOPenviron 5 secondes
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/dataaucun

Main@3.1 est mon enveloppe conservatrice pour un fichier universel, pas la limite des téléphones modernes. Android exige le décodage Main Profile depuis Android 6.0 et documente 1280×720 à 30 fps comme recommandation H.264 HD. Apple recommande H.264 MP4 pour les fichiers web statiques.

Je dépense du CPU une fois pour éviter de dépenser de la bande passante toujours

-preset veryslow

L'encodage se fait une fois, la livraison se répète. Je laisse donc x264 chercher longtemps une meilleure représentation, tout en limitant séparément la complexité du decoder. CRF 28 reste spécifique à mon contenu illustré et bandwidth-first.

Chaque animation devient un segment indépendant

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

La séquence de frames et le resize/pad ont déjà été construits avant cette commande. Le GOP vaut environ cinq secondes :

gopFrames = selectedFps * 5

16 fps donne 80, 24 fps 120, 30 fps 150.

Concat ne fonctionne que grâce à un contrat de segment strict

Le concat demuxer de FFmpeg exige des streams compatibles et utilise la durée de chaque fichier pour positionner le suivant. La compatibilité doit donc être établie avant concat : même famille de codec, profile/level, pixel format, stream layout, canvas, signalisation couleur et time base de 90 kHz.

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

-c:v copy évite une seconde compression H.264 lossy.

Stream copy n'a pas garanti à lui seul ma grille temporelle

À 90 kHz et 24 fps, la durée attendue est :

90000 / 24 = 3750 ticks

Mon validator a pourtant trouvé un vrai output concaténé avec un packet de 3751 ticks.

Le fichier pouvait paraître normal, mais l'invariant était cassé. La leçon est que préserver le payload H.264 ne garantit pas que les timestamps remuxés restent exactement sur la grille voulue après les ajustements aux frontières de segments.

Le concat final utilise maintenant le bitstream filter setts pour normaliser PTS, DTS et durée des packets tout en gardant -c:v copy. L'expression exacte dépend de la timeline et du FPS connus de chaque segment, donc je ne la présente pas comme une recette universelle.

La validation fait partie de l'encodeur

Un exit code 0 de FFmpeg ne suffit pas. Je vérifie streams, codec/profile/level, pixel format, dimensions, time base, nombre de frames et packets, durées exactes, structure PTS/DTS, durée finale, puis je décode tout le fichier.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4
ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Le fichier n'est publié atomiquement qu'après ces contrôles.

Les erreurs que ce pipeline cherche à éliminer

  • Encoder des subframes au lieu des canvas affichés.
  • Faire confiance à un FPS estimé.
  • Forcer inutilement 30 fps.
  • Agrandir les petites sources.
  • Déformer les ratios.
  • Réencoder après concat.
  • Confondre -c copy et timing correct.
  • Vérifier seulement les headers sans décodage complet.

Ce que je choisis de ne pas préserver

L'alpha est aplati. Le VFR arbitraire est approximé par un CFR propre à chaque source. Les très hautes résolutions sont réduites. Un WebP déjà lossy subit une génération lossy supplémentaire. Il n'y a pas d'audio. Ce sont des compromis explicites du design.

Quand je n'utiliserais pas cette approche

Pas si la transparence doit rester compositable, si un timing irrégulier exact est essentiel, pour un master d'archive, ou si un stack vidéo adaptatif multi-codec existe déjà. Pour un WebP animé déjà minuscule, je mesurerais le gain au lieu de le supposer.

Le pipeline pratique actuel

  1. Lire les vraies métadonnées d'animation.
  2. Reconstruire les canvas visibles avec blend/disposal corrects.
  3. Nettoyer les durées et créer la timeline en ms.
  4. Choisir le plus petit CFR acceptable parmi 10/12/15/16/18/20/24/25/30.
  5. Calculer la géométrie sans upscale et appliquer canvas/padding/alpha flatten.
  6. Encoder H.264 Main@3.1, yuv420p, avc1 avec veryslow et CRF 28.
  7. Utiliser une time base de 90 kHz.
  8. Valider chaque segment.
  9. Concaténer avec -c:v copy.
  10. Normaliser la timeline finale sur la grille exacte.
  11. Inspecter les packets et décoder entièrement.
  12. Publier uniquement le fichier validé.

La leçon profonde : l'asset réel est la timeline

Animated WebP, GIF et APNG ne sont pas simplement « beaucoup d'images ». Ce sont des séquences temporelles d'états du canvas.

Une fois cette séquence traitée comme source de vérité, tout devient plus clair : cadence moins coûteuse sans inventer de mouvement, résolution plus faible sans inventer de pixels, CFR indépendant par source et concat sans nouvelle génération de perte.

H.264 réduit les fichiers et MP4 les rend faciles à distribuer. Mais aucun des deux ne sert si l'animation a déjà été détruite lors de l'extraction des frames ou du calcul du timing.

Le codec était la partie facile. Le vrai travail était de conserver le sens de l'animation en changeant sa représentation.

Documentation primaire