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:
| FPS | Duración exacta a 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 |
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
| Clase | Máximo |
|---|---|
| Landscape | 1280×720 |
| Portrait | 720×1280 |
| Secuencia final con orientaciones mixtas | aprox. 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
| Ajuste | 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 | ninguno |
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 copycon 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
- Leer metadatos reales de animación.
- Reconstruir canvas states completos respetando blend/disposal.
- Sanear los delays y construir el timeline en ms.
- Elegir por source el CFR mínimo válido entre 10/12/15/16/18/20/24/25/30.
- Calcular dimensiones sin upscale y aplicar canvas/padding/alpha flatten.
- Codificar H.264 Main@3.1, yuv420p, avc1 con
veryslowy CRF 28. - Usar time base de 90 kHz.
- Validar cada segmento.
- Concatenar con
-c:v copy. - Normalizar el timeline final al grid exacto.
- Inspeccionar packets y hacer decode completo.
- 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.