IOS音视频:视频编辑

原创:知识点总结性文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、AVFoundation 提供视频编辑接口
    • 1、AVFoundation 中的视频数据源
    • 2、AVFoundation 中的视频图像处理
    • 3、AVFoundation 中的音频处理
    • 4、视频合成的驱动者们
  • 二、使用 AVFoundation 进行视频编辑Demo演示
    • 1、音视频播放功能
    • 2、音乐播放器
    • 3、视频合成
    • 4、音视频转场特效
  • 三、Cabbage 框架去除繁杂
    • 1、去除繁杂
    • 2、新的视频编辑结构
    • 3、使用方式
    • 4、接口实现:用一种更简单的方式理解视频编辑
    • 5、内部核心实现
  • 四、Cabbage 框架的Demo演示
    • 1、仅仅播放视频
    • 2、在视频上添加图片
    • 3、实现视频转场效果
    • 4、将视频进行缩放
    • 5、四个视频同屏顺序播放
    • 6、逆向播放视频
    • 7、两个视频同屏同时播放
  • Demo
  • 参考文献

一、AVFoundation 提供视频编辑接口

苹果的 AVFoundation 已经提供了一套视频编辑的 API,先来看看这套 API 的主要结构,以及实现视频编辑功能需要怎样的实现流程。

1、AVFoundation 中的视频数据源

AVFoundation 中,视频和音频数据可以用 AVAsset 表示,AVAsset 里面包含了 AVAssetTrack 数据,比如:一个视频文件里面包含了一个视频 track 和两个音频 track。可以使用 AVCompositiontrack 进行裁剪和变速等操作,也可以把多段 track 拼接到 AVComposition 里面。

在处理完 track 的拼接和修改后,得到最终的 AVComposition,它是 AVAsset 的子类,也就是说可以把它传递到 AVPlayerAVAssetImageGeneratorAVExportSessionAVAssetReader 里面作为数据源,把 AVComposition 当成是一个视频数据进行处理。

新建拼接视频片段示例代码
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)
}

2、AVFoundation 中的视频图像处理

AVFoundation 提供了 AVVideoComposition 对象和 AVVideoCompositing 协议用于处理视频的画面帧。

AVVideoComposition 结构图
a、AVVideoComposition

AVVideoComposition 可以用于设置帧率、画布大小、指定不同的 video track 应用何种编辑操作以及可以将视频画面嵌套在 CALayer 中。指定 video track 的编辑操作,通过设置 AVVideoComposition 里的 instructions 属性实现,它是一个 AVVideoCompositionInstruction 协议数组,AVVideoCompositionInstruction 内定义了处理的时间范围、需要处理的 track ID 有哪些等。

AVVideoCompositionInstruction 在时间轴中的组成

将视频画面嵌套在 CALayer 内可以通过设置 AVVideoCompositionCoreAnimationToolAVVideoCompositionAVVideoCompositionCoreAnimationTool 有两种使用场景,一是可以添加一个 CALayer 做一个独立的 track 渲染到视频画面上。二是可以设置一个 parentLayer,然后把视频 layer 放置在这个 parentLayer 上,并且还可以放入其它的 layer

CALayer 的支持,可以把 CALayer 所支持的所有 CoreAnimation 动画带入到视频画面中。比如使用 Lottie,设计师在 AE 中导出的动画配置,客户端用配置生成 CALayer 类,添加到 AVVideoCompositionCoreAnimationTool 中就可以很方便的实现视频中做贴纸动画的功能。

AVVideoCompositionCoreAnimationTool 设置的 layer 层级
b、AVVideoCompositing

上面说的 AVVideoComposition 提供了视频渲染时时间轴相关的配置,而 AVVideoCompositing 这个协议可以接管视频画面的渲染。实现了AVVideoCompositing 协议的类中,AVComposition 处理到某一时间点的视频时,会向AVVideoCompositing 发起请求,AVVideoCompositing 内根据请求包含的视频帧、时间信息、画布大小信息等,
根据具体的业务逻辑进行处理,最后将处理后的视频数据返回。


3、AVFoundation 中的音频处理

AVFoundation 提供了 AVAudioMix 用于处理音频数据。 AVAudioMix 这个类很简单,只有一个 inputParameters 属性,它是一个 AVAudioMixInputParameters 数组。具体的音频处理都在 AVAudioMixInputParameters 里进行配置。

不同于 AVVideoCompositioninstructionsAVVideoCompositionInstruction 可以传入多个 trackID 方便之后多个视频画面进行合成。AVAudioMixInputParameters 只能绑定单个 AVAssetTrack 的音频数据,估计是因为音频波形数据和视频像素数据的差异,不适合做类似音频波形叠加。AVAudioMixInputParameters 内可以设置音量,支持分段设置音量,以及设置两个时间点的音量变化,比如 0 - 1 秒,音量大小从 0 - 1.0 线性递增。AVAudioMixInputParameters 内还有个 audioTapProcessor 属性,他是一个 MTAudioProcessingTap 类。这个属性提供了接口用于实时处理音频数据。


4、视频合成的驱动者们

上面提到了用 AVComposition 将数据源裁剪和拼接成最终的数据, AVVideoComposition 设置图像编辑逻辑,AVAudioMix 设置音频编辑逻辑。 当我们根据具体需求配置好这些对象后,AVFoundation 提供了 4 种场景使用它们。

AVFoundation 中支持 AVVideoComposition 和 AVAudioMix 的类
a、场景 1:视频播放 - AVPlayerItem

放入 AVPlayerItem 中,可用于视频播放。AVPlayerItem 的时间轴驱动视频数据的获取。

let composition: AVComposition = ...
let videoComposition: AVVideoComposition = ...
let audioMix: AVAduioMix = ...
let playerItem = AVPlayerItem(asset: composition)
playerItem.videoComposition = videoComposition
playerItem.audioMix = audioMix
b、场景 2:获取截图 - AVAssetImageGenerator

AVAssetImageGenerator 也是时间驱动,用于获取某个特定时间的视频截图。

let composition: AVComposition = ...
let videoComposition: AVVideoComposition = ...
let imageGenerator = AVAssetImageGenerator(asset: composition)
imageGenerator.videoComposition = videoComposition
c、场景 3:视频帧读取 - AVAssetReaderVideoCompositionOutput / AVAssetReaderAudioMixOutput
// 只能逐帧访问
let readerVideoOutput: AVAssetReaderVideoCompositionOutput = ...
readerVideoOutput.videoComposition = ...

// 只能逐帧访问
let readerAudioOutput: AVAssetReaderAudioMixOutput = ...
readerAduioOutput.audioMix = ...
d、场景 4:导出 - AVAssetExportSession

AVAssetExportSession 用于导出视频,内部也是逐帧访问,实际上是封装了 AVAssetReaderVideoCompositionOutputAVAssetReaderAudioMixOutput

let exportSession = AVAssetExportSession(asset: composition, presetName: "name")
exportSession.videoComposition = ...
exportSession.audioMix = ...
e、总结以上的场景

它们使用的核心数据、编辑配置都是一样的接口,可以把它们当做视频合成的不同驱动方式。 AVPlayerItem 需要实时性,所以会引入丢帧、跳帧等策略,AVAssetImageGenerator 不需要丢帧,但也可以进行跳帧操作。 AVAssetReaderVideoCompositionOutputAVAssetReaderAudioMixOutput 则是没有时间的概念,只能进行逐帧遍历操作,而 AVAssetExportSession 其实就是封装了 AVAssetReaderVideoCompositionOutputAVAssetReaderAudioMixOutput 并支持了写入本地文件的功能。


二、使用 AVFoundation 进行视频编辑Demo演示

1、音视频播放功能

a、播放视频
let ibaotuVideoUrl: URL = URL(string: "https://video-qn.ibaotu.com/00/98/99/98z888piCTeW.mp4")!

// 点击播放视频
@objc func didClickPlayButton()
{
    let player = AVPlayer(url: ibaotuVideoUrl);
    
    let controller = AVPlayerViewController();
    controller.player = player;
    present(controller, animated: true)
    {
        player.play();
    }
}
b、播放音频
self.audioPlayer = try! AVAudioPlayer.init(contentsOf: soundFileUrl)
self.audioPlayer?.prepareToPlay()

// 点击播放音频
@objc func didClickPlayAudioButton()
{
    if self.audioPlayer?.isPlaying == false
    {
        self.audioPlayer?.play()
    }
    else
    {
        print("正在播放,准备暂停");
        self.audioPlayer?.pause()
    }
}
c、加载音视频资源
let bossSoundFileUrl = Bundle.main.url(forResource: "bossSound.mp3", withExtension: nil)!

@objc func didClickAssetLoad()
{
    // 设置移动蜂窝网络下不会读取资源,只有在WiFi网络下才会加载资源
    let options = [AVURLAssetAllowsCellularAccessKey: false]
    //let asset = AVAsset(url: bossSoundFileUrl)
    let asset = AVURLAsset(url: bossSoundFileUrl, options: options)
    
    // 异步加载资源
    let playableKey = "metadata"
    asset.loadValuesAsynchronously(forKeys: [playableKey])
    {
        var error: NSError? = nil
        let status = asset.statusOfValue(forKey: playableKey, error: &error)
        switch status
        {
        case .loading:
            print("正在加载资源...")
        case .loaded:
            print("加载资源成功... \(asset.metadata)")
                ...
            }
        case .failed:
            print("加载资源失败...")
        case .cancelled:
            print("取消加载资源...")
        default: break
        }
    }
}
获取到元数据以后,需要读取元数据的值
for format in asset.availableMetadataFormats
{
    // 获取到元数据以后,需要读取元数据的值
    let metadata = asset.metadata(forFormat: format)
    
    // 标题ID
    let titleID = AVMetadataIdentifier.commonIdentifierTitle
    
    // 获取标题
    let titleItems = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: titleID)
    
    if let item = titleItems.first
    {
        print("标题:\n",item.commonKey!,item.identifier!,item.stringValue!)
    }
    
    // 封面
    ...
}
获取封面
let artworkItems = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: AVMetadataIdentifier.commonIdentifierArtwork)

DispatchQueue.main.async
{
    if let artworkItem = artworkItems.first
    {
        if let imageData = artworkItem.dataValue
        {
            let image = UIImage(data: imageData)
            self.imageView.image = image
        }
        else
        {
            print("哈哈")
        }
    }
}
输出结果
加载资源成功... [<AVMetadataItem...}, value class=__NSCFString, value=肖央/黄明志>]
标题:AVMetadataKey(_rawValue: title) AVMetadataIdentifier(_rawValue: id3/TIT2) 不想上班

2、音乐播放器

播放完毕!
当前的音频文件是否播放完毕:true
a、配置音频播放器
fileprivate func setupPlayer()
{
    ...
}
❶ 初始化播放器
playerItem = AVPlayerItem(url: fileUrl!)
player = AVPlayer(playerItem: playerItem!)
❷ 设置进度条相关属性
let duration: CMTime = playerItem!.asset.duration
let seconds: Float64 = CMTimeGetSeconds(duration)
pregressSlider!.minimumValue = 0
pregressSlider!.maximumValue = Float(seconds)
pregressSlider!.isContinuous = false
❸ 播放过程中动态改变进度条值和时间标签
player!.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1, preferredTimescale: 1), queue: DispatchQueue.main)
{ (CMTime) -> Void in
    if self.player!.currentItem?.status == .readyToPlay && self.player?.rate != 0
    {
        ...
    }
}
❹ 更新进度条进度值
let currentTime = CMTimeGetSeconds(self.player!.currentTime())
self.pregressSlider!.value = Float(currentTime)
❺ 一个小算法:用来实现00:00这种格式的播放时间
let all:Int = Int(currentTime)
let m:Int = all % 60
let f:Int = Int(all/60)

var time: String = ""
if f < 10
{
    time = "0\(f):"
}
else
{
    time = "\(f)"
}

if m < 10
{
    time += "0\(m)"
}
else
{
    time += "\(m)"
}
// 更新播放时间
self.timeLabel!.text = time
❻ 设置后台播放显示信息为正在播放
self.setInfoCenterCredentials(playbackState: 1)

b、控制音频播放
❶ 点击播放音频
@objc func didClickPlayButton()
{
    // 根据rate属性判断当前是否在播放
    if player?.rate == 0
    {
        player!.play()
        playerButton.setTitle("暂停", for: .normal)
    }
    else
    {
        player!.pause()
        playerButton.setTitle("播放", for: .normal)
        
        // 设置后台播放显示信息为停止
        setInfoCenterCredentials(playbackState: 0)
    }
}
❷ 用户通过拖动进度条控制播放器进度
@objc func playbackSliderValueChanged()
{
    let seconds: Int64 = Int64(pregressSlider.value)
    let targetTime: CMTime = CMTimeMake(value: seconds, timescale: 1)
    
    // 播放器定位到对应的位置
    player!.seek(to: targetTime)
    
    // 如果当前时暂停状态,则自动播放
    if player!.rate == 0
    {
        player?.play()
        playerButton.setTitle("暂停", for: .normal)
    }
}

c、播放完成
❶ 页面显示时添加相关通知监听
override func viewWillAppear(_ animated: Bool)
{
    // 播放完毕
    NotificationCenter.default.addObserver(self, selector: #selector(finishedPlaying),name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: playerItem)
    
    // 告诉系统接受远程响应事件,并注册成为第一响应者
    UIApplication.shared.beginReceivingRemoteControlEvents()
    self.becomeFirstResponder()
}
❷ 页面消失时取消歌曲播放结束通知监听
override func viewWillDisappear(_ animated: Bool)
{
    NotificationCenter.default.removeObserver(self)
    
    // 停止接受远程响应事件
    UIApplication.shared.endReceivingRemoteControlEvents()
    self.resignFirstResponder()
}
❸ 是否能成为第一响应对象
override var canBecomeFirstResponder: Bool
{
    return true
}
❹ 歌曲播放完毕
@objc func finishedPlaying(myNotification:NSNotification)
{
    print("播放完毕!")
    
    let stopedPlayerItem: AVPlayerItem = myNotification.object as! AVPlayerItem
    stopedPlayerItem.seek(to: CMTime.zero)
    { (status) in
        print("当前的音频文件是否播放完毕:\(status)")
    }
}

d、耳机操作
❶ 设置后台播放显示信息
func setInfoCenterCredentials(playbackState: Int)
{
    let mpic = MPNowPlayingInfoCenter.default()
    
    // 专辑封面
    let mySize = CGSize(width: 400, height: 400)
    let albumArt = MPMediaItemArtwork(boundsSize:mySize)
    { sz in
        return UIImage(named: "luckcoffee")!
    }
    
    // 获取进度
    let postion = Double(pregressSlider!.value)
    let duration = Double(pregressSlider!.maximumValue)
    
    mpic.nowPlayingInfo = [MPMediaItemPropertyTitle: "播放音频",
                           MPMediaItemPropertyArtist: "谢佳培",
                           MPMediaItemPropertyArtwork: albumArt,
                           MPNowPlayingInfoPropertyElapsedPlaybackTime: postion,
                           MPMediaItemPropertyPlaybackDuration: duration,
                           MPNowPlayingInfoPropertyPlaybackRate: playbackState]
}
❷ 耳机控制
override func remoteControlReceived(with event: UIEvent?)
{
    guard let event = event else
    {
        print("没有远程控制事件\n")
        return
    }
    
    if event.type == UIEvent.EventType.remoteControl
    {
        switch event.subtype
        {
        case .remoteControlTogglePlayPause:
            print("暂停/播放")
        case .remoteControlPreviousTrack:
            print("上一首")
        case .remoteControlNextTrack:
            print("下一首")
        case .remoteControlPlay:
            print("播放")
            player!.play()
        case .remoteControlPause:
            print("暂停")
            player!.pause()
            // 后台播放显示信息进度停止
            setInfoCenterCredentials(playbackState: 0)
        default:
            break
        }
    }
}

3、视频合成

a、点击按钮触发事件
播放视频
@objc func didClickPlayVideoButton()
{
    guard (composition != nil) else { return }

    playerItem = AVPlayerItem.init(asset: composition!)
    player = AVPlayer.init(playerItem: playerItem)
    
    playerlayer = AVPlayerLayer.init(player: player!)
    playerlayer?.frame = videoBackView.bounds
    videoBackView.layer.addSublayer(playerlayer!)
    
    player?.play()
}
合成视频
@objc func didClickVideoCompositeButton()
{
    composition = createVideoComposition()
    outputVideo(composition!)
}

b、合成视频
fileprivate func createVideoComposition() -> AVMutableComposition
{
    let logicAsset: AVAsset = AVAsset(url: logicVideoFileUrl)
    let girlAsset: AVAsset = AVAsset(url: girlVideoFileUrl)
    ...
}
❶ 用于从AVAsset创建新组合的可变对象
let composition = AVMutableComposition(urlAssetInitializationOptions: nil)
❷ 创建一个视频/音频轨道
// 创建一个视频轨道
let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)

// 创建一个音频轨道
let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
❸ 第一个视频的时长:0 ~ 3秒
let logicCursorTime = CMTime.zero
let logicDuration = logicAsset.duration
let logicVideoTimeRange = CMTimeRangeMake(start: logicCursorTime, duration: logicDuration)
❹ 第二个视频的时长:3 ~ 10秒
let girlCursorTime = CMTimeAdd(logicCursorTime, logicAsset.duration)
let girlDuration = girlAsset.duration
let girlVideoTimeRange = CMTimeRangeMake(start: girlCursorTime, duration: girlDuration)
❺ 在视频轨道中将视频插入到对应的时间范围
let logicAssetTrack = logicAsset.tracks(withMediaType: .video).first!
try! videoTrack?.insertTimeRange(logicVideoTimeRange, of: logicAssetTrack, at: logicCursorTime)
❻ 提供表示指定媒体类型的媒体的资产的AVAssetTracks数组(第一个)
let girlAssetTrack = girlAsset.tracks(withMediaType: .video).first!
try! videoTrack?.insertTimeRange(girlVideoTimeRange, of: girlAssetTrack, at: girlCursorTime)
❼ 在音频轨道中将音频插入到对应的时间范围
let logicAudioAssetTrack = logicAsset.tracks(withMediaType: .audio).first!
try! audioTrack?.insertTimeRange(logicVideoTimeRange, of: logicAudioAssetTrack, at: logicCursorTime)

let girlAudioAssetTrack = girlAsset.tracks(withMediaType: .audio).first!
try! audioTrack?.insertTimeRange(girlVideoTimeRange, of: girlAudioAssetTrack, at: girlCursorTime)

print("合成完毕")

return composition

c、输出视频
fileprivate func outputVideo(_ composition: AVMutableComposition)
{
    ...
}
❶ 视频输出路径
let cache = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).last
let dateString = getCurrentTime()
let filePath = cache! + "/\(dateString).mp4"
print("视频输出路径为:\(filePath)")
❷ 视频输出格式
let exporterSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality)
exporterSession?.outputFileType = AVFileType.mp4
exporterSession?.outputURL = NSURL(fileURLWithPath: filePath) as URL
exporterSession?.shouldOptimizeForNetworkUse = true
exporterSession?.exportAsynchronously(completionHandler: { () -> Void in
    switch exporterSession!.status
    {
    case .unknown:
        print("unknow")
    case .cancelled:
        print("cancelled")
    case .failed:
        print("failed")
    case .waiting:
        print("waiting")
    case .exporting:
        print("exporting")
    case .completed:
        print("completed")
    @unknown default:
        print("0000000")
    }
})

4、音视频转场特效

a、音视频转场特效
❶ 音频淡出
- (void)audioFadeOut:(UISwitch *)sender
{
    AVMutableAudioMixInputParameters *parameters = (AVMutableAudioMixInputParameters *)[self.audioMix.inputParameters firstObject];
    CMTimeRange timeRange = CMTimeRangeMake(kCMTimeZero,self.composition.duration);
    
    if (sender.isOn)
    {
        // 淡出效果:逐渐变小
        [parameters setVolumeRampFromStartVolume:1 toEndVolume:0 timeRange:timeRange];
    }
    else
    {
        [parameters setVolumeRampFromStartVolume:1 toEndVolume:1 timeRange:timeRange];
    }
    
    [self setupPlayer];
}
❷ 视频淡出
- (void)videoFadeOut:(UISwitch *)sender
{
    AVMutableVideoCompositionInstruction *compositionInstruction = (AVMutableVideoCompositionInstruction *)[self.videoComposition.instructions firstObject];
    AVMutableVideoCompositionLayerInstruction *compositionLayerInstruction = (AVMutableVideoCompositionLayerInstruction *)[compositionInstruction.layerInstructions firstObject];
    CMTimeRange timeRange = CMTimeRangeMake(CMTimeMakeWithSeconds(CMTimeGetSeconds(self.composition.duration)/2, 600), self.composition.duration);
    
    if (sender.isOn)
    {
        [compositionLayerInstruction setOpacityRampFromStartOpacity:1 toEndOpacity:0 timeRange:timeRange];
    }
    else
    {
        [compositionLayerInstruction setOpacityRampFromStartOpacity:1 toEndOpacity:1 timeRange:timeRange];
    }
    
    [self setupPlayer];
}
❸ 视频滑出
- (void)videoTransform:(UISwitch *)sender
{
    AVMutableVideoCompositionInstruction *compositionInstruction = (AVMutableVideoCompositionInstruction *)[self.videoComposition.instructions firstObject];
    AVMutableVideoCompositionLayerInstruction *compositionLayerInstruction = (AVMutableVideoCompositionLayerInstruction *)[compositionInstruction.layerInstructions firstObject];
    CMTimeRange timeRange = CMTimeRangeMake(CMTimeMakeWithSeconds(CMTimeGetSeconds(self.composition.duration)/2, 600), self.composition.duration);
    
    AVMutableCompositionTrack *videoTrack = [[self.composition tracksWithMediaType:AVMediaTypeVideo] firstObject];
    CGAffineTransform currentTransform = videoTrack.preferredTransform;
    CGAffineTransform newTransform  = CGAffineTransformTranslate(currentTransform, 0, videoTrack.naturalSize.height);
    
    if (sender.isOn)
    {
        [compositionLayerInstruction setTransformRampFromStartTransform:currentTransform toEndTransform:newTransform timeRange:timeRange];
    }
    else
    {
        [compositionLayerInstruction setTransformRampFromStartTransform:currentTransform toEndTransform:currentTransform timeRange:timeRange];
    }
    
    [self setupPlayer];
}

b、因为要将导出的视频保存到相册,所以需要用户授权
- (void)requestPhotoLibraryAuthorization
{
    if ([PHPhotoLibrary authorizationStatus] != PHAuthorizationStatusAuthorized)
    {
        self.exportButton.enabled = NO;
        
        [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status)
        {
            dispatch_async(dispatch_get_main_queue(), ^{
                if (status == PHAuthorizationStatusAuthorized)
                {
                    self.exportButton.enabled = YES;
                }
                else
                {
                    [self showAlertWithMessage:@"请允许app访问您的照片,否则无法使用视频导出功能"];
                }
            });
        }];
    }
}

c、音视频合成
- (void)setupComposition
{
    ...
}
❶ 获取音、视频资源(AVAssetTrack)
NSURL *video1Url = [[NSBundle mainBundle] URLForResource:@"Girl" withExtension:@"mp4"];
NSURL *video2Url = [[NSBundle mainBundle] URLForResource:@"Logic" withExtension:@"mp4"];
NSURL *audioUrl = [[NSBundle mainBundle] URLForResource:@"audio" withExtension:@"mp3"];

AVURLAsset *video1Asset = [AVURLAsset assetWithURL:video1Url];
AVURLAsset *video2Asset = [AVURLAsset assetWithURL:video2Url];
AVURLAsset *audioAsset = [AVURLAsset assetWithURL:audioUrl];

AVAssetTrack *video1Track = [[video1Asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
AVAssetTrack *video2Track = [[video2Asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
AVAssetTrack *audioTrack = [[audioAsset tracksWithMediaType:AVMediaTypeAudio] firstObject];

NSAssert(video1Track && video2Track && audioTrack, @"无法读取视频或音频材料");
❷ 初始化AVMutableComposition,并创建两条空轨道AVMutableCompositionTrack,一条是video类型,另一条是audio类型
self.composition = [AVMutableComposition composition];
AVMutableCompositionTrack *videoCompositionTrack = [self.composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
AVMutableCompositionTrack *audioCompositionTrack = [self.composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
❸ 往视频轨道插入视频资源
Float64 videoCutTime = 3;
CMTimeRange videoCutRange = CMTimeRangeMake(kCMTimeZero, CMTimeMakeWithSeconds(videoCutTime, 600));
[videoCompositionTrack insertTimeRange:videoCutRange ofTrack:video1Track atTime:kCMTimeZero error:nil];
[videoCompositionTrack insertTimeRange:videoCutRange ofTrack:video2Track atTime:CMTimeMakeWithSeconds(videoCutTime, 600) error:nil];
❹ 往音频轨道插入音频资源
CMTimeRange audioCutRange = CMTimeRangeMake(kCMTimeZero, CMTimeMakeWithSeconds(videoCutTime * 2, 600));
[audioCompositionTrack insertTimeRange:audioCutRange ofTrack:audioTrack atTime:kCMTimeZero error:nil];

d、设置播放器

这个播放方法有问题,导致不能正常在展示时播放。各位前辈如果修复好了可以发给我一份哈,谢谢 🙂

- (void)setupPlayer
{
    ...
    self.playButton.enabled = YES;
}
❶ 将 composition 放入 AVPlayerItem 中,可用于视频播放
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:self.composition];
item.audioMix = self.audioMix;
item.videoComposition = self.videoComposition;
❷ 如果播放器已经存在则替换正在播放的资源
if (self.player)
{
    // 如果播放器已经存在则替换正在播放的资源
    [self.player pause];
    [self.player replaceCurrentItemWithPlayerItem:item];
    
}
❸ 否则创建新的播放器并开始播放
else
{
    self.player = [[AVPlayer alloc] initWithPlayerItem:item];
    self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
    self.playerLayer.frame = self.playerView.bounds;
    [self.playerView.layer addSublayer:self.playerLayer];
    [self.player play];
    
    ...
}
❹ 播放完成后回到最初的位置
[[NSNotificationCenter defaultCenter] addObserverForName:AVPlayerItemDidPlayToEndTimeNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {
    
    [self.player seekToTime:kCMTimeZero];
    self.playButton.enabled = YES;
}];

e、导出视频
- (void)exportVideo
{
    ...
}
❶ 创建导出音视频会话
AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:self.composition presetName:AVAssetExportPresetHighestQuality];
❷ 判断导出的文件是否已经存在,存在则移除旧文件
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *videoUrl = [[fileManager URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:YES error:nil] URLByAppendingPathComponent:@"exportVideo.mp4"];
if ([fileManager fileExistsAtPath:videoUrl.path])
{
    [fileManager removeItemAtURL:videoUrl error:nil];
}
❸ 为导出视频添加水印记号
@property (strong,nonatomic) CALayer *waterMark;

if (self.waterMark)
{
    CGSize videoSize = self.videoComposition.renderSize;
    CALayer *waterMark = [self getWaterMarkWithSource:self.waterMark videoSize:videoSize playerViewSize:self.playerView.frame.size];
    CALayer *parentLayer = [CALayer layer];
    CALayer *videoLayer = [CALayer layer];
    parentLayer.frame = CGRectMake(0, 0, videoSize.width, videoSize.height);
    videoLayer.frame = CGRectMake(0, 0, videoSize.width, videoSize.height);
    [parentLayer addSublayer:videoLayer];
    [parentLayer addSublayer:waterMark];
    self.videoComposition.animationTool = [AVVideoCompositionCoreAnimationTool videoCompositionCoreAnimationToolWithPostProcessingAsVideoLayer:videoLayer inLayer:parentLayer];
}
❹ 配置导出视频的格式
exportSession.outputURL = videoUrl;
exportSession.outputFileType = AVFileTypeMPEG4;
exportSession.audioMix = self.audioMix;
exportSession.videoComposition = self.videoComposition;
❺ 开启子线程异步导出
[exportSession exportAsynchronouslyWithCompletionHandler:^{
    dispatch_async(dispatch_get_main_queue(), ^{
        
        NSString *message = nil;
        if (exportSession.status == AVAssetExportSessionStatusCompleted)
        {
            message = @"导出成功";
            
            // 存储视频
            [self saveVideoWithUrl:videoUrl];
        }
        else
        {
            message = @"导出失败";
        }
        [self showAlertWithMessage:message];
    });
}];

f、保存导出视频到相册
- (void)saveVideoWithUrl:(NSURL *)url
{
    [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
        
        [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:url];
        
    } completionHandler:^(BOOL success, NSError * _Nullable error) {
        
        if (success)
        {
            NSLog(@"视频保存成功");
        }
        else
        {
            NSLog(@"视频保存失败");
        }
    }];
}

g、为导出视频添加水印
- (CALayer *)getWaterMarkWithSource:(CALayer *)sourceWaterMark videoSize:(CGSize)videoSize playerViewSize:(CGSize)videoViewSize
{
    CGFloat scale = videoSize.width / videoViewSize.width;
    CGRect sourceFrame = sourceWaterMark.frame;
    CGFloat width = sourceFrame.size.width * scale;
    CGFloat height = sourceFrame.size.height * scale;
    CGFloat x = sourceFrame.origin.x * scale;
    CGFloat y = (videoViewSize.height - sourceFrame.size.height - sourceFrame.origin.y) * scale;
    
    CALayer *waterMark = [CALayer layer];
    waterMark.backgroundColor = sourceWaterMark.backgroundColor;
    waterMark.frame = CGRectMake(x, y, width, height);
    return waterMark;
}

h、懒加载
audioMix
- (AVMutableAudioMix *)audioMix
{
    if (!_audioMix)
    {
        AVMutableCompositionTrack *audioTrack = [[self.composition tracksWithMediaType:AVMediaTypeAudio] firstObject];
        AVMutableAudioMixInputParameters *parameters = [AVMutableAudioMixInputParameters audioMixInputParametersWithTrack:audioTrack];
        _audioMix = [AVMutableAudioMix audioMix];
        _audioMix.inputParameters = @[parameters];
    }
    
    return _audioMix;
}
videoComposition
- (AVMutableVideoComposition *)videoComposition
{
    if (!_videoComposition)
    {
        AVMutableCompositionTrack *videoTrack = [[self.composition tracksWithMediaType:AVMediaTypeVideo] firstObject];
        AVMutableVideoCompositionLayerInstruction *compositionLayerInstruction = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:videoTrack];
        
        // 时间范围信息
        AVMutableVideoCompositionInstruction *compositionInstruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
        compositionInstruction.timeRange = CMTimeRangeMake(kCMTimeZero, self.composition.duration);
        compositionInstruction.layerInstructions = @[compositionLayerInstruction];
        
        _videoComposition = [AVMutableVideoComposition videoComposition];
        _videoComposition.instructions = @[compositionInstruction];
        _videoComposition.renderSize = videoTrack.naturalSize;
        _videoComposition.frameDuration = CMTimeMake(1, 30);
    }
    
    return _videoComposition;
}

三、Cabbage 框架去除繁杂

1、去除繁杂

AVFoundation 提供了一整套功能强大的视频编辑 API,不过落地到具体的实现上,需要写很多代码,并且这一堆代码和业务逻辑关系不大。使用 AVFoundation 的 API 完整编写一个视频编辑逻辑会涉及到以下流程。这 8 个流程中,其中「根据用户数据创建视频图像处理对象」和「根据用户数据创建音频处理对象」都是比较复杂的逻辑实现,需要编写大量处理逻辑。并且使用 AVFoundation 的 API 没有办法支持使用图片或者其它自定义的非视频数据源作为视频合成的片段。AVFoundation 对前后两个片段的转场效果实现也不容易。

  1. 获取音视频数据
  2. 需要找地方记录用户对音视频数据的修改
  3. 对音视频数据进行裁剪和拼接设置
  4. 记录用户对视频画面的修改,比如:加了滤镜、视频修改画面大小等
  5. 根据用户数据创建视频图像处理对象(复杂)
  6. 记录用户对音频的修改
  7. 根据用户数据创建音频处理对象(复杂)
  8. 组合视频数据、画面处理、音频处理并生成不同的输出对象

可以看出 AVFoundation 虽然提供了一套强大的视频编辑 API,但是在使用上很麻烦,并且一些常见的基础功能没有支持。如果之后想要扩展新的编辑能力,没有一套简单通用的模式快速增加新能力的支持。基于对这些可以优化的点去思考,整理出一个新的视频编辑框架,它基于 AVFoundation 视频编辑 API ,封装那些麻烦又复杂的和业务逻辑无关的流程性代码,提供视频编辑常用的基础功能,并提供一套高度可扩展的模式接口让新的业务可以快速实现。对比之前使用 AVFoundation 做视频编辑时需要的 8 个步骤,这个新的编辑框架只需要以下步骤。

  1. 获取音视频数据,并用框架提供的对象做一些配置(支持图片和其它自定义的数据源)
  2. 创建视频编辑配置对象,并传入音视频源(如果有扩展需求,可以继承这个对象对配置进行扩展)
  3. 视频编辑配置对象传入框架的时间轴对象
  4. 使用时间轴对象生成各种场景下使用的输出对象

2、新的视频编辑结构

Cabbage 是一个基于 AVFoundation 实现的视频编辑框架,目的在于简化视频编辑的开发,让开发者容易上手实现复杂的视频编辑需求。使用这套框架开发者只需关心业务逻辑代码,无需深入底层视频编辑的实现,也不需要从头再构建一个视频编辑流程框架代码。框架在简化 API 的同时,还保留了方便扩展的接口,比如:可以提供自定义的视频资源,可以以组件的方式插入图像和音频滤镜,自定义转场效果等等。

Cabbage 视频编辑框架不仅是一个工具,它也是视频编辑功能实现的一个总结,里面包含了很多在做视频编辑器时需要用的解决方案,就算不使用 Cabbage这个框架,理解里面的代码也能在之后做视频编辑时更容易解决问题。

为了让视频编辑架构理解上更简单,扩展功能更容易扩展,使用更加方便,开发者便创建了 Cabbage 项目(Cabbage 是开发者家喵的名字),实现了一套新的视频编辑 API,基于 AVFoundationCabbage 有一个 Timeline 的概念, 它代表的是时间轴。所有的音视频资源都可以指定一个 TimeRange 然后放入 TimelineCabbage 核心类是 TimelineCompositionGenerator,开发者只要创建 Timeline,使用 Timeline 初始化 CompositionGenerator就可以用 generator 生成 AVPlayerItem / AVAssetImageGenerator / AVAssetExportSession 等各种场景下使用的对象。

let generator = CompositionGenerator(timeline: ...)

3、使用方式

a、框架能力
已有功能
  • 图片视频
  • 变速
  • 倒放
  • 贴纸
  • 转场动画
  • 关键帧动画
  • 多视频同框
扩展能力
  • 自定义音视频资源:继承 Resource 类并提供 AVAssetTrack 或继承 ImageResource 提供CIImage
  • 自定义图像滤镜:实现 VideoConfigurationProtocol 协议,并添加到 TrackItem.configuration.videoConfiguration.configurations 里。
  • 自定义音频混合:实现 AudioConfigurationProtocol 协议,并添加到 TrackItem.configuration.audioConfiguration.nodes 里。
  • 自定义视频画面转场:实现 VideoTransition 协议,TrackItem 类中可以设置转场,表示这个 TrackItem 和时间顺序的下一个 TrackItem 之间的转场。
  • 自定义音频转场:实现 AudioTransition 协议,设置方式同上。
  • 自定义贴纸:贴纸可以创建 ImageOverlayItem 并添加到 Timeline 上。ImageOverlayItem 中可以传入 ImageResource,可以复用已经实现的 ImageResource 子类。
  • 自定义关键帧动画:关键帧动画其实是一个自定义图像滤镜,实现了关键帧的类为 KeyframeVideoConfiguration,它实现了 VideoConfigurationProtocol 协议。关键帧动画的值类型是可以自定义的,只要是实现了 KeyframeValue 协议的类,都可以作为关键帧动画的插值。OpacityKeyframeValue 是一个实现了 alpha 值做关键帧动画的具体实现。

b、最简单的使用示例
❶ 创建资源
// 资源还包括:ImageResource、PHAssetImageResource、PHAssetTrackResource、AVAssetTrackResource、AVAssetReverseImageResource、AVAssetReaderImageResource。
let asset: AVAsset = ...     
let resource = AVAssetTrackResource(asset: asset)
❷ 创建资源配置对象 TrackItem instance,TrackItem 可以对音频和视频画面进行设置
let trackItem = TrackItem(resource: resource)
trackItem.configuration.videoConfiguration.baseContentMode = .aspectFill // 设置画面在画布中 aspectFill 的方式填充
❸ 放入 Timeline
let timeline = Timeline()
timeline.videoChannel = [trackItem]
timeline.audioChannel = [trackItem]
❹ 创建 CompositionGenerator,可以用于创建 AVAssetExportSession / AVAssetImageGenerator/AVPlayerItem
let compositionGenerator = CompositionGenerator(timeline: timeline)
compositionGenerator.renderSize = CGSize(width: 1920, height: 1080) // 设置画布大小
let exportSession = compositionGenerator.buildExportSession(presetName: AVAssetExportPresetMediumQuality)
let playerItem = compositionGenerator.buildPlayerItem()
let imageGenerator = compositionGenerator.buildImageGenerator()

c、贴纸
❶ 视频资源和配置
let asset: AVAsset = ...     
let resource = AVAssetTrackResource(asset: asset)
let trackItem = TrackItem(resource: resource)
❷ 放入 Timeline
let timeline = Timeline()
timeline.videoChannel = [trackItem]
timeline.audioChannel = [trackItem]
❸ 添加贴纸
timeline.passingThroughVideoCompositionProvider = {
    let imageCompositionGroupProvider = ImageCompositionGroupProvider()

    // 贴纸资源
    let url = Bundle.main.url(forResource: "overlay", withExtension: "jpg")!
    let image = CIImage(contentsOf: url)!
    let resource = ImageResource(image: image, duration: CMTime(seconds: 3, preferredTimescale: 600))

    // 贴纸配置
    let imageCompositionProvider = ImageOverlayItem(resource: resource)
    imageCompositionProvider.startTime = CMTime(seconds: 1, preferredTimescale: 600)
    let frame = CGRect(x: 100, y: 500, width: 400, height: 400)
    imageCompositionProvider.videoConfiguration.baseContentMode = .custom(frame)
    
    return imageCompositionGroupProvider
}()
❹ 创建 CompositionGenerator,可以用于创建 AVAssetExportSession / AVAssetImageGenerator/AVPlayerItem
let compositionGenerator = CompositionGenerator(timeline: timeline)
compositionGenerator.renderSize = CGSize(width: 1920, height: 1080)
let exportSession = compositionGenerator.buildExportSession(presetName: AVAssetExportPresetMediumQuality)
let playerItem = compositionGenerator.buildPlayerItem()
let imageGenerator = compositionGenerator.buildImageGenerator()

d、转场动画
let item1: TrackItem = ...
let item2: TrackItem = ...

// 为 item1 设置转场
let transitionDuration = CMTime(seconds: 2, preferredTimescale: 600)
item1.videoTransition = PushTransition(duration: transitionDuration)
item1.audioTransition = FadeInOutAudioTransition(duration: transitionDuration)

let timeline = Timeline()
timeline.videoChannel = [item1, item2]
timeline.audioChannel = [item1, item2]

try! Timeline.reloadVideoStartTime(providers: timeline.videoChannel)

let compositionGenerator = CompositionGenerator(timeline: timeline)
compositionGenerator.renderSize = CGSize(width: 1920, height: 1080)
let playerItem = compositionGenerator.buildPlayerItem()

e、关键帧动画
❶ 创建资源配置对象
let bambooTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "bamboo", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.configuration.videoConfiguration.baseContentMode = .aspectFit

    // 创建关键帧动画
    let keyframeConfiguration: KeyframeVideoConfiguration<OpacityKeyframeValue> = {
        let configuration = KeyframeVideoConfiguration<OpacityKeyframeValue>()
        
        let timeValues: [(Double, CGFloat)] = [(0.0, 0), (0.5, 1.0), (2.5, 1.0), (3.0, 0.0)]
        timeValues.forEach({ (time, value) in
            let opacityKeyframeValue = OpacityKeyframeValue()
            opacityKeyframeValue.opacity = value
            let keyframe = KeyframeVideoConfiguration.Keyframe(time: CMTime(seconds: time, preferredTimescale: 600), value: opacityKeyframeValue)
            configuration.insert(keyframe)
        })
        
        return configuration
    }()
    trackItem.configuration.videoConfiguration.configurations.append(keyframeConfiguration)

    return trackItem
}()
❷ 惯例操作
let timeline = Timeline()
timeline.videoChannel = [bambooTrackItem]
timeline.audioChannel = [bambooTrackItem]

let compositionGenerator = CompositionGenerator(timeline: timeline)
compositionGenerator.renderSize = CGSize(width: 1920, height: 1080)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

f、多视频同框
let trackItem1: TrackItem = ...
let trackItem2: TrackItem = ...

let timeline = Timeline()
timeline.videoChannel = [trackItem1]]
timeline.audioChannel = [trackItem1]

timeline.overlays = [trackItem2]
timeline.audios = [trackItem2]

let compositionGenerator = CompositionGenerator(timeline: timeline)
compositionGenerator.renderSize = renderSize
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

4、接口实现:用一种更简单的方式理解视频编辑

a、Timeline

用于往时间轴上添加数据片段,可以提供视频相关数据和音频相关数据。Timeline 类有 5 个属性。

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] = []
}
❶ passingThroughVideoCompositionProvider 是一个协议

实现这个协议可以对视频画面进行实时的处理,在时间轴的每一个时间点都会调用这个协议的回调方法,比较适合需要应用在主时间轴上的效果。

public protocol PassingThroughVideoCompositionProvider: class
{
   func applyEffect(to sourceImage: CIImage, at time: CMTime, renderSize: CGSize) -> CIImage
}
❷ videoChannel 和 audioChannel 是 Timeline 里的主轴

整个 Timeline 有多长时间,根据这里的视频或音频数据的时间长度得出。并且 videoChannelaudioChannelprovidertimeRange 会被强制按顺序排序重置。

❸ overlays 和 audios 是可以放在时间轴任意位置的图像数据和音频数据

适合的场景如:放置一个贴纸、视频到画面的某个位置。添加一个背景音乐或者录音。


b、CompositionGenerator

CompositionGenerator 其实是 TimelineAVFoundation 接口的桥接器。CompositionGenerator 用于把 Timeline 的数据合成为 AVCompositionAVVideoCompositionAVAudioMix,然后用这 3 个对象生成 AVPlayerItemAVAssetImageGeneratorAVAssetExportSession 等用于处理视频的对象。

let timeline = ...
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()

TimelineCompositionGenerator 就是公开 API 的核心了,业务开发完全可以根据自己的需求自定义 Timeline 内的数据完成需求。不过仅仅是提供了 Timeline 外部还要做不少工作,需要实现 VideoProviderAudioProvider 协议才能作为 Timeline 的数据源。其实有很多基础的视频编辑功能是比较通用的,每个做视频编辑功能的应用都会需要,于是开发者根据以往的经验以及参考了 [Videoleap] 等视频编辑工具,实现了一些基础的视频编辑功能,提供了 TrackItem 对象用于描述和处理音视频数据。


c、TrackItem

TrackItem 是一个音视频编辑的设置描述对象,类的内部实现了音频数据和视频画面的处理逻辑。它实现了 TransitionableVideoProviderTransitionableAudioProvider 协议,同时支持提供音频和视频数据。而具体的数据源是通过创建一个 Resource 的子类,并赋值给 TrackItem 完成配置。

如果有其它自定义的业务逻辑需要处理,可以继承 TrackItem 在它的处理基础上实现其它业务逻辑。如果业务逻辑和 TrackItem 完全不一样了,也可以完全自定义类,只要实现 TransitionableVideoProviderTransitionableAudioProvider 协议即可。

TrackItem 实现结构图

d、Resource

Resource 对象提供一个编辑片段的原始数据信息,它可以是一段视频、一个图片或者一段音频文件。现在内部已经实现了几个常用的 Resource。图片类型: ImageResourcePHAssetImageResource。音频和视频类型: AVAssetTrackResourcePHAssetTrackResource

Resouce 以及其子类结构图

e、Cabbage 使用示例
// 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()

5、内部核心实现

外部接口看起来简单直接,实际上在这些接口的内部封装了各种基础功能的具体实现,这些功能是视频编辑的核心。核心实现包括:时间数据结构、画面渲染分层设计、实时视频画面处理、实时音频处理、转场支持、自定义视频资源实现和渲染驱动生成器。

a、时间数据结构
❶ 在做视频编辑时,时间有多个纬度
  • 原始资源的总时长:这个数据放在 Resouceduration 属性里。
  • 原始资源选用的时间范围:这个数据放在 ResouceselectedTimeRange 属性里。
  • 原始资源选用的时间范围映射在合成时间轴上的时间范围:受到变速影响,受到转场效果影响。TrackItem.configuration.timelineTimeRange 属性是表示合成时间轴上的时间范围。

在设置了 TrackItem.configuration.speed 后都需要调用一下 TrackItem.reloadTimelineDuration() 确保 TrackItemtimeRange 是对应到合成时间轴上的时间。把 TrackItem 数组传入 TimelinevideoChannelaudioChannel 前,也需要保证 TrackItemtimeRange 是按顺序拼接的。

倍速原始素材时间对应到合成时间轴
❷ 如果支持转场效果,则当前片段的末尾和下一个片段开头之间需要有重合部分
转场效果,时间重合

b、画面渲染分层设计:让整个渲染流程变得清晰,易于理解和扩展
  1. 当进入某一个时间点时,底层渲染会先要求每个 Provider 处理完自己的画面然后返回,TrackItem 这个类就是专门用于处理每个独立 Resouce 的画面。
  2. 是否有转场效果,如果有,则把上一步得到的画面用于转场合成。
  3. 是否有人设置了 TimelinePassingThroughVideoCompositionProvider。如果有,则把上一步合成的图像传入,让 PassingThroughVideoCompositionProvider 处理。

c、实时视频画面处理

实时画面处理是通过实现 AVVideoCompositing 协议,并设置到 AVVideoComposition 中。 视频处理到某个特定时间点的时候,会向 AVVideoCompositing 发起视频合成请求,会调用 func startRequest(_ asyncVideoCompositionRequest: AVAsynchronousVideoCompositionRequest),可以从 AVAsynchronousVideoCompositionRequest 中获取这个时间点对应的视频图像数据、画布大小、时间等信息,处理完视频画面后,可以调用 AVAsynchronousVideoCompositionRequestfinish 方法结束合成。


d、实时音频处理

实时音频处理需要实现 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
}

e、转场支持:需要同一个时间显示两个视频画面,然后做各种叠加效果达到转场
  • 为了支持这一特性,会影响到主时间轴总时长和单个 track 在时间轴中的开始时间点,因为设置转场相当于时间轴上的时间变少了。
  • 需要标记转场的前后视频。这个涉及到如何设计对象描述,在转换到 VideoCompositionInstruction 的时候怎样才能用最简单的算法算出转场区间。
  • 在合成的时候要使用一种可扩展的模型实现转场叠加,因为转场可以有很多。

f、自定义视频资源实现

AVFoundation 原本就支持 Video TrackAudio Track,所以如果是音视频资源,用于合成拼接就非常简单。但如果想要把图片作为 track 呢?由于 AVFoundation 没有提供接口,所以开发者为图片类型的track 提供一个默认的黑帧视频 track,然后把图片保存在 Resource 里,在实时合成的时候再向 Resource 请求当前时间点应该返回的图像。


g、CompositionGenerator 合成器:作为 Timeline 和 AVFoundation 接口的桥接器
❶ 合成 AVComposition

Timeline 对象中的数据需要实现 VideoCompositionTrackProvider 或者 AudioCompositionTrackProvider 协议,这两个协议用于提供合成 AVComposition 的数据。

❷ 合成 AVVideoComposition

合成 AVVideoComposition 除了一些基础设置,最重要的一件事情就是根据上一步合成的 AVComposition,获取视频类型的 track,然后生成时间轴。时间轴用 VideoCompositionInstruction 数组表示,必须保证 VideoCompositionInstructiontimeRange 是连续的片段。 然后使用 VideoCompositionProvider 协议配置 VideoCompositionLayerInstruction,在视频实时渲染的时候,会调用 VideoCompositionLayerInstructionfunc applyEffect(to: , at: , renderSize:) -> CIImage 方法。

❸ 合成 AVAudioMix

合成 AVAudioMix 需要使用上一步合成的 AVComposition,取出音频类型的 track,然后对这些 track 应用 AudioMixProvider 设置的效果。


四、Cabbage 框架的Demo演示

1、仅仅播放视频

@objc func simplePlayerItem() -> AVPlayerItem?
{
    let girlTrackItem: TrackItem = {
        let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
        let resource = AVAssetTrackResource(asset: AVAsset(url: url))
        let trackItem = TrackItem(resource: resource)
        trackItem.videoConfiguration.contentMode = .aspectFit
        return trackItem
    }()
    
    let timeline = Timeline()
    timeline.videoChannel = [girlTrackItem]
    timeline.audioChannel = [girlTrackItem]
    timeline.renderSize = CGSize(width: 1920, height: 1080)
    
    let compositionGenerator = CompositionGenerator(timeline: timeline)
    let playerItem = compositionGenerator.buildPlayerItem()
    return playerItem
}

2、在视频上添加图片

func overlayPlayerItem() -> AVPlayerItem?
{
    ...
}
❶ 创建TrackItem
let girlTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    return trackItem
}()
❷ 创建Timeline
let timeline = Timeline()
timeline.videoChannel = [girlTrackItem]
timeline.audioChannel = [girlTrackItem]
timeline.renderSize = CGSize(width: 1920, height: 1080)
timeline.passingThroughVideoCompositionProvider = {
    ...
}()
❸ 创建ImageCompositionGroupProvider
let imageCompositionGroupProvider = ImageCompositionGroupProvider()

let url = Bundle.main.url(forResource: "overlay", withExtension: "jpg")!
let image = CIImage(contentsOf: url)!
let resource = ImageResource(image: image, duration: CMTime.init(seconds: 3, preferredTimescale: 600))

let imageCompositionProvider = ImageOverlayItem(resource: resource)
imageCompositionProvider.startTime = CMTime(seconds: 1, preferredTimescale: 600)
let frame = CGRect.init(x: 100, y: 500, width: 400, height: 400)
imageCompositionProvider.videoConfiguration.contentMode = .custom
imageCompositionProvider.videoConfiguration.frame = frame;
imageCompositionProvider.videoConfiguration.transform = CGAffineTransform.init(rotationAngle: CGFloat.pi / 4)

...

imageCompositionGroupProvider.imageCompositionProviders = [imageCompositionProvider]
return imageCompositionGroupProvider
❹ 创建keyframeConfiguration
let keyframeConfiguration: KeyframeVideoConfiguration<OpacityKeyframeValue> = {
    let configuration = KeyframeVideoConfiguration<OpacityKeyframeValue>()
    
    let timeValues: [(Double, CGFloat)] = [(0.0, 0), (0.5, 1.0), (2.5, 1.0), (3.0, 0.0)]
    timeValues.forEach({ (time, value) in
        let opacityKeyframeValue = OpacityKeyframeValue()
        opacityKeyframeValue.opacity = value
        let keyframe = KeyframeVideoConfiguration.Keyframe(time: CMTime(seconds: time, preferredTimescale: 600), value: opacityKeyframeValue)
        configuration.insert(keyframe)
    })
    
    return configuration
}()
imageCompositionProvider.videoConfiguration.configurations.append(keyframeConfiguration)
❺ 创建transformKeyframeConfiguration
let transformKeyframeConfiguration: KeyframeVideoConfiguration<TransformKeyframeValue> = {
    let configuration = KeyframeVideoConfiguration<TransformKeyframeValue>()

    let timeValues: [(Double, (CGFloat, CGFloat, CGPoint))] =
        [(0.0, (1.0, 0, CGPoint.zero)),
         (1.0, (1.0, CGFloat.pi, CGPoint(x: 100, y: 80))),
         (2.0, (1.0, CGFloat.pi * 2, CGPoint(x: 300, y: 240))),
         (3.0, (1.0, 0, CGPoint.zero))]
    timeValues.forEach({ (time, value) in
        let opacityKeyframeValue = TransformKeyframeValue()
        opacityKeyframeValue.scale = value.0
        opacityKeyframeValue.rotation = value.1
        opacityKeyframeValue.translation = value.2
        let keyframe = KeyframeVideoConfiguration.Keyframe(time: CMTime(seconds: time, preferredTimescale: 600), value: opacityKeyframeValue)
        configuration.insert(keyframe)
    })

    return configuration
}()
imageCompositionProvider.videoConfiguration.configurations.append(transformKeyframeConfiguration)
❻ 创建CompositionGenerator
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

3、实现视频转场效果

func transitionPlayerItem() -> AVPlayerItem?
{
    ...
}
a、创建TrackItem
❶ girlTrackItem
let girlTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    return trackItem
}()
❷ overlayTrackItem
let overlayTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "overlay", withExtension: "jpg")!
    let image = CIImage(contentsOf: url)!
    let resource = ImageResource(image: image, duration: CMTime.init(seconds: 5, preferredTimescale: 600))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    return trackItem
}()
❸ childTrackItem
let childTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "child", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    return trackItem
}()
b、创建Timeline
let transitionDuration = CMTime(seconds: 2, preferredTimescale: 600)
girlTrackItem.videoTransition = PushTransition(duration: transitionDuration)
girlTrackItem.audioTransition = FadeInOutAudioTransition(duration: transitionDuration)

overlayTrackItem.videoTransition = BoundingUpTransition(duration: transitionDuration)

let timeline = Timeline()
timeline.videoChannel = [girlTrackItem, overlayTrackItem, childTrackItem]
timeline.audioChannel = [girlTrackItem, childTrackItem]

do
{
    try Timeline.reloadVideoStartTime(providers: timeline.videoChannel)
}
catch
{
    assert(false, error.localizedDescription)
}
timeline.renderSize = CGSize(width: 1920, height: 1080)
c、创建CompositionGenerator
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

4、将视频进行缩放

func keyframePlayerItem() -> AVPlayerItem?
{
    ...
}
a、创建TrackItem
let girlTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    
        ...
        
        return configuration
    }()
    trackItem.videoConfiguration.configurations.append(transformKeyframeConfiguration)
    
    return trackItem
}()
b、创建KeyframeVideoConfiguration
let transformKeyframeConfiguration: KeyframeVideoConfiguration<TransformKeyframeValue> = {
    let configuration = KeyframeVideoConfiguration<TransformKeyframeValue>()
    
    let timeValues: [(Double, (CGFloat, CGFloat, CGPoint))] =
        [(0.0, (1.0, 0, CGPoint.zero)),
         (1.0, (1.2, CGFloat.pi / 20, CGPoint(x: 100, y: 80))),
         (2.0, (1.5, CGFloat.pi / 15, CGPoint(x: 300, y: 240))),
         (3.0, (1.0, 0, CGPoint.zero))]
    timeValues.forEach({ (time, value) in
        let opacityKeyframeValue = TransformKeyframeValue()
        opacityKeyframeValue.scale = value.0
        opacityKeyframeValue.rotation = value.1
        opacityKeyframeValue.translation = value.2
        let keyframe = KeyframeVideoConfiguration.Keyframe(time: CMTime(seconds: time, preferredTimescale: 600), value: opacityKeyframeValue)
        configuration.insert(keyframe)
    })
    
    return configuration
}()
trackItem.videoConfiguration.configurations.append(transformKeyframeConfiguration)
c、创建Timeline
let timeline = Timeline()
timeline.videoChannel = [girlTrackItem]
timeline.audioChannel = [girlTrackItem]
timeline.renderSize = CGSize(width: 1920, height: 1080)
d、创建CompositionGenerator
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

5、四个视频同屏顺序播放

a、创建TrackItem
let girlTrackItem: TrackItem = {
    let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .aspectFit
    return trackItem
}()
...
let trackItems = [girlTrackItem, childTrackItem, mydogTrackItem, pandaTrackItem]
b、创建Timeline
let timeline = Timeline()
timeline.videoChannel = trackItems
timeline.audioChannel = trackItems

try! Timeline.reloadVideoStartTime(providers: timeline.videoChannel)

let renderSize = CGSize(width: 1920, height: 1080)
c、Timeline 的 overlays
timeline.overlays = {
    let foursquareRenderSize = CGSize(width: renderSize.width / 2, height: renderSize.height / 2)
    var overlays: [VideoProvider] = []

    ...
    
    return overlays
}()
d、更新 main item's frame
func frameWithIndex(_ index: Int) -> CGRect
{
    switch index
    {
    case 0:
        return CGRect(origin: CGPoint.zero, size: foursquareRenderSize)
    case 1:
        return CGRect(origin: CGPoint(x: foursquareRenderSize.width, y: 0), size: foursquareRenderSize)
    case 2:
        return CGRect(origin: CGPoint(x: 0, y:  foursquareRenderSize.height), size: foursquareRenderSize)
    case 3:
        return CGRect(origin: CGPoint(x: foursquareRenderSize.width, y: foursquareRenderSize.height), size: foursquareRenderSize)
    default:
        break
    }
    return CGRect(origin: CGPoint.zero, size: foursquareRenderSize)
}
e、遍历trackItems
❶ enumerated
trackItems.enumerated().forEach({ (offset, mainTrackItem) in
    let frame: CGRect = {
        let index = offset % 4
        return frameWithIndex(index)
    }()
    mainTrackItem.videoConfiguration.contentMode = .aspectFit
    mainTrackItem.videoConfiguration.frame = frame
    
    ...
})
❷ fullTimeRange
let fullTimeRange: CMTimeRange = {
    var duration = CMTime.zero
    trackItems.forEach({ duration = $0.duration + duration })
    return CMTimeRange.init(start: CMTime.zero, duration: duration)
}()
❸ timeRange
let timeRanges = fullTimeRange.substruct(mainTrackItem.timeRange)
for timeRange in timeRanges
{
    Log.debug("timeRange: {\(String(format: "%.2f", timeRange.start.seconds)) - \(String(format: "%.2f", timeRange.end.seconds))}")
    if timeRange.duration.seconds > 0
    {
        let staticTrackItem = mainTrackItem.copy() as! TrackItem
        staticTrackItem.startTime = timeRange.start
        staticTrackItem.duration = timeRange.duration
        if timeRange.start <= mainTrackItem.timeRange.start {
            let start = staticTrackItem.resource.selectedTimeRange.start
            staticTrackItem.resource.selectedTimeRange = CMTimeRange(start: start, duration: CMTime(value: 1, 30))
        } else {
            let start = staticTrackItem.resource.selectedTimeRange.end - CMTime(value: 1, 30)
            staticTrackItem.resource.selectedTimeRange = CMTimeRange(start: start, duration: CMTime(value: 1, 30))
        }
        overlays.append(staticTrackItem)
    }
}
f、创建CompositionGenerator
timeline.renderSize = renderSize;
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

6、逆向播放视频

func reversePlayerItem() -> AVPlayerItem?
{
    let childTrackItem: TrackItem = {
        let url = Bundle.main.url(forResource: "child", withExtension: "mp4")!
        let resource = AVAssetReverseImageResource(asset: AVAsset(url: url))
        let trackItem = TrackItem(resource: resource)
        trackItem.videoConfiguration.contentMode = .aspectFit
        return trackItem
    }()
    
    let timeline = Timeline()
    timeline.videoChannel = [childTrackItem]
    timeline.renderSize = CGSize(width: 1920, height: 1080)
    
    let compositionGenerator = CompositionGenerator(timeline: timeline)
    let playerItem = compositionGenerator.buildPlayerItem()
    return playerItem
}

7、两个视频同屏同时播放

func twoVideoPlayerItem() -> AVPlayerItem?
{
    ...
}
a、创建 girlTrackItem
let girlTrackItem: TrackItem = {
    let width = renderSize.width / 2
    let height = width * (9/16)
    let url = Bundle.main.url(forResource: "girl", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    resource.selectedTimeRange = CMTimeRange.init(start: CMTime.zero, end: CMTime.init(value: 1800, 600))
    let trackItem = TrackItem(resource: resource)
    trackItem.videoConfiguration.contentMode = .custom
    trackItem.videoConfiguration.frame = CGRect(x: 0, y: (renderSize.height - height) / 2, width: width, height: height)
    return trackItem
}()
b、创建 childTrackItem
let childTrackItem: TrackItem = {
    let height = renderSize.height
    let width = height * (9/16)
    let url = Bundle.main.url(forResource: "child", withExtension: "mp4")!
    let resource = AVAssetTrackResource(asset: AVAsset(url: url))
    resource.selectedTimeRange = CMTimeRange.init(start: CMTime.zero, end: CMTime.init(value: 1800, 600))
    let trackItem = TrackItem(resource: resource)
    trackItem.audioConfiguration.volume = 0.3
    trackItem.videoConfiguration.contentMode = .custom
    trackItem.videoConfiguration.frame = CGRect(x: renderSize.width / 2 + (renderSize.width / 2 - width) / 2, y: (renderSize.height - height) / 2, width: width, height: height)
    return trackItem
}()
c、创建 Timeline
let trackItems = [girlTrackItem]

let timeline = Timeline()
timeline.videoChannel = trackItems
timeline.audioChannel = trackItems

timeline.overlays = [childTrackItem]
timeline.audios = [childTrackItem]
timeline.renderSize = renderSize;
d、创建 CompositionGenerator
let compositionGenerator = CompositionGenerator(timeline: timeline)
let playerItem = compositionGenerator.buildPlayerItem()
return playerItem

Demo

Demo在我的Github上,欢迎下载。
Multi-MediaDemo

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容