FFmpegPlayer的播放链路
一般来讲,基于FFmpeg播放器的播放过程是这样的
看起来蛮复杂的,对吧。但是整体流程比较简单,我们这样来看
我们来看第一部分
媒体源的分析

一般来讲,音视频文件有很多种类型,有很多类型的容器
我们分成不同类型的音视频流来讨论
| 媒体源类型 | 常见场景 | FFmpeg 底层协议 | 开发优先级 |
|---|---|---|---|
| 本地文件 | MP4/MKV/FLV/MP3 等音视频文件 | file:// | 最高 |
| 本地音视频设备 | 摄像头、麦克风(采集播放) | v4l2/alsa | 中 |
| 网络流 | HTTP/HTTPS(点播)、RTMP/RTSP(直播) | http/https/rtmp/rtsp | 高 |
| 内存流 | 内存中读取音视频数据(如 Qt 缓存、网络分片) | 自定义协议 /avio_alloc_context | 中(扩展用) |
不同的封装格式具有不同的特点以及对应的适应场景
| 封装格式 | 核心特点 | 典型适用场景 | FFmpeg 支持度 | Linux 播放器开发注意点 |
|---|---|---|---|---|
| MP4 (M4V) | 兼容性最强(跨平台 / 设备)、基于 ISO-BMFF 标准、支持 H.264/AAC 主流编码 | 本地文件点播、移动端视频、HTTP 点播 | 完全支持(读写 + 解封装) | 1. 是优先级最高的适配格式;2. 注意处理 “fMP4”(分片 MP4,HLS/DASH 直播用) |
| MKV (Matroska) | 开源免费、支持几乎所有音视频编码(H.265/AV1/FLAC 等)、可封装多音轨 / 字幕 | 本地高清视频、多语言视频文件 | 完全支持 | Linux 下最常用的本地高清格式,需注意提取多音轨 / 字幕流索引 |
| FLV | 轻量化、适配 RTMP 直播流、仅支持 H.264/AAC/MP3 | 直播平台(如抖音 / 快手)、RTMP 流封装 | 完全支持 | 处理 RTMP 流时,FFmpeg 会自动解析 FLV 封装,需配置直播流的缓存参数 |
| TS (MPEG-TS) | 容错性强、断流后可快速恢复、标准流媒体格式 | RTSP/HTTP 直播(IPTV、安防摄像头)、HLS 分片 | 完全支持 | Linux 下处理 RTSP 流时,底层多为 TS 封装,需注意 PTS/DTS 时间戳对齐 |
| AVI | 老旧格式、无流媒体适配、封装效率低 | 早期本地视频文件 | 完全支持(仅解封装) | 仅做兼容处理,无需优先优化,注意处理音视频同步问题 |
| MOV | 苹果生态格式、基于 QuickTime、和 MP4 同源(ISO-BMFF) | 苹果设备录制的视频、专业视频编辑文件 | 完全支持 | Linux 下需注意部分 MOV 文件的 “私有编码”,FFmpeg 需编译对应解码器 |
| WebM | 开源、适配网页播放、主打 VP9/AV1 视频 + Opus 音频 | 网页视频、开源平台视频 |
媒体源的 解封装(Demuxer) 与分流
我们把音视频文件进行解封装 DEMUX ,基于不同的目标格式。
extern "C" {#include <libavformat/avformat.h>#include <libavutil/error.h>}
int demux_with_split(const char* url) { AVFormatContext* fmt_ctx = nullptr; AVPacket* pkt = av_packet_alloc(); int video_idx = -1, audio_idx = -1; // 音视频流索引 int ret = 0;
// 1. 打开源 + 提取流信息 if ((ret = avformat_open_input(&fmt_ctx, url, nullptr, nullptr)) < 0 || (ret = avformat_find_stream_info(fmt_ctx, nullptr)) < 0) { char err[1024]; av_strerror(ret, err, sizeof(err)); return ret; }
// 2. 查找音视频流索引(分流核心:通过索引区分音视频包) video_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); audio_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
// 3. 读包 + 音视频分流 while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index == video_idx) { // 视频包处理逻辑 // 如:video_queue->push(pkt); 需注意深拷贝包 } else if (pkt->stream_index == audio_idx) { // 音频包处理逻辑 // 如:audio_queue->push(pkt); } av_packet_unref(pkt); // 释放包引用 }
// 4. 资源释放 av_packet_free(&pkt); avformat_close_input(&fmt_ctx); return 0;}这是一个解封装的简单实例
其中,FFmpeg 本身就可以读取url的资源,FFmpeg调用
avformat_open_input(&fmt_ctx, url, nullptr, nullptr)) 格式上下文,URL,其他....
来进行对url流的解析,创建并填充 AVFormatContext即 fmt_ctx,其中会填充媒体的封装格式,meta信息等等。
av_read_frame(fmt_ctx, pkt) 是这个流程中的重要一环。
我们已知fmt_ctx是对应的格式上下文,已被avformat_open_input所填充,AVPacket pkt在上文中 alloc 成功。
程序通过while来不断地从资源读AVPacket包裹。
AVPacket 像一个容器一样,承载着装一个Frame的作用,所以说在av_read_frame之后,被FFmpeg
av_packet_unref掉,这部分需要资源释放。
在最后,调用 avformat_close_input(&fmt_ctx); 来结束这一环节。
这就是解封装的第一部分,最终存储在fmt_ctx里。
被解析的每个AVPacket里,都有一个字段叫stream_index,它代表了这个AVPacket的流的格式。
在这部分,根据这个字段的分辨来进行对应的入队操作。
音视频解码
我们将入队后的AVPacket进行处理。
#include <thread>#include <atomic>#include <queue>
extern "C" {#include <libavformat/avformat.h>#include <libavcodec/avcodec.h>}
std::atomic<bool> running{true};AVFormatContext* fmt_ctx = nullptr;int vidx = -1, aidx = -1;
std::queue<AVPacket*> video_queue;std::queue<AVPacket*> audio_queue;
void demux_thread(const char* url) { //DEMUX}
void video_decode_thread() { if (vidx < 0) return; const AVCodec* codec = avcodec_find_decoder(fmt_ctx->streams[vidx]->codecpar->codec_id); AVCodecContext* cctx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(cctx, fmt_ctx->streams[vidx]->codecpar); avcodec_open2(cctx, codec, nullptr);
while (running) { if (video_queue.empty()) continue; AVPacket* pkt = video_queue.front(); video_queue.pop();
AVFrame* frame = av_frame_alloc(); avcodec_send_packet(cctx, pkt); avcodec_receive_frame(cctx, frame); av_frame_free(&frame); av_packet_free(&pkt); } avcodec_free_context(&cctx);}
void audio_decode_thread() { if (aidx < 0) return; const AVCodec* codec = avcodec_find_decoder(fmt_ctx->streams[aidx]->codecpar->codec_id); AVCodecContext* cctx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(cctx, fmt_ctx->streams[aidx]->codecpar); avcodec_open2(cctx, codec, nullptr);
while (running) { if (audio_queue.empty()) continue; AVPacket* pkt = audio_queue.front(); audio_queue.pop();
AVFrame* frame = av_frame_alloc(); avcodec_send_packet(cctx, pkt); avcodec_receive_frame(cctx, frame); av_frame_free(&frame); av_packet_free(&pkt); } avcodec_free_context(&cctx);}
int main() { av_register_all(); avformat_network_init();
std::thread demux(demux_thread, "/test.mp4"); std::thread vdec(video_decode_thread); std::thread adec(audio_decode_thread);
getchar(); running = false;
demux.join(); vdec.join(); adec.join(); avformat_close_input(&fmt_ctx); avformat_network_deinit(); return 0;}这个部分看起来很多,但是两个线程的行为几乎是一样的。
我们手头有两个队列,分别是std::queue<AVPacket*> video_queue; 和 std::queue<AVPacket*> audio_queue;
对于软件来说,解码部分不应该阻塞主线程(UI线程),所以我们采用多线程来实现。
在main函数中调用两个线程 Join,触发解码部分,但是这片代码不严谨,因为它没有考虑线程安全,正常开发情况下是要考虑解码过程的线程安全以对内存泄漏的注意。
我们注意到,两个线程中有avcodec_find_decoder(fmt_ctx->streams[aidx]->codecpar->codec_id),返回值是 const AVCodec*,这部分是做什么的呢?
顾名思义,avcodec_find_decoder,在fmt_ctx格式上下文中根据stream[aidx](a代表Audio) 对应流的AVCodecParameters,也就是codepar中获取对应的编码id,封装好AVCodec,它是解码器的说明书。
AVCodecContext 是解码器的上下文,存储必要的参数,调用avcodec_parameters_to_context去打开解码器。
在while之后,解码循环开始了,循环体不断地将队列的AVPakcet弹出,送进对应的解码器里,在这同时,AVFrame承载解码后的帧,A/V帧代表了对应的解码结果,同时也需要队列去维护,再后面就是资源释放了。
音频重采样
我们为什么需要去做这一部分? 因为解码后的AVFrame音频格式是不固定的,不同来源的音频参数可能差距巨大!
但是实际上播放设备只支持一些固定的格式,所以我们才有了重采样这一部分
| 参数 | 说明 | 常见取值 |
|---|---|---|
| 采样率 | 每秒采集的音频样本数 | 44100Hz、48000Hz(主流) |
| 声道数 | 音频声道数量 | 单声道(1)、双声道(2) |
| 采样格式 | 每个样本的存储格式 | S16(16 位整型,最通用)、FLT(浮点型) |
这个表是重采样的一些内容(参数)
// 1. 初始化重采样上下文(设置输入/输出参数)SwrContext* swr_ctx = swr_alloc_set_opts( nullptr, av_get_default_channel_layout(2), // 输出:双声道 AV_SAMPLE_FMT_S16, // 输出:16位整型 44100, // 输出:44100Hz采样率 av_get_default_channel_layout(codec_ctx->channels), // 输入:解码帧声道 codec_ctx->sample_fmt, // 输入:解码帧采样格式 codec_ctx->sample_rate, // 输入:解码帧采样率 0, nullptr);swr_init(swr_ctx);
// 2. 执行重采样(将解码后的PCM帧转换为目标格式)uint8_t* out_buf = nullptr;int out_samples = swr_convert( swr_ctx, &out_buf, // 输出缓冲区 frame->nb_samples, // 输出样本数 (const uint8_t**)frame->data, // 输入PCM数据(解码帧) frame->nb_samples // 输入样本数);
// 3. 释放资源swr_free(&swr_ctx);通过前文的分析,整个的框架显而易见,操作都是初始化上下文为基础进行的。
我们在解码后可以获得codec_ctx->sample_fmt 和codec_ctx->sample_rate,作为基础的音频信息。
对应的输出格式我们自定义,一般都是将AV_SAMPLE_FMT_FLTP转化为AV_SAMPLE_FMT_S16,浮点转化为整型非平面格式。
具体的格式我们就暂不讨论。
音视频同步
这是播放核心最重要的一环,也是很难的一环,这个部分的实现成功代表着一个播放器基本合格。
为什么需要同步?
因为音频解码线程,和视频解码线程,它们分开后的处理方式是独立的。这就导致它们的处理速度天然不一致,比如视频解码来说耗时更长一些,放任这样的话就会导致视频里口型对不上等等严重错误。
DTS 和 PTS
PTS/DTS 时间戳:是同步的 “标尺”,所有同步逻辑都基于这两个时间戳:
-
PTSPresentation Time Stamp:「显示 / 播放时间戳」,表示该帧应该被显示(视频)/ 播放(音频)的时间; -
DTSDecoding Time Stamp:「解码时间戳」,仅用于视频帧解码顺序(如 B 帧),同步只看 PTS。(音频帧无 B 帧,PTS=DTS,因此音频时间戳更稳定)。
我们进行同步大多条件下应该以Audio pts为准,这是因为人耳对音频更敏感,视频连续播放的情况下丢掉几个帧,人眼是不容易发现的。
在这个基础上我们维护一个音频时钟。这个时钟听命于同步模式,如果我们是以音频为同步基础,那么这个视频就根据时钟对齐播放。
- 我们维护一个差值,叫
δ,也就是delta。
| 变量名 | 含义 |
|---|---|
audio_clock | 音频基准时钟(核心):当前音频实际播放到的时间(由音频帧 PTS + 已播放时长计算) |
video_pts | 视频帧的显示时间戳:该帧应该被渲染的时间(解码后从 AVFrame 中获取) |
delta | 同步差值:delta = video_pts - audio_clock |
delta > 0:视频帧 “偏慢”(视频该显示的时间还没到,音频已经播放到前面了);
delta < 0:视频帧 “偏快”(视频该显示的时间已过,音频还没播放到,视频跑在前面);
delta = 0:完美同步(理论值,实际几乎不存在)
| Δsync 差值范围 | 同步状态判断 | 处理策略(仅调整视频,不修改音频) | 策略原因 |
|---|---|---|---|
| −0.2s≤Δsync<−0.05s | 视频轻微 / 中度超前 | 1. 丢弃当前 1 帧,直接解码下一帧;2. 禁用视频额外缓冲,渲染速度小幅提升(1.05~1.1 倍) | 单帧丢弃无感知,轻量追平音频 |
| −0.05s≤Δsync≤0.05s | 完美同步(无感知) | 直接渲染当前帧,不做任何干预,维持正常帧率 / 缓冲 | 优先保证播放流畅性 |
| 0.05s<Δsync≤0.2s | 视频轻微 / 中度滞后 | 1. 延迟渲染:sleep (Δsync−0.005),预留 5ms 缓冲;2. 关闭视频硬解帧缓存,减少渲染延迟 | 温和延迟,等待音频追上 |
| Δsync>0.2s | 视频严重滞后 | 1. 跳过延迟直接渲染当前帧;2. 丢弃历史缓存 1~2 帧,一次性追平音频时钟 | 避免画面卡停,少量丢帧无感知 |
| Δsync<−0.2s | 视频严重超前 | 1. 一次性丢弃 2~3 帧(按帧率适配,如 25 帧 / 秒≈0.04s / 帧);2. 重置视频渲染时钟 |
我们经过音视频同步后处理的队列,就可以准备播放了
音视频播放
以Qt平台为例,我们使用Qt的QAudioOutput
音频播放
重采样后 PCM → 音频输出初始化 → 开辟播放缓冲 → 异步写入 PCM 播放 → 配合同步模块启停
- 初始化
QAudioOutput:传入 PCM 参数(采样率、声道、格式),创建音频输出设备; - 打开音频输出流,创建循环缓冲(避免 PCM 数据断流,缓冲大小建议 200ms);
- 从音频解码线程获取重采样后的 PCM 数据,异步写入缓冲,
QAudioOutput自动从缓冲取数据送声卡; - 联动同步模块:同步异常时(如 Δ_sync 超阈值),仅暂停 / 恢复缓冲写入,不直接停止音频播放(避免音频杂音)。
- 采用回调 / 异步写入模式,避免阻塞解码线程;
- 缓冲大小控制在100~200ms:过小易断流爆音,过大增加同步延迟;
- 音频播放不做任何速度调整:前序同步仅靠视频适配,音频始终以固定速率播放(保证基准时钟稳定);
- 异常处理:无 PCM 数据时,写入静音数据(全 0),避免声卡断流。
视频播放
视频播放依赖解码后 YUV 帧(前序视频解码输出,如 YUV420P),Qt 无原生 YUV 渲染支持,需先通过 FFmpeg 的swscale库将 YUV 帧转为 Qt 支持的RGB/ARGB 格式,再通过 Qt 组件渲染,核心是格式转换 + 帧率控制 + 同步联动。
链路为:
解码 YUV 帧 → swscale 格式转换(YUV420P→ARGB32) → 渲染帧缓存 → 按同步策略刷新渲染 → 联动音频时钟控速
结语
以上为基于FFmpeg的播放器的基本播放流程,它流程很简单,但是实际开发起来很复杂,如果深挖起来要说很久。这需要很大的耐心与毅力去参与到每个部分的调试当中去,但凡有一个路径的逻辑混乱了,输出将会是一片狼藉。
同时这也是我学习FFmpeg的过程中内心的感慨,自身的实力仍然需要打磨,还有很长的一条路要走。
部分信息可能已经过时






