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

El tick que rompió mi CFR: por qué 5580 no era 5625 y 3751 no era 3750

Mi validador rechazó repetidamente un MP4 a 16 fps porque un paquete duraba 5580 ticks en lugar de 5625; más tarde encontró 3751 donde 24 fps exigían exactamente 3750. Los dos fallos me obligaron a separar el tiempo de origen, la cuantización CFR, la base de tiempo de MP4, PTS/DTS, el multiplexado y la validación a nivel de paquete.

FFmpegH.264CFRMarcas de tiempo de vídeoMP4Procesamiento multimedia

Un día mi canal de procesamiento multimedia dejó de publicar parte de los vídeos por un error que, a primera vista, parecía casi ridículo:

Duración de paquete CFR no válida: 5580 ticks; se esperaban 5625

FFmpeg no se había bloqueado y el archivo H.264 existía. Quien lo rechazaba era mi validador, después de la codificación: el vídeo debía tener frecuencia de fotogramas constante, pero la duración de un paquete no caía en la cuadrícula temporal que yo mismo había definido.

Repetí el proceso. Volvió a salir 5580. Lo repetí otra vez: el mismo número. Más tarde otro origen falló exactamente igual. Aquello descartaba la explicación cómoda de un fallo transitorio: el incumplimiento era determinista.

Después apareció un segundo caso. A 24 fotogramas/s, con una pista de 90 000 unidades por segundo, esperaba 3750 unidades por fotograma. El validador encontró 3751.

Los dos errores no eran lo mismo. 5580 frente a 5625 son 45 unidades, exactamente 0,5 ms. 3751 frente a 3750 es una sola unidad, unos 11,1 microsegundos. Meter ambos en el cajón de “FFmpeg redondea” habría ocultado la parte interesante.

El problema me obligó a separar conceptos que antes resumía demasiado deprisa con la palabra FPS: frecuencia de fotogramas, base de tiempo, PTS/DTS y duración de paquete.

CFR no es una etiqueta que diga «16 fps»

Ya no considero suficiente que ffprobe muestre 16/1 o 24/1. En este sistema, CFR es un contrato: los instantes de presentación deben caer en una cuadrícula regular y la duración normal de cada muestra debe corresponder a un paso de esa cuadrícula.

1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 ticks

Por tanto, 5625 no era una constante arbitraria del validador. Salía directamente de dos decisiones: 16 fotogramas/s y 90 000 unidades temporales por segundo.

Esta exigencia exacta solo tiene sentido porque elegí frecuencias que se representan con un número entero de unidades. Si una cadencia no cabe exactamente en la base de tiempo elegida, el validador debe aceptar el patrón entero correcto, no exigir un valor imposible.

Frecuencia, base de tiempo y escala temporal de MP4 no son lo mismo

  • Frecuencia de fotogramas: cadencia de presentación. A 16 fps hay un fotograma cada 62,5 ms.
  • Base de tiempo: duración de una unidad entera de marca temporal; por ejemplo, 1/90000 s.
  • Escala temporal de la pista MP4: unidades por segundo; 90 000 significa que una unidad dura 1/90000 s.
  • PTS: instante en que la imagen debe presentarse.
  • DTS: instante en que el paquete debe decodificarse.
  • Duración del paquete: duración de la muestra expresada en la base de tiempo del flujo.

Con fotogramas B, PTS y DTS pueden ser distintos de forma totalmente correcta. Por eso igualarlos a la fuerza no es una reparación general; la propia documentación de setts advierte contra esa idea cuando hay reordenación.

Con fotogramas B, PTS y DTS pueden diferir de forma perfectamente válida; por eso no se deben igualar a la fuerza.

Por qué usé una escala de 90 000

No es un número mágico. Para mis frecuencias permitidas produce duraciones enteras:

FrecuenciaDuración
10 fps9000
12 fps7500
15 fps6000
16 fps5625
18 fps5000
20 fps4500
24 fps3750
25 fps3600
30 fps3000

Además:

1 ms = 90 ticks

Eso era práctico porque las animaciones de origen llegaban con retardos en milisegundos. FFmpeg permite fijar video_track_timescale en el muxer MP4. La escala no corrige nada por sí sola; hace que el contrato sea medible.

El número 5580 ya señalaba dónde mirar

5580 / 90000 = 0.062 s = 62 ms

Ese valor coincidía exactamente con una duración real de los fotogramas de origen. Tenía un archivo con 49 fotogramas mostrados en 3,063 s y retardos alternos de 62 y 63 ms:

49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms

En la animación original, alternar 62/63 ms es una buena aproximación de 16 fps sobre una rejilla de milisegundos. Pero después de elegir CFR 16, cada fotograma normal debía durar 62,5 ms: 5625 unidades.

Por eso 5580 era una pista muy fuerte de que un valor original de 62 ms había sobrevivido hasta una fase en la que el tiempo ya debía estar cuantizado a CFR.

No convierto esa pista en una certeza inventada. El registro demuestra el número y la aritmética; no demuestra por sí solo qué función concreta dejó pasar ese valor.

La entrada a 1000 Hz no era el fallo

Reproduje esta parte con FFmpeg 7.1.5 y ffconcat. Con:

duration 0.010
option framerate 1000

las marcas quedaban en:

0 ms
10 ms
20 ms
30 ms

Si usaba 30 en esa fase temprana, el tiempo se cuantizaba aproximadamente a pasos de 33,3 ms.

La rejilla de 1000 Hz hacía justamente lo que necesitaba: conservar los retardos originales con precisión de 1 ms. No significaba producir vídeo a 1000 fps.

retardos originales en milisegundos
        ↓
línea temporal de referencia
        ↓
elegir CFR
        ↓
cuantizar explícitamente a la nueva rejilla
        ↓
conservar esa rejilla hasta el MP4 final

La alta precisión de entrada no era el problema. El problema era no hacer explícita la transición entre conservar el tiempo de origen y cuantizarlo a la nueva rejilla CFR.

CFR es una cuantización controlada del tiempo

El patrón 62/63 ms se aproxima muy bien a 16 fps, pero no deja de ser variable. El cambio real a CFR es:

62, 63, 62, 63 ms
        ↓
62.5, 62.5, 62.5, 62.5 ms

El filtro fps de FFmpeg es un lugar razonable para realizar esa conversión: construye la frecuencia solicitada descartando o repitiendo imágenes según PTS y la política de redondeo.

Después de esa frontera prefiero preservar la rejilla, no pedir a otra capa que vuelva a decidir cómo convertir la frecuencia.

Por qué tres reintentos no cambiaron nada

FAIL: 5580, esperado 5625
reintento 1/3
FAIL: 5580, esperado 5625
reintento 2/3
FAIL: 5580, esperado 5625

Un reintento sirve cuando la causa puede desaparecer: red, almacenamiento temporalmente indisponible, presión de recursos. No corrige una violación determinista generada por el mismo algoritmo con la misma entrada.

  • Fallo transitorio: se puede reintentar.
  • Entrada inválida: se rechaza o se trata por otra ruta.
  • Invariante determinista roto: se detiene y se diagnostica.

El caso 5580 pertenecía a la tercera categoría.

Después apareció 3751 en lugar de 3750

90000 / 24 = 3750 ticks

Sin embargo, un resultado concatenado contenía un paquete de 3751. La diferencia era de una sola unidad:

1 / 90000 s ≈ 11.111 µs

Visualmente es irrelevante. Técnicamente me importaba porque 24 fps se representa exactamente en esa escala. No había ninguna necesidad matemática de producir 3751.

Copiar el flujo no significa dejar intactas las marcas de tiempo

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy final.mp4

-c:v copy evita decodificar y volver a codificar las imágenes H.264. No elimina el trabajo temporal del contenedor.

El demuxer concat usa la duración de cada archivo para ajustar las marcas del siguiente. Además, FFmpeg convierte marcas enteras entre bases de tiempo racionales mediante operaciones de reescalado y redondeo.

Eso no demuestra que concat sea la única causa posible del 3751, pero sí invalida la idea de que copiar el flujo de bits garantice que las marcas de tiempo sean idénticas.

Identidad de datos comprimidos e identidad de la rejilla temporal son propiedades distintas.

PTS y DTS no se arreglan con una fórmula bonita

PTS = N * duration
DTS = N * duration

Esta fórmula puede ser incorrecta cuando H.264 usa fotogramas B. El orden de decodificación y el de presentación no tienen por qué coincidir.

La reparación correcta debe partir de una línea temporal conocida y restaurar la cadencia de presentación y las duraciones sin destruir una relación válida entre PTS y DTS.

Por esa razón no publico una expresión universal de setts: depende de las fronteras de segmentos y del modelo temporal que generó el archivo.

Por qué utilicé setts

El filtro de bitstream setts puede modificar PTS, DTS, duración y base de tiempo de los paquetes sin volver a codificar el vídeo.

línea temporal conocida
+ CFR conocido
+ escala 90000
        ↓
rejilla exacta esperada
        ↓
normalizar paquetes
        ↓
validar otra vez

Eso no es lo mismo que «si veo 3751 resto uno». La corrección deriva del modelo, no del mensaje de error.

Por qué no acepté una tolerancia de ±1

Las tolerancias son correctas cuando la base de tiempo no puede representar exactamente la cadencia. En mi caso elegí deliberadamente lo contrario:

16 fps → 5625
24 fps → 3750
30 fps → 3000

Si el valor exacto existe, aceptar ±1 convierte una violación desconocida en un estado permitido.

Mi regla es validar el patrón entero correcto cuando la representación exige alternancia, y exigir igualdad exacta cuando el contrato permite un único entero exacto.

    \n
  • si la rejilla exige alternar duraciones enteras, validar el patrón correcto;
  • \n
  • si la duración debe ser un único entero exacto, exigir exactamente ese entero;
  • \n
  • no usar ±1 como recurso universal para hacer que un validador en rojo pase.
  • \n
\n\n

Cómo valido CFR a nivel de paquetes

avg_frame_rate y otros resúmenes del flujo son útiles, pero no bastan para este contrato; necesito inspeccionar los paquetes reales.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json final.mp4

Compruebo la base de tiempo real y, paquete por paquete, pts, dts y duration. Para un archivo que debe ser CFR 16:

expected = 90000 / 16 // 5625

for each normal video packet:
    assert packet.duration == 5625

assert decode order is legal
assert PTS/DTS relation is legal
assert planned frame count and duration match

Un contenedor con edit lists, recorte u otras excepciones intencionadas necesita un modelo específico. No presento esta regla como una ley universal de MP4; es un contrato estricto para un generador que controlo.

Metadatos y decodificación completa responden preguntas distintas

Una cronología perfecta no prueba que todo el H.264 pueda decodificarse, y una decodificación correcta tampoco prueba que se respete mi rejilla temporal.

ffprobe + paquetes → estructura y tiempo
full decode        → integridad del flujo comprimido
ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Solo publico después de ambas comprobaciones.

Un código de salida 0 del codificador, por sí solo, ya no significa para mí que el archivo esté terminado.

Qué demostraron realmente los dos fallos

Para 5580 puedo confirmar que el mismo valor apareció tras varios reintentos; que 5580 a 90 kHz son exactamente 62 ms; que había retardos reales de 62/63 ms; que CFR 16 exige 62,5 ms; y que la entrada a 1000 Hz conservaba correctamente los milisegundos.

Eso hace muy plausible que una duración original sobreviviera demasiado lejos, pero el registro archivado no identifica por sí solo la línea exacta responsable.

Para 3751 puedo confirmar el paquete real, la expectativa exacta de 3750, la concatenación por copia y el uso posterior de setts. La documentación de FFmpeg confirma que concat ajusta las marcas de tiempo y que setts puede reescribirlas. Es compatible con redondeos durante el reescalado o el multiplexado, pero no demuestra que «concat siempre sume un tick».

El proceso que sigo ahora

  1. Reconstruir la línea temporal real del origen.
  2. Conservar los retardos originales con una entrada suficientemente precisa.
  3. Elegir CFR por separado.
  4. Cuantizar explícitamente a esa rejilla.
  5. Usar una escala que represente exactamente las frecuencias permitidas cuando sea posible.
  6. Codificar sin repetir conversiones de frecuencia en capas posteriores.
  7. Validar base de tiempo, PTS/DTS, recuento y duración de paquetes antes de concatenar.
  8. Comprobar compatibilidad completa antes de stream copy.
  9. Volver a inspeccionar los paquetes después de concat.
  10. Normalizar a partir de la cronología conocida si hace falta.
  11. Validar de nuevo.
  12. Decodificar el archivo completo.
  13. Publicar de forma atómica solo si todo pasa.

La regla que me quedó: CFR es un contrato temporal entero

Para mí, «16 fps» ya no es una etiqueta suficiente. En este archivo significa:

time base = 1/90000
normal duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering

5580 fue útil porque reveló una vieja rejilla de milisegundos. 3751 fue útil precisamente porque era casi invisible: bastó una unidad para demostrar que el sistema había dejado de conservar un invariante que, por diseño, debía ser exacto.

Ya no valido la etiqueta CFR. Valido el tiempo del que esa etiqueta debería deducirse.

Documentación principal