ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

32位Qt MinGW下FFmpeg 3.4预编译库集成与解码避坑

32位Qt MinGW下FFmpeg 3.4预编译库集成与解码避坑

简介:ffmpeg-3.4-mingw32 是面向 32 位 Windows 平台、由 MinGW32 工具链编译的 FFmpeg 3.4 开发包,适合使用 Qt 与 MinGW 构建音视频播放、转码或推流功能的 C/C++ 开发者。它解决的是在不引入 Visual Studio 依赖的前提下,快速获得一套可直接链接的编解码库的问题,涵盖 libavcodec、libavformat、libavfilter、libswscale、libswresample 等核心模块。压缩包为 7z 格式,共 170 个文件,体积约 2.62MB:107 个 h 头文件与 6 个 dll、6 个 a 导入库、6 个 lib、6 个 def 定义文件共同构成编译与运行所需的完整环境,6 个 pc 文件便于通过 pkg-config 配置工程;21 个 c 文件中的 transcoding.c、muxing.c、demuxing_decoding.c、transcode_aac.c 等示例可直接作为接口调用范本,另有 makefile、readme 与 ffpreset 预设文件辅助构建与参数调试。目前已有 229 人学习下载,适合希望把 FFmpeg 嵌入 Qt 桌面应用,或需要一份轻量 32 位编译产物做二次开发的读者对照参考。

1. 32 位 Qt 工程找不到能用的 FFmpeg:一份 MinGW 预编译库的定位

老项目维护最难受的不是代码烂,是环境配不齐。Qt 的 MinGW 32 位 Kit 摆在那儿,业务上要抽视频帧做缩略图,一搜 ffmpeg 预编译包,十有八九是 64 位 MSVC 版本,拖进工程不是链接期报一堆 undefined reference,就是编译过了运行崩在 0xc000007b。ffmpeg-3.4-mingw32 这份资源解决的就是这一段:它把 ffmpeg 3.4 按 32 位 MinGW 工具链编好,include、lib、bin 三件套齐全,直接挂到 Qt 的 .pro 里就能编。适合两类人:还在维护 32 位 Qt 上位机、需要在老工控机上跑视频解析的;以及想避过自己搭交叉编译环境、从零编一份 3.4 的人。

2. 拆开目录看结构:include、lib、bin 三件套怎么对应

拿到一个预编译包,第一件事不是往工程里塞,是先看清楚它给了什么。32 位 MinGW 版的目录结构和 Linux 下的 FFmpeg 安装目录几乎一致,理解了这个对应关系,后面配 .pro 的时候就不会瞎猜路径。

2.1 三件套与八个库的对应关系

把包解开,正常会看到三个顶层目录。include 下按库名分子目录,lib 下是链接期要用的导入库或静态库,bin 下是运行期要用的 DLL。

路径内容什么时候用
include/libavformat、libavcodec、libavutil、libswscale、libswresample 等公共头文件,每个库一个子目录加进 INCLUDEPATH
lib/libavformat.dll.a 一类动态链接的导入库LIBS 里 -lavformat
lib/libavformat.a 一类静态库(如果这个包是静态编的)LIBS 同上,但要补系统库
bin/avformat-57.dll 一类运行时动态库拷到 exe 同目录

FFmpeg 不是一个库,是八个库拼起来的。Qt 工程里常见只用到五个,但得知道谁依赖谁:

库负责什么本次场景是否必需
libavformat容器解析、解复用、读包必需
libavcodec编解码器必需
libavutil内存、帧、像素格式、时间基等基础工具必需(被上面两个依赖)
libswscale像素格式转换、缩放必需(转 QImage 要用)
libswresample音频重采样做音频才用
libavfilter滤镜图一般不用,除非要 scale/crop 滤镜链
libavdevice摄像头、采集卡采集场景用
libpostproc老式后处理基本不用

判断这个包是动态还是静态很简单:lib 目录里如果是一堆 .dll.a,那就是动态版,bin 目录里一定有对应 DLL;如果只有体积很大的 .a 且没有 DLL,那就是静态版,链接时要额外补 ws2_32、secur32、bcrypt 之类的系统库。两种包的 .pro 写法差别不小,先确认清楚再动手,能省掉后面一半的排查时间。

提示:不确定版本时不要靠记忆,直接打开 include/libavutil/avutil.h,看 LIBAVUTIL_VERSION_MAJOR 这几个宏,比任何下载页描述都准。

2.2 为什么这个场景必须锁死 32 位 MinGW

有人会问,都什么年代了还用 32 位。答案不在技术先进与否,在现场设备。老工控机、老一体机、某些专用外设驱动,只跑 32 位系统;Qt 那套 Kit 又是当年配好的 mingw 32 位,重新装一套 64 位 MSVC 环境意味着整个工程的构建脚本、第三方库、部署流程全要重来。改一行环境配置的成本,往往比业务代码本身还高。

第二层原因是 ABI。MSVC 编出来的 .lib 给 MinGW 链接,符号修饰规则不一样,直接链不上;反过来也一样。所以库的编译器必须和 Qt Kit 的编译器家族对齐,MinGW 的库只能配 MinGW 的 Kit,位数也必须一致——32 位 Kit 配 32 位库,64 位 Kit 配 64 位库,混着来就是链接期或运行期的各种玄学。

这里有个容易忽略的好消息:FFmpeg 是纯 C 写的,不依赖 libstdc++。这意味着它对 MinGW 的 gcc 版本没那么挑剔,只要大版本别差太远,通常不会出现"编译期过、运行期 C++ 运行时冲突"的问题。这一点比接一个 C++ 写的第三方库要省心得多,也是我倾向于在这种老项目里直接用预编译 FFmpeg 而不是自己重编的原因。

当然代价也有:纯 C 库意味着它对外暴露的全是 C 结构体,没有 RAII,所有 AVFrame、AVPacket、AVCodecContext 都要手工释放,写错一个就是内存泄漏,在 32 位进程里泄漏的后果比 64 位明显得多——32 位用户态地址空间只有 2GB,解几段 1080p 就能把堆撑爆。

2.3 3.4 的 API 边界:哪些函数还能用,哪些已经埋雷

3.4 是个分水岭前后的版本,网上教程新旧混杂,照着 4.x、5.x 的文章写代码,在 3.4 上大概率编不过。几条必须记牢的边界:

av_register_all()在 3.4 里依然存在而且需要调用。它从 4.0 开始被标记弃用,后面的版本直接删了,所以你在新教程里看不到它,但在这份库里不调用它,某些封装格式会解析失败。

解码接口有两条路。老路是avcodec_decode_video2(),一个包进一帧出,逻辑直观;新路是avcodec_send_packet()/avcodec_receive_frame(),从 3.1 起就有。3.4 上两条都能走,我一般先用老路跑通,因为它对"解码器内部还有缓存帧需要 flush"这件事的处理更符合直觉——传空包进去就能把尾巴吐出来。

AVStream::codec这个字段在 3.4 里还在,直接用也能跑,但它已经是弃用状态。正确做法是avcodec_alloc_context3()配avcodec_parameters_to_context(),从codecpar拷参数。这么写的好处是将来换 4.x、5.x 时不用重写这段。

音频相关的字段差异更大。3.4 用的是channels加channel_layout两个字段,ch_layout这个 AVChannelLayout 结构要到很后面的版本才有。谁要是把新版本的音频代码抄过来,编译期就会卡在找不到成员上。

另外注意AVFrame::pkt_pts:3.4 里它还在,用来取当前帧对应的显示时间戳,配合best_effort_timestamp一起用比较稳。这个字段在新版本里已经改名换姓,属于典型的"版本一换就编不过"的位置。

3. 接进 Qt 工程:.pro 写法、链接顺序与 DLL 部署

结构看明白了,接下来是把它挂进 qmake 工程。这一步的坑集中在两个地方:链接顺序,以及运行期 DLL 到底该放哪儿。前者决定能不能编过,后者决定编过了能不能跑起来。

3.1 INCLUDEPATH 与 LIBS 的写法

建议单独建一个 ffmpeg.pri,用相对路径引用,别写死绝对路径——这种老项目经常要在几台机器之间来回拷。第三方库统一放在工程目录下的 3rdparty 里,路径就能跟着工程走。

# ffmpeg.pri FFMPEG_DIR = $$PWD/3rdparty/ffmpeg-3.4-mingw32 INCLUDEPATH += $$FFMPEG_DIR/include DEPENDPATH += $$FFMPEG_DIR/include # 依赖方在前,被依赖方在后 LIBS += -L$$FFMPEG_DIR/lib \ -lavformat \ -lavcodec \ -lswresample \ -lswscale \ -lavutil

链接顺序不是随便排的。GNU ld 处理静态库和导入库时是单向扫描:遇到一个未定义符号,它只会去后面列出的库里找。avformat 依赖 avcodec,avcodec 依赖 avutil,所以 avformat 必须写在 avcodec 前面,avutil 必须放最后。顺序反了,报错就是一堆 undefined reference toav_...,看着像库没链上,其实是顺序不对。

如果这个包是静态版,还要补系统库,常见做法是:

LIBS += -lws2_32 -lsecur32 -lbcrypt -lole32 -luuid -lshell32 -lwinmm

具体要补哪些取决于编译时的配置,报错缺什么补什么,别一次全加上,加多了反而可能引入符号冲突。

工程里再include(ffmpeg.pri)就行。有些团队习惯在 .pro 里用contains(QT_ARCH, i386)判断位数,我一般不加这层——位数不匹配的时候链接器会直接告诉你,加了判断反而容易掩盖问题。

3.2 头文件引入方式与两个必须定义的宏

FFmpeg 的头文件本身都带extern "C"守卫,理论上直接 include 就能用。但工程里如果还有别的 C++ 头跟它混在一个编译单元里,包一层更保险,也符合大多数人的阅读习惯。

// ffmpeg_common.h #ifndef FFMPEG_COMMON_H #define FFMPEG_COMMON_H #define __STDC_CONSTANT_MACROS // 老版本 libstdc++ 下 INT64_C 一类的宏需要它 #define __STDC_FORMAT_MACROS extern "C" { #include <libavformat/avformat.h> #include <libavcodec/avcodec.h> #include <libswscale/swscale.h> #include <libswresample/swresample.h> #include <libavutil/imgutils.h> #include <libavutil/opt.h> #include <libavutil/time.h> } #endif

__STDC_CONSTANT_MACROS是历史遗留。FFmpeg 的avutil/common.h里用到了INT64_C、UINT64_C这类宏,而某些老版本 libstdc++ 在 C++ 模式下会把这些宏屏蔽掉,结果就是编译报INT64_C was not declared。现在的 gcc 大多不需要了,但加上一行没坏处,尤其是在 gcc 版本不明的老 Kit 上。

统一走这一个头,好处是后面所有源文件都不用再重复写 extern "C",也方便在换包的时候只改一个地方。

3.3 DLL 部署:别指望 windeployqt

Qt 自带的 windeployqt 只认 Qt 自己的库,第三方 DLL 它一概不管。这一步翻车特别常见:开发机上能跑,因为 DLL 在 PATH 里或者就在构建目录旁边;拷到现场机器上就不行,双击没反应或者弹一个缺 DLL 的对话框。

最简单的办法是手工把 bin 下所有 DLL 拷到 exe 同目录。麻烦但不出错。工程多点的话用 qmake 的后置命令自动化:

win32 { FF_BIN = $$shell_path($$FFMPEG_DIR/bin) DEST_DIR = $$shell_path($$OUT_PWD/$$DESTDIR) QMAKE_POST_LINK += $$quote(cmd /c xcopy /y /q \"$$FF_BIN\\*.dll\" \"$$DEST_DIR\\\" > nul) }

$$DESTDIR在不同构建配置下可能是空、debug 或 release,所以这里用$$OUT_PWD/$$DESTDIR拼出来,而不是直接写死 debug 目录。> nul是为了别让 xcopy 的输出把编译日志刷满。

拷 DLL 的时候有个细节:debug 和 release 的 exe 目录是两个,都要拷。有人只在 debug 下测通过就交付,release 包漏了 DLL,现场直接打脸。

3.4 第一段能跑的验证代码:读视频元信息

配置完先别急着写解码,跑一个最小验证:打开文件、打印时长、分辨率、编码器名。这段代码能过,说明编译、链接、DLL 部署三关全通了。

#include "ffmpeg_common.h" #include <QDebug> #include <QString> bool probeMedia(const QString &file) { av_register_all(); // 3.4 必须先注册 AVFormatContext *fmt = nullptr; // Windows 下路径必须转 UTF-8,中文路径全靠这一句 if (avformat_open_input(&fmt, file.toUtf8().constData(), nullptr, nullptr) < 0) { qWarning() << "open failed:" << file; return false; } if (avformat_find_stream_info(fmt, nullptr) < 0) { qWarning() << "find_stream_info failed"; avformat_close_input(&fmt); return false; } int vIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); qDebug() << "duration(ms):" << fmt->duration / (AV_TIME_BASE / 1000) << "bitrate:" << fmt->bit_rate; if (vIdx >= 0) { AVStream *st = fmt->streams[vIdx]; AVCodecParameters *par = st->codecpar; AVCodec *dec = avcodec_find_decoder(par->codec_id); qDebug() << "video:" << par->width << "x" << par->height << "codec:" << (dec ? dec->name : "unknown") << "fps:" << av_q2d(st->avg_frame_rate); } avformat_close_input(&fmt); // 传指针的地址,函数内部置空 return true; }

几个点值得说明。file.toUtf8().constData()这句不是可选项,Windows 下avformat_open_input走的是标准 C 文件接口,收到的是 UTF-8 字节串,QString 直接toLocal8Bit在中文路径上会打不开——这个坑后面还会展开。

avformat_close_input(&fmt)传的是指针的地址,函数会把fmt置成 nullptr,所以后面不需要再手动置空。整个 FFmpeg 的释放接口都是这个风格:av_frame_free(&frame)、av_packet_free(&pkt)、avcodec_free_context(&ctx),一律传地址。记住了这一点,能少写一半的野指针。

fmt->duration的单位是 AV_TIME_BASE(1000000),换算成毫秒要除 1000。有些人直接拿它当秒用,结果算出来的时长差三个数量级,还以为是库的问题。

4. 解一帧出来:从 avformat_open_input 到 QImage 的完整链路

元信息读通之后,真正的活儿是解码。这条链路固定是六步:打开输入、找流、开解码器、读包、解码、转格式。每一步都有对应的释放动作,漏一个就是泄漏。

4.1 打开输入、找流、开解码器

前面 probe 那段代码已经走了前三步的一半,这里补齐开解码器的部分。关键是用codecpar拷参数,而不是直接摸stream->codec。

AVFormatContext *fmt = nullptr; if (avformat_open_input(&fmt, file.toUtf8().constData(), nullptr, nullptr) < 0) return false; if (avformat_find_stream_info(fmt, nullptr) < 0) { avformat_close_input(&fmt); return false; } int vIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (vIdx < 0) { avformat_close_input(&fmt); return false; } AVCodecContext *dec = avcodec_alloc_context3(nullptr); // 先分配空上下文 avcodec_parameters_to_context(dec, fmt->streams[vIdx]->codecpar); // 再灌参数 dec->thread_count = 1; // 32 位进程先别开多线程 AVCodec *codec = avcodec_find_decoder(dec->codec_id); if (!codec || avcodec_open2(dec, codec, nullptr) < 0) { avcodec_free_context(&dec); avformat_close_input(&fmt); return false; }

avcodec_alloc_context3(nullptr)传空指针是合法的,后面avcodec_parameters_to_context会把 width、height、pix_fmt、extradata 这些全填上。这比直接dec = stream->codec多写两行,但换版本的时候不用改。

thread_count = 1这行在 32 位进程里我一般会显式关掉多线程解码。FFmpeg 的帧级多线程会额外开几个解码上下文,每个都带自己的缓冲区,1080p 素材下内存占用能翻两三倍。32 位进程只有 2GB 用户态地址空间,片段一长就容易bad_alloc。如果确定素材分辨率不高,可以放开成 2 或 0(自动),但要有心理准备。

4.2 解码循环与 avcodec_decode_video2 的收尾语义

读包和解码是两层循环:外层av_read_frame一个个取包,内层判断这个包是不是视频流的,是就送进解码器。

AVPacket *pkt = av_packet_alloc(); AVFrame *pic = av_frame_alloc(); bool got = false; while (!got && av_read_frame(fmt, pkt) >= 0) { if (pkt->stream_index == vIdx) { int ret = 0; if (avcodec_decode_video2(dec, pic, &ret, pkt) >= 0 && ret > 0) { got = true; // pic 里现在是一帧有效数据 } } av_packet_unref(pkt); // 每次循环都要 unref,不能等循环结束 }

这里有两个容易写错的地方。第一,av_packet_unref(pkt)必须放在循环体内、每次读完就调,而不是等整个循环结束再调一次。av_read_frame每次会往 pkt 里填新的缓冲区,不 unref 就是一路泄漏。

第二,avcodec_decode_video2的返回值ret表示"这一帧是否完整解出来了",可能为 0——因为 B 帧的存在,送进去一个包不一定马上出一帧。所以判断条件是ret > 0而不是>= 0。很多人写成>= 0就拿着一帧空数据去转格式,结果要么崩,要么出来一张花屏。

循环结束后还有一步收尾:解码器内部可能还压着几帧没吐出来,要传一个空包进去把它挤干净。

// flush:传 nullptr 作为包,反复取直到没有帧产出 while (true) { int ret = 0; if (avcodec_decode_video2(dec, pic, &ret, nullptr) < 0 || ret <= 0) break; // 这里处理 pic }

只有当你要解完整段视频时这步才重要;只取首帧的话,读完第一帧就可以跳出了。但如果是做"抽第 N 帧"或者"逐帧导出"的功能,漏了 flush 就会少最后几帧,而且这个 bug 很隐蔽——正常视频的尾巴一般是黑场或片尾字幕,少几帧不容易被发现。

4.3 sws_scale 转 RGB888 并落到 QImage

解码出来的AVFrame是 YUV 格式,Qt 显示不了,必须转成 RGB。这一步用 libswscale。

SwsContext *sws = nullptr; QByteArray buf; QImage image; sws = sws_getCachedContext(sws, pic->width, pic->height, dec->pix_fmt, // 源 pic->width, pic->height, AV_PIX_FMT_RGB24, // 目标 SWS_BILINEAR, nullptr, nullptr, nullptr); int bufSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24, pic->width, pic->height, 1); buf.resize(bufSize); uint8_t *dstData[4] = { reinterpret_cast<uint8_t *>(buf.data()), nullptr, nullptr, nullptr }; int dstLinesize[4] = { pic->width * 3, 0, 0, 0 }; sws_scale(sws, pic->data, pic->linesize, 0, pic->height, dstData, dstLinesize); image = QImage(reinterpret_cast<const uchar *>(buf.constData()), pic->width, pic->height, pic->width * 3, QImage::Format_RGB888).copy(); // copy 不能省

sws_getCachedContext和sws_getContext的区别在于:前者在参数不变时会复用已有的上下文。逐帧处理时一定要用带 Cached 的版本,否则每帧新建一个 SwsContext,开销大到离谱,而且很容易忘记释放。

copy()这一句是重点。QImage 有个构造函数接受外部缓冲区指针,但它不接管所有权,只是持有一个指针。buf是局部变量,函数一返回内存就失效了,这时候再 paint 出去要么花屏要么崩。加一个copy()让 QImage 自己深拷一份,代价是一次内存拷贝,换来的是安全。

dstLinesize[0] = width * 3是 RGB24 的行字节数,因为一个像素三字节且没有行对齐填充。如果目标格式是 RGB32,这里就是width * 4。写错了会得到一张斜切的图——这是判断行跨度问题最直观的现象。

4.4 关键参数与内存归属对照表

写这类代码,脑子里要有一张"谁分配、谁释放"的表,不然一定会漏。

对象分配释放备注
AVFormatContextavformat_open_inputavformat_close_input(&fmt)传地址,内部置空
AVCodecContextavcodec_alloc_context3avcodec_free_context(&ctx)传地址
AVPacketav_packet_allocav_packet_free(&pkt)循环内还要 av_packet_unref
AVFrameav_frame_allocav_frame_free(&frame)不持有 data 时很轻
SwsContextsws_getCachedContextsws_freeContext用 Cached 版可跨帧复用
AVPacket 内部缓冲av_read_frame 自动填av_packet_unref每次循环都要调

几个常用字段的取值也要心里有数:dec->pix_fmt是解码器实际输出的像素格式,用它喂给 sws 而不是用codecpar->format,后者只是容器里声明的值,实际解码可能不一样;pic->linesize[0]是源数据的行跨度,可能大于 width,绝对不能想当然地按 width 算;st->time_base是流的时间基,通常是个分数,pts 乘它才得到秒。

5. 避坑与排查:32 位 FFmpeg 在 Qt 里翻车的五个现场

下面这五条基本都是我在实际项目里真撞过的,按"出现什么现象、为什么会这样、怎么解决"的顺序写,遇到了直接对号入座。

5.1 现象:链接期一串 undefined reference toav_register_all

原因通常是两个:库的顺序排错了,或者 Kit 的位数和库不匹配。前者的报错里往往能看到"某个库里的符号被另一个库引用",后者更隐蔽——32 位的 .dll.a 被 64 位链接器读到,会直接跳过,报的也是一模一样的未定义。

解决:先把 LIBS 改成-lavformat -lavcodec -lswresample -lswscale -lavutil这个顺序试一遍。还不行就看编译器实际调用的命令行,Qt Creator 的编译输出里能看到完整的 g++ 参数,确认-L指向的路径确实是这个 32 位包的 lib 目录,而且 Kit 是 MinGW 32-bit。这一步别猜,直接看输出。

5.2 现象:编译通过,双击 exe 弹"应用程序无法正常启动 0xc000007b"

原因几乎一定是 32/64 位 DLL 混用。系统里装了 64 位的 FFmpeg,PATH 里有它的 bin 目录,而你的 exe 是 32 位的,加载器找到 64 位 DLL 就报这个错。另一种可能是 Qt 的 DLL 版本不对,比如用了 64 位 Qt 的 Qt5Core.dll。

解决:不要依赖 PATH。把这份包 bin 目录下的 DLL 全部拷到 exe 同目录,确保同目录下没有其他来源的 FFmpeg DLL。用 Process Explorer 或者 depends 一类工具看实际加载了哪个路径的 DLL,比猜快得多。还有个土办法:把 exe 拷到一个干净的空目录,只放确认要用的 DLL,能跑就说明是环境干扰。

5.3 现象:运行时崩在 avcodec_open2 或者第一次 malloc 就挂

原因多半是运行库不一致。静态链接的 FFmpeg 可能用了不同的 CRT 配置,跟 Qt 这边的 MinGW 运行时冲突;也可能是 32 位地址空间被撑爆,只是崩的位置恰好在 malloc 上。

解决:先确认包是动态版还是静态版。动态版的话,bin目录下除了 av*.dll,可能还需要一并拷 MinGW 的运行时 DLL(libgcc_s_dw2-1.dll、libwinpthread-1.dll 之类),具体看 DLL 的依赖。如果确认是内存问题,把thread_count降到 1,检查解码循环里有没有漏掉av_packet_unref,再拿一个 30 秒的短视频对比测试。

5.4 现象:QImage 显示出来是花屏、绿屏或者颜色明显偏

原因分三种。一是把 YUV 数据直接塞进了 QImage,根本没走 sws_scale;二是走了 sws 但没copy(),缓冲区已经失效;三是 dstLinesize 算错了,RGB24 写成了 width 而不是 width * 3。

解决:先打印dec->pix_fmt和pic->width/height/linesize[0],确认源数据正常。然后重点查 dstLinesize,这个参数错的表现很有特征——图像会呈现规律的斜向错位,而不是全屏噪点。如果是整屏绿色,基本是像素格式判断错了,比如 source 传的是 AV_PIX_FMT_YUV420P 但实际数据是 NV12。

5.5 现象:英文路径一切正常,中文路径 avformat_open_input 返回失败

原因是字符编码。Windows 下avformat_open_input最终走标准 C 的文件打开接口,在 MinGW 环境下它按 UTF-8 解释传入的字节串,而 QString 转toLocal8Bit得到的是 GBK 字节,路径里带中文就对不上。

解决:统一用file.toUtf8().constData()。如果是通过 QFileDialog 拿到的路径,QString 本身就是正确的 Unicode,只需要在传给 FFmpeg 前转一次编码。反过来,如果要把 FFmpeg 里的元数据字符串(比如标题、作者)显示到界面上,那串是 UTF-8 字节,要用QString::fromUtf8()还原,别直接QString(...)。

6. 时间戳校准:让解码出来的帧对上播放时钟

只取首帧的场景到上一章就够了,但凡是做成播放器或者逐帧分析工具,迟早要面对一个问题:解出来的帧顺序对,时间对不上。表现出来就是画面和声音越走越偏,或者拖动进度条之后画面跳一下。

根子在时间基。容器里每个流有自己的time_base,帧上的 pts 是这个时间基下的整数计数,不是毫秒也不是秒。换算必须走av_q2d:

// 把帧的 pts 换算成秒 double frameSeconds(const AVFrame *f, const AVStream *st) { int64_t ts = f->best_effort_timestamp; if (ts == AV_NOPTS_VALUE) ts = f->pkt_pts; // 3.4 里这个字段还在,新版本已换名 if (ts == AV_NOPTS_VALUE) return -1.0; // 拿不到时间戳,交给上层兜底 return ts * av_q2d(st->time_base); }

best_effort_timestamp是 FFmpeg 帮你算过的"最可信时间戳",优先用它;拿不到再退回pkt_pts。两个都没有,说明这个容器里确实没写时间信息,只能按帧率累加,这时候就不要再纠结精度了。

有了秒数之后,播放时钟我一般用QElapsedTimer自己维护一个,而不是跟着系统时间跑:

QElapsedTimer clock; clock.start(); double baseMedia = 0.0; // 播放起点对应的媒体时间 // 每帧:算出这一帧应该出现在什么时候 double target = baseMedia + clock.elapsed() / 1000.0; double frameTs = frameSeconds(pic, fmt->streams[vIdx]); if (frameTs > target + 0.04) { // 快了 40ms 以上,等一等 QThread::msleep(static_cast<unsigned long>((frameTs - target) * 1000)); } else if (frameTs < target - 0.15) { // 慢了 150ms 以上,直接丢帧 continue; }

40ms 和 150ms 这两个阈值是可以调的:前者决定最多等多一会儿,后者决定容忍多大的滞后。窗口太小会频繁 sleep 导致卡顿,太大会让音画不同步变得明显。经验值是按一帧的时长取上下界,25fps 素材一帧 40ms,刚好对上。

验证这套逻辑是否靠谱,我用一个笨但有效的办法:拿同一段素材,用外部工具读出的总帧数和时长做基准,然后用自己写的循环跑一遍,比对三个数——解出来的帧数、首帧时间戳、末帧时间戳。三者对得上,说明时间轴处理没问题;帧数少了,多半是漏了 flush;首帧时间戳明显不为零且不是容器起始偏移,那是时间基用错了。

检查项期望结果不符时的排查方向
解码帧数与外部工具读到的帧数一致是否漏了 flush 空包
首帧 pts接近流的 start_time时间基是否用错
pts 序列单调不减是否用了 dts 而非 pts
末帧时间接近容器时长是否中途丢包

从那以后我每接一个新的 FFmpeg 预编译包,都强制先跑一遍这套自检:探元信息、解首帧、数帧数、打时间戳,四步走完再写业务代码。这套流程看着笨,但它能把位数、链接、DLL、像素格式、时间基这几类问题全部挡在业务逻辑之前,省下来的调试时间远超写这几行验证代码的成本。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进