八.音视频编辑-动画图层
Core Animation是OS X和iOS平台提供的用于合成和制作动画的框架,它提供一种简单、声明式应用程序模型使得在不需要使用OpenGL或OpenGL ES框架的前提下就可以很容易地创建高性能、基于GPU的动画效果。我们已经在书中见过AV Foundation是如何使用Core Animation框架提供硬件加速视频渲染效果,其中就用到了AVPlayerLayer和AVVideoCaptu
引言
推荐博客专栏链接:AVFoundation架构与实践
博客专栏源码链接:AVFoundation-建议转存大量实战源码
到目前为止,我们的应用已经有了编辑视频,编辑音频,创建音频混合,创建视频过渡的功能。不过到目前为止我们还有一个神秘的轨道处于置空的状态。本篇博客我们就来介绍它,组合之上的动画图层,这一功能可以让我们为应用添加一些叠加效果,比如水印,标题,字幕或者其他的动画效果。
概述
Core Animation 简介
Core Animation是OS X和iOS平台提供的用于合成和制作动画的框架,它提供一种简单、声明式应用程序模型使得在不需要使用OpenGL或OpenGL ES框架的前提下就可以很容易地创建高性能、基于GPU的动画效果。我们已经在书中见过AV Foundation是如何使用Core Animation框架提供硬件加速视频渲染效果,其中就用到了AVPlayerLayer和AVVideoCapturePreviewLayer两个类。
从高层次角度看,Core Animation包含两类对象:
- Layer:图层对象又CALayer类定义,并用于管理屏幕中可视化内容的元素。除了基础的CALayer,框架还定义了很多实用的子类,比如CATextLayer,CAShapeLayer。
- Animation:动画对象是抽象类CAAnimation的实例,定义所有动画类型所共有的一些核心动画行为,同样框架也CAAnimation定义了许多具体实用子类,最常用的就是CABasicAnimation和CAKeyFrameAnimation。这些类将动画状态变为单独的图层属性,以便创建简单的和复杂的动画效果。CABasicAnimation可以让你创建简单的单关键帧动画,意味着在一段时间内将属性状态以动画方式由一种状态变为另一种状态。这个类在实现简单动画时非常实用,比如动态调整图层的尺寸、位置或背景色。CAKeyFrameAnimation用于实现更高级的功能,它对动画中的“关键帧”有着更多的控制。比如,当一个图层沿着Bezier路径动态显示,可以用到关键帧动画来指定具体的时间和节奏。
关于Core Animation后续可能会出一个专栏来详细的介绍它,但这里我们就介绍到这,在本篇博客中我们只会应用到最常见的缩放和旋转动画。
以旋转动画为例,代码如下:
let layer = CALayer()
let image = UIImage(named: "cover")
layer.contents = image?.cgImage
layer.contentsScale = UIScreen.main.scale
// 458 × 314 pixels
let widht = 458 * 0.3
let height = 314 * 0.3
layer.frame = CGRect(x: (view.bounds.width - widht) * 0.5, y: 100, width: widht, height: height)
self.view.layer.addSublayer(layer)
let rotaionAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotaionAnimation.fromValue = 0
rotaionAnimation.toValue = 2 * Double.pi
rotaionAnimation.duration = 2
rotaionAnimation.repeatCount = HUGE
layer.add(rotaionAnimation, forKey: "rotation")
在AV Foundation使用Core Animation
使用Core Animation为视频应用程序创建叠加动画图层效果和在UIKit中创建动画的方法几乎相同。最大区别在于运行动画的时间模型。
当创建实时动画时,CAAnimation实例从系统主机时钟获取执行时间。
主机时间从系统启动开始计算并单向向前推进,将动画执行时间同主机时间相关联在实时动画方面非常适用,不过对于创建视频动画就不合适。
视频动画需要基于“影片时间”来操作,开始的时间应该是影片开始的时间知道影片持续时间结束。另外影片时间是可以停止、暂停回退或快进。因为动画需要紧密地与视频时间轴绑定,所以需要使用不同的场景时间模式。
AV Foundation根据不同的用例给出了两种解决方案,我们先来看一下播放场景。
使用AVSynchronizedLayer播放
AV Foundation提供了一个专门CALayer子类AVSynchronizedLayer,用于与给定的AVPlayerItem实例同步时间。这个图层本身不展示任何内容,仅用来与图层子树协同时间。这样所有在基础关系中附属于该图层的动画都可以从激活的AVPlayerItem实例中获取相应的执行时间。后面的应用中会给出具体的实现方案。
通常使用AVSynchronizedLayer时会将其整合到播放器视图的图层继承关系中,同步图层直接呈现视频图层之上。
使用AVVideoCompositionCoreAnimationTool导出
要将core Animation图层和动画整合导出视频中,需要使用AVVideoCompositionCoreAnimationTool类。AVVideoComposition使用这个类将core Animation下过作为视频组合的后期处理阶段。
后面我们也会讨论AVVideoCompositionCoreAnimationTool的详细用法。不过先声明一些一会在案例中需要注意的问题:
- Core Animation框架的默认行为是执行东海并在动画行为完成后进行处理。通常这些行为就是我们希望在实时案例中使用的,因为时间一旦过去就没法返回。但对于视频动画就会有问题,所以需要设置动画的removedOnCompletion属性为NO来禁用这一行为。如果没有这样做,则动画效果就是一次性的,如果用户重新播放视频或者在时间轴上移动搓擦条也不会再次看到动画。
- 动画的beginTime属性被设置为0的话是不会看到动画效果的。Core Animation将值为0.0的beginTime对象转换为CACurrentMediaTime(),这是当前主机时间,同影片时间轴中的有效时间没有关系。如果希望在影片开头加入动画,将动画的beginTime属性设置成AVCoreAnimationBeginTimeAtZero常量。
应用
下面我们就将它应用到我们的APP中。在Core Animation中使用AVComposition还有一个挑战就是协调不同的概念和时间模型。在使用AVComposition时,考虑的是轨道以及CMTime和CMTimeRange值。而Core Animation没有轨道的概念并使用浮点型数来表示时间。
构建图层及动画
和其它的轨道一样,我们为动画图层也创建一个名为PHTitleItem的数据模型。
主要用于定义动画的开始时间,持续时间,以及动画图层的内容和动画。
PHTitleItem代码如下:
import UIKit
let PH720VidieoRect = CGRect(x: 0, y: 0, width: 1280, height: 720)
class PHTitleItem: NSObject {
// 动画开始时间
var startTime:CMTime = .zero
// 动画持续时间长
var timeRange:CMTimeRange = CMTimeRange.zero
// 构建动画图层
func buildAnimationLayer() -> CALayer {
let parentLayer = CALayer()
parentLayer.frame = PH720VidieoRect
parentLayer.opacity = 0.0
var sublayerTransform = CATransform3DIdentity
sublayerTransform.m34 = -1.0/500.0
parentLayer.sublayerTransform = sublayerTransform
//添加图片图层
let imageLayer = buildImageLayer()
parentLayer.addSublayer(imageLayer)
//添加文字图层
let textLayer = buildTextLayer()
parentLayer.addSublayer(textLayer)
// 添加淡入淡出动画
let fadeAnimation = buildFadeInOutAnimation()
parentLayer.add(fadeAnimation, forKey: nil)
return parentLayer
}
// 构建图片图层
func buildImageLayer() -> CALayer {
let imageLayer = CALayer()
imageLayer.contents = UIImage(named: "cover")?.cgImage
imageLayer.contentsScale = UIScreen.main.scale
let width = 458.0
let height = 314.0
imageLayer.bounds = CGRect(x: 0.0, y: 0.0, width: width, height: height)
imageLayer.position = CGPoint(x: PH720VidieoRect.midX, y: PH720VidieoRect.midY)
imageLayer.allowsEdgeAntialiasing = true
// 添加旋转动画
let rotationAnimation = buildRotationAnimation()
imageLayer.add(rotationAnimation, forKey: nil)
return imageLayer
}
// 构建文案图层
func buildTextLayer() -> CALayer {
let textLayer = CATextLayer()
textLayer.string = "Hello, World!"
textLayer.fontSize = 40
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.alignmentMode = .center
textLayer.bounds = CGRect(x: 0.0, y: 0.0, width: 300.0, height: 100.0)
textLayer.position = CGPoint(x: PH720VidieoRect.midX, y: PH720VidieoRect.midY + 200.0)
// 添加淡入淡出动画
let fadeAnimation = buildScaleAnimation()
textLayer.add(fadeAnimation, forKey: nil)
return textLayer
}
// 构建旋转动画
func buildRotationAnimation() -> CAAnimation {
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.y")
rotationAnimation.fromValue = 0
rotationAnimation.toValue = 2 * Double.pi
rotationAnimation.beginTime = CMTimeGetSeconds(startTime)
rotationAnimation.duration = CMTimeGetSeconds(timeRange.duration)
rotationAnimation.isRemovedOnCompletion = false
return rotationAnimation
}
// 构建放大动画
func buildScaleAnimation() -> CAAnimation {
let scaleAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleAnimation.fromValue = 0.0
scaleAnimation.toValue = 1.0
scaleAnimation.beginTime = CMTimeGetSeconds(startTime)
scaleAnimation.duration = CMTimeGetSeconds(timeRange.duration)
scaleAnimation.isRemovedOnCompletion = false
return scaleAnimation
}
// 构建淡入淡出动画
func buildFadeInOutAnimation() -> CAAnimation {
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
fadeAnimation.fromValue = 0.0
fadeAnimation.toValue = 1.0
fadeAnimation.beginTime = CMTimeGetSeconds(startTime)
fadeAnimation.duration = CMTimeGetSeconds(timeRange.duration)
fadeAnimation.isRemovedOnCompletion = false
return fadeAnimation
}
}
- PHTitleItem中有两个属性startTime表示动画开始时间,timeRange表示动画持续时间。
- 构建一个parentLayer动画图层,设置它的frame值与视频尺寸相同1280*720,并为父图层设置渐隐动画和sublayerTransform属性。
- 添加图片图层并为图片图层添加旋转动画。
- 添加文字图层并为文字图层添加放大动画。
准备组合-播放
创建一个PHOverlyCompositionBuilder类,遵循PHCompositionBuilder协议,其内容与上几篇博客中其它遵循该协议的类类似,只是多了一个构建动画图层。
class PHOverlayCompositionBuilder: NSObject,PHCompositionBuilder {
/// 时间线
var timeLine:PHTimeLine!
/// composition
private var composition = AVMutableComposition()
init(timeLine: PHTimeLine!) {
self.timeLine = timeLine
}
func buildComposition() -> PHComposition? {
// 添加视频轨道
let _ = addCompositionTrack(mediaType: .video, mediaItems: timeLine.videoItmes)
// 添加音频轨道
let _ = addCompositionTrack(mediaType: .audio, mediaItems: timeLine.audioItems)
// 创建AVVideoComposition
let videoComposition = buildVideoComposition()
// 添加背景音乐
var audioMix:AVAudioMix? = nil
if timeLine.musicItem != nil {
let musicCompositionTrack = addCompositionTrack(mediaType: .audio, mediaItems: [timeLine.musicItem!])
let musicAudioMix = buildAudioMixWithTrack(track: musicCompositionTrack)
audioMix = musicAudioMix
}
// 添加动画图层
let overLayer = buildOverLayer()
return PHOverlayComposition(compostion: composition, audioMix: audioMix,videoComposition: videoComposition, overlayLayer: overLayer)
}
/// 私有方法-添加媒体资源轨道
/// - Parameters:
/// - mediaType: 媒体类型
/// - mediaItems: 媒体媒体资源数组
/// - Returns: 返回一个AVCompositionTrack
private func addCompositionTrack(mediaType:AVMediaType,mediaItems:[PHMediaItem]?) -> AVMutableCompositionTrack? {
if PHIsEmpty(array: mediaItems) {
return nil
}
let trackID = kCMPersistentTrackID_Invalid
guard let compositionTrack = composition.addMutableTrack(withMediaType: mediaType, preferredTrackID: trackID) else { return nil }
//设置起始时间
var cursorTime = CMTime.zero
guard let mediaItems = mediaItems else { return nil }
for item in mediaItems {
//这里默认时间都是从0开始
guard let asset = item.asset else { continue }
guard let assetTrack = asset.tracks(withMediaType: mediaType).first else { continue }
do {
try compositionTrack.insertTimeRange(item.timeRange, of: assetTrack, at: cursorTime)
} catch {
print("addCompositionTrack error")
}
cursorTime = CMTimeAdd(cursorTime, item.timeRange.duration)
}
return compositionTrack
}
/// 创建音频混合器
/// - Parameters:
/// - musicTrack: 音乐轨道
func buildAudioMixWithTrack(track:AVMutableCompositionTrack?) -> AVAudioMix? {
guard let track = track else { return nil }
guard let musicItem = timeLine.musicItem else { return nil }
let audioMix = AVMutableAudioMix()
let audioMixParam = AVMutableAudioMixInputParameters(track: track)
for volumeAutomaition in musicItem.volumeAutomations {
audioMixParam.setVolumeRamp(fromStartVolume: volumeAutomaition.startVolume, toEndVolume: volumeAutomaition.endVolume, timeRange: volumeAutomaition.timeRange)
}
audioMix.inputParameters = [audioMixParam]
return audioMix
}
/// 创建AVVideoComposition
func buildVideoComposition() -> AVMutableVideoComposition? {
let videoComposition = AVMutableVideoComposition(propertiesOf: self.composition)
videoComposition.renderSize = CGSize(width: PH720VidieoRect.size.width + 1, height: PH720VidieoRect.size.height + 1)
return videoComposition
}
/// 创建动画图层
func buildOverLayer() -> CALayer? {
...
}
}
在这里我们重点说一下关于AVMutableVideoComposition renderSize属性的设置,当前视频的原尺寸是1280*720但是我们设置组合时会发现没有办法播放。
在这里我们重新设置renderSize比原来大一个像素,就可以正常播放视频。
构建动画图层的方法,我们直接获取PHTitleItem的实例构建图层。
/// 创建动画图层
func buildOverLayer() -> CALayer? {
guard let titleItem = self.timeLine.titleItem else { return nil }
let overLayer = titleItem.buildAnimationLayer()
return overLayer
}
创建一个遵循PHComposition协议名为PHOverlayComposition的类。
import UIKit
import AVFoundation
class PHOverlayComposition: NSObject,PHComposition {
// 私有变量composition
private var compostion:AVComposition?
// 音频混合
private var audioMix:AVAudioMix?
// 视频轨道
var videoComposition:AVMutableVideoComposition?
// 动画图层
private var overlayLayer:CALayer?
// synchronizedLayer
var synchronizedLayer:AVSynchronizedLayer?
// 自定义初始化
init(compostion: AVComposition? = nil, audioMix: AVAudioMix? = nil, videoComposition: AVMutableVideoComposition? = nil, overlayLayer: CALayer? = nil) {
self.compostion = compostion
self.audioMix = audioMix
self.videoComposition = videoComposition
self.overlayLayer = overlayLayer
}
func makePlayerItem() -> AVPlayerItem? {
return nil
}
func makeAssetExportSession() -> AVAssetExportSession? {
return nil
}
}
- 本次相对之前新增的内容为两个CALayer的子类图层,overlayLayer为动画图层,synchronizedLayer则是从AVPlayerItem获取的用于同步动画时间的图层。
首先来看下makePlayerItem的实现:
func makePlayerItem() -> AVPlayerItem? {
let playerItem = AVPlayerItem(asset: compostion!.copy() as! AVAsset)
playerItem.audioMix = audioMix
playerItem.videoComposition = videoComposition
if let overlayLayer = overlayLayer {
synchronizedLayer = AVSynchronizedLayer(playerItem: playerItem)
synchronizedLayer?.addSublayer(overlayLayer)
}
return playerItem
}
从playerItem中获取synchronizedLayer并将动画图层添加到synchronizedLayer上面。
但此时播放视频资源并不会看见任何效果,我们需要手动将synchronizedLayer添加到播放器的图层关系当中。
为此我们给PHEditorView添加一个添加动画图层的回调,回调到ViewController中,并在回调中将synchronizedLayer传递过来,添加到播放器的视图中,代码如下:
添加回调:
//MARK: 添加编辑视图
func addEditorView() {
let editorView = PHEditorView(frame: CGRect(x: 0.0, y: self.view.bounds.height * 0.5, width: self.view.bounds.size.width, height: self.view.bounds.height * 0.5));
editorView.delegate = playerController
self.view.addSubview(editorView)
playerController.delegate = editorView
editorView.addTitleItemBlock = { [weak self] synchronizedLayer in
guard let `self` = self else { return }
self.addSynchronizedLayer(synchLayer: synchronizedLayer)
}
}
将图层添加到视频播放器:
func addSynchronizedLayer(synchLayer:AVSynchronizedLayer) {
if let animationView = animationView {
animationView.removeFromSuperview()
self.animationView = nil
}
animationView = UIView()
animationView?.backgroundColor = UIColor.blue.withAlphaComponent(0.5)
guard let animationView = animationView else { return }
synchLayer.bounds = PH720VidieoRect
animationView.layer.addSublayer(synchLayer)
guard let playerView = self.playerController.view else { return }
let scale = fminf(Float(playerView.bounds.width / PH720VidieoRect.width), Float(playerView.bounds.height / PH720VidieoRect.height))
let videoRect = AVMakeRect(aspectRatio: PH720VidieoRect.size, insideRect: playerView.bounds)
animationView.center = CGPoint(x: videoRect.midX, y: videoRect.midY)
animationView.transform = CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale))
playerView.addSubview(animationView)
}
准备组合-导出
关于导出我们需要借助AVVideoCompositionCoreAnimationTool来实现,代码如下:
func makeAssetExportSession() -> AVAssetExportSession? {
if let overlayLayer = overlayLayer {
let animationLayer = CALayer()
animationLayer.frame = PH720VidieoRect
let videoLayer = CALayer()
videoLayer.frame = PH720VidieoRect
animationLayer.addSublayer(videoLayer)
animationLayer.addSublayer(overlayLayer)
animationLayer.isGeometryFlipped = true
let animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer, in: animationLayer)
self.videoComposition?.animationTool = animationTool
}
let exportSession = AVAssetExportSession(asset: compostion!.copy() as! AVAsset, presetName: AVAssetExportPresetHighestQuality)
exportSession?.videoComposition = videoComposition
exportSession?.audioMix = audioMix
return exportSession
}
执行导出:
/// 开始导出
public func beginExport() {
self.exportSession = self.composition.makeAssetExportSession()
guard let exportSession = exportSession else { return }
exportSession.outputURL = exportURL()
exportSession.outputFileType = .mp4
exportSession.exportAsynchronously {[weak self] in
DispatchQueue.main.async {
guard let self = self else { return }
guard let exportSession = self.exportSession else { return }
let status = exportSession.status
if status == .completed {
//成功导出,存储到相册
self.writeExportedVideoToPhotoLibrary()
print("导出成功")
} else if status == .failed {
//导出失败
if let error = exportSession.error {
print("导出失败 error:\(error)")
}
} else if status == .cancelled {
//导出取消
print("导出取消")
}
}
}
exporting = true
monitorExportProgress()
}
/// 监听视频导出进度
@objc func monitorExportProgress() {
guard let exportSession = exportSession else { return }
let status = exportSession.status
if status == .exporting {
self.progress = Double(exportSession.progress)
print("导出进度:\(self.progress)")
self.afterTask = PHAfterTask(after: 0.1, target: self, selector: #selector(monitorExportProgress))
} else {
print("导出结束")
exporting = false
}
}
有两件值得注意的事情:
1.保存的相册之前需要申请权限。
2.设置animationTool之后导出视频需要使用真机,模拟器会崩溃哦,这个问题反复耽误了一天,希望大家能引起重视。
结语
在本文中,我们深入探讨了在iOS应用中使用AVFoundation框架进行视频合成,并添加动画图层的方法。我们学习了如何创建一个AVMutableComposition来组合多个音频和视频轨道,以及如何使用AVMutableVideoComposition添加动画效果到视频中。我们还了解了AVAssetExportSession的使用方法,将合成的视频导出到文件中。通过这些技术,我们可以创建出具有丰富动画效果的视频内容,为我们的应用增添更多互动性和吸引力。
当然,在实际应用中,我们可能会遇到各种各样的挑战和问题。因此,对于每个项目,我们都需要耐心调试和优化代码,确保最终的视频合成效果符合我们的预期。同时,不断学习和探索新的技术和工具,也是我们成长的重要途径。
希望本文能够为你提供一些有价值的信息,并且在你的iOS开发之旅中,能够帮助你更好地应对视频合成和动画图层的挑战。愿你的应用开发之路充满乐趣和成就!

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。
更多推荐
所有评论(0)