Voltar ao blog
13 de agosto de 2026Sergei Solod10 min de leitura

Como transformo Animated WebP, GIF e APNG em pequenos MP4 H.264 seguros para navegadores

Meu pipeline de produção reconstrói os frames realmente exibidos e seu timing, escolhe CFR por source, evita upscale, codifica segmentos H.264 conservadores, concatena sem um segundo encode lossy e valida o timeline exato dos packets antes de publicar.

H.264FFmpegMP4Animated WebPGIFAPNGCompressão de vídeoPipeline de mídia

Eu não construí este pipeline porque queria brincar com codecs. Construí porque imagens animadas estavam se tornando uma forma cara de entregar algo que, na prática, já se comportava como vídeo curto e sem áudio.

Meu workload é formado principalmente por WebP animado, GIF e APNG de poucos segundos, normalmente com dezenas de frames exibidos e muita redundância temporal. O tráfego é majoritariamente mobile, os mesmos assets podem ser servidos repetidamente, e o tempo de encoding inicial importa muito menos do que os bytes enviados em cada visualização futura.

A ideia parece simples: “converter a animação para MP4”. Em produção, não é. Uma imagem animada não precisa ser uma sequência limpa de frames completos em um único FPS. Pode conter retângulos parciais, regras de disposal, alpha blending, delays irregulares, durações zero ambíguas, diferentes orientações e metadados de timing que ferramentas genéricas resumem mal.

Por isso meu pipeline não é “rodar FFmpeg em um WebP”. É uma cadeia de invariantes:

animated WebP / GIF / APNG
        ↓
decodificar e reconstruir os estados exibidos do canvas
        ↓
recuperar o timeline original
        ↓
escolher um CFR por source
        ↓
normalizar geometria sem upscale
        ↓
codificar um segmento H.264 conservador
        ↓
repetir independentemente para cada source
        ↓
concatenar segmentos compatíveis com stream copy
        ↓
normalizar o timeline final dos packets
        ↓
probe + validação dos packets + decode completo
        ↓
publicação atômica

O codec é apenas uma parte. O desafio real é preservar o que a animação realmente mostrava enquanto sua representação é completamente trocada.

Primeira regra: extrair os frames exibidos, não os retângulos armazenados

É perigoso assumir que cada frame armazenado substitui a tela inteira. WebP animado pode guardar posição, tamanho do retângulo, blend e disposal. APNG usa offsets, dimensões e operações de blend/dispose. GIF também possui regras para manter o canvas, restaurar o background ou voltar ao estado anterior.

Um frame físico pode ser só um pequeno patch que depende do que já existe no canvas. Codificar esses patches como imagens completas produz a animação errada.

Minha unidade de extração é o estado completo do canvas que um viewer correto exibiria, depois de aplicar disposal do frame anterior e blending do atual. Esse é o primeiro invariant de correção da pipeline.

Timing é dado do source, não um FPS para adivinhar

  • Animated WebP guarda duração por frame em unidades de 1 ms.
  • GIF guarda delay em centésimos de segundo.
  • APNG usa numerador e denominador; denominador zero é tratado como 100 pela especificação PNG.

Esses delays são o timeline real. Um FPS médio é só um resumo.

Um WebP real tinha 1264×720, 49 frames exibidos, delays alternando entre 62 e 63 ms e duração total de 3,063 segundos. Isso corresponde praticamente a 16 fps, ou 62,5 ms por frame.

Um probe, porém, informou 25 fps. Confiar nesse número teria alterado o timing ou criado frames duplicados sem necessidade.

Normalizo timings ruins antes de escolher CFR

WebP observa que durações zero e valores muito pequenos, frequentemente 10 ms ou menos, podem ser tratados de forma dependente da implementação. GIF também permite delay zero; APNG permite numerador zero.

Minha política mantém precisão em milissegundos, aplica mínimo de 10 ms e usa 100 ms apenas como fallback quando não existe timing útil. Não são números universais. O importante é limpar timing antes de quantizá-lo e preservar todos os delays válidos.

Por que não forço tudo a 30 fps

Se a animação muda de estado cerca de 16 vezes por segundo, armazenar 30 samples temporais por segundo é desperdício. Por isso escolho, por source:

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

O selector parte do timeline real e escolhe o menor candidato que representa bem as mudanças. 30 fps é fallback. A regra é começar pelos display times reais e escolher o CFR mais barato que os represente corretamente.

No exemplo de 62/63 ms, 16 fps encaixa naturalmente: 62,5 ms exatos por frame.

Cada source pode usar seu próprio 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 → um MP4

Cada segmento é CFR, mas o arquivo completo é CFR por trechos e variável no conjunto. Não faço um segundo encode só para impor um FPS global.

Por que uso time base de 90 kHz

Todos os FPS permitidos dividem 90.000 exatamente:

FPSDuração exata em 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

Isso permite validar uma grade temporal inteira, sem frações de tick. Uso 90.000 tanto para o video track timescale quanto para movie timescale.

Geometria é normalizada sem upscale

ClasseLimite
Landscape1280×720
Portrait720×1280
Orientações mistas no arquivo finalcanvas aproximado de 960×960

Nunca aumento um source menor. Para misturar aspect ratios em um stream compatível, encaixo a imagem no canvas e uso padding em vez de stretch. O background do meu pipeline é preto.

Alpha também é uma decisão explícita: H.264/yuv420p aqui é opaco, então a transparência é flattened no background.

Por que H.264 MP4 costuma ser mais barato para esse conteúdo

GIF, APNG e WebP animado já possuem otimizações, portanto não é correto dizer que sempre armazenam cada frame completo. Mas H.264 foi desenhado para compressão temporal com referências, inter prediction, P-frames e B-frames.

Isso funciona muito bem em clips ilustrados com backgrounds estáticos e pequenas regiões mudando.

A Apple recomenda H.264 MP4 para vídeo web estático e MP4 no lugar de GIF animado; a documentação Safari afirma que GIF pode consumir até 12 vezes mais bandwidth e aproximadamente o dobro de energia comparado a um codec de vídeo moderno. Esse 12× não é meu benchmark.

Animated WebP lossy já está comprimido, então H.264 representa uma segunda geração lossy. O resultado pode ser menor, mas exige validação visual.

O perfil H.264 é intencionalmente conservador

ConfiguraçãoValor
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 GOPaprox. 5 segundos
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datanenhum

Main@3.1 é um envelope conservador para um único arquivo, não o limite de hardware moderno. Android exige decoder Main Profile desde Android 6.0 e documenta 1280×720 a 30 fps como recomendação H.264 HD. Apple recomenda H.264 MP4 para arquivos web estáticos.

Gasto CPU uma vez para economizar bandwidth sempre

-preset veryslow

Encoding acontece uma vez; delivery acontece repetidamente. Eu deixo x264 gastar CPU na busca e limito decoder complexity separadamente. CRF 28 também é específico ao meu conteúdo ilustrado e bandwidth-first.

Cada animação vira um segmento independente

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

Frames e geometry já foram preparados antes dessa etapa. O GOP é cerca de cinco segundos:

gopFrames = selectedFps * 5

Concat só funciona porque o contrato dos segmentos é rígido

O concat demuxer do FFmpeg exige streams compatíveis e usa a duração de cada arquivo para posicionar o próximo. Por isso todos os segments já chegam com codec/profile/level, pixel format, stream layout, canvas, color signaling e time base compatíveis.

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

-c:v copy evita mais uma geração lossy.

Stream copy não garantiu sozinho a grade exata de timestamps

90000 / 24 = 3750 ticks

Meu validator encontrou uma saída real com 3751 ticks em um packet de 24 fps. Visualmente era quase invisível, mas quebrava o invariant.

A lição: preservar o payload H.264 não garante que timestamps remuxed caiam automaticamente na grade exata depois do ajuste de boundaries.

O concat final usa setts para normalizar PTS, DTS e duração mantendo -c:v copy. A expressão é gerada a partir do timeline conhecido de cada segment, por isso não publico uma fórmula universal.

Validation faz parte do encoder

Exit code 0 não basta. Verifico streams, profile/level, pixel format, dimensions, time base, contagens, durações exatas, PTS/DTS, duração final e 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 -

Só depois o arquivo é publicado atomicamente.

Erros que o pipeline tenta evitar

  • Codificar subframes em vez do canvas real.
  • Confiar em FPS estimado.
  • Forçar 30 fps.
  • Upscale.
  • Stretch de aspect ratio.
  • Re-encode após concat.
  • Assumir que -c copy prova timing correto.
  • Não fazer decode completo.

O que eu deliberadamente não preservo

Alpha é flattened. VFR arbitrário é aproximado por CFR por source. Resolução excessiva é reduzida. WebP lossy recebe outra geração lossy. Não há áudio. Esses compromissos fazem parte do design.

Quando eu não usaria essa abordagem

Não usaria se transparency precisa ser preservada, se timing irregular exato é essencial, para archival masters ou em um stack adaptativo multi-codec já existente. Para um animated WebP já minúsculo, eu mediria antes de assumir ganho.

Pipeline prático atual

  1. Ler metadados reais da animação.
  2. Reconstruir canvas states completos respeitando blend/disposal.
  3. Sanear delays e construir timeline em ms.
  4. Escolher por source o menor CFR válido entre 10/12/15/16/18/20/24/25/30.
  5. Calcular geometry sem upscale, aplicar padding e alpha flatten.
  6. Codificar H.264 Main@3.1/yuv420p/avc1 com veryslow e CRF 28.
  7. Usar time base 90 kHz.
  8. Validar cada segment.
  9. Concat com -c:v copy.
  10. Normalizar timeline final.
  11. Inspecionar packets e decodificar tudo.
  12. Publicar somente após validation.

A lição principal: o asset real é o timeline

Animated WebP, GIF e APNG não são apenas “muitas imagens”. São uma sequência temporal de estados do canvas.

Quando passei a tratar essa sequência como source of truth, pude reduzir cadence sem inventar movimento, reduzir resolução sem inventar pixels, escolher CFR por source e concatenar sem outra geração de perda.

H.264 tornou os arquivos pequenos e MP4 os tornou fáceis de servir. Mas isso não importa se a animação já foi corrompida na extração ou no timing.

O codec foi a parte fácil. O trabalho real foi preservar o significado da animação enquanto sua representação mudava.

Documentação primária