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

Comment je convertis des WebP animés, GIF et APNG en H.264 MP4 sans altérer les images ni la temporisation

Ma chaîne de production reconstruit les images complètes réellement affichées, préserve la temporisation de la source, choisit un seul CFR pour chaque MP4 final, calcule le plus petit canevas commun sans agrandissement, encode des segments H.264 compatibles, les concatène sans seconde compression avec pertes et valide à la fois le fichier et sa diffusion HTTP. Lors d’une exécution mesurée, 217 WebP animés totalisant 1,49 Go sont devenus un unique MP4 H.264 de 78,49 Mo.

H.264FFmpegMP4WebP animéGIFAPNGCompression vidéoTraitement multimédia

Je n’ai pas construit cette chaîne pour expérimenter avec des codecs. Je l’ai construite parce que les images animées étaient devenues une manière coûteuse de distribuer ce qui se comportait déjà, en pratique, comme une courte vidéo muette.

Mon contenu est surtout constitué de courts WebP animé, GIF et APNG : quelques secondes, souvent seulement quelques dizaines d’images affichées et beaucoup de redondance temporelle. La majorité des consultations se fait sur mobile, les mêmes fichiers peuvent être demandés de nombreuses fois, et le coût unique de l’encodage compte beaucoup moins que les octets servis ensuite à chaque lecture.

La difficulté n’est pas d’appeler FFmpeg. Une image animée n’est pas nécessairement une pile propre d’images complètes à fréquence régulière. Elle peut contenir des rectangles partiels, des règles de composition et d’effacement, de l’alpha, des délais irréguliers, des images de durée nulle, des orientations différentes et des métadonnées temporelles qu’un outil générique peut résumer de façon trompeuse.

Je traite donc la conversion comme un ensemble d’invariants plutôt que comme une commande unique :

WebP animé / GIF / APNG
        ↓
reconstruire les états complets réellement affichés
        ↓
récupérer et normaliser la temporisation source
        ↓
analyser toutes les sources de la séquence finale
        ↓
choisir un seul CFR pour le MP4 final
        ↓
calculer le plus petit canevas commun sans agrandissement
        ↓
encoder des segments H.264 compatibles
        ↓
vérifier le contrat de flux
        ↓
concaténer par copie de flux
        ↓
normaliser et valider la ligne temporelle des paquets
        ↓
valider la diffusion HTTP
        ↓
publier de façon atomique

Le codec compte, mais préserver exactement ce que l’animation affichait compte davantage.

Un résultat mesuré en production : 217 WebP animés sont devenus un MP4 de 78,49 Mo

L’entrée n’était pas une seule vidéo de 1,49 Go. Il s’agissait de 217 fichiers WebP animés distincts contenant 10 633 images affichées. Ensemble, ces animations sources occupaient environ 1,49 Go.

ENTRÉE
217 fichiers WebP animés
1,49 Go au total
10 633 images affichées

SORTIE
1 MP4 H.264
78,49 Mo
0,98 Mbit/s
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow

Le fichier H.264 obtenu pesait 78,49 Mo, avec un débit moyen d’environ 0,98 Mbit/s. Par rapport au volume cumulé des animations sources, cela représente environ 19 fois moins, soit près de 94,7% de données en moins.

C’est un résultat réel de bout en bout, pas un test A/B propre «ancien H.264 contre nouveau H.264 ». La représentation passe de centaines de fichiers d’images animées à une seule vidéo compressée temporellement ; je n’attribue donc pas les 19× à CRF 28, veryslow ou à une option unique de l’encodeur.

Je reconstruis d’abord les images que le spectateur voit réellement

Le raccourci le plus dangereux consiste à supposer que chaque image stockée dans l’animation est une image complète qui remplace la précédente.

Une image d’un WebP animé peut décrire un rectangle positionné avec des règles de composition et d’effacement. APNG possède des décalages, des dimensions, une durée ainsi que des opérations de composition et d’effacement. GIF peut lui aussi conserver le canevas précédent, effacer une zone ou restaurer un état antérieur.

Une image stockée peut donc n’être qu’un fragment dont le sens dépend du canevas produit par les images précédentes. Encoder ces fragments comme des images complètes ne donne pas la même animation en plus petit : cela donne une animation erronée.

Ma frontière d’extraction est l’état complet du canevas affiché : les pixels déjà composés qu’un lecteur correct montrerait après l’effacement de l’image précédente et la composition de l’image courante.

C’est la première garantie de correction de toute la chaîne. Une fois qu’un fragment mal reconstruit a été aplati dans H.264, aucun CRF, préréglage ou paramètre de conteneur ne peut le réparer.

La durée d’une image est une donnée source, pas un FPS à deviner

Les formats d’animation enregistrent le temps différemment. WebP animé utilise une durée par image en unités de 1 ms. GIF stocke les délais en centièmes de seconde. APNG utilise un numérateur et un dénominateur pour chaque délai ; si le dénominateur vaut zéro, la spécification PNG demande de le traiter comme 100.

Ces délais constituent la ligne temporelle. Une valeur FPS fournie par un outil générique n’est qu’un résumé et peut être trompeuse.

Un WebP réel de ma chaîne mesurait 1264×720 et contenait 49 images affichées. Ses délais alternaient entre 62 et 63 ms, pour une durée totale de 3,063 secondes. Cela correspond pratiquement à 16 fps, puisqu’une image à 16 fps dure 62,5 ms.

Un outil générique indiquait pourtant 25 fps pour cette source. Si j’avais fait confiance à ce chiffre, j’aurais modifié la temporisation d’origine ou créé des répétitions inutiles.

Il faut aussi une règle explicite pour les délais invalides ou ambigus. WebP laisse à l’implémentation l’interprétation d’une durée nulle et souvent des durées très faibles. GIF peut contenir un délai nul. APNG autorise un numérateur nul, ce qui signifie que l’image suivante doit être rendue aussi vite que possible, même si le lecteur peut imposer une limite minimale pratique.

Ma politique conserve la précision à la milliseconde, applique un petit minimum de 10 ms aux durées nulles ou manifestement trop courtes et utilise 100 ms uniquement comme valeur de secours quand aucune durée utile n’est réellement disponible. Ce sont des choix d’ingénierie, pas des normes universelles.

Je choisis un seul CFR pour tout le MP4 final au lieu d’imposer 30 fps

Après avoir reconstruit les états affichés et leurs durées, je projette la ligne temporelle source sur une ligne temporelle vidéo. Je n’encode pas tout aveuglément à 30 fps.

Pour toutes les sources destinées au même MP4 final, j’évalue ce petit ensemble de candidats :

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

Le sélecteur choisit le CFR le plus bas qui représente correctement l’ensemble de la séquence. Deux MP4 finaux peuvent utiliser des fréquences différentes, mais tous les segments encodés séparément à l’intérieur d’un même MP4 utilisent exactement le même CFR.

L’exemple de 3,063 secondes rend l’économie évidente : à 16 fps il faut environ 49 images de sortie ; à 30 fps, environ 92. Si une autre source du même MP4 nécessite réellement 30 fps, toute la collection utilise 30. Je ne mélange pas plusieurs fréquences d’image dans un même flux final.

J’utilise une échelle temporelle de 90 000 Hz pour la piste vidéo, car chaque CFR autorisé produit une durée entière :

10 fps → 9000 unités
12 fps → 7500 unités
15 fps → 6000 unités
16 fps → 5625 unités
18 fps → 5000 unités
20 fps → 4500 unités
24 fps → 3750 unités
25 fps → 3600 unités
30 fps → 3000 unités

C’est l’échelle de la piste vidéo qui définit cette grille exacte. Je règle aussi l’échelle temporelle générale du MP4 à 90 000 par cohérence, mais il s’agit d’une horloge distincte du conteneur. Le validateur compare les paquets vidéo à la grille entière exacte au lieu de se fier à des durées décimales arrondies.

Les limites de résolution sont des plafonds, pas des canevas obligatoires

Mon enveloppe de diffusion est d’environ 1280×720 en paysage, 720×1280 en portrait et au maximum 960 pixels de large comme de haut pour une séquence d’orientations mixtes.

La règle non négociable est : ne jamais agrandir. Une source 900×600 ne gagne aucun détail en devenant 1280×720 ; elle crée seulement des pixels interpolés que l’encodeur devra décrire.

La seconde règle est moins évidente : 960×960 est une enveloppe, pas un canevas carré obligatoire.

Je calcule d’abord, pour chaque source, les dimensions actives en n’autorisant que la réduction. Ensuite, la séquence finale reçoit le plus petit canevas commun de dimensions paires capable de contenir tous ces rectangles déjà réduits.

Par exemple, si la séquence exige une image paysage de 960×540 et une image portrait de 500×900, le canevas commun peut être 960×900 plutôt que 960×960. Tous les segments gardent les mêmes dimensions codées, la concaténation par copie reste donc possible, sans coder inutilement une zone noire supplémentaire.

Je conserve le rapport d’aspect et je complète l’espace libre plutôt que d’étirer l’image. Dans ma chaîne, le fond est noir. Comme H.264/yuv420p ordinaire ne conserve pas le canal alpha d’origine, la transparence est volontairement composée sur ce fond.

Pourquoi H.264 MP4 convient bien à ce problème de diffusion

GIF, APNG et WebP animé ne sont pas des formats primitifs. Ils peuvent eux aussi éviter de redessiner les zones inchangées ; dire que «la vidéo est toujours plus petite » serait faux.

H.264 est cependant conçu pour la prédiction temporelle entre images. Les courtes boucles illustrées avec fond statique et petites zones mobiles sont un cas favorable pour les images de référence, la prédiction inter-image et les images P et B.

La documentation Safari actuelle d’Apple recommande les MP4 encodés en H.264 pour la vidéo statique et indique que les GIF animés peuvent coûter jusqu’à douze fois plus en bande passante et environ deux fois plus en énergie qu’un codec vidéo moderne. Ces 12× sont l’exemple d’Apple, pas ma mesure.

Mon résultat mesuré de 19× est donc une observation de production utile, mais je continue à mesurer plutôt qu’à supposer que tout WebP animé déjà minuscule perdra face au MP4.

Chaque animation est encodée en segment sous un contrat de flux unique

Quand l’encodeur démarre, la chaîne connaît déjà les images affichées, le CFR commun, le canevas final et la durée attendue.

La partie centrale de ma commande de segment ressemble à ceci :

ffmpeg -framerate "$COLLECTION_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

Le GOP maximal est d’environ cinq secondes et dépend du CFR choisi : 80 images à 16 fps, 120 à 24 fps et 150 à 30 fps.

La garde -t "$EXPECTED_DURATION" n’est pas décorative. Dans ma liste d’images, je répète la dernière comme sentinelle afin que la durée de la dernière image réelle soit prise en compte. Sans limite de durée explicite, cette sentinelle peut devenir une image finale supplémentaire.

Je l’ai reproduit sur le cas de 49 images et 3,063 secondes. Sans -t, j’obtenais 50 images à 16 fps et 94 à 30 fps. Avec -t 3.063, j’obtenais les 49 prévues à 16 fps et 92 à 30 fps.

La concaténation par copie n’est sûre qu’après une vérification stricte de compatibilité

Le démultiplexeur concat de FFmpeg attend les mêmes flux, notamment le même codec et la même base temporelle, et utilise la durée de chaque fichier pour positionner le suivant. Une durée incorrecte peut donc créer des défauts dans la ligne temporelle.

Je n’utilise pas la concaténation pour rendre compatibles des fichiers incompatibles. Chaque segment doit déjà respecter le contrat avant d’être accepté :

CFR de la collection = identique
base temporelle du flux = identique
échelle temporelle de la piste MP4 = identique
dimensions du canevas / SAR = identiques
profil / niveau / format de pixel = identiques
signalisation colorimétrique = identique
avcC / données supplémentaires AVC = identiques octet par octet

J’utilise stitchable=1 de x264 parce que les segments sont encodés indépendamment, mais je ne considère pas ce réglage comme une preuve que la configuration AVC est identique. Je compare encore les vrais octets de configuration avant la concaténation.

Une fois le contrat respecté, l’assemblage final ne nécessite aucune seconde compression :

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

-c:v copy évite de décoder et de recompresser les segments H.264 déjà terminés.

La validation couvre le MP4 et la manière dont il est distribué

Je ne publie pas un fichier simplement parce que FFmpeg s’est terminé avec le code 0.

Le validateur a détecté de vraies erreurs temporelles déterministes : à 16 fps, j’ai observé 5580 unités alors que le contrat en exigeait 5625 ; plus tard, une sortie à 24 fps contenait 3751 au lieu de 3750 exactement. L’enquête détaillée sur cet écart d’une unité est un autre sujet ; la leçon ici est qu’une répétition identique ne répare pas une erreur temporelle déterministe.

Pour l’objet multimédia, je contrôle le nombre de flux attendu, le profil et le niveau H.264, le format de pixel, les dimensions exactes planifiées, le SAR, la signalisation colorimétrique, l’échelle temporelle de la piste vidéo à 90 kHz, les durées de paquets, les nombres d’images et de paquets, la durée totale, les relations PTS/DTS, la configuration AVC identique des segments, la présence de moov avant mdat et un décodage complet avec gestion stricte des erreurs.

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 -

Mais un MP4 local correct peut encore être mal distribué sur le réseau. Je contrôle donc aussi le chemin HTTP : Content-Type attendu, Content-Length correct, prise en charge des plages d’octets, réponse valide 206 Partial Content et Content-Range correct.

Quand je modifie le contrat de l’encodeur, j’effectue aussi un petit essai sur de vrais appareils et navigateurs au lieu de supposer que ffprobe prouve la compatibilité matérielle. Je vérifie démarrage, déplacement dans la vidéo, boucle, passage en arrière-plan et reprise, ainsi que lecture par plages sur un iPhone récent avec Safari, un appareil Android modeste et les principaux navigateurs de bureau.

Je ne remplace l’élément de production de façon atomique qu’après validation du fichier et de sa voie de diffusion.

Les informations que cette chaîne perd volontairement

Il s’agit d’une transformation de diffusion, pas d’un master d’archive. L’alpha est aplati. La temporisation irrégulière est quantifiée sur un seul CFR pour le MP4 final. Les grandes sources peuvent être réduites. Un WebP animé déjà avec pertes subit une nouvelle génération avec pertes. L’audio est volontairement absent.

Je n’utiliserais pas exactement cette chaîne si la transparence devait rester composable sur n’importe quel fond, si la temporisation irrégulière exacte était elle-même significative, si je créais une source d’archive ou si l’application disposait déjà d’une pile vidéo adaptative multicodec qui résout autrement la distribution.

Je mesure également les WebP animés minuscules et déjà très optimisés au lieu de supposer que le MP4 doit nécessairement gagner.

La séquence de production que j’utilise aujourd’hui

  1. Détecter le format animé et lire les vraies métadonnées de contrôle des images.
  2. Reconstruire les états complets affichés selon les règles de composition et d’effacement.
  3. Récupérer et normaliser la durée de chaque image.
  4. Construire la ligne temporelle source de référence avec une précision à la milliseconde.
  5. Analyser toutes les sources destinées au même MP4 final.
  6. Choisir un CFR commun parmi 10/12/15/16/18/20/24/25/30.
  7. Projeter les états affichés sur cette ligne temporelle CFR.
  8. Calculer les dimensions actives uniquement par réduction ; ne jamais agrandir.
  9. Construire le plus petit canevas commun de dimensions paires nécessaire.
  10. Compléter sans étirer et composer volontairement l’alpha.
  11. Encoder chaque source en H.264 Main@3.1 / yuv420p / avc1 sous le même contrat de flux.
  12. Limiter chaque segment à sa durée attendue.
  13. Refuser tout segment dont la configuration AVC réelle ou la temporisation viole le contrat.
  14. Concaténer les segments acceptés avec -c:v copy.
  15. Normaliser et valider la ligne temporelle finale des paquets.
  16. Décoder intégralement le résultat.
  17. Vérifier les en-têtes HTTP, les plages d’octets et les réponses partielles.
  18. Après une modification du profil d’encodage, effectuer un essai sur appareils et navigateurs.
  19. Publier de façon atomique uniquement après réussite de toutes les vérifications.

La référence est la ligne temporelle, pas l’extension du fichier

Un WebP animé, GIF ou APNG est une séquence temporelle d’états complets affichés, pas simplement un dossier d’images portant une certaine extension.

H.264 peut exploiter très efficacement la redondance temporelle, mais il ne peut pas réparer une mauvaise composition, une temporisation inventée ou des métadonnées de segments incompatibles. Une grande partie de l’ingénierie qui rend cette chaîne fiable se situe avant et après x264.

La règle que j’en retiens est simple : ne changer la représentation qu’après avoir défini précisément ce qui doit rester invariant.

Documentation primaire