简介:这份QT环境下基于ffmpeg与SDL开发的音视频播放器工程源码,适合有一定C++基础、希望学习多媒体播放器实现原理的开发者。资源完整演示了如何将ffmpeg的解码能力与SDL的渲染能力集成进QT界面,并额外实现录像与截图功能,覆盖YUV转RGB、音视频同步、新建录制线程等关键环节。压缩包共450个文件,包含364个h头文件、8个cpp源文件、多个dll与lib运行库、pro工程配置、ui界面文件及demo视频和截图样例,整体约43.5MB,结构清晰,便于直接编译学习。已有1154人浏览学习。通过研读源码,不仅能掌握音视频播放器的整体框架,还能在此基础上扩展推流、滤镜、多格式支持等高级功能,对深入理解多媒体处理流程很有帮助。
1. 先从整体看:QT下ffmpeg+SDL播放器到底在解决什么问题
用Qt自带的QMediaPlayer看视频,多数场景足够,但你想在播放到某一帧时截图,想在视频播放过程中直接录制一段MP4,QMediaPlayer要么拿不到解码后的原始帧,要么录像接口只在特定平台上开放。QT下使用ffmpeg+SDL实现音视频播放器,核心是把界面、解码、渲染三者拆开: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拿AVPacket,avcodec_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_size | 50 | 即2秒一个关键帧,seek定位的参考点 |
| max_b_frames | 0 | 延迟低,直播和实时播放场景推荐 |
| bit_rate | 2000000 | 默认2Mbps,可调到4Mbps提高画质 |
| pix_fmt | YUV420P | 播放兼容性最好 |
录像结束时别忘了先置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::vector<uint8_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事件里只触发一次seek,sliderMoved期间只更新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下用ffmpeg+SDL时,编译成功但运行时提示找不到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。如果源文件格式是yuv420p,SDL纹理就选IYUV;是nv12就选NV12,两个混用必黑屏。SDL无声时,先检查音频FIFO里是否有数据,再看SDL_OpenAudioDevice返回的actual.format是否和want.format一致。声卡不支持AUDIO_S16SYS时SDL会悄悄换格式,回调里直接按原始s16解析就会变噪声。
最后再强调一个容易被忽略的技巧:播放器在release模式下崩溃,先用Debug模式重新编译,重点看av_frame_ref是否成对释放。QT下ffmpeg+SDL这类工程,70%的崩溃都源自多线程里AVFrame被提前释放,另一个高频点是SDL窗口嵌入Qt后winId在老版本Qt上变化,这时需要重设WA_NativeWindow后再创建一次SDL_Window。先跑通最小链路,再加录像截图功能,排查复杂度会低很多。
本文还有配套的精品资源,点击获取