Terug naar de blog
13 augustus 2026Sergei Solod7 min leestijd

Hoe ik Animated WebP, GIF en APNG omzet in kleine, browser-veilige H.264-MP4

Mijn productie-pipeline reconstrueert de werkelijk zichtbare frames en timing, kiest CFR per source, voorkomt upscale, encodeert conservatieve H.264-segmenten, concateneert zonder tweede lossy encode en valideert de exacte packet timeline vóór publicatie.

H.264FFmpegMP4Animated WebPGIFAPNGVideocompressieMedia-pipeline

Ik bouwde deze pipeline niet omdat ik met codecs wilde experimenteren. Ik bouwde hem omdat geanimeerde afbeeldingen een dure manier werden om iets te leveren dat in de praktijk al een korte stille video was.

Mijn workload bestaat vooral uit korte animated WebP-, GIF- en APNG-assets: meestal enkele seconden, tientallen zichtbare frames en veel temporele redundantie. Het verkeer is sterk mobiel, dezelfde bestanden worden vaak opnieuw opgevraagd en eenmalige encodingtijd is veel minder belangrijk dan de bytes die daarna telkens opnieuw worden verzonden.

“Converteer de animatie naar MP4” klinkt simpel, maar een animated image is niet noodzakelijk een nette reeks volledige frames met één FPS. Het kan partial rectangles, disposal-regels, alpha blending, onregelmatige delays, nulduur, verschillende oriëntaties en slecht samengevatte timingmetadata bevatten.

Mijn pipeline is daarom een keten van invarianten:

animated WebP / GIF / APNG
        ↓
zichtbare canvas states reconstrueren
        ↓
source timeline herstellen
        ↓
per source CFR kiezen
        ↓
geometrie normaliseren zonder upscale
        ↓
een conservatief H.264-segment encoden
        ↓
voor elke source onafhankelijk herhalen
        ↓
compatibele segmenten met stream copy concateneren
        ↓
finale packet timeline normaliseren
        ↓
probe + packetcontrole + volledige decode
        ↓
atomair publiceren

Extraheer wat de gebruiker zag, niet alleen wat in het bestand staat

Animated WebP, APNG en GIF kunnen kleine frame-rectangles opslaan die afhankelijk zijn van eerdere canvasinhoud. Blend- en disposal-regels bepalen wat uiteindelijk zichtbaar is.

Daarom encode ik niet de ruwe opgeslagen patches, maar de volledig samengestelde zichtbare canvas state van elke animatiestap. Als die reconstructie fout is, kan H.264 het later niet herstellen.

Timing is source-data

  • WebP gebruikt frame duration in 1 ms-eenheden.
  • GIF gebruikt honderdsten van een seconde.
  • APNG gebruikt een teller/noemer-verhouding; noemer 0 wordt als 100 behandeld.

Een echt WebP-voorbeeld was 1264×720 met 49 zichtbare frames, afwisselend 62 en 63 ms, totaal 3,063 seconden: ongeveer 16 fps. Een probe meldde toch 25 fps.

De per-frame delays waren dus leidend, niet de samengevatte FPS.

Eerst timing opschonen, daarna CFR kiezen

Zeer kleine of nul-delays hebben format- en implementatieafhankelijke betekenis. Mijn policy werkt in milliseconden, gebruikt 10 ms als minimale frame delay en 100 ms alleen als fallback als bruikbare timing ontbreekt.

Die getallen zijn workload-specifiek. Het belangrijke principe is dat geldige source timing niet wordt vervangen door een gemakzuchtige default.

Niet alles naar 30 fps

Per source kies ik de laagste passende CFR uit:

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

De echte timeline is uitgangspunt; 30 fps is alleen fallback. Bij 62/63 ms past 16 fps natuurlijk omdat één frame 62,5 ms duurt.

Elke source mag een andere CFR hebben

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 → één MP4

Elk segment is CFR, het geheel is stuk-voor-stuk CFR en globaal variabel. Ik doe geen tweede encode om alles één FPS te geven.

Waarom 90 kHz

FPSExacte duur op 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

Alle toegestane FPS delen 90.000 exact. Daardoor kan ik packetduur als een harde integer-grid valideren.

Geometrie zonder upscale

TypeMaximum
Landscape1280×720
Portrait720×1280
Gemengde oriëntatieongeveer 960×960 canvas

Kleine bronnen worden niet vergroot. Verschillende aspect ratios worden in een gemeenschappelijk canvas geplaatst met padding in plaats van stretching. Mijn achtergrond is zwart en alpha wordt daarop flattened.

Waarom H.264 MP4 meestal goedkoper is om te leveren

Animated image formats hebben zelf optimalisaties, maar H.264 is gebouwd voor inter-frame voorspelling met references, P-frames en B-frames. Dat werkt goed bij geïllustreerde clips met grotendeels statische achtergronden.

Apple raadt H.264 MP4 aan voor static web video en MP4 in plaats van animated GIF; Safari-documentatie noemt tot 12× meer bandwidth en ongeveer 2× energie voor GIF. Dat is geen benchmark van mij.

Lossy animated WebP is al gecomprimeerd, dus H.264 is een tweede lossy generatie. Visuele validatie blijft nodig.

Het H.264-profiel is bewust saai

InstellingWaarde
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
GOPongeveer 5 seconden
VBV4M / 8M
ColorBT.709 limited
Timescale90,000
Audio/subtitles/datageen

Main@3.1 is een conservatieve single-file keuze, niet de limiet van moderne hardware. Android vereist Main Profile decode vanaf Android 6.0 en noemt 1280×720@30 fps als H.264 HD-aanbeveling; Apple raadt H.264 MP4 voor static web video aan.

CPU één keer betalen

-preset veryslow

Encoding gebeurt één keer, delivery vaak. Daarom mag x264 veel CPU gebruiken. CRF 28 is eveneens specifiek voor mijn geïllustreerde, bandwidth-first workload.

Elk asset wordt een onafhankelijk segment

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

De frame sequence en geometrie zijn al eerder gemaakt. GOP is ongeveer selectedFps * 5.

Concat vereist een streng segmentcontract

FFmpeg concat vereist compatibele streams en gebruikt file duration om de volgende start te bepalen. Daarom valideer ik compatibiliteit vóór concat.

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

Geen tweede lossy encode.

Stream copy betekende niet automatisch perfecte timestamps

90000 / 24 = 3750 ticks

Een echte output bevatte toch een packet van 3751 ticks. De visuele impact was klein, maar de invariant was gebroken.

De finale concat gebruikt nu setts om PTS, DTS en duration op de bekende 90-kHz-grid te normaliseren terwijl -c:v copy behouden blijft.

Validatie hoort bij de encoder

Ik controleer streams, profile/level, pixel format, afmetingen, time base, frame/packet counts, exacte durations, PTS/DTS, totale duration en volledige 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 -

Fouten die ik hiermee voorkom

  • Subframes encoden in plaats van zichtbare canvases.
  • FPS gokken.
  • Alles 30 fps maken.
  • Upscaling.
  • Aspect ratio vervormen.
  • Opnieuw encoden na concat.
  • -c copy verwarren met correcte timing.
  • Geen volledige decode doen.

Wat bewust verloren gaat

Alpha wordt flattened; willekeurige VFR wordt per source benaderd met CFR; te hoge resolutie wordt verkleind; lossy WebP krijgt nog een lossy generatie; audio is niet van toepassing. Dit zijn expliciete keuzes.

Wanneer ik dit niet zou gebruiken

Niet wanneer alpha behouden moet blijven, exacte onregelmatige timing essentieel is, voor archive masters of wanneer al een multi-codec adaptive video stack bestaat. Bij een al piepkleine animated WebP zou ik eerst meten.

De huidige praktische pipeline

  1. Animation metadata lezen.
  2. Volledige zichtbare canvas states reconstrueren.
  3. Delays saneren en ms-timeline bouwen.
  4. Per source CFR kiezen uit 10/12/15/16/18/20/24/25/30.
  5. Geometrie zonder upscale, padding en alpha flatten.
  6. H.264 Main@3.1/yuv420p/avc1 encoden met veryslow, CRF 28.
  7. 90-kHz time base.
  8. Segment valideren.
  9. Concat met -c:v copy.
  10. Finale timeline normaliseren.
  11. Packets inspecteren en volledig decoderen.
  12. Pas daarna publiceren.

De kern: de timeline is het asset

Animated WebP, GIF en APNG zijn geen “stapel plaatjes”, maar een getimede reeks canvas states. Zodra ik die reeks als source of truth behandelde, werd de rest logisch: geen verzonnen frames, geen verzonnen pixels, per-source CFR en geen extra lossy encode bij concat.

H.264 maakte de bestanden klein en MP4 maakte delivery eenvoudig. De codec was het makkelijke deel; de echte engineering zat in het behouden van de betekenis van de animatie.

Primaire documentatie