Android直播开发之旅(16):使用FFmpeg保存网络流到本地文件

在Amdroid直播开发之旅(5):详解ffmpeg编译与在Android平台上的移植和Android直播开发之旅(12):初探FFmpeg开源框架文章中,我们分别探讨了FFmpeg的编译移植、FFmpeg框架和相关重要结构体。本文就在此基础上,将详细阐述FFmpeg的裁剪移植,以便剔除不必要的功能,达到为APK"瘦身"的效果,同时,写了一个将网络流保存到本地文件的实战案例,以加深对FFmpeg相关结构体、功能函数的理解。

1 . FFmpeg裁剪移植

 在详解ffmpeg编译与在Android平台上的移植一文中,我们简单地讲解了下如何在Linux系统中编译FFmpeg,但是编译出来的so体积太大,而且得到的多个so不便于使用。本节在此基础上,将详细讲解在编译FFmpeg时如何对相关模块作裁剪以精简so的体积,并且编译只生成一个so文件。首先,我们来看下在配置编译选项时,configure的具体配置信息,可以进入FFmpeg源码根目录执行./configure --help命令可得到,部分配置选项如下:

  • Standar Options
  --logfile=FILE           指定日志文件输出路径[ffbuild/config.log]
  --disable-logging        不记录配置调试信息
  --fatal-warnings         如果配置出现警告,就认为失败
  --prefix=PREFIX          编译得到的库文件输出路径[/usr/local]
  --bindir=DIR             二进制文件输出路径 [PREFIX/bin]
  --datadir=DIR            数据输出路径[PREFIX/share/ffmpeg]
  --docdir=DIR             文档输出路径[PREFIX/share/doc/ffmpeg]
  --libdir=DIR             libs输出路径[PREFIX/lib]
  --shlibdir=DIR           动态库输出路径 [LIBDIR]
  --incdir=DIR             头文件输出路径 [PREFIX/include]
  --mandir=DIR             帮助文档输出路径 [PREFIX/share/man]
  --pkgconfigdir=DIR       pkg-config文件输出路径[LIBDIR/pkgconfig]
  • Configuration options
  --disable-static         禁止编译静态库
  --enable-shared          开启编译动态库
  --enable-small           开启优化大小
  --disable-runtime-cpudetect 禁用在运行时检测CPU功能
  --enable-gray            启用全灰度支持(slower color)
  --disable-swscale-alpha  禁止在swscale中支持alpha通道
  --disable-all            禁止编译所有组件(components)、库(libraries)、程序(programs)
  --disable-autodetect     禁用自动检测到的外部库

 这里用得较多的是--disable-static--enable-shared--enable-small选项,其中,--disable-static用于是否使能编译静态库文件(.a);--enable-shared用于使能编译动态库文件(.so)。

  • Program options
  --disable-programs       禁止编译programs
  --disable-ffmpeg         禁止编译ffmpeg
  --disable-ffplay         禁止编译ffplay
  --disable-ffprobe        禁止编译ffprobe

 通常,我们会使用禁止编译ffmpeg、ffplay和ffprobe,其中,ffplay是一个使用了FFmpeg和SDL库的、简单的、可移植的媒体播放器;ffprobe用于查看多媒体文件的信息。

  • Component options
  --disable-avdevice       禁止编译libavdevice模块
  --disable-avcodec        禁止编译libavcodec模块
  --disable-avformat       禁止编译libavformat模块
  --disable-swresample     禁止编译libswresample模块
  --disable-swscale        禁止编译libswscale模块
  --disable-postproc       禁止编译libpostproc模块
  --disable-avfilter       禁止编译libavfilter模块
  --enable-avresample      该模块已被弃用
  --disable-pthreads       禁止pthreads [autodetect]
  --disable-w32threads     禁止Win32 threads [autodetect]
  --disable-os2threads     禁止OS/2 threads [autodetect]
  --disable-network        禁止network支持
  --disable-dct            禁止DCT代码模块
  --disable-dwt            DWT代码模块
  --disable-error-resilience error resilience code
  --disable-lsp            禁止LSP代码模块
  --disable-lzo            禁止LZO decoder代码模块
  --disable-mdct           禁止MDCT代码模块
  --disable-rdft           禁止RDFT代码模块
  --disable-fft            禁止FFT代码模块
  --disable-faan           禁止floating point AAN (I)DCT代码模块
  --disable-pixelutils     禁止libavutil模块中的pixel工具

 这部分类似一个全局开关,用于对模块进行管控,假如我们非常明确编译的ffmpeg有明确的功能(不考虑未来扩展),那么,就可以对某些模块进行裁剪,以最大化精简so的大小、功能。

  • Individual component options
  --disable-everything     禁止所有的组件,就是下面列出来的这些
  --disable-encoder=NAME   禁用名称为NAME的编码器
  --enable-encoder=NAME    使能名称为NAME的编码器
  --disable-encoders       禁用所有编码器,可通过指定NAME具体开启
  --disable-decoder=NAME   禁用名称为NAME的解码器
  --enable-decoder=NAME    使能名称为NAME的解码器
  --disable-decoders       禁用所有解码器,可通过指定NAME具体开启
  --disable-hwaccel=NAME   禁用名称为NAME的hwaccel
  --enable-hwaccel=NAME    使能名称为NAME的hwaccel
  --disable-hwaccels       禁用所有hwaccel,可通过指定NAME具体开启
  --disable-muxer=NAME     muxer NAME
  --enable-muxer=NAME      enable muxer NAME
  --disable-muxers         禁用所有复用器,可通过指定NAME具体开启
  --disable-demuxer=NAME   demuxer NAME
  --enable-demuxer=NAME    enable demuxer NAME
  --disable-demuxers       禁用所有解复用器,可通过指定NAME具体开启
  --enable-parser=NAME     enable parser NAME
  --disable-parser=NAME    parser NAME
  --disable-parsers        禁用所有解析器,可通过指定NAME具体开启
  --enable-bsf=NAME        enable bitstream filter NAME
  --disable-bsf=NAME       bitstream filter NAME
  --disable-bsfs           禁用所有位流过滤器,可通过指定NAME具体开启
  --enable-protocol=NAME   enable protocol NAME
  --disable-protocol=NAME  protocol NAME
  --disable-protocols      禁用所有协议,可通过指定NAME具体开启
  --enable-indev=NAME      enable input device NAME
  --disable-indev=NAME     input device NAME
  --disable-indevs         禁用所有输入设备,可通过指定NAME具体开启
  --enable-outdev=NAME     enable output device NAME
  --disable-outdev=NAME    output device NAME
  --disable-outdevs        禁用所有输出设备,可通过指定NAME具体开启
  --disable-devices        禁用所有设备,包括输入、输出
  --enable-filter=NAME     enable filter NAME
  --disable-filter=NAME    filter NAME
  --disable-filters        禁用所有过滤器,可通过指定NAME具体开启

 本部分的配置主要是选择那些组件需要编译,比如编码器、解码器、复用器、解复用器等等。举个栗子:

–disable-encoders
–enable-encoder=h263
–enable-encoder=libx264
–enable-encoder=aac
–enable-encoder=mpeg4
–enable-encoder=mjpeg
–enable-encoder=png
–enable-encoder=gif
–enable-encoder=bmp
–disable-muxers
–enable-muxer=h264
–enable-muxer=flv
–enable-muxer=gif
–enable-muxer=mp3
–enable-muxer=dts
–enable-muxer=mp4
–enable-muxer=mov
–enable-muxer=mpegts
–disable-decoders
–enable-decoder=aac
–enable-decoder=aac_latm
–enable-decoder=mp3
–enable-decoder=h263
–enable-decoder=h264
–enable-decoder=mpeg4
–enable-decoder=mjpeg
–enable-decoder=gif
–enable-decoder=png
–enable-decoder=bmp
–enable-decoder=yuv4
–disable-demuxers
–enable-demuxer=image2
–enable-demuxer=h263
–enable-demuxer=h264
–enable-demuxer=flv
–enable-demuxer=gif
–enable-demuxer=aac
–enable-demuxer=ogg
–enable-demuxer=dts
–enable-demuxer=mp3
–enable-demuxer=mov
–enable-demuxer=m4v
–enable-demuxer=concat
–enable-demuxer=mpegts
–enable-demuxer=mjpeg
–enable-demuxer=mpegvideo
–enable-demuxer=rawvideo
–enable-demuxer=yuv4mpegpipe
–enable-demuxer=rtsp
–disable-parsers
–enable-parser=aac
–enable-parser=ac3
–enable-parser=h264
–enable-parser=mjpeg
–enable-parser=png
–enable-parser=bmp
–enable-parser=mpegvideo
–enable-parser=mpegaudio
–disable-protocols
–enable-protocol=file
–enable-protocol=hls
–enable-protocol=concat
–enable-protocol=rtp
–enable-protocol=rtmp
–enable-protocol=rtmpt
–disable-filters
–disable-filters
–enable-filter=aresample
–enable-filter=asetpts
–enable-filter=setpts
–enable-filter=ass
–enable-filter=scale
–enable-filter=concat
–enable-filter=atempo
–enable-filter=movie
–enable-filter=overlay
–enable-filter=rotate
–enable-filter=transpose
–enable-filter=hflip

  • External library support
--enable-libopencv       enable video filtering via libopencv [no]
--enable-libopenh264     enable H.264 encoding via OpenH264 [no]
--enable-libopenjpeg     enable JPEG 2000 de/encoding via OpenJPEG [no]
--enable-libx264         enable H.264 encoding via x264 [no]
--enable-libx265         enable HEVC encoding via x265 [no]
--enable-librtmp         enable RTMP[E] support via librtmp [no]
...

 FFmpeg框架中集成了非常多的第三方库,本部分选项主要是开启是否使用某些第三方库,以完成特定的功能。

  • Toolchain options
  --arch=ARCH              指定架构
  --cpu=CPU                指定CPU型号
  --cross-prefix=PREFIX    交叉编译工具的前缀(PREFIX)
  --progs-suffix=SUFFIX    program name suffix []
  --enable-cross-compile   使能交叉编译
  --sysroot=PATH           root of cross-build tree
  --sysinclude=PATH        cross-build系统头文件路径
  --target-os=OS           指定编译的系统类型
  --target-exec=CMD        指定在系统上运行可执行程序的命令
  --target-path=DIR        指定系统上查看编译路径
  --target-samples=DIR     指定系统上samples的目录
  --toolchain=NAME         根据NAME设置工具默认值
                           (gcc-asan, clang-asan, gcc-msan, clang-msan,
                           gcc-tsan, clang-tsan, gcc-usan, clang-usan,
                           valgrind-massif, valgrind-memcheck,
                           msvc, icl, gcov, llvm-cov, hardened)
  --nm=NM                  指定nm工具,名称为NM
  --ar=AR                  指定ar工具,名称为ARuse archive tool AR [ar]
  --as=AS                  指定汇编程序assembler AS []
  --ln_s=LN_S              指定符号连接工具 LN_S [ln -s -f]
  --strip=STRIP            指定strip工具STRIP [strip]
  --windres=WINDRES        指定windows资源编译器WINDRES [windres]
  --x86asmexe=EXE          指定nasm-compatible汇编EXE [nasm]
  --cc=CC                  指定C编译器use C compiler CC [gcc]
  --cxx=CXX                use C compiler CXX [g++]
  --objcc=OCC              use ObjC compiler OCC [gcc]
  --dep-cc=DEPCC           use dependency generator DEPCC [gcc]
  --nvcc=NVCC              use Nvidia CUDA compiler NVCC [nvcc]
  --ld=LD                  use linker LD []
  --pkg-config=PKGCONFIG   use pkg-config tool PKGCONFIG [pkg-config]
  --pkg-config-flags=FLAGS pass additional flags to pkgconf []
  --extra-cflags=ECFLAGS   add ECFLAGS to CFLAGS []
  --extra-cxxflags=ECFLAGS add ECFLAGS to CXXFLAGS []
  --extra-objcflags=FLAGS  add FLAGS to OBJCFLAGS []
  --extra-ldflags=ELDFLAGS add ELDFLAGS to LDFLAGS []
  --extra-ldexeflags=ELDFLAGS add ELDFLAGS to LDEXEFLAGS []
  --extra-ldsoflags=ELDFLAGS add ELDFLAGS to LDSOFLAGS []
  --extra-libs=ELIBS       add ELIBS []
  --extra-version=STRING   version string suffix []
  ...

 这部分用于配置编译选项,比如配置交叉编译工具、指定编译架构、CPU型号以及其他编译参数等。对于比较常见的选项,我大概列举了一下,具体介绍如下:

–arch=ARCH

 用于指定CPU的架构,常见的架构有armarm64x86等,其中,arm对应的CPU型号分为armv7-aarmv5tearmv6等;arm64对应的CPU型号为armv8-a

–cpu

 用于指定CPU的型号,比如armv7-aarmv5tearmv8-a等。

–target-os

 用于指定编译的系统平台,比如linux、win32等。

–cross-prefix

 用于指定编译工具前缀,比如–cross-prefix=/home/jiangdg/opt/android-ndk-r14b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/bin/arm-linux-androideabi-

–sysroot

 用于指定Android平台的目录,便于在编译过程中需要引用相关的库或者头文件,就会在--sysroot指定的目录下去搜索,如:–sysroot=/home/jiangdg/opt/android-ndk-r14b/platforms/android-21/arch-arm/,当然,如果需要编译不同的架构,arch-arm可能会不同,比如arm-arm64arch-x86arch-mips等。

–cc

 用于指定gcc工具,根据编译的架构不同而不一样,如–cc=/home/jiangdg/opt/android-ndk-r14b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/bin/arm-linux-androideabi-gcc。

–nm

 用于指定nm工具,根据编译的架构不同而不一样,如–cc=/home/jiangdg/opt/android-ndk-r14b/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/bin/arm-linux-androideabi-nm。

–extra-cxxflags

 用于指定C++编译器(g++)的选项,比如--extra-cxxflags="-D__thumb__ -fexceptions -frtti",其中,-fexceptions参数用于开启编译器异常捕获;-frtti参数用于为每个有虚函数的类添加一些信息以支持rtti特性。其他g++编译器选项,请参考g++编译器参数说明。

–extra-cflags

 用于指定C编译器(gcc)的选项,比如--extra-cflags="-march=armv7-a -mfloat-abi=softfp -mfpu=neon -Os -fPIC -DANDROID -Wfatal-errors -Wno-deprecated",其中,-march参数用于针对不同的CPU使用对应的CPU指令;-mfloat-abi参数用于指定浮点;-Os参数用于开启代码空间优化。

–extra-ldflags

 用于指定库文件的位置,比如--extra-ldflags="L/home/jiangdg/opt/android-ndk-r14b/platforms/android-21/arch-arm/usr/lib"

  • Optimization options(experts only)
  --disable-asm            禁用所有程序集优化
  --disable-altivec        禁用AltiVec优化
  --disable-vsx            禁用AltiVec优化
  --disable-power8         禁用POWER8优化
  --disable-amd3dnow       禁用POWER8优化
  --disable-amd3dnowext    禁用3 dnow !扩展优化
  --disable-mmx            禁用MMX优化
  --disable-mmxext         禁用MMXEXT优化
  --disable-sse            禁用SSE优化
  --disable-sse2           禁用SSE2优化
  --disable-sse3           禁用SSE3优化
  --disable-ssse3          禁用SSSE3优化
  --disable-sse4           禁用SSE4优化
  --disable-sse42          禁用SSE4.2
  --disable-avx            禁用AVX优化
  --disable-xop            禁用XOP优化
  --disable-fma3           禁用FMA3优化
  --disable-fma4           禁用FMA4优化
  --disable-avx2           禁用AVX2优化
  --disable-avx512         禁用AVX-512优化
  --disable-aesni          禁用AESNI优化
  --disable-armv5te        禁用armv5te优化
  --disable-armv6          禁用armv6优化
  --disable-armv6t2        禁用armv6t2优化
  ...

 这部分选项仅限对ffmpeg框架非常熟悉的开发者使用,用于作某种优化,如果不熟悉轻易使用,可能会出现我们无法预知的异常。在编写脚本时,只有--disable-asm选项用得比较多,即禁止所有程序集优化。

 考虑文章篇幅原因,我的编译脚本就不贴了,有兴趣的可以前往github上下载:build_configure.sh。

2. 利用FFmpeg保存网络流到文件

2.1 重要结构体、函数

  • AVDictionary
// AVDictionary结构体
struct AVDictionary {
    int count;
    AVDictionaryEntry *elems;
};
// AVDictionaryEntry结构体
typedef struct AVDictionaryEntry {
    char *key;
    char *value;
} AVDictionaryEntry;

解析:AVDictionary结构体用于存储一系列key-value键值对,这些选项参数值将影响某一函数的操作,比如读取超时、传输协议选择(TCP/UDP)等。其中,count字段表示key-value键值对的数量;elems存储一系列key-value键值对,每个元素的类型为AVDictionaryEntry结构体。

  • 函数:av_dict_get
/**
 * 设置选项参数,如果之前存在则覆盖
 
 * @param pm 指向AVDictionary结构体指针的指针变量
 * @param key entry 
 * @param value entry value 
 * @param flags 可以设为不同的选项的组合,包含AV_DICT_MATCH_CASE时表示key的匹配是要区分大小写的
 *              默认是不区分大小写;
 * @return >= 0 设置选项参数成功
 */
int av_dict_set(AVDictionary **pm, const char *key, const char *value, int flags);

解析:av_dict_set函数用于为某一操作设置选项参数,比如对于解协议来说,rtsp_transport参数用于设置传输协议且值可为"TCP"或"UDP";stimeout参数用于设置超时时限(毫秒)等等。flags参数可以设置不同的选项组合(通常设0),来限定某些行为,主要有以下几个值:

AV_DICT_MATCH_CASE 区分大小写,默认不区分
AV_DICT_IGNORE_SUFFIX
AV_DICT_DONT_STRDUP_KEY
AV_DICT_DONT_STRDUP_VAL
AV_DICT_DONT_OVERWRITE 不覆盖已存在的key-value
AV_DICT_APPEND 如果key-value存在,则值追加
AV_DICT_MULTIKEY 允许字典中存储相同的key

  • 函数:avformat_alloc_output_context2
/**
 * 为输出格式(output format)分配一个AVFormatCotext
 * 注:使用avformat_free_context()释放分配的资源
 *
 * @param *ctx 要创建的输出格式AVFormatCotext;
 * @param oformat 指定分配context的格式,如果为NULL则使用format_name和filename指定;
 * @param format_name 指定音视频的格式,比如'mpegts';
 * @param filename 音视频文件路径;
 * @return >= 0 成功
 */
int avformat_alloc_output_context2(AVFormatContext **ctx, 
                                   AVOutputFormat *oformat,
                                   const char *format_name, 
                                   const char *filename);

解析:avformat_alloc_output_context2函数用于为指定输出文件格式创建(分配)一个AVFormatContext对象,我们可以直接通过oformat对象指定输出格式(音视频文件格式),也可以通过format_name来指定。其中,format_name指的是输出文件封装格式,比如mpegts(MP4)、flvmov等(其他格式详见源文件\ffmpeg-4.0.2\libavformat\allformats.c)。

  • 函数:avio_open2
/**
 * 创建并初始化一个AVIOContext对象,该对象用于访问URL指定的资源,即用于打开FFmpeg的输
 * 入输出文件,声明在libavformat\avio.h头文件中
 *
 * @param s 将要被创建的AVIOContext对象;
 * @param url 资源URL地址;
 * @param flags 打开URL方式,可以选择只读、只写或者读写;
 * @param int_cb 中断回调接口,暂时没用到;
 * @param options  设置选项参数,暂时没用到;
 * @return >= 0 成功
 */
int avio_open2(AVIOContext **s, const char *url, int flags,
               const AVIOInterruptCB *int_cb, AVDictionary **options);

解析:avio_open2函数用于打开FFmpeg的输入\输出文件,当函数调用成功后,会为该文件创建一个对应的AVIOContext,通过AVIOContext来访问文件资源。其中,flags用于指定打开输入\输出文件的方式,如AVIO_FLAG_READ_WRITE(读写)、AVIO_FLAG_READ(只读)、AVIO_FLAG_WRITE(只写)(被声明在libavformat\avio.h头文件中)。

  • 函数:avcodec_copy_context
/**
 * 拷贝源AVCodecContext信息到目标AVCodecContext
 *    该函数被声明在libavcodec\avcodec.h头文件中
 *
 * @param dest 目标编解码器上下文(codec context)AVCodecContext
 * @param src 源编解码器上下文(codec context)AVCodecContext
 * @return 0 成功
 */
int avcodec_copy_context(AVCodecContext *dest, const AVCodecContext *src);

解析:avcodec_copy_context函数的作用是将源AVCodecContext的设置拷贝到目标AVCodecContext,需要注意的是,在拷贝之前,我们需要使用avcodec_alloc_context3avformat_alloc_output_context2函数初始化目标AVCodecContext,即创建和分配内存。另外,该函数已经被废弃了,虽然可用,但还是建议使用avcodec_parameters_from_context()avcodec_parameters_to_context()函数。

  • 函数:avformat_write_header
/**
 * 为流的private data分配内存,同时将流头部写到输出文件中。
 *    该函数被声明在libavformat\avformat.h头文件中
 *
 * @param s 用于输出的AVFormatContext;
 * @param options  可选项参数,暂未用到;
 *
 * @return >= 成功
 */
int avformat_write_header(AVFormatContext *s, AVDictionary **options);

解析:avformat_write_header()函数的作用是写输出视频文件的头部,其中,s是输出文件的AVFormatContext,因此在调用该函数之前,我们需要为该AVFormatContext分配内存,并获得输出文件对应的AVIOContext对象。

  • 函数:av_packet_rescale_ts
/**
 * 将AVPacket包中的有效计时字段(时间戳/持续时间)从一个转换为一个时基到另一个。
 *   该函数被声明在libavcodec\avcodec.h头文件中。
 * @param pkt 将被处理的数据包(存储的是编码后的数据)
 * @param tb_src 原始时间基
 * @param tb_dst 目标时间基
 */
void av_packet_rescale_ts(AVPacket *pkt, AVRational tb_src, AVRational tb_dst);

解析:av_packet_rescale_ts函数用于将将AVPacket包中的有效计时字段(时间戳/持续时间)从一个转换为一个时基到另一个,以确保音视频数据同步(因素之一)。时间基的作用就是要将PTS(Presentation TimeStamp,渲染时间戳)或DTS(Decodeing TimeStamp,解码时间戳)转换成以秒为单位的时间,其中,PTS用于视频渲染;DTS用于视频解码。ffmpeg中包含以下三种时间基:

tbr:是我们通常所说的帧率。time base of rate
tbn:视频流的时间基。time base of stream
tbc:视频解密的时间基。time base of codec

  • 函数:av_interleaved_write_frame
/**
 * 将数据包写入到输出文件中
 *    该函数被声明在libavformat\avformat.h头文件中。
 *
 * @param s 输出文件的AVFormatContext
 * @param pkt 将要被写入的数据包
 *
 * @return 0 成功
 */
int av_interleaved_write_frame(AVFormatContext *s, AVPacket *pkt);

解析:av_interleaved_write_frame函数用于将AVPacket中的压缩数据写入到输出文件中,它与av_write_frame函数不同的就是,前者适用于多个流或单一数据流情况,后者只适用于单一流。

2.2 实现原理

(1) 初始化FFmpeg引擎

 为了使FFmpeg正常工作,我们首先要初始化FFmpeg引擎,主要包括初始化所有muxers、demuxers、protocol以及编解码器等,因为在保存网络流过程中,需要解协议(解封装)得到里面的音视频数据、获取编码器信息、重新封装等。其中,av_register_all函数的作用就是初始化libavformat库和所有muxers、demuxers、protocol;avcodec_register_all函数的作用是初始化所有编解码器。(注:所谓所有,即FFmpeg裁剪后保留下来的。)

void initFFmpeg() {
    av_register_all();
    avcodec_register_all();
    // 设置FFmpeg引擎日志等级
    av_log_set_level(AV_LOG_VERBOSE);
    // 为一个AVPacket分配内存
    // 用于临时存储解协议得到的数据包
    g_save.avPacket = (AVPacket *) av_malloc(sizeof(AVPacket));
}

注:g_save的类型为SaveStream结构体,该结构体为自定义,具体如下:

typedef struct SaveStream{
 	AVFormatContext *inputCtx; 
 	AVFormatContext *outputCtx;
 	AVPacket *avPacket;
}FFmpegSaveStream;
// 声明一个全局FFmpegSaveStream变量
extern FFmpegSaveStream g_save;

(2) 打开输入URL

 在初探FFmpeg框架一文中,我们介绍到了AVFormatContext结构体描述了一个多媒体文件或流的构成和基本信息,是FFmpeg中最为基本的一个结构体,也是其他所有结构的根。因此,我们首先需要调用avformat_alloc_context()函数为输入的URL分配一个AVFormatContext结构体。然后,调用avformat_open_input()函数打开输入流和读取头部信息并将其存储到AVFormatContext。接着,调用avformat_find_stream_info函数读取一部分视音频数据并且获得一些相关的信息,通俗来说,就是探测流格式信息,比如编码宽高等。

int openInput(char *input_url){
    MLOG_I_("#### open url = %s", input_url);
    if(! input_url) {
        MLOG_E("#### input url is null in openInput function.");
        return -100;
    }
    // 初始化输入URL的AVFormatContext
    g_save.inputCtx = avformat_alloc_context();
    if(! g_save.inputCtx) {
        MLOG_E("#### alloc input AVFormatContext failed.");
        return -99;
    }
    AVDictionary *opts = NULL;
    av_dict_set(&opts, "rtsp_transport","tcp", 0); //设置tcp or udp,默认一般优先tcp再尝试udp
    av_dict_set(&opts, "stimeout", "3000000", 0);  //设置超时3秒
    // 打开URL,初始化输入文件的g_save.inputCtx
    int ret = avformat_open_input(&g_save.inputCtx, input_url, NULL, &opts);
    if(ret < 0) {
        MLOG_E_("#### open input url failed,err=%d(timesout?)", ret);
        return ret;
    }
    // 探测流的格式信息
    ret = avformat_find_stream_info(g_save.inputCtx, NULL);
    if(ret < 0) {
        MLOG_E_("#### find stream failed,err=%d", ret);
        return ret;
    }
    return ret;
}

(3) 打开输出文件

 同输入文件一样,对于输出文件我们也需要为其创建一个AVFormatContext结构体,但通过调用avformat_alloc_output_context2()函数实现,并且需要指定输出文件的封装格式,比如“mpegts”(MP4)、movmkv等。然后,调用avio_open2()函数创建并初始化一个AVIOContext来访问url表示的资源;接着,根据输入文件流信息为输出文件创建相应的stream(avformat_new_stream()),同时将输入文件流的编码器信息写入到输出文件的AVCodecContext(avcodec_copy_context);最后,调用avformat_write_header()函数写视频文件头,即完成对输出文件的初始化。

int openOutput(char *out){
    MLOG_I_("#### open output file = %s", out);
    // 初始化输出文件AVFormatContext
    int ret = avformat_alloc_output_context2(&g_save.outputCtx, NULL, "mpegts", out);
    if(ret < 0) {
        MLOG_E_("#### Allocate an AVFormatContext for an output format failed,err=%d", ret);
        closeOutput();
        return ret;
    }
    // 创建并初始化一个AVIOContext,用于访问url资源
    // app需要给存储权限,否则ret=-13
    ret = avio_open2(&g_save.outputCtx->pb, out, AVIO_FLAG_WRITE, NULL, NULL);
    if(ret < 0) {
        MLOG_E_("#### Create and initialize a AVIOContext failed,err=%d", ret);
        closeOutput();
        return ret;
    }
    // 根据inputCtx,为输出文件创建流
    // 获取每个流的编码器信息,为输出流复制一份
    int num_streams = g_save.inputCtx->nb_streams;
    for(int i = 0; i < num_streams; i++){
        AVStream * stream = avformat_new_stream(g_save.outputCtx,
                                                g_save.inputCtx->streams[i]->codec->codec);
        ret = avcodec_copy_context(stream->codec, g_save.inputCtx->streams[i]->codec);
        if(ret < 0)
        {
            av_log(NULL, AV_LOG_ERROR, "copy coddec context failed");
        }
    }
    // 为流的private data分配空间
    // 并将stream header写到输出文件中
    ret = avformat_write_header(g_save.outputCtx, NULL);
    if(ret < 0) {
        MLOG_E_("#### write the stream header to"
                " an output media file failed,err=%d", ret);
        return ret;
    }
    return ret;
}

(4) 从输入网络流读取视/音频数据

 从输入文件流中读取压缩数据很简单,只需要调用av_read_frame()函数即可实现将视频或音频读入到AVPacket中缓存起来,需要注意的是,每次读取最好是调用av_init_packet()函数初始化这个临时的AVPacket。另外,AVPacket存储的是压缩数据,且对于视频数据来说,存储的是一帧视频数据,而对于音频来说,可能存储了多帧音频数据。

int readAvPacketFromInput(){
    if(! g_save.avPacket) {
        return -99;
    }
    // 初始化临时AVPacket变量
    av_init_packet(g_save.avPacket);
    int ret = av_read_frame(g_save.inputCtx, g_save.avPacket);
    if(ret < 0) {
        MLOG_I("#### read frame error or end of file");
        return ret;
    }
    MLOG_I("----->read a frame");
    return ret;
}

(5) 写入数据到输出文件

 将读出的压缩数据写入到输出文件中,是通过调用av_interleaved_write_frame()函数实现的,相比av_write_frame来说,前者允许复用器muxers提前获取将要处理的packets相关信息。但是,在写入之前需要调用av_packet_rescale_ts()函数将AVPacket中的原始时间基转换为目标时间基,以确保音视频同步。

int writeAvPacketToOutput() {
    int ret = -99;
    if(! g_save.avPacket) {
        return ret;
    }
    AVStream *inputStream = g_save.inputCtx->streams[g_save.avPacket->stream_index];
    AVStream *outputStream = g_save.outputCtx->streams[g_save.avPacket->stream_index];
    if(inputStream && outputStream) {
        // 处理同步
        av_packet_rescale_ts(g_save.avPacket, inputStream->time_base, outputStream->time_base);
        // 写入数据到输出文件
        ret = av_interleaved_write_frame(g_save.outputCtx, g_save.avPacket);
        if(ret < 0) {
            MLOG_E_("#### write a packet to an output media file failed,err=%d", ret);
            return ret;
        }
    }
    MLOG_I("----->write a frame");
    return ret;
}

(6) 释放FFmpeg引擎资源

 关闭流,释放分配的内存资源。

void releaseFFmpeg(){
    closeOutput();
    closeInput();
    if(g_save.avPacket) {
        av_packet_unref(g_save.avPacket);
    }
}

void closeInput() {
    if(g_save.inputCtx) {
        avformat_close_input(&g_save.inputCtx);
        avformat_free_context(g_save.inputCtx);
    }
}

void closeOutput() {
    if(g_save.outputCtx) {
        for(int i = 0 ; i < g_save.outputCtx->nb_streams; i++) {
            AVStream * avStream = g_save.outputCtx->streams[i];
            if(avStream) {
                AVCodecContext *codecContext = avStream->codec;
                avcodec_close(codecContext);
            }
        }
        avformat_close_input(&g_save.outputCtx);
        avformat_free_context(g_save.outputCtx);
    }
}

FFmpeg错误代码

2.3 实战案例

 本节将在上节的基础上,演示Android平台如何使用FFmpeg引擎将网络流(rtsp、rtmp等)保存到本地文件中,且封装格式为mp4。为了不影响Android主线程的运行,在native层我们创建一个子线程来处理。FFmpeg的具体处理流程如下图所示:

Android直播开发之旅(16):使用FFmpeg保存网络流到本地文件_第1张图片

(1) 注册native方法

static JNINativeMethod g_methods[] = {
        {"nativeStart","(Ljava/lang/String;Ljava/lang/String;Lcom/jiangdg/natives/SaveStreamUtil$OnInitCallBack;)I", 
         (void *)save_start},
        {"nativeStop", "()I", (void *)save_stop}
};

extern "C"
JNIEXPORT jint JNI_OnLoad(JavaVM *jvm, void *reserved)
{
    JNIEnv *env;
    // 缓存JavaVM,获取JNIEnv实例
    g_jvm = jvm;
    if(jvm->GetEnv((void**)&env, JNI_VERSION_1_4) != JNI_OK) {
        MLOG_E("##### get JNIEnv object failed.");
        return JNI_ERR;
    }
    // 获取Java Native类
    jclass clazz = env->FindClass("com/jiangdg/natives/SaveStreamUtil");
    // 注册Natives方法,NELEM获得方法的数量
    if(env->RegisterNatives(clazz, g_methods, NELEM(g_methods)) < 0) {
        MLOG_E("##### register natives failed.");
        return JNI_ERR;
    }
    return JNI_VERSION_1_4;
}

 与以往在native层生成Java方法映射函数不同的是,本示例将在JNI_OnLoad()函数中通过调用JNIEnv$RegisterNatives()函数来实现,这种方法的好处就是我们无需在像以前样为每个Java native方法进行声明,并且JNI_OnLoad()函数在so被Java层加载时(System.loadLibrary(so))就会被调用,方便我们处理一些全局信息,如缓存JavaVM实例等。JNIEnv$RegisterNatives()需要传入三个参数,即Java层native方法类信息、JNINativeMethod类型的数组以及数组的元素个数,其中,JNINativeMethod类型数组存储的是Java层方法与native层函数的映射信息,该类型是一种结构体,包含三个成员变量,即Java层native方法、native方法签名、映射函数。JNINativeMethod结构体如下:

typedef struct {
    const char* name;      // native方法名
    const char* signature; // native方法签名
    void*       fnPtr;     // native方法的映射函数
} JNINativeMethod;

(2) 启动保存子线程

static jint save_start(JNIEnv *env, jobject thiz, jstring _url, jstring _out, jobject callback)
{
    g_quit = 0;
    if(!_url || !_out) {
        MLOG_E("#### save_start: url or output path can not be null");
        return -1;
    }
    c_url = jstring_to_string(env, _url);
    c_out = jstring_to_string(env, _out);

    g_callbackobj = env->NewGlobalRef(callback);

    // 启动子线程
    // sizeof(params)得到的是指针变量大小,固定占4字节
    params = (ThreadParams *)malloc(sizeof(ThreadParams));
    params->url = c_url;
    params->out = c_out;
    pthread_create(&id_save_thread, NULL, save_thread, params);

    return 0;
}

 为了不影响Android主线程正常运行,我们在nativeStart映射函数save_start中创建一个子线程来处理具体的业务,需要注意的是,考虑到在Java语言中对象作为参数在函数中传递总是传递的是对象实体而不是对象引用,因此,假如我们传入到nativeStart方法的_url_out是一个局部变量,当调用nativeStart的某个Java方法执行完毕后,也就是不等待save_start执行完毕,此时_url_out对象的引用将会被释放,而传入的对象就会直接"裸奔",容易被GC回收,从而导致底层save_start函数还未用出现访问异常情况。因此,我们需要对其在底层进行缓存再使用,当然,对于开辟的新内存注意合适的时候进行释放操作。jstring_to_string函数处理如下:

char * jstring_to_string(JNIEnv *env, jstring j_str) {
    const char * c_str  = env->GetStringUTFChars(j_str, JNI_FALSE);
    jsize len = env->GetStringLength(j_str);
    char * ret = NULL;
    // char * 默认末尾有'/0'
    if(len > 0) {
        ret = (char *) malloc((len+1) * sizeof(char));11
        memset(ret, 0, (len+1));
        memcpy(ret, c_str, len);
        ret[len] = 0;
    }
    env->ReleaseStringUTFChars(j_str, c_str);
    return ret;
}

(3) 初始化FFmpeg,处理数据

// 子线程函数入口
void *save_thread(void *args) {
    pthread_detach(pthread_self());
    JNIEnv *env = NULL;
    jmethodID methodId = NULL;
    // 将当前线程绑定到JavaVM,从JVM中获取JNIEnv*
	// 并得到回调接口方法
    if(g_jvm) {
        if(g_jvm->GetEnv(reinterpret_cast<void **>(env), JNI_VERSION_1_4)>0) {
            MLOG_E("Get JINEnv object failed.");
            return NULL;
        }
        if(JNI_OK != g_jvm->AttachCurrentThread(&env, NULL)) {
            MLOG_E("Get JINEnv object failed.");
            return NULL;
        }
        jclass cbClz = env->GetObjectClass(g_callbackobj);
        methodId = env->GetMethodID(cbClz, "onResult", "(I)V");
    }
	// 初始化FFmpeg引擎
    initFFmpeg();
    ThreadParams *params = (ThreadParams *)args;
    if(! params) {
        MLOG_E("#### get thread parms failed in save_thread.");
        if(env) {
            env->DeleteGlobalRef(g_callbackobj);
            g_jvm->DetachCurrentThread();
        }
        return NULL;
    }
    // 打开输入流
    int ret = openInput(params->url);
    if(ret < 0) {
        MLOG_E_("#### open input url failed,err=%d", ret);
        if(g_jvm && methodId) {
            env->CallVoidMethod(g_callbackobj, methodId, -1);
            env->DeleteGlobalRef(g_callbackobj);
            g_jvm->DetachCurrentThread();
        }
        closeInput();
        return NULL;
    }
    // 打开输出文件
    ret = openOutput(params->out);
    if(ret < 0) {
        MLOG_E_("#### open out file failed,err=%d", ret);
        if(g_jvm && methodId) {
            env->CallVoidMethod(g_callbackobj, methodId, -2);
            env->DeleteGlobalRef(g_callbackobj);
            g_jvm->DetachCurrentThread();
        }
        closeOutput();
        return NULL;
    }
    if(methodId) {
        env->CallVoidMethod(g_callbackobj, methodId, 0);
    }
    // 循环读取
    bool is_reading = false;
    while (! g_quit) {
        if(readAvPacketFromInput() == 0) {
            writeAvPacketToOutput();
            MLOG_I("##### write a packet data");
        }
        if(! is_reading) {
            is_reading = true;
            env->CallVoidMethod(g_callbackobj, methodId, 1);
        }
    }
    // 释放各种资源
    releaseFFmpeg();
    if(params) {
        free(params);
    }
    if(c_url) {
        free(c_url);
    }
    if(c_out) {
        free(c_out);
    }
    if(g_jvm) {
        env->CallVoidMethod(g_callbackobj, methodId, 2);
        env->DeleteGlobalRef(g_callbackobj);
        g_jvm->DetachCurrentThread();
    }
    MLOG_I("save stream success.");
    // void * 必须要返回NULL
    // 否则会报libc: Fatal signal 5 (SIGTRAP)错误
    return NULL;
}

 为了便于获取native层的处理情况,我们需要通过在native层调用Java层回调接口将处理结果反馈给Java层。需要注意的是,native层调用Java层接口、对象、方法等都是需要用到JNIEnv的函数,但是JNIEnv只对当前线程(一般为主线程)有效(全局缓存也没用,也只是对当前线程有效),在其他子线程是无法直接获取JNIEnv,因此,需要调用JavaVM$AttachCurrentThread()函数将该线程绑定到JavaVM(解绑使用JavaVM$DetachCurrentThread()),然后获取对应的JNIEnv指针。

注:JavaVM$GetEnv() < 0时,表示获取JNIEnv指针成功。

github项目地址:DemoSaveFile

效果演示:

你可能感兴趣的:(Android视频直播,【ffmpeg,开发艺术】)