保存ARKit预览画面输出AR视频

作者: Worthy | 来源:发表于2017-09-24 23:01 被阅读555次

    苹果在 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地址

    相关文章

      网友评论

      • Y_Swordsman:大佬。对于SCNRenderer有个snapshotAtTime:withSize:antialiasingMode:方法可以截取UIImage,这个方法获取输出源。现在有什么更好的替代品吗?

      本文标题:保存ARKit预览画面输出AR视频

      本文链接:https://www.haomeiwen.com/subject/ltulextx.html