百姓网在半年前启动了自己的短视频业务。经过多个版本的迭代,我们的移动端应用不光具备了短视频业务的基本能力, 还集成了一项杀手级功能——“魔力拍”。这项功能用起来大概是这样的:
“魔力拍”所做的事情简单来说就是视频合成。用户输入图片或文字,程序拿着用户的输入和原始视频模板经过一系列处理,最终生成目标视频。 业务流程看着非常简单:
可以看到整个“魔力拍”的核心问题就是怎么在移动端实现这样一个视频合成器。
其实第一次听说要接这个需求时,我是拒绝的。老板同志跟我说“我们都决定啦,你来写合成器。”我说另请高明吧。 我也不是谦虚,我一个没啥视频处理经验的 iOS 工程师怎么就要做视频合成了呢?但是呢,老板同志讲“组织已经研究决定了”, 所以后来我就……就接下了需求呀。
“魔力拍”的功能初看着很复杂:输入的东西既要贴到画面上,又要跟着画面平移、旋转、缩放,一下子就能唬住不少人。 然而作为一名工程师,我的职责就是解决问题呀。再复杂的问题最后总能简化成小问题。
既然要处理视频,那么不妨先明确一下概念。
什么是视频呢?我认为从本质上讲它就是一帧一帧的图像加上时间轴罢了。姑且用下面这个公式表示。
图像 + 时间 = 视频
所以刨去时间轴,视频的静态表现就是一系列的图像。改变这些图像自然就可以改变视频。对视频的处理问题就可以转化为对图像的处理问题。 于是乎,我们发现实现“魔力拍”其实就是去实现图像合成啊。
思路自然而然就来了:
- 首先,从模板视频里获取每一帧的图像;
- 然后,拿着用户输入和获取到的图像,对它们做一番处理生成新的图像;
- 最后,用这些新生成的图像创建目标视频。
OK,道理我都懂。在 iOS 上具体应该怎么实现呢?答案是 AVFoundation。
AVFoundation 是 iOS SDK 的全能视频处理框架。开发者可以通过 AVFoundation 轻松地实现播放、创建和编辑视频。这当然包括“魔力拍”这种 花式玩法。
在 AVFoundation 里我们 AVAsset 这个类来表示多媒体对象。AVAsset 又包含若干个 AVAssetTrack 对象,表示各种“轨道”。例如,一份 视频文件一般就会包含视频轨,音频轨和字幕轨。
创建一个 AVAsset
对象也很简单,把目标多媒体文件的 URL 当参数传给构造函数就可以了。
// Objective-C
NSURL *url = <#A URL that identifies an movie file#>;
AVAsset *anAsset = [AVAssetassetWithURL:maskAssetURL];
一般来说,在创建完 AVAsset 实例后,它身上的一些值不是马上可读的,需要显式地调用加载数据的函数以确保系统帮你把这些值载了进来。 在完成载入的 block 里,我们可以通过获取目标值的状态来判定所需要的数据是不是被真的被载了进来。
// Objective-C
Preparing an Asset for UseNSURL *url = <#A URL that identifies an audiovisual asset such as a movie file#>;
AVURLAsset *asset = [[AVURLAssetalloc] initWithURL:url options:nil];
[asset loadValuesAsynchronouslyForKeys:@[@"tracks"] completionHandler:^{
NSError *error = nil;
AVKeyValueStatus tracksStatus = [asset statusOfValueForKey:@"tracks"error:&error];
switch (tracksStatus) {
case AVKeyValueStatusLoaded: {
// Continue dealing with asset
break;
}
case AVKeyValueStatusFailed: {
// Report error
break;
}
case AVKeyValueStatusCancelled: {
// Do whatever is appropriate for cancelation.
break;
}
case AVKeyValueStatusLoading: {
// Loading
break;
}
case AVKeyValueStatusUnknown: {
// Unkown
break;
}
}
}];
在搞定视频对象的创建和关键数据的载入后,让我们思考先前方案的第一步:如何从模板视频里获取每一帧的图像?
在翻阅开发文档后,不难发现 AVFoundation 提供了获取视频帧的能力。它们是:
- AVAssetImageGenerator
- AVAssetReader
AVAssetImageGenerator 顾名思义,专门用来从视频生成图像的类。接口定义看着也能满足我们的需求:
// Objective-C
- (instancetype)initWithAsset:(AVAsset *)asset NS_DESIGNATED_INITIALIZER;
- (nullable CGImageRef)copyCGImageAtTime:(CMTime)requestedTime
actualTime:(nullable CMTime *)actualTime
error:(NSError * _Nullable * _Nullable)outError CF_RETURNS_RETAINED;
/* error object indicates the reason for failure if the result is AVAssetImageGeneratorFailed */
typedefvoid (^AVAssetImageGeneratorCompletionHandler)(CMTime requestedTime, CGImageRef _Nullable image, CMTime actualTime, AVAssetImageGeneratorResultresult, NSError * _Nullable error);
- (void)generateCGImagesAsynchronouslyForTimes:(NSArray<NSValue *> *)requestedTimes
completionHandler:(AVAssetImageGeneratorCompletionHandler)handler;
- (void)cancelAllCGImageGeneration;
但是槽点多多:
- API 不保证生成的图像就是你给定的那个时刻的图像,实际取到的和期望可能会有偏差。即
requestedTime
和actualTime
的偏差。 - 即使能精确生成某个时刻的图像,但考虑到模板视频帧率千差万别,且同一视频帧率是不均匀的,调用者也难以给出每一帧的时刻,自然不好精确取出该帧图像。
- 批量取图是异步接口,每搞定一张执行一下回调函数。写起来要各种判断,蛋疼啊。
- 性能上太慢了。
AVAssetReader 同样顾名思义,就是专门用来读 AVAsset 的类。关键接口定义如下:
// Objective-C
// AVAssetReader
@property (nonatomic, retain, readonly) AVAsset *asset;
@property (readonly) AVAssetReaderStatus status;
@property (nonatomic, readonly) NSArray<AVAssetReaderOutput *> *outputs;
- (nullableinstancetype)initWithAsset:(AVAsset *)asset error:(NSError * _Nullable * _Nullable)outError NS_DESIGNATED_INITIALIZER;
- (BOOL)canAddOutput:(AVAssetReaderOutput *)output;
- (void)addOutput:(AVAssetReaderOutput *)output;
- (BOOL)startReading;
- (void)cancelReading;
配合 AVAssetReader 一起使用的是 AVAssetReaderOutput。字面意思就是 reader 的输出。核心接口如下:
// Objective-C
// AVAssetReaderOutput
// 获取下一采样的缓冲,这里可以理解为获取视频下一帧
- (nullable CMSampleBufferRef)copyNextSampleBuffer CF_RETURNS_RETAINED;
AVAssetReader 在添加完各种 output 后,就可以开始读取。在读取过程中,output 反复调用 -copyNextSampleBuffer
就可以逐一获取采样数据。
例如,在下面这段代码里,我们对一个视频对象的视频轨道进行了逐帧读取:
// Objective-C
// 构造一个 AVAssetReader 如果出错就返回
self.assetReader = [[AVAssetReader alloc] initWithAsset:self.asseterror:error];
if (!self.assetReader || *error) {
return NO;
}
// 希望读这个视频文件的视频轨
AVAssetTrack *assetVideoTrack = [[self.asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
// 设定 output 的解码参数
NSDictionary *decompressionVideoSettings = @{
(id)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA),
(id)kCVPixelBufferIOSurfacePropertiesKey : [NSDictionary dictionary]
};
// 创建 AVAssetReaderTrackOutput 实例
self.assetReaderVideoOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:assetVideoTrack
outputSettings:decompressionVideoSettings];
// 尝试把 output 加到 reader 身上
if ([self.assetReader canAddOutput:self.assetReaderVideoOutput]) {
[self.assetReaderaddOutput:self.assetReaderVideoOutput];
}
// 开始读取
[self.sourceAssetReaderstartReading];
BOOL done = NO;
// 一直读到完成
while (!done) {
CMSampleBufferRef sampleBuffer = [self.assetReaderOutput copyNextSampleBuffer];
if (sampleBuffer) {
// 在这处理 sampleBuffer
// 释放资源
CFRelease(sampleBuffer);
sampleBuffer = NULL;
} else {
// 找出为什么不能 copyNextSampleBuffer 的原因
if (self.sourceAssetReader.status == AVAssetReaderStatusFailed) {
NSError *failureError = self.sourceAssetReader.error;
// 在这处理错误
} else {
// 读完了
done = YES;
}
}
}
相比 AVAssetImageGenerator 而言,AVAssetReader 不需要关心时间,可以逐帧把视频都出来。并且由于它仅仅拿数据,没有经过 CoreGraphics 去渲染成 CGImageRef 在速度上快得多。有同学就要问了,好歹人家转成了 CGImageRef 这个我知道是图像的结构。 CMSampleBufferRef 是什么鬼?
别急,CMSampleBufferRef 同样也能转换成图像。
- CMSampleBufferRef -> CVImageBufferRef
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
- CVImageBufferRef -> CIImage
CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer]
有了 CIImage 有啥图像类型不能转的?于是 AVAssetReader 成了我们实现“魔力拍”的重要基石。利用 AVAssetReader 我们已经完成了三步方案里 的第一步:“从模板视频里获取每一帧的图像”。那么,接下来我们如何实现第二步:“拿着用户输入和获取到的图像,对它们做一番处理生成新的图像”呢?
对于单张图片,我们希望实现下面这种效果:
即如何拿着用户输入的图像和刚才我们从视频里取出来的单帧图像,生成目标图像。可以看到这两张图其实做了两个主要的变换:
- 用户输入的图片像一张纸一样“侧了一下”
- 这张纸被贴到了视频帧上。
怎么实现上述变化呢?答案是使用 Core Image。
Core Image 是 iOS SDK 的高级图像处理框架。开发者可以使用内置或自定义滤镜对静态图像或视频图像做各种各样的处理。
几个核心类:
- CIImage 表示 Core Image 滤镜要处理或输出的图像对象
- CIFilter 处理单个或多个图像并生成目标图像的处理器
- CIContext 渲染和处理图像的上下文
下面列出了 Core Image 的基本用法:
// Objective-C
// 创建上下文
CIContext *context = [CIContextcontextWithOptions:nil];
// 创建要处理的图像
CIImage *image = <# An CIImage>;
// 创建一个滤镜
CIFilter *filter = [CIFilterfilterWithName:@"aFilterName"];
[filter setValue:image forKey:kCIInputImageKey];
// 设置滤镜的其他属性
// 渲染
[context createCGImage:filter.outputImagefromRect:aRect];
Core Image 内置了很多实用的滤镜。在翻阅文档后,我们可喜地发现,“魔力拍”要的效果就在那里。
这种“侧了一下”的效果就是 Perspective Transform。输入:
- inputImage
- inputTopLeft
- inputTopRight
- inputBottomRight
- inputBottomLeft
普通叠加滤镜。输入:
- inputImage
- inputBackgroundImage
结合两种滤镜,代码
通过使用上述两种滤镜,我们顺利地把图片粘到了视频图像上,看上去第二步就到此结束了。正当你洋洋得意准备吹一波时,下面这张图给了你当头一棒。
怎么办?想想怎么用 PS 恶搞别人照片的啊!抠图嘛!把这只碍事的手从粘上来的图里抠掉啊。再翻阅一遍文档,我们惊喜地发现苹果爸爸是爱我们的。
蒙板混合滤镜,简直良心。输入:
- inputImage
- inputBackgroundImage
- inputMaskImage
利用若干张下面这样的蒙板,就可以把手排除在外了。
那么,问题来了,这些蒙板怎么获取呢?我们的办法是用 Abode After Effects 和 mocha AE 在粗粒度上实现自动抠像,在细节上再通过人工修正 来获取一个视频里的所有关键帧蒙板。相当花力气。
在忙活半天后,我们终于克服了第二步。对视频里所有关键帧都做好了处理。最后要做的就是把这些经过处理的图像重新组合成一个视频咯。
既然 AVFoundation 有 AVAssetReader 用来读视频,那么是不是有 AVAssetWriter 用来写视频呢?没错!
AVAssetWriter 和 AVAssetReader 接口定义和用法类似。需要和 AVAssetWriterInput 配合使用。
// Objective-C
// AVAssetWriter
- (nullableinstancetype)initWithURL:(NSURL *)outputURL
fileType:(NSString *)outputFileType
error:(NSError * _Nullable * _Nullable)outError NS_DESIGNATED_INITIALIZER;
@property (nonatomic, copy, readonly) NSURL *outputURL;
@property (readonly) AVAssetWriterStatus status;
@property (readonly, nullable) NSError *error;
@property (nonatomic, readonly) NSArray<AVAssetWriterInput *> *inputs;
- (BOOL)canAddInput:(AVAssetWriterInput *)input;
- (void)addInput:(AVAssetWriterInput *)input;
- (BOOL)startWriting;
- (void)startSessionAtSourceTime:(CMTime)startTime;
- (void)endSessionAtSourceTime:(CMTime)endTime;
- (void)cancelWriting;
- (void)finishWritingWithCompletionHandler:(void (^)(void))handler;
// Objective-C
// AVAssetWriterInput
- (instancetype)initWithMediaType:(NSString *)mediaType
outputSettings:(nullable NSDictionary<NSString *, id> *)outputSettings
sourceFormatHint:(nullable CMFormatDescriptionRef)sourceFormatHint NS_DESIGNATED_INITIALIZER;
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, id> *outputSettings;
@property (nonatomic, readonly, getter=isReadyForMoreMediaData) BOOLreadyForMoreMediaData;
- (void)requestMediaDataWhenReadyOnQueue:(dispatch_queue_t)queue usingBlock:(void (^)(void))block;
- (BOOL)appendSampleBuffer:(CMSampleBufferRef)sampleBuffer;
- (void)markAsFinished;
创建 AVAssetWriter 实例后,向其添加各种 AVAssetWriterInput 实例。Writer 调用开始写。在写的过程中,input 不断把数据灌入 直到把数据全部写完,再标记已完成。
下面的代码把 AVAssetReader 和 AVAssetWriter 组合起来用,实现一边逐帧读视频,一边把读到的东西逐帧写到新的视频里。
// Objective-C
// Prepare the asset writer for writing.
[self.assetWriterstartWriting];
// Start a sample-writing session.
[self.assetWriterstartSessionAtSourceTime:kCMTimeZero];
// Specify the block to execute when the asset writer is ready for media data and the queue to call it on.
[self.assetWriterInput requestMediaDataWhenReadyOnQueue:myInputSerialQueue usingBlock:^{
while ([self.assetWriterInput isReadyForMoreMediaData]){
// Get the next sample buffer.
CMSampleBufferRef nextSampleBuffer = [self.assetReaderOutput copyNextSampleBuffer];
if (nextSampleBuffer) {
// If it exists, append the next sample buffer to the output file.
[self.assetWriterInput appendSampleBuffer:nextSampleBuffer];
CFRelease(nextSampleBuffer);
nextSampleBuffer = NULL;
} else {
// Assume that lack of a next sample buffer means the sample buffer source is out of samples and mark the input as finished.
[self.assetWriterInput markAsFinished];
break;
}
}
}];
“魔力拍”的核心代码实现其实和上述示例代码并没有太大的区别。我们只是在拿到 nextSampleBuffer
后,把它转成 CIImage 对象,然后通过上
文提到的各种滤镜处理了一遍,最后再把输出的 CIImage 对象转换成 assetWriterInput
能认的形式写回去。这一步也挺简单的,稍微改写一下:
// Objective-C
CMSampleBufferRef nextSampleBuffer = [self.assetReaderOutput copyNextSampleBuffer];
if (nextSampleBuffer) {
// 从 nextSampleBuffer 取出图像,转成 CIImage
// 用各种滤镜处理 CIImage,得到输出 filteredImage
CVPixelBufferRef renderedOutputPixelBuffer = NULL;
CVReturn error = CVPixelBufferPoolCreatePixelBuffer(NULL, self.assetWriterInputPixelBufferAdaptor.pixelBufferPool, &renderedOutputPixelBuffer);
if (!error) {
[self.ciContext render:filteredImage toCVPixelBuffer:renderedOutputPixelBuffer];
[self.assetWriterInputPixelBufferAdaptor appendPixelBuffer:renderedOutputPixelBuffer
withPresentationTime:CMSampleBufferGetOutputPresentationTimeStamp(sourceSampleBuffer)];
}
CFRelease(nextSampleBuffer);
nextSampleBuffer = NULL;
} else {
[self.assetWriterInput markAsFinished];
break;
}
至此,利用 AVAssetWriter 我们顺利地把每帧图像写到了一个新的视频文件里。实现了“魔力拍”的功能。最终我们程序上的流程大概是这样的: