学校有一个网课系统,该系统的客户端是使用Silver light(一个.NET相关的技术框架,类似Flash)实现的,不支持跨平台。我对它的代码进行了分析,发现它会从服务器中接收H264格式的视频裸流和AAC格式的音频裸流,这两个裸流都不能用普通的播放器播放,所以我写了一个客户端将这两种裸流重封装成FLV格式。在这个过程中我也学习了flv重封装H264、AAC流的具体方法,实现了一些函数,所以在此记录,为了方便我用的是Python语言。
首先简单介绍一下,常见的视频格式一般分两个层级:内层是编码算法,负责对视频像素数据和音频采样数据进行压缩和编码,常见的视频编码算法包括H264(又称AVC)、M-JPEG、HEVC等,常见的音频编码算法包括AAC、PCM、AC3等。外层是视频容器,负责将这些流分块打包,并包含视频相关的一些元信息和控制信息,常见的视频容器包括MPEG(以MP4为代表)、MKV、AVI、FLV等。而裸流指的就是没有容器,单纯就是将编码之后的所有数据包依次连接得到的视频或音频流。
那么为什么要选择FLV这种格式呢,因为FLV格式非常适合直播,在传输时只要在开始传输音视频数据前先传输一下FLV文件头以及音视频的控制信息,剩下的就是不断将音视频裸流分包并加上FLV的帧标签再传输就可以了。一言以蔽之,就是FLV支持无限大的视频文件,相比之下其他视频格式一般都要求给出文件或数据流的整体大小,因此很难支持音视频流源源不断到来的情况。
我前面关于MP4的博客介绍过,H264裸流由NALU包组成,每个包可能代表一组控制信息(例如SPS帧和PPS帧),也可能代表视频的一帧。一般可以直接播放(需要支持格式比较多的播放器,如VLC、ffplay等)的H264裸流都是Annex-B格式,即开头是00 00 00 01或00 00 01。而FLV要求的H264数据包需要是AVCC格式的,即开头四个字节是数据包的大小。
注意,可以直接播放的H264裸流要求前两个帧必须分别是SPS帧和PPS帧,因为里面包含了视频流如何进行解码的信息,因此想通过播放裸流测试接收到的H264流有没有问题,一定要注意到这一点。
和H264裸流类似,AAC裸流也是由包组成的,每个包包含1024个采样点的数据。一般可以直接播放的AAC裸流是ADTS格式的,即每个数据包开头有7个字节或9个字节的ADTS头,其中包含了解码的控制信息。本系统输入的AAC流没有数据头,所以想通过裸流测试接收到的AAC流需要在每个数据包开头手动添加,格式可以参考这篇文章1,额外注意有三点: * ADTS数据头的所有数均为大端存储,这点很重要,不知道为什么很多介绍二进制格式的文章都忽略大小端的问题。 * 一般Layer属性都取MPEG4代表的0,channel_configuration属性一般都是双声道代表的2,其他没有专门介绍的属性都取0即可。 * 如果出现Number of bands (xxx) exceeds limit (xxx)
之类的错误,记得检查一下sampling_frequency_index属性代表的采样率有没有问题,多尝试几个采样率。因为有时候一段音频流表面上说它是44100Hz,结果这个44100Hz指的是两个声道总的采样率,实际上每个声道采样率是22050Hz,那么这个属性也要填表示22050Hz的7而不是表示44100Hz的4,这是一个坑点。
接下来介绍实现。首先是FLV文件头,代码如下:
def getFlvHeader():
# flv header: FLV(3) + version(1) + flags(1) + headersize(4)
r = bytes.fromhex('464c56 01 05 00000009')
# last tag length
r += bytes.fromhex('00000000')
return r
所有flv文件开头都是这13个字节,除了flags可能不一样,该字节二进制表示的第0位表示文件是否包含视频流(即画面),第2位表示文件是否包含音频流,像我的文件两者都包含,所以两位都为1,即5。不过不管有没有对应流flags一律取5问题也不大,大部分播放器也都能识别。
接下来就是多个FLV Tag,每个Tag包含视频流或者音频流的一个数据包,具体格式可以参考这篇文章2,下面是生成Tag头的代码:
def tagHeader(tagType, frameLen, timeStamp):
# tag header: tag type(1) + data size(3) + timestamp(4) + streamid(3)
r = struct.pack('>BBH', tagType, frameLen >> 16, frameLen & 0xffff)
r += struct.pack('>BHB', (timeStamp >> 16) & 0xff, timeStamp & 0xffff, timeStamp >> 24)
r += bytes.fromhex('000000')
return r
注意,FLV格式中所有的数均为大端存储,因此用Python的struct库进行编码时格式符都要加>
号。函数中tagType参数表示Tag的类型,8表示音频,9表示视频,当然还有其他类型的Tag,不过这里不涉及;frameLen表示这11个字符结束一直到本Tag结尾的TagLen开始中间数据的长度,注意不要以为是音视频数据包的长度,而是数据包的长度加上后面介绍的Video/Audio Tag Header的总长度;timeStamp参数表示Tag的时间戳,用来校准视频和音频,后面会介绍如何计算。
在所有FLV Tag中,排在最前面的一般是一些Script Tag提供视频的元数据,对于视频本身这些Tag是可有可无的。之后就是实际存储视频数据和音频数据的Tag了。在所有视频Tag和音频Tag之中,第一个Tag必须得是控制Tag,即包含Video/Audio Sequence Header的Tag。它们包含了音视频的分辨率、采样率等各种信息,起到的作用就如同H264裸流开头的SPS和PPS帧、AAC裸流每一个数据包开头的7字节ADTS头一样,非常重要。下面是生成控制Tag的代码:
sps = bytes()
pps = bytes()
def getSequenceHeader(package, packageType):
r = bytes()
if packageType == 'video':
frameType = package[4] & 0x1f
if frameType == 7:
# remove (00 00 00 01) 4 bytes
sps = package[4:]
if frameType == 8:
pps = package[4:]
if len(sps) > 0 and len(pps) > 0:
# tag header: video
r += tagHeader(9, 5 + 8 + len(sps) + 3 + len(pps), 0)
# video tag header: key frame | avc(1) + avc sequence header(1) + composition time 0(3)
r += bytes.fromhex('17 00 000000')
# video sequence header: sps
r += bytes([0x1, sps[1], sps[2], sps[3], 0xff, 0xe1])
r += struct.pack('>H', len(sps))
r += sps
# video sequence header: pps
r += bytes([0x1])
r += struct.pack('>H', len(pps))
r += pps
# last tag length
r += struct.pack('>I', 11 + 5 + 8 + len(sps) + 3 + len(pps))
# tag header: audio
r += tagHeader(8, 4, 0)
# audio tag header: sndStereo | snd16Bit | 22kHz | aac(1) + aac sequence header(1)
r += bytes([1 | (1 << 1) | (2 << 2) | (10 << 4), 0])
# audio sequence header: channel_pair_element | 22050Hz | aac lc(2)
r += struct.pack('>H', (2 << 3) | (7 << 7) | (2 << 11))
# last tag length
r += struct.pack('>I', 15)
return r
首先介绍一下函数的调用约定,package是到来的音视频包,如果是视频包,就是以00 00 00 01四个字节开头的Annex-B格式的NALU包;如果是音频包,就是不带ADTS数据头的AAC数据包。同时视频流中每隔一些其他类型的数据包后会有SPS和PPS数据包。因此这个函数的功能就是接收数据包,如果是SPS数据包或者PPS数据包就存下来,因为Video Sequence Header必须要用到这两个数据包的数据,在生成带有Video Sequence Header的Tag后,顺便把带有Audio Sequence Header的Tag也生成然后一起返回。
接下来介绍函数内容,在判断完当前包是视频包之后,就判断包的第5个字节,该字节可以看出视频帧的类型,如果后5位是7则为SPS,是8则为PPS,后面也将用这个字节判断视频帧是I帧(关键帧)还是P帧(前向推算帧)(实际上H264还有种帧叫B帧,需要双向推算,不过本应用中没有,估计因为直播流是按顺序来的,没法搞这种帧)。注意取SPS和PPS的时候要把前4个字节即00 00 00 01去掉。
当SPS和PPS都有了以后,就可以生成Sequence Header对应的Tag了。开头自然是Tag头,注意控制Tag的时间戳为0。然后是Video Tag Header,即视频Tag专属的Tag头,每个视频Tag都有,包含5个字节,具体格式可以参考这篇文章3,注意含有Sequence Header的帧也算关键帧。然后就是Sequence Header的具体内容,我参考的是这篇文章4里的C++代码,具体为什么这样写估计得去查规范。最后4个字节是该Tag的长度(不包括这4个字节),实际上就是前面填入TagHeader的长度加上11。
然后就是音频的控制块,Tag头就不说了,然后是Audio Tag Header,每个音频Tag都有,包含两个字节,格式在5里也有。不过实际上AAC编码的FLV文件中Audio Tag Header里的采样率、声道数这些属性并不会被用来决定解码参数,所以理论上填错了也没啥问题。真正决定解码参数的是Audio Sequence Header,也就是接下来的两个字节,同样参考6即可。不过要注意的是这些属性需要自己根据实际情况填写,不像H264流一样控制参数就写在流里面可以拿来用,因此如果弄不清楚声道数、采样率这些的话就得多尝试;或者加上ADTS头用裸流来测试,测试成功的话ADTS里填的声道数、采样率这些参数和Audio Sequence Header是一致的。最后4个字节依然是该Tag长度。
在提供了控制Tag之后,FLV播放器就能解码实际的音视频Tag了。所以接下来就是源源不断地将音视频裸流中的数据包加上Tag信息返回给解码器,代码如下:
def getFrame(package, packageType, frameRate, audioTime, videoTime):
r = bytes()
if packageType == 'audio':
frameLen = len(package) + 2
# tag header: audio
r += tagHeader(8, frameLen, audioTime)
audioTime += round(1024 * 1000 / frameRate)
# audio tag header: sndStereo | snd16Bit | 22kHz | aac(1) + aac raw(1)
r += bytes([1 | (1 << 1) | (2 << 2) | (10 << 4), 1])
# audio frame
r += package
# last tag length
r += struct.pack('>I', frameLen + 11)
if packageType == 'video':
frameType = package[4] & 0x1f
frameLen = len(package)
if frameType not in [7, 8]:
# tag header: video
r += tagHeader(9, 5 + frameLen, videoTime)
videoTime += round(1000 / frameRate)
# video header: frame type | avc(1) + avc raw(1) + composition time 0(3)
if frameType == 1:
r += bytes.fromhex('17 01 000000')
else:
r += bytes.fromhex('27 01 000000')
# video frame (change 00 00 00 01 to frameLen - 4)
r += struct.pack('>I', frameLen - 4)
r += package[4:]
# last tag length
r += struct.pack('>I', 11 + 5 + frameLen)
return r, audioTime, videoTime
首先是音频数据的处理,frameLen那里的+2
是加上Audio Tag Heaader的长度。这里说一下时间戳的计算,实际上时间戳的含义就是当前数据包在视频中是第几毫秒,音视频的时间戳是完全独立的。所以函数中用audioTime参数表示当前音频的时间戳。frameRate在当前数据包是音频时为每秒采样率,在当前数据包是视频时为每秒帧率,前面说了一个AAC音频数据包含1024个采样,1秒等于1000毫秒,结果四舍五入,公式就是round(1024 * 1000 / frameRate)
了。
然后是视频数据的处理,前面说了每隔一些其他类型的数据包后会有SPS和PPS数据包,因此需要将它们忽略掉。时间戳和音频计算类似,只是一个数据包就是一帧,因此不用直接用1000除以帧率即可。frameType为1表示是关键帧,Video Tag Header第一个字节就是0x17,否则第一个字节就是0x27。然后是具体的帧数据,FLV用的H264格式是AVCC,所以要将前面的00 00 00 01换成包大小减4作为帧的长度,再次注意这里是大端存储。
主程序涉及切分包,网络传输之类的无聊细节,这里就只给出伪代码,看看上面的函数怎么用:
送入播放器(getFlvHeader())
while True:
package, packageType, _ = 获取数据包()
r = getSequenceHeader(package, packageType)
if len(r) > 0:
送入播放器(r)
break
audioTime, videoTime = 0, 0
while True:
package, packageType, frameRate = 获取数据包()
r, audioTime, videoTime = getFrame(package, packageType, frameRate, audioTime, videoTime)
送入播放器(r)
我之前写和MP4相关的程序时,有稍微了解一点H264格式的内容,因此在这个项目前期测试H264裸流时就很轻松,不过主要也是ffmpeg比较牛逼,即使开头不是SPS和PPS帧也能顺利解码。在测试AAC裸流的时候就花了很长时间,一开始我不知道服务器传过来的流需要自己补上ADTS头,而且没有ADTS头解码器就拿不到音频参数,即使是ffmpeg也无能为力,所以我卡了很久。解决ADTS头的问题之后又被采样率的问题坑了很久,正如前面所说ffmpeg报了错,又不知道这错是啥意思,总不可能去看ffmpeg的代码,我也不是学这方面的。后面试出了22050Hz才解决,主要还是服务器传过来的元信息太坑了,22050Hz就是22050Hz,非告诉我是44100Hz,醉了……
之后AAC裸流能播放了,不过我发现除了ffmpeg和VLC没几个能播放裸流的播放器,后来找到了FLV这个格式,重封装音视频流非常方便,所以就学习了一下这个格式,所幸资料不少(毕竟斗鱼、B站都用这种格式),很快就能写出代码来。不过就是每篇文章可能都会或多或少漏点细节,最好的老师还是它:
一边看文章的解释,一边对着一个实际的FLV文件进行分析,就能做到滴水不漏了。
可惜的是,最后这个网课系统客户端没能派上用场,因为这个系统主要是老师们用的,不希望给学生用,怕同学们在未经老师允许下录像传播啥的,所以我也没有公开项目的代码。不过我从这个项目中学到了很多音视频编解码的知识,而且FLV这个格式的确挺简单的,比MP4简单多了,用来入门视频容器和直播相关技术也是非常好的选择。
AAC ADTS格式分析,邶风,https://www.cnblogs.com/zhangxuan/p/8809245.html↩︎
音视频封装:FLV格式详解和打包H264、AAC方案(上),潇湘落木,https://cloud.tencent.com/developer/article/1747043↩︎
音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎
视频h264 和 音频aac 混合到 flv文件里,淡默恬愉,https://www.jianshu.com/p/85f1c6d0c7d0↩︎
音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎
音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎