关于iOS端视频采集和硬编码的资料很少,官方也没有看到详细的相关介绍,github上有不少demo,注释写的很少,对于整个iOS 视频采集到硬编码出h.264都没有做很好的说明。查阅了很多资料,现在我把这个过程知道的细节都记录下来,供同样做直播的朋友参考:Demo地址
注:
- 文章代码都是swift 2.2 编写,OC视频采集的demo不少,可自行github 上搜索,swift在视频压缩部分的demo 比较少。
- 刚接触iOS开发和swift, 代码挺烂,有错误不详的地方希望评论指正。
相机数据采集
摄像头的数据采集通过 AVFoundation.framework
的 AVCaptureSession
类完成,它负责调配影音输入与输出之间的数据流:
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可能不支持较高质量的输出。session
的sessionPreset
枚举的值很多,可以跟入源码看一下,这里设定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!)
VTCompressionSessionCreate
的encode 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 存储结构,下图比较了二者的差异
编码前和解码后的视频数据存储在
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的码流由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播放,后来也是问在一个群里问别人才知道的。
可以看到充第#10
起,每帧的编号变成两个,正常的是1,2,3,4...... 这样没有重复下来的。帧分片出现在输出的视频质量较高的情况下,一帧会拆成多个片,我们只要知道这样没有错就可以了,有兴趣可以查看下面参考 h264 ES流文件经过计算first_mb_in_slice区分帧边界
参考资料: