我最早被 MP4 封装折腾,是在做一个实时推流录像功能的时候。视频编码出来一帧一帧的数据,单独看每一帧都没问题,但是一旦往 MP4 文件里写,播放器要么卡在开头,要么进度条乱跳,要么干脆提示文件损坏。后来排查到根上,问题大多出在写 MP4 样本时那几个关键参数没填对。这篇文章就围绕 MP4WriteSample 这个场景,结合我实操中的经验和踩过的坑,把参数逐个拆开讲清楚。
MP4WriteSample 可以理解成“向 MP4 文件写入一个样本”的接口或流程,这里的“样本”在视频里是一帧图像数据,在音频里是一段 PCM 或压缩后的音频包。很多多媒体框架、自研封装器、流媒体服务里都有类似叫法的函数或模块。它解决的问题是把编码器输出的原始数据,按照 MP4 容器的规范正确地写入文件,让播放器能定位、能解码、能按正确顺序播放。适合正在做音视频录制、视频编辑、直播录像、离线转封装,或者需要自己写 MP4 muxer 的开发者参考。
1. 整体设计与思路拆解
1.1 MP4WriteSample 在录制链路中的位置
一次完整的 MP4 录制链路通常是这样的:采集设备产生原始音视频数据,送入编码器压缩,编码器输出编码后的样本,再由封装器写到文件。在这个链路里,MP4WriteSample 属于最后一公里,也是最容易被低估的一环。很多人花大量时间调编码器参数,觉得编码器输出正常文件就正常,实际上封装阶段对参数的处理会直接影响文件的可用性。
举个例子,编码器输出的 H.264 数据里,可能有多个 NAL 单元放在一起,也可能每个样本只包含一个分片。MP4 容器要求每个视频样本是完整的、可解码的单元,音频样本则需要携带正确的采样率和通道数描述。如果你直接照着编码输出的原始缓冲区往文件里写,不处理样本边界,那播放器就会“看不懂”。
1.2 为什么单独强调“参数解读”
因为 MP4WriteSample 这个接口往往参数很多,给开发者造成了不小的困惑。常见参数包括样本数据指针、样本大小、时间戳、持续时间、关键帧标志,还有一些和编解码器相关的附加信息,比如 SPS/PPS、AudioSpecificConfig 等。每个参数的含义、单位、生命周期、容错方式都不一样,用错了不会立刻报错,但会导致播放器兼容性问题。
我更愿意把这些参数分为三组来看:第一组是“样本基本信息”,包括数据地址、大小、是否关键帧;第二组是“时间相关信息”,包括 DTS、PTS、时长;第三组是“解码器初始化信息”,比如 H.264 的 SPS/PPS、AAC 的 AudioSpecificConfig。第三组参数不是在每个样本里都要填,通常一个 track 写一次就够了,但如果写漏了或者写错位置,视频开头就是黑屏,甚至整个文件无法播放。
1.3 MP4 两种写模式的场景取舍
在设计写 MP4 的功能时,首先要想清楚用普通模式还是碎片化模式。普通 MP4 文件的 moov 元数据块在文件头部或者尾部,需要知道所有样本的偏移和大小后才能完整写入。适合本地录制完再上传、编辑、播放的场景,文件结构紧凑,随机访问性能也不错。碎片化 MP4(fMP4)则把元数据分散到一个个 moof 块里,每写几个样本就更新一次索引,适合直播、边录边传、录制中断后视频可恢复的场景,但文件体积稍大,对部分老播放器不友好。
MP4WriteSample 在不同模式下,参数的使用方式是有区别的。普通模式下,样本的偏移信息由 muxer 自动计算,你只需要保证写入顺序正确;碎片化模式下,你需要配合片段边界去控制 flush 时机,让关键帧尽量位于片段开头,否则播放器首帧画面无法快速显示。
2. 核心细节解析与实操要点
2.1 时间戳:微秒是基础单位
时间戳是 MP4WriteSample 里最容易出错、也最影响播放体验的参数。MP4 容器标准里,时间单位是“微秒”,也就是一百万分之一秒,几乎所有 muxer 的时间戳都用微秒。而编码器输出的时间戳往往是以采样率或帧率为基准的,比如视频 90000 赫兹,音频 48000 赫兹。你没有做单位换算就传入 muxer,就相当于拿米尺量了布料,却跟裁缝说是用英尺量出来的,成品自然对不上。
我建议在封装层做一个统一的时间戳规格,全部换算成微秒后再交由 MP4WriteSample 处理。换算方式很简单,视频帧:
timestamp_us = frame_index * 1000000 // fps音频样本:
timestamp_us = sample_index * 1000000 // sample_rate * frame_size这里注意,音频的 frame_size 是每包包含的采样点数,比如 AAC 一帧固定 1024 个采样点。如果直接从编码器拿到的时间戳不连续,要先检查是否是编码器丢帧、B 帧重排、或者音频重采样导致的偏差。
2.2 DTS 与 PTS:顺序不能乱
MP4WriteSample 的接口通常同时提供 DTS(解码时间戳)和 PTS(显示时间戳)。H.264/H.265 等编码器在开启 B 帧时,编码输出顺序是解码顺序,不是显示顺序。封装时必须精确记录每个样本的 DTS 和 PTS,并在写入时按 DTS 顺序写入文件,而 PTS 负责告诉播放器什么时候显示这帧画面。
我在第一次接入硬件编码器时踩了个坑:以为编码器输出的顺序就是显示顺序,直接拿编码器输出顺序当 PTS 用,结果视频画面频繁跳帧、动作不连贯。后来打印每一帧的 DTS 和 PTS 才发现,存在多个 B 帧时,PTS 是乱序的。正确写法是,编码器每输出一个样本,同时带上该样本的 DTS 和 PTS,muxer 内部按 DTS 排序写入,但每个样本仍保留自己的 PTS 值。
值得提醒的是,B 帧会导致解码延迟,需要为播放器提供一定的缓冲。在写文件时不需要关心播放器缓冲,但在做实时流录制时,如果 MP4WriteSample 被上层模块直接调用,要保证 DTS 是单调递增的。否则文件里的样本排列就会出现“未来时间戳提前于之前时间戳”的情况,部分播放器会因此中断播放或跳过画面。
2.3 关键帧标志:影响 seek 和首帧
每个视频样本都有一个“是否关键帧”的标志。关键帧是可以独立解码的帧,H.264 里对应 IDR 帧,AAC 里每个音频样本本身都是可独立解码的,所以音频样本的关键帧标志通常恒为真。视频样本的关键帧标志错了,会导致播放器在 seek 时找不到解码起点,或者首帧长时间黑屏。
举一个实际例子:编码器开启场景切换检测后,会在画面剧烈变化时自动插入 IDR 帧。但编码器的输出回调可能会把 IDR 帧和前面的普通 P 帧放在同一个缓冲区里,需要在解析 NAL 头时判断是否有 IDR 类型,然后给该样本标记关键帧。如果直接按“每 N 帧强制一个关键帧”的固定逻辑去标记,本来不是关键帧的样本会被误标,播放器解码花屏,反之关键帧被漏标,seek 时花屏。
2.4 解码器初始化信息:SPS/PPS 与 AudioSpecificConfig
MP4 文件不是把裸 H.264 流直接塞进去就行,它要求把解码器初始化信息单独存起来,放在 avcC 这个 box 里。avcC 包含 SPS 和 PPS,播放器读取 MP4 文件时,会先解析 avcC 拿到这两个 NAL 单元,初始化解码器,然后才开始解码样本。
在你写第一个视频样本之前,MP4WriteSample 所在的 muxer 需要收到完整的 SPS/PPS。H.264 编码器的 SPS/PPS 通常在编码器初始化时就能拿到,也可以在输出第一个 IDR 帧时从码流里提取。我遇到过只提取了 IDR 帧数据、没提取 SPS/PPS 的情况,结果录出来的文件用播放器打开,画面直接绿屏。排查后发现,avcC 里 SPS 缺失,解码器拿不到分辨率、帧率信息,自然解不出画面。
音频类似,AAC 需要 AudioSpecificConfig,里面封装了音频对象类型、采样率索引、声道配置。这些信息一般是固定的,只要编码参数不变,整个录制过程中只需要写一次。如果用的是 FFmpeg 的 muxer,通常是在写 header 的时候传入 extradata,extradata 就是从编码器 context 里取出来的。
3. 实操过程与核心环节实现
3.1 样本数据的组织与边界处理
我整理一个常见的写入流程,以视频为例。每次从编码器拿到一个 AVPacket(或者类似的数据结构),里面可能是多个 NAL 单元组成的一个完整样本。写 MP4 样本前,要做三件事:
第一,确认样本边界正确。有的封装接口要求每个样本只包含一个 NAL 单元,这时需要拆包;有的接口接受“一个样本包含多个 NAL”的形式,这时要保证第一个 NAL 是 SPS 或 IDR 的开头。
第二,填充长度前缀。MP4 的样本数据使用 4 字节长度前缀表示 NAL 长度,而编码器输出的 Annex-B 格式是起始码(00 00 00 01)。写入 MP4 文件时,要去掉起始码,换成长度前缀。这一步做错,播放器解析 NAL 时会把起始码当作数据的一部分,直接解码失败。
第三,判断并设置关键帧标志。IDR 类型的 NAL 所在样本标记为关键帧,其他标记为非关键帧。
伪码类似这样:
def write_video_sample(packet): nal_units = split_by_start_code(packet.data) for i, nal in enumerate(nal_units): if i == 0: # 如果第一个 NAL 是 IDR,设置关键帧标志 sample_keyframe = is_idr(nal) mp4_writer.write_sample( data=nal_with_length_prefix(nal), dts_usec=packet.dts * 1000000, pts_usec=packet.pts * 1000000, duration_usec=calc_frame_duration(packet.fps), is_keyframe=sample_keyframe )3.2 音频样本的写入细节
音频样本相对简单,但有两个地方容易忽略。一是音频样本的时间戳必须连续,不能有空洞。如果采集进程偶尔丢包,直接跳过会导致音频播放时出现卡顿甚至变调。最好是检测到不连续时先写一段静音样本填充,或者调整样本的时间戳,让时间轴保持连续。
二是音频样本大小不固定时,要保证写入的样本长度和实际数据长度完全一致。AAC 编码器的输出大小会因为码率控制而变化,如果传入 MP4WriteSample 的 size 参数比实际数据长度大,文件末尾会多出垃圾字节,部分播放器在快进到该位置时会解析异常。
3.3 样本持续时间的计算
Duration 参数表示一个样本在播放时间轴上的持续时间。视频的持续时间通常用 1/帧率 表示,比如 30fps 的视频每帧时长约 33333 微秒。但要注意,可变帧率下持续时间可能不同。如果接口要求每个样本填自身 duration,不能直接用一个固定值。
音频的 duration 需要根据 sample_rate 和每帧采样点数计算。AAC 每帧 1024 个采样点,在 44100Hz 下,duration 是 1024/44100 秒,约 23220 微秒。你如果把音频帧的 duration 算成 2048/44100,声音和画面就会逐渐错位,视频越长越明显,到最后声音比画面快了整整几秒。
3.4 关键 flush 时机
在碎片化 MP4 模式里,MP4WriteSample 不负责切分片段,上层需要主动调用 flush 或 new_segment 方法。每次 flush 生成一个 moof,包含最近的样本索引。切分时机的把握直接影响播放体验和文件体积。我一般以关键帧作为片段边界,这样每个片段都能独立从关键帧开始解码,seek 时可以直接跳到任意片段头部。
录制短视频场景下,每 2 到 5 秒一个片段还是比较合理的。太短会生成大量 moof 头,浪费空间;太长则分段不灵活,网络传输、实时上传场景下不够用。如果是本地录制完一次性写入的普通 MP4,不需要频繁 flush,但在文件写完时,必须正确收尾,把 moov box 写到文件头或文件尾。如果录制过程被异常终止,moov 没写出来,文件可能全部作废。这也是我推荐实时录制场景优先考虑 fMP4 的原因之一,即便中途崩溃,之前写完的片段仍然可以播放。
4. 常见问题与排查技巧实录
4.1 时间戳不单调导致播放器进度条“后退”
症状:播放正常但进度条偶尔跳变,或者播放器在某个时间点卡住。用工具分析文件时发现,某些样本的 DTS 比前一个样本小。
原因:编码器输出了乱序,或者上层做了 frame dropping 后没有同步修正时间戳。排查方法:在写入日志里输出每个样本的 DTS,画成曲线;如果是锯齿状,检查是否开启了 B 帧但编码器没有按解码序输出。
修复:确认每个 packet 自带 dts 和 pts,而不是自己猜。如果只有 pts,没有 dts,在开启 B 帧的情况下要按 pts 排序后手动分配 dts,保证递增。
4.2 视频绿屏或无法解码,但是文件时长正常
症状:文件能打开,进度条在走,但画面是绿色的或者满屏马赛克。
原因:SPS/PPS 没有写进 avcC,或者关键帧标记错误。多数播放器遇到第一个样本不是关键帧时,会黑屏或花屏等待下一个关键帧;如果 avcC 里没有 SPS,解码器无法初始化,就一直绿屏。
排查方法:用工具查看 avcC box 的十六进制,确认里面能看到 SPS/PPS 的数据。也可以把 MP4 文件转成裸 H.264 流,看是否能正常解码。如果裸流能解,问题出在封装层。
修复:在写入第一个样本前,强制把编码器的 extradata 写入 avcC。对于某些编码器,只有 IDR 帧才携带 SPS/PPS,可以在 IDR 帧前截取一遍 extradata。
4.3 音画不同步,且时间越长偏差越大
症状:前 10 秒正常,1 分钟后声音逐渐领先或落后画面几百毫秒。
原因:最典型的是把视频帧 duration 或音频 duration 配错了。比如把视频帧率配置成 29.97 但编码器实际输出 30fps,那么每帧实际时长是 33333 微秒,你填了 33367 微秒,一分钟偏差大约 60 毫秒,十分钟偏差 600 毫秒,很难第一时间发现。
排查方法:分别导出音频轨和视频轨的时长。两个轨道的时间轴长度不一致,基本就是 duration 配置有问题。再计算期望 duration 和实际时间的误差率。
修复:严格按照编码器真实输出帧率计算 duration,不能想当然按照配置文件里的值。帧率变化时,每帧单独计算。
4.4 分段录制后拼接失败
症状:两个 MP4 文件用工具拼接后,第二个文件的前几秒花屏或者音画不同步。
原因:两个文件的编码参数不一致,比如 SPS/PPS 不同、采样率不同,但拼接时直接合并样本数据,没有重新初始化解码器。
修复:如果必须拼接,建议在解码层重新编码,或者统一转码后再拼接。只在封装层拼接,必须保证两个文件的编码参数完全一致,包括 profile、level、SPS/PPS 内容。这一点做起来比想象中难,我一般直接走转码方案。
4.5 参数速查表
整理一份常用参数速查表,方便对照排查:
| 参数 | 含义 | 常见错误 | 推荐做法 |
|---|---|---|---|
| 数据指针 | 样本数据起始地址 | 传了非法的缓冲指针 | 确保完整样本数据连续内存 |
| 数据大小 | 样本字节数 | 包含起始码或多余填充 | 转成长度前缀,长度精确 |
| DTS | 解码时间戳 | 单位混用 / 不递增 | 统一微秒,确保单调递增 |
| PTS | 显示时间戳 | 与 DTS 相同 | B 帧场景下分别设置 |
| Duration | 样本持续时长 | 固定值所有帧一样 | 按帧率/采样率计算 |
| 关键帧标志 | 是否可独立解码 | 漏标或误标 | 按 NAL 类型判断 |
| SPS/PPS | 解码器初始化 | 写漏或乱序 | 首样本前写一次即可 |
| AudioSpecificConfig | AAC 解码描述 | object type 错误 | 由编码器 extradata 获取 |
| SampleRate | 音频采样率 | 与编码器不一致 | 从编码器 context 拿真实值 |
| ChannelLayout | 声道布局 | 单双声道搞反 | 编码器初始化后读取 |
5. 写在最后的一点个人经验
这几个参数里,时间相关和关键帧标志是出问题的重灾区。如果做的是实时录制系统,我强烈建议从一开始就实现一套统一的时间戳换算工具,一个函数走天下,所有模块都用微秒传参,不要在接入不同编码器时反复写换算逻辑。每次接入新的编码器,先打印 100 个样本的 DTS 和 PTS,核对顺序和单位再继续开发,这个习惯帮我避开了很多隐形问题。
另外,录制类功能一定要考虑异常中断的情况。普通 MP4 的 moov 在文件末尾,进程崩溃时前面的数据等于白写。用碎片化 MP4 或者边录边把 moov 提前生成临时版本,能大大降低录制内容丢失的概率。如果你是做移动端或者浏览器端的录制,先确认目标平台对 fMP4 的支持程度,再做技术选型,避免功能做完才发现播放器不支持。
关于样本写入顺序还有一个技巧:在写入第一个样本前,先通过计算文件头大小预留 moov 空间,可以减少写完后再移动 moov 的开销。但这个优化需要根据具体 muxer 实现来评估,不是所有场景都值得做。对大多数本地录制场景,能够保证参数正确、时间戳连续、关键帧标志准确就已经成功了。如果你在写 MP4 样本时也遇到过类似问题,希望这篇文章能帮你少走几步弯路。