ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Qt+FFmpeg播放器源码实现:解码、渲染与音视频同步

Qt+FFmpeg播放器源码实现:解码、渲染与音视频同步 简介一款基于Qt5与FFmpeg开发的视频播放器完整源码面向具备C基础、希望快速掌握音视频播放器实现思路的开发者。压缩包共43个文件包含cpp/h源码、ui界面设计、qrc资源文件、pro与sln工程配置以及flv格式样例视频还附带了编译生成的obj、tlog、log等过程文件整体大小20.08MB。目前已有2450人学习下载源码经过验证可直接编译运行免去依赖搭建与工程配置的烦恼。工程结构清晰界面与FFmpeg解码逻辑分层便于对照学习Qt事件循环与音视频解码流程通过.ui文件可研究界面布局.qrc文件展示了资源打包方式FFmpeg调用逻辑则揭示了音视频解码的关键步骤。既适合直接复用播放器模块也可作为课程设计或毕业设计的参照是一份完整可落地的FFmpeg与Qt实践资料下载后即可对照源码动手验证。1. 为什么用QtFFmpeg做播放器而不是直接拿现成框架先把这个项目的定位说清楚。网上能直接用的播放器一大堆VLC、MPV、PotPlayer随便挑一个都比自己写的体验好那我为什么还要用Qt和FFmpeg从零搭一个播放器源码出来答案很简单工作中需要的是“能嵌入到自己业务系统里的播放内核”而不是一个独立的播放器成品。比如你在做一个工业巡检软件视频窗口只是界面里的一个模块要跟检测算法联动、要控制播放节奏、要把特定帧抽出来做图像处理——这些需求拿现成的播放器是做不到的。自己基于FFmpeg解码、基于Qt渲染等于把播放链路的核心环节全部握在自己手里。1.1 视频播放器的底层链路拆解不管是什么播放器底层干的活其实就四件事解封装Demux把MP4、MKV、FLV这类容器格式拆开提取出视频流、音频流、字幕流等数据包。FFmpeg的avformat_open_input和av_read_frame干的就是这事。解码Decode把H.264、HEVC等编码格式的压缩数据解码成原始的一帧一帧图像YUV和PCM音频数据。对应的API是avcodec_send_packet和avcodec_receive_frame。渲染Render把解码后的视频帧显示到屏幕上。Qt这边有两条路一是把YUV转成QImage直接画二是用OpenGL把YUV数据作为纹理上传到GPU渲染后者性能好很多。播放控制Control播放、暂停、拖动进度条、倍速播放本质上都是在对解码循环和渲染循环做状态控制。这个链路搞清楚之后写播放器源码就变成了一个模块对接的问题用FFmpeg解决前两步用Qt解决第三步自己写代码串起第四步。1.2 方案选型背后的取舍你可能要问Qt本身不是有QMediaPlayer吗为什么还要自己调FFmpeg我用过QMediaPlayer它在Windows下底层走的是系统自带的DirectShow或WMF框架最大的问题是解码能力取决于系统装了什么解码器。碰到H.265、4K高码率、或者一些特殊封装格式系统解码器不支持就直接黑屏没声音。FFmpeg就不一样它内置了几乎全套开源解码器不依赖系统环境你编译进去什么样的解码能力就是什么样。可控性这是我选FFmpeg而不是QMediaPlayer的核心原因。渲染方面也有取舍。刚开始做的时候用CPU转QImage渲染一个1080p的视频在普通笔记本上CPU占用率能飙到40%以上解码线程稍微慢一点画面就卡成PPT。后来改成OpenGL纹理渲染把YUV转RGB这件事交给了GPU性能立刻上了一个台阶。后面我会详细讲这一步怎么做。2. 播放器源码的整体架构设计这一节是重点。我把源码的模块设计思路拆开讲顺序是从整体到局部你先有个大局观后面看代码才知道每一段是干什么的。2.1 模块划分解码、渲染、音频、同步四大块整个工程我分成下面几个核心类和模块每个类职责单一方便扩展模块核心类职责解码模块VideoDecoder打开文件、读取数据包、解码音视频帧视频渲染VideoWidget基于QOpenGLWidget显示视频帧音频输出AudioPlayer通过QAudioOutput播放PCM数据同步控制PlaybackController音视频时钟同步、播放/暂停/seekUI层MainWindow播放器界面、控制条、事件交互一个核心的设计原则解码渲染不能阻塞UI线程。QML或者Widgets的界面事件循环一旦被阻塞整个窗口就会无响应所以解码操作必须跑在独立的线程里。我这边开了两个线程主解码线程负责视频帧的解码和渲染回调音频播放用的是QAudioOutput的push模式在音频设备的回调驱动下往缓冲区里喂数据。解码线程和UI线程之间通过信号槽通信比如解码线程发frameReady(QImage)给渲染窗口发positionChanged(qint64)给进度条更新。2.2 FFmpeg API版本兼容性处理很多人第一次编译FFmpeg相关的代码最头疼的就是API变了。FFmpeg 4.x、5.x、6.x之间的接口差异不小网上的老教程很多已经跑不起来了。比如最典型的几个avcodec_decode_video2这个老接口在FFmpeg 4.0之后被删了现在统一改成avcodec_send_packetavcodec_receive_frame这种异步模型。解码器初始化的时候需要手动avcodec_alloc_context3然后设置pCodecContext-codec_id等参数而不是直接avcodec_alloc_context。解码得到的AVFrame里YUV数据存储格式是分平面的data[0]存Ydata[1]存Udata[2]存V每行还有对齐的linesize不是连续的字节流。这点不搞清楚渲染出来就是花的。我的做法是统一按FFmpeg 6.0这套新版API来写代码里只依赖最新的接口。如果你用的版本比较老遇到编译报错先检查是不是API差异导致的再去查对应版本的迁移文档。2.3 线程模型解码线程与UI线程怎么配合再展开说一下线程模型音频输出的时序控制也要在这里交代。解码线程跑一个while循环不断读包、解码、按PTS时间戳决定是否推送。UI线程只负责接收视频帧信号来更新画面以及接收音频模块的播放进度来驱动进度条。音频播放则是独立的在QAudioOutput::start之后系统会主动调用你注册的回调接口来取数据你在回调里从音频缓冲队列里取PCM数据返回给系统。这样写的好处是音频时间线由系统声卡时钟驱动解码线程反而不需要精确sleep来掐时间只需要持续往缓冲区里补充数据就行。3. 核心实现细节与关键代码现在我挑代码的关键路径来讲你拿到源码对着看会清楚得多。完整代码量比较大这里不全部贴出来我把最容易写错、也是最核心的几段说清楚。3.1 解码循环打开视频、读取帧、解码帧第一步初始化FFmpeg上下文。注意新版API里av_register_all已经被移除不需要调用了。打开文件的代码如下// 打开文件并探测流信息 AVFormatContext* fmtCtx nullptr; int ret avformat_open_input(fmtCtx, filePath.toStdString().c_str(), nullptr, nullptr); if (ret 0) { qDebug() 打开文件失败错误码: ret; return false; } ret avformat_find_stream_info(fmtCtx, nullptr); if (ret 0) { qDebug() 无法获取流信息; return false; }接着需要遍历所有流找到视频流和音频流的索引然后分别打开对应的解码器for (unsigned int i 0; i fmtCtx-nb_streams; i) { AVCodecParameters* codecParams fmtCtx-streams[i]-codecpar; const AVCodec* codec avcodec_find_decoder(codecParams-codec_id); if (!codec) continue; if (codecParams-codec_type AVMEDIA_TYPE_VIDEO videoStreamIndex -1) { videoStreamIndex i; videoCodecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(videoCodecCtx, codecParams); avcodec_open2(videoCodecCtx, codec, nullptr); } else if (codecParams-codec_type AVMEDIA_TYPE_AUDIO audioStreamIndex -1) { audioStreamIndex i; audioCodecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(audioCodecCtx, codecParams); avcodec_open2(audioCodecCtx, codec, nullptr); } }解码一帧视频的循环新版API的核心逻辑是这样while (av_read_frame(fmtCtx, packet) 0) { if (packet-stream_index videoStreamIndex) { int ret avcodec_send_packet(videoCodecCtx, packet); if (ret 0) continue; while (avcodec_receive_frame(videoCodecCtx, frame) 0) { // 这里拿到一个完整的视频帧可转成QImage或纹理上传 handleVideoFrame(frame); } } else if (packet-stream_index audioStreamIndex) { // 音频包也走类似流程解码后送到音频缓冲队列 handleAudioPacket(packet); } av_packet_unref(packet); }提示avcodec_receive_frame返回AVERROR(EAGAIN)表示需要继续喂包返回AVERROR_EOF表示解码结束。这是新版异步API的正常流程很多初写者在这里把逻辑写成了死循环。AVPacket需要每次用av_packet_alloc分配解完包记得av_packet_unref释放引用不然内存涨得飞快。3.2 视频渲染QImage格式转换与纹理上传视频帧最常见的是YUV420P也叫I420格式Qt的QImage不直接支持所以要么转成RGB888再画要么用OpenGL做GPU转换。第二种方式性能损失小很多。先说用OpenGL的思路在QOpenGLWidget的paintGL里用glTexImage2D把Y、U、V三个平面分别上传为三个纹理然后通过一个简单的着色器程序把YUV转成RGB输出。这一步把最容易消耗CPU的YUV转RGB操作交给了GPU1080p视频在普通核显上跑也能保持很低负载。下面是用QOpenGLTexture管理的纹理创建关键代码GLuint texY, texU, texV; glGenTextures(1, texY); glBindTexture(GL_TEXTURE_2D, texY); glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, frame-data[0]); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // U、V平面类似尺寸分别是width/2、height/2着色器部分用经典的YUV转RGB公式要注意BT.601和BT.709色域的区别网络视频多数用BT.601// 片段着色器核心逻辑 vec3 yuv vec3(texture(texY, v_texCoord).r, texture(texU, v_texCoord).r - 0.5, texture(texV, v_texCoord).r - 0.5); vec3 rgb mat3(1.0, 1.0, 1.0, 0.0, -0.344, 1.772, 1.402, -0.714, 0.0) * yuv;如果你不想引入OpenGL也有一个简化方案用FFmpeg的sws_scale把YUV转成RGB888然后封装成QImage再update()到QLabel或QWidget上。这个方案代码少但性能差适合练手或处理低分辨率视频我这里也给你贴一下转换的核心部分SwsContext* swsCtx sws_getContext(width, height, AV_PIX_FMT_YUV420P, width, height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); uint8_t* rgbData new uint8_t[width * height * 3]; uint8_t* dstSlice[1] { rgbData }; int dstStride[1] { width * 3 }; sws_scale(swsCtx, frame-data, frame-linesize, 0, height, dstSlice, dstStride); QImage image(rgbData, width, height, width * 3, QImage::Format_RGB888);3.3 音频输出QAudioOutput播放PCM数据的两种方式音频解码后得到的是PCM裸数据Qt这边可以用QAudioOutput来播放。有两种模式Pull模式系统按固定频率主动调用你的readData回调你返回音频字节数据。这种方式节奏由声卡驱动不容易卡顿推荐用于播放。Push模式你主动往QIODevice里写数据适合一次性写入小段数据的场景但播放长音频时容易因为写太快导致内存积压。我推荐用Pull模式初始化QAudioOutput的时候传入音频格式采样率、声道数、位深然后自定义一个QIODevice子类在readData回调里从音频缓冲队列取出PCM数据返回给系统。伪代码如下class AudioIODevice : public QIODevice { public: qint64 readData(char* data, qint64 maxLen) override { return audioPlayer-pullData(data, maxLen); } qint64 writeData(const char* data, qint64 len) override { return len; } };这样音频播放的实际节奏完全由声卡的播放时钟决定而播放器主线程只需要按视频帧的PTS跟音频时钟比较即可达到音视频同步。3.4 音视频同步基于pts的主时钟方案说到音视频同步这是播放器里最容易翻车的地方。最常见的方案是以音频时钟为主时钟视频帧根据它的PTS时间戳跟音频当前的播放时间做比较如果视频帧的时间比音频早就等一等晚了就立即显示并丢弃迟到的帧。具体实现解码线程每次拿到视频帧的时候取帧里的pts用av_rescale_q把它从视频流的时间基转换成毫秒然后跟当前音频播放的位置比较qint64 videoPtsMs av_rescale_q(frame-pts, videoStream-time_base, AVRational{1, 1000}); qint64 audioClockMs audioPlayer-getCurrentPositionMs(); qint64 diff videoPtsMs - audioClockMs; if (diff 30) { // 视频比音频晚等一小会但要控制sleep粒度建议5ms一轮地等 QThread::msleep(5); } else if (diff -30) { // 视频落后太多跳过这帧不显示 return; }这里有个细节帧率一般24fps或30fps即每帧间隔33~41ms所以30毫秒的容差比较合理。如果容差太小网络视频会因为封装时间戳抖动而不停卡顿容差太大音画不同步就会变得很严重。这个值需要根据你目标播放的视频源特点做调整。3.5 Seek定位与播放状态切换Seek功能的核心API是av_seek_frame但它有个坑seek之后需要清空解码器的内部缓冲否则会解出seek前残留的帧画面花掉或者时间轴错乱。正确流程是这样void VideoPlayer::seekTo(qint64 targetMs) { // 1. 把目标时间从毫秒转成流时间基 int64_t targetPts av_rescale_q(targetMs * 1000, AVRational{1, 1000000}, videoStream-time_base); // 2. 执行seekAVSEEK_FLAG_BACKWARD表示向前找关键帧避免花屏 av_seek_frame(fmtCtx, videoStreamIndex, targetPts, AVSEEK_FLAG_BACKWARD); // 3. 清空解码器缓冲关键 avcodec_flush_buffers(videoCodecCtx); avcodec_flush_buffers(audioCodecCtx); // 4. 重置音频缓冲队列和时钟 audioPlayer-clearBuffer(); audioPlayer-resetClock(); }AVSEEK_FLAG_BACKWARD这个标志位一定要带上它的作用是seek到目标时间点之前最近的一个关键帧然后从关键帧解码到目标位置。如果不带这个标志解码器可能定位在目标时间之后的帧等真正显示到那一帧的时候时间已经过了画面会卡顿或者跳跳的。4. 环境搭建与编译配置这部分如果不提前准备好后面会一直出幺蛾子。我把Windows和Linux两条路的配置都写了你按自己的环境选。4.1 Windows下FFmpeg与Qt的安装配置第一步装QtQt 6.2以上开源版可以从官网下载安装时勾选MinGW 64-bit套件或者MSVC也行看你后面用哪种编译器。国内用户下载慢的话用中科大镜像站下载离线安装包会快很多。第二步装FFmpeg这里有个常见的坑——不要自己从源码编译FFmpeg耗时耗力还容易缺依赖。直接下载编译好的FFmpeg开发库shared版本解压后会看到bin、include、lib三个目录。然后在Qt工程的.pro文件里配置库路径# 注意路径改成你实际解压的位置 INCLUDEPATH D:/ffmpeg/include LIBS -LD:/ffmpeg/lib \ -lavformat \ -lavcodec \ -lavutil \ -lswscale \ -lswresample运行程序的时候记得把D:/ffmpeg/bin下的avcodec-*.dll、avformat-*.dll拷贝到exe同目录或者加到系统PATH里否则启动会提示找不到动态库。4.2 CMakeLists.txt关键配置如果你用的是CMake工程配置逻辑差不多但需要注意链接顺序。FFmpeg各库之间存在依赖关系链接的时候要把依赖别人的库放在前面否则某些Linux发行版会报未定义引用find_package(Qt6 COMPONENTS Widgets OpenGL Multimedia REQUIRED) find_path(FFMPEG_INCLUDE_DIR avformat.h) find_library(AVFORMAT_LIB avformat) find_library(AVCODEC_LIB avcodec) find_library(AVUTIL_LIB avutil) find_library(SWSCALE_LIB swscale) find_library(SWRESAMPLE_LIB swresample) add_executable(VideoPlayer ${SOURCES}) target_include_directories(VideoPlayer PRIVATE ${FFMPEG_INCLUDE_DIR}) target_link_libraries(VideoPlayer PRIVATE Qt6::Widgets Qt6::OpenGL Qt6::Multimedia ${AVFORMAT_LIB} ${AVCODEC_LIB} ${SWSCALE_LIB} ${AVUTIL_LIB} ${SWRESAMPLE_LIB} )有一点要提醒的Windows下MinGW编译器和MSVC编译器对FFmpeg库的兼容性不同如果你下载的是MinGW版FFmpeg工程必须用MinGW套件编译否则符号对不上。5. 踩坑记录与常见问题速查这个部分是我实际调试过程中遇到的问题汇总每条都是花了时间才定位出来的建议收藏对照。5.1 常见报错与解法报错信息原因分析解决方法error: av_register_all was not declared使用了旧版API新版FFmpeg已移除该函数直接删掉这行代码播放只有声音没有画面视频帧格式转换错误或渲染时序不对检查视频流实际像素格式用frame-format确认YUVJ420P等格式要单独处理画面拉伸变形QImage的bytesPerLine没处理对不能直接用width*3作为每行字节数要用image.bytesPerLine()拖动进度条后花屏seek后没有冲刷解码器缓冲调用seek后立刻执行avcodec_flush_buffers程序退出时崩溃解码线程还在跑UI资源已经被释放退出时先停止解码线程并join再释放FFmpeg上下文和Qt资源打开某些视频延迟高达好几秒avformat_find_stream_info在解析时长不影响功能但可以提示loading状态或用超时参数限制注意第一条提到的av_register_all是网上老教程最典型的过时代码如果你看过5年前的播放器源码基本都会遇到这个问题。新版FFmpeg初始化解码器不需要注册步骤直接用avcodec_find_decoder就行。5.2 播放流畅度与内存泄漏排查播放卡顿的排查思路我一般按这个顺序来先看CPU占用率。如果CPU满了大概率是YUV转RGB在CPU上跑优先换OpenGL渲染。如果CPU正常但画面依然卡看日志里解码线程是否丢帧严重——可以打印每帧的PTS和当前系统时间对比看看是不是解码速度跟不上。检查视频帧是否按PTS做了延时控制。有些新手直接把所有解码帧一股脑推给UI显示根本不看时间戳结果就是画面飞速播放然后卡死。必须按PTS等时间。内存泄漏排查我用valgrind或者Qt自带的QObject父子机制检查。常见的泄漏点是AVPacket没有unref、AVFrame没有av_frame_free、SwsContext没有sws_freeContext。每次解码循环结束前检查有没有把这几个资源释放掉。另外QImage转发的内存分配也是个隐藏的泄漏源。每次发一个QImage信号底层会拷贝一份图像数据频率很高的话内存占用会快速增长。优化的办法是用QSharedPointerQImage作为信号参数避免深层拷贝。6. 性能优化与功能扩展思路如果你已经能正常播放视频了那说明基础链路已经全部打通。接下来这段是进阶方向也是一个合格播放器源码该有的样子。6.1 性能优化四步走换硬解FFmpeg的h264_mediacodec、h264_qsv、hevc_cuvid等硬解解码器能把解码负载从CPU转移到GPU。在avcodec_find_decoder的时候优先查找硬解解码器找不到再回退软解。帧缓冲队列解码线程和渲染线程之间用一个定长队列解耦队列满则解码线程休眠队列空则渲染线程等待。避免解码一帧、渲染一帧这样串行阻塞。多线程解码新版FFmpeg支持AV_CODEC_FLAG_LOW_DELAY和thread_count配置对高分辨率视频设置codecCtx-thread_count 0让其自动选择最优线程数。减少颜色空间转换如果你的目标平台屏幕色彩格式支持YUV直接呈现例如通过DRM/KMS可以跳过RGB转换这一步直接渲染YUV平面性能能进一步提升但通用性会下降。6.2 功能扩展这些方向都能玩截图功能拿到视频帧之后用image.save()一行就能存成PNG。如果想截取原始YUV数据做算法分析也可以直接从AVFrame里导数据。倍速播放调整播放速度的核心是调整音视频的PTS缩放因子。视频方面可以让解码循环按倍数缩短等待时间音频方面可以用swr_scale重采样改变采样率来改变播放速度音调会变或者用FFmpeg的atempo音频滤镜音调不变。本地文件拖拽播放重写dragEnterEvent和dropEvent把拖入的路径传给解码模块即可体验感会提升很多。字幕支持FFmpeg能解出字幕流数据但显示需要自己画到视频帧上涉及字幕格式解析和字体渲染是个独立的大活想魔改的可以挑战一下。我在实际使用中的一个体会是播放器源码的调试难点不在写代码而在定位音视频不同步和渲染异常这类时序问题这类问题不像语法错误一样直接报错而是让你看到满屏的花屏或刺啦刺啦的声音只能靠不断加日志、对比时间戳来排查。最后再分享一个小技巧写播放器时把调试信息统一封装成一个宏比如#define DEBUG_TIME(qmsg) qDebug() QTime::currentTime().toString(hh:mm:ss.zzz) qmsg时间戳精确到毫秒级别排查“音频快了几十毫秒”这类问题时能少走很多弯路。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表