Video Live.png

关于iOS端视频采集和硬编码的资料很少,官方也没有看到详细的相关介绍,github上有不少demo,注释写的很少,对于整个iOS 视频采集到硬编码出h.264都没有做很好的说明。查阅了很多资料,现在我把这个过程知道的细节都记录下来,供同样做直播的朋友参考:Demo地址
注:

  • 文章代码都是swift 2.2 编写,OC视频采集的demo不少,可自行github 上搜索,swift在视频压缩部分的demo 比较少。
  • 刚接触iOS开发和swift, 代码挺烂,有错误不详的地方希望评论指正。

相机数据采集

摄像头的数据采集通过 AVFoundation.frameworkAVCaptureSession 类完成,它负责调配影音输入与输出之间的数据流:

AVCaptureSession.png

AVCaptureSession的工作流程:实例化一个AVCaptureSession,添加配置输入和输出(输入其实就是一个或多个的 AVCaptureDevice对象,这些对象通过AVCaptureDeviceInput连接上 CaptureSession,输出是 AVCaptureVideoDataOutput,可以通过它拿到采集到的视频数据),启动AVCaptureSession

  • 设置Capture Device
// 定义相机设备
var cameraDevice: AVCaptureDevice?
let devices = AVCaptureDevice.devices()
// 遍历相机设备,找到后置摄像头
for device in devices {
    if (device.hasMediaType(AVMediaTypeVideo)) {
        if (device.position == AVCaptureDevicePosition.Back) {
            cameraDevice = device as? AVCaptureDevice
            if cameraDevice != nil {
                print("Capture Device found.")
            }
        }
    }
}

前后置相机可以通过 AVCaptureDevicePosition这个枚举选择。

  • 设置和添加输入输出
do {
   // 为output添加 代理,在代理中就可以拿到采集到的原始视频数据
    output.setSampleBufferDelegate(self, queue: lockQueue)
  // 将cameraDevice 与 Input 关联,添加到会话中
    input = try AVCaptureDeviceInput(device: cameraDevice)
    if session.canAddInput(input) {
        session.addInput(input)
    }
  // 添加输出
    if session.canAddOutput(output) {
        session.addOutput(output)
    }
// 配置 session
    session.beginConfiguration()
// 指定视频输出质量等级
    if session.canSetSessionPreset(AVCaptureSessionPreset1280x720) {
        session.sessionPreset = AVCaptureSessionPreset1280x720
    }
// 设置摄像头的方向
    let connection:AVCaptureConnection = output.connectionWithMediaType(AVMediaTypeVideo)
    connection.videoOrientation = .Portrait
    session.commitConfiguration()
} catch let error as NSError {
    print(error)
}
// 开始采集
session.startRunning()

视频输出等级:
指定了视频的输出质量,设置前最好判断设配社否支持所设的输出等级,一些老的iPhone可能不支持较高质量的输出。sessionsessionPreset枚举的值很多,可以跟入源码看一下,这里设定1280×720的输出。

相机方向:
相机的方向是这样,当屏幕设置为可旋转的情况下,若不设置相机方向,屏幕变为横屏,预览图像还是按竖屏显示。

输出代理:
output 需要设置遵循AVCaptureVideoDataOutputSampleBufferDelegate的代理来拿到采集到的视频数据。CMSampleBuffer存放编解码前后的视频图像的容器数据结构,这里存放的就是未经编码的摄像机数据。

通过CMSampleBufferGetImageBuffer()接口就可以拿到CVImageBuffer编码前的数据,可以送去编码器进行编码的数据。

extension VideoIOComponent: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(captureOutput:AVCaptureOutput!, didOutputSampleBuffer sampleBuffer:CMSampleBuffer!, fromConnection connection:AVCaptureConnection!) {
        guard let image:CVImageBufferRef = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }     
        // 送去IOS的硬编码器编码
        avcEncoder.encodeImageBuffer(image, presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sampleBuffer), presentationDuration: CMSampleBufferGetDuration(sampleBuffer))
        // print("get camera image data! Yeh!")
    }
}

相机图像预览:

previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer?.videoGravity = AVLayerVideoGravityResizeAspect
previewLayer?.frame = viewController.view.bounds
viewController.view.layer.addSublayer(previewLayer!)

AVCaptureVideoPreviewLayer可以在视频采集的同时预览摄像头图像。

参考资料:

  • 在 iOS 上捕获视频
  • Core Image 和视频

视频数据硬编码

视频压缩编码通过Video Toolbox框架下的VTCompressionSession完成。Video ToolBox 是一个基于 CoreMedia,CoreVideo,CoreFoundation 框架的 C 语言 API,来处理硬件的编码和解码,在iOS 8.0后,苹果将该框架引入iOS系统,苹果在iOS 8.0系统之前,没有开放系统的硬件编码解码功能。
由于Video ToolBox 提供的是 C 的API,所以编码时会用到一些swift的指针操作。

  • ** 定义创建配置CompressionSession:**
private var session:VTCompressionSessionRef?
// 将
VTCompressionSessionCreate(
    kCFAllocatorDefault,
    480, // encode height
    640,// encode width
    kCMVideoCodecType_H264, //encode type,h.264
    nil,
    attributes,  
    nil,
    callback,
    unsafeBitCast(self, UnsafeMutablePointer.self),
    &session)
// 设置编码的属性
VTSessionSetProperties(session!, properties)
VTCompressionSessionPrepareToEncodeFrames(session!)

VTCompressionSessionCreateencode height, encode width设置编码输出的h.264文件的宽高,单位px(像素)。

编码类型主要用的就是kCMVideoCodecType_H264(h.264),其他的类型还有h.263等,好像最新的 iphone 6s 已经支持 h.265的编码,但还没有放出接口。

  • ** attributes 和 properties**

这个地方先给出一个参考的设置,具体的参数意义我只是了解个大概。

为了高效率的输出,硬件解码器输出偏向于选择本机的色度格式,也就是 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
。然而,有许多其他的视频格式可以用,并且转码过程中有 GPU 参与,效率非常高。可以通过设置kCVPixelBufferPixelFormatTypeKey
键来启用,我们还需要通过 kCVPixelBufferWidthKey
和kCVPixelBufferHeightKey
来设置整数的输出尺寸。有一个可选的键也值得一提,kCVPixelBufferOpenGLCompatibilityKey
,它允许在 OpenGL 的上下文中直接绘制解码后的图像,而不是从总线和 CPU 之间复制数据。这有时候被称为零拷贝通道,因为在绘制过程中没有解码的图像被拷贝

参考文章 视频工具箱和硬件加速 视频输出格式一节

let defaultAttributes:[NSString: AnyObject] = [
        kCVPixelBufferPixelFormatTypeKey: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange), // 指定像素的输出格式
        kCVPixelBufferIOSurfacePropertiesKey: [:],
        kCVPixelBufferOpenGLESCompatibilityKey: true, // about openGL
    ]

private var attributes:[NSString: AnyObject] {
    var attributes:[NSString: AnyObject] = defaultAttributes
    attributes[kCVPixelBufferHeightKey] = 480  //output height
    attributes[kCVPixelBufferWidthKey] = 640   // output width
    return attributes
}

我测试中,这个地方的宽高好像不起什么作用,输出的宽高和VTCompressionSessionCreate传入的宽高相同。
properties详细指定了编码的具体参数,这个地方牵扯一些关于编码比较专业的东西,只把我明白的添加了注释,具体的查阅资料后,慢慢完善。

var profileLevel:String = kVTProfileLevel_H264_Baseline_3_1 as String
private var properties:[NSString: NSObject] {
    let isBaseline:Bool = profileLevel.containsString("Baseline")
    var properties:[NSString: NSObject] = [
        kVTCompressionPropertyKey_RealTime: kCFBooleanTrue,
        // h264 profile level
        kVTCompressionPropertyKey_ProfileLevel: profileLevel,
        // 视频码率
        kVTCompressionPropertyKey_AverageBitRate: Int(640*480),
        // 视频帧率
        kVTCompressionPropertyKey_ExpectedFrameRate: NSNumber(double: 30.0),
        // 关键帧间隔,单位秒
kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration: NSNumber(double: 2.0),
        kVTCompressionPropertyKey_AllowFrameReordering: !isBaseline,
        kVTCompressionPropertyKey_PixelTransferProperties: [
            "ScalingMode": "Trim"
        ]
    ]
    if (!isBaseline) {
        properties[kVTCompressionPropertyKey_H264EntropyMode] = kVTH264EntropyMode_CABAC
    }
    return properties
}
  • 编码输出:

VTCompressionOutputCallback对象 callback,是 session 的回调,通过 callback 拿到编码后的h.264视频数据。CMSampleBuffer对象 sampleBuffer 存有编码后的视频数据,可以发现编码前后都是使用的 CMSampleBuffer 存储结构,下图比较了二者的差异

CMSampleBuffer.png

编码前和解码后的视频数据存储在 CPixelBuffer结构中,和上面的CVImageBuffer相同的数据结构。编码后和解码前的视频数据存储在CMBlockBuffer的数据结构中。

private var callback:VTCompressionOutputCallback = {(
    outputCallbackRefCon:UnsafeMutablePointer,
    sourceFrameRefCon:UnsafeMutablePointer,
    status:OSStatus,
    infoFlags:VTEncodeInfoFlags,
    sampleBuffer:CMSampleBuffer?
    ) in
    guard let sampleBuffer:CMSampleBuffer = sampleBuffer where status == noErr else {
        return
    }
    
    // print("get h.264 data!")
    let encoder:AVCEncoder = unsafeBitCast(outputCallbackRefCon, AVCEncoder.self)
    // 是否是h264的关键帧
    let isKeyframe = !CFDictionaryContainsKey(unsafeBitCast(CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), CFDictionary.self), unsafeBitCast(kCMSampleAttachmentKey_NotSync, UnsafePointer.self))
    if isKeyframe {
      // h264的 pps、sps
        encoder.formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer)
    }
    // h264具体视频帧内容
    encoder.sampleOutput(video: sampleBuffer)
}

保存h264码流文件:

H264码流文件结构如下:

H264码流结构.png

H264的码流由NALU单元组成,NALU单元包含视频图像数据和H264的参数信息。其中视频图像数据就是CMBlockBuffer,而H264的参数信息则可以组合成FormatDesc。具体来说参数信息包含SPS(Sequence Parameter Set)和PPS(Picture Parameter Set)

h264 文件在保存的时候可以在每一个I帧前都添加SPS和PPS信息,也可以只在整个文件开始时只添加一组SPS和PPS。每个NALU都是以0x00,0x00,0x00,0x01开始。

  • 保存SPS,PPS
let sampleData =  NSMutableData()
// let formatDesrciption :CMFormatDescriptionRef = CMSampleBufferGetFormatDescription(sampleBuffer!)!
let sps = UnsafeMutablePointer>.alloc(1)
let pps = UnsafeMutablePointer>.alloc(1)
let spsLength = UnsafeMutablePointer.alloc(1)
let ppsLength = UnsafeMutablePointer.alloc(1)
let spsCount = UnsafeMutablePointer.alloc(1)
let ppsCount = UnsafeMutablePointer.alloc(1)
sps.initialize(nil)
pps.initialize(nil)
spsLength.initialize(0)
ppsLength.initialize(0)
spsCount.initialize(0)
ppsCount.initialize(0)
var err : OSStatus
// 获取 psp
err = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescription!, 0, sps, spsLength, spsCount, nil )
if (err != noErr) {
    NSLog("An Error occured while getting h264 parameter")
}
// 获取pps
err = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescription!, 1, pps, ppsLength, ppsCount, nil )
if (err != noErr) {
    NSLog("An Error occured while getting h264 parameter")
}
// 添加NALU开始码
let naluStart:[UInt8] = [0x00, 0x00, 0x00, 0x01]
sampleData.appendBytes(naluStart, length: naluStart.count)
sampleData.appendBytes(sps.memory, length: spsLength.memory)
sampleData.appendBytes(naluStart, length: naluStart.count)
sampleData.appendBytes(pps.memory, length: ppsLength.memory)
// 写入文件
fileHandle.writeData(sampleData)

  • ** 保存视频数据**
print("get slice data!")
// todo : write to h264 file
let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer)
var totalLength = Int()
var length = Int()
var dataPointer: UnsafeMutablePointer = nil

let state = CMBlockBufferGetDataPointer(blockBuffer!, 0, &length, &totalLength, &dataPointer)

if state == noErr {
    var bufferOffset = 0;
    let AVCCHeaderLength = 4
    // 在输出较高质量的视频时,比如720p会有帧分片的情况,循环取出,这段的变量命名可能不太准确,但功能是实现了。
    while bufferOffset 

这样从采集摄像头数据,到保存h264文件整个流程就走通了,主要为后面通过RTMP协议推流做准备。在进行视频推流时,需要将H264视频数据进一步按照FLV Tag的格式封包,这些有时间再写写。

关于帧分片
帧分片是我在用h264分析工具的时候注意到的,有的视频帧会有重复,开始不懂以为 保存的文件有问题,但可以用VLC播放,后来也是问在一个群里问别人才知道的。

H264 帧分片.png

可以看到充第#10起,每帧的编号变成两个,正常的是1,2,3,4…… 这样没有重复下来的。帧分片出现在输出的视频质量较高的情况下,一帧会拆成多个片,我们只要知道这样没有错就可以了,有兴趣可以查看下面参考 h264 ES流文件经过计算first_mb_in_slice区分帧边界

参考资料:

  • iOS8系统H264视频硬件编解码说明
  • 视频工具箱和硬件加速
  • iOS8 Core Image In Swift:视频实时滤镜
  • h264 ES流文件经过计算first_mb_in_slice区分帧边界

文章来源于互联网,如有雷同请联系站长删除:iOS直播视频数据采集、硬编码保存h264文件

发表评论