短视频编辑中的AVFoundation框架(三)视频编辑与导出

前言

上一篇短视频编辑中的AVFoundation框架(二)素材添加与处理我们完成了短视频素材的导入,本篇正式开始使用AVFoundation进行短视频编辑,从实际的编辑功能入手介绍框架中Editing模块拥有的各项能力。

一、 视频拼接 + bgm

我们首先从最简单的功能入手:实现两(多)段视频的拼接,并为其添加一段背景音乐。

1.1 AVMutableComposition

上一篇我们提到,AVAssetWriter在进行写入时不支持预览(虽然通过AVSampleBufferDisplayLayer可以显示CMSambuffer,但这无疑增加了很多的工作量也违背了我们从宏观角度看待视频编辑的初心),而视频播放需要的AVPlayerItem需要一个AVAsset实例来初始化,我们希望有一个类,它继承自AVAsset,而且能够对其中的AVAssetTrack进行任意的修改,既可以处理编辑,也可以用来在耗时的导出之前进行预览,AVFoundation为我们提供了这样一个类AVComposition,其可变子类AVMutableComposition满足了这些要求。

AVFoundation的Editing模块的很多类都有这样一个特点:不可变的父类拥有很多只读的属性,其可变的子类继承父类的属性将部分属性变为了可读可写,这里注意不一定是全部都可读可写。之后我们直接从"Mutable"可变的类入手进行视频编辑,不再一一兼顾不可变类。

在Assets模块的中,我们说到AVAsset包含一个或多个 AVAssetTrack,同样作为子类的AVComposition也包含一个或多个 AVCompositionTrack,而我们处理的对象正是他们的可变子类AVMutableCompositionAVMutableCompositionTrack

AVMutableComposition中提供了两个类方法用来创建一个空资源。

+ (instancetype)composition;
+ (instancetype)compositionWithURLAssetInitializationOptions:(nullable NSDictionary<NSString *, id> *)URLAssetInitializationOptions NS_AVAILABLE(10_11, 9_0);
复制代码

从composition中添加和移除AVMutableCompositionTrack的方法:

//向 composition 中添加一个指定媒体资源类型的空的AVMutableCompositionTrack
- (AVMutableCompositionTrack *)addMutableTrackWithMediaType:(NSString *)mediaType preferredTrackID:(CMPersistentTrackID)preferredTrackID;
//从 composition 中删除一个指定的 track
- (void)removeTrack:(AVCompositionTrack *)track;
复制代码

修改AVCompositionTrack的方法:

//将指定时间段的 asset 中的所有的 tracks 添加到 composition 中 startTime 处
- (BOOL)insertTimeRange:(CMTimeRange)timeRange ofAsset:(AVAsset *)asset atTime:(CMTime)startTime error:(NSError * _Nullable * _Nullable)outError;
//向 composition 中的所有 tracks 添加空的时间范围
- (void)insertEmptyTimeRange:(CMTimeRange)timeRange;
//从 composition 的所有 tracks 中删除一段时间,该操作不会删除 track ,而是会删除与该时间段相交的 track segment
- (void)removeTimeRange:(CMTimeRange)timeRange
//改变 composition 中的所有的 tracks 的指定时间范围的时长,该操作会改变 asset 的播放速度
- (void)scaleTimeRange:(CMTimeRange)timeRange toDuration:(CMTime)duration;
复制代码

从上面提供的方法可以看出,我们可以对AVMutableComposition添加空的AVMutableCompositionTrack轨道,然后将准备好的AVAssetAVAssetTrack插入到AVMutableCompositionTrack轨道中,视频的拼接就是这样一个简单的操作。

使用AVMutableComposition我们已经可以完成视频的拼接了,然后添加一段时长与总时间相等的AVCompositionAudioTrack就有了背景音乐,如果要调整多个音频轨道混合后播放时各个轨道的音量,我们还需要另一个类AVAudioMixAVPlayerItem也含有这一属性,在播放时应用混合音频参数。

1.2 AVMutableAudioMix

AVMutableAudioMix 包含一组 AVAudioMixInputParameters,每个 AVAudioMixInputParameters 对应一个它控制的音频 AVCompositionTrackAVAudioMixInputParameters 包含一个 MTAudioProcessingTap,用来实时处理音频,一个AVAudioTimePitchAlgorithm,可以使用它来设置音调,这两个相对要稍微复杂一点,我们暂时不关注。现在我们想分别设置原视频和背景音乐轨道播放时的音量大小,可以使用AVAudioMixInputParameters提供的如如下方法:

// 从某个时间点开始,将音量变为volume
- (void)setVolume:(float)volume atTime:(CMTime)time
// 在timeRange时间内将音量线性变化,从startVolume逐渐变为endVolume
- (void)setVolumeRampFromStartVolume:(float)startVolume toEndVolume:(float)endVolume timeRange:(CMTimeRange)timeRange;
复制代码

即通过创建每个音频轨道的AVAudioMixInputParameters,配置音量,将多个AVAudioMixInputParameters加入数组,作为AVAudioMixinputParameters属性。

AVAudioMix 并不直接改变音频播放的方式,其只是存储了音频播放的方式,AVVideoComposition同理

下面是拼接视频并添加背景音乐的代码示例:

// 1. 创建AVMutableComposition、AVMutableudioMix、和AVAudioMixInputParameters数组
AVMutableComposition *composition = [AVMutableComposition composition];
AVMutableAudioMix *audioMix = [AVMutableAudioMix audioMix];
NSMutableArray *audioMixInputParameters = [NSMutableArray array];

// 2. 插入空的音视频轨道
AVMutableCompositionTrack* videoCompositionTrack = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
AVMutableCompositionTrack* audioCompositionTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];

// 记录已添加的视频总时间
CMTime startTime = kCMTimeZero;
CMTime duration = kCMTimeZero;
// 拼接视频
for (int i = 0; i < assetArray.count; i++) {
    AVAsset* asset = assetArray[i];
    AVAssetTrack* videoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
    AVAssetTrack* audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
    // 3. 轨道中插入对应的音视频
    [videoCompositionTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:videoTrack atTime:startTime error:nil];
    [audioCompositionTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:audioTrack atTime:startTime error:nil];
    
    // 4. 配置原视频的AVMutableAudioMixInputParameters     
    AVMutableAudioMixInputParameters *audioTrackParameters = [AVMutableAudioMixInputParameters audioMixInputParametersWithTrack:audioTrack];
    // 设置原视频声音音量
    [audioTrackParameters setVolume:0.2 atTime:startTime];
    [audioMixInputParameters addObject:audioTrackParameters];
    // 设置原视频声音音量
    [audioTrackParameters setVolume:0.2 atTime:startTime];
    [audioMixInputParameters addObject:audioTrackParameters];
        
    // 拼接时间
    startTime = CMTimeAdd(startTime, asset.duration);
};

// 5. 添加BGM音频轨道
AVAsset *bgmAsset = ...;
AVMutableCompositionTrack *bgmAudioTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
AVAssetTrack *bgmAssetAudioTrack = [[bgmAsset tracksWithMediaType:AVMediaTypeAudio] objectAtIndex:0];
[bgmAudioTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:bgmAssetAudioTrack atTime:kCMTimeZero error:nil];
AVMutableAudioMixInputParameters *bgAudioTrackParameters = [AVMutableAudioMixInputParameters audioMixInputParametersWithTrack:bgmAudioTrack];
// 6. 设置背景音乐音量
[bgAudioTrackParameters setVolume:0.8 atTime:kCMTimeZero];
[audioMixArray addObject:bgAudioTrackParameters];
// 7. 设置inputParameters
audioMix.inputParameters = audioMixArray;

// 使用AVPlayerViewController预览
AVPlayerViewController *playerViewController = [[AVPlayerViewController alloc]init];
// 使用AVMutableComposition创建AVPlayerItem
AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithAsset:composition];
// 8. 将音频混合参数传递给AVPlayerItem
playerItem.audioMix = audioMix;
playerViewController.player = [[AVPlayer alloc] initWithPlayerItem:playerItem];
playerViewController.view.frame = self.view.frame;
[playerViewController.player play];
[self presentViewController:playerViewController animated:YES completion:nil];
复制代码

最简单的视频拼接和添加背景音乐的功能就完成了,但是看起来视频的过渡比较僵硬,我们希望能像控制音轨音量一样,控制每一段视频轨道的合成方式,甚至添加视频过渡效果,这时候我们需要AVVideoComposition,顺便我们实现一个"叠化"的视频转场的效果。

二、 视频转场

2.1 AVMutableVideoComposition

AVVideoComposition从iOS4.0开始支持,从命名看起来 AVVideoComposition 好像跟 AVComposition 好像是有什么血缘关系,事实并非如此,AVVideoComposition 继承自 NSObject ,我们可以把它看做与同样继承自 NSObjectAVAudioMix 平级,一个负责音频轨道的合成控制,一个负责视频轨道的合成控制。

创建AVMutableVideoComposition 的方法如下:

// 返回属性为空的实例
+ (AVMutableVideoComposition *)videoComposition;
// 返回包含了适合的指令的实例
+ (AVMutableVideoComposition *)videoCompositionWithPropertiesOfAsset:(AVAsset *)asset API_AVAILABLE(macos(10.9), ios(6.0), tvos(9.0)) API_UNAVAILABLE(watchos);
// iOS13.0新增,为了在创建时直接设置好背景色
+ (AVMutableVideoComposition *)videoCompositionWithPropertiesOfAsset:(AVAsset *)asset prototypeInstruction:(AVVideoCompositionInstruction *)prototypeInstruction API_AVAILABLE(macos(10.15), ios(13.0), tvos(13.0)) API_UNAVAILABLE(watchos);
复制代码

AVMutableVideoComposition部分属性列表:

//视频每一帧的刷新时间
@property (nonatomic) CMTime frameDuration;

//视频显示时的大小范围
@property (nonatomic) CGSize renderSize;

//视频显示范围大小的缩放比例
@property (nonatomic) float renderScale;

//描述视频集合中具体视频播放方式信息的集合。
@property (nonatomic, copy) NSArray<id <AVVideoCompositionInstruction>> *instructions;

//这三个属性设置了渲染帧时的视频原色、矩阵、传递函数
@property (nonatomic, nullable) NSString *colorPrimaries;
@property (nonatomic, nullable) NSString *colorYCbCrMatrix;
@property (nonatomic, nullable) NSString *colorTransferFunction;

// iOS 15新增,告诉视频合成对象合成的数据样本相关的轨道ID
@property (nonatomic, copy) NSArray<NSNumber *> *sourceSampleDataTrackIDs;
复制代码

从属性列表可以看出,从一个 AVMutableVideoComposition 输出视频时,我们还可以指定输出的尺寸renderSize(裁剪功能)、缩放比例renderScale、帧率frameDuration等。同时AVPlayerItem也含有videoComposition属性,在播放时按照合成指令显示视频内容。

AVVideoCompositionAVAudioMix都没有和AVComposition强相关,这样做的好处是我们在预览、导出、获取视频缩略图功能上能够更灵活的使用。

2.2 AVMutableVideoCompositionInstruction

videoComposition 最重要的一个属性是 instructions,数组包含一个或多个AVMutableVideoCompositionInstruction,它拥有backgroundColor属性用来修改视频的背景色,此外最关键的一个属性是timeRange,它描述了一段组合形式出现的时间范围。

AVMutableVideoCompositionInstruction属性和方法列表:

// 指令适用的时间范围。
@property (nonatomic) CMTimeRange timeRange;
// 视频合成的背景颜色。
@property (nonatomic, retain, nullable) __attribute__((NSObject)) CGColorRef backgroundColor;

// 指定如何从源轨道分层和编写视频帧的说明。
@property (nonatomic, copy) NSArray<AVVideoCompositionLayerInstruction *> *layerInstructions;
// 指示指令是否需要后期处理。
@property (nonatomic) BOOL enablePostProcessing;

// 合成器合成视频帧所遵循的指令需要的轨道id。
@property (nonatomic) NSArray<NSValue *> *requiredSourceTrackIDs;

// passthrough轨道id
@property (nonatomic, readonly) CMPersistentTrackID passthroughTrackID; 

// iOS15新增,用于视频元数据合成。
@property (nonatomic) NSArray<NSNumber *> *requiredSourceSampleDataTrackIDs;
复制代码

这里注意一下AVMutableVideoCompositionLayerInstruction拥有一个passthroughTrackID属性,尽管在可变类中也还是个只读属性。

2.3 AVMutableVideoCompositionLayerInstruction

AVMutableVideoCompositionInstruction拥有一个layerInstructions属性,数组中是AVMutableVideoCompositionLayerInstruction类型的实例,通过AVMutableCompositionTrack创建`,或与之trackID关联,描述了对于该轨道的合成方式,提供了用于修改特定的时间点或者一个时间范围内线性变化的transform、crop、opacity的方法,如下,可做的事情并不多。

// 渐变仿射变换
- (void)setTransformRampFromStartTransform:(CGAffineTransform)startTransform toEndTransform:(CGAffineTransform)endTransform timeRange:(CMTimeRange)timeRange;
// 仿射变换,还可以用来修正视频方向
- (void)setTransform:(CGAffineTransform)transform atTime:(CMTime)time;
// 透明度渐变
- (void)setOpacityRampFromStartOpacity:(float)startOpacity toEndOpacity:(float)endOpacity timeRange:(CMTimeRange)timeRange;
// 设置透明度
- (void)setOpacity:(float)opacity atTime:(CMTime)time;
// 裁剪区域渐变
- (void)setCropRectangleRampFromStartCropRectangle:(CGRect)startCropRectangle toEndCropRectangle:(CGRect)endCropRectangle timeRange:(CMTimeRange)timeRange;
// 设置裁剪区域
- (void)setCropRectangle:(CGRect)cropRectangle atTime:(CMTime)time;
复制代码

综上,AVMutableVideoComposition用于视频合成,对视频组合做一个总体描述,AVMutableVideoCompositionInstruction用于规定其包含的AVMutableVideoCompositionLayerInstruction集合所控制的时间范围,而AVMutableVideoCompositionLayerInstruction是描述具体轨道混合时的呈现形式。那么这里有一个问题:为什么要这样分别设计三个类?我们先往下看。

我们先从创建AVMutableVideoComposition的第一种方法+ (AVMutableVideoComposition *)videoComposition说起,他会返回一个属性基本都是空的实例,我们从零去创建,这样可以更好理解。

要将两段视频进行混合,首先需要两段视频在时间线上含有重叠的区域,之后分别创建各自在混合区域中的出现或消失的指令。苹果官方文档介绍,每一个视频轨道都会配置一个单独的解码器,不建议添加过多的轨道,我们通常使用A/B轨道法——即创建两段视频轨道,将avassetTrack交替插入A/B轨道中,如下图,我们需要对段视频添加相应的instruction,不包含重叠区域的称为pass through,只需要指定时间范围即可,重叠区域称为transition,需要一个描述前一个视频隐藏方式的layerInstruction指令和描述后一个视频出现方式的layerInstruction指令。

每一个instruction都要设置好控制的时间范围,一旦出现指令时间范围没有拼接完整或出现交叉等情况就会产生错误,例如崩溃或者无法正常播放,在合成前我们可以调用AVVideoComposition的- (BOOL)isValidForAsset: timeRange: validationDelegate:以检查指令描述的时间范围是否可用,其中delegate要求遵守的AVVideoCompositionValidationHandling的协议提供的方法给了我们更多的错误信息描述,如果不需要也可以传nil

transition部分的AVMutableVideoCompositionInstruction创建示例(叠化效果):

    CMTimeRange atTime_end = kCMTimeRangeZero;
    __block CMTimeRange atTime_begin = kCMTimeRangeZero;
    NSMutableArray* layerInstructions = [NSMutableArray array];
    // 视频合成指令
    AVMutableVideoCompositionInstruction *videoCompositionInstruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
    videoCompositionInstruction.timeRange = CMTimeRangeMake(kCMTimeZero, totalDuration);
    
    for (int i = 0; i < compositionVideoTracks.count; i++) {
        AVMutableCompositionTrack *compositionTrack = compositionVideoTracks[i];
        AVAsset *asset = assets[i];
        // layerInstruction
        AVMutableVideoCompositionLayerInstruction *layerInstruction = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:compositionTrack];
        if (compositionVideoTracks.count > 1) {
            // 叠化掉过
            atTime_begin = atTime_end;
            atTime_end =  CMTimeRangeMake(CMTimeAdd(CMTimeSubtract(atTime_end.start, transTime), asset.duration), transTime);
            CMTimeRangeShow(atTime_begin);
            CMTimeRangeShow(atTime_end);
            if (i == 0) {
                [layerInstruction setOpacityRampFromStartOpacity:1.0 toEndOpacity:0.0 timeRange:atTime_end];
            } else if (i == compositionVideoTracks.count - 1) {
                [layerInstruction setOpacityRampFromStartOpacity:0.0 toEndOpacity:1.0 timeRange:atTime_begin];
            } else{
                [layerInstruction setOpacityRampFromStartOpacity:1.0 toEndOpacity:0.0 timeRange:atTime_end];
                [layerInstruction setOpacityRampFromStartOpacity:0.0 toEndOpacity:1.0 timeRange:atTime_begin];
            }
        }
        [layerInstructions addObject:layerInstruction];
    }
    videoCompositionInstruction.layerInstructions = layerInstructions;
复制代码

转场效果:

2.4 构建视频合成器的其他方式

当然,像上述这样逐个地创建"pass through instruction"和"transition instruction"肯定不是我们想要的,上面介绍的方式是使用+ (AVMutableVideoComposition *)videoComposition;方法创建AVMutableVideoComposition,返回的是一个各个属性都为空的对象,所以需要我们逐个添加指令。

苹果还给我们提供了一个+ (AVMutableVideoComposition *)videoCompositionWithPropertiesOfAsset:(AVAsset *)asset方法,传入添加好视频轨道的AVMutableComposition,方法返回的AVMutableVideoComposition实例包含了设置好的属性值和适用于根据其时间和几何属性以及其轨道的属性呈现指定资源的视频轨道的指令,简单的说就是instructons和其layerInstructions都已经为我们创建好了,我们可以直接从中取出transition时间段中的fromLayerInstruction和toLayerInstruction,一个消失一个显示,就能够完成视频转场效果了,这两种创建videoComposition的方式称为内置合成器(Bultin-in Compositor),虽然有着施展空间不足的问题,但是优点是苹果对于这种已经经过封装的接口能够自动针对新的技术或设备的适配,例如WWDC2021提到的对HDR视频文件的适配,内置合成器会将含有HDR视频合成输出一个HDR视频。

苹果在iOS9.0开始又提供了可以对视频使用CIFilter添加类似模糊、色彩等滤镜效果的方式来创建AVMutableVideoComposition+ (AVMutableVideoComposition *)videoCompositionWithAsset:(AVAsset *)asset applyingCIFiltersWithHandler:(void (^)(AVAsynchronousCIImageFilteringRequest *request))applier,不过这种方式只有使用系统的CIFilter才能支持HDR的合成导出,否则需要修改参数。

CIFilter *filter = [CIFilter filterWithName:@"CIGaussianBlur"];
AVMutableVideoComposition  *videocomposition = [AVMutableVideoComposition videoCompositionWithAsset:asset applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest * _Nonnull request) {
    // 获取源ciimage
    CIImage *source = request.sourceImage.imageByClampingToExtent;
    // 添加滤镜
    [filter setValue:source forKey:kCIInputImageKey];
    Float64 seconds = CMTimeGetSeconds(request.compositionTime);
    CIImage *output = [filter.outputImage imageByCroppingToRect:request.sourceImage.extent];
    filter setValue:seconds * 10.0 forKey:kCIInputRadiusKey];
    // 提交输出
    [request finishWithImage:output context:nil];
}];
复制代码

注意: 使用该方法创建的AVMutableVideoComposition实例,其instructions数组中的数据类型就成了私有类AVCoreImageFilterVideoCompositionInstruction,官方文档没有任何资料,我们没法创建或者修改它和它的layerInstructions,不过我们可以使用CIAffineTransformCIilter直接调整sourceImage的方向,修正方向问题。

在iOS13.0又提供了+ (AVMutableVideoComposition *)videoCompositionWithPropertiesOfAsset:(AVAsset *)asset prototypeInstruction:(AVVideoCompositionInstruction *)prototypeInstruction方法,我们可以提前创建一个原型指令,将背景色进行设置,之后调用该方法创建AVMutableVideoComposition就可以得到一个各段instruction背景色都设置好的AVMutableVideoComposition实例。

但是要实现完全的自定义转场或者自定义合成,能够做到对每一帧做处理,这些方法还是不够,苹果在iOS7.0在AVMutableVideoComposition类中新增了customVideoCompositorClass属性,它要求一个遵守了AVVideoCompositing协议的类,注意,这里需要传的是一个类。

@property (nonatomic, retain, nullable) Class<AVVideoCompositing> customVideoCompositorClass;
复制代码

AVVideoCompositing协议定义如下:

@protocol AVVideoCompositing<NSObject>
// 源PixelBuffer的属性
@property (nonatomic, nullable) NSDictionary<NSString *, id> *sourcePixelBufferAttributes;
// VideoComposition创建的PixelBuffer的属性
@property (nonatomic) NSDictionary<NSString *, id> *requiredPixelBufferAttributesForRenderContext;
// 通知切换渲染上下文
- (void)renderContextChanged:(AVVideoCompositionRenderContext *)newRenderContext;
// 开始合成请求,在
- (void)startVideoCompositionRequest:(AVAsynchronousVideoCompositionRequest *)asyncVideoCompositionRequest;
// 取消合成请求
- (void)cancelAllPendingVideoCompositionRequests;
复制代码

其中startVideoCompositionRequest方法中的AVAsynchronousVideoCompositionRequest对象,拥有- (CVPixelBufferRef)sourceFrameByTrackID:(CMPersistentTrackID)trackID;方法,可以获某个轨道此刻需要合成的CVPixelBufferRef,之后我们就可以自定义合成方式了,结合、core image、opengles或者metal等实现丰富的效果。

创建自定义合成器示例:

// 返回源PixelBuffer的属性
- (NSDictionary *)sourcePixelBufferAttributes {
    return @{ (NSString *)kCVPixelBufferPixelFormatTypeKey : [NSNumber numberWithUnsignedInt:kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange],
              (NSString*)kCVPixelBufferOpenGLESCompatibilityKey : [NSNumber numberWithBool:YES]};
}
// 返回VideoComposition创建的PixelBuffer的属性
- (NSDictionary *)requiredPixelBufferAttributesForRenderContext {
    return @{ (NSString *)kCVPixelBufferPixelFormatTypeKey : [NSNumber numberWithUnsignedInt:kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange],
              (NSString*)kCVPixelBufferOpenGLESCompatibilityKey : [NSNumber numberWithBool:YES]};
}
// 通知切换渲染上下文
- (void)renderContextChanged:(nonnull AVVideoCompositionRenderContext *)newRenderContext {
}
// 开始合成请求
- (void)startVideoCompositionRequest:(nonnull AVAsynchronousVideoCompositionRequest *)request {
    @autoreleasepool {
        dispatch_async(_renderingQueue, ^{
            if (self.shouldCancelAllRequests) {
                // 用于取消合成 
                [request finishCancelledRequest];
            } else {
                NSError *err = nil;
                CVPixelBufferRef resultPixels = nil;
                //获取当前合成指令 
                AVVideoCompositionInstruction *currentInstruction = request.videoCompositionInstruction;
                // 获取指定trackID的轨道的PixelBuffer
                CVPixelBufferRef currentPixelBuffer = [request sourceFrameByTrackID:currentInstruction.trackID];
                // 在这里就可以进行自定义的处理了
                CVPixelBuffer resultPixels = [self handleByYourSelf:currentPixelBuffer];
                
                if (resultPixels) {
                    CFRetain(resultPixels);
                    // 处理完毕提交处理后的CVPixelBufferRef
                    [request finishWithComposedVideoFrame:resultPixels];
                    CFRelease(resultPixels);
                } else {
                    [request finishWithError:err];
                }
            }
        });
    }
}
// 取消合成请求
- (void)cancelAllPendingVideoCompositionRequests {
    _shouldCancelAllRequests = YES;
    dispatch_barrier_async(_renderingQueue, ^() {
        self.shouldCancelAllRequests = NO;
    });
}
复制代码

流程可分解为:

  1. AVAsynchronousVideoCompositionRequest绑定了当前时间的一系列原始帧,以及当前时间所在的 Instruction。
  2. 收到startVideoCompositionRequest: 回调,并接收到这个 Request。
  3. 根据原始帧及Instruction 相关混合参数,渲染得到合成的帧。
  4. 调用finishWithComposedVideoFrame,交付渲染后的帧。

在创建并使用自定义合成器后,我们通常不再使用AVVideoCompositionInstruction,而是遵守AVVideoCompositionInstruction遵守的AVVideoCompositionInstruction协议,创建自定义的合成指令。为什么?

因为我们自定义的合成器类,是作为一个类传递给AVVideoComposition,而不是一个属性,在实际合成渲染的时候都是在自定义合成器内部进行的,这就是说我们实际上拿不到自定义合成器的对象,那我们怎么告诉合成器我们设计的五花八门的合成方式呢?AVAsynchronousVideoCompositionRequest中可以拿到AVVideoCompositionInstruction实例,所以我们只要遵循AVVideoCompositionInstruction协议创建自己的合成指令类,我们就可以随意添加参数传递给合成器内,完成数据流通。

AVVideoCompositionInstruction协议内容如下:

@protocol AVVideoCompositionInstruction<NSObject>
@required
// 指令适用的时间范围
@property (nonatomic, readonly) CMTimeRange timeRange;
// 指示指令是否需要后期处理
@property (nonatomic, readonly) BOOL enablePostProcessing;
// YES表示从相同的源buffer在相同的组合指令下在两个不同的和城市间下渲染帧和可能会产生不同的输出帧。NO值表示两个组合物产生相同的帧。
@property (nonatomic, readonly) BOOL containsTweening;
// 合成器合成视频帧所遵循的指令需要的轨道id
@property (nonatomic, readonly, nullable) NSArray<NSValue *> *requiredSourceTrackIDs;
// 在不合成的情况下通过源轨道的标识符
@property (nonatomic, readonly) CMPersistentTrackID passthroughTrackID; 

@optional
// iOS15新增,用于视频元数据合成
@property (nonatomic, readonly) NSArray<NSNumber *> *requiredSourceSampleDataTrackIDs;

@end
复制代码

通过比较可以发现,AVVideoCompositionInstruction类与AVVideoCompositionInstruction协议的内容基本一致,只是多了backgroundCoclorlayerInstructions属性。也可以说苹果只是遵循了AVVideoCompositionInstruction协议,创建了一个我们看到的"系统的类"AVVideoCompositionInstruction,而AVVideoCompositionInstruction类只是新增了一个修改背景色的“设计”和一个用于传递合成细节的layerInstructions属性,为我们做了一个简单的范例。

那合成器的startVideoCompositionRequest:方法每次都必须执行吗?我们在讲转场的开始将指令分为了"pass through"和"transition",理论上我们既然规定了某一段指令属于"pass through",那就应该直接通过,不必请求合成,这里就需要前面的只读属性passthroughTrackID了,不过在我们遵循协议创建自己的"CustomMutableVideoCompositionInstruction"后,我们可以进行修改了,在设置了passthroughTrackID之后,就不会在需要"pass through"该段轨道时调用startVideoCompositionRequest:方法了。

经测试,使用+ (AVMutableVideoComposition *)videoCompositionWithPropertiesOfAsset:(AVAsset *)asset方法构建的合成器,其自动为我们创建好的AVMutableVideoCompositionLayerInstructionpassthroughTrackID值是nil。

到这里,前面的问题就好解释了:为什么要分别设这三个类?

要合成视频首先需要一个合成器(内置或者遵循合成协议自定义),一个遵循合成指令协议的类来指定分段的时间范围,但是我们不一定需要AVVideoCompositionLayerInstruction类来控制合成细节,所以这三者是分开设计,AVVideoCompositionLayerInstruction只是系统的示例为了要传递参数而封装的容器。

综上,一个完整的自定义视频合成器的过程应该是这样的:

  1. 通过AVAsset(s)构建AVMutableComposition实例composition
  2. 通过composition创建AVMutableVideoComposition实例videoComposition
  3. 创建自定义合成器和自定义合成指令;
  4. 设置videoCompositioncustomVideoCompositorClass设置为自定义合成器;
  5. videoComposition的instructions中的指令替换为自定义合成指令,并分别配置各段的自定义合成参数。
  6. 在自定义合成器中的startVideoCompositionRequest:方法中取出自定义合成指令,根据指令的合成参数处理每一帧的合成。

最后我们解释一下iOS15新增的两个属性的作用——时基元数据合成:iOS15支持自定义时基元数据合成,WWDC举的例子是我们有一系列GPS数据,并且该数据带有时间戳并与视频同步,如果希望使用这些GPS数据来影响帧的组合方式,第一步需要先把GPS数据写入源电影中的定时元数据轨道(我们可以使用AVAssetWriter),之后我们可以使用AVMutableVideoComposition新的sourceSampleDataTrackIDs属性告诉合成器需要合成的时基元数据轨道ID,设置AVMutableVideoCompositionInstructionrequiredSourceSampleDataTrackIDs属性以告诉它与当前指令相关的轨道ID,最后在自定义合成器中获取元数据执行自定义合成。

WWDC2021的示例:

func startRequest(_ request: AVAsynchronousVideoCompositionRequest){
    for trackID in request.sourceSampleDataTrackIDs {
        // 也可以使用sourceSampleBuffer(byTrackID:获取CMSampleBuffer
        let metadata: AVTimedMetadataGroup? = request.sourceTimedMetadata(byTrackID: trackID
        // 执行自定义合成操作
        using metadata, here.
    }
    request.finish(withComposedVideoFrame: composedFrame)
}
复制代码

三、 添加文字、贴纸

虽然我们可以处理CVPixelBuffer了,要实现文字贴纸都不难了,不过我们还有更简单的方式,使用我们熟悉的Core Animation框架,AVFoundation为我们从播放和导出视频分别提供了对接Core Animation的方式。

3.1 播放-AVSynchronizedLayer

AVFoundation提供了一个专门的CALayer子类AVSynchronizedLayer,用于与给定的AVPlaverltem实例同步时间。这个图层本身不展示任何内容,只是用来与图层子树协同时间。通常使用AVSynchronizedLayer时会将其整合到播放器视图的图层继承关系中,AVSynchronizedLayer直接呈现在视频图层之上,这样就可以添加标题、贴纸或者水印到播放视频中,并与播放器的行为保持同步。

日常使用Core Animation时,时间模型取决于系统主机,主机的时间不会停止,但是视频动画有其自己的时间线,同时还要支持停止、暂停、回退或快进等效果,所以不能直接用系统主机的时间模型向一个视频中添加基于时间的动画,所以动画的beginTime 不能直接设置为0.0了,因为它会转为CACurrentMediaTime()代表当前的主机时间,苹果官方文档还说到,任何具有动画属性的CoreAnimation层,如果被添加为AVSynchronizedLayer的子层,应该将动画的beginTime属性设置为一个非零的正值,这样动画才能在playerItem的时间轴上被解释。此外我们必须设置removedOnCompletion = NO,否则动画就是一次性的。

我们直接以gif表情包贴纸为例,可以直接使用上面提到的gif获取每一帧图片和其停留时间的代码。

// 创建gif关键帧动画
CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"contents"];
animation.beginTime = AVCoreAnimationBeginTimeAtZero;
animation.removedOnCompletion = NO;
// 获取gif的图片images和停留时间数组times
// 使用上文中的实例代码
// 设置动画时间点的contents对应的gif图片
animation.keyTimes = times;
animation.values = images;
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.duration = totalTime;
animation.repeatCount = HUGE_VALF;

// 创建gif图层
_gifLayer = [CALayer layer];
_gifLayer.frame = CGRectMake(0, 0, 150, 150);
[_gifLayer addAnimation:animation forKey:@"gif"];

// 播放器
AVPlayerViewController *playerVC = [[AVPlayerViewController alloc] init];
AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithAsset:_asset];
AVPlayer *player = [[AVPlayer alloc] initWithPlayerItem:playerItem];
playerVC.player = player;

// 创建AVSynchronizedLayer
AVSynchronizedLayer *asyLayer = [AVSynchronizedLayer synchronizedLayerWithPlayerItem:playerItem];

// 将gif图层添加到asyLayer
[asyLayer addSublayer:_gifLayer];
// 将asyLayer图层添加到播放器图层
[playerVC.view.layer addSublayer:asyLayer];
[player play];
复制代码

效果如下图:

到这里我们可以解决AVAssetWriter导出前的预览问题了,毫无疑问,我们希望时使用AVComposition,我们把要转为视频的图片看做一张张贴图,使用Core Animation,为一段视频添加CALayer,在设置的停留时间之后替换掉contents属性即可,这段视频我们可以直接使用没有任何内容的纯黑色视频,作为图片转视频的视频轨道。(马蜂窝视频编辑框架设计及在 iOS 端的业务实践 3.2.2提到了这样的设计)

播放过程添加贴纸文字动画等效果就完成了,导出视频我们还需要AVVideoCompositionCoreAnimationTool

3.2 导出-AVVideoCompositionCoreAnimationTool

AVMutableVideoComposition拥有一个AVVideoCompositionCoreAnimationTool类型的属性animationTool,构建AVVideoCompositionCoreAnimationTool的常用是+ (instancetyp*)videoCompositionCoreAnimationToolWithPostProcessingAsVideoLayer:(CALayer *)videoLayer inLayer:(CALayer *)animationLayer;,其中要求我们传递了两个CALayer的对象,一个VideoLayer一个animationLayer,苹果官方文档解释,将视频的合成帧与animationLayer一起渲染形成最终的视频帧,videoLayer应该在animationLayer的子图层中,animationLayer不应该来自或被添加到任何其他的图层树中。

//创建一个合并图层
CALayer *animationLayer = [CALayer layer];

//创建一个视频帧图层,将承载组合的视频帧
CALayer *videoLayer = [CALayer layer];
[animationLayer addSublayer:videoLayer];
[animationLayer addSublayer:gifLayer];

// 创建AVVideoCompositionCoreAnimationTool与videoComposition的animationTool关联
AVVideoCompositionCoreAnimationTool *animationTool = [AVVideoCompositionCoreAnimationTool videoCompositionCoreAnimationToolWithPostProcessingAsVideoLayer:videoLayer inLayer:animationLayer];
self.videoComposition.animationTool = animationTool;
复制代码

注意:在为videoComposition配置了animationTool之后,就不能再用于播放的playItem了,AVVideoCompositionCoreAnimationTool只能用于AVAssetExportSession和AVAssetReader这种离线渲染,不能用于实时渲染。

四、 导出

4.1 选择视频封面

通常视频导出前还会有一个选择视频封面的功能,获取视频封面所用到的类AVAssetImageGenerator,在基础篇已经介绍过了,不再赘述。

视频的封面默认是第一帧,在用户选择了封面之后,可以使用AVAssetWriter将该时刻对应的视频帧插入到视频的视频最前面一帧停留数帧时间,也可以将封面当做贴纸与原视频合并,AVFoundation的知识已经讲到这里了,我们应该可以有很多思路来解决问题了,在实际的尝试中去选择最优或者最合适的方案,选择封面完成之后就是视频导出的范畴了。

4.2 AVAssetExportSession

导出部分比较简单,只需要把前面创建的合成参数传递给导出用的实例即可。导出部分的核心类是AVAssetExportSession,创建一个AVAssetExportSession需要传递一个asset和一个预设参数presetName,预设参数支持H.264HEVCApple ProRes编码,支持不同的视频分辨率,支持不同的视频质量级别,不过并非所有presetName都与所有asset和文件类型兼容,因此我们应该导出之前调用下面的放法来检查特定组合的兼容性,检查方法如下:

+ (void)determineCompatibilityOfExportPreset:(NSString *)presetName
withAsset:(AVAsset *)asset
outputFileType:(AVFileType)outputFileType
completionHandler:(void (^)(BOOL compatible))handler;
复制代码

下面列举了AVAssetExportSession 重要的属性。

// 导出的文件类型,容器格式
@property (nonatomic, copy, nullable) AVFileType outputFileType;
// 导出的路径
@property (nonatomic, copy, nullable) NSURL *outputURL;
// 是否针对网络使用进行优化
@property (nonatomic) BOOL shouldOptimizeForNetworkUse;
// 导出的状态
@property (nonatomic, readonly) AVAssetExportSessionStatus status;
// 音频混合参数
@property (nonatomic, copy, nullable) AVAudioMix *audioMix;
// 视频合成指令
@property (nonatomic, copy, nullable) AVVideoComposition *videoComposition;
// 导出的时间区间
@property (nonatomic) CMTimeRange timeRange;
// 限制的导出文件大小
@property (nonatomic) long long fileLengthLimit;
// 限制导出的时长
@property (nonatomic, readonly) CMTime maxDuration;
// 元数据
@property (nonatomic, copy, nullable) NSArray<AVMetadataItem *> *metadata;
// 元数据标识
@property (nonatomic, retain, nullable) AVMetadataItemFilter *metadataItemFilter;
// 导出的进度
@property (nonatomic, readonly) float progress;
复制代码

从属性列表中我们可以看到,AVAssetExportSession 除了可以设置文件类型外,还可以设置文件的大小、时长、范围等属性,而且拥有前面介绍的视频合成需要的几个关键属性,音频的混合方式AVAudioMix、视频的合成方式AVVideoComposition都可以在导出时应用。

导出是一个相对耗时的操作,AVAssetExportSession 提供了异步导出的接口- (void)exportAsynchronouslyWithCompletionHandler:(void (^)(void))handler,在block中我们可以随时获取progress导出进度,同时根据AVAssetExportSessionStatus的值,来观察导出结果是否符合预期。

// 通过composition和presetName创建AVAssetExportSession
self.exportSession = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetHEVCHighestQuality];
// 配置合成视频参数
self.exportSession.videoComposition = videoComposition;
// 配置音频混合参数
self.exportSession.audioMix = audioMix;
// 配置输出url地址
self.exportSession.outputURL = [NSURL fileURLWithPath:path];
// 配置输出的文件格式
self.exportSession.outputFileType = AVFileTypeQuickTimeMovie;
// 开始异步导出
[self.exportSession exportAsynchronouslyWithCompletionHandler:^(void){
    // 监听导出状态
    switch (self.exportSession.status) {
        case AVAssetExportSessionStatusCompleted:
            if (complete) {
                complete();
            }
            break;
        case AVAssetExportSessionStatusFailed:
            NSLog(@"%@",self.exportSession.error);
            break;
        case AVAssetExportSessionStatusCancelled:
            NSLog(@"AVAssetExportSessionStatusCancelled");
            break;
        default:
        break;
    }
}];
复制代码

前面我们还学习了使用AVAssetReaderAVAssetWriter配合来重新编码写入文件的方式,其中AVAssetReaderAudioMixOutput拥有audioMix属性,AVAssetReaderVideoCompositionOutput拥有videoCompositionOutput属性,这样的话整个composition的合成配置都可以作为AVAssetReaderOutput的参数了。 现在我们已经学习两种导出文件的方式AVAssetExportSessionAVAssetWriter。如果只要简单的导出为某种文件格式,不对细节有很高的要求,使用AVAssetExportSession就足够了, 而使用AVAssetWriter的优势是可以通过指定比特率、帧率、视频帧尺寸、色彩空间、关键帧间隔、视频比特率、H.264配置文件、像素宽高比甚至用于导出的视频编码器等等,我们可以完全控制导出过程。

总结

最后用一张图片总结一下本文的内容:

demo地址: avfoundationdemo
功能包含:

  • 音频:
    1. AVAudioPlayer音频播放
    2. AVAudioEngineRecorder音频录制
  • 视频:
    1. 视频拼接合成、添加背景音乐、叠化转场效果
    2. 视频添加贴纸、文字、gif表情包
  • 图片:
    1. 普通图片转视频
    2. 实况照片转视频、gif转视频
  • 其他:
    1. 获取文件数据样本格式信息

参考文档

AVFoundation官方文档
wwdc2021-What’s new in AVFoundation
wwdc2020-Edit and play back HDR video with AVFoundation
wwdc2020-What’s new in camera capture
VideoLab - 高性能且灵活的 iOS 视频剪辑与特效框架
马蜂窝视频编辑框架设计及在 iOS 端的业务实践

猜你喜欢

转载自juejin.im/post/7079397452192841735