十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Qt、FFmpeg与SDL2的本地视频播放器开发实践

基于Qt、FFmpeg与SDL2的本地视频播放器开发实践 简介基于C、Qt、FFmpeg与SDL2构建的本地视频播放器源码面向希望深入多媒体开发、掌握跨平台GUI与音视频解码渲染的C开发者。压缩包共356个文件大小94.12MB其中以215个头文件、16个cpp源文件、7个ui界面文件为主并附有DLL动态库、LIB导入库及工具链配置文件能够帮助读者直观理解工程结构和依赖关系。已有554人学习浏览。借助此工程可系统学习FFmpeg完成视频流打开、解码、格式转换SDL2实现纹理渲染与窗口管理以及播放暂停、进度控制、音视频同步等关键环节同时内置的库文件与配置免去手动编译FFmpeg的繁琐步骤适合用作视频播放器开发的项目范本与课设参考。1. 从一处播放卡顿说起为什么是 Qt、FFmpeg 与 SDL2 三件套如果只是要写一个能播本地视频的播放器,最常见的第一反应是调用系统解码器或者直接用 QMediaPlayer 一把梭。但当你把一部 4K 高码率影片拖进窗口,或者需要逐帧 seek、显示字幕、自定义渲染倍速时,系统组件常常会甩给你一个不可控的黑框或者几十秒的音画不同步。这个标题里的三件套,其实对应的是播放器开发里三层完全不同的职责Qt 负责界面和事件循环,FFmpeg 负责把各种封装格式解封装并解码成原始帧,SDL2 负责把音频和视频帧以低延迟的方式推给声卡和显卡。三者的组合,是「自己控制每一帧」的常见工程方案,而不是封装良好的玩具级播放器。本文按一条可落地的路径展开先讲清楚为什么这三个库缺一不可,再给出基于 C 的最小工程骨架,接着写完整的解码、渲染、时钟同步代码,最后处理 windows 下 QtFFmpegSDL2 环境配置和发布时的常见坑。无论你是在 GitHub 上找过类似源码却没跑通,还是已经写完一个能播 480p 的 demo 想往高码率上靠,下面这套思路都值得照着敲一遍。提示本文涉及的项目标题为「基于 C QtFFMPEGSDL2 写的视频播放器源代码,可以播放本地视频」。由于只给出标题,所有代码、函数签名与参数以一套可行实现为例,不宣称出自某个特定开源仓库。2. 解码链路与选型理由FFmpeg 怎么把本地文件变成原始帧2.1 FFmpeg 在播放器里到底负责哪一段在动手编码之前,先明确边界。FFmpeg 是一个处理多媒体数据的库,它不是一个渲染器,也不是一个 GUI 框架。一个本地视频文件从磁盘到屏幕,大致经过四步读取封装格式如 MP4、MKV、AVI→ 分离音视频流 → 解码压缩数据H.264、AAC 等→ 输出原始的 YUV 帧和 PCM 音频采样。FFmpeg 在这四步里的定位是前三步的封装与解码,最后一步的渲染由 SDL2 接手,而用户交互、播放列表、进度条和按键响应由 Qt 负责。常见误解是把 FFmpeg 当成播放器全家桶,但 avformat_open_input 只是把文件句柄交给解复用器,av_read_frame 返回的是压缩后的 AVPacket,真正能显示的东西要等 avcodec_send_packet 和 avcodec_receive_frame 之后才出现。标题里同时出现 FFmpeg 和 SDL2,正是因为 FFmpeg 的输出AVFrame 中的 YUV420P 数据恰好是 SDL2 纹理输入的标准格式。如果你用 SDL2 的 SDL_CreateYUVOverlay 或新接口 SDL_UpdateYUVTexture 去更新纹理,就可以跳过一次 YUV 到 RGB 的软件转换,性能好很多。2.2 为什么不用 Qt 自带解码,而是 FFmpegQt Multimedia 模块里的 QMediaPlayer 在 Windows 上默认走系统解码器,碰到特殊编码、外挂字幕或者多音轨时会表现得很「黑盒」。若你希望播放器能像 ffplay 一样随时切换音频流、拖动 seek 到关键帧、甚至把硬解帧输出到自定义特效管线,就必须在 Qt 之外引入 FFmpeg。这套组合的门槛在于FFmpeg 是 C 语言接口,内存管理全靠手动,Qt 是 C 的元对象系统,两者之间需要一个桥接层。常见做法是单独封装一个 VideoDecoder 类,内部持有 FFmpeg 上下文,通过信号槽把解码后的 AVFrame 转成 QImage 或者直接交给 SDL2 渲染线程。另一个容易被忽略的理由是版本控制。FFmpeg 的 API 在 4.x 和 5.x 之间发生过多次废弃,例如 avcodec_decode_video2 老接口已经移除。选择新版本并使用 avcodec_send_packet/receive_frame 这套现代 API,好处是以后升级库版本时改动最小。标题里的「源代码」一般指完整工程,在工程里把 FFmpeg 和 SDL2 的版本号固定,比在 README 里写一句「需要最新版」要靠谱得多。2.3 音视频同步的核心模型以音频时钟为基准播放器开发与普通视频处理工具最大的不同在于必须实时。FFmpeg 解码出来的帧没有时间概念,它只是带着 PTS显示时间戳的原始数据。播放器需要一个主时钟来决定「当前这一帧该不该显示」。图简单用视频帧率循环,会导致音频忽快忽慢用系统高精度时钟对不上视频 PTS,又会出现音画漂移。最稳妥的做法是音频时钟作为主时钟,因为人对声音延迟更敏感。实现思路是SDL2 音频回调函数每请求一组数据,就把音频 PTS 推到一个全局或类成员变量上视频渲染线程在拿到解码后的视频帧后,对比该帧的 PTS 与当前音频时钟,差值大于阈值就延迟显示,差值小于 0 就丢帧追赶。这套逻辑在 ffplay 内部叫 master clock,代码量不大但必须单独抽出来。后面的代码章节里会直接把这一套写成 VideoPlayerCore 里的一个方法。3. 搭建 Windows 下的 Qt FFmpeg SDL2 编码环境3.1 工具链选择和版本搭配标题里的技术栈常见于 Windows 开发,但 Qt、FFmpeg、SDL2 都是跨平台库。写作本文时,一组稳妥的组合是Qt 5.15.2 使用 MSVC2019 64 位编译器,FFmpeg 5.1.2 或 6.0 的 windows 官方 build,SDL2 2.28.x。不建议使用 MinGW FFmpeg 混搭,因为 FFmpeg win 版发布包分为 gpl 和 lgpl,且 dev 包里的 .lib 文件名会带版本后缀,MSVC 环境下更容易配。C 标准选择 C17,MMF 模式在现代 Qt 中已不推荐。如果你是第一次配置,最容易卡住的地方是 Qt 里包含 FFmpeg 头文件时提示 errno 未定义。这是因为 FFmpeg 的某些头文件在 MSVC 下依赖额外定义,常见解决办法是在 pro 文件里加上 DEFINES _CRT_SECURE_NO_WARNINGS,并在包含 FFmpeg 头文件之前先包含标准头。这个坑几乎每个人都会遇到,记下来。3.2 用 .pro 文件组织依赖以下这段 .pro 配置可以直接抄进工程。注意路径要替换成你自己的实际目录,使用绝对路径最省事,但多人协作时建议改成环境变量。QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET QtFFmpegSDL2Player TEMPLATE app CONFIG c17 console DEFINES _CRT_SECURE_NO_WARNINGS # FFmpeg 路径 FFMPEG_HOME D:/libs/ffmpeg-6.0-full_build INCLUDEPATH $${FFMPEG_HOME}/include LIBS -L$${FFMPEG_HOME}/lib \ -lavformat \ -lavcodec \ -lavutil \ -lswresample \ -lswscale # SDL2 路径 SDL2_HOME D:/libs/SDL2-devel-2.28.5-VC INCLUDEPATH $${SDL2_HOME}/include LIBS -L$${SDL2_HOME}/lib/x64 \ -lSDL2 \ -lSDL2main SOURCES \ main.cpp \ playerwindow.cpp \ videocore.cpp HEADERS \ playerwindow.h \ videocore.h参数说明-lavformat对应 avformat.lib 的动/静态链接版本,FFmpeg 的 lib 目录里通常有带版本后缀或不带后缀的 .lib 文件,链接器会自动匹配。-lSDL2main会让 SDL2 接管 main 函数,因此你的 main.cpp 中不能再定义普通签名的 main,而是要用 SDL 提供的宏把入口替换成 SDL_main。如果不加-lSDL2main,则需要在 Qt 里手动调用 SDL_Init,比较绕。CONFIG 里加 console 是为了调试时看 FFmpeg 的日志输出,发布版可以去掉。3.3 验证 FFmpeg 和 SDL2 是否能被 Qt 正确引用配置完 pro 文件后,先写一个最简 main.cpp,只调用 av_version_info() 和 SDL_GetVersion(),编译运行。这一步能过滤掉大半环境问题,而不是等写完播放器再回来查链接错误。注意运行生成的 exe 时,需要把 FFmpeg 的 bin 目录中的 DLLavcodec-60.dll 等和 SDL2.dll 复制到 exe 旁边,或者把 D:/libs/.../bin 加进系统 PATH。以下代码是验证代码,不带任何界面。#include QDebug #include SDL.h extern C { #include libavcodec/avcodec.h #include libavformat/avformat.h } int main(int argc, char *argv[]) { Q_UNUSED(argc) Q_UNUSED(argv) qDebug() FFmpeg version: av_version_info(); SDL_version sdlVersion; SDL_GetVersion(sdlVersion); qDebug() SDL2 version: static_castint(sdlVersion.major) . static_castint(sdlVersion.minor) . static_castint(sdlVersion.patch); return 0; }为什么要把 FFmpeg 的头文件包在 extern C 里因为 FFmpeg 是 C 语言编译的,头文件内部没有 extern C 保护,而你的工程是 C,不包起来会出现链接时的一堆未解析符号,如 LNK2019。包含顺序也有讲究先包含 SDL.h,再包含 FFmpeg 头文件,可以降低两者对某个宏的命名冲突。如果你的工程里还需要 Qt 的 QImage 转换 FFmpeg 的 AVFrame,记得包含 libswscale。4. 用 FFmpeg 解码本地视频并交给 SDL2 渲染核心代码4.1 视频解码类的接口设计一个能用的播放器源码至少包含两个部分解码线程和渲染线程。解码线程负责打开文件、读包、解码;渲染线程负责把 AVFrame 转换成 SDL2 纹理并上屏。为了不让 Qt 的界面线程卡死,解码必须丢到 std::thread 里,不能用 QThread 里跑阻塞循环,否则关闭窗口时无法响应退出事件。下面先写头文件视频解码类。#ifndef VIDEOCORE_H #define VIDEOCORE_H #include QObject #include QTimer #include thread #include atomic #include queue #include mutex struct AVFormatContext; struct AVCodecContext; struct AVFrame; struct AVPacket; struct SwsContext; struct SDL_Texture; struct SDL_Renderer; struct SDL_Window; class VideoCore : public QObject { Q_OBJECT public: explicit VideoCore(QObject *parent nullptr); ~VideoCore(); bool openFile(const QString filePath); // 打开本地视频 void startPlay(); // 启动解码与渲染线程 void stopPlay(); // 停止播放并释放资源 void setPause(bool pause); qint64 currentPosition() const; // 当前播放进度(ms) signals: void frameReady(); // 通知 Qt 界面刷新(用于显示进度等) void playbackFinished(); private: void decodeLoop(); void renderLoop(); double getMasterClock() const; // 音频时钟 // FFmpeg AVFormatContext *fmtCtx nullptr; AVCodecContext *videoCtx nullptr; AVCodecContext *audioCtx nullptr; int videoStreamIndex -1; int audioStreamIndex -1; // 视频转换 SwsContext *swsCtx nullptr; AVFrame *swsFrame nullptr; // 转成 SDL 需要的 YUV420P // SDL2 SDL_Window *sdlWindow nullptr; SDL_Renderer *sdlRenderer nullptr; SDL_Texture *sdlTexture nullptr; // 控制 std::atomicbool playing{false}; std::atomicbool paused{false}; }; #endif // VIDEOCORE_H接口设计的思路openFile 只是打开文件并查找流信息,不启动解码;startPlay 创建两个 std::thread,解耦 I/O 和渲染。用原子变量 playing 和 paused 控制循环退出,避免直接调用 join 导致死锁。currentPosition 方法用于 Qt 的进度条轮询,内部直接读一个成员变量,不经过解码线程。4.2 openFile 的实现老接口的替代方案FFmpeg 新版本中,av_register_all() 已经被移除了,不需要再调用。openFile 的核心是打开文件、查找流的类型、初始化解码器。extern C { #include libavcodec/avcodec.h #include libavformat/avformat.h #include libswscale/swscale.h } bool VideoCore::openFile(const QString filePath) { // 1. 打开输入文件 if (avformat_open_input(fmtCtx, filePath.toUtf8().constData(), nullptr, nullptr) ! 0) { return false; } // 2. 读取文件内的流信息 if (avformat_find_stream_info(fmtCtx, nullptr) 0) { avformat_close_input(fmtCtx); return false; } // 3. 查找视频流和音频流 for (unsigned int i 0; i fmtCtx-nb_streams; i) { AVCodecParameters *params fmtCtx-streams[i]-codecpar; if (params-codec_type AVMEDIA_TYPE_VIDEO videoStreamIndex 0) { videoStreamIndex static_castint(i); } else if (params-codec_type AVMEDIA_TYPE_AUDIO audioStreamIndex 0) { audioStreamIndex static_castint(i); } } if (videoStreamIndex 0) { avformat_close_input(fmtCtx); return false; } // 4. 打开视频解码器 AVCodecParameters *videoParams fmtCtx-streams[videoStreamIndex]-codecpar; const AVCodec *videoDecoder avcodec_find_decoder(videoParams-codec_id); videoCtx avcodec_alloc_context3(videoDecoder); avcodec_parameters_to_context(videoCtx, videoParams); if (avcodec_open2(videoCtx, videoDecoder, nullptr) 0) { return false; } return true; }这段代码较容易忽略的点有三个。一是 avformat_open_input 的第二个参数,在 windows 下如果路径含中文,要用 toUtf8() 转码,否则某些版本的文件协议会拒收;二是必须调用 avformat_find_stream_info,因为很多封装格式的时长和索引信息要读一段包之后才准确;三是 avcodec_find_decoder 找到的可能是软解解码器,若标题提到的源码没有硬解要求,软解前期最容易跑通。4.3 解码循环send 与 receive 的字节流式处理解码循环是整套代码中生命周期最长的部分,负责从文件读取 AVPacket 并送进解码器。void VideoCore::decodeLoop() { AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); while (playing !paused) { int ret av_read_frame(fmtCtx, pkt); if (ret 0) { // 读完文件,可能要 flush 解码器 avcodec_send_packet(videoCtx, nullptr); emit playbackFinished(); break; } if (pkt-stream_index videoStreamIndex) { ret avcodec_send_packet(videoCtx, pkt); av_packet_unref(pkt); if (ret 0) { while (avcodec_receive_frame(videoCtx, frame) 0) { // 这里 frame 里是解码后的原始视频帧 // 在渲染线程中显示,避免在这里做耗时操作 // TODO: 渲染 av_frame_unref(frame); } } } else if (pkt-stream_index audioStreamIndex) { // 音频解码与 SDL2 播放,后续小节处理 av_packet_unref(pkt); } else { av_packet_unref(pkt); } } av_frame_free(frame); av_packet_free(pkt); }注意 av_read_frame 返回的 pkt 是带流索引的,必须判断 stream_index 再决定送哪个解码器。avcodec_send_packet 不管理 pkt 的内存,所以每次循环末尾都要 unref,否则内存会持续递增。代码中 TODO 的位置不应直接调用 SDL_RenderCopy,原因是解码循环与 SDL 的渲染器可能不在同一线程,SDL API 不是线程安全的,只有一个线程能创建渲染器和处理渲染命令。这里更合适的做法是维护一个帧队列,渲染线程从这个队列取数据。如果只是想在源码里快速看到画面,可以把解码后帧存到队列并抛给渲染循环。为了方便你测试,建议在 decodeLoop 里把 AVFrame 的 pts 通过 qDebug() 打印出来,验证解码是否通畅。pts 的单位是 time_base,一个一分钟的视频可能有几千甚至几万个 pts 值,全部打印会刷屏,只打印前 10 帧即可。4.4 SDL2 渲染线程从 AVFrame 到纹理再上屏渲染线程的核心是把 AVFrame 的 YUV 数据拷贝到 SDL2 纹理里。SDL2 支持 YUV420P 纹理格式,但 FFmpeg 解码出来的 AVFrame 可能不是连续的,也就是 linesize 不等于宽度。不能用 memcpy 一行行拷贝,要按 linesize 逐行复制,或者直接用 sws_scale 转成 SDL2 友好排布。SDL_UpdateTexture 可以直接接受非连续行数据,前提是给 pitch 参数传递一行的字节数。下面的 renderLoop 做了两件事从帧队列取帧,调用 SDL_UpdateTexture,然后 SDL_RenderClear/SDL_RenderCopy/SDL_RenderPresent。void VideoCore::renderLoop() { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) ! 0) { return; } int winWidth videoCtx-width; int winHeight videoCtx-height; sdlWindow SDL_CreateWindow(Qt FFmpeg SDL2 Player, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, winWidth, winHeight, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE); sdlRenderer SDL_CreateRenderer(sdlWindow, -1, SDL_RENDERER_ACCELERATED); sdlTexture SDL_CreateTexture(sdlRenderer, SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_STREAMING, winWidth, winHeight); while (playing) { // 从队列取一帧 YUV 数据 // 这里为了代码紧凑,假设 frames 队列放在成员中 AVFrame *frame nullptr; { std::lock_guardstd::mutex lock(frameMutex); if (!frameQueue.empty()) { frame frameQueue.front(); frameQueue.pop(); } } if (frame) { // 把 AVFrame 的 YUV420P 数据传给 SDL 纹理 SDL_UpdateYUVTexture(sdlTexture, nullptr, frame-data[0], frame-linesize[0], frame-data[1], frame-linesize[1], frame-data[2], frame-linesize[2]); av_frame_unref(frame); av_frame_free(frame); SDL_RenderClear(sdlRenderer); SDL_RenderCopy(sdlRenderer, sdlTexture, nullptr, nullptr); SDL_RenderPresent(sdlRenderer); } SDL_Delay(5); } SDL_DestroyTexture(sdlTexture); SDL_DestroyRenderer(sdlRenderer); SDL_DestroyWindow(sdlWindow); SDL_Quit(); }这段代码避开了一个隐蔽的坑FFmpeg 解码出的 YUV 格式是 YUV420P,而 SDL_YV12 的平面顺序是 Y, V, U, FFmpeg 的 AVFrame 里 data[1] 是 U, data[2] 是 V。SDL_UpdateYUVTexture 的参数顺序是 data[0], data[1], data[2],恰好对应 SDL_YV12 的 YVU 顺序。如果你的代码按地址顺序填,颜色会偏紫红。想要标准 YUV420P 的 U 和 V 顺序,应该使用 SDL_PIXELFORMAT_IYUV,也就是 data[1] 对应 U, data[2] 对应 V。建议创建纹理时优先用 SDL_PIXELFORMAT_IYUV,这样 match 得更直观。渲染循环里的 SDL_Delay(5) 是为了降低空转,但实际播放时的节拍不完全由这个 delay 控制,更精确的控制要在 4.5 节做 PTS 对齐。4.5 音频播放与音视频同步的最小实现音视频同步是本项目标题的隐藏难点。用较简单的方式可这样实现SDL2 打开音频设备,音频回调函数里填充解码后的 PCM 数据,同时把当前播放到的时间存成 g_audioClock;视频渲染线程里比对当前帧的 pts 与 g_audioClock,若帧的 pts 大于音频时钟,计算差值并等待。具体等待可以用 SDL_Delay 的微秒版本。static double g_audioClock 0.0; void audioCallback(void *userdata, Uint8 *stream, int len) { // userdata 里保存着 AudioQueue 指针 // 每次回调只拷贝 len 字节的 PCM 数据,并把已播放的字节换算成时间 // 假设音频从队列里拿数据,本处省略队列细节 // g_audioClock 已拷贝字节数 / (采样率 * 通道数 * 每个样本字节数) } // 在视频渲染循环中,拿到一帧后做同步 double videoPts av_q2d(videoStream-time_base) * frame-pts; double audioClock g_audioClock; double diff videoPts - audioClock; if (diff 0.01) { SDL_Delay(static_castUint32(diff * 1000.0)); }注意这里的 g_audioClock 是跨线程共享变量,最好用 std::atomic 或加锁,否则在 release 模式下可能读到旧值。另一个细节是 FFmpeg 解码的音频帧需要经过重采样,因为原始文件的采样率可能不是 44100 Hz,而 SDL2 的 AUDIO_S16SYS 固定按输出参数读取。常见做法是用 swr_convert 把原始 AVFrame 转换为 44100 Hz 双声道交错数据。若你是从研究源码的角度读这段,记住音视频同步分为两部分音频回调里累积时钟是「生产者」,视频渲染里的 diff 判断是「消费者」。中间不需要额外协调,只要 diff 绝对值大于某个阈值就调整,否则不调整。阈值取 0.01 秒,人眼对小于 10ms 的偏差感知很弱,设太小会导致频繁 delay,设太大又有可感知的不同步。5. Qt 界面与播放器封装拖拉进度条、打开文件、暂停继续5.1 用 QWidget 组合 SDL2 窗口的两种思路SDL2 创建的窗口自带标题栏和装饰,如果直接放在 Qt 界面里,视觉上会出现两个独立窗口。更常见的工程化做法有两种一是让 SDL2 借用一个 QWidget 的 winId,把 SDL 窗口创建到该控件的句柄上;二是不用 SDL 显示视频,而是在 Qt 的 QOpenGLWidget 上渲染纹理由 SDL2 通过共享纹理完成。第二种性能好但代码长,第一种容易理解且能满足标题里「可以播放本地视频」的需求。要让 SDL 窗口嵌入 QWidget,可以在 Qt 创建一块空白 QWidget,获取其 HWND,再调用 SDL_CreateWindowFrom(hwnd)。这个办法的缺点是窗口尺寸变化时,需要手动调整 SDL 渲染器输出的 logical size。下面给出嵌入思路的关键代码片段,原来 4.4 节里的 SDL_CreateWindow 换成 SDL_CreateWindowFrom。// playerwindow.h class PlayerWindow : public QWidget { Q_OBJECT private: QWidget *videoContainer nullptr; VideoCore *core nullptr; public: explicit PlayerWindow(QWidget *parent nullptr); }; // playerwindow.cpp 关键部分 PlayerWindow::PlayerWindow(QWidget *parent) : QWidget(parent) { videoContainer new QWidget(this); videoContainer-setMinimumSize(640, 360); // 先给 SDL 一个占位窗口,SDL_CreateWindowFrom 需要的是窗口句柄 // 在 VideoCore 中传入此容器句柄 core new VideoCore(this); connect(core, VideoCore::playbackFinished, this, [this]() { // 更新按钮文字等 }); }这里要注意 SDL_CreateWindowFrom 接收的是 void* 的 native handle。在 windows 平台,需要把 videoContainer-winId() 强制转为 HWND 再传给 SDL。跨平台代码建议用 QWidget::winId() 得到 WId,然后 reinterpret_cast,不要直接取整数值。5.2 打开文件对话框与拖动进度条Qt 负责「本地视频」几个字的交互部分选择文件、显示文件名、拖动 seek。seek 动作在 SDL2 渲染循环中需要额外处理,因为在解码循环里 av_read_frame 正在阻塞,若此时直接 seek,最稳妥的做法是先暂停解码,调用 av_seek_frame,然后清空队列。下面是一段可以直接放进 PlayerWindow 的槽函数void PlayerWindow::onOpenFile() { QString fileName QFileDialog::getOpenFileName(this, tr(选择视频文件), QDir::homePath(), tr(视频文件 (*.mp4 *.mkv *.avi *.mov *.flv *.ts);;所有文件 (*.*))); if (fileName.isEmpty()) return; if (!core-openFile(fileName)) { QMessageBox::warning(this, tr(错误), tr(无法打开该视频文件)); return; } core-startPlay(); }进度条拖动需要处理 paused 状态。简单实现是拖动滑块后把期望位置转换成秒,再调 core-seekTo(position)。VideoCore 里维护一个 std::atomic seekRequested 和一个 double seekTarget,解码线程每读一个包前检查该标记,发现置位就调 av_seek_frame。这种异步 seek 方式比直接跨线程调用 FFmpeg 接口安全得多。拖动进度条过程中,SDL2 渲染线程仍可能显示旧帧,所以需要配合暂停按钮,把渲染循环里的 SDL_Delay 改成 SDL_WaitEvent 之类的阻塞事件,避免 CPU 空转。播放中暂停时,可以在 renderLoop 里增加一个 paused 判断,暂停时只渲染一帧空画面或保留当前画面。5.3 关闭窗口时避免崩溃的线程回收这是写播放器源码最常见的崩溃点关闭 Qt 窗口时,解码线程还在 av_read_frame,渲染线程还在 SDL 里,而 C 对象已经析构。一个可靠的顺序是先置 playingfalse,再请求 SDL 停止,最后 join 线程,最后释放 FFmpeg 上下文。VideoCore::~VideoCore() { playing false; paused false; if (decodeThread.joinable()) decodeThread.join(); if (renderThread.joinable()) renderThread.join(); if (videoCtx) avcodec_free_context(videoCtx); if (fmtCtx) avformat_close_input(fmtCtx); if (swsCtx) sws_freeContext(swsCtx); // SDL 清理放在 renderLoop 尾部更安全 }析构函数里不要调用 SDL_DestroyTexture,因为如果 renderThread 还持有渲染器,双线程调用同一渲染器会崩。把 SDL 清理收进 renderLoop 的收尾部分,析构只负责 join,这个原则能帮你避开播放器关闭时的绝大部分段错误。6. 播放器调优与排查从无声黑屏到高码率卡顿的实战技巧6.1 用 FFmpeg 命令行校验输入文件,缩小问题范围当播放器出现黑屏、花屏、音画不同步时,先用 ffmpeg 命令把 输入视频的编码格式、分辨率、帧率、时间基拿出来,能帮你区分是源码问题还是文件问题。相关热搜词里有 ffmpeg 命令 和 ffmpeg 使用教程,因此这里直接给最常用的探测命令。ffprobe -show_streams -show_format input.mp4如果 ffprobe 输出的视频流格式是 av1,而工程里没有链接足够新的 libdav1d,解码就会失败。这种情况下源码需要升级 FFmpeg 版本,或者改用带 av1 解码器的 build。另一个高频问题是 10bit H.264 的 hevc 文件,SDL2 的 YUV420P 纹理放不下 10bit 数据,需要用 sws_scale 转成 8bit。这种转换不要在播放循环里频繁做,应在 openFile 后只创建一次 swsCtx。相关热词里还有 ffmpeg m3u8 转为 mp4 命令,虽然标题只写了本地视频,但如果你测试的文件是 ts 或 m3u8 切片,同样可以用 ffprobe 查看。本地视频播放与网络流播放的初始化解码逻辑几乎一样,只是 avformat_open_input 的参数不同。6.2 音频没有声音先看 SDL_OpenAudioDevice 的返回值项目里最容易遇到的现象是画面正常但没声,或者声音忽大忽小。先排查 SDL2 音频设备是否打开成功,失败时 SDL_GetError() 会返回详细原因。常见坑是打开音频设备之前没有调用 SDL_Init(SDL_INIT_AUDIO),或者采样率规格与文件不匹配。FFmpeg 解码出的音频样本是 AV_SAMPLE_FMT_FLTP平面浮点,SDL2 默认要 S16 交错,必须用 swresample 转换。想让声音能听,重采样参数必须正确,例如声道数从 5.1 缩减成 2 声道,用 swr_convert 一次搞不定时要分多次调用。另一个容易被忽略的问题在 SDL2 的回调线程里调用 FFmpeg 的 avcodec_receive_frame 是不推荐的,因为回调线程要求低延迟,凡是能提前处理的数据都要在解码线程里准备好。设计音频队列时,把重采样后的 PCM 数据复制进一个环形缓冲区,回调里只做 memcpy,可以大幅度减小音频卡顿。6.3 SDL2 渲染与 Qt 窗口尺寸的同步标题里没有要求做全屏神级播放器,但高分辨率视频在小窗口下更容易看出锯齿。SDL_RenderSetLogicalSize 可以保持宽高比,但放在嵌入式窗口时需要随着 Qt resizeEvent 更新。代码如下,放在 PlayerWindow::resizeEvent 里void PlayerWindow::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); if (videoContainer core) { // 让 SDL 渲染器按容器新尺寸设置逻辑尺寸 core-updateRenderSize(videoContainer-width(), videoContainer-height()); } }在 VideoCore 中,updateRenderSize 内部调用 SDL_RenderSetLogicalSize(sdlRenderer, w, h)。注意这个函数会拉伸视频画面,如果不希望拉伸,需要自行计算缩放后的矩形。6.4 通过输出日志验证解码与渲染是否各自独立工作排查播放器时,一个极其有效的方法是打三组日志解码帧序号、渲染帧序号、音频时钟值。如果解码帧序号持续增长但渲染序号不动,说明帧队列里在堆积,通常是渲染线程卡在 SDL_UpdateTexture。如果渲染序号跳动但音频时钟一直为 0,说明音频回调没有拿到数据,应检查重采样输出 buffer 大小。如果三组日志都正常,却依旧感觉画面卡,可能是 SDL 窗口的 WaitEvent 没有触发 expose 重绘,考虑在 renderLoop 末尾加 SDL_RenderPresent 后再加 SDL_Delay。方便起见,可以写一个小的日志函数:void logFrameProgress(int decoded, int rendered, double audioClock) { static int count 0; if (count % 120 0) { // 每秒约打两行日志 qDebug() decoded: decoded rendered: rendered audioClock: audioClock; } }这里的三个值通过普通全局变量累加即可,因为只是日志,不用精准原子。如果 decoded 每分钟只增长几十,说明 FFmpeg 的解码能力不够,此时会看到 CPU 单核打满,解决办法是降低输入分辨率或者开启硬解,这个就超出标题范围,但至少你知道瓶颈在解码器还是渲染器。6.5 让播放器支持拖拽打开文件与退出时保存窗口位置最后给一个提升使用体验的小功能注册 dragEnterEvent 和 dropEvent,让用户直接把视频拖进 Qt 窗口就能播放。这是很多开源播放器的基础交互,代码只需几行,但需要额外判断拖进来的是不是文件。void PlayerWindow::dragEnterEvent(QDragEnterEvent *event) { if (event-mimeData()-hasUrls()) { event-acceptProposedAction(); } } void PlayerWindow::dropEvent(QDropEvent *event) { QListQUrl urls event-mimeData()-urls(); if (!urls.isEmpty()) { onOpenFileFromPath(urls.first().toLocalFile()); } }播放结束后,可以再增加一个循环播放开关,把 player finished 信号与 openFile 连接起来,但这会让标题里的「本地视频播放器」变得更像一个完整应用。源码编写进度到此,已经完成了从环境配置、解码、渲染、同步到 Qt 界面包装的闭环,你可以在此基础上继续扩展字幕、倍速、硬解和播放列表。本文还有配套的精品资源点击获取
返回列表