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
| FPS | Dokładny czas przy 90 kHz |
|---|---|
| 10 | 9000 ticks |
| 12 | 7500 ticks |
| 15 | 6000 ticks |
| 16 | 5625 ticks |
| 18 | 5000 ticks |
| 20 | 4500 ticks |
| 24 | 3750 ticks |
| 25 | 3600 ticks |
| 30 | 3000 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
| Klasa | Limit |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Mieszane orientacje w finalnym pliku | okoł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
| Ustawienie | Wartość |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Refs | 4 |
| B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| GOP | około 5 sekund |
| VBV | 4M / 8M |
| Color | BT.709 limited |
| Timescale | 90,000 |
| Audio/subtitles/data | brak |
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 copyza 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ś
- Odczytać prawdziwe animation metadata.
- Zrekonstruować pełne widoczne canvas states.
- Oczyścić delays i zbudować timeline w ms.
- Wybrać per source CFR z 10/12/15/16/18/20/24/25/30.
- Geometria bez upscale, padding, alpha flatten.
- H.264 Main@3.1/yuv420p/avc1,
veryslow, CRF 28. - 90-kHz time base.
- Walidować każdy segment.
- Concat
-c:v copy. - Normalizacja final timeline.
- Packet inspection i pełny decode.
- 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.