Qt下FFmpeg+SDL播放器开发:从解码到录像截图完整实践

Qt下FFmpeg+SDL播放器开发:从解码到录像截图完整实践 简介这份QT环境下基于ffmpeg与SDL开发的音视频播放器工程源码适合有一定C基础、希望学习多媒体播放器实现原理的开发者。资源完整演示了如何将ffmpeg的解码能力与SDL的渲染能力集成进QT界面并额外实现录像与截图功能覆盖YUV转RGB、音视频同步、新建录制线程等关键环节。压缩包共450个文件包含364个h头文件、8个cpp源文件、多个dll与lib运行库、pro工程配置、ui界面文件及demo视频和截图样例整体约43.5MB结构清晰便于直接编译学习。已有1154人浏览学习。通过研读源码不仅能掌握音视频播放器的整体框架还能在此基础上扩展推流、滤镜、多格式支持等高级功能对深入理解多媒体处理流程很有帮助。1. 先从整体看QT下ffmpegSDL播放器到底在解决什么问题用Qt自带的QMediaPlayer看视频多数场景足够但你想在播放到某一帧时截图想在视频播放过程中直接录制一段MP4QMediaPlayer要么拿不到解码后的原始帧要么录像接口只在特定平台上开放。QT下使用ffmpegSDL实现音视频播放器核心是把界面、解码、渲染三者拆开Qt负责窗口和按钮ffmpeg负责解封装、解码、编码SDL负责YUV显示和音频输出。录像和截图功能本质上都是在解码之后、渲染之前拦截AVFrame再走编码器。这套方案比直接调用QMediaPlayer复杂但能拿到原始帧数据适合需要做自定义滤镜、帧分析、录像推流的场景也适合对QMediaPlayer封装不满意、想完全掌控播放链路的开发者。2. 用ffmpeg把视频流“拆开”解封装、解码、AVPacket与AVFrame想在QT下实现音视频播放器第一件事不是打开窗口而是把ffmpeg的初始化解封装链路理顺。这里常见的做法是走一套固定流程打开输入、找流、打开解码器、循环读包、循环收帧。每一步都有对应API参数选错会直接影响后续SDL能否正常显示。2.1 ffmpeg库链接与解码前的初始化工程源码里通常会包含libavformat、libavcodec、libavutil、libswscale、libswresample 五个库。libavformat解决容器格式mp4、mkv、flv都归它管libavcodec解决H.264、H.265、AAC等编码libavutil提供time_base、内存分配等公共能力libswscale做像素格式转换libswresample做音频重采样。缺少哪一个都可能在链接时报未定义符号所以pro文件或CMake里最好把路径统一指到3rd/ffmpeg目录下。extern C { #include libavformat/avformat.h #include libavcodec/avcodec.h #include libavutil/pixdesc.h } int open_input(const QString path, AVFormatContext **fmt_ctx) { std::string p path.toUtf8().constData(); if (avformat_open_input(fmt_ctx, p.c_str(), nullptr, nullptr) 0) { return -1; } if (avformat_find_stream_info(*fmt_ctx, nullptr) 0) { return -2; } return 0; }avformat_open_input的第二个参数传nullptr表示让ffmpeg根据文件头自动探测封装格式。第三个参数也传nullptr使用系统默认的探测逻辑。avformat_find_stream_info这一步必须执行否则后面获取到的time_base和stream信息不完整。注意Windows路径里的中文要用toUtf8转一下很多封装格式对locale字符支持不好不转会出现打开失败。2.2 查找音视频流并配置解码器上下文拿到AVFormatContext后通过av_find_best_stream分别找到视频流和音频流。这个函数比手工遍历streams更省事它还会自动匹配一个可用的解码器。const AVCodec *decoder nullptr; int video_index av_find_best_stream( fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, decoder, 0); if (video_index 0) { qCritical() no video stream; return; } AVStream *vstream fmt_ctx-streams[video_index]; AVCodecContext *vcodec_ctx avcodec_alloc_context3(decoder); if (!vcodec_ctx) return; avcodec_parameters_to_context(vcodec_ctx, vstream-codecpar); if (avcodec_open2(vcodec_ctx, decoder, nullptr) 0) { qCritical() open codec failed; return; }音频流的处理路径完全一致只是枚举类型换成AVMEDIA_TYPE_AUDIO。avcodec_parameters_to_context负责把流里的编码参数完整复制到解码器上下文中这一步不能漏。解码器打开后记下两个关键值video_stream-time_base以及vcodec_ctx-pix_fmt。time_base在做pts换算时会用到pix_fmt决定后续SDL纹理选择哪一种像素格式。这里常见的错误处理集中在几个返回值上调试时优先判断它们返回值含义排查方向AVERROR(EAGAIN)解码器缓冲区还没有输出继续送包或继续收帧AVERROR_EOF输入已经结束在文件读取结束后做flush处理AVERROR_INVALIDDATA数据不完整或格式不可识别检查源文件或换解码器实现2.3 读取AVPacket并解码为AVFrame播放器的主循环通常写成一个独立线程核心动作只有三个av_read_frame拿AVPacketavcodec_send_packet把包交给解码器avcodec_receive_frame从解码器拿AVFrame。AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); while (!stop_flag av_read_frame(fmt_ctx, pkt) 0) { if (pkt-stream_index video_index) { if (avcodec_send_packet(vcodec_ctx, pkt) 0) { while (avcodec_receive_frame(vcodec_ctx, frame) 0) { handle_video_frame(frame); // 交给SDL或录像模块 } } } else if (pkt-stream_index audio_index) { // 同样解码后送入audio fifo } av_packet_unref(pkt); } // 读完后flush avcodec_send_packet(vcodec_ctx, nullptr); while (avcodec_receive_frame(vcodec_ctx, frame) 0) { handle_video_frame(frame); }AVPacket在每次循环末尾必须av_packet_unref否则上一个包的数据会残留在内部。解码后的AVFrame也是一样如果你只有一帧可以用av_frame_ref复制引用而不是memcpy整个buffer。很多播放器崩溃都出在帧生命周期上谁ref谁unref这个规则要贯穿始终。3. 用SDL把画面和声音“放出来”渲染、音频回调与同步ffmpeg输出的是解码后的YUV帧和PCM数据SDL负责把这些数据变成屏幕上的画面和扬声器里的声音。SDL在这一环节比QImage高效很多因为SDL可以直接创建YUV纹理GPU完成色彩转换不需要CPU去转RGB。3.1 初始化SDL窗口并创建YUV纹理SDL初始化时要把视频和音频子系统一起打开随后创建窗口和渲染器。渲染器使用SDL_RENDERER_ACCELERATED纹理使用SDL_TEXTUREACCESS_STREAMING表示每一帧都会从CPU写入新数据。if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) 0) { qCritical() SDL_GetError(); return; } SDL_Window *win SDL_CreateWindow(FFmpeg SDL Player, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, width, height, SDL_WINDOW_RESIZABLE); SDL_Renderer *renderer SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED); SDL_Texture *texture SDL_CreateTexture(renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, vcodec_ctx-width, vcodec_ctx-height);SDL_PIXELFORMAT_IYUV对应ffmpeg里的AV_PIX_FMT_YUV420P。如果解码器输出的是NV12或YUV444这里要改成对应格式否则画面会出现颜色错乱。Streaming纹理的意思是纹理内容会频繁更新SDL会为它分配可写的系统内存。渲染一帧视频时先把AVFrame的Y、U、V三个plane上传到纹理再复制到渲染器上呈现SDL_UpdateYUVTexture(texture, nullptr, frame-data[0], frame-linesize[0], frame-data[1], frame-linesize[1], frame-data[2], frame-linesize[2]); SDL_RenderClear(renderer); SDL_RenderCopy(renderer, texture, nullptr, nullptr); SDL_RenderPresent(renderer);SDL_UpdateYUVTexture里的pitch参数对应frame-linesize不是width。linesize可能因为解码器对齐而大于width直接填width在某些显卡驱动下会裁掉右侧像素。3.2 音频回调需要FIFO而不是直接读文件SDL音频播放走回调模型声卡需要数据时SDL在回调里向你要一段PCM。你不能在回调里处理文件读写回调里能做的事越少越好。解码线程把PCM放进一个有锁的FIFO回调线程从FIFO搬运数据到SDL缓冲区。SDL_AudioSpec want {}; want.freq 44100; want.format AUDIO_S16SYS; want.channels 2; want.samples 1024; want.callback audio_callback; SDL_AudioDeviceID dev SDL_OpenAudioDevice( nullptr, 0, want, actual, 0); SDL_PauseAudioDevice(dev, 0);音频解码出来的格式常常是FLTP即浮点平面格式SDL不能直接播。常见做法是用swr_convert把FLTP重采样为交错S16采样率统一到44100声道数统一到2。如果swr配置不对声音会是“机器人音”或者听不到人声。samples值设在1024左右比较平衡太小回调频繁CPU占用高太大则延迟明显截屏录像时音画会错位。3.3 同步时用audio clock为主时钟音视频同步最简单的策略是让音频做主时钟。播放器每渲染一帧视频先算当前视频帧的pts再拿音频时钟与之对比。double v_pts av_q2d(vstream-time_base) * frame-pts; double a_time get_audio_clock(audio_ctx); double diff a_time - v_pts; if (diff 0.1) { // 音频比视频快太多跳过当前帧 return; } else if (diff -0.1) { // 视频超前等待音频追上来 SDL_Delay(10); }当视频帧的pts小于上一帧pts时要直接忽略因为B帧较多的情况下pts并不严格单调。SDL_Delay的实际精度由系统调度决定Windows下通常只有10到15毫秒。对精度要求更高时可以在循环里用QElapsedTimer做忙等待但CPU占用会提高。对于一般播放器10毫秒级别的误差已经足够可接受。4. 录像和截图功能帧数据再编码与保存播放器做到这里能看能听接下来加录像和截图。录像和截图吃的都是同一份AVFrame差别在于一个走视频编码器一个走图像编码器或Qt图像库。4.1 录像编码时先初始化输出容器和编码器录像最常见的需求是把当前视频流重新编码为MP4编码器选libx264。不要直接复制源视频流到MP4因为源文件可能是h265也可能是封装的flv直接拷贝出来不一定能被播放器识别。重新做一次编码反而更可控。AVFormatContext *out_ctx nullptr; avformat_alloc_output_context2(out_ctx, nullptr, mp4, out_path); AVStream *out_stream avformat_new_stream(out_ctx, nullptr); out_stream-time_base.num 1; out_stream-time_base.den 25; const AVCodec *encoder avcodec_find_encoder_by_name(libx264); AVCodecContext *enc_ctx avcodec_alloc_context3(encoder); enc_ctx-width frame_width; enc_ctx-height frame_height; enc_ctx-pix_fmt AV_PIX_FMT_YUV420P; enc_ctx-time_base.num 1; enc_ctx-time_base.den 25; enc_ctx-framerate.num 25; enc_ctx-framerate.den 1; enc_ctx-gop_size 50; enc_ctx-max_b_frames 0; enc_ctx-bit_rate 2000000; avcodec_open2(enc_ctx, encoder, nullptr); avcodec_parameters_from_context(out_stream-codecpar, enc_ctx); avio_open(out_ctx-pb, out_path, AVIO_FLAG_WRITE); avformat_write_header(out_ctx, nullptr);编码时每来一帧AVFrame就avcodec_send_frame再循环avcodec_receive_packet直到返回EAGAIN为止。int ret avcodec_send_frame(enc_ctx, frame); while (ret 0) { ret avcodec_receive_packet(enc_ctx, pkt); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) { break; } av_packet_rescale_ts(pkt, enc_ctx-time_base, out_stream-time_base); pkt.stream_index out_stream-index; av_interleaved_write_frame(out_ctx, pkt); av_packet_unref(pkt); }这里的录像是实时转码帧率受解码速度影响。编码器参数对文件大小和画质影响明显参数推荐值说明gop_size50即2秒一个关键帧seek定位的参考点max_b_frames0延迟低直播和实时播放场景推荐bit_rate2000000默认2Mbps可调到4Mbps提高画质pix_fmtYUV420P播放兼容性最好录像结束时别忘了先置recording标志为false再继续收完编码器缓冲然后av_write_trailer和avio_close否则MP4文件时间轴是坏的。4.2 截图用swscale转RGB24直接给QImage截图如果不做滤镜处理最简单的方案是用libswscale把AVFrame从YUV420P转成RGB24再用QImage保存。这样避免再走一遍MJPEG编码代码量也更少。SwsContext *sws sws_getContext( width, height, AV_PIX_FMT_YUV420P, width, height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); std::vectoruint8_t rgb(width * height * 3); uint8_t *dst[1] { rgb.data() }; int dst_linesize[1] { width * 3 }; sws_scale(sws, frame-data, frame-linesize, 0, height, dst, dst_linesize); QImage image(rgb.data(), width, height, width * 3, QImage::Format_RGB888); image.save(filePath, PNG);图片保存时最容易踩的坑是把frame-linesize[0]直接当作QImage的bytesPerLine传入。YUV planes的linesize通常大于width按linesize构造QImage会让图片倾斜或带杂色。更稳妥的做法是固定用width * 3作为QImage的stride因为RGB24每个像素正好3字节。如果希望截图是JPEG格式把save的第二个参数改成JPG并通过QImageWriter设置质量QImageWriter writer(savePath, JPG); writer.setQuality(92); writer.write(image);JPEG在白色文字边缘会有轻微模糊截图用于文档或分析时建议用PNG用于快速预览上传服务器时用JPEG更节省空间。5. Qt界面、按钮和后台线程的协作播放器界面和ffmpeg逻辑写在一起运行起来界面会卡死因为视频解码循环会占满主线程。正确的做法是把解码渲染放到QThread中Qt主线程只处理按钮点击、进度条拖动和状态显示。5.1 用SDL_CreateWindowFrom把SDL画面嵌进Qt控件让SDL画面显示在Qt窗口内部而不是弹出一个独立SDL窗口做法是给QWidget设置原生窗口属性再把winId交给SDL。videoWidget-setAttribute(Qt::WA_NativeWindow, true); videoWidget-setAttribute(Qt::WA_PaintOnScreen, true); SDL_Window *sdlWin SDL_CreateWindowFrom( (void *)videoWidget-winId());WA_PaintOnScreen的作用是阻止Qt在SDL渲染后再次重绘该控件否则SDL绘制的内容会被Qt清掉。SDL_Window创建后Qt在resizeEvent里调用SDL_SetWindowSize让SDL渲染尺寸和QWidget保持一致。这个方案在Windows和Linux上都很稳定macOS需要额外处理NSView的指针确保view已经真正创建。5.2 播放、暂停、停止和拖进度条的控制指令要跨线程播放器常见的几个控制指令分别是打开文件、播放、暂停、停止、拖动进度条。UI线程不能直接操作解码线程内部的AVFormatContext或AVCodecContext只能通过原子变量和消息队列来控制。控件动作跨线程指令解码线程响应打开文件thread-openFile(path)重置内部状态重新初始化暂停pause_flag.store(true)暂停向SDL送帧保留音频FIFO继续pause_flag.store(false)恢复渲染循环停止stop_flag.store(true)退出解码循环释放解码器拖动进度条发送seek请求队列调用av_seek_frame并flush解码器进度条拖动时最激进的做法是每移动一格就seek一次会卡到无法操作。我一般会在sliderReleased事件里只触发一次seeksliderMoved期间只更新UI显示。connect(playBtn, QPushButton::clicked, this, []{ if (decodeThread-isPaused()) { decodeThread-setPaused(false); } else { decodeThread-openFile(pathEdit-text()); } }); connect(slider, QSlider::sliderReleased, this, []{ decodeThread-requestSeek(slider-value()); });5.3 录像和截图按钮如何与解码线程协作录像按钮不能直接在槽函数里start一个编码线程因为点击录像时解码线程正在播放下一帧。我的方案是解码线程在每帧视频处理完后判断一个std::atomic recording标志为true时把当前AVFrame复制一份到录像模块的队列中。bool should_record recording.load(); if (should_record) { RecordTask task; task.pts frame-pts; av_frame_ref(task.frame, frame); recordQueue-push(task); }录像模块是一个独立QThread它从队列取帧交给编码器写入MP4。这样录像是异步的解码线程不会被编码速度拖慢。停止录像时先等recordQueue排空再调用av_write_trailer。过快关闭输出文件会导致MP4时间长度异常。截图按钮则不需要队列直接从“最近一帧”缓存里av_frame_ref一份交给QtConcurrent::run去保存PNG。截图逻辑里不要访问正在被解码线程写入的frame必须先加锁或者复制引用。6. 最后一个技巧验证SDK配置与工程打包顺序很多人在QT下用ffmpegSDL时编译成功但运行时提示找不到dll或者在命令行里执行ffmpeg时报“ffmpeg不是内部或外部命令”这都不是代码问题而是开发环境路径没配对。6.1 bin目录、ffmpeg路径和windeployqt的配合ffmpeg下载解压后会有bin、lib、include三个目录。pro文件里include和lib路径指向有效就行但运行时依赖的是dll。我一般把ffmpeg的bin目录里与播放器相关的dll复制到构建输出目录和exe放一起然后在.pro里配置相对路径INCLUDEPATH $$PWD/3rd/ffmpeg/include LIBS -L$$PWD/3rd/ffmpeg/lib \ -lavformat -lavcodec -lavutil -lswscale -lswresampleQt程序要发布时先用windeployqt生成Qt运行库再把SDL和ffmpeg的dll手动复制到exe同目录。如果release后提示platform plugin cannot be loaded需要检查exe目录下是否包含platforms文件夹以及QT_QPA_PLATFORM_PLUGIN_PATH环境变量是否指向这个文件夹。项目属性里配置QTDIR为qt\5.15.2\msvc2019_64能减少很多头文件冲突。6.2 用ffprobe和qDebug快速定位黑屏或无声播放器最麻烦的问题是无报错但黑屏。我建议在解码循环里打印关键日志用qDebug输出每一帧的pts和pix_fmt同时用ffprobe命令确认源文件本身没问题ffprobe -v error -show_streams -show_format input.mp4重点看codec_name、width、height、pix_fmt、nb_streams。如果源文件格式是yuv420pSDL纹理就选IYUV是nv12就选NV12两个混用必黑屏。SDL无声时先检查音频FIFO里是否有数据再看SDL_OpenAudioDevice返回的actual.format是否和want.format一致。声卡不支持AUDIO_S16SYS时SDL会悄悄换格式回调里直接按原始s16解析就会变噪声。最后再强调一个容易被忽略的技巧播放器在release模式下崩溃先用Debug模式重新编译重点看av_frame_ref是否成对释放。QT下ffmpegSDL这类工程70%的崩溃都源自多线程里AVFrame被提前释放另一个高频点是SDL窗口嵌入Qt后winId在老版本Qt上变化这时需要重设WA_NativeWindow后再创建一次SDL_Window。先跑通最小链路再加录像截图功能排查复杂度会低很多。本文还有配套的精品资源点击获取