Volver al blog
13 de agosto de 2026Sergei Solod12 min de lectura

Cómo convierto Animated WebP, GIF y APNG en pequeños MP4 H.264 seguros para el navegador

Mi pipeline de producción reconstruye los frames realmente mostrados y su timing, elige un CFR por source, evita upscale, codifica segmentos H.264 conservadores, los concatena sin una segunda compresión con pérdida y valida el timeline exacto de packets antes de publicar.

H.264FFmpegMP4Animated WebPGIFAPNGCompresión de vídeoPipeline multimedia

No construí este pipeline porque quisiera experimentar con códecs. Lo hice porque las imágenes animadas se estaban convirtiendo en una forma cara de entregar algo que, en la práctica, ya era vídeo corto y silencioso.

Mi carga de trabajo está formada sobre todo por WebP animado, GIF y APNG de pocos segundos, normalmente con decenas de frames visibles y mucha redundancia temporal. El tráfico es principalmente móvil, los mismos assets pueden servirse muchas veces y el tiempo de codificación importa mucho menos que los bytes que se enviarán después en cada reproducción.

La idea parece sencilla: «convertir la animación a MP4». En producción no lo es. Una imagen animada no tiene por qué ser una secuencia limpia de frames completos a un único FPS. Puede contener rectángulos parciales, reglas de disposal, alpha blending, delays irregulares, duraciones cero ambiguas, distintas orientaciones y metadatos de tiempo que una herramienta genérica puede resumir mal.

Por eso mi pipeline no es «ejecutar FFmpeg sobre un WebP». Es una cadena de invariantes:

animated WebP / GIF / APNG
        ↓
decodificar y reconstruir los estados visibles del canvas
        ↓
recuperar el timeline original
        ↓
elegir un CFR por source
        ↓
normalizar geometría sin upscale
        ↓
codificar un segmento H.264 conservador
        ↓
repetir de forma independiente para cada source
        ↓
concatenar segmentos compatibles con stream copy
        ↓
normalizar el timeline final de packets
        ↓
probe + validación de packets + decode completo
        ↓
publicación atómica

El códec es solo una parte. Lo difícil es conservar lo que realmente mostraba la animación mientras se cambia por completo su representación.

Primera regla: extraer frames mostrados, no rectángulos almacenados

La simplificación más peligrosa es suponer que cada frame guardado es una imagen completa que sustituye a la anterior.

WebP animado puede almacenar posición, tamaño del rectángulo, modo de mezcla y disposal. APNG usa offsets, dimensiones y operaciones de blend/dispose. GIF puede indicar que se conserve el contenido anterior, se restaure el fondo o se recupere el canvas previo.

Por tanto, un frame almacenado puede ser solo un pequeño parche que depende de lo que ya había en el canvas. Si codifico esos parches como imágenes completas, no obtengo la misma animación en menos bytes: obtengo una animación incorrecta.

Mi límite de extracción es el estado del canvas que realmente se mostraría. Para cada paso necesito los píxeles ya compuestos después de aplicar el disposal del frame anterior y el blending del actual.

Esto parece un detalle de decoder, pero es la primera garantía de corrección de todo el pipeline. Una vez que un frame mal compuesto queda convertido en H.264, ninguna opción posterior puede arreglarlo.

El timing pertenece al source, no a un FPS adivinado

Los formatos guardan el tiempo de formas distintas:

  • Animated WebP: duración por frame en unidades de 1 ms.
  • GIF: delay en centésimas de segundo.
  • APNG: numerador y denominador de delay; si el denominador es cero, la especificación PNG lo trata como 100.

Esos delays forman el timeline. Un FPS medio es solo un resumen.

Un WebP real me dejó esto claro: 1264×720, 49 frames visibles, delays alternando entre 62 y 63 ms y una duración total de 3.063 segundos. Es prácticamente una cadencia de 16 fps: 62,5 ms por frame.

Sin embargo, un probe informó 25 fps.

Si hubiera confiado en ese dato y lo hubiera convertido sin más a 25 o 30 fps, habría cambiado el timing o añadido frames repetidos innecesarios. Los delays del source eran la verdad.

Normalizo timings problemáticos antes de elegir CFR

WebP especifica que duraciones cero y, a menudo, valores muy pequeños como 10 ms o menos pueden interpretarse de forma dependiente de la implementación. GIF también permite delay cero. APNG permite un numerador cero para avanzar tan rápido como sea posible.

Mi pipeline mantiene precisión de milisegundos, aplica un mínimo de 10 ms y utiliza 100 ms únicamente como fallback cuando no existe timing útil. No presento esos valores como universales; son una política explícita. Lo importante es corregir timing antes de cuantizarlo y conservar todos los valores válidos del source.

Por qué no fuerzo todo a 30 fps

Si una animación cambia visualmente unas 16 veces por segundo, almacenarla a 30 fps introduce muestras temporales redundantes. H.264 las comprime bien, pero siguen siendo trabajo y bytes innecesarios.

Selecciono un CFR por source entre:

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

El selector parte del timeline real y escoge el candidato más bajo que representa de forma limpia los cambios visibles. 30 fps es el fallback.

No publico un único umbral mágico de error temporal porque debe validarse según el tipo de contenido. La regla general es: usar primero los tiempos reales y elegir después el CFR más barato que los represente suficientemente bien.

Para 62/63 ms, 16 fps encaja de forma natural porque un frame dura exactamente 62,5 ms.

Cada source puede tener su propio CFR

Un MP4 final puede contener segmentos procedentes de varias animaciones independientes. No las vuelvo a codificar todas a un FPS global.

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

Cada segmento es CFR; el archivo combinado es, en la práctica, CFR por tramos y variable en conjunto. Para que funcione, todos comparten una descripción de stream compatible y una base temporal capaz de representar exactamente todas las cadencias permitidas.

Por qué uso una base temporal de 90 kHz

Uso un timescale de vídeo de 90.000 Hz porque todos mis FPS candidatos dividen 90.000 exactamente:

FPSDuración exacta a 90 kHz
109000 ticks
127500 ticks
156000 ticks
165625 ticks
185000 ticks
204500 ticks
243750 ticks
253600 ticks
303000 ticks

Un frame de 24 fps dura exactamente 3750 ticks; uno de 16 fps, 5625. No necesito fracciones de tick en los límites de segmento.

Configuro tanto el video track timescale como el movie timescale en 90.000 y valido la cuadrícula exacta, no solo segundos redondeados.

Normalizo la geometría antes de codificar y nunca hago upscale

ClaseMáximo
Landscape1280×720
Portrait720×1280
Secuencia final con orientaciones mixtasaprox. canvas 960×960

La regla estricta es no ampliar. Una imagen pequeña conserva su tamaño útil. Cuando distintas relaciones de aspecto deben compartir un único stream, ajusto el contenido dentro de un canvas común y uso padding en lugar de deformarlo. Mi fondo es negro.

Eso también define qué ocurre con alpha: H.264/yuv420p no conserva este canal en mi pipeline, por lo que la transparencia se aplana deliberadamente sobre el fondo elegido.

Por qué H.264 MP4 suele ser más barato para este workload

GIF, APNG y WebP animado no son simples pilas de imágenes completas: todos tienen mecanismos para evitar cierto trabajo repetido. Pero H.264 es un códec de vídeo construido alrededor de predicción temporal entre imágenes, referencias, P-frames y B-frames.

Eso encaja especialmente bien con clips ilustrados donde gran parte del fondo permanece igual y solo cambian regiones pequeñas.

Apple recomienda actualmente H.264 MP4 para vídeo web estático y aconseja MP4 en lugar de GIF animado; su documentación de Safari indica que un GIF puede llegar a costar hasta 12 veces más ancho de banda y aproximadamente el doble de energía frente a un códec de vídeo moderno. Esa cifra no es mi benchmark ni una promesa.

Con animated WebP hay que ser más cuidadoso: un WebP lossy ya está comprimido y H.264 supone una segunda generación con pérdida. Puede seguir siendo mucho menor en contenido temporalmente redundante, pero hay que validar calidad.

El perfil H.264 está hecho para ser aburrido

AjusteValor
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/dataninguno

Main@3.1 es mi envelope conservador para un único archivo compatible, no el límite de los móviles modernos. Android requiere decoder Main Profile desde Android 6.0 y documenta 1280×720 a 30 fps como recomendación H.264 HD. Apple recomienda H.264 MP4 para archivos web estáticos.

Prefiero pagar CPU una vez que ancho de banda siempre

-preset veryslow

El encode se hace una vez y la entrega se repite. Por eso dejo que x264 haga una búsqueda costosa y controlo la complejidad del decoder por separado. CRF 28 también pertenece a mi carga de trabajo ilustrada y bandwidth-first; no es una regla universal.

Cada animación se convierte en un segmento independiente

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

La secuencia de frames ya ha sido construida según el timeline antes de esta fase y las dimensiones resize/pad se calculan previamente. El GOP es aproximadamente cinco segundos:

gopFrames = selectedFps * 5

16 fps → 80, 24 fps → 120, 30 fps → 150.

Concat solo funciona porque el contrato de los segmentos es estricto

El concat demuxer de FFmpeg exige streams compatibles y usa la duración de cada archivo para colocar el siguiente. Por eso no intento arreglar compatibilidad durante concat: cada segmento ya debe cumplir codec, profile/level, pixel format, stream layout, canvas, color signaling y time base de 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 segunda compresión H.264 con pérdida.

Stream copy no garantizó por sí solo mi timeline exacto

A 90 kHz, 24 fps debe producir:

90000 / 24 = 3750 ticks

Mi validador encontró una salida real con un packet de 3751 ticks. Visualmente podía pasar desapercibido, pero rompía precisamente el invariant que había diseñado.

La lección fue que preservar el payload H.264 no garantiza que los timestamps muxed terminen exactamente en mi cuadrícula después de ajustar límites entre segmentos.

El concat final usa ahora el bitstream filter setts para normalizar PTS, DTS y duración de packets manteniendo -c:v copy. La expresión concreta se genera con el timeline conocido de cada segmento; no es un snippet universal.

La validación forma parte del encoder

No publico porque FFmpeg devuelva 0. Compruebo streams, codec/profile/level, pixel format, dimensions, time base, número de frames/packets, duración exacta de packets, PTS/DTS, duración final y 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 después se publica el archivo de forma atómica.

Errores que este pipeline intenta impedir

  • Codificar subframes en vez del canvas visible.
  • Confiar en un FPS estimado.
  • Forzar todo a 30 fps.
  • Hacer upscale.
  • Deformar relaciones de aspecto.
  • Re-encode después de concat.
  • Confundir -c copy con timing correcto.
  • Validar solo headers y no decodificar todo.

Lo que decido no conservar

Alpha se aplana. VFR arbitrario se aproxima mediante un CFR por source. Resoluciones excesivas se reducen. WebP lossy recibe otra generación lossy. No hay audio. Son compromisos explícitos, no limitaciones escondidas.

Cuándo no usaría este enfoque

No lo usaría si la transparencia debe mantenerse, si el timing irregular exacto es parte esencial del contenido, para masters de archivo o cuando ya existe un stack adaptativo multi-codec. Tampoco asumiría ahorro para un WebP animado ya diminuto: lo mediría.

El pipeline práctico actual

  1. Leer metadatos reales de animación.
  2. Reconstruir canvas states completos respetando blend/disposal.
  3. Sanear los delays y construir el timeline en ms.
  4. Elegir por source el CFR mínimo válido entre 10/12/15/16/18/20/24/25/30.
  5. Calcular dimensiones sin upscale y aplicar canvas/padding/alpha flatten.
  6. Codificar H.264 Main@3.1, yuv420p, avc1 con veryslow y CRF 28.
  7. Usar time base de 90 kHz.
  8. Validar cada segmento.
  9. Concatenar con -c:v copy.
  10. Normalizar el timeline final al grid exacto.
  11. Inspeccionar packets y hacer decode completo.
  12. Publicar únicamente el archivo validado.

La lección principal: el asset real es el timeline

Animated WebP, GIF y APNG no son simplemente «muchas imágenes». Son una secuencia temporal de estados del canvas.

Cuando traté esa secuencia como source of truth, todo encajó: podía reducir cadence sin inventar movimiento, reducir resolución sin inventar píxeles, usar un CFR distinto por source y concatenar sin otra generación con pérdida.

H.264 hizo pequeños los archivos y MP4 hizo sencilla su entrega. Pero ninguno habría servido si la animación ya se hubiera roto al extraer frames o adivinar timing.

El códec fue la parte fácil. El trabajo real fue conservar el significado de la animación mientras cambiaba por completo su representación.

Documentación primaria