简介:这是一份基于Qt 5.8与FFmpeg 5.1开发的流媒体播放器完整工程源码,面向具备一定C++基础、希望掌握音视频拉流与渲染的开发者。工程支持通过TCP或UDP拉取实时流,实测可接入监控摄像头RTSP流;音频部分采用QAudioOutput播放,视频经FFmpeg解码并完成YUV转RGB后,在QOpenGLWidget中进行渲染显示,同时提供截图、录像与音视频播放功能。资源共263个文件,压缩包约43.23MB,其中以144个头文件、13个cpp源文件为主,并包含45个dll与8个lib运行库、2个exe可执行程序、16个qm翻译文件及2个mp4测试视频,另有pro工程文件与ui界面文件,便于直接打开编译与二次开发。目录结构完整,可对照学习Qt多线程解码、OpenGL渲染、音频输出及录像封装等关键模块的协作方式。目前已有1992人浏览学习,适合用作监控播放器开发、流媒体入门或毕业设计的参考资料。
1. 用Qt+ffmpeg自研流媒体播放器:为什么说它不是个玩具项目
需要一个能嵌入到业务系统里的播放器时,VLC能播但不能定制界面,截图和录像还得靠人肉操作。自己用Qt+ffmpeg写一个流媒体播放器,能接RTSP/HTTP流,支持音视频播放,还能把当前帧截图、把一段流录成文件——听起来不算难,但真正做起来会发现最折磨人的不是解码,而是线程模型和音视频同步。这篇按我自己的实现路径,把这个方案的架构、代码和踩过的坑完整梳理一遍。适合有内部工具平台开发需求、想摆脱对通用播放器依赖的开发者参考。
2. 播放器骨架怎么搭:解码线程、渲染线程与缓冲区设计
播放器的架构决定了后面所有功能的稳定性。ffmpeg的解码是阻塞式、CPU密集的,Qt的GUI又是事件循环驱动的,这两者直接混在一起最先翻车。先讲清楚为什么必须分线程,再给一个能直接用的帧队列实现,最后说资源释放的顺序问题。
2.1 为什么解码必须离开GUI线程:阻塞与丢帧的根源
av_read_frame在读取网络流时,一次调用可能阻塞几十毫秒甚至更久。假如这个调用发生在GUI线程里,QApplication的事件循环被堵住,窗口拖动、按钮点击全部无响应,界面上直接显示“未响应”。解码本身也是高CPU操作,在GUI线程里做会让UI事件得不到及时处理,体验非常差。
所以常见的做法是:一个QThread专门跑解码循环,一个QThread处理音频输出,视频帧通过信号槽发回主线程渲染。主线程只做两件事:接收QImage、setPixmap到QLabel或QOpenGLWidget上。阻塞操作全部放到子线程,GUI线程保持事件循环畅通。
这里有一个细节:信号槽传帧不要直接传AVFrame*,跨线程裸指针的释放时机很难控制。我一般会把AVFrame转成QImage或者封装成自定义结构再emit,保证每次emit都携带完整的、自包含的数据。QImage本质上就是一块内存加上格式描述,跨线程传递非常安全。
2.2 帧队列的三种实现方式:从QQueue到双缓冲
解码线程和渲染线程之间需要缓冲,目的是吸收网络抖动和解码耗时波动。最简单的实现是QQueue加QMutex加QWaitCondition,代码如下:
class VideoFrameQueue { public: VideoFrameQueue(int maxSize) : m_maxSize(maxSize) {} void put(QImage frame) { QMutexLocker locker(&m_mutex); while (m_queue.size() >= m_maxSize) { // 队列满说明渲染线程跟不上,丢最旧的帧,保证实时性 m_queue.dequeue(); } m_queue.enqueue(std::move(frame)); m_condNotEmpty.wakeOne(); } bool take(QImage &frame, int timeoutMs) { QMutexLocker locker(&m_mutex); if (m_queue.isEmpty()) { // 没有新帧时等待,避免主线程空转 m_condNotEmpty.wait(&m_mutex, timeoutMs); } if (m_queue.isEmpty()) { return false; } frame = m_queue.dequeue(); return true; } void clear() { QMutexLocker locker(&m_mutex); m_queue.clear(); } private: QQueue<QImage> m_queue; QMutex m_mutex; QWaitCondition m_condNotEmpty; int m_maxSize; };put函数在队列满时直接丢最旧的帧,这一点在直播场景很重要。播放器追求的是实时性而不是追帧,积压只会让延迟越来越大。take函数带超时,主线程用QTimer定时来取帧,取不到就继续显示上一帧。
队列大小一般设5到10。1080p的QImage一帧大约6MB(1920×1080×3字节),队列上限设10就是60MB内存,可以接受。如果设得太大,网络卡顿恢复后解码线程会快速向队列灌帧,造成内存峰值和明显的追帧画面,观感反而更差。
2.3 生命周期管理:AVFormatContext与AVFrame的释放顺序
ffmpeg的对象大多是引用计数或嵌套分配的,释放顺序错了就是use-after-free。常见错误是:窗口关闭时先avformat_close_input,但解码线程还持有一个AVFrame没unref,这个帧内部引用了AVCodecContext的buffer,结果解码线程一访问就崩溃。
正确的顺序是:先置一个退出标志,让解码线程跳出循环;然后QThread::wait等待线程真正结束;再清空帧队列,av_frame_unref队列里所有帧;最后才调用avcodec_free_context和avformat_close_input。任何一步提前了,都有可能在看似随机的位置崩溃。
AVFrame本身也是引用计数的,av_frame_unref会释放内部buffer引用,av_frame_free才释放结构体本身。每次从解码器拿到的帧,用完必须立刻unref,否则内存会缓慢增长,录像几小时后内存翻倍是常态。这部分用Valgrind或AddressSanitizer能查出来,但更好的是从一开始就养成“每帧必unref、每上下文必配对释放”的习惯。
3. 从文件到流媒体:ffmpeg接入与音视频同步
播放器核心在于两个点:一是能打开各种输入源,二是音画能对上。ffmpeg的libavformat负责解析封装和流媒体协议,libavcodec负责解码。这一章先写打开输入的完整代码,再说同步策略,最后补音频输出。
3.1 打开输入:avformat_open_input与rtsp_transport参数
无论是本地文件、RTSP还是HTTP流,对ffmpeg来说都是“输入格式上下文”的差异。RTSP这类实时流需要额外配置传输协议,不对的话会出现延迟暴涨甚至完全卡死。先把打开输入和解码器的函数写出来:
bool openMedia(const QString &url, AVFormatContext **fmtCtx, AVCodecContext **videoCtx, AVCodecContext **audioCtx, int *videoStreamIdx, int *audioStreamIdx) { AVDictionary *options = nullptr; // RTSP用TCP传输,避免UDP丢包重传导致延迟增加 av_dict_set(&options, "rtsp_transport", "tcp", 0); // 降低探测时长,加快首屏出现 av_dict_set(&options, "probesize", "1000000", 0); av_dict_set(&options, "max_delay", "500000", 0); AVFormatContext *fmt = avformat_alloc_context(); int ret = avformat_open_input(&fmt, url.toStdString().c_str(), nullptr, &options); if (ret < 0) { char errBuf[128]; av_strerror(ret, errBuf, sizeof(errBuf)); qDebug() << "open input failed:" << errBuf; return false; } ret = avformat_find_stream_info(fmt, nullptr); if (ret < 0) return false; *videoStreamIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); *audioStreamIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); // 为视频流分配解码器上下文 if (*videoStreamIdx >= 0) { AVStream *stream = fmt->streams[*videoStreamIdx]; AVCodecParameters *params = stream->codecpar; const AVCodec *codec = avcodec_find_decoder(params->codec_id); *videoCtx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(*videoCtx, params); // 直播低延迟场景没必要多线程解码,1就够 (*videoCtx)->thread_count = 1; if (avcodec_open2(*videoCtx, codec, nullptr) < 0) return false; } return true; }几个参数的取舍值得记住。rtsp_transport=tcp是直播场景几乎必设的项,UDP虽然延迟略低,但弱网下丢包会触发重传请求,接收端反而要等更多数据,延迟会持续累积。probesize控制探测流的字节数,调小能加快首屏,但太小可能识别不了流的真实参数。本地文件不用管这两个,RTSP或HTTP拉流才需要。
av_find_best_stream比手动遍历streams数组更省事,它会考虑参数是否完整。注意音视频流索引尽量不要复用同一个变量,后面同步要同时用到两个流的时间基。
3.2 音视频同步:以音频时钟为基准的pts校准
同步是播放器里最玄学的部分,但原理并不复杂:以音频播放时钟为基准,视频帧的pts换算成秒后与音频时钟做比较。视频比音频慢就等待,快就丢帧。音频本身自然向前播放,所以音频就是天然的主时钟。
void syncVideoToAudio(VideoFrameQueue &queue, AVStream *videoStream, double audioClock) { QImage frame; while (queue.take(frame, 10)) { double ptsSeconds = frame.metadata("pts").toDouble(); double diff = ptsSeconds - audioClock; // 视频比音频早太多,丢弃这帧,追回进度 if (diff > 0.1) { continue; } // 视频比音频晚,说明要等音频,退出循环保留这帧 if (diff < -0.05) { queue.putFront(frame); QThread::msleep(10); break; } // 误差在可接受范围内,立即渲染 emit frameReady(frame); return; } }阈值设多少取决于播放场景。本地文件追求流畅,diff在±0.05秒内直接渲染即可;直播可以放宽到±0.1秒,因为网络抖动本来就比本地文件大。wait时长不能太大,否则视频会一卡一卡的;也不能太小,否则忙等占用CPU。10到30毫秒是常用区间。
时间戳换算有个经典坑:pts的单位不是秒,是流的time_base。换算公式是pts × av_q2d(stream->time_base),av_q2d把AVRational转换成double。如果不做这个换算就直接和秒比较,音画会差出好几百倍。
3.3 音频输出:QAudioOutput播放PCM数据
ffmpeg解码出来的音频是AVFrame里的PCM数据,Qt播放PCM的标准方式是QAudioOutput。解码线程解出音频帧后,把data[0]和linesize[0]交给音频线程写入QAudioOutput。
void initAudioOutput(QAudioOutput **audioOutput, AVCodecContext *audioCtx) { QAudioFormat format; format.setSampleRate(audioCtx->sample_rate); format.setChannelCount(audioCtx->channels); format.setSampleSize(16); format.setCodec("audio/pcm"); format.setByteOrder(QAudioFormat::LittleEndian); format.setSampleType(QAudioFormat::SignedInt); *audioOutput = new QAudioOutput(format); // 缓冲调小一点,直播场景能降低几十毫秒延迟 (*audioOutput)->setBufferSize(4096); }sample_rate要取audioCtx->sample_rate,不能写死44100。很多RTSP流的音频是8000或16000采样率,写死就会变调。声道也一样,解码器输出的channels才代表真实布局。
QAudioOutput有push和pull两种模式。push模式是主动调用write写入数据,适合从解码线程取帧再推送。注意write可能一次性写入的字节数小于请求数,返回的写入量要记录下来,剩余数据下次再写。
void writeAudioToDevice(QAudioOutput *audioOutput, AVFrame *audioFrame) { int bytesWritten = 0; int totalBytes = audioFrame->linesize[0]; while (bytesWritten < totalBytes) { // QAudioOutput内部缓冲满时write会返回0,需要稍等再写 int ret = audioOutput->write( audioFrame->data[0] + bytesWritten, totalBytes - bytesWritten); if (ret <= 0) { QThread::msleep(5); continue; } bytesWritten += ret; } }另外,播放速度由音频时钟驱动,Qt的QAudioOutput内部时钟就是音频时钟。实现同步时可以直接使用写入音频的总时长来估算audioClock,不需要额外维护时钟,省去很多麻烦。
4. 截图与录像:不只是抓一帧、存一段
截图和录像在项目里的价值是“留证”和“回放”。RTSP直播流不能倒带,但用播放器截图、录像就能把一段时间的内容存下来。实现思路完全不同:截图是从解码帧直接转成图像,录像则要重新编码成文件,两条路都要跨过几个ffmpeg的格式坑。
4.1 截图过程:sws_scale转RGB后再交给QImage
解码器输出的帧通常是YUV420P,而QImage不直接支持YUV,需要先通过sws_scale转成RGB24。转换器只需要创建一次,复用同一个SwsContext可以省掉每次重新分配的开销。
bool captureFrame(AVFrame *decodedFrame, AVCodecContext *codecCtx, QString filepath) { // 转换器:从解码器像素格式转成RGB24 SwsContext *sws = sws_getContext( codecCtx->width, codecCtx->height, codecCtx->pix_fmt, codecCtx->width, codecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); // RGB缓冲要按行对齐分配,不能假设width*3就是行大小 uint8_t *rgbData[1] = {nullptr}; int rgbLinesize[1] = {0}; av_image_alloc(rgbData, rgbLinesize, codecCtx->width, codecCtx->height, AV_PIX_FMT_RGB24, 32); // 第4个参数是源帧的行数,只转视频区域,不转padding sws_scale(sws, decodedFrame->data, decodedFrame->linesize, 0, codecCtx->height, rgbData, rgbLinesize); // QImage的bytesPerLine必须用转换后的linesize,否则图像错位 QImage image(rgbData[0], codecCtx->width, codecCtx->height, rgbLinesize[0], QImage::Format_RGB888); image.save(filepath); av_freep(&rgbData[0]); sws_freeContext(sws); return true; }av_image_alloc分配的内存是高度对齐的,行大小不一定是width×3。构造QImage时bytesPerLine必须传入rgbLinesize[0],否则图像会发生斜切错位,这是截图黑屏之外最容易踩的坑。
RGB24在Qt里的对应格式是Format_RGB888,内存布局是R、G、B连续排列,正好和sws输出的packed RGB一致。如果转换目标选成GBRP(planar RGB),QImage的构造方式就完全不一样了,所以这里尽量固定用RGB24,别给自己挖坑。
截图的时机也有讲究。最稳妥的方式是解码线程里拿到帧后直接调用captureFrame,此时AVFrame还处于有效状态,引用计数也对。如果先emit到主线程再截图,帧可能已被unref,数据就成了悬垂指针。
4.2 录像:把解码帧重新编码成mp4
录像常见的选择是“重编码”而不是“转封装”。转封装需要源流包含完整的关键帧信息和连续时间戳,适合本地文件;直播流的RTSP封装和时间戳往往不规整,直接转封装录出来的文件会花屏、时长错乱。重编码虽然消耗CPU,但录出来的文件干净可靠。
重编码路径需要开一套输出上下文档。整体流程是:先avformat_alloc_output_context2创建输出上下文,再avformat_new_stream添加视频流,接着为视频流配编码器并avcodec_open2打开,最后avformat_write_header写文件头。之后每来一帧就send_frame加receive_packet循环写,结束时先av_write_trailer再释放所有对象。
bool startRecording(AVCodecContext *decoderCtx, AVFormatContext **outFmtCtx, AVStream **outStream, AVCodecContext **encCtx, QString path) { avformat_alloc_output_context2(outFmtCtx, nullptr, "mp4", path.toStdString().c_str()); *outStream = avformat_new_stream(*outFmtCtx, nullptr); // 用libx264编码H.264,码率按分辨率给一个基准值 const AVCodec *encoder = avcodec_find_encoder_by_name("libx264"); *encCtx = avcodec_alloc_context3(encoder); (*encCtx)->width = decoderCtx->width; (*encCtx)->height = decoderCtx->height; (*encCtx)->pix_fmt = AV_PIX_FMT_YUV420P; (*encCtx)->time_base = {1, 25}; // 输出按25fps固定 (*encCtx)->framerate = {25, 1}; (*encCtx)->bit_rate = 2000000; // 2Mbps,1080p够用 avcodec_open2(*encCtx, encoder, nullptr); avcodec_parameters_from_context((*outStream)->codecpar, *encCtx); if (avformat_write_header(*outFmtCtx, nullptr) < 0) return false; return true; }编码器要求的时间基是{1,25},意思是pts用1/25秒为单位,也就是一帧加1。写帧的时候用输出流自己的时基推时间戳,不要依赖解码帧的原始pts,这样能彻底避开源流时间戳不连续的问题。代码:
void writeFrameToRecording(AVFormatContext **outFmtCtx, AVStream **outStream, AVCodecContext **encCtx, AVFrame *decodedFrame, int *frameIndex) { AVFrame *encFrame = av_frame_alloc(); av_frame_copy_props(encFrame, decodedFrame); encFrame->format = AV_PIX_FMT_YUV420P; encFrame->width = (*encCtx)->width; encFrame->height = (*encCtx)->height; encFrame->pts = (*frameIndex)++; // 帧号递增,保证时间戳单调连续 avcodec_send_frame(*encCtx, encFrame); AVPacket *pkt = av_packet_alloc(); while (avcodec_receive_packet(*encCtx, pkt) == 0) { av_packet_rescale_ts(pkt, (*encCtx)->time_base, (*outStream)->time_base); av_interleaved_write_frame(*outFmtCtx, pkt); av_packet_unref(pkt); } av_frame_free(&encFrame); av_packet_free(&pkt); }录制结束时要先av_write_trailer,再关闭编码器和输出上下文,顺序不能颠倒。采样率、宽高这类参数一旦写进MP4头部,后面改不了,所以录制过程中不允许改分辨率或者帧率,这也就是为什么录像回调里必须用解码器的原始尺寸而不是界面缩放后的尺寸。
另一个容易忽略的工作量:要不要把音频一起录进去。如果需要,得额外开一个AAC编码器,把解码出的音频PCM重采样成44100立体声后再编码写进同一个MP4,没有这一步录出来的视频是无声的。
5. 虐我最多的5个坑:崩溃、黑屏、音画不同步与录像损坏
这一章集中记录实现这个播放器过程中遇到的高频问题。如果你准备照着做,大概率会至少撞上其中两三个。
5.1 退出即崩溃:资源释放顺序错了
现象:程序运行正常,关闭窗口时Segmentation fault,崩溃位置在avformat_close_input或QThread::wait附近。
原因:解码线程还在运行,GUI线程就把AVFormatContext给关了。或者队列里还有未unref的AVFrame,这些帧引用的buffer随上下文被释放,线程再访问就崩。
解决:按“停线程→清队列→关解码器→关上下文”的顺序释放。closeEvent里先置m_exitFlag=true,wait解码线程退出,再清空帧队列并unref所有帧,最后avcodec_free_context和avformat_close_input。顺序做对,这个问题基本就消失了。
5.2 录像文件时长错乱、拖动花屏
现象:录出来的MP4只有十几秒,实际录了半小时;或者播放到后半段全是马赛克。
原因:直接把源流AVFrame的pts写进了MP4,而直播流的pts存在跳变和回绕,不是从0开始连续递增。封装器拿到不连续的时间戳就会出错。
解决:录像时完全不管源帧的pts,用输出帧号乘以输出时基来生成新pts。startRecording里设time_base为{1,25},writeFrame里pts=frameIndex++,这样任何输入源录出来的时间戳都是严格单调的。
5.3 RTSP延迟越拉越大,几小时后变幻灯片
现象:刚开始播放延迟约1秒,半小时后变3秒,几个小时后画面能落后十几秒。
原因:rtsp_transport默认是UDP,丢包后ffmpeg会等待重传并缓存数据,解码线程追不上接收速度,帧队列积压。积压的帧被播放后延迟自然越来越大。
解决:两件事一起做。第一,打开输入时设置rtsp_transport=tcp,走TCP后乱序和丢包大幅减少。第二,帧队列做“队满丢旧帧”策略,即代码里put函数在队列满时dequeue最旧的帧,让播放始终追着最新画面走。直播场景优先级是实时性大于流畅性,丢帧是正确行为。
5.4 截图全黑:等不到关键帧就开始拍
现象:打开流后立刻点截图,保存的图片全黑或一大片绿。
原因:视频流起播是从关键帧开始的,但截图时机可能在收到关键帧之前。此时解码器输出的帧颜色数据不完整,转出来的图自然不可用。
解决:解码线程里检查AVFrame->key_frame标志,收到第一个关键帧之前不触发截图。还可以把截图动作延迟300到500毫秒再执行,给解码器留出缓冲预热时间。两种方法组合最稳。
5.5 音频爆音、偶尔卡顿
现象:播放声音“滋啦滋啦”响,或每隔几秒顿一下。
原因:QAudioOutput的write和音频帧的产生节奏不匹配。write调用过频,每次写的数据太少,就会断续;数据不均匀时QAudioOutput缓冲被写穿,就会爆音。
解决:用定时器或独立音频线程按可用空间写入,每次写入后检查bytesFree,避免一次塞太多;同时核对QAudioFormat的采样率、声道数是否和解码器实际参数一致。这两样错了,声音要么变调要么爆音。
6. 进阶技巧:seek精准定位与低延迟直播的调参习惯
功能都通了以后,有两个方向值得再投入一点时间:seek精确跳转和直播低延迟。前者提升本地文件体验,后者直接决定RTSP播放能不能用于实时监控场景。
seek的核心是av_seek_frame,但它处理的是微秒时间戳,不是帧号。跳转前要把目标秒数乘以AV_TIME_BASE,转换成ffmpeg内部时间单位。跳转后必须调用avcodec_flush_buffers清空解码器内部缓存,否则解码器会基于旧的关键帧状态输出坏帧,画面直接花掉。一个容易忽略的点:seek到最近关键帧需要加AVSEEK_FLAG_BACKWARD标志,否则跳转点可能落在关键帧之后,解码器没有完整的参考帧,画面要花屏一会儿才恢复。
低延迟这块,我最终固定下来的参数组合是:rtsp_transport=tcp、probesize压到1MB以内、max_delay设500000、队列上限5帧、音频QAudioOutput缓冲4096字节。这套参数在局域网内能做到约200毫秒的端到端延迟,画面清晰度没有明显损失。如果还嫌慢,可以再研究硬件解码路径,用d3d11va这类API把解码负荷移出CPU,但同步逻辑要重写一小部分,收益集中在高分辨率多路流场景。
我个人在这类项目上最深刻的教训是:不要在一开始就想着把所有功能都接好再测。先把“打开流→出画面→出声”这条最小链路跑通,再加截图,再加录像,每一步都验证稳定了再往下一步。FFmpeg的API组合错误经常在运行几分钟后才爆出来,用最小链路反复跑,比最后整体排查节省大量时间。这个习惯救了我很多次,希望帮到你。
本文还有配套的精品资源,点击获取