【FFmpeg之如何新增一个硬件解码器】
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里面怎么写的那就不得而知了。(-_->