FFmpeg之如何新增一个硬件解码器
- 前言
- 一、config配置
- 二、解码器定义
- 1.目录结构
- 2.数据结构
- 三、解码流程
- 1、初始化mediacodec_decode_init
- 2、帧接收mediacodec_receive_frame
- 2.1 解码上下文MediaCodecH264DecContext
- 2.2 发包AVPacket到解码器 -- ff_mediacodec_dec_send
- 2.3 接收解码后数据AVFrame -- ff_mediacodec_dec_receive
- 3、刷新缓冲区mediacodec_decode_flush
- 4、关闭解码器mediacodec_decode_close
- 四、回顾与总结
前言
最近在鸿蒙上开发音视频相关功能,在适配好SDL2之后,接入FFmpeg软解即可播放音视频,然对于4k大码率的视频,播放时却非常卡顿。于是乎琢磨着在鸿蒙上加一个FFmpeg的硬解码,PS:鸿蒙官方目前只提供解码相关 SDK。不过应该如何入手呢?想到这个鸿蒙跟安卓还是有点类似,解码都是异步回调机制,于是先捋一遍安卓下硬解码流程吧。
一、config配置
首先MediaCodec是Android平台提供的底层音视频编解码API,支持解码的格式有
- 视频:H.264(AVC)、H.265(HEVC)、VP8、VP9、MPEG-4、AV1
- 音频:AAC、MP3、Opus
下面以H.264格式为例,在configure文件中有以下两行:
h264_mediacodec_decoder_deps="mediacodec"
h264_mediacodec_decoder_select="h264_mp4toannexb_bsf h264_parser"
第一行"mediacodec"表示该解码器依赖 Android 的 MediaCodec API,在配置阶段,configure 脚本会通过配置的NDK路径,检测系统中是否存在这些依赖项;
第二行声明该解码器的关联组件,当 h264_mediacodec_decoder 被启用时,configure 会自动启用以下组件:
- h264_mp4toannexb_bsf:将 H.264 码流从 MP4 封装格式(AVCC)转换为 Annex B 格式。【这个后面会用到,稍后再讲~】
- h264_parser:H.264 码流解析器,用于解析码流中的 NALU 单元。.
在命令行指定一下相关参数即可开启硬件解码:
./configure \--target-os=android \--arch=arm64 \--enable-cross-compile \--sysroot=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot \--enable-mediacodec \--enable-decoder=h264_mediacodec
生成的MakeFile文件中有一行
OBJS-$(CONFIG_H264_MEDIACODEC_DECODER) += mediacodecdec.o
二、解码器定义
1.目录结构
在libavcodec目录下,mediacodec相关的文件有12个,如下:
2.数据结构
首先我们可以看下mediacodecdec.c中DECLARE_MEDIACODEC_VDEC相关的宏定义,
#define DECLARE_MEDIACODEC_VCLASS(short_name) \
static const AVClass ff_##short_name##_mediacodec_dec_class = { \.class_name = #short_name "_mediacodec", /* 注册到 FFmpeg 的类名 */ \.item_name = av_default_item_name, /* 默认的对象名称生成器 */ \.option = ff_mediacodec_vdec_options, /* 解码器配置选项指针 */ \.version = LIBAVUTIL_VERSION_INT, /* 版本号对齐校验 */ \
};#define DECLARE_MEDIACODEC_VDEC(short_name, full_name, codec_id, bsf) \
DECLARE_MEDIACODEC_VCLASS(short_name) /* 先声明 AVClass */ \
const FFCodec ff_ ## short_name ## _mediacodec_decoder = { \.p.name = #short_name "_mediacodec", /* 解码器名称 */ \CODEC_LONG_NAME(full_name " Android MediaCodec decoder"), /* 长描述 */ \.p.type = AVMEDIA_TYPE_VIDEO, /* 媒体类型:视频 */ \.p.id = codec_id, /* FFmpeg 编解码ID */ \.p.priv_class = &ff_##short_name##_mediacodec_dec_class, /* 私有类指针 */ \.priv_data_size = sizeof(MediaCodecH264DecContext), /* 私有数据区大小 */ \.init = mediacodec_decode_init, /* 初始化回调函数 */ \FF_CODEC_RECEIVE_FRAME_CB(mediacodec_receive_frame), /* 帧接收回调 */ \.flush = mediacodec_decode_flush, /* 冲刷缓冲区回调 */ \.close = mediacodec_decode_close, /* 关闭解码器回调 */ \.p.capabilities = AV_CODEC_CAP_DELAY | /* 支持延迟输出 */ \AV_CODEC_CAP_AVOID_PROBING | /* 避免格式探测 */ \AV_CODEC_CAP_HARDWARE, /* 硬件加速标志 */ \.caps_internal = FF_CODEC_CAP_NOT_INIT_THREADSAFE, /* 非线程安全初始化 */ \.bsfs = bsf, /* 关联的比特流过滤器 */ \.hw_configs = mediacodec_hw_configs, /* 硬件配置信息表 */ \.p.wrapper_name = "mediacodec", /* 封装器名称 */ \
};#if CONFIG_H264_MEDIACODEC_DECODER
/* 实例化 H.264 解码器结构体 */
DECLARE_MEDIACODEC_VDEC(h264, /* 短名称 */"H.264", /* 标准名称 */AV_CODEC_ID_H264, /* FFmpeg 编码ID */"h264_mp4toannexb") /* MP4 到 Annex-B 格式转换器 */
#endif
其中先声明一个AVClass 结构体,这个是FFmpeg 的类系统核心,用于统一管理编解码器的元数据,注册FFCodec解码器;
然后就是在解码器中注册四个回调函数:mediacodec_decode_init(初始化)、mediacodec_receive_frame(帧接收)、mediacodec_decode_flush(刷新缓冲区)、mediacodec_decode_close(关闭解码器)
最后可以看到capabilities属性中的AV_CODEC_CAP_HARDWARE标志,这个就是开启硬件加速,比较关键,如果没有这个标志那么就是用的软解了。
三、解码流程
1、初始化mediacodec_decode_init
mediacodec_decode_init里面主要做了两件事:
一、设置FFAMediaFormat媒体格式的一些属性,如MIME类型、context的宽和高等:
// h264对应的MIME为"video/avc"ff_AMediaFormat_setString(format, "mime", codec_mime);ff_AMediaFormat_setInt32(format, "width", avctx->width);ff_AMediaFormat_setInt32(format, "height", avctx->height);
最后通过调用NDK里面的相关函数设置(没有NDK的话就通过jni去调java的? 没细究 0.o)
二、ff_mediacodec_dec_init,其中首先通过
s->codec_name = ff_AMediaCodecList_getCodecNameByType(mime, profile, 0, avctx);
s->codec = ff_AMediaCodec_createCodecByName(s->codec_name, s->use_ndk_codec);
获取解码器名称并创建解码器,然后再配置解码器并启动
status = ff_AMediaCodec_configure(s->codec, format, s->surface, NULL, 0);
status = ff_AMediaCodec_start(s->codec);
上述函数均有NDK和JNI两套调用逻辑。
2、帧接收mediacodec_receive_frame
mediacodec_receive_frame是从解码器中获取解码后的视频帧,里面的核心流程是异步处理输入和输出缓冲区(通过队列管理)。当ff_mediacodec_dec_send被调用时,AVPacket数据会被放入输入队列,等待解码器处理。解码后的数据则从输出队列中取出,即ff_mediacodec_dec_receive函数负责从输出队列获取解码后的帧AVFrame。
2.1 解码上下文MediaCodecH264DecContext
不过在这之前首先来看下解码器上下文MediaCodecH264DecContext这个关键类的数据结构设计:
// H264 MediaCodec解码器上下文:
typedef struct MediaCodecH264DecContext {//AVClass 集成到FFmpeg的类系统中,用于日志记录、私有选项配置及参数解析AVClass *avclass;//MediaCodecDecContext指向通用的MediaCodec解码器上下文,h264只是其中的一个特化MediaCodecDecContext *ctx;//当输入数据包过大,无法一次性写入硬件缓冲区时,剩余数据暂存于此,等待后续处理AVPacket buffered_pkt;//延迟刷新解码器的标志,确保所有已提交数据被处理后再执行刷新int delay_flush;int amlogic_mpeg2_api23_workaround; // ?// NDK API更高效,减少Java层交互开销,适用于高性能需求场景int use_ndk_codec;
} MediaCodecH264DecContext;//通用的MediaCodec解码器上下文,管理硬件解码器状态:
typedef struct MediaCodecDecContext {AVCodecContext *avctx; //FFmpeg编解码上下文,用于日志和配置信息atomic_int refcount;atomic_int hw_buffer_count;char *codec_name;FFAMediaCodec *codec;FFAMediaFormat *format;void *surface; // FFANativeWindow * 渲染表面(用于零拷贝)int started;int draining;int flushing;int eos;int width;int height;int stride;int slice_height;int color_format;int crop_top;int crop_bottom;int crop_left;int crop_right;int display_width;int display_height;uint64_t output_buffer_count;ssize_t current_input_buffer;bool delay_flush;atomic_int serial;bool use_ndk_codec;
} MediaCodecDecContext;
在提交缓冲区给解码器解码的过程中,一般来说可以通过Surface(上面的void *surface就是指向渲染表面)或DMA 缓冲区共享技术实现零拷贝,减少 CPU 与 GPU 间的数据传输避免解码过慢。
2.2 发包AVPacket到解码器 – ff_mediacodec_dec_send
// 尝试获取输入缓冲区索引
index = ff_AMediaCodec_dequeueInputBuffer(codec, input_dequeue_timeout_us);
// 获取输入缓冲区的内存地址data和容量size
data = ff_AMediaCodec_getInputBuffer(codec, index, &size);
// 提交输入缓冲区到解码器
status = ff_AMediaCodec_queueInputBuffer(codec, index, 0, size, pts, 0);
2.3 接收解码后数据AVFrame – ff_mediacodec_dec_receive
//: 从解码器输出队列中获取缓冲区索引
index = ff_AMediaCodec_dequeueOutputBuffer(codec, &info, output_dequeue_timeout_us);
...
if (info.size) {// Surface 模式:通过 ANativeWindow 直接渲染到 Surface,无需拷贝数据。if (s->surface) {if ((ret = mediacodec_wrap_hw_buffer(avctx, s, index, &info, frame)) < 0) {av_log(avctx, AV_LOG_ERROR, "Failed to wrap MediaCodec buffer\n");return ret;}// ByteBuffer 模式:将 MediaCodec 的 ByteBuffer 数据复制到 AVFrame->data} else {data = ff_AMediaCodec_getOutputBuffer(codec, index, &size);if (!data) {av_log(avctx, AV_LOG_ERROR, "Failed to get output buffer\n");return AVERROR_EXTERNAL;}if ((ret = mediacodec_wrap_sw_buffer(avctx, s, data, size, index, &info, frame)) < 0) {av_log(avctx, AV_LOG_ERROR, "Failed to wrap MediaCodec buffer\n");return ret;}}s->output_buffer_count++;return 0;
} else {status = ff_AMediaCodec_releaseOutputBuffer(codec, index, 0);if (status < 0) {av_log(avctx, AV_LOG_ERROR, "Failed to release output buffer\n");}
}
...
if (ff_AMediaCodec_infoOutputBuffersChanged(codec, index)) {ff_AMediaCodec_cleanOutputBuffers(codec); // 清理旧缓冲区
}
3、刷新缓冲区mediacodec_decode_flush
mediacodec_decode_flush在视频播放中,当用户跳转进度时,需要清空之前的解码数据,这时候就会调用flush函数。
static void mediacodec_decode_flush(AVCodecContext *avctx)
{MediaCodecH264DecContext *s = avctx->priv_data;//av_packet_unref是FFmpeg中释放AVPacket资源的函数,//这里是释放MediaCodecH264DecContext 中缓存的未完全提交到硬件解码器的 AVPacket 数据包av_packet_unref(&s->buffered_pkt);//清空解码器的输入/输出缓冲区若,解码器正在处理数据(Executing 状态),flush会强制停止当前操作ff_mediacodec_dec_flush(avctx, s->ctx);
}
一般来说一下四种场景会调用flush:
a、视频播放器跳转进度:
用户拖动进度条时,需清空当前解码队列,避免旧数据与新位置的数据混合。
b、处理解码错误
当解码器因数据错误进入异常状态时,通过刷新重置其状态,恢复解码能力。
c、格式动态切换
切换分辨率或码率时,需先清空原有数据,再重新配置解码器。
d、结束流或重新初始化
在流结束或重新初始化解码器前,确保资源正确释放。
4、关闭解码器mediacodec_decode_close
通过引用计数管理声明周期,计数为0时依次删除MediaCodec、MediaFormat和Surface等对象,最后删除MediaCodecDecContext。
static void ff_mediacodec_dec_unref(MediaCodecDecContext *s)
{...// 原子操作:引用计数减1,若原值为1(减后为0),则释放资源if (atomic_fetch_sub(&s->refcount, 1) == 1) {ff_AMediaCodec_delete(s->codec); ff_AMediaFormat_delete(s->format);ff_mediacodec_surface_unref(s->surface, NULL);}
}
四、回顾与总结
综上所述,FFmpeg添加一个硬件解码器的关键步骤如下:
步骤 | 关键操作 |
---|---|
1. 配置编译 | 修改 configure 和 Makefile,添加新解码器选项 |
2. 定义结构体 | 注册 AVCodec,实现编解码器上下文 |
3. 初始化与配置 | 创建 MediaCodec 实例,设置格式参数 |
4. 数据传递 | 实现 send_packet 和 receive_frame,适配硬件缓冲区 |
5. 资源管理 | 处理刷新、关闭和引用计数,确保无内存泄漏 |
整体来说,硬件解码核心流程并不复杂,主要是要对NDK中的接口调用以及处理,相比于软件解码来说主要是在数据在CPU和GPU之间传输的不同,虽然硬件解码后支持GPU直接渲染,无需数据回传,不过若需要CPU处理(如滤镜),还需将数据从显存拷贝到系统内存。因此现代播放器常结合两者,优先尝试硬件解码,失败时回退到软件解码。鸿蒙平台和安卓很类似,无非是NDK不同罢了,不过NDK里面怎么写的那就不得而知了。(-_->