
简介这是一份面向Qt开发者的视频解码显示示例工程基于FFMPEG 2.5.2在Windows平台完成视频解码并使用QPaint将帧绘制到QWidget控件上适合正在学习Qt多媒体开发或需要集成FFMPEG解码逻辑的中初级开发者。资源包共134个文件包含93个头文件、3个C源码、Qt工程与界面文件以及配套的FFmpeg动态库、静态库和导入库等压缩包整体约13MB目录结构紧凑便于直接对照工程研究解码流程。已有3405人学习下载。内容不仅提供可运行的示例程序还重点提示运行前必须将ffmpeg/bin目录下的dll拷贝到exe所在目录避免启动失败源码中演示了从打开视频文件、解码循环到帧图像绘制到QWidget的完整链路配合作者博客的分步讲解可帮助读者理解FFMPEG解码流程、QPaint绘制机制以及常见库依赖问题的排查思路非常适合作为Qt与FFMPEG入门实战的参考模板。1. Qt接FFmpeg解码视频并显示为什么明明能解码界面还是卡成翔用 Qt 接 FFmpeg 做“解码视频并显示”几乎是音视频桌面开发的必过一关。很多人以为难点在 FFmpeg 的 API 不好背真动手才发现解码本身几百行就能跑通真正让人原地爆炸的是解码线程、像素格式转换、UI 刷新这三件事怎么塞进同一个进程还不互相拖垮。这个需求背后是一类很实在的场景——自定义播放器、视频抽帧预览、录像回放工具凡是 Qt 自带的 QMediaPlayer 不够用、需要直接操纵裸码流的地方最终都会走到这个组合上。适合谁你至少见过avformat_open_input这个函数手头有基础 C 和 Qt 信号槽能力你想绕开 QMediaPlayer 对系统解码器和封装格式的限制你要在显示之前对每一帧动手脚比如叠加时间戳、做亮度分析。先说一个反直觉的结论视频卡顿多半不是解码太慢而是帧从解码线程到 UI 线程的这段路上没人管。2. 动手前先把架构想清楚线程、像素格式与显示路径解码显示这个方向代码写起来快但返工也快。我见过太多人把av_read_frame直接塞进QWidget::paintEvent里跑结果窗口拖不动、进度条点不了最后跑来问“是不是 FFmpeg 太慢”。其实问题不在解码速度而在你把阻塞操作放进了 UI 线程。这一章先把三个必选项定下来后面写代码才不会翻车。2.1 解码线程与UI线程一条铁律和它的例外铁律只有一句解码和像素格式转换放在后台线程UI 线程只负责接收“已经能显示的 QImage”并绘制。Qt 的 UI 线程跑着事件循环paintEvent、鼠标事件、定时器全都要靠它转起来。一旦在 UI 线程里做av_read_frame遇到一个网络流或者损坏的 MP4卡顿会直接传导给整个窗口。我一般建议用QThread派生一个解码工作线程在run()里写死循环通过信号把每帧 QImage 发回 UI 线程。如果只是做单次抽帧用QtConcurrent::run更轻量但播放场景一定要长驻线程否则每帧都创建一次线程的开销远大于解码本身。解码线程和 UI 线程之间还要夹一个帧队列队列上限控制在 3 到 5 帧。上限太小会 UI 来不及消费导致解码线程阻塞太大则播放延迟越来越明显。播放器最忌讳“点暂停了画面还继续走两三秒”就是队列里积压的帧没清空。这条铁律也有例外极小的视频缩略图、单次抽帧预览整个任务只有几十毫秒临时在 UI 线程里做一次问题不大。判断标准是耗时是否接近一个 UI 刷新周期约 16ms超过了就别碰 UI 线程。2.2 像素格式选型YUV420P、RGB24不转换行不行FFmpeg 解码出来的原始帧几乎不会是 RGB绝大多数 H.264/HEVC 流解码后是 YUV420P8bit planar有的硬件解码器会给 NV12。YUV420P 的数据布局是三个独立平面Y 亮度平面、U 和 V 色度平面U/V 的宽高是 Y 的一半。QImage 能直接显示的格式只有 RGB 家族比如Format_RGB888、Format_RGB32。所以你有两条路一是用sws_scale每帧把 YUV 转成 RGB24 再包装成 QImage简单直观二是保留 YUV用 QOpenGLWidget 把 Y/U/V 三个平面分别上传纹理在着色器里完成 YUV 到 RGB 的换算。第二条路省掉了一次 CPU 格式转换但要多写 shader 和纹理上传代码。选型建议很简单项目要快出效果、分辨率不高1080p 以内无脑走方案 A要做 4K、要跑在性能较弱的嵌入式平台、或者不想让 CPU 承担转换开销就选方案 B。另外注意一点不要写死AV_PIX_FMT_YUV420P。解码后先用av_pix_fmt_desc_get(dec_ctx-pix_fmt)打印一下实际格式有的解码器输出的是 YUVJ420P、NV12 之类写死格式会导致 sws_scale 报参数错误或画面偏色。2.3 显示路径对比QLabel、自绘还是OpenGL拿到 QImage 之后显示方式也有三种常见选择这里做一个对比方便你根据项目情况拍板显示方式CPU 开销跨线程友好度二次开发空间适合场景QLabel::setPixmap偏高QPixmap 转换在 UI 线程一般小验证解码链路、临时调试自定义 Widget 的paintEventdrawImage中等好中常规播放器、抽帧预览QOpenGLWidget绘纹理低较好但要自己管理纹理大4K、低延迟、视频处理工具QLabel 适合验证链路但不适合作为长期方案因为QPixmap::fromImage要把图像数据再拷贝一次1080p 时这个开销肉眼可见。自定义 Widget 是性价比最高的起点信号槽把 QImage 接进来update()触发重绘paintEvent里drawImage一行搞定。OpenGL 方案放在第 4 章展开它的收益在 GPU 接管缩放和色彩转换CPU 可以彻底腾出来。3. FFmpeg解码环节打开流、读取packet、送解码器架构定了接下来是 FFmpeg 的解码主体。这一章全是最常用的现代 API用的都是 FFmpeg 4.x 之后的标准写法。如果你在网上搜到avcodec_decode_video2、avcodec_copy_context这类函数直接划走那套老 API 已经被移除了。3.1 初始化从avformat_open_input到avcodec_open2的完整链路// 1. 打开文件/网络流拿到 AVFormatContext AVFormatContext *fmt_ctx nullptr; if (avformat_open_input(fmt_ctx, filePath.toUtf8().constData(), nullptr, nullptr) 0) { qWarning() open input failed; return false; } // 2. 读取流信息填充 streams、time_base 等元数据 if (avformat_find_stream_info(fmt_ctx, nullptr) 0) { qWarning() find stream info failed; return false; } // 3. 找到视频流下标 int video_stream_idx av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (video_stream_idx 0) { qWarning() no video stream; return false; } // 4. 用 codecpar 创建解码器上下文并打开 const AVCodec *decoder avcodec_find_decoder( fmt_ctx-streams[video_stream_idx]-codecpar-codec_id); AVCodecContext *dec_ctx avcodec_alloc_context3(decoder); avcodec_parameters_to_context(dec_ctx, fmt_ctx-streams[video_stream_idx]-codecpar); if (avcodec_open2(dec_ctx, decoder, nullptr) 0) { qWarning() open decoder failed; return false; }逻辑说明avformat_open_input只做容器层的打开不会解析帧数据avformat_find_stream_info会读取一段数据来填充流的时基、宽高和编码参数播放前必须调用否则time_base可能是空的。av_find_best_stream比手动遍历streams更稳它会把字幕、附注流过滤掉。参数说明filePath.toUtf8().constData()是为了处理带中文和空格的路径Qt 的QString不能直接传给 FFmpeg 的 const char* 接口这个转换不能省。avcodec_open2的最后一个参数可以传 AVDictionary常用做法是传threadsauto让解码器自动决定开几个线程。老代码里的avcodec_copy_context已经废弃现在统一用avcodec_parameters_to_context从codecpar拷贝参数。3.2 解码主循环av_read_frame与send/receive APIAVPacket packet; while (av_read_frame(fmt_ctx, packet) 0) { // 关键packet 可能是音频、字幕必须先按 stream_index 过滤 if (packet.stream_index ! video_stream_idx) { av_packet_unref(packet); continue; } // 送入解码器 int ret avcodec_send_packet(dec_ctx, packet); if (ret 0) { av_packet_unref(packet); break; } // 循环取出所有可用帧send/receive 不是一对一 AVFrame *frame av_frame_alloc(); while (avcodec_receive_frame(dec_ctx, frame) 0) { // 到这里 frame 就是一帧可解码的 YUV 原始数据 processFrame(frame); // 交给后续的格式转换和显示 } av_frame_free(frame); av_packet_unref(packet); }逻辑说明av_read_frame返回的是压缩后的AVPacket里面装的是 H.264 的 NAL 单元不是可以显示的像素这一步很多新手会误解。send/receive 是异步流式 APIavcodec_send_packet只是把压缩数据塞进解码器内部缓冲真正的解码发生在avcodec_receive_frame里。因为 B 帧重排的存在解码器可能输入多个 packet 才输出一个帧所以 receive 要放在内层循环里反复调用直到返回AVERROR(EAGAIN)。参数说明av_packet_unref和av_frame_free这两个清理不能漏AVPacket内部引用着缓冲区不 unref 会导致内存持续上涨跑几个小时的播放器迟早崩掉。processFrame是你接显示功能的入口注意这里拿到的AVFrame数据在下一轮循环会被复用如果要把帧保存下来必须先拷贝数据再离开这个回调。3.3 别忘了sws_scale从YUV到RGB的最后一步// 创建转换上下文源格式是解码器输出目标格式固定 RGB24 SwsContext *sws_ctx sws_getContext( dec_ctx-width, dec_ctx-height, dec_ctx-pix_fmt, dec_ctx-width, dec_ctx-height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); // 预分配目标 RGB 缓冲区align32 提高后续 SIMD 效率 AVFrame *rgb_frame av_frame_alloc(); av_image_alloc(rgb_frame-data, rgb_frame-linesize, dec_ctx-width, dec_ctx-height, AV_PIX_FMT_RGB24, 32); // 每帧转换src 是解码器的 AVFramedst 是 rgb_frame sws_scale(sws_ctx, frame-data, frame-linesize, // 源平面与每行字节数 0, frame-height, // 转换的纵向范围 rgb_frame-data, rgb_frame-linesize); // 目标平面逻辑说明sws_getContext负责把 YUV420P 转成 RGB24同时可以做缩放。上面代码里宽高保持原尺寸如果要做预览图可以把目标宽高改成缩略图尺寸sws_scale会一次完成缩放和格式转换。SWS_BILINEAR是双线性滤波画质与性能均衡SWS_FAST_BILINEAR更快但边缘会毛糙视频播放基本看不出区别静止画面放大时能看出来。参数说明构造 QImage 时必须用rgb_frame-linesize[0]而不是width * 3。av_image_alloc分配的行字节数会按 32 字节对齐可能比实际宽度多几个字节直接用width * 3做QImage的 bytesPerLine 会导致画面呈斜纹错位。这是一个非常典型的翻车点排查起来比改代码费时间得多。4. Qt显示环节两种能跑通的做法解码链路的终点是 Qt 的绘制。这一章给出两种方案方案 A 适合先跑通方案 B 是正式产品更倾向的选择。两种我都给核心代码尽量做到复制下来改改就能看到画面。4.1 方案AQImage加自绘paintEvent3分钟先看到画面解码线程里的processFrame实现// 假设 sws_ctx 和 rgb_frame 已经初始化好 void processFrame(AVFrame *frame) { sws_scale(sws_ctx, frame-data, frame-linesize, 0, frame-height, rgb_frame-data, rgb_frame-linesize); // 用 rgb_frame 数据构造 QImage注意 bytesPerLine 用 linesize QImage image(rgb_frame-data[0], dec_ctx-width, dec_ctx-height, rgb_frame-linesize[0], QImage::Format_RGB888); // 跨线程发送前强制拷贝一份隐式共享的坑见第5章 emit frameReady(image.copy()); }UI 线程这边// VideoWidget.h 里声明的槽函数 void VideoWidget::onFrameReady(const QImage image) { m_currentFrame image; update(); // 触发 paintEvent } void VideoWidget::paintEvent(QPaintEvent *) { QPainter painter(this); if (m_currentFrame.isNull()) { painter.fillRect(rect(), Qt::black); return; } // 按控件比例绘制保持宽高比 QImage scaled m_currentFrame.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); painter.drawImage(0, 0, scaled); }逻辑说明解码线程 emit 的信号通过 Qt 的队列连接传到 UI 线程槽函数这里天然是线程安全的。image.copy()必须在 emit 前做否则 QImage 引用的rgb_frame-data在下一轮sws_scale之后就会被覆盖。scaled每次绘制都做一次缩放对 1080p 来说开销不大如果帧率要求高可以改成只在控件尺寸变化时缩放一次。参数说明QImage::Format_RGB888对应每像素 3 字节数据必须连续如果你的 RGB 数据带 alpha改用Format_RGB32每像素 4 字节注意字节序是 BGR 还是 RGB与sws_scale的目标格式对应即可。scaled的Qt::SmoothTransformation质量好但慢播放时通常用FastTransformation更合适。4.2 方案BQOpenGLWidget直传YUV纹理省掉一次转换如果走 OpenGLsws_scale就不需要了解码线程直接把 YUV 数据丢给渲染线程。这里只给出核心的纹理上传和 shader 部分// paintGL 里上传 YUV 三个平面的纹理 glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, m_texY); glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, width, height, 0, GL_RED, GL_UNSIGNED_BYTE, frame-data[0]); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, m_texU); glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, width / 2, height / 2, 0, GL_RED, GL_UNSIGNED_BYTE, frame-data[1]); glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_2D, m_texV); glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, width / 2, height / 2, 0, GL_RED, GL_UNSIGNED_BYTE, frame-data[2]);着色器核心#version 330 core in vec2 v_uv; out vec4 fragColor; uniform sampler2D texY; uniform sampler2D texU; uniform sampler2D texV; // BT.709 系数配合 Y 减 offset 使用 const vec3 offset vec3(0.0625, 0.5, 0.5); const mat3 c mat3( vec3(1.0, 1.0, 1.0), vec3(0.0, -0.1873, 1.8556), vec3(1.5748, -0.4681, 0.0)); void main() { vec3 yuv; yuv.x texture(texY, v_uv).r; yuv.y texture(texU, v_uv).r; yuv.z texture(texV, v_uv).r; fragColor vec4(c * (yuv - offset), 1.0); }逻辑说明YUV420P 的 U、V 平面尺寸是 Y 平面的一半上传纹理时宽高要除以 2但纹理坐标仍然用 0 到 1 的范围OpenGL 会自动按纹理尺寸做采样映射。shader 里先把 Y 减去 16/2550.0625U/V 减去 128/2550.5再乘 BT.709 的转换矩阵。这里矩阵系数是列主序构造实际效果对应 R Y 1.5748V、G Y - 0.1873U - 0.4681V、B Y 1.8556U。参数说明GL_UNPACK_ALIGNMENT必须设成 1因为 YUV 平面的行字节数不保证按 4 字节对齐默认值是 4不改会出斜纹。纹理格式用GL_R8而不是老的GL_LUMINANCE后者在 OpenGL 3.2 核心模式里不可用。如果解码器输出 NV12就不能用三纹理方案NV12 的 UV 是交织在一个平面上的要改用双纹理加 shader 里拆分的写法。4.3 播放节奏控制QTimer只是刷新器不是播放时钟很多新手把QTimer当成播放时钟设个 30ms 的定时器去取帧结果视频越播越偏或者快慢不定。因为解码速度不恒定文件在不同码率段的耗时差异极大。正确的做法是让解码线程以最快的速度解码按帧的时间戳决定何时显示。AVFrame的pts字段就是这一帧的显示时间单位是流的time_base。换算成秒的公式是double pts_sec frame-pts * av_q2d(stream-time_base);。在 UI 线程维护一个“下一帧显示时间”用QElapsedTimer实测已经过了多少毫秒到了才触发绘制没到就继续等。这样无论解码快还是慢画面节奏都跟随文件本身的帧率走。另外注意pts可能是AV_NOPTS_VALUE这类帧通常是解码器在 seek 后产生的无效帧直接丢弃即可。进度条显示也用这个pts_sec比用“已读字节数除总字节数”准得多。5. 高频踩坑与排查花屏、丢帧、崩溃逐个对症下药这个方向的问题很集中我按真实项目中踩过的顺序列出来每条都是“现象 → 原因 → 解决”的结构方便你按图索骥。5.1 画面跳动丢帧packet没先判stream_index现象视频播着播着突然快进一段或者某个时间段画面反复重播时间进度却正常。原因av_read_frame读到的不只有视频 packet还有音频和字幕 packet。如果你不过滤stream_index音频 packet 也会被送到视频解码器解码器尝试按视频帧解码音频数据轻则出错丢帧重则输出乱码画面。解决进入循环后第一件事就是if (packet.stream_index ! video_stream_idx) { av_packet_unref(packet); continue; }这个判断要养成肌肉记忆。5.2 seek之后花屏绿屏解码器内部状态没清现象拖动进度条之后画面大面积花屏或绿屏持续一两秒甚至更久才恢复。原因seek 只是把文件指针移到新位置但解码器内部还保留着上一段的参考帧缓存。H.264 的解码依赖前向参考帧缓存里的帧和新位置的帧不对应就会解出花屏。解决seek 完成后必须调用avcodec_flush_buffers(dec_ctx)把解码器内部缓冲全部清空。同时清空你自建的显示帧队列否则老帧会排在前面继续显示。5.3 发绿发紫偏色pix_fmt与颜色矩阵不匹配现象画面整体发绿、发紫或者暗部发灰像蒙了一层白雾。原因源格式判断错误或者颜色矩阵用错。有些解码器实际输出 YUVJ420P全范围 YUV你按 YUV420P 去做sws_getContext转换矩阵就不对另一种情况是 OpenGL 方案里视频源是 BT.601 标准老视频你却用 BT.709 矩阵。解决解码后先用av_pix_fmt_desc_get(dec_ctx-pix_fmt)打印实际格式再做转换。shader 方案里可以动态读取frame-color_range判断 video range 还是 full range再决定用哪套 offset 和矩阵。5.4 结束帧被吞没给解码器drain现象视频播到最后一秒画面停在倒数第二帧或者文件结束但最后一帧没显示。原因FFmpeg 4.x 的 send/receive 流程里解码器内部还有 B 帧重排留下的缓存帧它们要等所有 packet 送完才输出。只在av_read_frame返回 EOF 后就结束循环这些缓存帧就永远出不来。解决循环结束后再调用一次avcodec_send_packet(dec_ctx, nullptr)然后继续循环avcodec_receive_frame直到返回AVERROR_EOF。这段 drain 逻辑被称为“解码器的后悔药”漏掉它结局就是丢帧。5.5 跨线程传QImage崩溃隐式共享的坑现象程序跑几分钟稳定崩溃崩溃栈指向 QImage 的析构函数或者在 Qt 控制台看到 “QImage used in QPixmap after thread” 警告。原因QImage 是隐式共享类你在解码线程构造的 QImage 数据只被引用计数持有。跨线程传信号时如果发线程先退出函数底层数据可能被回收收线程拿到的 QImage 还在引用一块已失效的内存。解决emit 信号前对 QImage 做copy()强制深拷贝让每个线程持有一份独立数据。更彻底的做法是在 OpenGL 方案里把解码线程的数据拷贝到自己的 AVFrame 缓冲渲染线程只访问这个缓冲。6. 进阶用法把帧率算准、把seek做干净基础播放跑通之后下一步通常是把交互做专业进度条可以拖拽帧率显示是真实值而不是写死的 25fps。这两个点看似小实际影响使用体验很大。帧率显示不要用1.0 / 解码器平均间隔去推算FFmpeg 在流信息里已经给了答案。用AVRational rate av_guess_frame_rate(fmt_ctx, video_stream, nullptr)拿到的rate.num / rate.den就是源视频的真实帧率比如 29.97 的 NTSC 视频会显示为 30000/1001。固定写成 30fps 的播放器遇到这种视频进度条和时间轴会缓慢积累误差。seek 要做干净不能只调av_seek_frame。我的习惯是三步走先按目标秒数换算成stream-time_base的整数时间戳调用avformat_seek_file然后立刻avcodec_flush_buffers(dec_ctx)清空解码器参考帧缓存最后把显示帧队列里残留的旧帧清空避免 seek 后头几帧还是拖动前的位置。换算公式是int64_t ts (int64_t)(seconds * av_q2d(stream-time_base_inv))更稳妥是av_rescale_q(seconds * AV_TIME_BASE, AV_TIME_BASE_Q, stream-time_base)。回头看我最早做的那个图像处理 Demo播放器就是把整个解码循环塞在 UI 线程里还用QThread::sleep节流1080p 一播就整个窗口卡死。后来养成两个习惯先跑通“线程分离 QImage 方案 A”看到画面再逐步换成 OpenGL 优化每个坑都按“现象→原因→解决”记下来。这套思路搬过来Qt 接 FFmpeg 做解码显示基本不会走到推倒重来的境地。希望帮到你。本文还有配套的精品资源点击获取