利用GPUImage录制直播流合成小视频

本篇文章是在开发新功能-观众端录制直播流的小视频过程中,记录学习到内容,踩过的坑,分享一下.

需求

在观众端可以录制正在播放的流的小视频,同时要将屏幕上的用户互动,包括:礼物,聊天,弹幕等元素同时录制下来,与视频流合在一起.

背景介绍

播放器使用七牛PLPlayerKit.而该框架在播放流时有两个回调方法,将解析到的流数据暴露出来.

/**
 回调将要渲染的帧数据
 该功能只支持直播

 @param player 调用该方法的 PLPlayer 对象
 @param frame 将要渲染帧 YUV 数据。
 CVPixelBufferGetPixelFormatType 获取 YUV 的类型。
 软解为 kCVPixelFormatType_420YpCbCr8Planar.
 硬解为 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange.
 @param pts 显示时间戳 单位ms
 @param sarNumerator
 @param sarDenominator
 其中sar 表示 storage aspect ratio
 视频流的显示比例 sarNumerator sarDenominator
 @discussion sarNumerator = 0 表示该参数无效
 
 @since v2.4.3
 */
- (void)player:(nonnull PLPlayer *)player willRenderFrame:(nullable CVPixelBufferRef)frame pts:(int64_t)pts sarNumerator:(int)sarNumerator sarDenominator:(int)sarDenominator;

/**
 回调音频数据

 @param player 调用该方法的 PLPlayer 对象
 @param audioBufferList 音频数据
 @param audioStreamDescription 音频格式信息
 @param pts 显示时间戳 是解码器进行显示帧时相对于SCR(系统参考)的时间戳。SCR可以理解为解码器应该开始从磁盘读取数据时的时间
 @param sampleFormat 采样位数 枚举:PLPlayerAVSampleFormat
 @return audioBufferList 音频数据
 
 @since v2.4.3
 */
- (nonnull AudioBufferList *)player:(nonnull PLPlayer *)player willAudioRenderBuffer:(nonnull AudioBufferList *)audioBufferList asbd:(AudioStreamBasicDescription)audioStreamDescription pts:(int64_t)pts sampleFormat:(PLPlayerAVSampleFormat)sampleFormat;

分析

拿到需求时,针对要将用户互动内容一起渲染的需求,首先想到了OpenGL中的多重纹理混合的应用,将通过视频流创建的纹理和通过屏幕元素创建的纹理混合后,输出我们需要的纹理数据,转为视频数据,通过回调接口的pts与音频数据同步,录入视频.
而这个流程中的合成和写入视频,基于 OpenGL ES的GPUImage都有很好的是实现,本着不重复造轮子,合理利用资源,于是就决定基于GPUImage来实现.

视频数据

通过可以拿到的视频数据为kCVPixelFormatType_420YpCbCr8Planar(软解)或者kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange(硬解)的CVPixelBufferRef数据.

/**
 @abstract 是否使用 video toolbox 硬解码。
 @discussion 使用 video toolbox Player 将尝试硬解码,失败后,将切换回软解码。
 @waring 该参数仅对 rtmp/flv 直播生效, 默认不使用。支持 iOS 8.0 及更高版本。
 @since v2.1.4
 */
extern NSString  * _Nonnull PLPlayerOptionKeyVideoToolbox;

虽然GPUImage对kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange格式的数据有着很好的支持和使用过程,但是本着兼容性考虑,必须对使用kCVPixelFormatType_420YpCbCr8Planar格式视频数据作为输入.

代码

GPUImagePixelRender继承GPUImageOutput,作为输出视频纹理的类,进入GPUImage响应链.基本仿照了GPUImageMovie类的初始化流程.关键点在修改shader. kCVPixelFormatType_420YpCbCr8Planar是三个planar来分别存储YUV数据,在上传纹理时必然使用是三个纹理采样.

// DTVRecordVideoFrame:数据模型类,记录视频数据
- (DTVRecordVideoFrame *)creatTextureYUV:(CVPixelBufferRef)pixelBuffer
{
    OSType pixelType = CVPixelBufferGetPixelFormatType(pixelBuffer);
    NSAssert(pixelType == kCVPixelFormatType_420YpCbCr8Planar, @"pixelType error ...");
    int pixelWidth = (int)CVPixelBufferGetWidth(pixelBuffer);
    int pixelHeight = (int)CVPixelBufferGetHeight(pixelBuffer);
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);
    
    DTVRecordVideoFrame *yuv = [[DTVRecordVideoFrame alloc] init];
    // 视频数据的宽高
    yuv.width = pixelWidth;
    yuv.height = pixelHeight;
    // YUV三个分量数据
    size_t y_size = pixelWidth * pixelHeight;
    uint8_t *yuv_y_frame = malloc(y_size);
    uint8_t *y_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);
    memcpy(yuv_y_frame, y_frame, y_size);
    yuv.Y = yuv_y_frame;
    
    size_t u_size = y_size / 4;
    uint8_t *yuv_u_frame = malloc(u_size);
    uint8_t *u_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);
    memcpy(yuv_u_frame, u_frame, u_size);
    yuv.U = yuv_u_frame;
    
    size_t v_size = y_size / 4;
    uint8_t *yuv_v_frame = malloc(v_size);
    uint8_t *v_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 2);
    memcpy(yuv_v_frame, v_frame, v_size);
    yuv.V = yuv_v_frame;
    
    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
    return yuv;
}

解析出数据后,创建FBO,上传顶点和纹理数据等不做详解,可参考GPUImageMovie来做.下面是创建纹理对象的代码

- (void)setupTexture:(DTVRecordVideoFrame *)videoFrame
{
    if (0 == _textures[0]) glGenTextures(3, _textures);
    
    const uint8_t *pixelByte[3] = {videoFrame.Y , videoFrame.U , videoFrame.V};
    const int widths[3]  = { videoFrame.width, videoFrame.width / 2, videoFrame.width / 2 };
    const int heights[3] = { videoFrame.height, videoFrame.height / 2, videoFrame.height / 2 };

    for (int i = 0; i < 3; i++) {
        glBindTexture(GL_TEXTURE_2D, _textures[i]);
        glTexImage2D(GL_TEXTURE_2D,
                     0,
                     GL_LUMINANCE,
                     widths[i],
                     heights[i],
                     0,
                     GL_LUMINANCE,
                     GL_UNSIGNED_BYTE,
                     pixelByte[i]);
        
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glBindTexture(GL_TEXTURE_2D, 0);
    }
}

再看一下shader,shader的代码是从kxMoive中学习到的,用来渲染YUV三个分量,

NSString *const kGPUImageYUVPlanarFragmentShaderString = SHADER_STRING
(
 varying highp vec2 textureCoordinate;
 
 uniform sampler2D s_texture_y;
 uniform sampler2D s_texture_u;
 uniform sampler2D s_texture_v;
 
 void main()
 {
     highp float y = texture2D(s_texture_y, textureCoordinate).r;
     highp float u = texture2D(s_texture_u, textureCoordinate).r - 0.5;
     highp float v = texture2D(s_texture_v, textureCoordinate).r - 0.5;
     
     highp float r = y +             1.402 * v;
     highp float g = y - 0.344 * u - 0.714 * v;
     highp float b = y + 1.772 * u;
     
     gl_FragColor = vec4(r,g,b,1.0);
 }
);
View数据

GPUImageUIElement就是用来根据view或layer来生成纹理的,按理说可以拿来直接使用,然而我们屏幕上的动画并不是都用的layer.contents来实现的,有些是基于UIView或者CAAnimation的一些layer动画,如果直接使用view或layer,一些动画根本不会显示出来.如果对CALayer图层了解的话,肯定知道为什么了.Layer层中modelLayer的属性是在修改后立刻就变为终值的,而presentationLayer则会经历一个渐变的修改过程.而我们平常view.layer就是modelLayer,直接是终值了.所以我们拿到的纹理动画上会有些奇怪.
知道了这点,对GPUImageUIElement进行修改,每次创建纹理获取数据时,是用presentationLayer来渲染.
同时发现GPUImageUIElement每次更新纹理时,都创建新的FBO,不会cache回收,对下面这段代码进行了修改,使用完FBO后.

    for (id<GPUImageInput> currentTarget in targets)
     {
        if (currentTarget != self.targetToIgnoreForUpdates)
        {
            NSInteger indexOfObject = [targets indexOfObject:currentTarget];
            NSInteger textureIndexOfTarget = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue];
            [currentTarget setInputSize:layerPixelSize atIndex:textureIndexOfTarget];
            [currentTarget setInputFramebuffer:outputFramebuffer atIndex:textureIndexOfTarget];
            [currentTarget newFrameReadyAtTime:kCMTimeIndefinite atIndex:textureIndexOfTarget];
        }
    }
合成

PlanA:视频收到一帧合成绘制一帧.通常我们采用的视频流帧率是24或36,而屏幕刷新是60,经测试,以视频的帧率来刷新会比较节省CPU,但视频卡顿时,屏幕元素也会卡住,同时动画不够流畅.

PlanB:以CADisplayLink刷新屏幕元素,以接收到的帧数据刷新视频帧.
GPUImageMovieWriter用来写入视频数据,存入本地.

//缓存视频帧
- (void)addVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer pts:(int64_t)videoPts fps:(int)videoFPS;
{
    // 已缓存足够的数据
    if (_hasFillFrame) {
        return;
    }
    // 记录开始的pts
    if (!_firstFramePTS) _firstFramePTS = videoPts;
    
    DTVRecordVideoFrame *videoframe = [self creatTextureYUV:pixelBuffer];
    if (videoframe.Y == NULL || videoframe.U == NULL || videoframe.V == NULL ) {
        NSLog(@"无视频效帧");
        return;
    }
    videoframe.pts = videoPts;
   //帧持续时长
    videoframe.duration = _previousFrame ? (videoPts - _previousFrame.pts) : (1 / 24.f * 1000);
   //帧在我们录制视频中的pts
    videoframe.frameTime = CMTimeMake((videoPts - _firstFramePTS) * 600, 600 * 1000);
  // 缓存
    [self.videoBuffer addObject:videoframe];
    
    _previousFrame = videoframe;
    if (self.videoBuffer.count > 3 && !self.displayLink) {
        //循环切换视频帧
        [self tick];
    }
}

在tick中会根据缓存的数量,和帧持续的时长切换当前的帧数据.通过GPUImageMovieWriter写入视频中.
https://github.com/BradLarson/GPUImage/issues/1729解答GPUImageMovieWriter写入AVFileTypeMPEG4时出现问题解决办法.

- (void)tick
{
    if (self.videoBuffer.count < 1) {
        if (_hasFillFrame) {
            [self stopDisplayLinkTimer];
            if (_movieWriter) {
                [_movieWriter finishRecording];
                [_blendFilter removeTarget:_movieWriter];
                _movieWriter = NULL;
            }
            if (self.completeBlock) self.completeBlock(_coverImage);
        }
        else{
            _renderVideoFrame = NO;
            NSLog(@"卡住...");
        }
    }
    else
    {
        _renderVideoFrame = YES;
        DTVRecordVideoFrame *frameTexture = self.videoBuffer.firstObject;
        
        if (!self.movieWriter) {
            unlink([DefaultFuckVideoPath UTF8String]);
            _movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:[NSURL fileURLWithPath:DefaultFuckVideoPath] size:CGSizeMake(540, 960) fileType:AVFileTypeMPEG4 outputSettings:nil];
            _movieWriter.encodingLiveVideo = YES;
            _movieWriter.hasAudioTrack = YES;
            _movieWriter.assetWriter.movieFragmentInterval = kCMTimeInvalid;
            
            [self.pixelRender addTarget:self.blendFilter];
            [self.layerRender addTarget:self.blendFilter];
            [self.blendFilter addTarget:_movieWriter];
            [_movieWriter startRecording];
        }
        
        [self startDisplayLinkTimer];
        
        runAsynchronouslyOnVideoProcessingQueue(^{
            [self.pixelRender processVideoFrame:frameTexture];
        });
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(frameTexture.duration * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
            [self.videoBuffer removeObjectAtIndex:0];
            [self tick];
        });
        //作为封面
        if (CMTimeGetSeconds(_previousFrame.frameTime) > 0.5f && !_coverImage) {
            [self.blendFilter useNextFrameForImageCapture];
            _coverImage = [self.blendFilter imageFromCurrentFramebuffer];
        }
    }
}

CADisplayLink方法writerFrame,负责刷新获取屏幕元素数据,与当前视频帧_currentFrame通过GPUImageAlphaBlendFilter的滤镜合成最终一帧.

- (void)writerFrame
{
   [self.layerRender updateWithPresentationLayer:_renderView.layer.presentationLayer];
}
音频

音频要和视频同步,由于我们通过七牛接口拿到的是AudioBufferList数据,需要转换为GPUImageMovieWriter 需要的CMSampleBufferRef数据.

 // 根据视频的pts重新计算获取音频的pts
    CMTime time = CMTimeMake((audioPts - _firstFramePTS) * 600, 600 * 1000);
//转换CMSampleBufferRef
    CMSampleBufferRef audioBuffer = NULL;
    CMFormatDescriptionRef format = NULL;
    CMSampleTimingInfo timing = {CMTimeMake(1, audioStreamDescription.mSampleRate),time, kCMTimeInvalid};
     UInt32 size = audioBufferList->mBuffers->mDataByteSize / sizeof(UInt32);
     UInt32 mNumberChannels = audioBufferList->mBuffers->mNumberChannels;
     CMItemCount numSamples = (CMItemCount)size / mNumberChannels;

     OSStatus status;
     status = CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &audioStreamDescription, 0, NULL, 0, NULL, NULL, &format);
        if (status != noErr) {
            CFRelease(format);
            return;
        }
        
    status = CMSampleBufferCreate(kCFAllocatorDefault,NULL,false,NULL,NULL,format,numSamples, 1, &timing, 0, NULL, &audioBuffer);
        if (status != noErr) {
            CFRelease(format);
            return;
        }
        
    status = CMSampleBufferSetDataBufferFromAudioBufferList(audioBuffer, kCFAllocatorDefault,kCFAllocatorDefault, 0,audioBufferList);
        if (status != noErr) {
            CFRelease(format);
            return;
        }

    if (_movieWriter && audioBuffer) {
            [_movieWriter processAudioBuffer:audioBuffer];
        }
总结

在做这个功能的过程中学习到了很多内容,CALayer图层,视频数据格式,音频转换,简单的音视频同步,加深了GPUImage的理解.个人感觉收获颇多.

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

推荐阅读更多精彩内容

  • 我自我感觉良好,不应该是良好,是相当好。我觉得我优点挺多的,这众多优点当中,“好奇心”是我觉得最有价值的。这可能...
    周_星_星阅读 296评论 0 0
  • 好冷……好冷……有谁在吗……竹子无助地缩成一团,慢慢睁开双眼,那是一双不符合她年龄的眼睛,没有任何神采。 她静静地...
    懒猫子L阅读 207评论 1 0
  • 周六、周日连续跑步,觉得13周的进度太慢,要加快速度。 晚饭吃的太油了,以后要喝粥。 跑完没有做拉伸,要改进。 周...
    草上霜阅读 313评论 0 1