前言
本文是讲解特效相机中的视频播放器的实现,完整源码可查看AwemeLike。
首先我们先来看一下播放器的结构
--> 音频帧队列 --> --> 音频处理 --> 音频渲染
/ \ /
视频文件 --> 解码器 --> --> 音视频同步 -->
\ / \
--> 视频帧队列 --> --> 视频处理 --> 视频渲染
可以看到,播放一个视频文件需要经过解码、音视频同步、音视频处理等步骤,然后才能渲染出来。
相对于一般的播放器,视频编辑器的播放器需要修改它的音视频数据,也就是多了音视频处理这个步骤。所以我们的播放器不仅需要有play、pause和seekTime等功能,还需要提供一些接口来让外部修改音视频数据。
(视频编辑器的工作主要是集中音视频处理这个步骤,但这个不是这篇文章的重点)
对音频的处理,目前只支持添加配乐和修改音量,其内部是使用AudioUnit实现的
- (void)play;
- (void)playWithMusic:(NSString *)musicFilePath;
- (void)changeVolume:(CGFloat)volume isMusic:(CGFloat)isMusic;
对视频的处理,使用OpenGLES来实现的,GPUImageOutput
是GPUImage这个库中的类
NSArray *> *filters;
下面再来看看播放器HPPlayer的头文件
@interface HPPlayer : NSObject
- (instancetype)initWithFilePath:(NSString *)path preview:(UIView *)preview playerStateDelegate:(id)delegate;
- (instancetype)initWithFilePath:(NSString *)path playerStateDelegate:(id)delegate;
@property (nonatomic, strong) UIView *preview;
@property(nonatomic, copy) NSArray *> *filters;
@property (nonatomic, copy) NSString *musicFilePath;
@property(nonatomic, assign) BOOL shouldRepeat;
@property(nonatomic, assign) BOOL enableFaceDetector;
- (CMTime)currentTime;
- (CMTime)duration;
- (NSInteger)sampleRate;
- (NSUInteger)channels;
- (void)play;
- (void)pause;
- (BOOL)isPlaying;
- (void)playWithMusic:(NSString *)musicFilePath;
- (CGFloat)musicVolume;
- (CGFloat)originVolume;
- (void)changeVolume:(CGFloat)volume isMusic:(CGFloat)isMusic;
- (void)seekToTime:(CMTime)time;
- (void)seekToTime:(CMTime)time status:(HPPlayerSeekTimeStatus)status;
@end
1. 解码器
解码工作是由项目中的HPVideoDecoder
类完成的,其内部是使用系统自带的AVAssetReader
来解码的,AVAssetReader
是一个高层的API,使用起来非常方便,只需要一个本地视频文件和一些简单的参数就可以直接得到解码后音视频帧--系统会帮我们做解封装和音视频帧解码的工作。
1.1. 基本使用
初始化时,我们需要设置一些解码参数,用来指定解码后音视频的格式
- (AVAssetReader*)createAssetReader {
NSError *error = nil;
AVAssetReader *assetReader = [AVAssetReader assetReaderWithAsset:self->asset error:&error];
NSMutableDictionary *outputSettings = [NSMutableDictionary dictionary];
[outputSettings setObject:@(kCVPixelFormatType_32BGRA) forKey:(id)kCVPixelBufferPixelFormatTypeKey];
readerVideoTrackOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:[[self->asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0] outputSettings:outputSettings];
readerVideoTrackOutput.alwaysCopiesSampleData = false;
readerVideoTrackOutput.supportsRandomAccess = true;
[assetReader addOutput:readerVideoTrackOutput];
NSArray *audioTracks = [self->asset tracksWithMediaType:AVMediaTypeAudio];
BOOL shouldRecordAudioTrack = [audioTracks count] > 0;
audioEncodingIsFinished = true;
if (shouldRecordAudioTrack)
{
audioEncodingIsFinished = false;
AVAssetTrack* audioTrack = [audioTracks objectAtIndex:0];
readerAudioTrackOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:audioTrack outputSettings:@{AVFormatIDKey: @(kAudioFormatLinearPCM), AVLinearPCMIsFloatKey: @(false), AVLinearPCMBitDepthKey: @(16), AVLinearPCMIsNonInterleaved: @(false), AVLinearPCMIsBigEndianKey: @(false)}];
readerAudioTrackOutput.alwaysCopiesSampleData = false;
readerAudioTrackOutput.supportsRandomAccess = true;
[assetReader addOutput:readerAudioTrackOutput];
}
return assetReader;
}
可以看到,视频帧会给解码成BGRA
的格式,这是为了方便之后人脸识别的使用,而音频帧解码后的格式是LPCM 16int 交叉存储
,这是为了匹配HPAudioOutput
中的设置。
初始化之后,调用AVAssetReader
的startReading
,接着我们就可以使用AVAssetReaderOutput
的copyNextSampleBuffer
方法来获取音视频帧了。
1.2. 如何实现seekTime
在播放视频的过程中是可以改变播放进度的,在AVAssetReader
中有两种方式来重置进度
一种是使用AVAssetReader
的timeRange
属性,使用这种方式时,必须重新创建一个新的AVAssetReader
对象,因为timeRange
属性只能在startReading
调用之前修改。
另一种是使用AVAssetReaderOutput
的- (void)resetForReadingTimeRanges:(NSArray
方法,使用这个方法不需要重新创建一个新的AVAssetReader
对象,但是必须是在copyNextSampleBuffer
方法返回NULL
之后才能调用。
项目中使用的第二种方法,以下是seekTime的具体实现
- (CMTime)seekToTime:(CMTime)time {
[lock lock];
CMTime maxTime = CMTimeSubtract(asset.duration, CMTimeMake(5, 100));
if (CMTIME_COMPARE_INLINE(time, >=, maxTime)) {
time = maxTime;
}
CMSampleBufferRef buffer;
while ((buffer = [readerVideoTrackOutput copyNextSampleBuffer])) {
CFRelease(buffer);
};
while ((buffer = [readerAudioTrackOutput copyNextSampleBuffer])) {
CFRelease(buffer);
};
audioEncodingIsFinished = false;
videoEncodingIsFinished = false;
NSValue *videoTimeValue = [NSValue valueWithCMTimeRange:CMTimeRangeMake(time, videoBufferDuration)];
NSValue *audioTimeValue = [NSValue valueWithCMTimeRange:CMTimeRangeMake(time, audioBufferDuration)];
[readerVideoTrackOutput resetForReadingTimeRanges:@[videoTimeValue]];
[readerAudioTrackOutput resetForReadingTimeRanges:@[audioTimeValue]];
[lock unlock];
return time;
}
1.3. 使用限制
其实,AVAssetReader
并不适合用来做这种可以重置播放进度的实时视频播放,这是因为上述两种重置播放进度的方法都是一个非常耗时的操作,而且视频文件越大耗时越多,耗时多了就会导致声音出现噪音。
在本项目中,这种方式勉强能够工作,因为我们编辑的视频一般在1分钟以内,可能播放一遍会有一两次噪音出现,这是一个很大的缺点,而且很难避免。
一种更好的方式是,使用FFmpeg解封装,然后使用VideoToolBox解码视频帧。
1.4. resetForReadingTimeRanges
崩溃问题
app从后台到前台时,会在执行resetForReadingTimeRanges
时崩溃。
这是因为app从后台到前台之后,通过copyNextSampleBuffer
返回的值很大可能是NULL,但实际上当前AVAssetReader
缓存的音视频帧还没有读完,这时执行resetForReadingTimeRanges
就会崩溃,所以在进入前台之后,最好是重新在创建一个新的AVAssetReader
对象(也就是执行openFile
方法)
- (void)addNotification {
__weak typeof(self) wself = self;
observer1 = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
__strong typeof(wself) self = wself;
[self->lock lock];
self->isActive = false;
[self->lock unlock];
}];
observer2 = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
__strong typeof(wself) self = wself;
[self->lock lock];
self->isActive = true;
BOOL opened = [self openFile];
[self->lock unlock];
if (opened) {
CMTime nextTime = CMTimeAdd(self->audioLastTime, self->audioLastDuration);
[self seekToTime:nextTime];
}
}];
}
2. 音频渲染
音频渲染是由HPAudioOutput
类来完成的,它的功能是将输入的原始音频和配乐音频这两路音频合并成一路,然后通过扬声器或耳机播放出来。其具体实现是使用AudioUnit将多个音频单元组合起来构成一个图状结构来处理音频,图状结构如下
convertNode -->
\
--> mixerNode --> ioNode
/
musicFileNode -->
2.1. 音频单元
上图中这四个Node都可以看做是音频单元
convertNode
是带有音频格式转换功能的AUNode,其对应的AudioUnit是convertUnit
,通过设置它的回调函数InputRenderCallback
来获取输入的原始音频
AURenderCallbackStruct callbackStruct;
callbackStruct.inputProc = &InputRenderCallback;
callbackStruct.inputProcRefCon = (__bridge void *)self;
AudioUnitSetProperty(_convertUnit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &callbackStruct, sizeof(callbackStruct));
musicFileNode
是一个可以关联媒体文件的AUNode,其对应的AudioUnit是musicFileUnit
,它将关联的媒体文件解码之后作为输入数据源,在项目中我们会关联一个配乐文件来作为输入
AudioFileID musicFile;
CFURLRef songURL = (__bridge CFURLRef)[NSURL URLWithString:filePath];
AudioFileOpenURL(songURL, kAudioFileReadPermission, 0, &musicFile);
AudioUnitSetProperty(_musicFileUnit, kAudioUnitProperty_ScheduledFileIDs,
kAudioUnitScope_Global, 0, &musicFile, sizeof(musicFile));
设置从哪个地方开始读取媒体文件
ScheduledAudioFileRegion rgn;
memset (&rgn.mTimeStamp, 0, sizeof(rgn.mTimeStamp));
rgn.mTimeStamp.mFlags = kAudioTimeStampSampleTimeValid;
rgn.mTimeStamp.mSampleTime = 0;
rgn.mCompletionProc = NULL;
rgn.mCompletionProcUserData = NULL;
rgn.mAudioFile = musicFile;
rgn.mLoopCount = 0;
rgn.mStartFrame = (SInt64)(startOffset * fileASBD.mSampleRate);;
rgn.mFramesToPlay = MAX(1, (UInt32)nPackets * fileASBD.mFramesPerPacket - (UInt32)rgn.mStartFrame);
AudioUnitSetProperty(_musicFileUnit, kAudioUnitProperty_ScheduledFileRegion,
kAudioUnitScope_Global, 0,&rgn, sizeof(rgn));
mixerNode
是一个具有多路混音效果的AUNode,其对应的AudioUnit是 mixerUnit
,它的作用就是讲上述两路输入音频合并成一路,然后输出到ioNode
。
同时,它还可以修改输入的每一路音频的音量大小,以下代码表示将element为0(也就是原始音频)的那一路音频的音量设置为0.5
AudioUnitSetParameter(_mixerUnit, kMultiChannelMixerParam_Volume, kAudioUnitScope_Input, inputNum, 0.5, 0);
ioNode
是一个用来采集和播放音频的AUNode,对应的AudioUnit是ioUnit
。通过麦克风采集音频时会使用element为1的那一路通道,播放音频时使用element为0的那一路。当使用它来播放音频时,它就会成为整个数据流的驱动方。
在项目中我们只是使用ioNode
来播放音频,所以在连接ioNode
时,注意必须指定它的element为0
- (void)makeNodeConnections {
OSStatus status = noErr;
status = AUGraphConnectNodeInput(_auGraph, _convertNode, 0, _mixerNode, 0);
CheckStatus(status, @"Could not connect I/O node input to mixer node input", YES);
//music file input
status = AUGraphConnectNodeInput(_auGraph, _musicFileNode, 0, _mixerNode, 1);
CheckStatus(status, @"Could not connect I/O node input to mixer node input", YES);
//output
status = AUGraphConnectNodeInput(_auGraph, _mixerNode, 0, _ioNode, 0);
CheckStatus(status, @"Could not connect I/O node input to mixer node input", YES);
}
2.2. 关于音频格式
在HPAudioOutput
中使用了两种音频格式clientFormat16int
和clientFormat32float
。
由于我们将convertUnit
的输入端的格式设置为了clientFormat16int
,所以输入的原始音频数据必须要符合这种格式。
由于使用ioUnit
播放音频时,它只支持clientFormat32float
格式的输入音频数据,所以convertUnit
的输出端格式也必须是clientFormat32float
。
- (void)configPCMDataFromat {
Float64 sampleRate = _sampleRate;
UInt32 channels = _channels;
UInt32 bytesPerSample = sizeof(Float32);
AudioStreamBasicDescription clientFormat32float;
bzero(&clientFormat32float, sizeof(clientFormat32float));
clientFormat32float.mSampleRate = sampleRate;
clientFormat32float.mFormatID = kAudioFormatLinearPCM;
clientFormat32float.mFormatFlags = kAudioFormatFlagsNativeFloatPacked | kAudioFormatFlagIsNonInterleaved;
clientFormat32float.mBitsPerChannel = 8 * bytesPerSample;
clientFormat32float.mBytesPerFrame = bytesPerSample;
clientFormat32float.mBytesPerPacket = bytesPerSample;
clientFormat32float.mFramesPerPacket = 1;
clientFormat32float.mChannelsPerFrame = channels;
self.clientFormat32float = clientFormat32float;
bytesPerSample = sizeof(SInt16);
AudioStreamBasicDescription clientFormat16int;
bzero(&clientFormat16int, sizeof(clientFormat16int));
clientFormat16int.mFormatID = kAudioFormatLinearPCM;
clientFormat16int.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;
clientFormat16int.mBytesPerPacket = bytesPerSample * channels;
clientFormat16int.mFramesPerPacket = 1;
clientFormat16int.mBytesPerFrame= bytesPerSample * channels;
clientFormat16int.mChannelsPerFrame = channels;
clientFormat16int.mBitsPerChannel = 8 * bytesPerSample;
clientFormat16int.mSampleRate = sampleRate;
self.clientFormat16int = clientFormat16int;
}
3. 视频渲染
视频渲染是由HPVideoOutput
类来完成的,它的作用是将传入的视频帧显示到屏幕上。
@interface HPVideoOutput : NSObject
@property(nonatomic, readonly) UIView *preview;
@property(nonatomic, copy) NSArray *> *filters;
@property(nonatomic, assign) BOOL enableFaceDetector;
- (instancetype)initWithFrame:(CGRect)frame orientation:(CGFloat)orientation;
- (void)presentVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer;
@end
具体而言,可分为三步
- 将传入的视频帧通过
GPUImageRawDataInput
上传到纹理
如果需要人脸信息,就需要将视频帧传给Face++做人脸检测。需要注意的是,人脸检测和上传视频帧到纹理的操作都需要放在OpenGL的特定子线程中执行,防止阻塞其他线程,且能保证在之后执行其他滤镜时拿到的人脸信息的正确性。
- (void)presentVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer {
if (!sampleBuffer) {
return;
}
runAsynchronouslyOnVideoProcessingQueue(^{
if (self.enableFaceDetector) {
[self faceDetect:sampleBuffer];
}
CVImageBufferRef cameraFrame = CMSampleBufferGetImageBuffer(sampleBuffer);
int bufferWidth = (int) CVPixelBufferGetBytesPerRow(cameraFrame) / 4;
int bufferHeight = (int) CVPixelBufferGetHeight(cameraFrame);
CMTime currentTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
CVPixelBufferLockBaseAddress(cameraFrame, 0);
void *bytes = CVPixelBufferGetBaseAddress(cameraFrame);
[self.input updateDataFromBytes:bytes size:CGSizeMake(bufferWidth, bufferHeight)];
CVPixelBufferUnlockBaseAddress(cameraFrame, 0);
CFRelease(sampleBuffer);
[self.input processDataForTimestamp:currentTime];
});
}
- 将
filters
中包含的滤镜应用到纹理上 - 最终将纹理通过
GPUImageView
显示到屏幕上
由于OpenGL并不负责窗口管理和上下文管理,所以想要将纹理显示到屏幕上,需要使用CAEAGLLayer
和EAGLContext
这两个类。
下面以GPUImage
库的GPUImageView
为例,讲解如何进行上下文环境的搭建
- 重写视图的
layerClass
+ (Class)layerClass
{
return [CAEAGLLayer class];
}
- 给
CAEAGLLayer
设置参数
CAEAGLLayer *eaglLayer = (CAEAGLLayer *)self.layer;
eaglLayer.opaque = YES;
eaglLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithBool:NO], kEAGLDrawablePropertyRetainedBacking, kEAGLColorFormatRGBA8, kEAGLDrawablePropertyColorFormat, nil];
- 给线程绑定
EAGLContext
,必须为每一个线程绑定一个EAGLContext
[GPUImageContext useImageProcessingContext];
+ (void)useImageProcessingContext;
{
[[GPUImageContext sharedImageProcessingContext] useAsCurrentContext];
}
- (void)useAsCurrentContext;
{
EAGLContext *imageProcessingContext = [self context];
if ([EAGLContext currentContext] != imageProcessingContext)
{
[EAGLContext setCurrentContext:imageProcessingContext];
}
}
- 给
CAEAGLLayer
绑定帧缓存displayFramebuffer
(由于iOS不允许使用OpenGLES直接渲染屏幕,所以需要先将帧缓存渲染到displayRenderbuffer
上)
glGenFramebuffers(1, &displayFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, displayFramebuffer);
glGenRenderbuffers(1, &displayRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, displayRenderbuffer);
[[[GPUImageContext sharedImageProcessingContext] context] renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer*)self.layer];
GLint backingWidth, backingHeight;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight);
if ( (backingWidth == 0) || (backingHeight == 0) )
{
[self destroyDisplayFramebuffer];
return;
}
_sizeInPixels.width = (CGFloat)backingWidth;
_sizeInPixels.height = (CGFloat)backingHeight;
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, displayRenderbuffer);
另外,由于OpenGLES的原点在左下角,而CAEAGLLayer
的原点在左上角,如果不做改动,最终显示的图像是上下颠倒的。
为了修正这个问题,在GPUImageView
中,默认的纹理坐标是上下颠倒的
+ (const GLfloat *)textureCoordinatesForRotation:(GPUImageRotationMode)rotationMode;
{
static const GLfloat noRotationTextureCoordinates[] = {
0.0f, 1.0f,
1.0f, 1.0f,
0.0f, 0.0f,
1.0f, 0.0f,
};
........
}
4. 音视频同步模块
为什么需要音视频同步模块,因为音频和视频都是在各自的线程中播放的,导致它们的播放速率可能不一致,为了统一音视频的时间,所以需要一个模块来做同步工作。
这个模块是由HPPlayerSynchronizer
这个类完成的,它的主要工作是维护一个内部的音视频帧的缓存队列,然后外界通过下列两个方法来从缓存队列中获取同步好的音视频帧,如果没有,则会运行内部的解码线程来填充缓存队列。
- (void)audioCallbackFillData:(SInt16 *)outData
numFrames:(UInt32)numFrames
numChannels:(UInt32)numChannels;
- (CMSampleBufferRef)getCorrectVideoSampleBuffer;
这两个获取音视频帧的方法、音视频帧缓存队列和解码线程三者共同构成了一个生产者-消费者模型。
HPPlayerSynchronizer
是如何保证音视频帧是同步的呢
一般来说有三种方式,音频向视频同步、视频向音频同步和音频视频都向外部时钟同步。
本项目中使用的是视频向音频同步,因为相对于画面,我们对声音更加敏感,当发生丢帧或插入空数据时,我们的耳朵是能清晰的感觉到的。使用视频向音频同步可以保证音频的每一帧都会播放出来,相应的,视频帧可能会发生丢帧或跳帧,不过,我们的眼睛一般发现不了。
由于是视频向音频同步,所以音频帧只需要按照顺序从缓存队列中取出就可以。
在获取视频帧时,则需要对比当前的音频时间audioPosition
,如果差值没有超过阈值,则返回此视频帧。
如果超过了阈值,则需要分为两种情况,一种是视频帧比较大,则说明视频太快了,直接返回空,表示继续渲染上一帧;另一种是视频帧比较小,则说明视频太慢了,需要将当前视频帧丢弃,然后从视频缓存队列中获取下一帧继续对比,直到差值在阈值之内为止。
static float lastPosition = -1.0;
- (CMSampleBufferRef)getCorrectVideoSampleBuffer {
CMSampleBufferRef sample = NULL;
CMTime position;
[bufferlock lock];
while (videoBuffers.count > 0) {
sample = (__bridge CMSampleBufferRef)videoBuffers[0];
position = CMSampleBufferGetPresentationTimeStamp(sample);
CGFloat delta = CMTimeGetSeconds(CMTimeSubtract(audioPosition, position));
if (delta < (0 - syncMaxTimeDiff)) {//视频太快了
sample = NULL;
break;
}
CFRetain(sample);
[videoBuffers removeObjectAtIndex:0];
if (delta > syncMaxTimeDiff) {//视频太慢了
CFRelease(sample);
sample = NULL;
continue;
}
break;
}
[bufferlock unlock];
if(sample && fabs(CMTimeGetSeconds(audioPosition) - lastPosition) > 0.01f){
lastPosition = CMTimeGetSeconds(audioPosition);
return sample;
} else {
return nil;
}
}
5. CMSampleBufferRef的引用计数问题
解码器HPVideoDecoder
返回的音视频帧和HPPlayerSynchronizer
中的音视频缓存队列存储的都是CMSampleBufferRef
类型的对象,如果没有维护好CMSampleBufferRef
的引用计数,会导致大量的内存泄漏。
我们只要记住一点就可以维护好引用计数,即保持CMSampleBufferRef
对象的引用计数为1。
下面以一个CMSampleBufferRef
的生命周期为例
- 创建一个
CMSampleBufferRef
,一般是由解码器HPVideoDecoder
完成的,这时的引用计数为1,
CMSampleBufferRef sampleBufferRef = [readerVideoTrackOutput copyNextSampleBuffer]
- 将
CMSampleBufferRef
添加到数组videos
,然后将videos
返回给音视频同步类HPPlayerSynchronizer
。这时,被添加到数组会导致其引用计数加1,所以使用CFBridgingRelease
减1,最终引用计数是1
[videos addObject:CFBridgingRelease(sampleBufferRef)];
- 将返回的
videos
添加到音视频队列数组videoBuffers
,引用计数加1,临时数组videos
销毁时减1,最终引用计数是1
CMSampleBufferRef sample = (__bridge CMSampleBufferRef)videos[i];
[videoBuffers addObject:(__bridge id)(sample)];
- 这一步是从音视频队里中返回一个视频帧给外部,很明显
sample
的引用计数仍然是1
CMSampleBufferRef sample = (__bridge CMSampleBufferRef)videoBuffers[0];
CFRetain(sample);
[videoBuffers removeObjectAtIndex:0];
- 最终在
HPVideoOutput
中将视频帧上传到纹理后,释放sample
CFRelease(sample);