iOS AVDemo(7):視頻采集,視頻系列來了丨音視頻工程示例

vx 搜索『gjzkeyframe』 關(guān)注『關(guān)鍵幀Keyframe』來及時獲得最新的音視頻技術(shù)文章。

莫奈《睡蓮》

這個公眾號會路線圖 式的遍歷分享音視頻技術(shù)音視頻基礎(chǔ)(完成)音視頻工具(完成)音視頻工程示例(進行中) → 音視頻工業(yè)實戰(zhàn)(準備)。

iOS/Android 客戶端開發(fā)同學如果想要開始學習音視頻開發(fā),最絲滑的方式是對音視頻基礎(chǔ)概念知識有一定了解后,再借助 iOS/Android 平臺的音視頻能力上手去實踐音視頻的采集 → 編碼 → 封裝 → 解封裝 → 解碼 → 渲染過程,并借助音視頻工具來分析和理解對應(yīng)的音視頻數(shù)據(jù)。

音視頻工程示例這個欄目,我們將通過拆解采集 → 編碼 → 封裝 → 解封裝 → 解碼 → 渲染流程并實現(xiàn) Demo 來向大家介紹如何在 iOS/Android 平臺上手音視頻開發(fā)。

這里是第七篇:iOS 視頻采集 Demo。這個 Demo 里包含以下內(nèi)容:

  • 1)實現(xiàn)一個視頻采集模塊;
  • 2)實現(xiàn)視頻采集邏輯并將采集的視頻圖像渲染進行預覽,同時支持將數(shù)據(jù)轉(zhuǎn)換為圖片存儲到相冊;
  • 3)詳盡的代碼注釋,幫你理解代碼邏輯和原理。

你可以在關(guān)注本公眾號后,在公眾號發(fā)送消息『AVDemo』來了解相關(guān)工程源碼。

1、視頻采集模塊

首先,實現(xiàn)一個 KFVideoCaptureConfig 類用于定義視頻采集參數(shù)的配置。

KFVideoCaptureConfig.h

#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, KFVideoCaptureMirrorType) {
    KFVideoCaptureMirrorNone = 0,
    KFVideoCaptureMirrorFront = 1 << 0,
    KFVideoCaptureMirrorBack = 1 << 1,
    KFVideoCaptureMirrorAll = (KFVideoCaptureMirrorFront | KFVideoCaptureMirrorBack),
};

@interface KFVideoCaptureConfig : NSObject
@property (nonatomic, copy) AVCaptureSessionPreset preset; // 視頻采集參數(shù),比如分辨率等,與畫質(zhì)相關(guān)。
@property (nonatomic, assign) AVCaptureDevicePosition position; // 攝像頭位置,前置/后置攝像頭。
@property (nonatomic, assign) AVCaptureVideoOrientation orientation; // 視頻畫面方向。
@property (nonatomic, assign) NSInteger fps; // 視頻幀率。
@property (nonatomic, assign) OSType pixelFormatType; // 顏色空間格式。
@property (nonatomic, assign) KFVideoCaptureMirrorType mirrorType; // 鏡像類型。
@end

NS_ASSUME_NONNULL_END

這里的參數(shù)包括了:分辨率、攝像頭位置、畫面方向、幀率、顏色空間格式、鏡像類型這幾個參數(shù)。

其中畫面方向是指采集的視頻畫面是可以帶方向的,包括:PortraitPortraitUpsideDown、LandscapeRightLandscapeLeft 這幾種。

顏色空間格式對應(yīng) RGB、YCbCr 這些概念,具體來講,一般我們采集圖像用于后續(xù)的編碼時,這里設(shè)置 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange 即可;如果想支持 HDR 時(iPhone12 及之后設(shè)備才支持),這里設(shè)置 kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange。在我們這個 Demo 中,我們想要將采集的圖像數(shù)據(jù)直接轉(zhuǎn)換并存儲為圖片,所以我們會設(shè)置采集的顏色空間格式為 kCVPixelFormatType_32BGRA,這樣將更方便將 CMSampleBuffer 轉(zhuǎn)換為 UIImage。后面你會看到這個邏輯。

鏡像類型表示采集的畫面是否左右鏡像,這個在直播時,主播經(jīng)常需要考慮是否對自己的畫面進行鏡像,從而決定主播和觀眾的所見畫面是否在『左右』概念的理解上保持一致。

其他的幾個參數(shù)大家應(yīng)該從字面上就能理解,就不做過多解釋了。

KFVideoCaptureConfig.m

#import "KFVideoCaptureConfig.h"

@implementation KFVideoCaptureConfig

- (instancetype)init {
    self = [super init];
    if (self) {
        _preset = AVCaptureSessionPreset1920x1080;
        _position = AVCaptureDevicePositionFront;
        _orientation = AVCaptureVideoOrientationPortrait;
        _fps = 30;
        _mirrorType = KFVideoCaptureMirrorFront;

        // 設(shè)置顏色空間格式,這里要注意了:
        // 1、一般我們采集圖像用于后續(xù)的編碼時,這里設(shè)置 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange 即可。
        // 2、如果想支持 HDR 時(iPhone12 及之后設(shè)備才支持),這里設(shè)置為:kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange。
        _pixelFormatType = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange;
    }
    
    return self;
}

@end

上面我們在 KFVideoCaptureConfig 的初始化方法里提供了一些默認值。

接下來,我們實現(xiàn)一個 KFVideoCapture 類來實現(xiàn)視頻采集。

KFVideoCapture.h

#import <Foundation/Foundation.h>
#import "KFVideoCaptureConfig.h"

NS_ASSUME_NONNULL_BEGIN

@interface KFVideoCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config;

@property (nonatomic, strong, readonly) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureVideoPreviewLayer *previewLayer; // 視頻預覽渲染 layer。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 視頻采集數(shù)據(jù)回調(diào)。
@property (nonatomic, copy) void (^sessionErrorCallBack)(NSError *error); // 視頻采集會話錯誤回調(diào)。
@property (nonatomic, copy) void (^sessionInitSuccessCallBack)(void); // 視頻采集會話初始化成功回調(diào)。

- (void)startRunning; // 開始采集。
- (void)stopRunning; // 停止采集。
- (void)changeDevicePosition:(AVCaptureDevicePosition)position; // 切換攝像頭。
@end

NS_ASSUME_NONNULL_END

上面是 KFVideoCapture 的接口設(shè)計,可以看到這些接口類似音頻采集器的接口設(shè)計,除了初始化方法,主要是有獲取視頻配置以及視頻采集數(shù)據(jù)回調(diào)錯誤回調(diào)的接口,另外就是開始采集停止采集的接口。

有一些不同的是,這里還提供了初始化成功回調(diào)、視頻預覽渲染 Layer、以及切換攝像頭的接口,這個主要是因為視頻采集一般會實現(xiàn)所見即所得,能讓用戶看到實時采集的畫面,這樣就需要在初始化成功后讓業(yè)務(wù)層感知到來做一些 UI 布局,并通過預覽渲染的 Layer 來展示采集的畫面。切換攝像頭的接口則主要是對應(yīng)了手機設(shè)備常見的前置、后置等多攝像頭的能力。

在上面的音頻采集數(shù)據(jù)回調(diào)接口中,我們依然使用了 CMSampleBufferRef[1],可見這個數(shù)據(jù)結(jié)構(gòu)的通用性和重要性。

KFVideoCapture.m

#import "KFVideoCapture.h"
#import <UIKit/UIKit.h>

@interface KFVideoCapture () <AVCaptureVideoDataOutputSampleBufferDelegate>
@property (nonatomic, strong, readwrite) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureDevice *captureDevice; // 視頻采集設(shè)備。
@property (nonatomic, strong) AVCaptureDeviceInput *backDeviceInput; // 后置攝像頭采集輸入。
@property (nonatomic, strong) AVCaptureDeviceInput *frontDeviceInput; // 前置攝像頭采集輸入。
@property (nonatomic, strong) AVCaptureVideoDataOutput *videoOutput; // 視頻采集輸出。
@property (nonatomic, strong) AVCaptureSession *captureSession; // 視頻采集會話。
@property (nonatomic, strong, readwrite) AVCaptureVideoPreviewLayer *previewLayer; // 視頻預覽渲染 layer。
@property (nonatomic, assign, readonly) CMVideoDimensions sessionPresetSize; // 視頻采集分辨率。
@property (nonatomic, strong) dispatch_queue_t captureQueue;
@end

@implementation KFVideoCapture
#pragma mark - Property
- (AVCaptureDevice *)backCamera {
    return [self cameraWithPosition:AVCaptureDevicePositionBack];
}

- (AVCaptureDeviceInput *)backDeviceInput {
    if (!_backDeviceInput) {
        _backDeviceInput = [[AVCaptureDeviceInput alloc] initWithDevice:[self backCamera] error:nil];
    }
    
    return _backDeviceInput;
}

- (AVCaptureDevice *)frontCamera {
    return [self cameraWithPosition:AVCaptureDevicePositionFront];
}

- (AVCaptureDeviceInput *)frontDeviceInput {
    if (!_frontDeviceInput) {
        _frontDeviceInput = [[AVCaptureDeviceInput alloc] initWithDevice:[self frontCamera] error:nil];
    }
    
    return _frontDeviceInput;
}

- (AVCaptureVideoDataOutput *)videoOutput {
    if (!_videoOutput) {
        _videoOutput = [[AVCaptureVideoDataOutput alloc] init];
        [_videoOutput setSampleBufferDelegate:self queue:self.captureQueue]; // 設(shè)置返回采集數(shù)據(jù)的代理和回調(diào)。
        _videoOutput.videoSettings = @{(id)kCVPixelBufferPixelFormatTypeKey: @(_config.pixelFormatType)};
        _videoOutput.alwaysDiscardsLateVideoFrames = YES; // YES 表示:采集的下一幀到來前,如果有還未處理完的幀,丟掉。
    }

    return _videoOutput;
}

- (AVCaptureSession *)captureSession {
    if (!_captureSession) {
        AVCaptureDeviceInput *deviceInput = self.config.position == AVCaptureDevicePositionBack ? self.backDeviceInput : self.frontDeviceInput;
        if (!deviceInput) {
            return nil;
        }
        // 1、初始化采集會話。
        _captureSession = [[AVCaptureSession alloc] init];
        
        // 2、添加采集輸入。
        for (AVCaptureSessionPreset selectPreset in [self sessionPresetList]) {
            if ([_captureSession canSetSessionPreset:selectPreset]) {
                [_captureSession setSessionPreset:selectPreset];
                if ([_captureSession canAddInput:deviceInput]) {
                    [_captureSession addInput:deviceInput];
                    break;
                }
            }
        }
        
        // 3、添加采集輸出。
        if ([_captureSession canAddOutput:self.videoOutput]) {
            [_captureSession addOutput:self.videoOutput];
        }
        
        // 4、更新畫面方向。
        [self _updateOrientation];
        
        // 5、更新畫面鏡像。
        [self _updateMirror];
    
        // 6、更新采集實時幀率。
        [self.captureDevice lockForConfiguration:nil];
        [self _updateActiveFrameDuration];
        [self.captureDevice unlockForConfiguration];
        
        // 7、回報成功。
        if (self.sessionInitSuccessCallBack) {
            self.sessionInitSuccessCallBack();
        }
    }
    
    return _captureSession;
}

- (AVCaptureVideoPreviewLayer *)previewLayer {
    if (!_captureSession) {
        return nil;
    }
    if (!_previewLayer) {
        // 初始化預覽渲染 layer。這里就直接用系統(tǒng)提供的 API 來渲染。
        _previewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:_captureSession];
        [_previewLayer setVideoGravity:AVLayerVideoGravityResizeAspectFill];
    }
    
    return _previewLayer;
}

- (AVCaptureDevice *)captureDevice {
    // 視頻采集設(shè)備。
    return (self.config.position == AVCaptureDevicePositionBack) ? [self backCamera] : [self frontCamera];
}

- (CMVideoDimensions)sessionPresetSize {
    // 視頻采集分辨率。
    return CMVideoFormatDescriptionGetDimensions([self captureDevice].activeFormat.formatDescription);
}

#pragma mark - LifeCycle
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config {
    self = [super init];
    if (self) {
        _config = config;
        _captureQueue = dispatch_queue_create("com.KeyFrameKit.videoCapture", DISPATCH_QUEUE_SERIAL);
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionRuntimeError:) name:AVCaptureSessionRuntimeErrorNotification object:nil];
    }
    
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

#pragma mark - Public Method
- (void)startRunning {
    typeof(self) __weak weakSelf = self;
    dispatch_async(_captureQueue, ^{
        [weakSelf _startRunning];
    });
}

- (void)stopRunning {
    typeof(self) __weak weakSelf = self;
    dispatch_async(_captureQueue, ^{
        [weakSelf _stopRunning];
    });
}

- (void)changeDevicePosition:(AVCaptureDevicePosition)position {
    typeof(self) __weak weakSelf = self;
    dispatch_async(_captureQueue, ^{
        [weakSelf _updateDeveicePosition:position];
    });
}

#pragma mark - Private Method
- (void)_startRunning {
    AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
    if (status == AVAuthorizationStatusAuthorized) {
        if (!self.captureSession.isRunning) {
            [self.captureSession startRunning];
        }
    } else {
        NSLog(@"沒有相機使用權(quán)限");
    }
}

- (void)_stopRunning {
    if (_captureSession && _captureSession.isRunning) {
        [_captureSession stopRunning];
    }
}

- (void)_updateDeveicePosition:(AVCaptureDevicePosition)position {
    // 切換采集的攝像頭。
    
    if (position == self.config.position || !_captureSession.isRunning) {
        return;
    }
    
    // 1、切換采集輸入。
    AVCaptureDeviceInput *curInput = self.config.position == AVCaptureDevicePositionBack ? self.backDeviceInput : self.frontDeviceInput;
    AVCaptureDeviceInput *addInput = self.config.position == AVCaptureDevicePositionBack ? self.frontDeviceInput : self.backDeviceInput;
    if (!curInput || !addInput) {
        return;
    }
    [self.captureSession removeInput:curInput];
    for (AVCaptureSessionPreset selectPreset in [self sessionPresetList]) {
        if ([_captureSession canSetSessionPreset:selectPreset]) {
            [_captureSession setSessionPreset:selectPreset];
            if ([_captureSession canAddInput:addInput]) {
                [_captureSession addInput:addInput];
                self.config.position = position;
                break;
            }
        }
    }
    
    // 2、更新畫面方向。
    [self _updateOrientation];
    
    // 3、更新畫面鏡像。
    [self _updateMirror];

    // 4、更新采集實時幀率。
    [self.captureDevice lockForConfiguration:nil];
    [self _updateActiveFrameDuration];
    [self.captureDevice unlockForConfiguration];
}

- (void)_updateOrientation {
    // 更新畫面方向。
    AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo]; // AVCaptureConnection 用于把輸入和輸出連接起來。
    if ([connection isVideoOrientationSupported] && connection.videoOrientation != self.config.orientation) {
        connection.videoOrientation = self.config.orientation;
    }
}

- (void)_updateMirror {
    // 更新畫面鏡像。
    AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo];
    if ([connection isVideoMirroringSupported]) {
        if ((self.config.mirrorType & KFVideoCaptureMirrorFront) && self.config.position == AVCaptureDevicePositionFront) {
            connection.videoMirrored = YES;
        } else if ((self.config.mirrorType & KFVideoCaptureMirrorBack) && self.config.position == AVCaptureDevicePositionBack) {
            connection.videoMirrored = YES;
        } else {
            connection.videoMirrored = NO;
        }
    }
}

- (BOOL)_updateActiveFrameDuration {
    // 更新采集實時幀率。
    
    // 1、幀率換算成幀間隔時長。
    CMTime frameDuration = CMTimeMake(1, (int32_t) self.config.fps);
    
    // 2、設(shè)置幀率大于 30 時,找到滿足該幀率及其他參數(shù),并且當前設(shè)備支持的 AVCaptureDeviceFormat。
    if (self.config.fps > 30) {
        for (AVCaptureDeviceFormat *vFormat in [self.captureDevice formats]) {
            CMFormatDescriptionRef description = vFormat.formatDescription;
            CMVideoDimensions dims = CMVideoFormatDescriptionGetDimensions(description);
            float maxRate = ((AVFrameRateRange *) [vFormat.videoSupportedFrameRateRanges objectAtIndex:0]).maxFrameRate;
            if (maxRate >= self.config.fps && CMFormatDescriptionGetMediaSubType(description) == self.config.pixelFormatType && self.sessionPresetSize.width * self.sessionPresetSize.height == dims.width * dims.height) {
                self.captureDevice.activeFormat = vFormat;
                break;
            }
        }
    }
    
    // 3、檢查設(shè)置的幀率是否在當前設(shè)備的 activeFormat 支持的最低和最高幀率之間。如果是,就設(shè)置幀率。
    __block BOOL support = NO;
    [self.captureDevice.activeFormat.videoSupportedFrameRateRanges enumerateObjectsUsingBlock:^(AVFrameRateRange * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (CMTimeCompare(frameDuration, obj.minFrameDuration) >= 0 &&
            CMTimeCompare(frameDuration, obj.maxFrameDuration) <= 0) {
            support = YES;
            *stop = YES;
        }
    }];
    if (support) {
        [self.captureDevice setActiveVideoMinFrameDuration:frameDuration];
        [self.captureDevice setActiveVideoMaxFrameDuration:frameDuration];
        return YES;
    }
    
    return NO;
}

#pragma mark - NSNotification
- (void)sessionRuntimeError:(NSNotification *)notification {
    if (self.sessionErrorCallBack) {
        self.sessionErrorCallBack(notification.userInfo[AVCaptureSessionErrorKey]);
    }
}

#pragma mark - Utility
- (AVCaptureDevice *)cameraWithPosition:(AVCaptureDevicePosition)position {
    // 從當前手機尋找符合需要的采集設(shè)備。
    NSArray *devices = nil;
    NSString *version = [UIDevice currentDevice].systemVersion;
    if (version.doubleValue >= 10.0) {
        AVCaptureDeviceDiscoverySession *deviceDiscoverySession = [AVCaptureDeviceDiscoverySession  discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInWideAngleCamera] mediaType:AVMediaTypeVideo position:position];
        devices = deviceDiscoverySession.devices;
    } else {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
        devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
#pragma GCC diagnostic pop
    }
    
    for (AVCaptureDevice *device in devices) {
        if ([device position] == position) {
            return device;
        }
    }
    
    return nil;
}

- (NSArray *)sessionPresetList {
    return @[self.config.preset, AVCaptureSessionPreset3840x2160, AVCaptureSessionPreset1920x1080, AVCaptureSessionPreset1280x720, AVCaptureSessionPresetLow];
}

#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    // 向外回調(diào)數(shù)據(jù)。
    if (output == self.videoOutput) {
        if (self.sampleBufferOutputCallBack) {
            self.sampleBufferOutputCallBack(sampleBuffer);
        }
    }
}

@end

@end

上面是 KFVideoCapture 的實現(xiàn),結(jié)合下面這兩張圖可以讓我們更好地理解這些代碼:

AVCaptureSession

AVCaptureSession 配置多組輸入輸出

AVCaptureSession

AVCaptureConnection 連接單或多輸入到單輸出

可以看到在實現(xiàn)采集時,我們是用 AVCaptureSession 來串聯(lián)采集設(shè)備作為輸入,其他輸出對象作為輸出。我們這個 Demo 里的一個輸出對象就是 AVCaptureVideoPreviewLayer,用它來接收輸出的數(shù)據(jù)并渲染。此外,還可以使用 AVCaptureConnection 來連接一個或多個輸入到一個輸出。

從代碼上可以看到主要有這幾個部分:

  • 1)創(chuàng)建采集設(shè)備 AVCaptureDevice。
    • -captureDevice 中實現(xiàn)。
    • 由于我們這里的采集模塊支持前置和后置攝像頭,所以這里的采集設(shè)備是根據(jù)當前選擇的攝像頭位置動態(tài)指定的。分別對應(yīng) -backCamera-frontCamera
  • 2)基于采集設(shè)備,創(chuàng)建對應(yīng)的采集輸入 AVCaptureDeviceInput。
    • 由于支持前置和后置攝像頭切換,所以這里我們有兩個采集輸入對象,分別綁定前置和后置攝像頭。對應(yīng)實現(xiàn)在 -backDeviceInput-frontDeviceInput。
  • 3)創(chuàng)建采集視頻數(shù)據(jù)輸出 AVCaptureVideoDataOutput
    • -videoOutput 中實現(xiàn)。
  • 4)創(chuàng)建采集會話 AVCaptureSession,綁定上面創(chuàng)建的采集輸入和視頻數(shù)據(jù)輸出。
  • 5)創(chuàng)建采集畫面預覽渲染層 AVCaptureVideoPreviewLayer,將它綁定到上面創(chuàng)建的采集會話上。
    • -previewLayer 中實現(xiàn)。
    • 該 layer 可以被外層獲取用于 UI 布局和展示。
  • 6)基于采集會話的能力封裝開始采集和停止采集的對外接口。
    • 分別在 -startRunning-stopRunning 方法中實現(xiàn)。注意,這里是開始和停止操作都是放在串行隊列中通過 dispatch_async 異步處理的,這里主要是為了防止主線程卡頓。
  • 7)實現(xiàn)切換攝像頭的功能。
    • -changeDevicePosition:-_updateDeveicePosition: 方法中實現(xiàn)。注意,這里同樣是異步處理。
  • 8)實現(xiàn)采集初始化成功回調(diào)、數(shù)據(jù)回調(diào)、采集會話錯誤回調(diào)等對外接口。
    • 采集初始化成功回調(diào):在 -captureSession 中初始化采集會話成功后,向外層回調(diào)。
    • 數(shù)據(jù)回調(diào):在 AVCaptureVideoDataOutputSampleBufferDelegate 的回調(diào)接口 -captureOutput:didOutputSampleBuffer:fromConnection: 中接收采集數(shù)據(jù)并回調(diào)給外層。
    • 采集會話錯誤回調(diào):在 -sessionRuntimeError: 中監(jiān)聽 AVCaptureSessionRuntimeErrorNotification 通知并向外層回調(diào)錯誤。

更具體細節(jié)見上述代碼及其注釋。

2、采集視頻并實時展示或截圖

我們在一個 ViewController 中來實現(xiàn)視頻采集并實時預覽的邏輯,也提供了對采集的視頻數(shù)據(jù)截圖保存到相冊的功能。

KFVideoCaptureViewController.m

objc- 在 -captureSession 中實現(xiàn)。

  • 5)創(chuàng)建采集畫面預覽渲染層 AVCaptureVideoPreviewLayer,將它綁定到上面創(chuàng)建的采集會話上。
    • -previewLayer 中實現(xiàn)。
    • 該 layer 可以被外層獲取用于 UI 布局和展示。
  • 6)基于采集會話的能力封裝開始采集和停止采集的對外接口。
    • 分別在 -startRunning-stopRunning 方法中實現(xiàn)。注意,這里是開始和停止操作都是放在串行隊列中通過 dispatch_async 異步處理的,這里主要是為了防止主線程卡頓。
  • 7)實現(xiàn)切換攝像頭的功能。
    • -changeDevicePosition:-_updateDeveicePosition: 方法中實現(xiàn)。注意,這里同樣是異步處理。
  • 8)實現(xiàn)采集初始化成功回調(diào)、數(shù)據(jù)回調(diào)、采集會話錯誤回調(diào)等對外接口。
    • 采集初始化成功回調(diào):在 -captureSession 中初始化采集會話成功后,向外層回調(diào)。
    • 數(shù)據(jù)回調(diào):在 AVCaptureVideoDataOutputSampleBufferDelegate 的回調(diào)接口 -captureOutput:didOutputSampleBuffer:fromConnection: 中接收采集數(shù)據(jù)并回調(diào)給外層。
    • 采集會話錯誤回調(diào):在 -sessionRuntimeError: 中監(jiān)聽 AVCaptureSessionRuntimeErrorNotification 通知并向外層回調(diào)錯誤。

更具體細節(jié)見上述代碼及其注釋。

2、采集視頻并實時展示或截圖

我們在一個 ViewController 中來實現(xiàn)視頻采集并實時預覽的邏輯,也提供了對采集的視頻數(shù)據(jù)截圖保存到相冊的功能。

KFVideoCaptureViewController.m

#import "KFVideoCaptureViewController.h"
#import "KFVideoCapture.h"
#import <Photos/Photos.h>

@interface KFVideoCaptureViewController ()
@property (nonatomic, strong) KFVideoCaptureConfig *videoCaptureConfig;
@property (nonatomic, strong) KFVideoCapture *videoCapture;
@property (nonatomic, assign) int shotCount;
@end

@implementation KFVideoCaptureViewController
#pragma mark - Property
- (KFVideoCaptureConfig *)videoCaptureConfig {
    if (!_videoCaptureConfig) {
        _videoCaptureConfig = [[KFVideoCaptureConfig alloc] init];
        // 由于我們的想要從采集的圖像數(shù)據(jù)里直接轉(zhuǎn)換并存儲圖片,所以我們這里設(shè)置采集處理的顏色空間格式為 32bit BGRA,這樣方便將 CMSampleBuffer 轉(zhuǎn)換為 UIImage。
        _videoCaptureConfig.pixelFormatType = kCVPixelFormatType_32BGRA;
    }
    
    return _videoCaptureConfig;
}

- (KFVideoCapture *)videoCapture {
    if (!_videoCapture) {
        _videoCapture = [[KFVideoCapture alloc] initWithConfig:self.videoCaptureConfig];
        __weak typeof(self) weakSelf = self;
        _videoCapture.sessionInitSuccessCallBack = ^() {
            dispatch_async(dispatch_get_main_queue(), ^{
                [weakSelf.view.layer addSublayer:weakSelf.videoCapture.previewLayer];
                weakSelf.videoCapture.previewLayer.frame = weakSelf.view.bounds;
            });
        };
        _videoCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sample) {
            if (weakSelf.shotCount > 0) {
                weakSelf.shotCount--;
                [weakSelf saveSampleBuffer:sample];
            }
        };
        _videoCapture.sessionErrorCallBack = ^(NSError* error) {
            NSLog(@"KFVideoCapture Error:%zi %@", error.code, error.localizedDescription);
        };
    }
    
    return _videoCapture;
}

#pragma mark - Lifecycle
- (void)viewDidLoad {
    [super viewDidLoad];
    self.edgesForExtendedLayout = UIRectEdgeAll;
    self.extendedLayoutIncludesOpaqueBars = YES;
    self.title = @"Video Capture";
    self.view.backgroundColor = [UIColor whiteColor];

    self.shotCount = 0;
    [self requestAccessForVideo];
    
    // Navigation item.
    UIBarButtonItem *cameraBarButton = [[UIBarButtonItem alloc] initWithTitle:@"切換" style:UIBarButtonItemStylePlain target:self action:@selector(changeCamera)];
    UIBarButtonItem *shotBarButton = [[UIBarButtonItem alloc] initWithTitle:@"截圖" style:UIBarButtonItemStylePlain target:self action:@selector(shot)];
    self.navigationItem.rightBarButtonItems = @[cameraBarButton, shotBarButton];
}

- (void)viewWillLayoutSubviews {
    [super viewWillLayoutSubviews];
    self.videoCapture.previewLayer.frame = self.view.bounds;
}

- (void)dealloc {
    
}

#pragma mark - Action
- (void)changeCamera {
    [self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}

- (void)shot {
    self.shotCount = 1;
}

#pragma mark - Utility
- (void)requestAccessForVideo {
    __weak typeof(self) weakSelf = self;
    AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
    switch (status) {
        case AVAuthorizationStatusNotDetermined: {
            // 許可對話沒有出現(xiàn),發(fā)起授權(quán)許可。
            [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
                if (granted) {
                    [weakSelf.videoCapture startRunning];
                } else {
                    // 用戶拒絕。
                }
            }];
            break;
        }
        case AVAuthorizationStatusAuthorized: {
            // 已經(jīng)開啟授權(quán),可繼續(xù)。
            [weakSelf.videoCapture startRunning];
            break;
        }
        default:
            break;
    }
}

- (void)saveSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    __block UIImage *image = [self imageFromSampleBuffer:sampleBuffer];
    
    PHAuthorizationStatus authorizationStatus = [PHPhotoLibrary authorizationStatus];
    if (authorizationStatus == PHAuthorizationStatusAuthorized) {
        PHPhotoLibrary *library = [PHPhotoLibrary sharedPhotoLibrary];
        [library performChanges:^{
            [PHAssetChangeRequest creationRequestForAssetFromImage:image];
        } completionHandler:^(BOOL success, NSError * _Nullable error) {
            
        }];
    } else if (authorizationStatus == PHAuthorizationStatusNotDetermined) {
        // 如果沒請求過相冊權(quán)限,彈出指示框,讓用戶選擇。
        [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
            // 如果用戶選擇授權(quán),則保存圖片。
            if (status == PHAuthorizationStatusAuthorized) {
                [PHAssetChangeRequest creationRequestForAssetFromImage:image];
            }
        }];
    } else {
        NSLog(@"無相冊權(quán)限。");
    }
}

- (UIImage *)imageFromSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    // 從 CMSampleBuffer 中創(chuàng)建 UIImage。
    
    // 從 CMSampleBuffer 獲取 CVImageBuffer(也是 CVPixelBuffer)。
    CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
    
    // 鎖定 CVPixelBuffer 的基地址。
    CVPixelBufferLockBaseAddress(imageBuffer, 0);
    void *baseAddress = CVPixelBufferGetBaseAddress(imageBuffer);
    
    // 獲取 CVPixelBuffer 每行的字節(jié)數(shù)。
    size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
    // 獲取 CVPixelBuffer 的寬高。
    size_t width = CVPixelBufferGetWidth(imageBuffer);
    size_t height = CVPixelBufferGetHeight(imageBuffer);
 
    // 創(chuàng)建設(shè)備相關(guān)的 RGB 顏色空間。這里的顏色空間要與 CMSampleBuffer 圖像數(shù)據(jù)的顏色空間一致。
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
 
    // 基于 CVPixelBuffer 的數(shù)據(jù)創(chuàng)建繪制 bitmap 的上下文。
    CGContextRef context = CGBitmapContextCreate(baseAddress, width, height, 8, bytesPerRow, colorSpace, kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);

    // 從 bitmap 繪制的上下文中獲取 CGImage 圖像。
    CGImageRef quartzImage = CGBitmapContextCreateImage(context);
    // 解鎖 CVPixelBuffer。
    CVPixelBufferUnlockBaseAddress(imageBuffer, 0);
 
    // 是否上下文和顏色空間。
    CGContextRelease(context);
    CGColorSpaceRelease(colorSpace);
 
    // 從 CGImage 轉(zhuǎn)換到 UIImage。
    UIImage *image = [UIImage imageWithCGImage:quartzImage];
 
    // 釋放 CGImage。
    CGImageRelease(quartzImage);
 
    return image;
}

@end

上面是 KFVideoCaptureViewController 的實現(xiàn),主要分為以下幾個部分:

  • 1)在 -videoCaptureConfig 中初始化采集配置參數(shù)。
    • 這里需要注意的是,我們設(shè)置了采集的顏色空間格式為 kCVPixelFormatType_32BGRA。這主要是為了方便后面截圖時轉(zhuǎn)換數(shù)據(jù)。
  • 2)在 -videoCapture 中初始化采集器,并實現(xiàn)了采集會話初始化成功的回調(diào)、采集數(shù)據(jù)回調(diào)、采集錯誤回調(diào)。
  • 3)在采集會話初始化成功的回調(diào) sessionInitSuccessCallBack 中,對采集預覽渲染視圖層進行布局。
  • 4)在采集數(shù)據(jù)回調(diào) sampleBufferOutputCallBack 中,實現(xiàn)了截圖邏輯。
    • 通過 -saveSampleBuffer:-imageFromSampleBuffer: 方法中實現(xiàn)截圖。
    • -saveSampleBuffer: 方法主要實現(xiàn)請求相冊權(quán)限,以及獲取圖像存儲到相冊的邏輯。
    • -imageFromSampleBuffer: 方法實現(xiàn)了將 CMSampleBuffer 轉(zhuǎn)換為 UIImage 的邏輯。這里需要注意的是,我們在繪制 bitmap 時使用的是 RGB 顏色空間,與前面設(shè)置的采集的顏色空間一致。如果這里前后設(shè)置不一致,轉(zhuǎn)換圖像會出問題。
  • 5)在 -requestAccessForVideo 方法中請求相機權(quán)限并啟動采集。
  • 6)在 -changeCamera 方法中實現(xiàn)切換攝像頭。

更具體細節(jié)見上述代碼及其注釋。

參考資料

[1]

CMSampleBufferRef: https://developer.apple.com/documentation/coremedia/cmsamplebufferref/

- 完 -

推薦閱讀

《iOS 音頻處理框架及重點 API 合集》

《iOS AVDemo(6):音頻渲染》

《iOS AVDemo(5):音頻解碼》

《iOS AVDemo(4):音頻解封裝》

《iOS AVDemo(3):音頻封裝》

《iOS AVDemo(2):音頻編碼》

《iOS AVDemo(1):音頻采集》

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容