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

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区分帧边界

参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容