从零写一个 FFmpeg 播放器:架构设计远比“解码显示”复杂

基于 FFmpeg 构建跨平台播放器系统时,其架构设计通常遵循‌模块化、高内聚低耦合‌的原则,核心目标是实现高效的音视频解码、渲染及同步。

很多人学 FFmpeg,都是从这段代码开始的:

复制代码
ffplay input.mp4

然后自己照着抄一个:

复制代码
av_read_frame()
avcodec_send_packet()
avcodec_receive_frame()
SDL_RenderCopy()

跑起来,能播,很开心。

但一旦加上:

  • 暂停 / 继续

  • Seek

  • 音视频同步

  • 硬解

  • 倍速播放

  • 网络流 / 本地文件切换

👉 整个程序会瞬间崩塌。

这篇文章不讲 API 怎么调,而是站在工程视角 ,拆解一个可维护、可扩展、接近 mpv/ffplay 的播放器架构


一、先给结论:播放器 = 多线程流水线

播放器不是"解码器 + 渲染器",而是一个"实时多媒体流水线系统"。

它的核心矛盾只有两个:

  1. 生产者 / 消费者速度不匹配

  2. 音视频时间轴必须对齐

因此,一个合格的播放器,至少包含 5 个模块 + 3 条队列 + 1 个时钟


二、整体架构图(脑内先建立模型)

复制代码
┌────────────┐
│   Input    │  file / rtmp / srt
└─────┬──────┘
      │ av_read_frame
┌─────▼──────┐
│ Demuxer    │ 单线程
└─────┬──────┘
      ├───► PacketQueue(Audio)
      └───► PacketQueue(Video)
              │
   ┌──────────┴──────────┐
   │                     │
┌──▼───┐           ┌────▼──┐
│ Audio│           │ Video │  Decode Thread
│ Decoder│         │ Decoder│
└──┬───┘           └────┬──┘
   │ FrameQueue(Audio)   │ FrameQueue(Video)
   │                     │
   └───────▶ Clock ◀─────┘
           ▲
           │ A/V Sync
   ┌───────┴───────┐
   │  Audio Output │
   │  Video Render │
   └───────────────┘

所有复杂问题,最终都收敛到这张图上。


三、模块一:Demuxer(读包线程)

职责

  • av_read_frame()

  • 识别流类型

  • 将 packet 分发到对应队列

关键设计点

必须是独立线程

复制代码
while (running) {
    av_read_frame(fmt_ctx, &pkt);
    queue_push(pkt);
}

暂停 ≠ sleep

  • 暂停时:阻塞 packet queue

  • 而不是停 av_read_frame

Seek 是全局事件

  • 清空 packet queue

  • 清空 frame queue

  • avcodec_flush_buffers()

  • avformat_seek_file()


四、Packet Queue:第一道缓冲

为什么需要它?

  • 网络抖动

  • 解码速度不稳定

  • Seek 后需要预读

核心字段

复制代码
typedef struct {
    AVPacketList *first, *last;
    int nb_packets;
    int size;        // bytes
    int64_t duration;
    SDL_mutex *mutex;
    SDL_cond *cond;
} PacketQueue;

重要策略

  • 上限控制(防止 OOM)

  • 音视频独立队列

  • 时钟不在这里


五、Decoder:解码线程(音 / 视分离)

音频解码线程

  • 通常 快于播放

  • 受音频回调驱动

视频解码线程

  • 通常 慢于显示

  • 受 AVSync 控制

关键代码骨架

复制代码
while (running) {
    packet_queue_get(&pkt);
    avcodec_send_packet(dec_ctx, &pkt);
    while (ret >= 0) {
        ret = avcodec_receive_frame(dec_ctx, &frame);
        frame_queue_push(frame);
    }
}

⚠️ MKV / 网络流特别注意:

  • EAGAIN不是错误

  • 必须继续 read_frame

  • 硬解(DXVA2 / NVDEC)必须 reset


六、Frame Queue:第二道缓冲

为什么需要两层队列?

层级 作用
PacketQueue 平滑 IO
FrameQueue 平滑解码 & 同步

视频 FrameQueue 特点

  • 容量小(3--6 帧)

  • 严格 FIFO

  • 保存 PTS / duration

  • 硬解 surface 生命周期在此管理


七、Clock:播放器的"心跳"

没有 Clock,就没有播放器,只有幻灯片。

三个时间源

  1. Audio clock(最准)

  2. Video clock

  3. External clock(直播 / 无音频)

Clock 结构

复制代码
typedef struct {
    double pts;
    double pts_drift;
    double last_updated;
    int serial;
} Clock;

核心思想

复制代码
physical_time = get_monotonic_time()
clock = pts + drift
drift = last_pts - physical_time

八、AVSync:播放器最难的部分

视频同步到音频(最常见)

复制代码
diff = video_clock - audio_clock;

if (diff > 0) {
    // 视频快了 → 延迟显示
    usleep(diff * 1e6);
} else if (diff < -threshold) {
    // 视频慢太多 → 丢帧
    drop_next_frame();
}

丢帧策略(非常关键)

  • 只丢非参考帧

  • 不破坏 B-frame 依赖

  • Seek 后禁止丢帧


九、渲染线程(Audio / Video)

音频

  • 回调驱动(PortAudio / ALSA / WASAPI)

  • 永不阻塞

  • 不足补静音

视频

  • 独立线程 or UI 线程

  • 从 FrameQueue 取帧

  • 按 Clock 显示

硬解渲染(重点)

  • DXVA2:StretchRect → Present

  • VA-API:EGL + DRM

  • NVDEC:CUDA → OpenGL

⚠️ BeginScene / EndScene 不能包 StretchRect


十、状态机:播放器的灵魂

复制代码
enum PlayerState {
    STOPPED,
    OPENING,
    PLAYING,
    PAUSED,
    SEEKING,
    BUFFERING,
    ERROR
};

所有模块 只响应状态变化

  • Seek → 全部 flush

  • Pause → 暂停 packet 消费

  • Buffering → 阻塞渲染


十一、为什么 ffplay 看起来很简单?

因为 ffplay:

  • queue / clock / sync​ 藏在全局变量里

  • 大量 goto

  • 强耦合 SDL

  • 不适合二次开发

👉 ffplay 是"教学代码",不是"架构范本"。


十二、一个真实的坑清单(避坑用)

  • Seek 后第一帧黑屏(没等到 keyframe)

  • 暂停后 CPU 100%(不该忙等)

  • 网络流卡死后不恢复(没超时 + retry)

  • 硬解 surface 泄漏(FrameQueue 没 Release)

  • 音频视频不同步(Clock 没更新)

  • MKV 进度条跳动(byte seek 没 scan)


十三、推荐的工程目录结构

复制代码
player/
├── demux.c          # Demuxer 线程
├── packet_queue.c
├── frame_queue.c
├── audio_dec.c
├── video_dec.c
├── audio_out.c
├── video_render.c
├── clock.c
├── sync.c
├── state.c
└── player.h

十四、总结

**FFmpeg 播放器的复杂度,不在 API,而在"并发 + 缓冲 + 同步 + 状态管理"。**​

你不是在写播放器,而是在写一个 实时多媒体操作系统内核

相关推荐
kuinnebula3 小时前
MP4音频帧定位与提取
音视频
AI创界者6 小时前
开源硬核LTX-Video 本地部署整合包教程:超低显存生成高清AI视频,告别云端排队!
人工智能·aigc·音视频
海带紫菜菠萝汤7 小时前
H.264 宏块划分与码率分配机制:影响压缩效率的关键参数详解
前端·javascript·音视频
音视频牛哥7 小时前
实现低延迟音视频传输,WebRTC并非唯一选择
音视频·webrtc还是rtmp·webrtc还是rtsp·流媒体技术选型·webrtc和rtmp延迟·rtsp低延迟播放器·rtmp低延迟播放器
FFZero18 小时前
[mpv架构] (二) 为什么 mpv 要把 FFmpeg 的 I/O 换掉?
音视频·mpv
开开心心就好9 小时前
文件查重软件批量删除重复文件释放空间
java·开发语言·随机森林·ocr·excel·音视频·最小二乘法
半甜柠檬10 小时前
Agnes 生图生视频 API 接入实战:一个 Skill 的封装过程
音视频·ai生图·agnes·ai生视频
程序员老陆10 小时前
FFmpeg 硬解在 Linux 上:从“能跑”到“跑满 GPU”的实战笔记
linux·笔记·ffmpeg
开开心心就好10 小时前
文件批量重命名工具简单好用支持规则改名
java·开发语言·b树·ocr·excel·音视频·kmeans