ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于libmp4v2的H.265裸流MP4封装实践

基于libmp4v2的H.265裸流MP4封装实践 简介基于libmp4v2库的H265视频MP4录制实现方案面向嵌入式与多媒体开发工程师用于在ARM平台通过C语言完成H265视频、AAC音频的MP4封装录制。资源共104个文件以99个h头文件为主配合2个静态库a文件、2个cpp示例文件及1个new文件整体仅2.11MB便于快速阅读源码与移植到资源受限的嵌入式项目中已有2519人学习下载。内容涵盖libmp4v2的API调用方式、H265/HEVC编码原理如熵编码、运动估计、帧内预测、AAC音频流封装处理以及跨平台工程组织方法。通过分析头文件接口和示例代码能够掌握音视频流写入MP4容器的基本流程理解轨道、采样、元数据等关键概念同时学习ARM平台下内存受限、性能优化的常见做法并可从头文件定义反推库的整体设计思路。对于希望深入C语言多媒体开发或需要扩展视频录制功能的工程师具有切实的借鉴价值。1. 这个项目从哪儿来一台边缘盒子的录像需求1.1 需求一眼看着简单落地时才发现重封装绕不开去年在给一台边缘盒子做录像功能摄像头输出的是H.265裸流产品要求把所有视频落地成MP4文件并且在Windows上双击就能直接播。最开始我的老思路是上FFmpeg功能确实没问题可板子内存只有64MBFlash也紧张。FFmpeg这种全功能的库光跑一个muxer也要带上一堆用不上的协议和滤镜代码为了录个像把系统资源耗掉一截实在不划算。后来我把目光放到libmp4v2上。这个库的定位很纯粹就是MP4封装和解析API直接用C语言调用没有太多多余的依赖。写H.264的MP4录像非常顺MP4Create、MP4AddH264Track、MP4WriteSample几个函数串起来就能跑。真正动起手来才发现官方版本的libmp4v2只完整支持到H.264H.265这条track没有任何现成接口得自己动手补。这篇文章就来完整梳理一遍为什么选libmp4v2、H.265裸流进MP4之前有哪些底层规则必须搞懂、基于libmp4v2怎么做HEVC封装、以及我实拍测试中踩过的坑。给同样要在嵌入式设备上用C语言做MP4录像的朋友当参考。1.2 对比过几个封装库还是libmp4v2更适合这活在决定自己改libmp4v2之前我把常见的几条路都过了一遍方案优点缺点FFmpeg功能全协议多封装稳定体积大依赖多内存占用高libmp4v2轻量专注MP4C接口直接H.265支持需要自己补GPACHEVC支持较好封装能力强库整体复杂嵌入式移植成本高自己写MP4 muxer完全可控box语法细节多周期太长真正常见的硬件摄像头码流格式都比较标准不需要FFmpeg那套庞大的协议栈。libmp4v2虽然更新节奏慢但代码结构干净H.264封装路径可以直接复用。H.265无非是在MP4里多一种sample entry类型以及把WPS/SPS/PPS参数写进hvcC描述里。把原来H.264那套逻辑复制一份改一改工作量是可控的。所以我最后的结论是只要能解决HEVC track支持libmp4v2就是这批方案里性价比最高的选择。2. H.265裸流进MP4前先搞懂NALU和sample这套规则2.1 NALU是裸流的最小单元sample是MP4的最小存取单元H.265裸流默认是Annex B格式每个NALU前面是一段start code。start code通常是00 00 00 01也有3字节的00 00 01。这样一串一坨从编码器或者摄像头里出来直接塞进MP4是不行的。MP4里面装sample每个sample的开头不是start code而是一个4字节的网络序长度值后面跟着对应的NALU数据。举个例子裸流里的一段 00 00 00 01 40 01 0C ... MP4 sample里应该 00 00 00 17 40 01 0C ...第一个4字节0x17表示这个NALU的长度然后把NALU数据原样跟在后面。如果一个视频帧包含多个NALU比如关键帧通常由VPS、SPS、PPS、SEI、slice这几个NALU组成那这一整帧算一个sample把这些NALU挨个加上4字节长度前缀后拼在一起。这里有个特别容易被忽略的点H.265比H.264多了一个VPS解码器必须先知道VPS、SPS、PPS这三类参数才能正确解码。裸流里它们通常出现在IDR关键帧之前封装时必须提取出来放到sample description里也就是生成hvcC描述信息。漏掉任何一份播放器打不开画面或者花屏都不奇怪。2.2 timescale、duration和renderingOffset决定播放器能不能正常快进MP4的track有自己的时间基libmp4v2创建track时要传timeScale。这个值你可以理解为每秒被切成多少份。常见的视频时间基是90000音频可能是48000。假设视频是30fpsduration就是90000除以30等于3000。timeScale 90000 duration 90000 / 30 3000有了第一层还不行H.265码流如果有B帧视频帧的解码顺序和显示顺序就不一致。MP4文件中sample的存储顺序按解码顺序来也就是DTS递增但播放器显示时还要用PTS。libmp4v2写sample时有一个renderingOffset参数它就是PTS和DTS的差值。假设一个I P B B的GOP编码输出和解码顺序是解码顺序帧类型DTSPTSrenderingOffset0I0001P3000900060002B60003000-30003B90006000-3000看表格就明白B帧的renderingOffset是负的这说明它的解码时间比显示时间晚。所以当你组装sample时一定要按DTS顺序写入文件同时把PTS和DTS的差填进renderingOffset。这个参数如果全填0只能说刚好没有B帧一旦码流里出现B帧播放器大概率会出现卡顿、花屏或者快进失灵。3. 基于libmp4v2写H.265封装器的代码骨架3.1 创建MP4文件和HEVC video track我用的libmp4v2分支里已经扩展了MP4AddH265Track所以建track的代码非常直接。#include mp4v2/mp4v2.h MP4FileHandle mp4 MP4Create(rec_001.mp4, 0); if (mp4 MP4_INVALID_FILE_HANDLE) { printf(create mp4 failed\n); return -1; } /* 视频帧率30fps时间基90000 */ uint32_t timeScale 90000; uint32_t frameDuration 3000; MP4TrackId video MP4AddH265Track(mp4, timeScale, frameDuration, 1920, 1080, vps, vps_len, sps, sps_len, pps, pps_len); if (video MP4_INVALID_TRACK_ID) { printf(add h265 track failed\n); MP4Close(mp4, 0); return -1; }如果你手上的libmp4v2标准版本编译不过MP4AddH265Track那就自己扩展。方法是把MP4AddH264Track的实现复制一份把sample entry type从avc1改成hvc1再把生成avcC描述的逻辑替换成hvcC描述。这个改动主要集中在mp4atom.c和mp4av.c里难度不算高。MP4AddH265Track内部的vps、sps、pps参数会写成sample description也就是hvcC box。所以后面再写sample时理论上不需要重复放这些参数。不过为了兼容某些要求苛刻的播放器我仍然会在关键帧sample里把VPS/SPS/PPS带一份这是后话。3.2 按帧写sample关键帧标记和长度前缀创建一个sample前先把这一帧的所有NALU拼成一个带4字节长度前缀的缓冲区。static int append_nal_to_sample(uint8_t *out, size_t out_cap, size_t *off, const uint8_t *nal, size_t nal_len) { if (*off 4 nal_len out_cap) { return -1; } uint32_t be_len htonl((uint32_t)nal_len); memcpy(out *off, be_len, 4); *off 4; memcpy(out *off, nal, nal_len); *off nal_len; return 0; }有了这个函数写入sample的流程就清楚了遍历当前帧的所有NALU逐个往缓冲区里追加然后调用MP4WriteSample。size_t sample_len 0; uint8_t *sample_buf malloc(sample_buf_cap); for (int i 0; i nal_count; i) { append_nal_to_sample(sample_buf, sample_buf_cap, sample_len, nals[i], lens[i]); } /* dts和pts来自编码器单位需要换算成timeScale */ MP4Duration render_offset pts - dts; int is_sync (nal_type 16 nal_type 21) ? 1 : 0; MP4WriteSample(mp4, video, sample_buf, sample_len, frameDuration, render_offset, is_sync); free(sample_buf);HEVC里BLA、IDR、CRA这几种类型都算关键帧所以is_sync判断用16到21这个范围比较稳妥。如果你只判断nal_type 19或者20可能漏掉部分起始帧导致播放器seek时找不准关键帧。3.3 关闭文件MP4Close和MP4Optimize录制结束不要直接退出先让libmp4v2把moov信息写完。MP4Close(mp4, 0); MP4Optimize(rec_001.mp4, rec_001_opt.mp4); rename(rec_001_opt.mp4, rec_001.mp4);MP4Close会把moov box写到文件末尾这是最基础的状态。大多数电脑播放器能正常处理。但如果文件要放到网页播放器、旧机顶盒或者车载播放器上就得用MP4Optimize把moov挪到文件头部优化启动加载速度。这里要注意MP4Optimize会产生一个临时文件磁盘占用会接近原文件的两倍嵌入式环境一定要提前检查剩余空间。4. 实拍测试中反复出现的几个封装翻车点4.1 VPS/SPS/PPS漏写导致黑屏第一次拿到样本文件我直接在PotPlayer里打开画面黑屏进度条正常走。查了半天问题出在hvcC描述信息不完整。H.265比H.264多一个VPS必须有VPS、SPS、PPS三件套一起放进hvcC。裸流提取时按nal_unit_type判断即可32对应VPS、33对应SPS、34对应PPS。有些摄像头只在开机或者关键帧前发一次参数如果你在初始化track时没有正确缓存并写入播放器就完全不知道如何解码。后来我改成在每个IDR关键帧sample里都附带一份VPS/SPS/PPS作为兜底方案。这样即使某些播放器不读hvcC也能从sample内部恢复参数。4.2 B帧把时间戳搞乱后播放器要么卡住要么快进失灵这个问题最容易在“摄像头开启B帧”的场景下出现。编码器输出的PTS不是单调递增的如果你拿PTS当写入顺序或者把所有renderOffset都填0播放器解码后无法正确排序显示。我的处理是在编码器回调里同时记录dts和pts写sample前统一换算到track的timeScale然后用dts决定写入顺序用pts - dts作为render_offset。如果你不想引入B帧也可以在编码器初始化时强制关闭B帧比如x265里用bframes0这样renderOffset永远是0逻辑会简单很多。但压缩率会有所下降看产品怎么取舍。4.3 start code残留让第一个NALU自我错位这个坑相当隐蔽。第一次自己写组装函数时我图省事直接把裸流整段拷贝进sample结果生成的MP4只有第一帧播放乱码。打开十六进制检查才发现sample开头是00 00 00 01而不是4字节长度。错误00 00 00 01 40 01 0C ... 正确00 00 00 17 40 01 0C ...也就是说我在拼接NALU时忘了剥掉Annex B的start code。还有一个容易漏的是3字节start codeH.265裸流中偶尔会出现00 00 01解析循环里要同时兼容这两种情况否则帧切分点会错位。4.4 hvc1/hev1的兼容性不是玄学扩展libmp4v2时track的sample entry type会决定播放器怎么解读这段HEVC流。常见的有hvc1和hev1两种。我实测下来绝大多数播放器优先认hvc1尤其是Windows平台和苹果生态。hev1在部分Android播放器上也能播但兼容性差一些。如果你是自己在源码里扩展建track的时候一定要检查生成的MP4文件里stsd信息确保是hvc1。如果发现写成了hev1改掉sample entry type重新封装一次。很多芯片方案出厂MP4文件播不了就是这个原因。5. 嵌入式场景下的分段录像与兼容性调优5.1本文还有配套的精品资源点击获取
返回列表