NDK学习笔记:FFmpeg解压MP4提取视频YUV
继上一篇NDK的开发笔记,既然我们已经从源码手动编译ffmpeg-so出来了,这篇文章就当是检验编译的so是否可用,对FFmpeg进行一番学习,写一个最简单的例子。并结合工作中的一些架构内容,推出一些简单架构的话题。欢迎大家互相学习。事不宜迟,马上撸码。
一、准备工作
定义native方法的java入口。FFmpegUtils
public class ZzrFFmpeg {
public static native int Mp4TOYuv(String input_path_str, String output_path_str );
static
{
try {
System.loadLibrary("avutil");
System.loadLibrary("swscale");
System.loadLibrary("swresample");
System.loadLibrary("avcodec");
System.loadLibrary("avformat");
System.loadLibrary("postproc");
System.loadLibrary("avfilter");
System.loadLibrary("avdevice");
//以上库最后要手动放置到jniLibs文件夹的对应ANDROID_ABI当中
//System.loadLibrary("zzr-ffmpeg-utils");
//我们自己编写方法的库,最后生成之后要打开注释
} catch (Exception e) {
e.printStackTrace();
}
}
}
这里要强调一点,以上的so加载顺序是有序的!其中依赖关系我们可以查阅ffmpeg的编译配置configure(关于编译与配置有不明白的,请参考上一篇NDK的文章)依赖关系如下所示:
# libraries, in linking order
avcodec_deps="avutil"
avdevice_deps="avformat avcodec avutil"
avfilter_deps="avutil"
avformat_deps="avcodec avutil"
avresample_deps="avutil"
postproc_deps="avutil gpl"
swresample_deps="avutil"
swscale_deps="avutil"
建立好native入口的java文件之后,我们就可以开始实现native方法了。我们在项目的cpp中新建文件夹ffmpeg,把我们手动编译的ffmpeg产物全部拷贝,并新建ffmpeg_util.c文件,目录结构大概如下:
其中我们在zzr_ffmpeg_util.c文件编写我们的native方法Mp4TOYuv的实现:
#include <jni.h>
JNIEXPORT jint JNICALL
Java_org_zzrblog_mp_ZzrFFmpeg_Mp4TOYuv(JNIEnv *env, jclass clazz,
jstring input_path_jstr, jstring output_path_jstr)
{
// ... ...
}
有强迫症的同学(例如我自己)可能懊恼为啥java文件的native方法还是标红报错,找不到对应的c/c++实现,rebuild还是不行。这是因为CMake编译脚本还没真正的生成符号表,所以导致找不到c/c++实现的入口。所以我们还是把CMake编译脚本准备好
# 引入外部 ffmpeg so 供源文件编译
add_library(avcodec SHARED IMPORTED )
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavcodec.so)
set_target_properties(avcodec PROPERTIES LINKER_LANGUAGE CXX)
add_library(avdevice SHARED IMPORTED )
set_target_properties(avdevice PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavdevice.so)
set_target_properties(avdevice PROPERTIES LINKER_LANGUAGE CXX)
add_library(avfilter SHARED IMPORTED )
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavfilter.so)
set_target_properties(avfilter PROPERTIES LINKER_LANGUAGE CXX)
add_library(avformat SHARED IMPORTED )
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavformat.so)
set_target_properties(avformat PROPERTIES LINKER_LANGUAGE CXX)
add_library(avutil SHARED IMPORTED )
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libavutil.so)
set_target_properties(avutil PROPERTIES LINKER_LANGUAGE CXX)
add_library(postproc SHARED IMPORTED )
set_target_properties(postproc PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libpostproc.so)
set_target_properties(postproc PROPERTIES LINKER_LANGUAGE CXX)
add_library(swresample SHARED IMPORTED )
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libswresample.so)
set_target_properties(swresample PROPERTIES LINKER_LANGUAGE CXX)
add_library(swscale SHARED IMPORTED )
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/src/main/cpp/ffmpeg/lib/libswscale.so)
set_target_properties(swscale PROPERTIES LINKER_LANGUAGE CXX)
add_library( # 生成动态库的名称
zzr-ffmpeg-utils
# 指定是动态库SO
SHARED
# 编译库的源代码文件
src/main/cpp/ffmpeg/zzr_ffmpeg_util.c)
target_link_libraries( # 指定目标链接库
zzr-ffmpeg-utils
# 添加预编译库到目标链接库中
${log-lib}
avutil
avcodec
avformat
swscale )
我们在之前的fmod基础上,很快就可以理解并编写脚本。如果有问题的同学可以去查看之前的fmod(下)文章
二、实现Mp4TOYuv (RGB)(H264)
到这里我们开始编写一个ffmpeg最简单的解码例子。并从中学习ffmpeg的使用步骤。在写代码之前,我们先看看 “雷神” 雷霄骅的一页ppt教学。缅怀念这位同期的伟人。愿天堂还能继续做自己喜欢的研究 R.I.P
这页ppt对应的是3.x之前的版本的API,我们使用的是3.3.9略有变化,但是大体流程是一致的。用雷神的原话:初次学习,一定要将这流程和函数名称熟记于心。 现在开始真正的编码。
JNIEXPORT jint JNICALL
Java_org_zzrblog_mp_ZzrFFmpeg_Mp4TOYuv(JNIEnv *env, jclass clazz, jstring input_path_jstr, jstring output_path_jstr) {
const char *input_path_cstr = (*env)->GetStringUTFChars(env, input_path_jstr, 0);
const char *output_path_cstr = (*env)->GetStringUTFChars(env, output_path_jstr, 0);
LOGD("输入文件:%s", input_path_cstr);
LOGD("输出文件:%s", output_path_cstr);
// 1.注册组件
av_register_all();
avcodec_register_all();
avformat_network_init();
// 2.获取格式上下文指针,便于打开媒体容器文件获取媒体信息
AVFormatContext *pFormatContext = avformat_alloc_context();
// 打开输入视频文件
if(avformat_open_input(&pFormatContext, input_path_cstr, NULL, NULL) != 0){
LOGE("%s","打开输入视频文件失败");
return -1;
}
// 获取视频信息
if(avformat_find_stream_info(pFormatContext,NULL) < 0){
LOGE("%s","获取视频信息失败");
return -2;
}
// 3.准备视频解码器,根据视频AVStream所在pFormatCtx->streams中位置,找出索引
int video_stream_idx = -1;
for(int i=0; i<pFormatContext->nb_streams; i++)
{
//根据类型判断,是否是视频流
if(pFormatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_idx = i;
break;
}
}
LOGD("VIDEO的索引位置:%d", video_stream_idx);
// 根据codec_parameter的codec索引,提取对应的解码器。
AVCodec *pCodec = avcodec_find_decoder(pFormatContext->streams[video_stream_idx]->codecpar->codec_id);
if(pCodec == NULL) {
LOGE("%s","解码器创建失败.");
return -3;
}
// 4.创建解码器对应的上下文
AVCodecContext * pCodecContext = avcodec_alloc_context3(pCodec);
if(pCodecContext == NULL) {
LOGE("%s","创建解码器对应的上下文失败.");
return -4;
}
// 坑位!!!
//pCodecContext->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
//pCodecContext->width = pFormatContext->streams[video_stream_idx]->codecpar->width;
//pCodecContext->height = pFormatContext->streams[video_stream_idx]->codecpar->height;
//pCodecContext->pix_fmt = AV_PIX_FMT_YUV420P;
avcodec_parameters_to_context(pCodecContext,
pFormatContext->streams[video_stream_idx]->codecpar);
// 5.打开解码器
if(avcodec_open2(pCodecContext, pCodec, NULL) < 0){
LOGE("%s","解码器无法打开");
return -5;
} else {
LOGI("设置解码器解码格式pix_fmt:%d", pCodecContext->pix_fmt);
}
// ... ...
return 0;
}
以上代码对应的是ppt上的前五步骤。我们来逐一分析:
- 第1步:固定步骤,注册FFmpeg所需的组件,启动初始化运行环境。关于有多个xxx_register_xxx的方法,其实可以通过各种写代码编译器的智能提示,输入register就能看到也没多少注册方法,也就3~4~5~6个而已。/doge
- 第2步:关于任何媒体容器文件(MP4、RMVB、TS、FLV、AVI)还是各种在线传输协议(udp-rtp、rtmp)在正式解码之前,我们都必须要获取当前视频的格式信息,以便更好的创建解码器&运行时环境。 ffmpeg获取视频格式信息通过三个API完成:(1)avformat_alloc_context 创建 AVFormatContext * 格式上下文指针;(2)通过avformat_open_input 关联打开 格式上下文指针 & 媒体文件/流;(3)avformat_find_stream_info 获取媒体文件/流的格式信息。
- 第3步:到达这一步,我们已经掌握了媒体文件/流的格式信息了。通过遍历AVFormatContext->streams的 AVStream 数组,获取AVCodecParameters 编解码器的参数列表 中的codec_type编码类型。(这里AVStream代表的是媒体文件/流中具体的某一轨信息,一个正常媒体资源可能包括:视频+音频+字幕 等等其他信息)我们这里找出视频轨的索引位置,根据索引出来的AVCodecParameters->codec_id解码器ID,通过avcodec_find_decoder提取对应的AVCodec视频解码器。(这里的引用有点多而且复杂,请认真理解)(这一步骤也是3.x前后的小区别之一,请注意)
- 第4步:你以为有AVCodec解码器就完事了吗?NONONO,此时的AVCodec解码器还没完成初始化,不知道你想要它干啥呢!此时我们需要构建AVCodecContext指针关联AVCodec解码器。注意!这一步的内容是3.x前后的明显区别之一,之前版本的API,我们是不需要这样自己创建关联。通过AVCodecContext * pCodecContext = avcodec_alloc_context3(pCodec); 我们创建AVCodecContext指针,但是此时的AVCodecContext 还需要通过新的API接口avcodec_parameters_to_context(AVCodecContext *, AVCodecParameters * )来完成初始化工作,从上面的注释我可以诚恳的告诉大家,别想着自己完成初始化赋值工作,因为变量实在太多了,一个错了,就会导致后面解码工作的失败。所以还是乖乖的用API吧。
- 第5步:基本工作准备好我们就可以正式启动解码器,通过avcodec_open2(AVCodecContext * , AVCodec * , AVDictionary**); 结束FFmpeg解码工作环境的建立。
接下来,我们进入解码流程:
// ... ... 紧接上方 ... ...
// 解码流程,多看多理解。
// 解压缩前的数据对象
AVPacket *packet = av_packet_alloc();
// 解码后数据对象
AVFrame *frame = av_frame_alloc();
AVFrame *yuvFrame = av_frame_alloc();
// 为yuvFrame缓冲区分配内存,只有指定了AVFrame的像素格式、画面大小才能真正分配内存
int buffer_size = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, pCodecContext->width, pCodecContext->height, 1);
uint8_t *out_buffer = (uint8_t *)av_malloc((size_t) buffer_size);
// 初始化yuvFrame缓冲区
av_image_fill_arrays(yuvFrame->data, yuvFrame->linesize, out_buffer,
AV_PIX_FMT_YUV420P, pCodecContext->width, pCodecContext->height, 1 );
// yuv输出文件
FILE* fp_yuv = fopen(output_path_cstr,"wb");
// test:264输出文件
char save264str[100]={0};
sprintf(save264str, "%s", "/storage/emulated/0/10s_test.h264");
FILE* fp_264 = fopen(save264str,"wb");
//用于像素格式转换或者缩放
struct SwsContext *sws_ctx = sws_getContext(
pCodecContext->width, pCodecContext->height, pCodecContext->pix_fmt,
pCodecContext->width, pCodecContext->height, AV_PIX_FMT_YUV420P,
SWS_BICUBIC, NULL, NULL, NULL); //SWS_BILINEAR
int ret, frameCount = 0;
// 5. 循环读取视频数据的分包 AVPacket
while(av_read_frame(pFormatContext, packet) >= 0)
{
if(packet->stream_index == video_stream_idx)
{
// test:h264数据写入本地文件
fwrite(packet->data, 1, (size_t) packet->size, fp_264);
//AVPacket->AVFrame
ret = avcodec_send_packet(pCodecContext, packet);
if(ret < 0){
LOGE("avcodec_send_packet:%d\n", ret);
continue;
}
while(ret >= 0) {
ret = avcodec_receive_frame(pCodecContext, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF){
LOGD("avcodec_receive_frame:%d\n", ret);
break;
}else if (ret < 0) {
LOGW("avcodec_receive_frame:%d\n", AVERROR(ret));
goto end; //end处进行资源释放等善后处理
}
if (ret >= 0)
{ //frame->yuvFrame (调整缩放)
sws_scale(sws_ctx,
(const uint8_t* const*)frame->data, frame->linesize, 0, frame->height,
yuvFrame->data, yuvFrame->linesize);
//向YUV文件保存解码之后的帧数据
//AVFrame->YUV,一个像素包含一个Y
int y_size = frame->width * frame->height;
fwrite(yuvFrame->data[0], 1, (size_t) y_size, fp_yuv);
fwrite(yuvFrame->data[1], 1, (size_t) y_size/4, fp_yuv);
fwrite(yuvFrame->data[2], 1, (size_t) y_size/4, fp_yuv);
frameCount++ ;
}
}
}
av_packet_unref(packet);
}
LOGI("总共解码%d帧",frameCount++);
解码流程如上,我们快速简单分析一波,然后祭出灵魂画师(我的)画的图,帮助大家理解:
1、首先创建 解压缩数据对象 AVPacket *packet,解码后数据的对象AVFrame *frame/*yuvFrame 先别问为啥有两个AVFrame。
2、然后为yuvFrame缓冲区分配内存,通过av_image_get_buffer_size计算yuv对应大小的内存大小buffer_size,然后就是av_malloc(buffer_size),最后就是av_image_fill_arrays关联绑定 内存区 和 yuvFrame。只有指定了像素格式、画面大小AVFrame才能真正分配内存。 继续别问为啥只对yuvFrame操作,frame不需要。
3、然后就是打开本地文件句柄,准备写入相关数据流。接着我们创建yuv/rgb 转换器,这部分代码比较固定而且简单。
4、然后分析关键循环,通过av_read_frame循环读取媒体文件/流,获取裸码流数据AVPacket,判断当前是否视频数据。如果是视频数据,那此时的AVPacket装载的就是h264的源数据了。我们把数据写入本地文件。循环最后需要调用av_packet_unref来解除AVPacket对象的引用次数。
接着我们把h264数据通过avcodec_send_packet(pCodecContext, packet) 发送到解码器,判断返回值是否正常,然后我可以立刻通过avcodec_receive_frame(pCodecContext, frame) 获取解码后的数据,通过此对API,我们就完成了从裸码流AVPacket->AVFrame的解码数据的转换。但请注意,AVPacket和AVFrame的关系并不是一对一的!更多时候是N个AVPacket才对应一个AVFrame!此时AVFrame的格式就是对应的之前准备工作avcodec_parameters_to_context中AVCodecParameters的格式,也就是通过FFmpeg解读出来的pix_fmt(其实就是YUV,不信你自己debug看看)
出来AVFrame之后,我们继续通过sws_scale达到yuv/rgb的格式转换,转换成之后把数据写入本地文件。
5、好了,回到1和2的两个问题,然后我们怎么对这个流程加深理解呢?看看下图:我们以之前分析Android.MediaCodec的工作原理图,然后再结合流程分析,我们就可以明白了,为啥frame不需要独立分配内存空间,而缩放后得到我们想要的yuvFrame需要费一堆API来配合工作。 配合图解还不懂的,请不要私信我~~~
重要的内容已经结束了。但是不要忘了资源回收的收尾工作。
end:
// 结束回收工作
fclose(fp_yuv);
fclose(fp_264);
sws_freeContext(sws_ctx);
av_free(out_buffer);
av_frame_free(&frame);
av_frame_free(&yuvFrame);
avcodec_close(pCodecContext);
avcodec_free_context(&pCodecContext);
avformat_close_input(&pFormatContext);
avformat_free_context(pFormatContext);
(*env)->ReleaseStringUTFChars(env, input_path_jstr, input_path_cstr);
(*env)->ReleaseStringUTFChars(env, output_path_jstr, output_path_cstr);
return 0;
三、思考问题
代码确实可以运行起来了,然后也能生成.h264 和 .yuv了,然而。。。我现在每次avcodec_send_packet就立刻avcodec_receive_frame,然后直接fwrite,那新的数据岂不是~~~旧的~~~ /皱眉/皱眉/皱眉