21、FFmpeg播放器:基于FFmpeg+SDL2实现简易播放器核心逻辑
终于到了动手搭建播放器核心的时候了。
前面我们聊了很多FFmpeg的解封装、解码,SDL2的渲染和音频输出。现在,是时候把这些零散的知识点串起来,做一个真正能跑起来的简易播放器。
说实话,我第一次把视频画面成功渲染到窗口上时,心里还是挺激动的。虽然那会儿画面卡顿、音画不同步,但至少证明这条路走得通。
播放器的核心架构
一个播放器,说白了就是一条流水线。数据从文件里读出来,经过解封装、解码,最后送到显示器和扬声器。
我习惯把这条流水线分成三个模块:
- 读取线程:负责从文件里读数据包,塞进队列
- 解码线程:从队列取数据包,解码成原始帧
- 渲染线程:把解码后的帧显示出来或播放声音
你想想看,如果这三个步骤都在一个线程里做,那画面肯定会一卡一卡的。因为读文件要等磁盘,解码要等CPU,渲染要等GPU。所以,多线程是必须的。
核心思路:生产者-消费者模型。读取线程是生产者,解码线程是消费者。中间用队列缓冲,避免互相等待。
下面这张图,就是我常用的播放器核心架构:
初始化流程
写播放器,第一步肯定是初始化。我一般按这个顺序来:
- 注册FFmpeg组件:av_register_all() 虽然在新版里已经废弃了,但老项目里还能见到。新版本直接用 avformat_network_init() 就行。
- 打开媒体文件:avformat_open_input(),这一步会读取文件头信息。
- 查找流信息:avformat_find_stream_info(),拿到视频流、音频流的索引。
- 打开解码器:根据流索引找到对应的解码器,avcodec_find_decoder() 然后 avcodec_open2()。
- 初始化SDL:SDL_Init(),设置视频窗口和音频参数。
小技巧:打开解码器之前,最好先检查一下硬件加速是否可用。我在项目里遇到过,有些4K视频软解根本跑不动,后来加了硬解才流畅。
读取循环与队列管理
读取线程的逻辑其实很简单,就是一个死循环:
// 伪代码 - 读取线程
while (!is_quit) {
AVPacket pkt;
int ret = av_read_frame(fmt_ctx, &pkt);
if (ret < 0) {
// 读完了或者出错了
break;
}
if (pkt.stream_index == video_stream_idx) {
// 放入视频包队列
video_pkt_queue.push(pkt);
} else if (pkt.stream_index == audio_stream_idx) {
// 放入音频包队列
audio_pkt_queue.push(pkt);
} else {
av_packet_unref(&pkt);
}
}
这里有个坑,我踩过好几次。队列一定要加锁!因为读取线程和解码线程会同时操作队列。我习惯用 std::mutex 配合 std::condition_variable 来实现。
注意:av_read_frame() 返回的 AVPacket 需要手动释放内存。如果直接丢进队列就不管了,内存泄漏会非常严重。建议用 av_packet_alloc() 分配新包,然后 av_packet_ref() 拷贝进去。
解码与帧管理
解码线程从队列里取包,然后送给解码器。这里有个细节:
// 伪代码 - 解码线程
while (!is_quit) {
AVPacket pkt = video_pkt_queue.pop();
int ret = avcodec_send_packet(codec_ctx, &pkt);
if (ret < 0) {
// 发送失败,处理错误
av_packet_unref(&pkt);
continue;
}
AVFrame *frame = av_frame_alloc();
ret = avcodec_receive_frame(codec_ctx, frame);
if (ret == 0) {
// 成功解码一帧
video_frame_queue.push(frame);
} else {
av_frame_free(&frame);
}
av_packet_unref(&pkt);
}
为什么 avcodec_send_packet 和 avcodec_receive_frame 要分开调用?
因为解码器内部有缓冲。你送一个包进去,它可能吐出好几帧(比如B帧的情况)。或者你送好几个包,它才吐出一帧。所以这个循环要一直调用 receive,直到返回 EAGAIN 为止。
音视频同步
这是播放器里最头疼的部分。没有同步,画面和声音各跑各的,体验极差。
我常用的方法是以音频时钟为基准。音频是连续播放的,它的时间戳最可靠。视频渲染的时候,拿当前视频帧的PTS和音频时钟做比较:
- 如果视频帧比音频早太多,就丢帧(跳过去)
- 如果视频帧比音频晚太多,就等待
- 如果误差在合理范围内(比如±40ms),直接渲染
核心公式:delay = video_pts - audio_clock。delay > 0 说明视频快了,需要等;delay < 0 说明视频慢了,需要追。
我曾经在一个项目里,因为PTS计算错误,导致画面一直比声音慢半秒。查了两天才发现,原来是时间基没换算对。FFmpeg里的时间戳都是用 AVRational 表示的,一定要用 av_q2d() 转成秒。
SDL2渲染
视频渲染用 SDL2 的纹理。流程很简单:
// 创建纹理
SDL_Texture *texture = SDL_CreateTexture(
renderer,
SDL_PIXELFORMAT_YV12, // 注意像素格式
SDL_TEXTUREACCESS_STREAMING,
width, height
);
// 渲染循环
while (!is_quit) {
AVFrame *frame = video_frame_queue.pop();
// 同步逻辑...
SDL_UpdateYUVTexture(
texture, NULL,
frame->data[0], frame->linesize[0],
frame->data[1], frame->linesize[1],
frame->data[2], frame->linesize[2]
);
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
av_frame_free(&frame);
}
音频渲染稍微复杂一点。SDL2 需要你提供一个回调函数,它会在需要音频数据的时候调用你。你在这个回调里把解码好的音频数据填进去就行。
经验之谈:SDL2 的音频回调是在独立线程里调用的。所以你的音频帧队列也要加锁。我见过有人直接在回调里做解码,结果卡得不行。正确的做法是提前解码好,回调只负责拷贝数据。
完整流程总结
好了,我们把整个流程串起来看看:
| 步骤 | 线程 | 主要操作 | 注意事项 |
|---|---|---|---|
| 1. 初始化 | 主线程 | 打开文件、查找流、打开解码器、初始化SDL | 检查返回值,失败要释放资源 |
| 2. 读取 | 读取线程 | av_read_frame(),放入队列 | 队列加锁,注意内存释放 |
| 3. 解码 | 解码线程 | avcodec_send_packet/receive_frame | 处理EAGAIN,帧要alloc |
| 4. 视频渲染 | 渲染线程 | SDL_UpdateYUVTexture | 同步控制,丢帧策略 |
| 5. 音频播放 | SDL回调 | 拷贝音频数据到缓冲区 | 不要做耗时操作 |
这个架构虽然简单,但已经能跑通大部分视频了。当然,实际产品里还有很多细节要处理,比如seek、暂停、播放列表等等。但核心逻辑就是这些。
嗯,写到这里,我觉得你已经掌握了播放器最核心的部分。剩下的就是多写代码,多调试。我第一次写的时候,光音画同步就调了三天。但当你看到画面和声音完美同步的那一刻,一切都值了。