基于FFmpeg的QT多路音视频播放器:软硬解码切换与RTMP推流一体化实现
简介这是一款基于Qt框架开发的跨平台音视频播放器面向C与多媒体开发初学者及进阶工程师解决音视频多源播放、实时推流与软硬解码适配等典型工程问题。项目支持RTMP推流、单路/多路播放、本地文件与网络流如RTMP流播放并集成录像、截图等实用功能适用于直播系统调试、嵌入式音视频终端开发及Qt多媒体模块学习场景。资源包共528个文件含34个核心cpp/h源码文件如ctffmpeg.cpp、mp4recorder.cpp、mediathread.cpp、60个动态链接库dll、295个头文件h及13个测试样例sample完整呈现Qt QMediaPlayer、QNetworkAccessManager与FFmpeg/GStreamer集成方案压缩包大小为159.53MB。已有585人学习下载读者可直接编译运行获取含RTMP推流逻辑、多线程媒体处理、硬解码切换策略及MP4录像实现的完整Qt工程实践代码目录结构清晰模块职责分明便于理解音视频Pipeline构建全过程。1. 这不是又一个“能播视频”的QT Demo它把 RTMP 推流塞进了播放器主流程软硬解码切换不重启单机跑满 8 路 1080p 流也不卡顿你见过一边放本地 MP4、一边拉三路 RTSP 流、再顺手把其中一路画面推到直播平台的 QT 播放器吗这不是 demo是实打实压测过 8 路 1080p H.264 流 1 路推流共存的工程体。它没用 QMediaPlayer 做外壳套壳——那玩意儿连多路同步都靠玄学而是直接基于 FFmpeg 封装了CTFFmpeg类把解封装、解码、渲染、编码、推流全链路攥在自己手里。RTMP 推流不是挂个按钮点一下就完事而是和播放线程共享同一帧缓冲池推流帧率可独立设比如播放 30fps推流强制 25fps避免网络抖动拖垮本地渲染。软硬解码切换是运行时热切NVIDIA GPU 上默认走 CUVID断电切回 CPU 软解毫秒级无黑屏。录像不是“录当前窗口”而是直采解码后 YUV 数据绕过 Qt 渲染管线保证时间戳对齐截图也非 QPixmap::grabWindow() 那种易丢帧的 GUI 截图而是从解码器输出队列里取原始 AVFrame 写 PNG。适合做安防中控台、教育录播终端、工业视觉看板这类对稳定性、低延迟、多流并发有硬要求的场景而不是写个课程设计交差。2. 从源码结构读懂它的“四层解耦”为什么它敢在播放器里干推流的事这个项目不是把播放和推流当两个功能模块拼起来而是按数据生命周期拆成四层输入层 → 解码层 → 呈现层 → 输出层。每一层都有明确边界、独立线程、可插拔实现。这种结构让 RTMP 推流不再是“加个按钮”而是作为输出层的一个合法分支和本地录像、截图平级。我们从源码文件名和依赖关系切入2.1 输入层不止支持 RTMP还预埋了 RTSP/HTTP-FLV/本地文件三类入口输入层核心是mediathread.cpp和ctffmpeg.cpp。前者是线程调度中枢后者是 FFmpeg 封装体。关键不在它用了avformat_open_input()而在于它对不同协议做了差异化初始化// ctffmpeg.cpp 中 initInput() 片段 if (url.startsWith(rtmp://)) { // 强制设置超时与重连参数避免 librtmp 默认行为导致卡死 av_dict_set(opts, timeout, 5000000, 0); // 5秒超时 av_dict_set(opts, reconnect, 1, 0); // 自动重连 av_dict_set(opts, reconnect_at_eof, 1, 0); av_dict_set(opts, reconnect_on_network_error, 1, 0); } else if (url.startsWith(rtsp://)) { // RTSP 单独启用 TCP 传输禁用 UDP防丢包花屏 av_dict_set(opts, rtsp_transport, tcp, 0); av_dict_set(opts, stimeout, 5000000, 0); } else if (url.startsWith(http://) url.contains(.flv)) { // HTTP-FLV 手动解析 FLV header跳过 libavformat 的 flv_probe 陷阱 parseFlvHeader(url); }提示av_dict_set设置的参数不是可有可无的装饰。RTMP 流若不设reconnect网络闪断后线程会永久阻塞在av_read_frame()RTSP 若不锁死tcpUDP 包乱序会导致 I 帧丢失后续 P 帧全花屏——这是某高校实验室部署时翻车的第一现场。2.2 解码层软硬解码共用同一 AVFrame 队列切换不丢帧解码逻辑集中在CTFFmpeg::decodeVideoFrame()。它不区分软硬解码路径统一输出AVFrame*到m_videoFrameQueue一个线程安全的环形缓冲区。硬解码CUDA/NVDEC由cuvid_decode_frame()实现软解码走avcodec_send_packet()avcodec_receive_frame()。关键在帧管理// mediathread.cpp 中 decodeLoop() 片段 while (m_running) { AVPacket pkt; int ret av_read_frame(m_formatCtx, pkt); if (ret 0) { /* 处理 EOF/错误 */ continue; } // 统一送入解码器无论软硬 ret ctffmpeg-sendPacket(pkt); av_packet_unref(pkt); // 从共享队列取解码帧所有下游渲染/推流/录像从此处取 AVFrame* frame m_videoFrameQueue-pop(); if (frame) { // 分发给渲染线程、推流线程、录像线程 —— 同一帧零拷贝 emit newVideoFrame(frame); } }参数说明m_videoFrameQueue是自定义的LockFreeRingBufferAVFrame*容量为 16 帧。设太小如 4会导致高码率流下频繁覆盖旧帧设太大如 64则内存占用飙升且增加延迟。16 是实测 1080p30fps 下兼顾吞吐与延迟的平衡点。2.3 呈现层QOpenGLWidget 渲染 时间戳驱动拒绝“播放器式”卡顿渲染不用QVideoWidget而是继承QOpenGLWidget在paintGL()中用 OpenGL ES 2.0 着色器渲染 NV12/YUV420P。关键在时间戳同步// mainwindow.cpp 中 onNewVideoFrame() 槽函数 void MainWindow::onNewVideoFrame(AVFrame* frame) { // 1. 计算显示时间戳PTS与系统时间差 double ptsSec frame-pts * av_q2d(m_videoTimeBase); // 转换为秒 double nowSec av_gettime_relative() / 1000000.0; double diffSec ptsSec - nowSec m_audioDelay; // 补偿音频延迟 // 2. 若帧太新diff 0.1s丢弃太旧diff -0.05s插值补偿 if (diffSec 0.1) return; // 防止渲染线程被拖垮 if (diffSec -0.05) m_renderSkipCount; // 累计跳帧数用于动态调速 // 3. 送入 OpenGL 渲染队列带精确 PTS m_glRenderer-queueFrame(frame, ptsSec); }逻辑说明这里不做“收到就渲染”的傻瓜逻辑。m_audioDelay是音频流与视频流的时间偏移量通过av_sync_get_clock()动态计算。diffSec超出阈值即丢帧或插帧确保音画始终同步而非靠 Qt 的QTimer定时刷新——后者在多路并发时必然失准。2.4 输出层推流/录像/截图三线并行共享解码帧但独立编码输出层由三个独立线程构成RTMPThread推流、MP4Recorder录像、ScreenshotWorker截图。它们都从m_videoFrameQueue取帧但各自维护编码上下文模块编码器容器格式关键参数用途RTMPThreadlibx264软或 nvenc硬FLV-c:v libx264 -preset ultrafast -tune zerolatency低延迟直播推流MP4Recorderlibx264MP4-c:v libx264 -crf 23 -preset medium高质量本地录像ScreenshotWorkerlibpngPNG直接av_image_copy_to_buffer()无损截图注意推流线程的-preset ultrafast不是摆设。实测中若用medium编码耗时超 40ms1080p30fps 下必掉帧而ultrafast控制在 8ms 内配合tune zerolatency可压到首帧延迟 200ms。3. RTMP 推流不是“配个 URL 就行”从连接建立到帧注入的七步闭环推流功能藏在rtmpthread.cpp虽未在摘要列出但 commit log 中ctffmpeg.cpp修改涉及rtmp_init调用。它不依赖外部库如 librtmp而是用 FFmpeg 的libavformat原生支持 RTMP但必须手动处理握手、chunk stream、AMF0 封包等底层细节。以下是完整七步流程每一步都对应源码中的关键检查点3.1 步骤一URL 校验与参数提取防错第一关推流地址格式必须为rtmp://host:port/app/stream_key代码强制校验// rtmpthread.cpp 中 validateRtmpUrl() bool RTMPThread::validateRtmpUrl(const QString url) { QUrl qurl(url); if (!qurl.isValid() || qurl.scheme() ! rtmp) return false; if (qurl.host().isEmpty() || qurl.port() -1) return false; QStringList pathParts qurl.path().split(/, Qt::SkipEmptyParts); if (pathParts.size() 2) return false; // 至少要有 app/stream_key m_appName pathParts[0]; m_streamKey pathParts[1]; return true; }参数说明m_appName如live和m_streamKey如cam001会被拼入 AMF0 connect 消息的tcUrl字段。若pathParts.size() 2FFmpeg 会静默失败日志只报Invalid data found when processing input极难排查。3.2 步骤二创建输出上下文并设置复用模式推流不是“打开一个输出文件”而是创建AVFormatContext并启用AVFMT_NOFILE无文件模式// rtmpthread.cpp 中 initOutput() int RTMPThread::initOutput() { avformat_alloc_output_context2(m_outputCtx, nullptr, flv, m_rtmpUrl.toUtf8().constData()); if (!m_outputCtx) return -1; // 关键必须设为 NOFILE否则 FFmpeg 试图 fopen rtmp:// 导致崩溃 m_outputCtx-oformat-flags | AVFMT_NOFILE; // 添加视频流复用输入流的 codecpar m_videoStream avformat_new_stream(m_outputCtx, nullptr); if (!m_videoStream) return -1; avcodec_parameters_copy(m_videoStream-codecpar, m_inputVideoStream-codecpar); m_videoStream-codecpar-codec_tag 0; // FLV 不需要 tag return 0; }逻辑说明AVFMT_NOFILE是生死线。不设此 flagFFmpeg 会调用open()系统调用去“打开” rtmp:// 地址Linux 下返回ENXIOWindows 下直接 crash。avcodec_parameters_copy()复用输入流参数省去手动配置 profile/level/bitrate 的麻烦但需确保输入流参数完整如缺失codec_tagFLV muxer 会报错。3.3 步骤三打开输出并写入 FLV Header连接 RTMP 服务器需两阶段先avio_open()建立 IO再avformat_write_header()发送 FLV header// rtmpthread.cpp 中 openOutput() int RTMPThread::openOutput() { // 1. 打开 IO 上下文实际建立 TCP 连接 int ret avio_open(m_outputCtx-oformat-priv_data, m_rtmpUrl.toUtf8().constData(), AVIO_FLAG_WRITE); if (ret 0) return ret; // 2. 写入 FLV header关键漏掉则服务器拒收 uint8_t flvHeader[9] {0x46, 0x4C, 0x56, 0x01, 0x05, 0x00, 0x00, 0x00, 0x09}; avio_write(m_outputCtx-pb, flvHeader, 9); avio_flush(m_outputCtx-pb); // 3. 写入 metadata可选但推荐 writeMetadata(); return 0; }避坑avio_write()写 FLV header 是硬性要求。FFmpeg 的flv_muxer不自动写 header必须手动。漏掉这 9 字节Nginx-RTMP 或 SRS 服务器会直接关闭连接日志只显示client closed connection毫无提示。3.4 步骤四时间戳重映射解决推流延迟累积输入流 PTS 是相对其自身起始时间推流需转为绝对时间戳以 1970-01-01 为起点// rtmpthread.cpp 中 remapTimestamp() int64_t RTMPThread::remapTimestamp(int64_t pts, AVRational timeBase) { // 输入流时间戳转为微秒 int64_t us av_rescale_q(pts, timeBase, AV_TIME_BASE_Q); // 减去流开始时间首次帧的 us得到相对起始的 us if (m_firstPtsUs -1) m_firstPtsUs us; int64_t relUs us - m_firstPtsUs; // 转为 FLV 时间戳毫秒并限制在 0~0xFFFFFF24bit int64_t flvTs FFMPEG_MAX(relUs / 1000, 0); return flvTs 0xFFFFFF; }参数说明flvTs 0xFFFFFF是 FLV 规范要求。FLV timestamp 字段仅 24bit最大值 16777215约 4.66 小时。若不掩码超限后 timestamp 回绕导致播放器跳播或卡死。3.5 步骤五H.264 Annex-B 转 FLV AVC Sequence HeaderFLV 容器不接受 Annex-B 格式00 00 00 01 分隔需转为 AVCDecoderConfigurationRecord// rtmpthread.cpp 中 generateAvcc() int RTMPThread::generateAvcc(uint8_t** out, int* outSize) { // 从输入流的 extradata 提取 sps/pps uint8_t* sps nullptr; int spsLen 0; uint8_t* pps nullptr; int ppsLen 0; extractSpsPps(m_inputVideoStream-codecpar-extradata, m_inputVideoStream-codecpar-extradata_size, sps, spsLen, pps, ppsLen); // 构造 AVCDecoderConfigurationRecordFLV required *outSize 7 spsLen ppsLen; *out (uint8_t*)av_malloc(*outSize); uint8_t* p *out; *p 1; // configurationVersion *p sps[1]; *p sps[2]; *p sps[3]; // avcProfileIndication, profile_compatibility, avcLevelIndication *p 0xFF; // lengthSizeMinusOne 3 (4 bytes) *p 0xE1; // numOfSequenceParameterSets 1 *p (spsLen 8) 0xFF; *p spsLen 0xFF; // sps length memcpy(p, sps, spsLen); p spsLen; *p 1; // numOfPictureParameterSets 1 *p (ppsLen 8) 0xFF; *p ppsLen 0xFF; // pps length memcpy(p, pps, ppsLen); return 0; }逻辑说明extractSpsPps()从extradata中分离 SPS/PPS。若输入流是 MP4extradata是 AVCC 格式可直接用若是 Annex-B如 RTSP 流需先av_bitstream_filter_filter()转换。漏掉此步推流后播放器显示“黑屏有声音”。3.6 步骤六NALU 封装为 FLV Tag含关键帧标记每个视频帧需封装为 FLV Video Tag头部含keyframe标志// rtmpthread.cpp 中 writeVideoTag() int RTMPThread::writeVideoTag(AVFrame* frame, uint8_t* data, int size) { // 1. 构造 FLV Video Tag Header (11 bytes) uint8_t tagHeader[11]; tagHeader[0] 0x09; // Video tag int frameType (frame-key_frame) ? 1 : 2; // 1key, 2inter int codecId 7; // AVC tagHeader[1] (frameType 4) | codecId; // FrameType CodecID // 2. 时间戳24bit 时间戳扩展8bit int64_t flvTs remapTimestamp(frame-pts, m_inputVideoStream-time_base); tagHeader[2] (flvTs 0) 0xFF; tagHeader[3] (flvTs 8) 0xFF; tagHeader[4] (flvTs 16) 0xFF; tagHeader[5] (flvTs 24) 0xFF; // timestamp extended // 3. DataSize (3 bytes) tagHeader[6] (size 16) 0xFF; tagHeader[7] (size 8) 0xFF; tagHeader[8] (size 0) 0xFF; // 4. StreamID (3 bytes, always 0) tagHeader[9] 0; tagHeader[10] 0; tagHeader[11] 0; // 5. 写入 header NALU data avio_write(m_outputCtx-pb, tagHeader, 11); avio_write(m_outputCtx-pb, data, size); avio_flush(m_outputCtx-pb); return 0; }参数说明frame-key_frame来自 FFmpeg 解码器输出非手动判断。若误将 P 帧标为 keyframe会导致播放器 seek 错乱若漏标 I 帧播放器无法随机访问。3.7 步骤七心跳保活与异常重连生产环境刚需RTMP 连接需定期发ping包否则服务器超时断开// rtmpthread.cpp 中 heartbeatLoop() void RTMPThread::heartbeatLoop() { while (m_running m_connected) { // 每 30 秒发一次 ping if (av_gettime_relative() - m_lastPingTime 30000000) { uint8_t ping[6] {0x02, 0x00, 0x00, 0x00, 0x00, 0x00}; // ping command avio_write(m_outputCtx-pb, ping, 6); avio_flush(m_outputCtx-pb); m_lastPingTime av_gettime_relative(); } msleep(1000); } }避坑ping包必须是 AMF0 格式0x02是ping命令类型。某些服务器如 Wowza要求严格发错字节直接断连。msleep(1000)避免空转耗 CPU。4. 避坑八条血泪经验每一条都来自真实翻车现场这个播放器在多个嵌入式设备和工控机上部署过以下问题均非理论推测而是实打实的日志截图、内存 dump 和 Wireshark 抓包验证过的典型故障。请逐条对照你的环境4.1 现象RTMP 推流连接成功但服务器日志显示 “client closed connection”原因漏写 FLV 文件头9 字节46 4C 56 01 05 00 00 00 09或AVFMT_NOFILEflag 未设置导致 FFmpeg 底层尝试open(rtmp://...)失败后静默退出。解决在openOutput()中确认avio_open()后立即avio_write()写 header并检查m_outputCtx-oformat-flags AVFMT_NOFILE是否为真。4.2 现象多路播放时某一路突然卡死CPU 占用飙到 100%其他路正常原因mediathread.cpp中av_read_frame()阻塞在坏流上如 RTSP 流发送端崩溃但 TCP 连接未断而该线程无超时机制。解决为每路流单独设置stimeout参数单位微秒并在av_read_frame()外加select()超时检测超时则avformat_close_input()重建。4.3 现象硬解码CUDA开启后播放器启动即崩溃日志报cuInit failed原因NVDEC 初始化早于 CUDA 上下文创建或显卡驱动版本过低 410.x不支持cuvidCreateVideoSource()。解决在CTFFmpeg::initHardwareDecoder()中先调用cuInit(0)再检查nvcuvid.h版本若失败自动降级到软解并弹窗提示。4.4 现象录像文件MP4无法用 VLC 播放报 “moov atom not found”原因mp4recorder.cpp使用AVFMT_NOFILE模式但未在结束时调用av_write_trailer()写入 moov box。解决在MP4Recorder::stop()中必须执行av_write_trailer(m_outputCtx)且确保m_outputCtx-oformat-flags AVFMT_NOFILE为假即已avio_open()。4.5 现象截图 PNG 文件全黑或只有左上角 1/4 有图像原因ScreenshotWorker直接av_image_copy_to_buffer()时未按AV_PIX_FMT_NV12的 plane 布局拷贝Y 平面宽高UV 平面宽高各半。解决对 NV12 格式需分两次拷贝先拷 Y planeframe-data[0],frame-linesize[0] * height再拷 UV planeframe-data[1],frame-linesize[1] * height/2。4.6 现象推流到 SRS 服务器后Web 播放器显示 “NetStream.Play.StreamNotFound”原因SRS 要求tcUrl必须包含app名且stream_key不能含特殊字符如/,?,#而代码未过滤。解决在validateRtmpUrl()中对m_streamKey执行QUrl::toEncoded()编码并校验m_appName非空。4.7 现象8 路 1080p 播放时GPU 显存爆满nvidia-smi显示compute占用 100%原因CUVID 解码器未启用CUVID_PKT_ENDOFSTREAM标志导致解码器内部缓冲区持续增长显存泄漏。解决在CTFFmpeg::decodeVideoFrame()结束时向 CUVID 发送CUVID_PKT_ENDOFSTREAM包强制释放内部资源。4.8 现象本地文件MP4播放正常但 RTMP 流播放时音画不同步音频快 2 秒原因CTFFmpeg未正确解析 RTMP 流的音频时间基time_base默认用AV_TIME_BASE_Q导致av_rescale_q()计算错误。解决在initInput()中遍历m_formatCtx-streams对音频流调用av_guess_frame_rate()获取真实 time_base并缓存到m_audioTimeBase。5. 多路同步播放的终极方案用 AVSyncController 实现亚帧级精度控制当你需要同时播放 4 路 IPC 流并做画中画比对或 6 路教学视频同步启停时Qt 的QTimer或QElapsedTimer都不够用——它们的精度在 10~15ms而视频帧间隔是 33ms30fps误差足以导致肉眼可见的撕裂。本项目给出的解法是用 FFmpeg 的AVSyncController自研类接管所有流的 PTS 调度基于硬件时间戳clock_gettime(CLOCK_MONOTONIC)做闭环反馈。这不是理论是已在某跨平台安防中控台稳定运行 18 个月的方案。5.1 为什么不用 Qt 自带的媒体类QMediaPlayer的positionChanged()信号是事件驱动触发时机不可控setPlaybackRate()只能粗调无法应对网络抖动。而AVSyncController的目标是任意两路流的 PTS 差值稳定在 ±1 帧内即 ±33ms。它通过三重机制达成机制作用实现位置主时钟源选定一路流为 master clock其他流 slave clock 跟随AVSyncController::setMasterStream()动态速率调节当 slave PTS 落后 master 2 帧临时提速 1.05x超前则减速AVSyncController::adjustPlaybackRate()帧级丢弃/重复速率调节仍无法追平时主动丢弃或重复渲染一帧AVSyncController::renderFrame()5.2 核心代码如何让 6 路流真正“同频共振”AVSyncController是单例所有MediaThread在onNewVideoFrame()中注册自己// avsynccontroller.cpp void AVSyncController::registerStream(MediaThread* thread, bool isMaster) { QMutexLocker locker(m_mutex); StreamInfo info; info.thread thread; info.isMaster isMaster; info.lastRenderedPts -1; info.renderCount 0; m_streams.append(info); if (isMaster) { m_masterStream thread; m_masterBaseTime av_gettime_relative(); // 记录 master 启动时刻 } } // 主循环每 10ms 检查一次同步状态 void AVSyncController::syncLoop() { while (m_running) { msleep(10); QMutexLocker locker(m_mutex); if (!m_masterStream) continue; int64_t masterNow av_gettime_relative() - m_masterBaseTime; for (auto s : m_streams) { if (s.thread m_masterStream) continue; // 计算 slave 当前应渲染的 PTS单位微秒 int64_t targetPtsUs masterNow * 1000; // 1:1 同步 int64_t currentPtsUs s.thread-getCurrentPtsUs(); int64_t diffUs targetPtsUs - currentPtsUs; if (llabs(diffUs) 33000) { // 超过 1 帧33ms double rate 1.0 (diffUs / 330000.0); // 每 10 帧偏差调速 0.1x rate FFMPEG_CLAMP(rate, 0.5, 2.0); // 限幅 s.thread-setPlaybackRate(rate); // 若偏差 2 帧强制丢帧或插帧 if (llabs(diffUs) 66000) { s.thread-forceDropFrame(llabs(diffUs) 100000); } } } } }参数说明FFMPEG_CLAMP(rate, 0.5, 2.0)防止速率突变导致画面抽搐forceDropFrame()是MediaThread的接口内部直接跳过emit newVideoFrame()不进入渲染管线。5.3 验证方法用 Wireshark 抓包 自定义日志双校验光看画面是否同步不够要量化。我们在AVSyncController中加入日志钩子// avsynccontroller.cpp 中 syncLoop() 末尾 if (m_logEnabled (frameCount % 100 0)) { QFile logFile(/tmp/sync_log.txt); logFile.open(QIODevice::Append); QTextStream out(logFile); out QDateTime::currentMSecsSinceEpoch() \t master: m_masterStream-getCurrentPtsUs() \t slave1: m_streams[1].thread-getCurrentPtsUs() \t diff: (m_streams[1].thread-getCurrentPtsUs() - m_masterStream-getCurrentPtsUs()) \n; logFile.close(); }然后用 Python 脚本分析日志# analyze_sync.py import pandas as pd df pd.read_csv(/tmp/sync_log.txt, sep\t, names[ts, master, slave1, diff]) print(f平均偏差: {df[diff].mean():.0f} us) print(f最大偏差: {df[diff].max():.0f} us) print(f标准差: {df[diff].std():.0f} us) # 输出示例平均偏差: -1200 us最大偏差: 28000 us标准差: 4200 us → 达标 33000us逻辑说明diff单位是微秒33000us 33ms 1 帧。标准差 5000us 说明同步非常稳定若 10000us需检查master流是否本身有 jitter用ffprobe -v quiet -show_entries formatduration验证源流稳定性。5.4 进阶技巧如何让录像文件也保持多路同步多路录像不是简单地“每路开一个MP4Recorder”那样录出来的文件时间轴完全独立。正确做法是所有MP4Recorder共享同一个AVSyncController的 master clock并在写入AVPacket时重写pts/dts// mp4recorder.cpp 中 writeVideoPacket() int MP4Recorder::writeVideoPacket(AVPacket* pkt) { // 从 AVSyncController 获取当前 master 时间戳微秒 int64_t masterUs AVSyncController::instance()-getMasterTimeUs(); // 转为当前流的时间基 pkt-pts av_rescale_q(masterUs, AV_TIME_BASE_Q, m_videoStream-time_base); pkt-dts pkt-pts; return av_interleaved_write_frame(m_outputCtx, pkt); }这样录出的 4 个 MP4 文件用ffprobe查看start_time会发现它们完全一致用ffmpeg -i cam1.mp4 -i cam2.mp4 -filter_complex [0:v][1:v]hstack out.mp4拼接时画面严丝合缝。从那以后我每次部署多路播放项目都强制走一遍AVSyncController的syncLog分析哪怕客户说“看起来没问题”。因为人眼分辨不了 10ms 的偏差但算法识别会因帧错位漏检目标——这在某工业质检项目里让我返工三次最后咬牙加了这套同步。希望帮到你。本文还有配套的精品资源点击获取