FFmpeg功能强大,社区活跃,在多媒体处理业务中扮演着不可或缺的角色。但没有优化过的FFmpeg在生产环境下有很多性能瓶颈,因此对其进行优化势在必行。苏宁旗下PP体育音视频技术负责人田钊撰文分享了团队在处理海量视频切割过程中遇到的挑战及优化方法。感谢OnVideo视频创作云平台联合创始人、FFmpeg Maintainer刘歧对本文的技术审校。
文 / 田钊
审校 / 刘歧
一、前言
苏宁旗下PP体育所在的直播行业,每天有无数视频原始数据需要进行分类存储、渲染处理。处理这些视频,一个很重要的方面,就是要将长时段的直播视频切割成不定时长,不定画面组的短视频,以匹配现代用户碎片化的消费时间。尤其是体育赛事直播行业,在直播前的垫场片花、直播中的即时快看、直播后的全场集锦和精华镜头,都需要对大量的视频作剪切/压制处理。而且因为体育赛事直播行业的特殊性,对于直播中和直播后的精彩镜头,集锦类视频片段,要求必须能及时处理视频,并发布到用户端。这对视频的处理效率提出了非常高的要求。
在PP体育,我们在使用与业界同样高效的设计模式和优化方案的同时,另外尝试了换一种角度来思考这个问题,并进行了实践。下面我们来针对这部分的构思和实践中碰到的问题,来做个分享。
二、背景基础知识
先简单说一下我们对视频在数据层面上的理解。对于视频来说,无论是何种编码,何种封装格式,拆分开看,都是由音频流和视频流来组合而成的。从数据的最低层级往上推,会发现一个视频文件会由以下几个层面的数据组成。
1. 第一层是乱序的二进制数据层。基本看不出来是啥数据。
2. 第二层是未经编码的音视频数据层。这里就有了数据源出来的原始音频、视频等数据。原始音视频流数据量很大。
3. 第三层是编码数据层。通常音频使用AAC编码,视频使用H.264/265编码后,音视频流数据量就已经比较小了。
4. 第四层是封装层。将编码后的音视频数据”打包“封装成不同的封装格式。这里就是我们通常所看到的.ts/.mp4/.flv/.mkv等视频文件。这些文件里封装着M路编码的视频流和N路编码的音频流。当然也可以有其它的数据流,如字幕流,附加信息流等。
三、常规做法简述
视频的切割/转码/压制,目前业界通常的处理方式是在云端服务器,直接通过云转码模块集成的视频剪切服务来处理。通常使用FFmpeg套件改造而成。而且部分视频云服务厂商为提升转码效率,会用到云端转码集群。通过将完整的长段视频先进行切割,再将切割完的小段视频再通过分布式集群进行转码,合并,压制操作。其中,转码压制部分,由苏宁视频云服务提供的业界领先的分布式转码集群来完成。基础的转码业务图如下:
其中,转码部分,多数视频云服务厂商采用了分布式转码服务,来进行效率优化的提升。对于切割部分,却不一定重视。部分方案会和转码模块合并到一起,也有的厂商两样将分析视频的结果列表,也利用服务器集群来进行并发的切割操作。通常这种方案会直接使用FFmpeg套件来完成切割的动作。所以,对视频云厂商来说,FFmpeg套件切割视频功能的优化是提升切割效率的核心。各大厂商的业界大牛们为此做了不同的尝试,也取得了不错的效果。
典型的切割服务,多在音视频分层图的第三层作数据拷贝处理,典型如下列指令:
ffmpeg -ss 00:10:24 -i input.mp4 -vcodec copy -acodec copy -t 00:95:27 output.mp4
此切割指令使用FFmpeg套件对视频数据中的音视频,按音视频帧级数据包直接拷贝来处理。此种方式有优点也有缺陷。
缺点在于:经常会有比较明显的视频切割误差。因为视频GOP长度因素存在,经常会出现起始点视频帧并非关键帧。而FFmpeg切割程序代码需要找到切割起始点的视频关键帧,才能正常完成视频帧层面的切割动作。所以FFmpeg程序会计算查找当前视频帧的GOP关键帧后,再以此GOP关键帧为起始点来作为切割起始点。此种方式下会导致真实切割点与原始需求切割点是不一致的情况。导致切割出来的视频起止点并不精确。
优点也很明显:因为不对已编码的音视频数据进行解码再编码的操作,所以效率已经非常不错。并且在此基础上,进一步的优化方案,可以将FFmpeg套件按多进程模型来使用,利用服务器的多核性能来并行调用多个FFmpeg进程进行多路切割操作,缩短总体切割时间,以提升切割性能;再利用服务器集群,进行多服务器规模并行处理,进一步提高切割效率。
四、优化方法与实践
我们的优化做法,与上述情况在原理上是一致的,但是在细节上有做了微创新。
首先,我们没用使用FFmpeg套件来做核心切割功能服务。如上所述,业界通常利用FFmpeg套件切割视频文件时,是在视频分层图的第三层编码数据层对视频文件按”帧“级数据作拷贝处理。我们对生产环境及直播链路进行梳理后发现,视频的数据封装格式基本只有MP4/FLV/TS三种。而此三种封装格式里,除MP4封装稍复杂外,FLV/TS的封装相对容易分析处理。所以我们大胆地尝试了在视频分层图的第四层——封装层做分析处理。将视频切割动作分解为对封装数据的切分。
1. 分析视频封装里的详细描述信息;
2. 根据封装详细描述信息,对起止切割点进行计算;
3. 找到切割点二进制数据起止点;
4. 复制出起止点间二进制数据;
5. 重新描述起止切割点的封装信息,并与复制出的二进制数据进行拼合。
上述操作完成后,最终得到切割后的视频。这种操作方法,实际是将视频文件分解为两层,封装层和二进制数据层。切割工具从封装层得到描述信息后,对视频数据进行最底层的二进制数据拷贝,其中不涉及任何帧的处理。切割起始点与终止点的计算,以及拷贝数据拼合成新的视频,是这里的关键。典型代码片段如下:
func CopyStructureData(src *demux.VideoStructure, dst *demux.VideoStructure) {
copy(dst.FTYP.CompatibleBrands, src.FTYP.CompatibleBrands)
copy(dst.MOOV.MVHD.Flags, src.MOOV.MVHD.Flags)
copy(dst.MOOV.MVHD.Reserved, src.MOOV.MVHD.Reserved)
copy(dst.MOOV.MVHD.Matrix, src.MOOV.MVHD.Matrix)
copy(dst.MOOV.MVHD.PreDefined, src.MOOV.MVHD.PreDefined)
for i := 0; i < len(src.MOOV.TRAK); i ++ {
copy(dst.MOOV.TRAK[i].TKHD.Flags, src.MOOV.TRAK[i].TKHD.Flags)
copy(dst.MOOV.TRAK[i].TKHD.Reserved1, src.MOOV.TRAK[i].TKHD.Reserved1)
copy(dst.MOOV.TRAK[i].TKHD.Reserved2, src.MOOV.TRAK[i].TKHD.Reserved2)
copy(dst.MOOV.TRAK[i].TKHD.Reserved3, src.MOOV.TRAK[i].TKHD.Reserved3)
copy(dst.MOOV.TRAK[i].TKHD.Matrix, src.MOOV.TRAK[i].TKHD.Matrix)
copy(dst.MOOV.TRAK[i].EDTS.ELST.Flags, src.MOOV.TRAK[i].EDTS.ELST.Flags)
copy(dst.MOOV.TRAK[i].EDTS.ELST.TrackDurations, src.MOOV.TRAK[i].EDTS.ELST.TrackDurations)
copy(dst.MOOV.TRAK[i].EDTS.ELST.Times, src.MOOV.TRAK[i].EDTS.ELST.Times)
copy(dst.MOOV.TRAK[i].EDTS.ELST.Speeds, src.MOOV.TRAK[i].EDTS.ELST.Speeds)
copy(dst.MOOV.TRAK[i].MDIA.MDHD.Flags, src.MOOV.TRAK[i].MDIA.MDHD.Flags)
copy(dst.MOOV.TRAK[i].MDIA.HDLR.ComponentName, src.MOOV.TRAK[i].MDIA.HDLR.ComponentName)
copy(dst.MOOV.TRAK[i].MDIA.MINF.SMHD.Flags, src.MOOV.TRAK[i].MDIA.MINF.SMHD.Flags)
copy(dst.MOOV.TRAK[i].MDIA.MINF.SMHD.Balance, src.MOOV.TRAK[i].MDIA.MINF.SMHD.Balance)
copy(dst.MOOV.TRAK[i].MDIA.MINF.SMHD.Reserved, src.MOOV.TRAK[i].MDIA.MINF.SMHD.Reserved)
copy(dst.MOOV.TRAK[i].MDIA.MINF.VMHD.Flags, src.MOOV.TRAK[i].MDIA.MINF.VMHD.Flags)
......
}
}
// 生成片段视频文件
func Generate(clipVideo *demux.VideoStructure, videoSampleOffsets, audioSampleOffsets *SampleOffsets, videoSampleIndexRange, audioSampleIndexRange *SampleIndexRange, clipPath, clipPrefix string, clipTime *ClipTime, videoFilePath string) {
// 拼接完整路径名称
clipVideoPath := fmt.Sprintf("%s%s%d-%d.mp4", clipPath, clipPrefix, clipTime.Start, clipTime.Stop)
// 创建文件写入对象
writer, err := NewFileWriter(clipVideoPath)
if err != nil {
fmt.Print(err)
return
}
defer writer.Close()
writeFTYP(writer, clipVideo)
writeFREE(writer, clipVideo)
writeMDAT(writer, clipVideo, videoSampleOffsets, audioSampleOffsets, videoSampleIndexRange, audioSampleIndexRange, videoFilePath)
writeMOOV(writer, clipVideo)
writeMVHD(writer, clipVideo)
for _, track := range clipVideo.MOOV.TRAK {
if track.MDIA.HDLR.ComponentSubtype == "vide" {
writeTRAK(writer, &track, true)
}
if track.MDIA.HDLR.ComponentSubtype == "soun" {
writeTRAK(writer, &track, false)
}
}
}
// 从原视频结构体中取出片段帧的偏移,再从原视频中拷贝帧数据到片段视频
func writeMDAT(writer *FileWriter, clipVideo *demux.VideoStructure, videoSampleOffsets, audioSampleOffsets *SampleOffsets, videoSampleIndexRange, audioSampleIndexRange *SampleIndexRange, videoFilePath string) {
// 重算音视频帧总长度
sampleTotalSize := uint32(0)
for _, track := range clipVideo.MOOV.TRAK {
for _, sampleSize := range track.MDIA.MINF.STBL.STSZ.SampleSize {
sampleTotalSize += sampleSize
}
}
writer.WriteUint32BE(8 + sampleTotalSize)
writer.WriteString("mdat")
// 从原视频中拷贝帧数据
reader, err := NewRawReader(videoFilePath)
if err != nil {
fmt.Print(err)
return
}
// 视频数据如果连续,则合并长度,减少读取次数
currentOffset := int64(0)
currentLength := int64(0)
for index, offset := range videoSampleOffsets.Offset[videoSampleIndexRange.Start:videoSampleIndexRange.Stop] {
for _, track := range clipVideo.MOOV.TRAK {
// 视频track
if track.MDIA.HDLR.ComponentSubtype == "vide" {
if currentOffset == 0 {
currentOffset = int64(offset)
currentLength = int64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])
}
// 如果内存是连续的则合并长度待最后一次性读取
if index+1 <= videoSampleIndexRange.Stop-videoSampleIndexRange.Start && uint64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])+offset == videoSampleOffsets.Offset[index+1] {
if currentOffset > 0 {
currentLength += int64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])
}
} else {
sampleContent := reader.ReadBytesAt(currentLength, currentOffset)
writer.WriteBytes(sampleContent)
currentOffset = 0
currentLength = 0
}
break
}
}
}
// 音频数据如果连续,则合并长度,减少读取次数
currentOffset = int64(0)
currentLength = int64(0)
//多音轨视频,某音轨长度不足造成越界,直接补0
if audioSampleIndexRange.Start > len(audioSampleOffsets.Offset) || audioSampleIndexRange.Stop > len(audioSampleOffsets.Offset) {
log.Println("Current audio track length not enough to fit the cut range!")
for _, track := range clipVideo.MOOV.TRAK {
if track.MDIA.HDLR.ComponentSubtype == "soun" {
for _, sampleSize := range track.MDIA.MINF.STBL.STSZ.SampleSize {
buf := make([]byte, sampleSize)
writer.WriteBytes(buf)
}
}
}
return
}
for index, offset := range audioSampleOffsets.Offset[audioSampleIndexRange.Start:audioSampleIndexRange.Stop] {
for _, track := range clipVideo.MOOV.TRAK {
// 音频track
if track.MDIA.HDLR.ComponentSubtype == "soun" {
if currentOffset == 0 {
currentOffset = int64(offset)
currentLength = int64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])
}
// 如果内存是连续的则合并长度待最后一次性读取
if index+1 <= audioSampleIndexRange.Stop-audioSampleIndexRange.Start && uint64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])+offset == audioSampleOffsets.Offset[index+1] {
if currentOffset > 0 {
currentLength += int64(track.MDIA.MINF.STBL.STSZ.SampleSize[index])
}
} else {
sampleContent := reader.ReadBytesAt(currentLength, currentOffset)
writer.WriteBytes(sampleContent)
currentOffset = 0
currentLength = 0
}
break
}
}
}
}
这样就模拟了最原始的数据拷贝动作。实际应用效果对比看,优化后的切割方式,比使用FFmpeg套件,效率提升了近2倍。这是对切割操作思路的一种转换。
但是,这并不是优化的结束。我们前面谈到业界通行做法,都用到了服务器的多核处理。多核优化利用了机器的最大性能,是最基本的优化方式。那么,我们能不能在这方面再考虑入手呢?
是的,我们又在编程语言上微创新了一下。巧合的是,我们当时正在准备用Golang来做长链接系统的服务。程序员灵光乍现,用Golang实现了上述操作逻辑,顺便开了“一些” goroutine来做复制切割数据的动作。把每个goroutine模拟成一个FFmpeg切割进程,这样在同一台服务器上,每个内核线程上就运行着多个"goroutine形式的FFmpeg"切割JOB。简略的主流程代码如下:
func main() {
// 解码源视频
rawVideo := new(demux.VideoStructure)
demux.Demux(rawVideo, videoFilePath)
// 并发编码多个片段视频
start = time.Now()
pool := util.NewRoutinePool(len(clipTimes))
for _, clipTime := range clipTimes {
go func(clipTime *remux.ClipTime) {
pool.AddOne()
defer pool.DelOne()
// 编码片段视频
clipVideo := new(demux.VideoStructure)
videoOffsets, audioOffsets, videoRange, audioRange, err := remux.Remux(rawVideo, clipVideo, clipTime)
// 导出片段视频
remux.Generate(clipVideo,videoOffsets,audioOffsets,videoRange,audioRange,clipPath, clipPrefix, clipTime, videoFilePath)
}(clipTime)
}
pool.Wait()
}
经过此番转换后,一台服务器上的剪切视频操作,就从FFmpeg切割方案的“单进程/M线程”转换成“M线程xN协程"模式。(M为CPU内核数,N为单内核上的goroutine数)
在编程语言层面上的”误打误撞“并发处理后,切割效率又得到了进一步的提升。经过效果对比验证,比使用FFmpeg套件的单进程方式,效率提升了20~80倍。最终影响整个切割效率,成为瓶颈的,是硬盘的IO性能。
在此基础上,将单台服务器扩展至分布式服务集群。这样的视频切割JOB集群,带来的是超高效率的视频切割处理流程。
五、存在的问题
方案经过优化后,在视频切割方面,已经将效率提高了至少10倍以上。但同时优化过程中也有一些问题呈现出来。
1. 首先,就是适配的视频封装格式单一的问题。因为我们的数据源比较单一,基本是MP4封装格式,所以在初期,切割程序只需要解析MP4封装格式相关定义字段即可。不过网络上视频流媒体格式非常丰富,即使常用的也有4、5种。对此,我们后续添加了对另2种比较常见的FLV与TS封装格式的支持,满足了业务的正常需求。但是,仍然与FFmpeg套件的广泛适用性相去甚远。毕竟FFmpeg积累这么些年兼容了几乎所有的媒体格式,这也是用FFmpeg套件被广泛选择,且相对更简洁易用的原因。
2. 另外,在实际计算起止切割点时,往往会出现当前切割点的时间上并不是关键帧,导致部分数据无法被正确解码的问题。对此,我们也做了简单的处理:对于切割点上非关键帧的情况,我们的程序会自动往前/往后找到上一个/下一个关键帧的时间点,并以此时间点为基准,重新计算数据后再行切割。这样才能保证所有切割出来的视频是确定能被解码的。经过测试,对切割效率的影响几乎可以忽略不计。并且,我们也正在着手进行优化的“补帧”形式的精确起止点方案。
3. 还有,视频媒体源文件非标的处理问题。实际生产过程中,经常会发现数据源提供的视频文件里,有1路以上的音频流,而且经常性出现几路音频流中,都是无效的错误数据。这种情况在实际生产中会影响到数据切割后的音视频同步出错,导致无法切割成功,或者播放失败。我们对不同的情况进行分析后,找到几种思路/模式来解决:
(1)分析并保留正确的音频流数据。这对部分非现场录制的视频文件比较有效,绝大多数PGC生产的视频文件均可适用此模式。
(2)切割拷贝数据时不包括音频流数据。这意味着切割后的视频没有声音。大多数赛事直播现场录制的视频可应用此模式。
(3)对于无法分析正确且不能丢弃原始音频流数据的文件,作“降级”处理,改用FFmpeg套件接手切割工作,保证生产出正确的视频文件。
六、分析与小结
从解决方案的拆分模块角度看,任何环节的优化提升都是对整个方案的效率有积极的促进作用。故而,我们对整个视频剪切流程进行梳理划分。整理出视频数据切割操作中的不同模块。
优化方案的核心思路,主要是对数据处理模块进行效率提升。其关键点在于:
1. 单个剪切需求转换为数据拷贝的JOB。
2. JOB由进程转换为协程化处理。
3. 集群分布式处理JOB列表。
虽然在实际生产使用过程中,仍然不断有出现或大或小的坑,但是这都不影响我们在追求更高生产效率的路上继续前行。只要能提升效率,任何微小的创新都在我们的持续不懈的优化范围之中,这也正是苏宁的造极精神的体现。