惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

腾讯CDC
T
Threatpost
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
T
Tenable Blog
AWS News Blog
AWS News Blog
Know Your Adversary
Know Your Adversary
TaoSecurity Blog
TaoSecurity Blog
P
Palo Alto Networks Blog
Spread Privacy
Spread Privacy
I
Intezer
Security Latest
Security Latest
The Last Watchdog
The Last Watchdog
Google DeepMind News
Google DeepMind News
Help Net Security
Help Net Security
Cyberwarzone
Cyberwarzone
N
News and Events Feed by Topic
O
OpenAI News
A
Arctic Wolf
S
Secure Thoughts
Attack and Defense Labs
Attack and Defense Labs
N
News and Events Feed by Topic
M
MIT News - Artificial intelligence
F
Full Disclosure
P
Privacy International News Feed
The GitHub Blog
The GitHub Blog
T
Troy Hunt's Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
H
Hacker News: Front Page
aimingoo的专栏
aimingoo的专栏
S
Security @ Cisco Blogs
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Apple Machine Learning Research
Apple Machine Learning Research
Engineering at Meta
Engineering at Meta
Cloudbric
Cloudbric
大猫的无限游戏
大猫的无限游戏
Google Online Security Blog
Google Online Security Blog
Recent Announcements
Recent Announcements
H
Help Net Security
量子位
V
V2EX
美团技术团队
G
Google Developers Blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
Schneier on Security
V2EX - 技术
V2EX - 技术
D
Docker
博客园 - 【当耐特】
Project Zero
Project Zero
博客园 - 司徒正美

VnYzm的博客

游戏玩后感:ReLief:献给亲爱的你 我的周边(谷子)分享 游戏玩后感:Kanon 简谱:致真实的你 《Rust中常见的有关生命周期的误解》学习笔记 简谱:StarMap 简谱:かく咲きたらばいと恋ひめやも 简谱:东风 简谱:无法诉说的思念 简谱:Girlish 游戏玩后感:时钟机关的Layline 简谱:风之琶音 简谱:星空的记忆 简谱:因为遇见了你 简谱:月童 番茄简谱脚本转调器 游戏玩后感:青空下的约定:Refine 游戏玩后感:在这苍穹展翅 书籍读后感:控制论与科学方法论 游戏玩后感:恋爱表达式 游戏玩后感:樱之诗 MLIR-tutorial学习笔记 游戏玩后感:潜伏之赤途 游戏玩后感:纯爱咖啡厅:帕露菲重制版 游戏玩后感:智以泪聚 游戏玩后感:初雪樱 游戏玩后感:告别回忆:从今以后 游戏玩后感:梦灯花 游戏玩后感:金辉恋曲四重奏 游戏玩后感:五彩斑斓的世界 昇腾310P使用记录 游戏玩后感:AIR 游戏玩后感:弹丸论破 游戏玩后感:流景之海的艾佩莉亚 Xilinx_HLS上板过程记录 游戏玩后感:告别回忆2 游戏玩后感:恋爱绮谭 Faiss和Rapidsai_Raft使用记录 游戏玩后感:近月少女的礼仪 游戏玩后感:樱色之云,绯色之恋 游戏玩后感:幸运草的约定 游戏玩后感:星之梦、候鸟和丸子与银河龙 游戏玩后感:白色相簿2 Windows上使用VTune分析PyTorchExtension调用的Cpp程序 SpinalHDL上板过程记录 游戏玩后感:仰望夜空的星辰 最简单的算卦方法之一:梅花易数法 游戏玩后感:苍之彼方的四重奏 krkr引擎解包工具介绍 自定义CUDA实现PyTorch算子的四种简单方法 游戏玩后感:星空的记忆 游戏玩后感:9nine 游戏玩后感:AtriMyDearMoments 游戏玩后感:极限脱出 游戏玩后感:魔女的夜宴 SSH实现多跳代理 动漫观后感:向山进发 动漫观后感:夏日重现 CSP模板 游戏玩后感:海沙风云 动漫观后感:灵能百分百 游戏玩后感:交响乐之雨 游戏玩后感:爱上火车LastRun 游戏玩后感:LittleBustersEX 游戏玩后感:SummerPockets 游戏玩后感:逆转裁判 Ultra96V2开发板简单使用 SpinalWorkshop实验笔记(三) SpinalWorkshop实验笔记(二) SpinalWorkshop实验笔记(一) PYNQ开发板上使用USB声卡+OSS兼容层播放音频 TestOS移植K210开发板 rCore-Tutorial-Book-v3学习笔记(七) 动漫观后感:凉宫春日的忧郁 rCore-Tutorial-Book-v3学习笔记(♭七) rCore-Tutorial-Book-v3学习笔记(六) rCore-Tutorial-Book-v3学习笔记(五) rCore-Tutorial-Book-v3学习笔记(四) rCore-Tutorial-Book-v3学习笔记(三) rCore-Tutorial-Book-v3学习笔记(二) rCore-Tutorial-Book-v3学习笔记(一) 游戏玩后感:RewritePlus MIT-6.S081-2020实验(xv6-riscv64)十一:net MIT-6.S081-2020实验(xv6-riscv64)十:mmap MIT-6.S081-2020实验(xv6-riscv64)九:fs MIT-6.S081-2020实验(xv6-riscv64)八:lock MIT-6.S081-2020实验(xv6-riscv64)七:thread MIT-6.S081-2020实验(xv6-riscv64)六:cow MIT-6.S081-2020实验(xv6-riscv64)五:lazy MIT-6.S081-2020实验(xv6-riscv64)四:traps MIT-6.S081-2020实验(xv6-riscv64)三:pgtbl MIT-6.S081-2020实验(xv6-riscv64)二:syscall 动漫观后感:吹响吧上低音号 MIT-6.S081-2020实验(xv6-riscv64)一:util 快速生成网络mp4视频缩略图技术 用plantuml画图示例 QQ缩略图和大图不同实现 Python制作字符图片 动漫观后感:命运石之门 使用OpenCV对图片进行特征点检测和匹配
flv重封装H264、AAC流
VnYzm · 2023-03-30 · via VnYzm的博客

背景

学校有一个网课系统,该系统的客户端是使用Silver light(一个.NET相关的技术框架,类似Flash)实现的,不支持跨平台。我对它的代码进行了分析,发现它会从服务器中接收H264格式的视频裸流和AAC格式的音频裸流,这两个裸流都不能用普通的播放器播放,所以我写了一个客户端将这两种裸流重封装成FLV格式。在这个过程中我也学习了flv重封装H264、AAC流的具体方法,实现了一些函数,所以在此记录,为了方便我用的是Python语言。

FLV格式简述

首先简单介绍一下,常见的视频格式一般分两个层级:内层是编码算法,负责对视频像素数据和音频采样数据进行压缩和编码,常见的视频编码算法包括H264(又称AVC)、M-JPEG、HEVC等,常见的音频编码算法包括AAC、PCM、AC3等。外层是视频容器,负责将这些流分块打包,并包含视频相关的一些元信息和控制信息,常见的视频容器包括MPEG(以MP4为代表)、MKV、AVI、FLV等。而裸流指的就是没有容器,单纯就是将编码之后的所有数据包依次连接得到的视频或音频流。

那么为什么要选择FLV这种格式呢,因为FLV格式非常适合直播,在传输时只要在开始传输音视频数据前先传输一下FLV文件头以及音视频的控制信息,剩下的就是不断将音视频裸流分包并加上FLV的帧标签再传输就可以了。一言以蔽之,就是FLV支持无限大的视频文件,相比之下其他视频格式一般都要求给出文件或数据流的整体大小,因此很难支持音视频流源源不断到来的情况。

H264编码简述

我前面关于MP4的博客介绍过,H264裸流由NALU包组成,每个包可能代表一组控制信息(例如SPS帧和PPS帧),也可能代表视频的一帧。一般可以直接播放(需要支持格式比较多的播放器,如VLC、ffplay等)的H264裸流都是Annex-B格式,即开头是00 00 00 01或00 00 01。而FLV要求的H264数据包需要是AVCC格式的,即开头四个字节是数据包的大小。

注意,可以直接播放的H264裸流要求前两个帧必须分别是SPS帧和PPS帧,因为里面包含了视频流如何进行解码的信息,因此想通过播放裸流测试接收到的H264流有没有问题,一定要注意到这一点。

AAC编码简述

和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文件头

接下来介绍实现。首先是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问题也不大,大部分播放器也都能识别。

Tag头

接下来就是多个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的时间戳,用来校准视频和音频,后面会介绍如何计算。

控制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

在提供了控制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站都用这种格式),很快就能写出代码来。不过就是每篇文章可能都会或多或少漏点细节,最好的老师还是它:

ffmpeg -i input.mp4 -c:a aac -v:a libx264 output.flv
gvim -b output.flv

一边看文章的解释,一边对着一个实际的FLV文件进行分析,就能做到滴水不漏了。

可惜的是,最后这个网课系统客户端没能派上用场,因为这个系统主要是老师们用的,不希望给学生用,怕同学们在未经老师允许下录像传播啥的,所以我也没有公开项目的代码。不过我从这个项目中学到了很多音视频编解码的知识,而且FLV这个格式的确挺简单的,比MP4简单多了,用来入门视频容器和直播相关技术也是非常好的选择。

参考资料


  1. AAC ADTS格式分析,邶风,https://www.cnblogs.com/zhangxuan/p/8809245.html↩︎

  2. 音视频封装:FLV格式详解和打包H264、AAC方案(上),潇湘落木,https://cloud.tencent.com/developer/article/1747043↩︎

  3. 音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎

  4. 视频h264 和 音频aac 混合到 flv文件里,淡默恬愉,https://www.jianshu.com/p/85f1c6d0c7d0↩︎

  5. 音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎

  6. 音视频封装:FLV格式详解和打包H264、AAC方案(下),潇湘落木,https://cloud.tencent.com/developer/article/1746991↩︎