21、FFmpeg播放器:基于FFmpeg+SDL2实现简易播放器核心逻辑

终于到了动手搭建播放器核心的时候了。

前面我们聊了很多FFmpeg的解封装、解码,SDL2的渲染和音频输出。现在,是时候把这些零散的知识点串起来,做一个真正能跑起来的简易播放器。

说实话,我第一次把视频画面成功渲染到窗口上时,心里还是挺激动的。虽然那会儿画面卡顿、音画不同步,但至少证明这条路走得通。

播放器的核心架构

一个播放器,说白了就是一条流水线。数据从文件里读出来,经过解封装、解码,最后送到显示器和扬声器。

我习惯把这条流水线分成三个模块:

  • 读取线程:负责从文件里读数据包,塞进队列
  • 解码线程:从队列取数据包,解码成原始帧
  • 渲染线程:把解码后的帧显示出来或播放声音

你想想看,如果这三个步骤都在一个线程里做,那画面肯定会一卡一卡的。因为读文件要等磁盘,解码要等CPU,渲染要等GPU。所以,多线程是必须的。

核心思路:生产者-消费者模型。读取线程是生产者,解码线程是消费者。中间用队列缓冲,避免互相等待。

下面这张图,就是我常用的播放器核心架构:

简易播放器核心架构图 读取线程 av_read_frame() 数据包队列 AVPacket 缓冲 解码线程 avcodec_send_packet() avcodec_receive_frame() 视频帧队列 AVFrame 缓冲 音频帧队列 AVFrame 缓冲 视频渲染 SDL_UpdateTexture() 音频渲染 SDL 音频回调 音视频同步控制 以音频时钟为基准 线程/模块 队列缓冲 同步控制

初始化流程

写播放器,第一步肯定是初始化。我一般按这个顺序来:

  1. 注册FFmpeg组件:av_register_all() 虽然在新版里已经废弃了,但老项目里还能见到。新版本直接用 avformat_network_init() 就行。
  2. 打开媒体文件:avformat_open_input(),这一步会读取文件头信息。
  3. 查找流信息:avformat_find_stream_info(),拿到视频流、音频流的索引。
  4. 打开解码器:根据流索引找到对应的解码器,avcodec_find_decoder() 然后 avcodec_open2()。
  5. 初始化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、暂停、播放列表等等。但核心逻辑就是这些。

嗯,写到这里,我觉得你已经掌握了播放器最核心的部分。剩下的就是多写代码,多调试。我第一次写的时候,光音画同步就调了三天。但当你看到画面和声音完美同步的那一刻,一切都值了。

公众号:蓝海资料掘金营,微信deep3321