首页 / 资讯中心 / 文章详情

海康SDK取流到H264裸码与MP4封装:实时预览与落盘实践

海康SDK取流到H264裸码与MP4封装:实时预览与落盘实践 ★ FEATURED ARTICLE
简介基于海康威视 SDK 的 C 示例工程面向需要对接 H.264 裸码处理、MP4 封装及实时预览的开发者。作者利用海康回调接口获取标准 H.264 裸码实现了将裸码封装为 MP4 文件、保存 .h264 文件以及实时预览三种功能参数可自行调整适合学习海康二次开发或视频流存储处理的读者参考。资源包共 118 个文件压缩后大小约 42.62MB主要包含 42 个 DLL如播放与控制库、MP4 封装库及加密依赖、10 个 LIB、20 个头文件以及若干 C 源码、工程配置与编译产物另附示例 MP4、H264 输出文件方便对照查看。已有 1843 人学习下载。包内包含核心源码可快速理解从设备取流、裸码回调到 MP4 封装的完整流程工程中同样提供编译好的 EXE 与 PDB便于直接运行调试和定位问题对希望上手海康 SDK 的开发者有较高实用价值。1. 海康SDK与标准H264裸码封装从能预览到能剪辑中间隔着一次脱壳基于海康SDK及C做标准H264裸码封装MP4、保存.h264文件、外加实时预览这个组合听起来像把三个接口拼一拼就行实际动手后你会发现真正耗时间的不是登录设备也不是写文件而是把海康回调里的码流还原成标准H264裸码。我做过一个按事件导出一段录像的项目直接把回调数据写进MP4播放器打开全是花屏和黑屏反复折腾好几轮才弄清楚海康实时流回调里给的并不总是一段干净的NALU序列中间混着私有头和PS封装不脱掉这层壳后面一切封装都是空中楼阁。这篇文章把取流、NALU切分、MP4封装、.h264落盘和实时预览整条链路一次讲透适合要做本地录像、事件导出或视频分析前端的C开发者照着自己搭一套。2. 从海康实时流回调抠出H264裸码登录参数与回调线程怎么配2.1 为什么选实时流回调而不是录像回放取流做本地录像和事件导出时常见的取流方向有两个一个是走回放接口拉设备里的录像文件另一个是走实时流回调拿编码器输出的码流。回放取流拿到的已经是设备封装好的文件流格式和封装参数由设备固件决定你要把它转成标准MP4还得先解封装再重新封装绕一大圈。实时流回调则是直接面对编码器输出码流延迟低、数据结构相对原始更适合自己控制封装流程。这正是标题里「标准H264裸码封装」的前提——只有拿到裸码才谈得上自己决定怎么装进MP4。海康SDK里实时预览和取流是一体的调用NET_DVR_RealPlay_V40并挂上回调函数后SDK内部会保持取流连接码流数据按回调方式投递到应用层。回调里常见的数据类型有系统头、普通码流数据、私有数据等。普通码流数据才是我们要的H264负载。这里要注意有些设备型号在默认参数下会把H264包成PS流再塞给回调整个负载以PS头开头直接拿去切NALU会切出一堆垃圾。后面第5章我会把这个问题单独拎出来讲怎么排查。我一般会这样判断设备输出的封装形态回调收到第一包数据时把前16个字节打印出来看特征。如果看到0x00000001开头且第5个字节低5位是7SPS或者8PPS那基本就是裸NALU如果看到0x00000001后面跟着一大串非NALU的字节或者负载中间频繁出现0x000001BA之类的PS头标志那就是被PS封装过了。这个判断几十行代码就能写但能省掉后面大量排查时间。2.2 登录参数与最少代码实现初始化、连接超时、预览通道配置先看一段我常用的初始化加取流骨架代码。我这里用的SDK接口名是常见的NET_DVR系列不同版本头文件里的结构体字段略有差异但整体流程一致。#include cstring #include cstdio #include HCNetSDK.h // 回调函数海康SDK内部线程投递绝对不能做阻塞操作 void CALLBACK OnRealData(LONG lRealHandle, DWORD dwDataType, BYTE* pBuffer, DWORD dwBufSize, void* pUser) { if (dwDataType NET_DVR_SYSHEAD) { // 系统头里面可能带SPS/PPS信息先缓存起来 return; } if (dwDataType NET_DVR_STREAMDATA) { // 普通码流数据负载可能是裸NALU也可能带PS头 // 这里只做入队文件IO放到独立线程 FrameQueue::Instance().Push(pBuffer, dwBufSize); } } bool StartDeviceStream() { // 初始化SDK并开启日志排查时非常有用 NET_DVR_Init(); NET_DVR_SetConnectTime(2000, 1); // 连接超时2秒失败重试1次 NET_DVR_SetReconnect(5000, TRUE); // 断线5秒后自动重连 NET_DVR_USER_LOGIN_INFO login { 0 }; strncpy(login.sDeviceAddress, 192.168.1.64, sizeof(login.sDeviceAddress)); login.wPort 8000; // 海康设备默认端口 strncpy(login.sUserName, admin, sizeof(login.sUserName)); strncpy(login.sPassword, your_password, sizeof(login.sPassword)); login.bUseAsynLogin FALSE; // 同步登录方便拿错误码 NET_DVR_DEVICEINFO_V40 devInfo { 0 }; LONG userId NET_DVR_Login_V40(login, devInfo); if (userId 0) { printf(login failed, error%d\n, NET_DVR_GetLastError()); return false; } NET_DVR_PREVIEWINFO prev { 0 }; prev.lChannel 1; // 通道号按设备实际配置填 prev.dwStreamType 0; // 0主码流1子码流保存用选主码流 prev.dwLinkMode 0; // 0 TCP方式局域网内够用 prev.bBlocked TRUE; // 阻塞取流保证回调数据连续 LONG realHandle NET_DVR_RealPlay_V40(userId, prev, OnRealData, nullptr); if (realHandle 0) { printf(realplay failed, error%d\n, NET_DVR_GetLastError()); return false; } return true; }登录参数里有几个值得单独说。连接超时设2秒、重试1次是为了失败时能快速拿到错误码而不是干等十几秒。bUseAsynLogin建议先设同步登录调试阶段错误码直接返回等稳定了再考虑异步。dwStreamType选0取主码流分辨率高、码率大适合保存预览实时性要求高的话可以单独再开一路子码流但标题里只有一个取流回调说明期望的是主码流同时供保存和预览。回调函数是SDK内部线程调用的线程优先级高里面做fwrite、加锁时间过长、内存分配都有可能拖垮取流。我见过最严重的情况是回调里直接写硬盘写入速度波动时整个预览画面跟着卡顿甚至触发SDK底层重连。正确做法就是上面代码里的思路回调只做拷贝入队队列另一头由业务线程消费。队列容量要有上限满了就丢最老的帧避免内存无限增长。注意NET_DVR_SetReconnect只管SDK底层连接恢复不会自动恢复你的回调句柄。长时间运行时如果取流中断需要监听断线消息并重新调用RealPlay。3. NALU还原与MP4封装把回调负载变成有正确SPS/PPS和时间戳的帧3.1 从海康回调数据到标准H264裸码流起始码、NALU类型与关键帧拼接标准H264裸码流指的是一串带起始码的NALU序列起始码是0x00000001NALU的第一个字节低5位标类型7是SPS8是PPS5是IDR关键帧1是普通P帧。海康回调普通数据流里多数情况给的是不含起始码的NALU负载或者负载前面有一段私有头需要我们根据实际打印结果跳过私有头并补上起始码。这里最忌讳上来就猜偏移量正确做法是先用一个探针函数看数据特征。// 扫描一段负载尝试找起始码位置 int ProbeStartCode(const uint8_t* buf, size_t len) { for (size_t i 0; i 3 len; i) { // 三字节起始码 00 00 01 if (buf[i] 0 buf[i1] 0 buf[i2] 1) { return static_castint(i); } } return -1; // 没找到说明负载里可能带私有头 } // 按起始码切分NALU返回每个NALU在buf内的偏移和长度 std::vectorstd::pairsize_t, size_t SplitNalu(const uint8_t* buf, size_t len) { std::vectorstd::pairsize_t, size_t out; size_t start 0; size_t i 0; while (i 3 len) { if (buf[i] 0 buf[i1] 0 buf[i2] 1) { size_t codeLen 3; if (i 3 len buf[i3] 0) codeLen 4; // 四字节起始码 if (i start) { out.emplace_back(start, i - start); } i codeLen; start i; } else { i; } } if (start len) out.emplace_back(start, len - start); return out; }ProbeStartCode的作用是帮你确认手里这包数据到底是裸NALU还是带壳的。如果返回0说明负载开头就是起始码直接切分即可。如果返回-1就得去翻SDK头文件里对私有数据头的定义按结构体偏移跳过。不同型号设备对私有头的处理不一致有的前面是固定字节的扩展头有的直接就是裸负载这也是整个项目里最玄学的部分靠一次打印定位能省下大量瞎猜时间。拿到NALU列表后关键帧拼接是另一个坑。一个IDR帧往往由多个NALU组成比如SPS、PPS、SEI、IDR分片这些NALU在回调里可能是连续的一包也可能被拆成多个回调包。为了保险我会在业务线程做帧聚合缓存最近收到的NALU遇到类型5IDR时把缓存的SPS和PPS一起拼在IDR前面组成一个完整的可解码关键帧。这个策略同时服务MP4封装和.h264文件保存保证播放器无论从哪个位置开始读都能先拿到参数集。3.2 MP4封装参数extradata、time_base与B帧处理MP4封装我习惯用FFmpeg的libavformat来做因为同一套编译产物还能承担后面预览解码的事省去再引一个库的维护成本。封装H264到MP4最关键的是两件事一是把SPS/PPS写进extradata二是把每帧的时间戳换算到muxer的time_base上。extradata漏了播放器打开文件就是黑屏但文件本身又是好的这个症状后面避坑章会再提。// 假设已经拿到sps和pps的裸数据创建H264流并设置extradata AVStream* AddH264Stream(AVFormatContext* muxer, AVRational time_base) { AVStream* st avformat_new_stream(muxer, nullptr); st-time_base time_base; // 一般用 {1, 1000} 毫秒单位 st-codecpar-codec_type AVMEDIA_TYPE_VIDEO; st-codecpar-codec_id AV_CODEC_ID_H264; st-codecpar-width 1920; st-codecpar-height 1080; st-codecpar-format AV_PIX_FMT_YUV420P; // extradata必须包含SPS和PPS格式为AVCC length-prefixed std::vectoruint8_t extradata; extradata.push_back(0x01); // version extradata.push_back(sps[1]); // profile extradata.push_back(sps[2]); // compat extradata.push_back(sps[3]); // level extradata.push_back(0xFF); // 6 bits reserved 2 bits lengthSize extradata.push_back(0xE1); // 3 bits reserved 5 bits numOfSPS extradata.push_back((sps.size() 8) 0xFF); extradata.push_back(sps.size() 0xFF); extradata.insert(extradata.end(), sps.begin(), sps.end()); // 按同样格式追加pps这里省略 st-codecpar-extradata_size extradata.size(); st-codecpar-extradata (uint8_t*)av_malloc(extradata.size()); memcpy(st-codecpar-extradata, extradata.data(), extradata.size()); return st; }extradata的拼法有固定规范网上能搜到很多示例核心是保证SPS和PPS都以长度前缀形式出现在里面。很多新手直接把SP S和PPS二进制拼一起塞进extradata播放器解析失败就会报「non-existing PPS referenced」之类的错误。如果你不想手动拼也可以用FFmpeg的bitstream filter从AnnexB格式的裸流里自动提取SPS/PPS生成extradata但我个人还是习惯手动构造可控性更强。时间戳换算是第二个关键点。海康回调里的时间戳通常是一个32位毫秒值从设备开机开始累计。封装MP4时不能直接拿这个值写进packet正确做法是先记录首帧时间戳后续每帧都减去它得到相对首帧的偏移毫秒再通过av_rescale_q换算到muxer的time_base。如果设备码流里带B帧你的回调顺序可能已经是显示顺序dts可能出现回拨。处理方式是让FFmpeg开启自动排序或者自己维护一个按dts排序的小缓存。这里最容易犯的错是看到dts回拨就以为是异常数据直接丢掉结果导致画面跳帧。3.3 保存.h264文件和MP4同时落盘文件策略与崩溃恢复.h264文件的保存比MP4简单得多本质就是把NALU按AnnexB格式顺序写进文件每段NALU前面补0x00000001然后写NALU本体。因为裸码流文件没有索引结构播放器打开时靠起始码扫描定位所以文件写到一半崩溃前面已经落盘的部分通常还能救回来一部分。这个特性让它很适合做原始证据保存MP4则作为交付文件。// 向h264文件写入一个完整帧可能包含多个NALU void WriteFrameToH264(FILE* fp, const std::vectorstd::vectoruint8_t naluList) { static const uint8_t startCode[4] { 0, 0, 0, 1 }; for (const auto nalu : naluList) { fwrite(startCode, 1, 4, fp); fwrite(nalu.data(), 1, nalu.size(), fp); } fflush(fp); // 实时落盘保证崩溃时丢失窗口最小 }这里有个策略选择SPS/PPS要不要每帧都写我一般只在关键帧前面写一次SPS/PPS不会每个普通帧都重复因为文件体积会白白膨胀不少。但要注意有些播放器存在缺陷关键帧前如果没紧跟SPS/PPS会解码失败。折中方案是每隔一段时间比如每50个关键帧重复注入一次SPS/PPS文件大小增加可以忽略兼容性却好很多。MP4那边不受这个影响因为SPS/PPS已经写进extradata了。写MP4的时机也值得注意。FFmpeg的av_interleaved_write_frame会把packet缓存在内存里做排序和交织如果程序异常退出moov box很可能还没写完整整个MP4文件就废了。我的习惯是每写一定数量的帧就主动调一次av_write_frame强制刷新同时定期把文件flush到磁盘这样最坏情况下丢失的是最后几秒数据而不是整个文件。4. 实时预览与录制并行队列、解码线程与延迟控制的配合4.1 解码与渲染分离别在SDK回调线程里做任何重活实时预览如果走海康SDK自带预览窗口最省事但标题里把「实时预览」和「保存h264/MP4」并列说明期望的是在自绘窗口里看到画面。常见做法是取流回调只负责把H264帧入队预览线程从队列拿帧交给FFmpeg软解解出YUV帧后拷贝到渲染纹理再显示。这个架构同时服务保存和预览两条链路共用同一份码流设备压力最小。void PreviewLoop(std::dequeFrameData queue, std::mutex mtx, std::condition_variable cv, AVCodecContext* dec, IRenderer* renderer) { while (!g_stopFlag) { FrameData frame; { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return g_stopFlag || !queue.empty(); }); if (g_stopFlag) break; frame std::move(queue.front()); queue.pop_front(); } // 队列积压超过阈值时优先丢非关键帧维持实时性 if (queue.size() 3 !frame.isKeyFrame) { continue; } AVFrame* yuv av_frame_alloc(); int ret avcodec_send_packet(dec, frame.pkt); if (ret 0) { while (avcodec_receive_frame(dec, yuv) 0) { renderer-Present(yuv); // 内部做格式转换和渲染 } } av_frame_free(yuv); } }队列和解码分离的价值在于解码耗时和渲染耗时不会反向阻塞SDK取流线程。软解一帧1080P H264的耗时在主流CPU上是十几毫秒到几十毫秒不等如果直接放在回调里做取流回调的节奏全被打乱保存的文件也会出现丢帧。用队列缓冲后解码慢一点只是预览延迟略增保存链路不受影响。渲染线程要注意帧的生命周期。FFmpeg解码器内部会复用bufferavcodec_receive_frame返回的AVFrame里的数据指针在下一次调用解码接口时可能失效所以跨线程使用前必须做一次 av_frame_ref或者直接把YUV数据拷贝进自己的缓冲。这一步不做画面上会出现随机花屏特征是有时正常有时闪块排查起来最耗时间。4.2 延迟控制I帧间隔、待显示帧数与丢帧策略的权衡「实时预览」的实时是有边界的。从设备编码器输出到屏幕上显示延迟由几段组成设备端编码延迟、网络传输延迟、SDK取流缓冲延迟、你的解码队列延迟、渲染垂直同步延迟。我们能压缩的主要是解码队列和渲染缓冲。把队列深度控制在3帧以内配合丢非关键帧策略预览延迟能做到跟海康自带窗口接近的水平。参数推荐值对延迟的影响取流码流主码流分辨率高导致解码耗时增加SDK回调队列不超过100包回调堆积会带来秒级延迟解码待显示帧数不超过3帧超过3帧画面明显滞后I帧间隔设备默认或50帧丢帧后需要等到I帧才能恢复渲染缓冲1帧双缓冲超过2帧会有可感知延迟丢帧策略上我倾向于非关键帧直接丢而不是清空队列等I帧。清空队列虽然能让画面快速恢复但会带来明显的跳变和撕裂感对观看体验影响更大。只丢P帧则画面卡顿但不跳变下一帧I帧到达后自然恢复。如果项目里对延迟极度敏感可以把设备子码流也拉一路专门做预览主码流专心保存代价是多占一路设备取流通道。预览和录制共用同一批帧数据时如果保存线程写磁盘慢会不会拖慢预览理论上不会因为两个线程各自从队列拿数据保存线程写文件阻塞只会导致自己的消费速度变慢队列积压增加预览线程只要及时取走帧就不会被拖累。但队列长度要设上限否则SDK侧会一直入队导致内存增长。我的做法是队列到上限时丢最老的帧优先保证预览实时性保存那边丢帧总比整个进程崩掉强。5. 避坑与排查海康回调、时间戳和文件封装的4个经典翻车点5.1 明明选的是普通码流回调里却混进PS帧导致封装花屏现象回调收的NET_DVR_STREAMDATA数据里负载开头不是NALU起始码切出来一堆无法识别的类型封装成MP4后播放器要么报错要么花屏。原因部分设备型号在默认取流配置下输出的H264会先被包一层PS封装PS包的起始码是0x000001BA和H264的NALU起始码0x000001混在一起切分逻辑直接失效。这不是SDK版本问题是设备固件对流封装形式的默认设置决定的。解决先打印回调数据前16字节确认特征然后在取流前尝试把设备输出切换为裸流或TS封装模式。海康SDK的私有配置项里通常有流封装格式的设置字段名在不同版本头文件里略有不同搜索「封装格式」「stream type」相关的配置项把它设为裸流。实在找不到配置项就在回调侧加一个PS头识别逻辑检测到0x000001BA就丢弃到下一个NALU起始码为止能缓解但不能根治。5.2 关键帧前没注入SPS/PPSMP4打开黑屏几秒钟现象MP4文件时长、大小都正常播放器打开后前几秒黑屏快进之后又黑屏过一会儿自己恢复。原因封装时没把SPS/PPS写进extradata或者写的位置不对。播放器要解码IDR帧必须先拿到SPS/PPS这两个参数集丢失时播放器只能碰运气在码流里等下一次关键帧自带的参数集。黑屏的时长取决于I帧间隔I帧间隔50帧就是大约1到2秒的黑屏。解决在创建AVStream后立即设置codecpar-extradata内容必须是按AVCC格式拼好的SPS/PPS。排查时可以打印extradata的前16个字节核对正常应该是01、profile、compat、level那一串。如果用的是FFmpeg的bitstream filter自动生成也要确认filter真正生效了别在封装时才临时加。5.3 码流含B帧导致dts回拨MP4时长乱跳或快进卡死现象封装出的MP4播放时画面正常但进度条拖动卡顿播放器显示的总时长和实际不符有时快进后画面停住。原因海康部分编码档次启用B帧后回调顺序是显示顺序直接按这个顺序写packet会导致dts不单调递增。MP4的sample table要求按解码顺序排列dts回拨会让播放器索引错乱。解决确认码流里有没有B帧可以统计NALU类型里有没有类型1且显示顺序和解码顺序不一致的情况。处理上可以在写MP4前开启FFmpeg的交织排序或者自己按dts缓排每来一帧找到合适位置插入等缓存里有连续可写区间再交给muxer。要注意pts和dts的差值计算必须基于解码顺序不能按显示顺序算。5.4 SDK回调线程里做文件IO取流频繁中断且预览卡顿现象程序跑一段时间后预览画面卡住不动保存的h264文件中间出现大段时间空洞SDK日志里能看到重连记录。原因回调里做了磁盘写入写入耗时超出SDK底层超时阈值底层判定取流链路异常并触发重连。重连期间帧全部丢失恢复后再回调又写盘陷入周期性卡顿。解决回调函数只入队文件写入全部移到业务线程。队列用无锁或有短锁实现入队操作控制在微秒级。如果确实需要极低延迟落盘可以用内存映射文件代替fwrite能显著降低单次写入耗时。另外检查队列是否有堆积预警队列长度超过阈值时先丢帧而不是让SDK侧持续堆积。6. 收尾技巧ffprobe自查与按I帧切割成事件片段的进阶打法封装完的MP4和h264文件我习惯在交付前各做一次体检比依赖播放器肉眼判断靠谱得多。MP4用ffprobe看流信息和关键帧分布h264文件用一段小脚本统计NALU类型比例。这两步检查做完基本能滤掉上面说的所有常见坑。用ffprobe验证MP4的SPS/PPS和关键帧间隔命令大概是这样ffprobe -v trace -i output.mp4 21 | grep -E nal_unit_type|SPS|PPS正常输出里能看到h264的profile、level关键帧间隔和你设置的I帧间隔一致。如果nal_unit_type统计里没有类型7和8说明extradata没生效。h264文件侧可以写个几十行的Python脚本扫一遍起始码统计每个NALU类型出现的次数重点检查类型5的数量是否符合预期类型7和8是否在关键帧前稳定出现。进阶用法是趁着已经实现了裸码解析把「按I帧切割事件片段」做成一个附带能力。业务场景里经常需要把一个长录像按事件起止时间导出一段与其解码后重新编码不如直接基于I帧切先按时间索引到最近的I帧把起始码对齐的NALU直接拼出一个新的h264文件再封装成MP4。这个过程不涉及重编码导出速度是秒级的。我之前在事件回查项目里就是复用这套裸码处理逻辑把原来需要几分钟的导出压缩到一两秒同时码流本身没有被二次压缩画质零损失。回看整个方案最值钱的不是那几行封装代码而是对「海康回调数据不等于标准H264裸码」这个事实的清醒认识。每次拿到一批新设备我第一件事就是打印回调头字节确认封装形态再决定走哪条处理路径。这个习惯帮我躲过了很多次设备型号差异的坑也让我敢对同事说这个方案能复现。希望这篇笔记能帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站