苹果在 WWDC2017 中推出了 ARKit,新的AR框架给应用开发带来了更多可能性。值得注意的是iOS11正式版刚更新不久,就能看到市面上已经有了很多AR相关的应用。这些应用大多集中在游戏,短视频,工具应用中,比如最近很火的抖音就更新了AR相关的新玩法。可以预见,未来AR视频会是视频领域的又一个热点。
苹果原生的API做的非常完善,新的ARKit和AVFoundation,SceneKit等框架的结合也非常紧密,AR视频的保存也不难实现。
生成模板工程
首先,打开Xcode9创建新项目,选择Augmented Reality App,下一步Content Technology选择默认的SceneKit。SceneKit是苹果自带的3D游戏开发框架,用于完成渲染。
生成项目后,直接运行,可以看到一架飞机出现在了屏幕中。
关于ARKit框架,这里先不深入介绍,笔者也在学习摸索中。我们的任务是保存AR视频,所以接下来就直接在模板工程里面修改吧。
获取当前渲染的图像帧数据
要想保存视频,最重要一点就是得到当前渲染好的帧数据。得到帧数据后,下面的工作交给AVFoundation就可以轻松搞定了。
那么,如何得到当前画面的帧数据呢?
可以看到渲染的视图ARSCNView最终是继承自UIView,从UIView截取画面是很容易的。但是这样得到的画面,分辨率和当前视图的frame是一致的,如果要保存高分辨率就得缩放,这样肯定会模糊。所以这个方法最先排除。
再来看看ARSCNView这个类,它的直接父类是SCNView。前面提到SceneKit是苹果自带的游戏框架,这个框架里面或许有API能直接获取。查找了相关资料,确实发现SCNRenderer有个snapshotAtTime:withSize:antialiasingMode:方法可以截取UIImage,而SCNRenderer所需要的场景scene属性可以从ARSCNView中直接获取。既然可以得到UIImage,就可以转换为CVPixelBufferRel扔给AVFoundation框架处理。
(笔者目前只找到了这个方法,如果有更好的方法,请不吝赐教)
使用AVAssetWriter输出视频
得到每一帧数据,下面的工作就需要AVFoudation完成了。ARSessionDelegate有个session:didUpdateFrame:回调会在每次识别完成后调用,我们可以在这里控制截取新的图像帧数据与保存逻辑。
首先,我们需要在ViewController中添加需要的属性:
@property (nonatomic, strong) AVAssetWriter *writer;
@property (nonatomic, strong) AVAssetWriterInput *videoInput;
@property (nonatomic, strong) AVAssetWriterInputPixelBufferAdaptor *pixelBufferAdaptor;
@property (nonatomic, strong) SCNRenderer *renderer;
@property (nonatomic, assign) WZRecordStatus status;
@property (nonatomic, strong) dispatch_queue_t videoQueue;
@property (nonatomic, copy) NSString *outputPath;
@property (nonatomic, assign) CGSize outputSize;
@property (nonatomic, assign) int count;
具体每个属性的作用,后面看代码就知晓了。需要注意的是,我们需要一个状态控制录制状态:
typedef NS_ENUM(NSInteger, WZRecordStatus) {
WZRecordStatusIdle,
WZRecordStatusRecording,
WZRecordStatusFinish,
};
viewDidLoad中,初始化相关资源:
// 设置代理
self.sceneView.session.delegate = self;
// 添加一个录制按钮
CGRect bounds = [UIScreen mainScreen].bounds;
UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(bounds.size.width/2-60, bounds.size.height - 200, 120, 100)];
[button setTitle:@"tap to record" forState:UIControlStateNormal];
[button setTitle:@"recording" forState:UIControlStateSelected];
[button addTarget:self action:@selector(clicked:) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:button];
// 创建SCNRenderer
self.renderer = [SCNRenderer rendererWithDevice:nil options:nil];
// 将sceneView的sceneView传给renderer
self.renderer.scene = scene;
// 创建图像处理队列
self.videoQueue = dispatch_queue_create("com.worthy.video.queue", NULL);
// 设置输出分辨率
self.outputSize = CGSizeMake(720, 1280);
添加按钮的响应方法:
-(void)clicked:(UIButton *)sender {
sender.selected = !sender.selected;
if (sender.selected) {
// 开始录制
[self startRecording];
}else {
// 结束录制
[self finishRecording];
}
}
调用开始录制,需要创建和配置AVAssetWriter相关资源并设置状态。结束录制只需要设置状态:
- (void)setupWriter {
// 设置输出路径
self.outputPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0] stringByAppendingPathComponent:@"out.mp4"];
[[NSFileManager defaultManager] removeItemAtPath:self.outputPath error:nil];
// 创建AVAssetWriter
self.writer = [[AVAssetWriter alloc] initWithURL:[NSURL fileURLWithPath:self.outputPath] fileType:AVFileTypeQuickTimeMovie error:nil];
// 创建AVAssetWriterInput
self.videoInput = [[AVAssetWriterInput alloc]
initWithMediaType:AVMediaTypeVideo outputSettings:
@{AVVideoCodecKey:AVVideoCodecTypeH264,
AVVideoWidthKey: @(self.outputSize.width),
AVVideoHeightKey: @(self.outputSize.height)}];
[self.writer addInput:self.videoInput];
// 创建AVAssetWriterInputPixelBufferAdaptor
self.pixelBufferAdaptor = [[AVAssetWriterInputPixelBufferAdaptor alloc] initWithAssetWriterInput:self.videoInput sourcePixelBufferAttributes:
@{(id)kCVPixelBufferPixelFormatTypeKey:@(kCVPixelFormatType_32BGRA),
(id)kCVPixelBufferWidthKey:@(self.outputSize.width),
(id)kCVPixelBufferHeightKey:@(self.outputSize.height)}];
}
- (void)startRecording {
[self setupWriter];
[self.writer startWriting];
[self.writer startSessionAtSourceTime:kCMTimeZero];
self.status = WZRecordStatusRecording;
}
- (void)finishRecording {
self.status = WZRecordStatusFinish;
}
最后,我们在session:didUpdateFrame:回调方法中,控制录制逻辑:
if (self.status == WZRecordStatusRecording) {
dispatch_async(self.videoQueue, ^{
@autoreleasepool {
// 渲染一秒钟60次,视频帧只需要一秒钟30次
// 这里帧率60是写死的,更好的实践是获取当前渲染帧率再后做计算
if (self.count % 2 == 0) {
// 获取当前渲染帧数据
CVPixelBufferRef pixelBuffer = [self capturePixelBuffer];
if (pixelBuffer) {
@try {
// 添加到录制源
[self.pixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:CMTimeMake(self.count/2*1000, 30*1000)];
}@catch (NSException *exception) {
NSLog(@"%@",exception.reason);
}@finally {
CFRelease(pixelBuffer);
}
}
}
self.count++;
}
});
}else if (self.status == WZRecordStatusFinish) {
// 完成录制
self.status = WZRecordStatusIdle;
self.count = 0;
[self.videoInput markAsFinished];
[self.writer finishWritingWithCompletionHandler:^{
UISaveVideoAtPathToSavedPhotosAlbum(self.outputPath, nil, nil, nil);
NSLog(@"record finish, saved to alblum.");
}];
}
获取帧数据方法如下:
-(CVPixelBufferRef)capturePixelBuffer {
UIImage *image = [self.renderer snapshotAtTime:1 withSize:CGSizeMake(self.outputSize.width, self.outputSize.height) antialiasingMode:SCNAntialiasingModeMultisampling4X];
CVPixelBufferRef pixelBuffer = NULL;
CVPixelBufferPoolCreatePixelBuffer(NULL, [self.pixelBufferAdaptor pixelBufferPool], &pixelBuffer);
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
void *data = CVPixelBufferGetBaseAddress(pixelBuffer);
CGContextRef context = CGBitmapContextCreate(data, self.outputSize.width, self.outputSize.height, 8, CVPixelBufferGetBytesPerRow(pixelBuffer), CGColorSpaceCreateDeviceRGB(), kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
CGContextDrawImage(context, CGRectMake(0, 0, self.outputSize.width, self.outputSize.height), image.CGImage);
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
CGContextRelease(context);
return pixelBuffer;
}
现在,我们还差一步可以连真机调试了,需要在Info.plist中添加NSCameraUsageDescription描述。这样点击按钮开始录制,再次点击结束录制,结果就可以保存在相册中。
Demo地址