Wróć do bloga
13 sierpnia 2026Sergei Solod12 min czytania

Jak konwertuję animowane WebP, GIF i APNG do H.264 MP4 bez psucia klatek ani czasu animacji

Mój potok produkcyjny odtwarza pełne obrazy faktycznie widoczne dla użytkownika, zachowuje czas źródłowy, wybiera jeden CFR dla każdego końcowego MP4, wylicza najmniejsze wspólne płótno bez powiększania, koduje zgodne segmenty H.264, łączy je bez drugiego stratnego kodowania i sprawdza zarówno plik, jak i jego dostarczanie przez HTTP. W jednym zmierzonym przebiegu 217 animowanych plików WebP o łącznym rozmiarze 1,49 GB stało się jednym plikiem MP4 H.264 o rozmiarze 78,49 MB.

H.264FFmpegMP4Animowany WebPGIFAPNGKompresja wideoPrzetwarzanie multimediów

Nie zbudowałem tego potoku po to, by eksperymentować z kodekami. Powstał dlatego, że animowane obrazy stały się kosztownym sposobem dostarczania czegoś, co w praktyce zachowywało się już jak krótki, niemy film.

Mój materiał to głównie krótkie animowany WebP, GIF i APNG: zwykle kilka sekund, często tylko kilkadziesiąt widocznych klatek i dużo nadmiarowości czasowej. Większość odtworzeń pochodzi z urządzeń mobilnych, te same pliki są pobierane wielokrotnie, a jednorazowy koszt kodowania ma dla mnie znacznie mniejsze znaczenie niż bajty wysyłane przy każdym kolejnym odtworzeniu.

Trudność nie polega na uruchomieniu FFmpeg. Animowany obraz nie musi być uporządkowanym stosem pełnych klatek o jednej regularnej częstotliwości. Może zawierać częściowe prostokąty, reguły mieszania i czyszczenia, kanał alfa, nieregularne opóźnienia, klatki o zerowym czasie, różne orientacje i dane czasowe, które ogólne narzędzie może podsumować w mylący sposób.

Dlatego traktuję konwersję jako zestaw niezmienników, a nie pojedyncze polecenie:

animowany WebP / GIF / APNG
        ↓
odtworzyć pełne widoczne stany płótna
        ↓
odzyskać i znormalizować czas źródłowy
        ↓
przeanalizować wszystkie źródła końcowej sekwencji
        ↓
wybrać jeden CFR dla końcowego MP4
        ↓
wyliczyć najmniejsze wspólne płótno bez powiększania
        ↓
zakodować zgodne segmenty H.264
        ↓
sprawdzić kontrakt strumienia
        ↓
połączyć przez kopiowanie strumienia
        ↓
znormalizować i zweryfikować oś czasu pakietów
        ↓
sprawdzić dostarczanie HTTP
        ↓
opublikować atomowo

Kodek jest ważny, ale jeszcze ważniejsze jest zachowanie dokładnie tego, co animacja faktycznie wyświetlała.

Zmierzony wynik produkcyjny: 217 animowanych plików WebP stało się jednym MP4 o rozmiarze 78,49 MB

Wejściem nie był jeden film o rozmiarze 1,49 GB. Było to 217 oddzielnych animowanych plików WebP zawierających 10 633 widoczne klatki. Łącznie animacje źródłowe zajmowały około 1,49 GB.

WEJŚCIE
217 animowanych plików WebP
1,49 GB łącznie
10 633 widoczne klatki

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

Końcowy plik H.264 miał 78,49 MB przy średnim strumieniu około 0,98 Mbit/s. W porównaniu z łącznym rozmiarem animacji źródłowych daje to około 19 razy mniej, czyli około 94,7% mniej danych.

To rzeczywisty wynik całego potoku, a nie czysty test A/B „stare H.264 kontra nowe H.264”. Reprezentacja zmieniła się z setek plików animowanych obrazów w jeden film kompresowany czasowo, więc nie przypisuję całego 19× wartości CRF 28, veryslow ani jednej opcji kodera.

Najpierw odtwarzam obrazy, które widz faktycznie widzi

Najbardziej niebezpiecznym skrótem jest założenie, że każda zapisana klatka animacji jest pełnym obrazem zastępującym poprzedni.

Klatka animowanego WebP może opisywać umieszczony prostokąt oraz zachowanie mieszania i czyszczenia. APNG ma przesunięcia, wymiary, czas oraz operacje mieszania i czyszczenia. GIF również może pozostawić poprzednie płótno, wyczyścić obszar albo przywrócić wcześniejszy stan.

Zapisana klatka może więc być tylko małą łatą zależną od płótna zbudowanego wcześniej. Kodowanie takich łat jako pełnych obrazów daje błędną animację, a nie mniejszą kopię poprawnej.

Moją granicą ekstrakcji jest pełny stan płótna faktycznie wyświetlany: gotowe, złożone piksele, które poprawny odtwarzacz pokazałby po zastosowaniu reguły czyszczenia poprzedniej klatki i reguły mieszania bieżącej.

To pierwsza gwarancja poprawności całego potoku. Gdy błędnie odtworzona łatka zostanie już spłaszczona do H.264, później nie naprawi jej ani CRF, ani ustawienie kodera, ani opcja kontenera.

Czas klatki jest daną źródłową, a nie FPS-em do zgadnięcia

Formaty animacji zapisują czas na różne sposoby. Animowany WebP używa czasu na klatkę w jednostkach 1 ms. GIF zapisuje opóźnienie w setnych częściach sekundy. APNG używa licznika i mianownika dla czasu każdej klatki; jeśli mianownik wynosi zero, specyfikacja PNG nakazuje traktować go jak 100.

Te opóźnienia tworzą oś czasu. Wartość FPS podana przez ogólne narzędzie jest tylko podsumowaniem i może wprowadzać w błąd.

Jeden rzeczywisty WebP w moim potoku miał 1264×720 i 49 widocznych klatek. Opóźnienia zmieniały się między 62 i 63 ms, a łączny czas wynosił 3,063 sekundy. To praktycznie rytm 16 fps, ponieważ jedna klatka przy 16 fps trwa 62,5 ms.

Ogólne narzędzie podało dla tego samego źródła 25 fps. Gdybym zaufał tej liczbie, zmieniłbym czas źródłowy albo utworzył niepotrzebne powtórzenia.

Potrzebna jest też jawna polityka dla błędnych lub niejednoznacznych opóźnień. WebP pozostawia interpretację zerowego czasu i często bardzo małych wartości implementacji. GIF może zawierać opóźnienie zero. APNG zezwala na licznik równy zero, co oznacza wyświetlenie następnej klatki tak szybko, jak to możliwe, choć odtwarzacz może narzucić praktyczne minimum.

Moja normalizacja zachowuje dokładność do milisekundy, stosuje niewielkie minimum 10 ms dla wartości zerowych lub oczywiście zbyt małych i używa 100 ms tylko jako wartości awaryjnej, gdy użytecznej informacji czasowej rzeczywiście brakuje. To reguły produkcyjne, nie uniwersalne normy.

Wybieram jeden CFR dla całego końcowego MP4 zamiast domyślnych 30 fps

Po odtworzeniu widocznych stanów i ich czasów odwzorowuję źródłową oś czasu na oś wideo. Nie koduję wszystkiego bezmyślnie do 30 fps.

Dla wszystkich źródeł, które trafią do tego samego końcowego MP4, oceniam niewielki zestaw:

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

Selektor wybiera najniższy CFR, który wystarczająco dobrze reprezentuje całą sekwencję. Różne końcowe MP4 mogą mieć różne częstotliwości, ale wszystkie niezależnie kodowane segmenty wewnątrz jednego MP4 używają dokładnie tego samego CFR.

Przykład 3,063 sekundy dobrze pokazuje różnicę: przy 16 fps potrzeba około 49 klatek wyjściowych, a przy 30 fps około 92. Jeśli inne źródło w tym samym MP4 naprawdę wymaga 30 fps, cała kolekcja używa 30. Nie mieszam różnych częstotliwości klatek w jednym końcowym strumieniu.

Dla ścieżki wideo używam skali czasowej 90 000 Hz, ponieważ każdy dozwolony CFR daje całkowity czas klatki:

10 fps → 9000 taktów
12 fps → 7500 taktów
15 fps → 6000 taktów
16 fps → 5625 taktów
18 fps → 5000 taktów
20 fps → 4500 taktów
24 fps → 3750 taktów
25 fps → 3600 taktów
30 fps → 3000 taktów

To skala czasowa ścieżki wideo definiuje tę dokładną siatkę. Ogólną skalę czasową MP4 również ustawiam na 90 000 dla spójności, ale jest to osobny zegar kontenera. Walidator porównuje pakiety wideo z dokładną siatką całkowitą zamiast ufać zaokrąglonym czasom dziesiętnym.

Limity rozdzielczości są sufitami, a nie obowiązkowymi płótnami

Moja koperta dostarczania to około 1280×720 dla poziomego materiału, 720×1280 dla pionowego oraz najwyżej 960 pikseli zarówno szerokości, jak i wysokości przy mieszanych orientacjach.

Nienegocjowalna zasada brzmi: nigdy nie powiększać. Źródło 900×600 nie zyskuje szczegółów po zmianie na 1280×720; powstają tylko interpolowane piksele, które koder musi opisać.

Druga zasada jest mniej oczywista: 960×960 to maksymalna obwiednia, a nie obowiązkowe kwadratowe płótno.

Najpierw obliczam aktywne wymiary każdego źródła, zezwalając wyłącznie na zmniejszanie. Potem buduję najmniejsze wspólne płótno o parzystych wymiarach, które mieści wszystkie już zmniejszone aktywne prostokąty.

Jeśli sekwencja potrzebuje na przykład obrazu poziomego 960×540 i pionowego 500×900, wspólne płótno może mieć 960×900, a nie 960×960. Wszystkie segmenty nadal mają identyczne zakodowane wymiary, więc łączenie przez kopiowanie strumienia pozostaje możliwe bez kodowania bezużytecznej czarnej powierzchni.

Zachowuję proporcje i dopełniam wolne miejsce zamiast rozciągać obraz. W moim potoku tło jest czarne. Ponieważ zwykłe H.264/yuv420p nie zachowuje źródłowego kanału alfa, przezroczystość jest świadomie składana z tym tłem.

Dlaczego H.264 MP4 dobrze pasuje do tego problemu dystrybucji

GIF, APNG i animowany WebP nie są prymitywnymi formatami. One również potrafią unikać ponownego rysowania niezmienionych obszarów, więc stwierdzenie „wideo zawsze jest mniejsze” byłoby fałszywe.

H.264 jest jednak projektowany pod przewidywanie czasowe między obrazami. Krótkie ilustrowane pętle ze statycznym tłem i małymi zmieniającymi się regionami są korzystnym przypadkiem dla obrazów referencyjnych, przewidywania międzyklatkowego oraz klatek P i B.

Aktualna dokumentacja Safari firmy Apple zaleca H.264 w MP4 dla statycznego wideo i podaje, że animowane GIF-y mogą zużywać do dwunastu razy więcej pasma i około dwa razy więcej energii niż nowoczesny kodek wideo. Te 12× to przykład Apple, nie mój pomiar.

Mój zmierzony wynik 19× jest więc użyteczną obserwacją produkcyjną, ale nadal mierzę konkretny przypadek zamiast zakładać, że każdy już mały animowany WebP musi przegrać z MP4.

Każda animacja jest kodowana jako segment według jednego kontraktu strumienia

Gdy koder startuje, potok zna już widoczne klatki, wspólny CFR, końcowe płótno i oczekiwany czas.

Główna część mojego polecenia dla segmentu wygląda mniej więcej tak:

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

Maksymalny GOP wynosi około pięciu sekund i wynika z wybranego CFR: 80 klatek przy 16 fps, 120 przy 24 fps i 150 przy 30 fps.

Zabezpieczenie -t "$EXPECTED_DURATION" nie jest ozdobą. W mojej liście obrazów powtarzam ostatni obraz jako znacznik końca, aby zachowana została długość poprzedniej prawdziwej klatki. Bez jawnego limitu ten znacznik może stać się dodatkową klatką końcową.

Odtworzyłem to na przypadku 49 klatek i 3,063 sekundy. Bez -t uzyskiwałem 50 klatek przy 16 fps i 94 przy 30 fps. Z -t 3.063 były to oczekiwane 49 przy 16 fps i 92 przy 30 fps.

Łączenie przez kopiowanie strumienia jest bezpieczne dopiero po ścisłej kontroli zgodności

Demultiplekser concat w FFmpeg oczekuje takich samych strumieni, w tym kodeka i podstawy czasu, i wykorzystuje długość każdego pliku do pozycjonowania następnego. Błędna długość może więc powodować artefakty na osi czasu.

Nie używam łączenia do „naprawiania” niezgodnych plików. Segment musi spełniać kontrakt jeszcze przed zaakceptowaniem:

CFR kolekcji = identyczny
podstawa czasu strumienia = identyczna
skala czasowa ścieżki MP4 = identyczna
wymiary płótna / SAR = identyczne
profil / poziom / format pikseli = identyczne
sygnalizacja koloru = identyczna
avcC / dodatkowe dane AVC = identyczne bajt w bajt

Używam stitchable=1 w x264, ponieważ segmenty są kodowane niezależnie, ale nie traktuję tego przełącznika jako dowodu zgodności konfiguracji AVC. Przed połączeniem nadal porównuję rzeczywiste bajty konfiguracji.

Po spełnieniu kontraktu końcowe połączenie nie wymaga drugiej kompresji:

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

-c:v copy zapobiega ponownemu dekodowaniu i kompresowaniu gotowych segmentów H.264.

Walidacja obejmuje zarówno plik MP4, jak i sposób jego dostarczania

Nie publikuję pliku tylko dlatego, że FFmpeg zakończył się kodem 0.

Walidator wykrył rzeczywiste deterministyczne błędy czasu: przy 16 fps pojawiało się 5580 taktów, gdy kontrakt wymagał 5625; później wynik 24 fps zawierał 3751 zamiast dokładnych 3750. Szczegółowe śledztwo jednego taktu to osobny temat; tutaj wystarczy wniosek, że powtórzenie tej samej operacji nie naprawia deterministycznego błędu osi czasu.

Dla samego obiektu multimedialnego sprawdzam oczekiwaną liczbę strumieni, profil i poziom H.264, format pikseli, dokładne zaplanowane wymiary, SAR, sygnalizację koloru, skalę ścieżki wideo 90 kHz, czasy pakietów, liczbę klatek i pakietów, długość całkowitą, relacje PTS/DTS, identyczną konfigurację AVC między segmentami, moov przed mdat oraz pełne dekodowanie z rygorystyczną obsługą błędów.

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 -

Poprawny lokalnie MP4 może jednak zostać źle dostarczony przez sieć. Dlatego sprawdzam też ścieżkę HTTP: oczekiwany Content-Type, prawidłowy Content-Length, obsługę zakresów bajtów, poprawną odpowiedź 206 Partial Content i właściwy Content-Range.

Po zmianie kontraktu kodera wykonuję też mały test na prawdziwych urządzeniach i przeglądarkach, zamiast zakładać, że ffprobe dowodzi zgodności sprzętowej. Sprawdzam start, przewijanie, zapętlenie, przejście do tła i powrót oraz odtwarzanie zakresowe na aktualnym iPhonie z Safari, prostszym urządzeniu z Androidem i popularnych przeglądarkach komputerowych.

Dopiero gdy plik i jego dostarczanie spełniają kontrakt, podmieniam zasób produkcyjny atomowo.

Gdzie ten potok świadomie traci informacje

To transformacja do dystrybucji, a nie materiał archiwalny. Alfa jest spłaszczana. Nieregularny czas źródłowy jest kwantyzowany do jednego CFR końcowego MP4. Duże źródła mogą być zmniejszane. animowany WebP już skompresowany stratnie przechodzi kolejną stratną generację. Dźwięku celowo nie ma.

Nie użyłbym dokładnie tego potoku, jeśli przezroczystość musi działać na dowolnym tle, jeśli dokładny nieregularny czas każdej klatki jest częścią treści, jeśli tworzę źródło archiwalne albo jeśli aplikacja ma już adaptacyjny system wideo z wieloma kodekami, który rozwiązuje dystrybucję inaczej.

Bardzo małe i już mocno zoptymalizowane animowane pliki WebP również najpierw mierzę, zamiast zakładać, że MP4 musi być mniejszy.

Sekwencja produkcyjna, której używam dziś

  1. Wykryć format animacji i odczytać rzeczywiste metadane sterowania klatkami.
  2. Odtworzyć pełne widoczne stany płótna zgodnie z regułami mieszania i czyszczenia.
  3. Odzyskać i znormalizować czas każdej klatki.
  4. Zbudować źródłową oś czasu o dokładności milisekundowej.
  5. Przeanalizować wszystkie źródła, które trafią do tego samego końcowego MP4.
  6. Wybrać jeden wspólny CFR z 10/12/15/16/18/20/24/25/30.
  7. Odwzorować widoczne stany na tę oś CFR.
  8. Wyliczyć aktywne wymiary wyłącznie przez zmniejszanie; nigdy nie powiększać.
  9. Zbudować najmniejsze potrzebne wspólne płótno o parzystych wymiarach.
  10. Dopełnić bez rozciągania i świadomie skomponować kanał alfa.
  11. Zakodować każde źródło jako H.264 Main@3.1 / yuv420p / avc1 według jednego kontraktu.
  12. Ograniczyć każdy segment do oczekiwanego czasu.
  13. Odrzucić segment, jeśli rzeczywista konfiguracja AVC lub czas naruszają kontrakt.
  14. Połączyć zaakceptowane segmenty za pomocą -c:v copy.
  15. Znormalizować i zweryfikować końcową oś czasu pakietów.
  16. Całkowicie zdekodować wynik.
  17. Sprawdzić nagłówki HTTP, zakresy bajtów i odpowiedzi częściowe.
  18. Po zmianach profilu kodowania wykonać test urządzeń i przeglądarek.
  19. Publikować atomowo dopiero po przejściu wszystkich kontroli.

Źródłem prawdy jest oś czasu, nie rozszerzenie pliku

Animowany WebP, GIF lub APNG to czasowa sekwencja widocznych stanów płótna, a nie po prostu folder obrazów o określonym rozszerzeniu.

H.264 potrafi bardzo dobrze wykorzystać nadmiarowość czasową, ale nie naprawi błędnej kompozycji, wymyślonego czasu ani niezgodnych metadanych segmentów. Duża część inżynierii, która uczyniła ten potok niezawodnym, znajduje się przed i po x264.

Reguła, którą z tego zachowałem, jest prosta: zmieniaj reprezentację dopiero wtedy, gdy potrafisz dokładnie opisać, co musi pozostać niezmienne.

Dokumentacja pierwotna