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
| FPS | Exacte duur op 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 |
Alle toegestane FPS delen 90.000 exact. Daardoor kan ik packetduur als een harde integer-grid valideren.
Geometrie zonder upscale
| Type | Maximum |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Gemengde oriëntatie | ongeveer 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
| Instelling | Waarde |
|---|---|
| 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 | ongeveer 5 seconden |
| VBV | 4M / 8M |
| Color | BT.709 limited |
| Timescale | 90,000 |
| Audio/subtitles/data | geen |
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 copyverwarren 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
- Animation metadata lezen.
- Volledige zichtbare canvas states reconstrueren.
- Delays saneren en ms-timeline bouwen.
- Per source CFR kiezen uit 10/12/15/16/18/20/24/25/30.
- Geometrie zonder upscale, padding en alpha flatten.
- H.264 Main@3.1/yuv420p/avc1 encoden met
veryslow, CRF 28. - 90-kHz time base.
- Segment valideren.
- Concat met
-c:v copy. - Finale timeline normaliseren.
- Packets inspecteren en volledig decoderen.
- 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.