Torna al blog
13 agosto 2026Sergei Solod10 min di lettura

Come trasformo Animated WebP, GIF e APNG in piccoli MP4 H.264 sicuri per il browser

La mia pipeline di produzione ricostruisce i frame realmente visualizzati e il loro timing, sceglie un CFR per source, evita upscale, codifica segmenti H.264 conservativi, concatena senza un secondo encode lossy e valida la timeline esatta dei packet prima della pubblicazione.

H.264FFmpegMP4Animated WebPGIFAPNGCompressione videoPipeline media

Non ho costruito questa pipeline perché volevo sperimentare con i codec. L'ho costruita perché le immagini animate stavano diventando un modo costoso per distribuire qualcosa che, nella pratica, era già un breve video senza audio.

Il mio workload è composto soprattutto da WebP animate, GIF e APNG di pochi secondi, in genere con decine di frame visualizzati e molta ridondanza temporale. Il traffico è prevalentemente mobile, gli stessi asset possono essere richiesti molte volte e il tempo di encoding iniziale conta molto meno dei byte che verranno poi trasferiti a ogni visualizzazione.

L'idea sembra semplice: “convertire l'animazione in MP4”. In produzione non lo è. Un'immagine animata non è necessariamente una sequenza pulita di frame completi a un unico FPS. Può contenere rettangoli parziali, regole di disposal, alpha blending, delay irregolari, durate zero ambigue, orientamenti diversi e metadati temporali che uno strumento generico può riassumere male.

Per questo la mia pipeline non è “eseguire FFmpeg su una WebP”, ma una catena di invarianti:

animated WebP / GIF / APNG
        ↓
decodificare e ricostruire gli stati visualizzati del canvas
        ↓
recuperare la timeline originale
        ↓
scegliere un CFR per source
        ↓
normalizzare la geometria senza upscale
        ↓
codificare un segmento H.264 conservativo
        ↓
ripetere in modo indipendente per ogni source
        ↓
concatenare segmenti compatibili con stream copy
        ↓
normalizzare la timeline finale dei packet
        ↓
probe + controllo packet + decode completo
        ↓
pubblicazione atomica

Il codec è solo una parte. La parte difficile è preservare ciò che l'animazione mostrava davvero mentre si cambia completamente la sua rappresentazione.

Prima regola: estrarre i frame visualizzati, non i rettangoli memorizzati

L'errore più pericoloso è assumere che ogni frame salvato sia un'immagine completa che sostituisce la precedente.

Animated WebP può usare posizione, dimensione del rettangolo, blend e disposal. APNG ha offset, dimensioni e operazioni blend/dispose. GIF può lasciare il contenuto precedente, ripristinare lo sfondo o tornare al canvas precedente.

Un frame fisico può quindi essere solo una piccola patch che dipende dal canvas già esistente. Se codifico quelle patch come immagini complete, ottengo l'animazione sbagliata.

La mia unità di estrazione è lo stato completo del canvas realmente visualizzato, dopo il disposal del frame precedente e il blending di quello corrente. Questa è la prima garanzia di correttezza dell'intera pipeline.

Il timing è un dato della sorgente, non un FPS da indovinare

  • Animated WebP memorizza la durata di ogni frame in unità da 1 ms.
  • GIF usa centesimi di secondo.
  • APNG usa numeratore e denominatore; se il denominatore è zero, la specifica PNG lo considera 100.

Questi delay sono la timeline reale. Un FPS medio è solo un riepilogo.

Un WebP reale aveva 1264×720, 49 frame visualizzati, delay alternati di 62 e 63 ms e durata totale di 3,063 secondi: praticamente 16 fps, cioè 62,5 ms per frame.

Un probe riportava invece 25 fps. Fidarsi di quel numero avrebbe modificato il timing o aggiunto frame duplicati inutili.

Normalizzo timing problematici prima di scegliere il CFR

La specifica WebP nota che durate pari a zero e spesso valori molto piccoli come 10 ms o meno possono essere interpretati dall'implementazione. GIF consente delay zero; APNG consente numeratore zero.

La mia policy mantiene precisione in millisecondi, applica un minimo di 10 ms e usa 100 ms solo come fallback quando manca un timing utile. Non sono valori universali: conta che la normalizzazione avvenga prima della quantizzazione in CFR e che i delay validi vengano preservati.

Perché non forzo tutto a 30 fps

Se l'animazione cambia davvero stato circa 16 volte al secondo, 30 fps aggiunge campioni temporali ridondanti. Per ogni source scelgo quindi tra:

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

Il selector parte dalla timeline reale e prende il candidato più basso che rappresenta bene i cambiamenti visibili; 30 fps è il fallback. La regola è: prima i display time reali, poi il CFR meno costoso che li rappresenta correttamente.

Per 62/63 ms, 16 fps è naturale perché un frame dura esattamente 62,5 ms.

Ogni source può avere il proprio 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 → un MP4

Ogni segmento è CFR, mentre il file completo è CFR a tratti e variabile nel complesso. Non faccio un secondo encode per imporre un FPS globale.

Perché uso una time base di 90 kHz

Tutti gli FPS ammessi dividono 90.000 esattamente:

FPSDurata esatta a 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

A 24 fps un frame dura esattamente 3750 tick; a 16 fps, 5625. Imposto video track timescale e movie timescale a 90.000 e valido la griglia esatta.

Normalizzo la geometria senza mai fare upscale

ClasseLimite
Landscape1280×720
Portrait720×1280
Orientamenti misti nel file finalecanvas circa 960×960

Non ingrandisco sorgenti piccole. Per aspect ratio diversi uso un canvas comune e padding, non stretching. Il background nella mia pipeline è nero.

Anche alpha è una scelta esplicita: H.264/yuv420p qui è opaco, quindi la trasparenza viene flattened sul background.

Perché H.264 MP4 di solito costa meno da distribuire

GIF, APNG e WebP animate hanno già meccanismi di ottimizzazione, quindi non sono semplicemente stack di frame completi. H.264 però è progettato per temporal compression tramite reference pictures, inter prediction, P-frames e B-frames.

Questo funziona molto bene per clip illustrati in cui gran parte dello sfondo resta statico.

Apple raccomanda H.264 MP4 per static web video e consiglia MP4 al posto delle GIF animate; la documentazione Safari afferma che GIF può richiedere fino a 12 volte più bandwidth e circa il doppio dell'energia rispetto a un codec video moderno. Quel 12× non è il mio benchmark.

Animated WebP lossy è già compresso, quindi H.264 rappresenta una seconda generazione lossy. Il risultato può comunque essere molto più piccolo, ma va validato visivamente.

Il profilo H.264 è volutamente conservativo

ImpostazioneValore
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOPcirca 5 secondi
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datanessuno

Main@3.1 è il mio envelope conservativo per un singolo file, non il limite dell'hardware moderno. Android richiede decoder Main Profile da Android 6.0 e documenta 1280×720 a 30 fps come raccomandazione H.264 HD. Apple raccomanda H.264 MP4 per file web statici.

Spendo CPU una volta per risparmiare bandwidth sempre

-preset veryslow

L'encoding avviene una volta, la delivery molte volte. Lascio quindi x264 lavorare molto e limito la decoder complexity separatamente. CRF 28 è specifico per il mio workload illustrato e bandwidth-first.

Ogni animazione diventa un segmento indipendente

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 e geometria sono già state preparate prima. Il GOP è circa cinque secondi:

gopFrames = selectedFps * 5

Concat funziona solo perché il contratto dei segmenti è rigido

Il concat demuxer di FFmpeg richiede stream compatibili e usa la durata di ogni file per posizionare il successivo. Per questo ogni segmento deve già rispettare codec/profile/level, pixel format, stream layout, canvas, color signaling e time base 90 kHz.

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

-c:v copy evita una nuova generazione lossy.

Stream copy non garantisce automaticamente la mia griglia temporale

90000 / 24 = 3750 ticks

Il validator ha trovato un output reale con un packet di 3751 tick. Visivamente poteva sembrare perfetto, ma l'invariante era rotto.

La lezione: preservare il payload H.264 non garantisce che i timestamp muxed cadano automaticamente sulla griglia prevista dopo gli aggiustamenti ai confini dei segmenti.

Il concat finale usa setts per normalizzare PTS, DTS e duration mantenendo -c:v copy. L'espressione è generata dal timeline noto e non è una formula universale da copiare.

La validazione fa parte dell'encoder

Exit code 0 non basta. Controllo stream, profile/level, pixel format, dimensioni, time base, conteggi, durate esatte, PTS/DTS, durata finale e un decode completo.

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 -

Solo dopo il file viene pubblicato atomicamente.

Errori che la pipeline cerca di evitare

  • Codificare subframe invece del canvas mostrato.
  • Fidarsi di FPS stimati.
  • Forzare 30 fps.
  • Upscale.
  • Deformare aspect ratio.
  • Re-encode dopo concat.
  • Confondere -c copy con timing corretto.
  • Saltare il decode completo.

Cosa scelgo di non preservare

Alpha viene flattened. VFR arbitrario viene approssimato da un CFR per source. Risoluzioni eccessive vengono ridotte. WebP lossy riceve un altro encode lossy. Non c'è audio. Sono compromessi espliciti.

Quando non userei questo approccio

Non se la trasparenza deve restare compositabile, se il timing irregolare esatto è essenziale, per master archivistici o in presenza di uno stack video adattivo multi-codec. Per un WebP animato già minuscolo misurerei invece di assumere il risparmio.

La pipeline pratica attuale

  1. Leggere i veri metadata di animazione.
  2. Ricostruire gli stati completi del canvas con blend/disposal corretti.
  3. Sanificare i delay e costruire il timeline in ms.
  4. Scegliere per source il CFR minimo valido tra 10/12/15/16/18/20/24/25/30.
  5. Calcolare geometria senza upscale e applicare padding/alpha flatten.
  6. Codificare H.264 Main@3.1/yuv420p/avc1 con veryslow e CRF 28.
  7. Usare time base 90 kHz.
  8. Validare ogni segmento.
  9. Concat con -c:v copy.
  10. Normalizzare il timeline finale.
  11. Ispezionare i packet e decodificare tutto.
  12. Pubblicare solo dopo validation.

La lezione più importante: l'asset reale è la timeline

Animated WebP, GIF e APNG non sono semplicemente “molte immagini”. Sono una sequenza temporale di stati del canvas.

Trattare quella sequenza come source of truth mi permette di ridurre il cadence senza inventare movimento, ridurre la risoluzione senza inventare pixel, scegliere CFR per source e concatenare senza un'altra generazione di perdita.

H.264 rende i file piccoli e MP4 facili da distribuire, ma nulla serve se l'animazione è già stata rovinata durante frame extraction o timing.

Il codec era la parte facile. Il vero lavoro era preservare il significato dell'animazione cambiandone la rappresentazione.

Documentazione primaria