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

Como converto WebP animados, GIF e APNG para H.264 MP4 sem estragar os quadros nem a temporização

Meu fluxo de produção reconstrói os quadros completos que o usuário realmente vê, preserva a temporização da fonte, escolhe um único CFR para cada MP4 final, calcula a menor área comum sem ampliar, codifica segmentos H.264 compatíveis, concatena-os sem uma segunda codificação com perdas e valida tanto o arquivo quanto sua entrega por HTTP. Em uma execução medida, 217 WebP animados somando 1,49 GB viraram um único MP4 H.264 de 78,49 MB.

H.264FFmpegMP4WebP animadoGIFAPNGCompressão de vídeoProcessamento de mídia

Não construí esse fluxo para experimentar codecs. Construí porque imagens animadas tinham se tornado uma forma cara de entregar algo que, na prática, já se comportava como um vídeo curto e sem áudio.

Meu material é formado principalmente por WebP animado, GIF e APNG curtos: normalmente poucos segundos, muitas vezes apenas algumas dezenas de quadros visíveis e muita redundância temporal. A maior parte das visualizações vem de dispositivos móveis, os mesmos arquivos podem ser solicitados repetidamente e o custo de codificar uma vez importa muito menos do que os bytes enviados depois em cada reprodução.

A parte difícil não é chamar o FFmpeg. Uma imagem animada não precisa ser uma sequência limpa de imagens completas em uma frequência regular. Ela pode conter retângulos parciais, regras de composição e descarte, alfa, atrasos irregulares, quadros com duração zero, orientações diferentes e metadados de tempo que uma ferramenta genérica pode resumir mal.

Por isso trato a conversão como um conjunto de invariantes, e não como um único comando:

WebP animado / GIF / APNG
        ↓
reconstruir estados completos realmente exibidos
        ↓
recuperar e normalizar a temporização da fonte
        ↓
analisar todas as fontes da sequência final
        ↓
escolher um único CFR para o MP4 final
        ↓
calcular a menor tela comum sem ampliar
        ↓
codificar segmentos H.264 compatíveis
        ↓
verificar o contrato do fluxo
        ↓
concatenar por cópia do fluxo
        ↓
normalizar e validar a linha do tempo dos pacotes
        ↓
verificar a entrega por HTTP
        ↓
publicar de forma atômica

O codec importa, mas preservar exatamente o que a animação mostrava importa ainda mais.

Um resultado medido em produção: 217 WebP animados viraram um MP4 de 78,49 MB

A entrada não era um único vídeo de 1,49 GB. Eram 217 arquivos WebP animados separados, contendo 10.633 quadros visíveis. Juntas, as animações de origem ocupavam cerca de 1,49 GB.

ENTRADA
217 arquivos WebP animados
1,49 GB no total
10.633 quadros visíveis

SAÍDA
1 MP4 H.264
78,49 MB
0,98 Mbit/s
1264×720
30 fps CFR
Main@3.1 / CRF 28 / veryslow

O arquivo H.264 resultante tinha 78,49 MB, com taxa média de cerca de 0,98 Mbit/s. Em comparação com o total das animações de origem, isso representa aproximadamente 19 vezes menos, ou cerca de 94,7% menos dados.

Esse é um resultado real de ponta a ponta, não um A/B limpo entre “H.264 antigo” e “H.264 novo”. A representação mudou de centenas de arquivos de imagem animada para um único vídeo com compressão temporal, portanto não atribuo todo o ganho de 19× ao CRF 28, ao veryslow ou a uma única opção do codificador.

Primeiro reconstruo as imagens que o espectador realmente vê

O atalho mais perigoso é assumir que cada quadro armazenado é uma imagem completa que substitui a anterior.

Um quadro de WebP animado pode descrever um retângulo posicionado com regras de composição e descarte. APNG tem deslocamentos, dimensões, duração e operações de composição e descarte. GIF também pode manter o canvas anterior, limpar uma região ou restaurar um estado precedente.

Assim, um quadro armazenado pode ser apenas uma pequena área cujo significado depende do canvas construído pelos quadros anteriores. Codificar essas áreas como imagens completas produz uma animação errada, não uma cópia menor da animação correta.

Meu limite de extração é o estado completo do canvas exibido: os pixels já compostos que um visualizador correto mostraria depois de aplicar a regra de descarte do quadro anterior e a composição do quadro atual.

Essa é a primeira garantia de correção de todo o fluxo. Depois que uma área reconstruída de forma errada é achatada em H.264, nenhum CRF, predefinição ou opção do contêiner consegue corrigi-la.

A duração do quadro é dado da fonte, não um FPS para adivinhar

Os formatos de animação armazenam tempo de maneiras diferentes. WebP animado usa duração por quadro em unidades de 1 ms. GIF armazena atrasos em centésimos de segundo. APNG usa numerador e denominador para cada atraso; se o denominador for zero, a especificação PNG determina que ele seja tratado como 100.

Esses atrasos formam a linha do tempo. Um valor de FPS fornecido por uma ferramenta genérica é apenas um resumo e pode ser enganoso.

Um WebP real no meu fluxo tinha 1264×720 e 49 quadros visíveis. Os atrasos alternavam entre 62 e 63 ms e a duração total era 3,063 segundos. Na prática, isso corresponde a 16 fps, pois um quadro a 16 fps dura 62,5 ms.

Uma ferramenta genérica informou 25 fps para a mesma fonte. Se eu tivesse confiado nesse valor, teria alterado a temporização original ou criado repetições desnecessárias.

Também preciso de uma regra explícita para atrasos inválidos ou ambíguos. WebP deixa a interpretação de duração zero e, muitas vezes, de valores muito pequenos para a implementação. GIF pode conter atraso zero. APNG permite numerador zero, indicando que o próximo quadro deve ser mostrado o mais rápido possível, embora o visualizador possa impor um mínimo prático.

Minha normalização mantém precisão de milissegundos, aplica um pequeno mínimo de 10 ms a durações nulas ou claramente curtas demais e usa 100 ms apenas como valor de reserva quando realmente não existe informação temporal útil. São regras de produção, não padrões universais.

Escolho um único CFR para todo o MP4 final em vez de impor 30 fps

Depois de reconstruir os estados visíveis e suas durações, projeto a linha do tempo da fonte em uma linha do tempo de vídeo. Não codifico tudo cegamente a 30 fps.

Para todas as fontes que entrarão no mesmo MP4 final, avalio este pequeno conjunto:

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

O seletor escolhe o CFR mais baixo que represente adequadamente a sequência completa. MP4 finais diferentes podem usar frequências diferentes, mas todos os segmentos codificados separadamente dentro do mesmo MP4 usam exatamente o mesmo CFR escolhido.

O exemplo de 3,063 segundos deixa a economia clara: a 16 fps são necessários cerca de 49 quadros de saída; a 30 fps, cerca de 92. Se outra fonte no mesmo MP4 realmente precisar de 30 fps, a coleção inteira usa 30. Não misturo frequências diferentes no mesmo fluxo final.

Na trilha de vídeo uso escala temporal de 90.000 Hz, porque cada CFR permitido produz uma duração inteira:

10 fps → 9000 unidades
12 fps → 7500 unidades
15 fps → 6000 unidades
16 fps → 5625 unidades
18 fps → 5000 unidades
20 fps → 4500 unidades
24 fps → 3750 unidades
25 fps → 3600 unidades
30 fps → 3000 unidades

É a escala temporal da trilha de vídeo que define essa grade exata. Também configuro a escala temporal geral do MP4 para 90.000 por consistência, mas ela é um relógio separado do contêiner. O validador compara os pacotes de vídeo com a grade inteira exata em vez de confiar em durações decimais arredondadas.

Limites de resolução são tetos, não canvases obrigatórios

Minha faixa de entrega chega aproximadamente a 1280×720 em paisagem, 720×1280 em retrato e no máximo 960 pixels tanto de largura quanto de altura para material com orientação mista.

A regra inegociável é nunca ampliar. Uma fonte 900×600 não ganha detalhes ao virar 1280×720; apenas cria pixels interpolados que o codificador precisará descrever.

A segunda regra é menos óbvia: 960×960 é um limite máximo, não uma tela quadrada obrigatória.

Primeiro calculo as dimensões ativas de cada fonte permitindo apenas redução. Depois construo o menor canvas comum com dimensões pares capaz de conter todos esses retângulos já reduzidos.

Por exemplo, se a sequência precisa de uma imagem em paisagem de 960×540 e uma em retrato de 500×900, a tela comum pode ser 960×900, não 960×960. Todos os segmentos continuam com dimensões codificadas idênticas, então a concatenação por cópia permanece possível sem codificar área preta inútil.

Preservo a proporção e preencho o espaço livre em vez de deformar a imagem. No meu fluxo o fundo é preto. Como H.264/yuv420p comum não preserva o canal alfa original, a transparência é composta de forma intencional sobre esse fundo.

Por que H.264 MP4 se encaixa bem nesse problema de distribuição

GIF, APNG e WebP animado não são formatos primitivos. Eles também podem evitar redesenhar áreas inalteradas, então dizer que “vídeo é sempre menor” seria falso.

H.264, porém, foi projetado para previsão temporal entre imagens. Laços ilustrados curtos com fundo estático e pequenas regiões em movimento são um caso favorável para imagens de referência, previsão entre quadros e quadros P e B.

A documentação atual do Safari da Apple recomenda MP4 codificado em H.264 para vídeo estático e informa que GIFs animados podem consumir até doze vezes mais largura de banda e cerca do dobro da energia de um codec de vídeo moderno. Esse 12× é um exemplo da Apple, não minha medição.

Meu resultado medido de 19× é, portanto, uma observação útil de produção, mas ainda faço comparação direta em vez de presumir que todo WebP animado já pequeno perderá para MP4.

Cada animação é codificada como um segmento sob um único contrato de fluxo

Quando o codificador começa, o fluxo já conhece os quadros visíveis, o CFR comum, o canvas final e a duração esperada.

A parte central do meu comando por segmento é aproximadamente:

ffmpeg -framerate "$COLLECTION_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

O GOP máximo fica em torno de cinco segundos e deriva do CFR escolhido: 80 quadros a 16 fps, 120 a 24 fps e 150 a 30 fps.

A proteção -t "$EXPECTED_DURATION" não é decorativa. Na minha lista de imagens repito a última como sentinela para que a duração do último quadro real seja respeitada. Sem um limite explícito, essa sentinela pode virar um quadro final adicional.

Reproduzi isso no caso de 49 quadros e 3,063 segundos. Sem -t, obtive 50 quadros a 16 fps e 94 a 30 fps. Com -t 3.063, obtive os 49 previstos a 16 fps e 92 a 30 fps.

A concatenação por cópia só é segura depois de uma verificação rigorosa de compatibilidade

O demultiplexador concat do FFmpeg espera os mesmos fluxos, incluindo codec e base de tempo, e usa a duração de cada arquivo para posicionar o seguinte. Uma duração incorreta pode, portanto, gerar defeitos na linha do tempo.

Não uso a concatenação para tornar compatíveis arquivos incompatíveis. Cada segmento precisa cumprir o contrato antes de ser aceito:

CFR da coleção = idêntico
base de tempo do fluxo = idêntica
escala temporal da trilha MP4 = idêntica
dimensões da tela / SAR = idênticas
perfil / nível / formato de pixel = idênticos
sinalização de cor = idêntica
avcC / dados adicionais AVC = idênticos byte a byte

Uso stitchable=1 do x264 porque os segmentos são codificados independentemente, mas não trato essa opção como prova de que a configuração AVC coincide. Antes da concatenação ainda comparo os bytes reais de configuração.

Quando o contrato está correto, a união final não precisa de uma segunda compressão:

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

-c:v copy evita decodificar e recomprimir segmentos H.264 já prontos.

A validação cobre tanto o MP4 quanto a forma como ele é entregue

Não publico um arquivo só porque o FFmpeg terminou com código 0.

O validador encontrou erros temporais determinísticos reais: a 16 fps apareceram 5580 unidades onde o contrato exigia 5625; depois, uma saída a 24 fps continha 3751 em vez de 3750 exatos. A investigação detalhada da diferença de uma unidade é outro assunto; aqui basta a conclusão de que repetir a mesma operação não corrige um erro temporal determinístico.

No objeto de mídia verifico quantidade esperada de fluxos, perfil e nível H.264, formato de pixel, dimensões exatas planejadas, SAR, sinalização de cor, escala da trilha de vídeo a 90 kHz, durações de pacotes, contagem de quadros e pacotes, duração total, relações PTS/DTS, configuração AVC idêntica entre segmentos, moov antes de mdat e decodificação completa com tratamento rigoroso de erros.

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 -

Mas um MP4 local correto ainda pode ser entregue incorretamente pela rede. Por isso verifico também o caminho HTTP: Content-Type esperado, Content-Length correto, suporte a intervalos de bytes, uma resposta válida 206 Partial Content e um Content-Range correto.

Quando mudo o contrato do codificador também faço um pequeno teste em dispositivos e navegadores reais, em vez de presumir que ffprobe comprova compatibilidade do dispositivo. Testo início, busca, repetição, ida ao segundo plano e retorno, além de reprodução por intervalos, em um iPhone atual com Safari, um Android mais simples e os principais navegadores de computador.

Só depois que arquivo e entrega cumprem o contrato substituo o recurso de produção de forma atômica.

Onde esse fluxo perde informação de propósito

Esta é uma transformação para distribuição, não um arquivo mestre. O alfa é achatado. A temporização irregular da fonte é quantizada para um único CFR no MP4 final. Fontes grandes podem ser reduzidas. Um WebP animado já comprimido com perdas recebe outra geração com perdas. O áudio é omitido intencionalmente.

Eu não usaria exatamente esse fluxo quando a transparência precisa continuar componível sobre fundos arbitrários, quando a temporização irregular exata de cada quadro faz parte do conteúdo, quando estou criando uma fonte de arquivo ou quando a aplicação já possui uma estrutura de vídeo adaptativa com vários codecs que resolve a distribuição de outra maneira.

Também comparo primeiro WebP animados muito pequenos e já altamente otimizados, em vez de presumir que MP4 necessariamente será menor.

A sequência de produção que uso hoje

  1. Detectar o formato animado e ler os metadados reais de controle dos quadros.
  2. Reconstruir estados completos exibidos segundo as regras de composição e descarte.
  3. Recuperar e normalizar a duração de cada quadro.
  4. Construir a linha do tempo de referência da fonte com precisão de milissegundos.
  5. Analisar todas as fontes que entrarão no mesmo MP4 final.
  6. Escolher um único CFR comum entre 10/12/15/16/18/20/24/25/30.
  7. Projetar os estados visíveis nessa linha temporal CFR.
  8. Calcular dimensões ativas permitindo apenas reduzir; nunca ampliar.
  9. Construir o menor canvas comum necessário com dimensões pares.
  10. Preencher sem distorcer e compor o alfa de forma intencional.
  11. Codificar cada fonte como H.264 Main@3.1 / yuv420p / avc1 sob o mesmo contrato.
  12. Limitar cada segmento à duração esperada.
  13. Rejeitar qualquer segmento cuja configuração AVC real ou temporização viole o contrato.
  14. Concatenar os segmentos aceitos com -c:v copy.
  15. Normalizar e validar a linha do tempo final dos pacotes.
  16. Decodificar completamente o resultado.
  17. Verificar cabeçalhos HTTP, intervalos de bytes e respostas parciais.
  18. Após mudanças no perfil de codificação, executar um teste em dispositivos e navegadores.
  19. Publicar de forma atômica apenas depois de todas as verificações.

A fonte de verdade é a linha do tempo, não a extensão do arquivo

Um WebP animado, GIF ou APNG é uma sequência temporal de estados completos exibidos, não simplesmente uma pasta de imagens com determinada extensão.

H.264 consegue explorar muito bem a redundância temporal, mas não corrige composição errada, temporização inventada nem metadados de segmentos incompatíveis. Grande parte da engenharia que tornou esse fluxo confiável acontece antes e depois do x264.

A regra que ficou para mim é esta: só mude a representação depois de definir com precisão o que precisa permanecer invariável.

Documentação primária