美文网首页直播ios进阶即时通迅和直播
iOS直播视频数据采集、硬编码保存h264文件

iOS直播视频数据采集、硬编码保存h264文件

作者: devzhaoyou | 来源:发表于2016-07-17 11:31 被阅读5962次
    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枚举的值很多,可以跟入源码看一下,这里设定1280x720的输出。

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

    输出代理:
    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可以在视频采集的同时预览摄像头图像。

    参考资料:

    视频数据硬编码

    视频压缩编码通过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<Void>.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<Void>,
        sourceFrameRefCon:UnsafeMutablePointer<Void>,
        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<Void>.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<UnsafePointer<UInt8>>.alloc(1)
    let pps = UnsafeMutablePointer<UnsafePointer<UInt8>>.alloc(1)
    let spsLength = UnsafeMutablePointer<Int>.alloc(1)
    let ppsLength = UnsafeMutablePointer<Int>.alloc(1)
    let spsCount = UnsafeMutablePointer<Int>.alloc(1)
    let ppsCount = UnsafeMutablePointer<Int>.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<Int8> = nil
    
    let state = CMBlockBufferGetDataPointer(blockBuffer!, 0, &length, &totalLength, &dataPointer)
    
    if state == noErr {
        var bufferOffset = 0;
        let AVCCHeaderLength = 4
        // 在输出较高质量的视频时,比如720p会有帧分片的情况,循环取出,这段的变量命名可能不太准确,但功能是实现了。
        while bufferOffset < totalLength - AVCCHeaderLength {
            var NALUnitLength:UInt32 = 0
            memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength)
            NALUnitLength = CFSwapInt32BigToHost(NALUnitLength)
            var naluStart:[UInt8] = [UInt8](count: 4, repeatedValue: 0x00)
            naluStart[3] = 0x01
            let buffer:NSMutableData = NSMutableData()
            // NALU 起始码
            buffer.appendBytes(&naluStart, length: naluStart.count)
            // 视频帧数据
            buffer.appendBytes(dataPointer + bufferOffset + AVCCHeaderLength, length: Int(NALUnitLength))
            // 视频数据写入文件
            fileHandle.writeData(buffer)
            bufferOffset += (AVCCHeaderLength + Int(NALUnitLength))
        }
    }
    

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

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

    H264 帧分片.png

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

    参考资料:

    相关文章

      网友评论

      • KeyboardLife:视频监控,花屏如何处理呢
      • 开发者头条_程序员必装的App:感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/i0ddwy 欢迎点赞支持!
        欢迎订阅《iOS与swift学习之路》https://toutiao.io/subject/35291
        devzhaoyou:@开发者头条_程序员必装的App 谢谢肯定!
      • c1e0f39af20a:请问下。我在看x264的时候看到了preset参数设置,里面有很多设置编码速度的设置,但是硬编这里我只看到了一个RealTime实时的编码,请问硬编码也支持类似的设置吗
        devzhaoyou:@feng5f 不太清楚,我也是半路出家 :grin:
      • tbago:楼主,请问一下,RTMP协议是否支持帧分片,我的H.264源是帧分片的,用VideoCore推流,是花的。去掉帧分片选项,推流就是正常的。
        devzhaoyou:@tbago 那应该是videocore的bug吧,我没用过videocore rtmp部分是公司自己重新实现的
      • 我是卖报滴小行家:怎么将h264文件进行推流?
        devzhaoyou:@我是卖报滴小行家 http://billhoo.blog.51cto.com/2337751/1557646
      • 02f9a257aff9:请问楼主,录制手机屏幕并进行h264编码怎么做呢???
        02f9a257aff9:@devzhaoyou 使用AVFoundation录制视频存本地的思路是一直截图(截得的是UIImage),然后合成视频。我在原demo合成视频的地方把image转化成CVPixelBufferRef送去videotoolbox编码。楼主认为这个思路对么
        devzhaoyou:@圆圆的圆圈 这个也可以做,但是我没有具体研究过,编码的过程应该相同,就是采集的设备不同,具体可以参考github上的if.swift项目。
      • CharlyZheng:感谢楼主分享,
        想问一下如果要设置编码后视频帧率25,恒定码率600Kbps,关键帧间隔25这些该如何配置参数
        CharlyZheng:@devzhaoyou 这个CMTime好难写啊,OC代码怎么设置成25 :sweat: :sweat:
        devzhaoyou:@疯疯癫癫郑成功 码率:编码时设置 kVTCompressionPropertyKey_AverageBitRate: Int(600*1000),帧率:测试发现编码时设置的帧率没有起太大作用,即kVTCompressionPropertyKey_ExpectedFrameRate: NSNumber(double: 25.0)无作用,可以设置捕获视频的码率 device.activeVideoMinFrameDuration 和
        device.activeVideoMaxFrameDuration 都设置成CTime 25。
      • 若非长得丑怎会做逗比:你好 蹦在writeData 是咋回事呀 Mac平台
        若非长得丑怎会做逗比:@若非长得丑怎会做逗比 期待你的解码篇
        若非长得丑怎会做逗比:@Gezhaoyou 找到原因了 因为权限问题 那个文件没有创建成功 所以一写文件就崩溃
        devzhaoyou:@若非长得丑怎会做逗比 Mac平台没有测试,我只在ios9.3测试过
      • 落影loyinglin:赞了 再来看
      • 2fccb0689f51:你好 能加一下我的QQ吗?想请教关于视频编解码的问题 QQ 1243763169

      本文标题:iOS直播视频数据采集、硬编码保存h264文件

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