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

Jak zamieniam Animated WebP, GIF i APNG w małe, bezpieczne dla przeglądarek H.264 MP4

Mój produkcyjny pipeline rekonstruuje faktycznie wyświetlane klatki i timing, wybiera CFR osobno dla source, unika upscale, koduje konserwatywne segmenty H.264, scala je bez drugiego lossy encode i waliduje dokładny packet timeline przed publikacją.

H.264FFmpegMP4Animated WebPGIFAPNGKompresja wideoPipeline multimedialny

Nie zbudowałem tego pipeline'u dlatego, że chciałem eksperymentować z kodekami. Zbudowałem go dlatego, że animowane obrazy stawały się drogim sposobem dostarczania czegoś, co w praktyce było już krótkim, niemym wideo.

Mój workload to głównie krótkie animated WebP, GIF i APNG: zwykle kilka sekund, dziesiątki widocznych klatek i bardzo duża redundancja czasowa. Ruch jest mocno mobilny, te same assety są pobierane wielokrotnie, a jednorazowy czas kodowania jest dla mnie znacznie mniej istotny niż liczba bajtów wysyłanych później przy każdym odtworzeniu.

Na poziomie pomysłu brzmi to prosto: „zamień animację na MP4”. W produkcji problem jest trudniejszy. Animowany obraz nie musi być czystą sekwencją pełnych klatek o jednym FPS. Może zawierać częściowe prostokąty, reguły disposal, alpha blending, nieregularne opóźnienia, niejednoznaczne zerowe czasy, różne orientacje i timing metadata źle streszczane przez ogólne narzędzia.

Dlatego mój pipeline to łańcuch invariantów:

animated WebP / GIF / APNG
        ↓
dekodowanie i rekonstrukcja faktycznie wyświetlanych stanów canvas
        ↓
odtworzenie source timeline
        ↓
wybór CFR osobno dla source
        ↓
normalizacja geometrii bez upscale
        ↓
kodowanie konserwatywnego segmentu H.264
        ↓
powtórzenie niezależnie dla każdego source
        ↓
concat kompatybilnych segmentów przez stream copy
        ↓
normalizacja finalnego packet timeline
        ↓
probe + kontrola packetów + pełny decode
        ↓
atomowa publikacja

Kodek jest tylko częścią tej pracy. Trudniejsze jest zachowanie tego, co animacja naprawdę pokazywała, podczas całkowitej zmiany reprezentacji.

Pierwsza zasada: wyciągaj klatki wyświetlane, nie zapisane prostokąty

Najbardziej niebezpieczne uproszczenie to założenie, że każda zapisana klatka jest pełnym obrazem zastępującym poprzednią.

Animated WebP ma pozycję, rozmiar prostokąta, blend mode i disposal. APNG ma offsety, wymiary oraz operacje blend/dispose. GIF może pozostawić poprzednią zawartość, przywrócić background albo poprzedni stan canvas.

Zapisana klatka może więc być tylko małym patchem zależnym od tego, co już znajduje się na canvas. Zakodowanie takich patchy jako pełnych obrazów daje błędną animację.

Moja granica ekstrakcji to pełny stan canvas faktycznie widoczny dla użytkownika, po zastosowaniu disposal poprzedniej klatki i blendingu bieżącej.

Timing jest danymi source, a nie FPS do zgadnięcia

  • Animated WebP przechowuje duration klatki w jednostkach 1 ms.
  • GIF używa setnych części sekundy.
  • APNG przechowuje licznik i mianownik; mianownik równy 0 jest traktowany jak 100.

Te per-frame delays są prawdziwym timeline.

Realny WebP miał 1264×720, 49 widocznych klatek, opóźnienia na przemian 62 i 63 ms i łączny czas 3,063 s. To praktycznie 16 fps, czyli 62,5 ms na klatkę.

Probe podał jednak 25 fps. Gdybym zaufał tej liczbie, zmieniłbym timing albo wygenerował niepotrzebne duplikaty.

Najpierw normalizuję zły timing, potem wybieram CFR

WebP zaznacza, że czas 0 i często bardzo małe wartości, np. 10 ms lub mniej, są interpretowane zależnie od implementacji. GIF również dopuszcza zero delay, a APNG licznik 0.

Moja polityka pracuje w milisekundach, ustawia minimum 10 ms i wykorzystuje 100 ms tylko jako fallback przy braku użytecznego timing. Te wartości nie są uniwersalne; ważne jest, że prawidłowe source delays nie są zastępowane przypadkowym defaultem.

Dlaczego nie wymuszam 30 fps

Dla każdego source wybieram najniższy właściwy CFR z:

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

Selector zaczyna od rzeczywistego timeline; 30 fps jest fallbackiem. Reguła brzmi: najpierw prawdziwe display times, potem najtańszy CFR, który nadal dobrze je reprezentuje.

Dla 62/63 ms naturalne jest 16 fps, ponieważ jedna klatka trwa dokładnie 62,5 ms.

Każdy source może mieć własny CFR

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

Każdy segment jest CFR, ale całość jest CFR odcinkami i globalnie zmienna. Nie robię drugiego encode tylko po to, aby narzucić jeden FPS.

Dlaczego time base to 90 kHz

FPSDokładny czas przy 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

Każdy dozwolony FPS dzieli 90 000 dokładnie. Przy 24 fps klatka ma 3750 ticków, przy 16 fps 5625. Ustawiam video track i movie timescale na 90 000 i waliduję dokładną siatkę.

Geometria bez upscale

KlasaLimit
Landscape1280×720
Portrait720×1280
Mieszane orientacje w finalnym plikuokoło 960×960 canvas

Małych source nie powiększam. Różne aspect ratios umieszczam w wspólnym canvas z paddingiem zamiast rozciągania. Background jest czarny, a alpha świadomie flattened.

Dlaczego H.264 MP4 zwykle jest tańszy w delivery

GIF, APNG i WebP animated mają własne optymalizacje, więc nie są prostym stosem pełnych obrazów. H.264 jest jednak zaprojektowany do temporal compression z reference pictures, inter prediction, P-frames i B-frames.

Pasuje to dobrze do krótkich ilustrowanych clips, gdzie duża część obrazu pozostaje statyczna.

Apple rekomenduje H.264 MP4 dla static web video i MP4 zamiast animated GIF; dokumentacja Safari mówi, że GIF może kosztować do 12× więcej bandwidth i około 2× więcej energii niż nowoczesny codec wideo. To nie jest mój benchmark.

Lossy Animated WebP jest już skompresowany, więc H.264 oznacza drugą generację lossy. Oszczędność trzeba więc sprawdzać razem z jakością.

Profil H.264 jest celowo konserwatywny

UstawienieWartość
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Refs4
B-frames5
Open GOPoff
B-pyramidnormal
GOPokoło 5 sekund
VBV4M / 8M
ColorBT.709 limited
Timescale90,000
Audio/subtitles/databrak

Main@3.1 to mój konserwatywny envelope dla jednego pliku. Android wymaga Main Profile decode od Android 6.0 i podaje 1280×720@30 fps jako rekomendację H.264 HD. Apple rekomenduje H.264 MP4 dla static web video.

CPU płacę raz

-preset veryslow

Encoding dzieje się raz, delivery wiele razy. x264 może więc poświęcić dużo CPU. CRF 28 jest również specyficzny dla mojego ilustrowanego, bandwidth-first workload.

Każda animacja staje się niezależnym segmentem

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

Timeline i geometry są przygotowane wcześniej. GOP to około selectedFps * 5.

Concat wymaga ścisłego kontraktu segmentów

FFmpeg concat wymaga kompatybilnych streamów i korzysta z duration pliku do ustawienia następnego. Każdy segment musi więc wcześniej przejść walidację codec/profile/level, pixel format, canvas, stream layout, color i 90-kHz time base.

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

Nie ma drugiego lossy encode.

Stream copy nie gwarantuje idealnej siatki timestampów

90000 / 24 = 3750 ticks

Validator znalazł jednak prawdziwy packet o długości 3751 ticks. Wizualnie niemal niewidoczne, ale invariant został naruszony.

Finalny concat używa teraz setts do normalizacji PTS, DTS i duration przy zachowaniu -c:v copy. Konkretna expression jest generowana z timeline segmentu.

Walidacja jest częścią encoder

Sprawdzam streams, profile/level, pixel format, dimensions, time base, liczby frames/packets, dokładne durations, PTS/DTS, final duration i pełny decode.

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 -

Błędy, przed którymi chroni pipeline

  • Kodowanie subframes zamiast widocznego canvas.
  • Zgadywanie FPS.
  • Wymuszanie 30 fps.
  • Upscale.
  • Rozciąganie aspect ratio.
  • Re-encode po concat.
  • Uznawanie -c copy za dowód poprawnego timing.
  • Brak pełnego decode.

Co świadomie tracę

Alpha jest flattened. Dowolny VFR jest przybliżany przez CFR per source. Zbyt duża rozdzielczość jest zmniejszana. Lossy WebP dostaje kolejną generację lossy. Audio nie występuje. To jawne kompromisy.

Kiedy nie użyłbym tej metody

Nie przy wymaganej alpha, krytycznym nieregularnym timing, masterach archiwalnych lub istniejącym adaptive multi-codec video stack. Dla już bardzo małego animated WebP najpierw zrobiłbym benchmark.

Praktyczny pipeline dziś

  1. Odczytać prawdziwe animation metadata.
  2. Zrekonstruować pełne widoczne canvas states.
  3. Oczyścić delays i zbudować timeline w ms.
  4. Wybrać per source CFR z 10/12/15/16/18/20/24/25/30.
  5. Geometria bez upscale, padding, alpha flatten.
  6. H.264 Main@3.1/yuv420p/avc1, veryslow, CRF 28.
  7. 90-kHz time base.
  8. Walidować każdy segment.
  9. Concat -c:v copy.
  10. Normalizacja final timeline.
  11. Packet inspection i pełny decode.
  12. Dopiero potem publikacja.

Najważniejsza lekcja: assetem jest timeline

Animated WebP, GIF i APNG nie są tylko “zbiorem obrazów”. To czasowa sekwencja stanów canvas. Gdy potraktowałem ją jako source of truth, mogłem ograniczyć cadence bez wymyślania ruchu, rozdzielczość bez wymyślania pikseli, używać CFR per source i scalać bez kolejnej generacji strat.

H.264 zmniejszył pliki, MP4 ułatwił delivery. Kodek był łatwą częścią; prawdziwa praca polegała na zachowaniu znaczenia animacji podczas zmiany reprezentacji.

Dokumentacja pierwotna