iOS 视频编辑核心架构
2024 - 06 - 29
Posted by Vito
移动端视频音视频领域已经是一片红海,前一两年还能经常看到有新的短视频 app 冒出,在 2018 年已经死掉一批。我的两个老东家,一个美拍,曾经的短视频王者,现在却陷入了苦战。另一个猫饼,没什么太大起色,已经卖给了腾讯。现在最热门的短视频应用抖音与快手分别坐拥上亿日活用户,规模效应可能在慢慢成型,给其它玩家的机会或许不多了。不过谁又知道呢?曾经的美拍也是无敌的存在。
跟对产品很重要,不过作为手(打)艺(工)人(仔),我看到的是移动音视频领域技术春天的来临,不仅是视频类应用,一些社交类、服务类应用都内置了视频编辑功能,比如大众点评也有一个简单的视频编辑功能。移动视频编辑功能正在成为各种内容发布平台的必备能力。
而移动端音视频技术的解决方案不像应用层有非常多好用的高级框架。在做视频相关工作的时候,文档相对会少一些,没有做应用层开发那么多的技术讨论,stackoverflow 上相关的问题也比较少。这方面还算相对比较小众的领域。
视频编辑的架构又是一个非常重要的部分,它的设计会对未来的需求迭代新增功能产生非常深远的影响,早期没有好的架构设计,会导致后期想添加一些看起来简单的需求,在实现上会非常困难。而且在迭代的过程中往往会发现,新的需求很可能会影响到之前的模块,特别是对时间轴有影响的功能,比如:变速、分段视频裁剪和贴纸时间的关系的问题等等。这类新需求的添加,会让技术的复杂度呈指数级增加。 然而要理清视频编辑中各种问题以及提供相对更优的解决方案,需要经验的积累和平时大量的思考。这篇文章主要分享我在 iOS 端视频编辑架构上对过去经验的总结和思考结果,希望能帮到读者少踩一些坑。得出的结论不一定是最优解,如果你知道哪部分有更优的解决方案,欢迎讨论,一起成长。
接下去的主要内容:
- 介绍 AVFoundation 提供的视频编辑架构
- 对 AVFoundation API 进行分析,并提出优化
- 介绍 Cabbage,一个基于 AVFoundation 封装的,更易于使用和方便扩展的视频编辑框架
AVFoundation 提供视频编辑接口
苹果的 AVFoundation 已经提供了一套视频编辑的 API,先来看看这套 API 的主要结构,以及实现视频编辑功能需要怎样的实现流程。
AVFoundation 中的视频数据源
在 AVFoundation 中,视频和音频数据可以用 AVAsset
表示,AVAsset
里面包含了 AVAssetTrack
数据,比如:一个视频文件里面包含了一个视频 track 和两个音频 track。可以使用 AVComposition
对 track 进行裁剪和变速等操作,也可以把多段 track 拼接到 AVComposition
里面。
图:AVAsset 及其子类结构
新建拼接视频片段示例代码
let asset: AVAsset = ...
let composition = AVMutableComposition(urlAssetInitializationOptions: nil)
if let compositionTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: 0) {
let videoTrack = asset.tracks(withMediaType: .video).first!
compositionTrack.insertTimeRange(videoTrack.timeRange, of: videoTrack, at: kCMTimeZero)
}
在处理完 track 的拼接和修改后,得到最终的 AVComposition
,它是 AVAsset
的子类,也就是说可以把它传递到 AVPlayer
、AVAssetImageGenerator
、AVExportSession
和 AVAssetReader
里面作为数据源,把 AVComposition
当成是一个视频数据进行处理。
AVFoundation 中的视频图像处理
AVFoundation 提供了 AVVideoComposition
对象和 AVVideoCompositing
协议用于处理视频的画面帧。
图:AVVideoComposition 结构图
AVVideoComposition
AVVideoComposition
可以用于设置帧率、画布大小、指定不同的 video track 应用何种编辑操作以及可以将视频画面嵌套在 CALayer
中。
指定 video track 的编辑操作,通过设置 AVVideoComposition
里的 instructions
属性实现,它是一个 AVVideoCompositionInstruction
协议数组,AVVideoCompositionInstruction
内定义了处理的时间范围、需要处理的 track ID 有哪些等。
图:AVVideoCompositionInstruction 在时间轴中的组成
将视频画面嵌套在 CALayer
内可以通过设置 AVVideoCompositionCoreAnimationTool
给 AVVideoComposition
。AVVideoCompositionCoreAnimationTool
有两种使用场景,一是可以添加一个 CALayer
做一个独立的 track 渲染到视频画面上。二是可以设置一个 parentLayer,然后把视频 layer 放置在这个 parentLayer 上,并且还可以放入其它的 layer。
图:AVVideoCompositionCoreAnimationTool 设置的 layer 层级
对 CALayer
的支持,可以把 CALayer
所支持的所有 CoreAnimation
动画带入到视频画面中。比如使用 Lottie,设计师在 AE 中导出的动画配置,客户端用配置生成 CALayer
类,添加到 AVVideoCompositionCoreAnimationTool
中就可以很方便的实现视频中做贴纸动画的功能。
AVVideoCompositing
上面说的 AVVideoComposition
提供了视频渲染时时间轴相关的配置,而 AVVideoCompositing
这个协议可以接管视频画面的渲染。实现了AVVideoCompositing
协议的类中,AVComposition
处理到某一时间点的视频时,会向AVVideoCompositing
发起请求,AVVideoCompositing
内根据请求包含的视频帧、时间信息、画布大小信息等,根据具体的业务逻辑进行处理,最后将处理后的视频数据返回。
AVFoundation 中的音频处理
AVFoundation 提供了 AVAudioMix
用于处理音频数据。 AVAudioMix
这个类很简单,只有一个 inputParameters
属性,它是一个 AVAudioMixInputParameters
数组。具体的音频处理都在 AVAudioMixInputParameters
里进行配置。
不同于 AVVideoComposition
的 instructions
,AVVideoCompositionInstruction
可以传入多个 trackID 方便之后多个视频画面进行合成。AVAudioMixInputParameters
只能绑定单个 AVAssetTrack
的音频数据,估计是因为音频波形数据和视频像素数据的差异,不适合做类似音频波形叠加。
AVAudioMixInputParameters
内可以设置音量,支持分段设置音量,以及设置两个时间点的音量变化,比如 0 - 1 秒,音量大小从 0 - 1.0 线性递增。
AVAudioMixInputParameters
内还有个 audioTapProcessor
属性,他是一个 MTAudioProcessingTap
类。这个属性提供了接口用于实时处理音频数据。
视频合成的驱动者们
上面提到了用 AVComposition
将数据源裁剪和拼接成最终的数据, AVVideoComposition
设置图像编辑逻辑,AVAudioMix
设置音频编辑逻辑。 当我们根据具体需求配置好这些对象后,AVFoundation 提供了 4 种场景使用它们。
图:AVFoundation 中支持 AVVideoComposition 和 AVAudioMix 的类
场景 1,视频播放 - AVPlayerItem
放入 AVPlayerItem
中,可用于视频播放。AVPlayerItem
的时间轴驱动视频数据的获取。
let composition: AVComposition = ...
let videoComposition: AVVideoComposition = ...
let audioMix: AVAduioMix = ...
let playerItem = AVPlayerItem(asset: composition)
playerItem.videoComposition = videoComposition
playerItem.audioMix = audioMix
场景 2,获取截图 - AVAssetImageGenerator
AVAssetImageGenerator
也是时间驱动,用于获取某个特定时间的视频截图
let composition: AVComposition = ...
let videoComposition: AVVideoComposition = ...
let imageGenerator = AVAssetImageGenerator(asset: composition)
imageGenerator.videoComposition = videoComposition
场景 3,视频帧读取 - AVAssetReaderVideoCompositionOutput/AVAssetReaderAudioMixOutput
AVAssetReaderVideoCompositionOutput
和 AVAssetReaderAudioMixOutput
只能逐帧访问
let readerVideoOutput: AVAssetReaderVideoCompositionOutput = ...
readerVideoOutput.videoComposition = ...
let readerAudioOutput: AVAssetReaderAudioMixOutput = ...
readerAduioOutput.audioMix = ...
场景 4,导出 - AVAssetExportSession
AVAssetExportSession
用于导出视频,内部也是逐帧访问,实际上是封装了 AVAssetReaderVideoCompositionOutput
和 AVAssetReaderAudioMixOutput
let exportSession = AVAssetExportSession(asset: composition, presetName: "name")
exportSession.videoComposition = ...
exportSession.audioMix = ...
总结以上的场景
它们使用的核心数据、编辑配置都是一样的接口,可以把它们当做视频合成的不同驱动方式。 AVPlayerItem
需要实时性,所以会引入丢帧、跳帧等策略,AVAssetImageGenerator
不需要丢帧,但也可以进行跳帧操作。 AVAssetReaderVideoCompositionOutput
和 AVAssetReaderAudioMixOutput
则是没有时间的概念,只能进行逐帧遍历操作,而 AVAssetExportSession
其实就是封装了 AVAssetReaderVideoCompositionOutput
和 AVAssetReaderAudioMixOutput
并支持了写入本地文件的功能。
去除繁杂
AVFoundation 提供了一整套功能强大的视频编辑 API,不过落地到具体的实现上,需要写很多代码,并且这一堆代码和业务逻辑关系不大。
使用 AVFoundation 的 API 完整编写一个视频编辑逻辑会涉及到以下流程:
- 获取音视频数据
- 需要找地方记录用户对音视频数据的修改
- 对音视频数据进行裁剪和拼接设置
- 记录用户对视频画面的修改,比如:加了滤镜、视频修改画面大小等
- 根据用户数据创建视频图像处理对象(复杂)
- 记录用户对音频的修改
- 根据用户数据创建音频处理对象(复杂)
- 组合视频数据、画面处理、音频处理并生成不同的输出对象
而这 8 个流程中,其中「根据用户数据创建视频图像处理对象」和「根据用户数据创建音频处理对象」都是比较复杂的逻辑实现,需要编写大量处理逻辑。
并且使用 AVFoundation 的 API 没有办法支持使用图片或者其它自定义的非视频数据源作为视频合成的片段。AVFoundation 对前后两个片段的转场效果实现也不容易。
可以看出 AVFoundation 虽然提供了一套强大的视频编辑 API,但是在使用上很麻烦,并且一些常见的基础功能没有支持。如果之后想要扩展新的编辑能力,没有一套简单通用的模式快速增加新能力的支持。
基于对这些可以优化的点去思考,整理出一个新的视频编辑框架,它基于 AVFoundation 视频编辑 API ,封装那些麻烦又复杂的和业务逻辑无关的流程性代码,提供视频编辑常用的基础功能,并提供一套高度可扩展的模式接口让新的业务可以快速实现。
对比之前使用 AVFoundation 做视频编辑时需要的 8 个步骤,这个新的编辑框架只需要以下步骤:
- 获取音视频数据,并用框架提供的对象做一些配置(支持图片和其它自定义的数据源)
- 创建视频编辑配置对象,并传入音视频源(如果有扩展需求,可以继承这个对象对配置进行扩展)
- 视频编辑配置对象传入框架的时间轴对象
- 使用时间轴对象生成各种场景下使用的输出对象
新的视频编辑结构
为了让视频编辑架构理解上更简单,扩展功能更容易扩展,使用更加方便,我创建了 Cabbage 项目,实现了一套新的视频编辑 API,基于 AVFoundation。
Cabbage 是我家喵的名字,我在写 Cabbage 的数个日夜,它都是躺在我的 Mac 旁边。
Cabbage 核心类是 Timeline
和 CompositionGenerator
,开发者只要创建 Timeline
,使用 Timeline
初始化 CompositionGenerator
let generator = CompositionGenerator(timeline: ...)
就可以用 generator 生成 AVPlayerItem/AVAssetImageGenerator/AVAssetExportSession 等各种场景下使用的对象。
接口实现
用一种更简单的方式理解视频编辑
Timeline
用于往时间轴上添加数据片段,可以提供视频相关数据和音频相关数据。
public class Timeline {
// MARK: - Global effect
public var passingThroughVideoCompositionProvider: PassingThroughVideoCompositionProvider?
// MARK: - Main content, support transition.
public var videoChannel: [TransitionableVideoProvider] = []
public var audioChannel: [TransitionableAudioProvider] = []
// MARK: - Other content, can place anywhere in timeline
public var overlays: [VideoProvider] = []
public var audios: [AudioProvider] = []
}
Timeline 类有 5 个属性。
passingThroughVideoCompositionProvider
是一个协议,实现这个协议可以对视频画面进行实时的处理,在时间轴的每一个时间点都会调用这个协议的回调方法,比较适合需要应用在主时间轴上的效果。
public protocol PassingThroughVideoCompositionProvider: class {
func applyEffect(to sourceImage: CIImage, at time: CMTime, renderSize: CGSize) -> CIImage
}
videoChannel
和 audioChannel
是 Timeline 里的主轴,整个 Timeline 有多长时间,根据这里的视频或音频数据的时间长度得出。并且 videoChannel
和 audioChannel
内 provider 的 timeRange 会被强制按顺序排序重置。
overlays
和 audios
则是可以放在时间轴任意位置的图像数据和音频数据。适合的场景如:放置一个贴纸、视频到画面的某个位置。添加一个背景音乐或者录音。
CompositionGenerator
CompositionGenerator
其实是 Timeline
和 AVFoundation
接口的桥接器。
CompositionGenerator
用于把 Timeline 的数据合成为 AVComposition
、AVVideoComposition
和 AVAudioMix
,然后用这 3 个对象生成 AVPlayerItem
、AVAssetImageGenerator
和 AVAssetExportSession
等用于处理视频的对象。
CompositionGenerator
的使用非常简单
let timeline = ...
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
Timeline
和 CompositionGenerator
就是公开 API 的核心了,业务开发完全可以根据自己的需求自定义 Timeline
内的数据完成需求。不过仅仅是提供了 Timeline
外部还要做不少工作,需要实现 VideoProvider
和 AudioProvider
协议才能作为 Timeline
的数据源。
其实有很多基础的视频编辑功能是比较通用的,每个做视频编辑功能的应用都会需要,根据以往的经验以及参考了 [Videoleap] 等视频编辑工具,实现了一些基础的视频编辑功能,提供了 TrackItem
对象用于描述和处理音视频数据。
TrackItem
TrackItem
是一个音视频编辑的设置描述对象,类的内部实现了音频数据和视频画面的处理逻辑。
图:TrackItem 实现结构图
它实现了 TransitionableVideoProvider
和 TransitionableAudioProvider
协议,同时支持提供音频和视频数据。而具体的数据源是通过创建一个 Resource
的子类,并赋值给 TrackItem 完成配置。
如果有其它自定义的业务逻辑需要处理,可以继承 TrackItem
在它的处理基础上实现其它业务逻辑。如果业务逻辑和 TrackItem
完全不一样了,也可以完全自定义类,只要实现 TransitionableVideoProvider
或 TransitionableAudioProvider
协议即可。
Resource
Resource
对象提供一个编辑片段的原始数据信息,它可以是一段视频、一个图片或者一段音频文件。
图:Resouce 以及其子类结构图
现在内部已经实现了几个常用的 Resource:
- 图片类型:
ImageResource
,PHAssetImageResource
- 音频和视频类型:
AVAssetTrackResource
,PHAssetTrackResource
Cabbage 使用示例
下面是一个最简单的使用示例。实现一个完整的视频编辑功能已经简化到了十几行代码的级别。
当然,一个完整的视频编辑应用还需要包含 UI 和用户交互逻辑,这些部分也是有很大的工作量,但至少视频合成的底层实现已经可以节省很多时间了,生活又美好了一些。
// 1. Create a resource
let asset: AVAsset = ...
let resource = AVAssetTrackResource(asset: asset)
// 2. Create a TrackItem instance, TrackItem can configure video&audio configuration
let trackItem = TrackItem(resource: resource)
// Set the video scale mode on canvas
trackItem.configuration.videoConfiguration.baseContentMode = .aspectFill
// 3. Add TrackItem to timeline
let timeline = Timeline()
timeline.videoChannel = [trackItem]
timeline.audioChannel = [trackItem]
// 4. Use CompositionGenerator to create AVAssetExportSession/AVAssetImageGenerator/AVPlayerItem
let compositionGenerator = CompositionGenerator(timeline: timeline)
// Set the video canvas's size
compositionGenerator.renderSize = CGSize(width: 1920, height: 1080)
let exportSession = compositionGenerator.buildExportSession(presetName: AVAssetExportPresetMediumQuality)
let playerItem = compositionGenerator.buildPlayerItem()
let imageGenerator = compositionGenerator.buildImageGenerator()
内部核心实现
外部接口看起来简单直接,实际上在这些接口的内部封装了各种基础功能的具体实现,这些功能是视频编辑的核心。
核心实现包括:时间数据结构、画面渲染分层设计、实时视频画面处理、实时音频处理、转场支持、自定义视频资源实现和渲染驱动生成器。
时间数据结构
在做视频编辑时,时间有多个纬度
- 原始资源的总时长
这个数据放在 Resouce 的 duration 属性里
- 原始资源选用的时间范围
这个数据放在 Resouce 的 selectedTimeRange 属性里
- 原始资源选用的时间范围映射在合成时间轴上的时间范围
- 受到变速影响
- 受到转场效果影响
TrackItem.configuration.timelineTimeRange 属性是表示合成时间轴上的时间范围
在设置了 TrackItem.configuration.speed 后都需要调用一下 TrackItem.reloadTimelineDuration() 确保 TrackItem 的 timeRange 是对应到合成时间轴上的时间。
图:2 倍速原始素材时间对应到合成时间轴
把 TrackItem 数组传入 Timeline 的 videoChannel 和 audioChannel 前,也需要保证 TrackItem 的 timeRange 是按顺序拼接的。
如果支持转场效果,则当前片段的末尾和下一个片段开头之间需要有重合部分。
图:转场效果,时间重合
画面渲染分层设计
当进入某一个时间点时:
- 底层渲染会先要求每个 Provider 处理完自己的画面然后返回,TrackItem 这个类就是专门用于处理每个独立 Resouce 的画面
- 是否有转场效果,如果有,则把上一步得到的画面用于转场合成
- 是否有人设置了 Timeline 的
PassingThroughVideoCompositionProvider
如果有,则把上一步合成的图像传入,让PassingThroughVideoCompositionProvider
处理。
这种渲染分层让整个渲染流程变得清晰,并且易于理解和扩展。
实时视频画面处理
实时画面处理是通过实现 AVVideoCompositing
协议,并设置到 AVVideoComposition
中。 视频处理到某个特定时间点的时候,会向 AVVideoCompositing
发起视频合成请求,会调用 func startRequest(_ asyncVideoCompositionRequest: AVAsynchronousVideoCompositionRequest)
,可以从 AVAsynchronousVideoCompositionRequest
中获取这个时间点对应的视频图像数据、画布大小、时间等信息,处理完视频画面后,可以调用 AVAsynchronousVideoCompositionRequest
的 finish
方法结束合成。
实时音频处理
实时音频处理需要实现 MTAudioProcessingTap
,传入到 AVAudioMix
里。MTAudioProcessingTap
需要通过 MTAudioProcessingTapCreate
方法进行创建,可以为 MTAudioProcessingTap
绑定一组 call back,其中最关键的 MTAudioProcessingTapProcessCallback
就是音频实时处理时的回调
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
init: tapInit,
finalize: tapFinalize,
prepare: tapPrepare,
unprepare: tapUnprepare,
process: tapProcess)
var tap: Unmanaged<MTAudioProcessingTap>?
let err = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tap)
在 MTAudioProcessingTapProcessCallback
调用时,可以获取到当前处理的 AudioBufflerList
数据,然后对这段数据进行处理后最为最终音频数据输出
fileprivate var tapProcess: MTAudioProcessingTapProcessCallback = {
(tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut) in
// Process audio buffer
}
转场支持
实现转场,需要同一个时间显示两个视频画面,然后做各种叠加效果达到转场。 为了支持这一特性
- 会影响到主时间轴总时长和单个 track 在时间轴中的开始时间点,因为设置转场相当于时间轴上的时间变少了
- 需要标记转场的前后视频。这个涉及到如何设计对象描述,在转换到 VideoCompositionInstruction 的时候怎样才能用最简单的算法算出转场区间
- 在合成的时候要使用一种可扩展的模型实现转场叠加,因为转场可以有很多
自定义视频资源实现
AVFoundation 原本就支持 Video Track 和 Audio Track,所以如果是音视频资源,用于合成拼接就非常简单。
但如果想要把图片作为 track 呢?由于 AVFoundation 没有提供接口,所以我为图片类型的 track 提供一个默认的黑帧视频 track,然后把图片保存在 Resource 里,在实时合成的时候再向 Resource 请求当前时间点应该返回的图像。
CompositionGenerator 合成器
CompositionGenerator
作为 Timeline
和 AVFoundation
接口的桥接器。内部主要做三件事
- 合成 AVComposition
Timeline 对象中的数据需要实现 VideoCompositionTrackProvider
或者 AudioCompositionTrackProvider
协议,这两个协议用于提供合成 AVComposition
的数据。
- 合成 AVVideoComposition
合成 AVVideoComposition
除了一些基础设置,最重要的一件事情就是根据上一步合成的 AVComposition
,获取视频类型的 track,然后生成时间轴。时间轴用 VideoCompositionInstruction
数组表示,必须保证 VideoCompositionInstruction
的 timeRange 是连续的片段。 然后使用 VideoCompositionProvider
协议配置 VideoCompositionLayerInstruction
,在视频实时渲染的时候,会调用 VideoCompositionLayerInstruction
的 func applyEffect(to: , at: , renderSize:) -> CIImage
方法。
- 合成 AVAudioMix
合成 AVAudioMix
需要使用上一步合成的 AVComposition,取出音频类型的 track,然后对这些 track 应用 AudioMixProvider
设置的效果。
总结
回顾全文,讲了以下主要内容
- 视频编辑器现状,从现有的视频编辑软件功能进行分析都有哪些通用业务
- 深入 AVFoundation 的视频编辑 API 和架构,了解如何使用 AVFoundation 实现视频编辑功能以及它对应的播放、缩略图截图和导出功能的整套工具链。
- 新的框架 Cabbage,提供更好用的视频编辑 API
编写视频编辑代码是相对有难度的,它的难度不止在于 API 的使用不够方便,关于视频编辑相关的文档也比较稀缺,习惯了 StackOverflow 上 copy & paste 的我就经常搜索不到问题的解决方案。
Cabbage 视频编辑框架不仅是一个工具,它也是视频编辑功能实现的一个总结,里面包含了很多在做视频编辑器时需要用的解决方案,就算不使用 Cabbage 这个框架,理解里面的代码也能在之后做视频编辑时更容易解决问题。
最后附上 Cabbage 项目地址:Cabbage