简介:面向需要接入大华 IPC、NVR 并实现视频预览的 Java 后端开发者与监控系统学习者,这份播放 Demo 以 NetSDK 调用为核心,串联设备登录、实时码流获取、数据处理与向前端转发等环节,并配合前端页面展示播放效果,适合用于理解网络摄像机视频链路的整体协作,也可作为课程设计、项目原型和二次开发联调的参考样例。包内共 1852 个文件,以 1810 个 Java 源码为主体,配合 9 个 Vue 页面、8 个 XML 配置以及 properties、json、js、css 等资源,另含少量 Maven Wrapper 与 jar 依赖,压缩包约 1.74MB。工程涵盖后端服务、前端界面与构建配置,目录组织较完整,便于按模块查阅接口实现、页面调用和依赖管理。已有 1995 人学习下载,说明该示例在同类设备接入场景中具备一定参考热度。读者可从中梳理 NetSDK 接入思路、实时视频流转发流程、前后端联调接口组织方式,以及 IPC/NVR 视频播放场景的工程目录参考,适合作为监控平台功能验证与开发起步的样本。
1. 从一路网络摄像头说起:这个视频播放 Demo 解决的是什么问题
项目现场最常见的画面是这样的:一台 IPC 装在门口,NVR 在机柜里默默录像,用户打开浏览器想看实时画面,结果插件装不上、页面白屏。这时候业务方要的其实很简单——给我一个能在自己电脑上双击就播的窗口。这份 Demo 干的就是这件事,把 IPC 和 NVR 的实时视频、录像回放用原生程序播出来,不依赖浏览器和控件。
它适合三类人:做安防集成、需要在自有系统里嵌视频窗口的开发者;想搞懂 RTSP 拉流到屏幕渲染这条链路的学生和转行者;以及手上有一堆设备、想批量做巡检工具的人。代码不追求做成产品,但链路是完整的,登录、拉流、解码、渲染、回放各环节都能单独拆出来复用。
2. 工程结构与技术选型:拉流、解码、渲染三层怎么拆
2.1 目录结构:五个目录对应五件事
拿到这种播放器项目,我第一件事不是看 main,是先看目录怎么切的。切得清楚说明作者想过模块边界,后面改起来才不会牵一发动全身。这个 Demo 的目录大致是这样:
ipc-player/ ├── src/ │ ├── device/ # 设备登录、会话管理、断线重连 │ ├── media/ # 拉流、解复用、解码 │ ├── render/ # YUV 上屏、窗口与缩放 │ ├── playback/ # NVR 录像检索与回放控制 │ └── app/ # 入口、配置加载、UI 事件循环 ├── include/ # 对外头文件,SDK 与 FFmpeg 的薄封装 ├── config/ │ └── device.json # 设备地址、通道、码流类型 ├── third_party/ # 预编译 SDK 与 FFmpeg 库 └── CMakeLists.txtdevice和media分开是关键设计。很多新手 Demo 把登录和拉流写在一个函数里,结果换一台设备就要改一遍代码。这里device只负责"拿到一个可用会话",media只关心"给定 URL 或句柄,吐出解码后的帧"。渲染层再往下游走,只认AVFrame,不认设备。三层之间用结构体传参,没有全局变量横飞,这点很值得抄。
playback单独拎出来也不意外,因为实时预览和录像回放的取流方式完全不同:前者是持续推流,后者是按时间段取,还要支持暂停、拖动、倍速。混在一起写,代码很快就会变成一团。
2.2 技术栈:为什么最后落在 FFmpeg + SDL2 + 设备私有 SDK
这类项目通常有两条路:纯标准协议路线,全程 RTSP + FFmpeg;或者私有 SDK 路线,登录、拉流、回放都走厂商接口。Demo 里的做法是两条腿走路,各管一摊。
| 环节 | 选型 | 说明 |
|---|---|---|
| 设备登录 / 通道信息 | 设备私有 SDK | 拿通道列表、码流能力、录像时间段,标准协议拿不全 |
| 实时流拉取 | FFmpeg + RTSP | 通用,换品牌也能跑,参数可控 |
| 解码 | FFmpeg (libavcodec) | H.264 / H.265 都覆盖,硬解也能接 |
| 渲染 | SDL2 | 跨平台,YUV 纹理直传,代码量小 |
| 配置 | JSON | 改设备不用重新编译 |
为什么登录不直接用 RTSP 的 URL 带账号密码?因为实际设备上,主码流、子码流、第三码流的 URL 规则不一定公开,通道号、码流类型的映射关系也各不相同,硬拼 URL 是一场地狱。用 SDK 把设备"摸清楚",拿到通道和码流参数,再决定用哪条流,这条路径稳得多。
渲染选 SDL2 而不是 Qt 的QOpenGLWidget,理由很实在:Demo 要跨平台跑,SDL2 依赖少,一个SDL_UpdateYUVTexture就能把解码出来的 YUV420P 直接怼上屏,省掉自己写 shader 的工夫。等真要做产品,再换成 Qt 或直接上 OpenGL 都来得及,接口是同一套帧数据。
提示:SDK 的库文件通常分 x86 和 x64,还分带不带 HTTPS 的版本,混用会在登录阶段就失败,且报错信息往往很含糊。先把
third_party下的库和头文件版本对齐,再谈跑通。
2.3 构建:CMake 与依赖清单
依赖装好之后,编译本身不难,难的是让 CMake 同时找到私有 SDK 和 FFmpeg。我的写法是把 SDK 当成一个 imported target 包起来,业务代码只管 link 名字,不关心路径。
cmake_minimum_required(VERSION 3.16) project(ipc_player CXX) set(CMAKE_CXX_STANDARD 17) # 私有设备 SDK:头文件和库分开找,方便换平台 set(DEV_SDK_ROOT "${CMAKE_SOURCE_DIR}/third_party/devsdk") add_library(devsdk SHARED IMPORTED) set_target_properties(devsdk PROPERTIES IMPORTED_LOCATION "${DEV_SDK_ROOT}/lib/libdevsdk.so" INTERFACE_INCLUDE_DIRECTORIES "${DEV_SDK_ROOT}/include" ) # FFmpeg 用 pkg-config,比手写 find_path 少踩坑 find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libavutil libswscale) pkg_check_modules(SDL2 REQUIRED sdl2) add_executable(ipc_player src/app/main.cpp src/device/ipc_session.cpp src/media/rtsp_puller.cpp src/media/h264_decoder.cpp src/render/sdl_renderer.cpp src/playback/nvr_playback.cpp ) target_include_directories(ipc_player PRIVATE ${FFMPEG_INCLUDE_DIRS} ${SDL2_INCLUDE_DIRS}) target_link_libraries(ipc_player PRIVATE devsdk ${FFMPEG_LIBRARIES} ${SDL2_LIBRARIES} pthread )add_library(... SHARED IMPORTED)这段的意思是:告诉 CMake 有个现成的动态库放在指定位置,别去系统路径找。好处是target_link_libraries只写devsdk一个词,将来把库从third_party挪到/opt下,只改IMPORTED_LOCATION一行。
pkg_check_modules拿到的${FFMPEG_LIBRARIES}会自动带上-lavformat -lavcodec这些,不用手写。新手常见的翻车是find_package(FFmpeg)找不到——FFmpeg 官方并不提供 CMake config 文件,得靠 pkg-config 或自己写FindFFmpeg.cmake。
编译命令就三行:
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)-DCMAKE_BUILD_TYPE=Release别省。Debug 下开启的断言在解码循环里会明显拖慢帧率,第一次跑觉得"怎么这么卡",八成是忘了加这个参数。
3. 核心链路拆解:一路 RTSP 从 URL 到屏幕像素
3.1 登录与会话:先拿到设备句柄再谈拉流
登录这一层,我习惯封装成 RAII 的会话对象,构造即登录,析构即登出。这样主流程里哪怕中途抛异常,句柄也不会泄漏。
// ipc_session.h —— 设备会话,构造登录、析构登出 class IpcSession { public: explicit IpcSession(const DeviceCfg& cfg); ~IpcSession(); IpcSession(const IpcSession&) = delete; IpcSession& operator=(const IpcSession&) = delete; bool ok() const { return handle_ != 0; } long handle() const { return handle_; } const std::vector<ChannelInfo>& channels() const { return channels_; } private: long handle_ = 0; // 设备句柄,0 表示无效 std::vector<ChannelInfo> channels_; };// ipc_session.cpp IpcSession::IpcSession(const DeviceCfg& cfg) { LoginParam in{}; in.ip = cfg.ip.c_str(); in.port = cfg.port; // 设备服务端口,不是 RTSP 的 554 in.user = cfg.user.c_str(); in.pass = cfg.pass.c_str(); in.transport = LoginTransport::kTcp; // 强制 TCP,UDP 在跨网段时丢包严重 LoginResult out{}; // 第 4 个参数是超时毫秒,默认 5s 在弱网下偏短 handle_ = DevLogin(&in, &out, 8000); if (handle_ == 0) { throw std::runtime_error("login failed, code=" + std::to_string(out.errorCode)); } // 登录成功不代表通道可用,必须再查一次通道能力 ChannelInfo buf[16]{}; int n = DevQueryChannels(handle_, buf, 16); if (n <= 0) { DevLogout(handle_); handle_ = 0; throw std::runtime_error("query channels failed"); } channels_.assign(buf, buf + n); } IpcSession::~IpcSession() { if (handle_ != 0) { DevLogout(handle_); // 必须成对调用,否则设备侧会话数会耗尽 handle_ = 0; } }这里有两个参数值得单独说。in.port是设备服务端口,跟 RTSP 的 554 不是一个东西,很多人第一次写就把 554 填进去,然后登录一直超时。DevLogin的超时给 8 秒,是因为设备刚上电时服务还没完全起来,5 秒经常差那么一点。
通道查询那一步不能省。登录成功只证明账号密码对,不代表这个账号有权限看某个通道,也不代表通道在线。DevQueryChannels返回的数量就是当前账号能看到的通道数,为 0 就该直接报错退出,而不是硬着头皮去拉流。
注意:设备对同时存在的会话数有上限,通常是几十个。频繁开关的程序务必保证析构里登出,否则跑一夜之后登录会开始报"用户数已达上限",这个坑我踩过。
3.2 拉流与解复用:RTSP over TCP 的那几个参数
拿到通道信息之后,拼 RTSP URL 或者直接用 SDK 的取流句柄都可以。Demo 里用 FFmpeg 走标准 RTSP,好处是换别的品牌设备也能跑。
// rtsp_puller.cpp —— 打开 RTSP 并找到视频流索引 bool RtspPuller::open(const std::string& url) { AVDictionary* opt = nullptr; // 强制 TCP:UDP 在丢包时会花屏,且不会自动恢复 av_dict_set(&opt, "rtsp_transport", "tcp", 0); // 连接与读超时,单位微秒;不设的话断网时会卡死在 av_read_frame av_dict_set(&opt, "stimeout", "5000000", 0); // 缓冲上限,调小可以降延迟,太小会频繁丢帧 av_dict_set(&opt, "buffer_size", "1048576", 0); int ret = avformat_open_input(&fmt_, url.c_str(), nullptr, &opt); av_dict_free(&opt); if (ret < 0) { logErr("open_input", ret); return false; } ret = avformat_find_stream_info(fmt_, nullptr); if (ret < 0) { logErr("find_stream_info", ret); return false; } video_idx_ = av_find_best_stream(fmt_, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (video_idx_ < 0) { logErr("no video stream", video_idx_); return false; } return true; }rtsp_transport=tcp是这类项目的默认值,理由不是 TCP 比 UDP 快,而是 UDP 一旦丢一个包,解出来的画面就会绿一块黄一块,而且这种损坏会沿着参考帧往后传染好几帧。TCP 会把延迟顶上去一点,但画面是干净的,工程上更划算。
stimeout这个参数是老版本 FFmpeg 的叫法,新版叫timeout,两个都设上不冲突。不设的后果是:设备掉线后av_read_frame会一直阻塞,UI 线程直接冻住,用户以为程序崩了。
buffer_size是接收缓冲区。设大了延迟高、抗抖动强;设小了延迟低,但网络一抖就丢。1MB 是个比较中庸的值,局域网够用,跨公网再往上加。
3.3 解码:send_packet / receive_frame 的配对节奏
解码这一段,新手最容易写成"一包进一帧出",以为它们是一对一的。实际上一个 packet 可能解出 0 帧,也可能解出好几帧,必须用循环把它抽干。
// h264_decoder.cpp —— 送包、收帧,直到 EAGAIN bool H264Decoder::decode(const AVPacket* pkt, std::vector<AVFrame*>& out) { int ret = avcodec_send_packet(ctx_, pkt); if (ret < 0 && ret != AVERROR(EAGAIN)) { logErr("send_packet", ret); return false; } // 必须循环,一次 send 可能对应 0~N 次 receive while (true) { AVFrame* frame = av_frame_alloc(); ret = avcodec_receive_frame(ctx_, frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { av_frame_free(&frame); // 没数据就立刻释放,否则每次都漏一帧内存 break; } if (ret < 0) { av_frame_free(&frame); logErr("receive_frame", ret); return false; } out.push_back(frame); // 由调用方负责 av_frame_free } return true; }AVERROR(EAGAIN)不是错误,是"现在没数据,等下再问"。把它当错误处理,程序会在第一个 B 帧处直接退出。AVERROR_EOF在实时流里一般不出现,但回放拖到末尾时会遇到,同样走 break。
av_frame_alloc放在循环里面很有讲究。放在外面复用,看似省了 malloc,实际会被下一次receive_frame覆盖,如果下游是异步渲染,画面就会闪。宁可多分配几次,也不要共享同一个 frame。
还有一个隐藏点:送进去的pkt里的pts要保留。后面做音视频同步或者回放拖动,全靠这个时间戳。有些 Demo 图省事把 pts 清零,结果播放速度完全跟着网络走,快的时候飞、慢的时候卡。
3.4 渲染与时钟:YUV 直传纹理和丢帧策略
渲染层最简单,也最容易出玄学问题。SDL2 的做法是建一个 YUV 纹理,每帧更新三个平面的指针。
// sdl_renderer.cpp —— YUV420P 直接上屏 bool SdlRenderer::draw(AVFrame* f) { if (!tex_) { // 纹理格式必须和解码输出一致,H.264 一般是 IYUV tex_ = SDL_CreateTexture(ren_, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, f->width, f->height); if (!tex_) return false; } SDL_UpdateYUVTexture(tex_, nullptr, f->data[0], f->linesize[0], // Y 平面 f->data[1], f->linesize[1], // U 平面 f->data[2], f->linesize[2]); // V 平面 SDL_RenderClear(ren_); SDL_RenderCopy(ren_, tex_, nullptr, nullptr); // 拉伸铺满窗口 SDL_RenderPresent(ren_); return true; }linesize不能换成width。解码器的行对齐通常是 32 字节,1920 宽的图linesize[0]可能是 1920,也可能被补齐到 1920 之外的边界,用 width 当步长就会看到画面斜着撕裂。这是花屏问题里最高频的一条。
丢帧策略要单独说。实时预览的目标是"追上最新一帧",不是"一帧不落"。我的做法是维护一个队列,超过 3 帧就丢最老的:
// 队列超过阈值直接丢最旧帧,保证延迟不累积 if (frame_queue_.size() > 3) { AVFrame* old = frame_queue_.front(); frame_queue_.pop_front(); av_frame_free(&old); } frame_queue_.push_back(frame);阈值 3 是经验值。设 1 会让画面一顿一顿的,设 10 延迟就上到一秒以上。这个数跟设备 GOP 长度也有关,GOP 越大越要留余量,否则丢到关键帧前面,后面全解不出来。
4. NVR 录像回放:按时间轴取流的实现
4.1 录像检索:先问设备有哪些时间段有录像
回放和实时预览最大的区别是:预览随时能拉,回放得先知道哪里有录像。NVR 上的录像不是连续的,可能是移动侦测触发、可能是定时计划,中间有大量空洞。直接按时间点去拉,很可能拿到一片黑。
所以第一步永远是检索。Demo 里的做法是按天查一次,把时间段缓存起来。
// nvr_playback.cpp —— 查询某天某个通道的录像时间段 std::vector<TimeRange> NvrPlayback::queryDay(int channel, std::time_t day) { std::vector<TimeRange> result; QueryParam q{}; q.channel = channel; q.startTime = day; // 当天 00:00:00 q.endTime = day + 86400; // 次日 00:00:00 q.type = RecordType::kAll; // 定时 + 移动侦测 + 报警,全都要 // 单次最多返回 64 段,超出要分页 RecordItem buf[64]{}; int total = 0; int n = DevQueryRecord(handle_, &q, buf, 64, &total); if (n < 0) { logErr("query_record", n); return result; } for (int i = 0; i < n; ++i) { result.push_back({buf[i].begin, buf[i].end}); } // total > n 说明没取完,按最后一段的结束时间续查 while (total > n) { q.startTime = result.back().end; n = DevQueryRecord(handle_, &q, buf, 64, &total); if (n <= 0) break; for (int i = 0; i < n; ++i) { result.push_back({buf[i].begin, buf[i].end}); } } return result; }RecordType::kAll这个参数很关键。默认值往往只查定时录像,移动侦测那部分查不出来,用户就会说"明明有录像你程序里没有"。这类问题排查起来特别费时间,因为设备网页上看得见,你的程序里看不见,很容易怀疑是自己代码写错了。
分页那块也别省。一天 24 小时,如果摄像机每 10 分钟触发一次移动侦测,段数轻松过百,单次缓冲区装不下,不续查就会丢后半天的记录。
4.2 回放通道:时间戳、倍速与拖动定位
拿到时间段之后,回放本身是一次带起止时间的取流。设备侧会按时间顺序把录像推给你,中间遇到空洞会自动跳到下一段。
// 打开回放,指定起止时间 PlaybackParam p{}; p.channel = channel; p.startTime = seek_to; // 用户拖动后的目标时间 p.endTime = day_end; p.mode = PlaybackMode::kByTime; p.speed = speed; // 1 / 2 / 4 / 8 倍速 if (!DevStartPlayback(handle_, &p, &play_handle_)) { logErr("start_playback", 0); return false; }拖动定位的做法是"停掉再重开",而不是在流里找关键帧。因为 NVR 的取流是按时间索引的,你告诉它从 14:32:10 开始,它会自己定位到最近的 I 帧。自己在前端做 seek,反而容易错位。
倍速播放有个坑:2 倍速以上的时候,设备推流速度变快,但解码器的输出节奏不变,如果不丢帧,画面会越积越多。做法是解码后按pts / speed重算显示时间,超前的帧直接丢。
// 倍速下的显示时间换算,pts 单位是流的时间基 int64_t display_pts = frame->pts * tb_num / (tb_den * speed);拖动之后还要清空解码器缓存。avcodec_flush_buffers(ctx_)这一句不能漏,否则会拿新流的第一个 P 帧去参考旧流的帧,画面上出现一大片马赛克,持续到下一个 I 帧才恢复。
5. 跑起来:从编译到看到第一帧画面
5.1 环境准备与依赖安装
跑通这套东西,机器上要有编译器、CMake、FFmpeg 开发包、SDL2 开发包,加上厂商给的 SDK 目录。
# Debian / Ubuntu 系 sudo apt update sudo apt install -y build-essential cmake pkg-config \ libavformat-dev libavcodec-dev libavutil-dev libswscale-dev \ libsdl2-dev # 验证 FFmpeg 版本,4.x 和 6.x 的 API 有差异 pkg-config --modversion libavcodecFFmpeg 版本差异是真实存在的坑。4.x 时代avcodec_decode_video2还能用,6.x 已经删了,只剩 send/receive 这套。Demo 里用的是 send/receive,理论上 4.x 以上都能编。如果报"undefined reference to avcodec_decode_video2",那就是拿了老代码配新库,或者反过来。
SDL2 在部分发行版上要单独装libsdl2-dev,只装运行时包libsdl2-2.0-0是不够的,链接阶段会找不到SDL.h。
SDK 目录按前面的结构放进third_party/devsdk,里面至少要有include和lib两个子目录。SDK 一般还会带一堆运行时依赖,比如加密库、网络库,用ldd看一眼缺什么:
ldd third_party/devsdk/lib/libdevsdk.so | grep "not found"这条命令的输出如果非空,说明还差运行库,先把缺的补上再编译,否则程序能编过但一启动就退出,报错还特别含糊。
5.2 配置文件:一个 JSON 说清设备与播放参数
配置外置之后,换设备不用重新编译,调试也方便。Demo 里的config/device.json大概长这样:
{ "device": { "ip": "10.0.0.64", "port": 8000, "user": "admin", "pass": "******" }, "preview": { "channel": 1, "stream": "sub", "transport": "tcp", "buffer_ms": 300, "max_queue": 3 }, "playback": { "channel": 1, "speed": 1 } }| 字段 | 含义 | 调整建议 |
|---|---|---|
port | 设备服务端口 | 不是 RTSP 的 554,填错会一直超时 |
stream | 主码流 / 子码流 | 预览用 sub 降延迟,录像用 main 保清晰度 |
transport | tcp / udp | 跨网段一律 tcp |
buffer_ms | 接收缓冲时长 | 局域网 100~300,公网 500~1000 |
max_queue | 渲染队列上限 | 越小延迟越低,太小会顿 |
stream这个字段最值得注意。主码流通常是 1080P 甚至 4K,码率 4Mbps 以上,解码和渲染都吃资源;子码流一般是 D1 或 720P,码率几百 K,用来做多画面预览非常合适。很多人一上来就用主码流做九宫格,八个窗口一起跑,CPU 直接干到 100%。
5.3 首次运行与验证:三步确认链路通没通
第一次跑别急着看画面,按三步来,每一步都有明确的判断依据。
第一步,验证登录。程序启动后先看日志里有没有打印通道数量:
./ipc_player --config config/device.json --probe-only--probe-only只登录不拉流,输出通道列表和每个通道的编码格式。能看到通道就说明网络、端口、账号三样都对了。这一步失败,后面全都白搭。
第二步,验证取流。加上--dump-rtsp参数,把收到的头几个 packet 信息打出来:
./ipc_player --config config/device.json --dump-rtsp 30正常输出里应该有codec_id=H264或H265,以及递增的 pts。如果 codec_id 是 none,说明 SDP 协商没成功,多半是 URL 拼错了或者设备不支持这个码流类型。
第三步,才开窗口看画面。
./ipc_player --config config/device.json窗口出来、画面动起来,链路就通了。这时候可以打开日志看一眼实际帧率和丢帧数,如果帧率明显低于配置值,就是性能或者网络的问题,别急着怀疑代码。
6. 避坑与排查:黑屏、花屏、延迟的常见成因
6.1 窗口出来了但画面全黑
现象是程序不报错,窗口也能正常显示,但内容一直是黑的,日志里帧数还在涨。
原因通常是像素格式对不上。解码器输出的是 NV12,渲染层按 IYUV 建纹理,三个平面的偏移全部错位,结果整张图都是黑的或者纯色。另一种可能是linesize用了width代替,导致读越界,读到的全是零。
解决办法是先打印一帧的格式再建纹理:
std::cout << "pix_fmt=" << av_get_pix_fmt_name((AVPixelFormat)f->format) << " w=" << f->width << " h=" << f->height << " ls=" << f->linesize[0] << std::endl;按输出的格式去SDL_CreateTexture,别写死。如果格式是 NV12,就用SDL_PIXELFORMAT_NV12,用错了 SDL 不会报错,只会给你一片黑。
6.2 画面能动但每隔几秒花一下
现象是画面整体正常,但每隔几秒出现一次马赛克或者绿块,持续一两秒又恢复。
原因一般是丢包,而且丢在了关键帧附近。TCP 传输理论上不丢,但如果设备侧主动丢弃(比如带宽不够)、或者中间经过了会重置连接的设备,就会在 GOP 边界上出问题。另一个可能是接收缓冲区太小,buffer_size撑不住码率峰值。
解决办法是把buffer_size提到 2MB 以上先试,同时打开 FFmpeg 的日志级别看有没有Packet corrupt之类的警告:
av_log_set_level(AV_LOG_WARNING);如果警告里频繁出现decode_slice_header error,基本可以确定是码流不完整,去查网络链路,别在解码器上找原因。
6.3 延迟越跑越大,最后差十几秒
现象是刚启动时延迟正常,跑十几分钟后画面明显滞后于现实,重启程序又恢复。
原因是渲染队列只进不出,或者出得比进得慢。解码速度快于渲染速度时,队列无限增长,延迟线性累积。前面提过的max_queue限流没生效,或者写成了"队列满了就阻塞等待"而不是"丢最旧帧",都会导致这个问题。
解决办法是把阻塞式入队改成丢弃式,并定期打印队列长度:
if (queue_.size() > kMaxQueue) { dropOldest(); ++drop_count_; } if (++stat_tick_ % 100 == 0) { std::cout << "queue=" << queue_.size() << " dropped=" << drop_count_ << std::endl; }日志里dropped一直涨、queue稳定在阈值附近,说明限流在工作,延迟就是可控的。
6.4 跑两小时内存吃掉几个 G
现象是程序启动时内存几百兆,跑一段时间后涨到几个 G,最终被杀掉。
原因几乎都是AVFrame和AVPacket没释放。av_frame_alloc出来的对象,receive_frame之后如果没进渲染队列、或者在队列限流时被丢弃,都必须av_frame_free。有些代码在break分支里只写了continue,就漏了。
解决办法是用 RAII 包一层:
struct FrameDeleter { void operator()(AVFrame* f) const { if (f) av_frame_free(&f); } }; using FramePtr = std::unique_ptr<AVFrame, FrameDeleter>;队列里存FramePtr,出队即析构,再怎么改逻辑也不会漏。这种问题靠看代码审出来很难,用valgrind --leak-check=full跑十分钟,泄漏点一目了然。
6.5 NVR 回放拖动后直接卡死
现象是回放正常,一拖动进度条,程序就无响应,或者卡住十几秒才恢复。
原因有两个。一是拖动时没有先停流,直接在原句柄上改时间参数,设备侧状态机乱了。二是停流之后没有avcodec_flush_buffers,解码器还抱着旧帧的参考关系,新流的第一个 P 帧解不出来,一直卡在那里等下一个 I 帧,GOP 长的话就是十几秒。
解决办法是把拖动做成"停—清—重开"三步:
bool seekTo(std::time_t t) { stopPlayback(); // 1. 停掉当前取流 avcodec_flush_buffers(dec_ctx_); // 2. 清解码器缓存 return startPlayback(t); // 3. 从新时间点重开 }顺序不能换。先清缓存再停流,清完之后旧流还在往里送包,等于白清。
7. 二次开发:把它改成能进项目的播放器
这份 Demo 的可改造点集中在这几个地方,按投入产出排序:
| 扩展点 | 改哪里 | 工作量 | 收益 |
|---|---|---|---|
| 多通道同时预览 | app/main.cpp的事件循环 | 中 | 一个窗口看 4/9 路 |
| 硬件解码 | media/h264_decoder.cpp | 中 | CPU 占用降一半以上 |
| 录像存本地 | 新增record/模块 | 小 | 边看边存 |
| 叠加 OSD 文字 | render/sdl_renderer.cpp | 小 | 时间、通道名上屏 |
| 换渲染后端 | 实现新的IRenderer | 大 | 接 Qt / OpenGL |
我最常被问的是硬件解码怎么接。思路不复杂:把avcodec_find_decoder换成avcodec_find_decoder_by_name("h264_cuvid")之类的硬解器名,再把AVCodecContext的hw_device_ctx设好。但要注意硬解输出的AVFrame格式是AV_PIX_FMT_CUDA这种硬解专用格式,得走一遍av_hwframe_transfer_data转到系统内存,SDL2 才认。这一转会有一次拷贝,帧率高的场景反而不划算,1080P 以下建议直接软解。
另一个技巧是 OSD 叠加。别去改解码出来的 YUV 数据,那会把整条链路搞脏。正确做法是在渲染层加一个透明纹理,先RenderCopy视频,再RenderCopy文字纹理,SDL 会自己混合。
改完之后怎么验证没把主链路弄坏?我的习惯是保留一个--probe-only的裸链路模式,每次改完先跑一次,确认登录、取流、解码三项的耗时和改之前差不多。有一次我给渲染加了双缓冲,结果不小心在解码回调里做了等待,--probe-only的输出立刻从 25fps 掉到 8fps,一眼就看出问题在哪。从那以后我每次动media/下的代码,都强制走一遍 probe 再开窗口。希望帮到你。
本文还有配套的精品资源,点击获取