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:
| FPS | Duração exata em 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 |
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
| Classe | Limite |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Orientações mistas no arquivo final | canvas 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ção | Valor |
|---|---|
| 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 |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | aprox. 5 segundos |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,000 |
| Audio/subtitles/data | nenhum |
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 copyprova 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
- Ler metadados reais da animação.
- Reconstruir canvas states completos respeitando blend/disposal.
- Sanear delays e construir timeline em ms.
- Escolher por source o menor CFR válido entre 10/12/15/16/18/20/24/25/30.
- Calcular geometry sem upscale, aplicar padding e alpha flatten.
- Codificar H.264 Main@3.1/yuv420p/avc1 com
veryslowe CRF 28. - Usar time base 90 kHz.
- Validar cada segment.
- Concat com
-c:v copy. - Normalizar timeline final.
- Inspecionar packets e decodificar tudo.
- 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.